UNPKG

svn-dav-fs

Version:

handler for 'svn+https' url scheme (plain js svn dav fs)

634 lines (564 loc) 17.4 kB
import { HTTPSScheme } from "url-resolver-fs"; import { ActivityCollectionSet } from "./activity-collection-set"; import { headerIntoSet, encodeProperties } from "./util"; export { headerIntoSet, encodeProperties }; import { createStream } from "sax"; import hasha from "hasha"; /* const { createStream } = require("sax"); const hasha = require("hasha"); */ const XML_HEADER = '<?xml version="1.0" encoding="utf-8"?>'; const XML_CONTENT_TYPE = "text/xml"; const SVN_SKEL_CONTENT_TYPE = "application/vnd.svn-skel"; const SVN_SVNDIFF_CONTENT_TYPE = "application/vnd.svn-svndiff"; const NS_SVN_DAV = "http://subversion.tigris.org/xmlns/dav/"; const NS_SVN_DAV_DEPTH = NS_SVN_DAV + "svn/depth"; const NS_SVN_DAV_MERGINFO = NS_SVN_DAV + "svn/mergeinfo"; const NS_SVN_DAV_LOG_REVPROPS = NS_SVN_DAV + "svn/log-revprops"; /* const NS_SVN_DAV_ATOMIC_REVPROPS = NS_SVN_DAV + 'svn/atomic-revprops'; const NS_SVN_DAV_PARTIAL_REPLAY = NS_SVN_DAV + 'svn/partial-replay'; const DAVFeatures = { '1': {}, '2': {}, 'version-control': {}, 'checkout': {}, 'working-resource': {}, 'merge': {}, 'baseline': {}, 'activity': {}, 'version-controlled-collection': {}, 'http://subversion.tigris.org/xmlns/dav/svn/inherited-props': {}, 'http://subversion.tigris.org/xmlns/dav/svn/inline-props': {}, 'http://subversion.tigris.org/xmlns/dav/svn/reverse-file-revs': {}, 'http://subversion.tigris.org/xmlns/dav/svn/ephemeral-txnprops': {}, 'http://subversion.tigris.org/xmlns/dav/svn/replay-rev-resource': {}, [NS_SVN_DAV_MERGINFO]: {}, [NS_SVN_DAV_PARTIAL_REPLAY]: {}, [NS_SVN_DAV_DEPTH]: {}, [NS_SVN_DAV_LOG_REVPROPS]: {}, [NS_SVN_DAV_ATOMIC_REVPROPS]: {} }; */ const SVNHeaders = [ "SVN-Repository-UUID", "SVN-Repository-Root", "SVN-Rev-Root-Stub", // /svn/delivery_notes/!svn/rvr "SVN-Rev-Stub", // /svn/delivery_notes/!svn/rev "SVN-Txn-Root-Stub", // /svn/delivery_notes/!svn/vtxr "SVN-Txn-Stub", // /svn/delivery_notes/!svn/txn "SVN-Me-Resource", // /svn/delivery_notes/!svn/me "SVN-Rev-Root-Stub", // /svn/delivery_notes/!svn/rvr "SVN-Youngest-Rev", // Integer "SVN-Allow-Bulk-Updates", // Prefer "SVN-Supported-Posts", // create-txn create-txn-with-props "SVN-Repository-MergeInfo" // yes ]; function ignore() {} /** * URL scheme 'svn+https' svn over https */ export class SVNHTTPSScheme extends HTTPSScheme { /** * Extract options suitable for the constructor * form the given set of environment variables * @param {Object} env * @return {Object} undefined if no suitable environment variables have been found */ static optionsFromEnvironment(env) { if (env !== undefined) { const credentials = {}; if (env.SVN_USER !== undefined) { credentials.user = env.SVN_USER; } if (env.SVN_PASSWORD !== undefined) { credentials.password = env.SVN_PASSWORD; } return { credentials }; } return undefined; } static get name() { return "svn+https"; } /** * Execute options request * @param {Context} context execution context * @param {URL} url * @param {string[]} body xml lines * @return {Promise<Request>} */ optionsRequest(context, url, body) { return this.fetch(context, url, { method: "OPTIONS", body: [XML_HEADER, ...body].join(""), headers: { dav: this.davHeader, "content-type": XML_CONTENT_TYPE } }); } /** * query for the activity collection set. * @param {Context} context execution context * @param {URL} url * @return {Promise<ActivityCollectionSet>} */ async activityCollectionSet(context, url) { const options = await this.optionsRequest(context, url, [ '<D:options xmlns:D="DAV:">', "<D:activity-collection-set></D:activity-collection-set>", "</D:options>" ]); const attributes = new Map(); const davFeatures = new Set(); const allowedMethods = new Set(); SVNHeaders.forEach(h => { const v = options.headers.get(h); if (v !== undefined) { attributes.set(h, v); } }); headerIntoSet(options.headers.get("dav"), davFeatures); headerIntoSet(options.headers.get("allow"), allowedMethods); attributes.set( "SVN-Youngest-Rev", parseInt(options.headers.get("SVN-Youngest-Rev"), 10) ); return new ActivityCollectionSet( url, attributes, davFeatures, allowedMethods ); } /** * Delivers svn user agent * @return {string} user agent identifier */ get userAgent() { return "SVN/1.9.4 (x86_64-apple-darwin15.0.0) serf/1.3.8"; } /** * Delivers svn client version * @return {string} version */ get clientVersion() { return "1.9.4"; } /** * @type {string} */ get davHeader() { return [ NS_SVN_DAV_DEPTH, NS_SVN_DAV_MERGINFO, NS_SVN_DAV_LOG_REVPROPS ].join(","); } /** * <!-- skip-example --> * @example * MKCOL /svn/delivery_notes/!svn/txr/1485-1cs/data/comp2 HTTP/1.1 * DAV http://subversion.tigris.org/xmlns/dav/svn/depth * DAV http://subversion.tigris.org/xmlns/dav/svn/mergeinfo * DAV http://subversion.tigris.org/xmlns/dav/svn/log-revprops * @param {Context} context * @param {URL} url * @param {string} tx */ async mkcol(context, url, tx) { return this.fetch(context, url, { method: "MKCOL", headers: { dav: this.davHeader } }); } /** * <!-- skip-example --> * Start a new transaction * @param {Context} context * @param {ULR} url * @param {string} message * @return {Object} acs, txn * @example * POST /svn/delivery_notes/!svn/me HTTP/1.1 * Content-Type application/vnd.svn-skel * DAV http://subversion.tigris.org/xmlns/dav/svn/depth * DAV http://subversion.tigris.org/xmlns/dav/svn/mergeinfo * DAV http://subversion.tigris.org/xmlns/dav/svn/log-revprops * (create-txn-with-props (svn:txn-user-agent 48 SVN/1.9.4 (x86_64-apple-darwin15.0.0) serf/1.3.8 svn:log 19 this is the message svn:txn-client-compat-version 5 1.9.4)) * Response: * SVN-Txn-Name: 1483-1a1 */ async startTransaction(context, url, message) { const acs = await this.activityCollectionSet(context, url); const response = await this.fetch( context, acs.absoluteRepositoryRoot + "/!svn/me", { method: "POST", body: encodeProperties({ "create-txn-with-props": { "svn:txn-user-agent": this.userAgent, "svn:log": message, "svn:txn-client-compat-version": this.clientVersion } }), headers: { dav: this.davHeader, "content-type": SVN_SKEL_CONTENT_TYPE } } ); const txn = response.headers.get("SVN-Txn-Name"); if (txn === undefined) { throw new Error(`Can't create transaction: ${url}`); } return { acs, txn }; } /** * @see http://svn.apache.org/repos/asf/subversion/trunk/notes/svndiff * @see http://stackoverflow.com/questions/24865265/how-to-do-svn-http-request-checkin-commit-within-html * @see https://git.tmatesoft.com/repos/svnkit.git * @param {Context} context * @param {URL} url * @param {ReadableStream} stream * @param {Object} options */ async put(context, url, stream, options) { const { acs, txn } = await this.startTransaction( context, url, options.message ); const [versionName] = txn.split(/\-/); //const pathInsideRepository = '/data/releases.json'; const hashResult = await hasha.stream(stream, { algorithm: "md5" }); const r2 = await this.fetch( context, acs.absoluteRepositoryRoot + `/!svn/txr/${txn}/${acs.pathInsideRepository}`, { method: "PUT", //"body": { "encoding": "base64", "encoded": "U1ZOAAAlGQMEFQCEXQp9Cg==" } body: "U1ZOAAAlGQMEFQCEXQp9Cg==", headers: { dav: this.davHeader, "content-type": SVN_SVNDIFF_CONTENT_TYPE, "X-SVN-Version-Name": versionName, // MD5 (releases.json) = fc0c6bba9a0b8c2079be6a6c05b1b915 "X-SVN-Base-Fulltext-MD5": "528454b50ad14b271f18e4e763a4a951", "X-SVN-Result-Fulltext-MD5": hashResult //'fc0c6bba9a0b8c2079be6a6c05b1b915' } } ); console.log(r2); /* PUT /svn/delivery_notes/!svn/txr/1483-1a1/data/config.json HTTP/1.1 Content-Type: application/vnd.svn-svndiff DAV: http://subversion.tigris.org/xmlns/dav/svn/depth DAV: http://subversion.tigris.org/xmlns/dav/svn/mergeinfo DAV: http://subversion.tigris.org/xmlns/dav/svn/log-revprops X-SVN-Version-Name: 1483 X-SVN-Base-Fulltext-MD5: 7f407419826ad120a3c9374947770470 X-SVN-Result-Fulltext-MD5: 03d6350bb46a63e86f1c5db703af403c */ /* MERGE /svn/delivery_notes/data HTTP/1.1 Content-Type: text/xml DAV: http://subversion.tigris.org/xmlns/dav/svn/depth DAV: http://subversion.tigris.org/xmlns/dav/svn/mergeinfo DAV: http://subversion.tigris.org/xmlns/dav/svn/log-revprops X-SVN-Options: release-locks <?xml version="1.0" encoding="utf-8"?> <D:merge xmlns:D="DAV:"> <D:source> <D:href>/svn/delivery_notes/!svn/txn/1483-1a1</D:href> </D:source> <D:no-auto-merge/> <D:no-checkout/> <D:prop> <D:checked-in/> <D:version-name/> <D:resourcetype/> <D:creationdate/> <D:creator-displayname/> </D:prop> </D:merge> Reponse: HTTP/1.1 200 OK Content-Type: text/xml <?xml version="1.0" encoding="utf-8"?> <D:merge-response xmlns:D="DAV:"> <D:updated-set> <D:response> <D:href>/svn/delivery_notes/!svn/vcc/default</D:href> <D:propstat><D:prop> <D:resourcetype><D:baseline/></D:resourcetype> <D:version-name>1484</D:version-name> <D:creationdate>2016-12-28T20:24:49.296311Z</D:creationdate> <D:creator-displayname>arlac77</D:creator-displayname> </D:prop> <D:status>HTTP/1.1 200 OK</D:status> </D:propstat> </D:response> <D:response> <D:href>/svn/delivery_notes/data/config.json</D:href> <D:propstat><D:prop> <D:resourcetype/> <D:checked-in><D:href>/svn/delivery_notes/!svn/ver/1484/data/config.json</D:href></D:checked-in> </D:prop> <D:status>HTTP/1.1 200 OK</D:status> </D:propstat> </D:response> </D:updated-set> </D:merge-response> */ } async stat(context, url, options) { const acs = await this.activityCollectionSet(context, url); const path = url.href.substring( url.origin.length + acs.repositoryRoot.length ); const u2 = new URL( `${url.origin}${acs.attributes.get( "SVN-Rev-Root-Stub" )}/${acs.attributes.get("SVN-Youngest-Rev")}${path}` ); const properties = await this.propfind( context, u2, { resourcetype: "DAV:", getcontentlength: "DAV:", "deadprop-count": "http://subversion.tigris.org/xmlns/dav/", "version-name": "DAV:", creationdate: "DAV:", "creator-displayname": "DAV:" }, 0 ); return properties[0]; } async propfind(context, url, properties, depth = 1) { const xmls = [XML_HEADER, '<D:propfind xmlns:D="DAV:">']; if (properties === undefined) { xmls.push("<D:allprop/>"); } else { xmls.push("<D:prop>"); for (const p in properties) { xmls.push(`<${p} xmlns="${properties[p]}"/>`); } xmls.push("</D:prop>"); } xmls.push("</D:propfind>"); const response = await this.fetch(context, url, { method: "PROPFIND", body: xmls.join("\n"), headers: { dav: this.davHeader, depth: depth, "content-type": XML_CONTENT_TYPE } }); return new Promise((resolve, reject) => { const entries = []; let entry; let consume = ignore; let rootPathPrefixLength; const saxStream = createStream(true, { xmlns: true, position: false, trim: true }); saxStream.on("opentag", node => { switch (node.local) { case "response": break; case "prop": entry = {}; consume = ignore; break; case "collection": entry.collection = true; break; case "version-name": consume = text => { entry.version = parseInt(text, 10); consume = ignore; }; break; case "getcontentlength": consume = text => { entry.size = parseInt(text, 10); consume = ignore; }; break; case "creator-displayname": consume = text => { entry.creator = text; consume = ignore; }; break; case "creationdate": consume = text => { entry.creationDate = new Date(text); consume = ignore; }; break; case "baseline-relative-path": consume = text => { if (rootPathPrefixLength) { entry.name = text.substring(rootPathPrefixLength); } else { rootPathPrefixLength = text.length + 1; entry = undefined; } consume = ignore; }; break; /* case 'resourcetype': consume = text => {  console.log(`resourcetype: ${text}`); //consume = ignore; }; break; default: console.log(`${node.name} ${node.local} ${node.uri}`); */ } }); saxStream.on("closetag", name => { switch (name) { case "D:prop": if (entry !== undefined) { entries.push(entry); } break; } }); saxStream.on("text", text => consume(text)); saxStream.on("end", () => resolve(entries)); saxStream.on("error", reject); response.body.pipe(saxStream); }); } async *list(context, url, options) { for (const entry of await this.propfind(context, url, options)) { yield entry; } } async *history(context, url, options = {}) { let start; if (options.version === undefined) { const entries = this.list(context, url); start = (yield entries).version; } else { start = options.version; } const direction = options.direction || options.version === undefined ? "backward" : "forward"; const chunkSize = options.chunkSize || 1000; let end = direction === "forward" ? start + chunkSize : start - chunkSize; if (end < 0) { end = 0; } if (start > end) { const t = start; start = end; end = t; } const entries = await this._history(context, url, start, end); for (const entry of entries) { yield entry; } } async _history(context, url, start, end) { const xmls = [ XML_HEADER, '<S:log-report xmlns:S="svn:">', `<S:start-revision>${start}</S:start-revision>`, `<S:end-revision>${end}</S:end-revision>` ]; ["svn:author", "svn:date", "svn:log"].forEach(item => xmls.push(`<S:revprop>${item}</S:revprop>`) ); xmls.push("<S:path/>"); xmls.push("</S:log-report>"); const response = await this.fetch(context, url, { method: "REPORT", body: xmls.join("\n"), headers: { dav: this.davHeader, "content-type": XML_CONTENT_TYPE } }); return new Promise((resolve, reject) => { /* <S:log-report xmlns:S="svn:" xmlns:D="DAV:"> <S:log-item> <D:version-name>0</D:version-name> <S:date>2011-09-18T13:20:54.561302Z</S:date> </S:log-item> <S:log-item> */ const saxStream = createStream(true, { xmlns: true, position: false, trim: true }); const entries = []; let entry; let consume = ignore; saxStream.on("opentag", node => { switch (node.local) { case "log-item": entry = {}; consume = ignore; break; case "version-name": consume = text => { entry.version = parseInt(text, 10); consume = ignore; }; break; case "date": consume = text => { entry.date = new Date(text); consume = ignore; }; break; case "comment": consume = text => { entry.message = entry.message ? entry.message + text : text; }; break; case "creator-displayname": consume = text => { entry.creator = text; consume = ignore; }; break; default: consume = ignore; } }); saxStream.on("closetag", name => { switch (name) { case "S:log-item": entries.push(entry); break; } }); saxStream.on("text", text => consume(text)); saxStream.on("end", () => resolve(entries)); saxStream.on("error", reject); response.body.pipe(saxStream); }); } }