svn-dav-fs
Version:
handler for 'svn+https' url scheme (plain js svn dav fs)
641 lines (571 loc) • 17.7 kB
JavaScript
import { HTTPSScheme } from 'url-resolver-fs';
import { headerIntoSet, encodeProperties } from './util';
const sax = require('sax'),
{ URL } = require('url');
/**
* @module svn-dav-fs
*/
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 sheme 'svn+https' svn over https
*/
export class SVNHTTPSScheme extends HTTPSScheme {
/**
* Execute options request
* @param context {Context} execution context
* @param url {URL}
* @param body {string[]} xml lines
* @return {Promise}
*/
options(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 the activity collection set.
* @param context {Context} execution context
* @param url {URL}
* @return {Promise}
*/
async activityCollectionSet(context, url) {
const options = await this.options(context, url, [
'<D:options xmlns:D="DAV:">',
'<D:activity-collection-set></D:activity-collection-set>',
'</D:options>'
]);
const attributes = {};
const davFeatures = new Set();
const allowedMethods = new Set();
SVNHeaders.forEach(h => {
const v = options.headers.get(h);
if (v) {
attributes[h] = v;
}
});
headerIntoSet(options.headers.get('dav'), davFeatures);
headerIntoSet(options.headers.get('allow'), allowedMethods);
attributes['SVN-Youngest-Rev'] = parseInt(
options.headers.get('SVN-Youngest-Rev'),
10
);
return {
attributes,
davFeatures,
allowedMethods
};
}
// TODO why is this not taken from the base class ?
get type() {
return 'svn+https';
}
static get name() {
return 'svn+https';
}
/**
* 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';
}
get davHeader() {
return [
NS_SVN_DAV_DEPTH,
NS_SVN_DAV_MERGINFO,
NS_SVN_DAV_LOG_REVPROPS
].join(',');
}
/*
MKCOL /svn/delivery_notes/!svn/txr/1485-1cs/data/comp2 HTTP/1.1
Host subversion.assembla.com
Authorization Basic YXJsYWM3NzpzdGFydDEyMw==
User-Agent SVN/1.9.4 (x86_64-apple-darwin15.0.0) serf/1.3.8
Accept-Encoding gzip
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
*/
async mkcol(url, tx) {}
async startTransaction(context, url, message) {
const {
attributes,
davFeatures,
allowedMethods
} = await this.activityCollectionSet(context, url);
const response = await this.fetch(
context,
'https://subversion.assembla.com/svn/delivery_notes/' + '!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) {
return Promise.reject(new Error('Can`t create transaction'));
}
return txn;
}
/**
* http://svn.apache.org/repos/asf/subversion/trunk/notes/svndiff
* http://stackoverflow.com/questions/24865265/how-to-do-svn-http-request-checkin-commit-within-html
*/
async put(context, url, stream, options) {
/*this.activityCollectionSet(url).then(acs => {
}).then(() =>
this.options(url, ['<D:options xmlns:D="DAV:"/>'])
);*/
const response = await this.fetch(
context,
'https://subversion.assembla.com/svn/delivery_notes/' + '!svn/me',
{
method: 'POST',
body: encodeProperties({
'create-txn-with-props': {
'svn:txn-user-agent': this.userAgent,
'svn:log': options.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) {
return Promise.reject(new Error('Can`t create transaction'));
}
const [versionName] = txn.split(/\-/);
return this.fetch(
context,
`https://subversion.assembla.com/svn/delivery_notes/!svn/txr/${txn}/data/config.json`,
{
method: 'PUT',
body: '{ffffff}',
headers: {
dav: this.davHeader,
'content-type': SVN_SVNDIFF_CONTENT_TYPE,
'X-SVN-Version-Name': versionName,
'X-SVN-Base-Fulltext-MD5': '7f407419826ad120a3c9374947770470',
'X-SVN-Result-Fulltext-MD5': '03d6350bb46a63e86f1c5db703af403c'
}
}
)
.then(response => {
console.log(response);
})
.catch(e => console.error(e));
/*
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
*/
/*
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.attributes['SVN-Repository-Root'].length
);
const u2 = new URL(
`${url.origin}${acs.attributes['SVN-Rev-Root-Stub']}/${acs.attributes[
'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((fullfill, reject) => {
const entries = [];
let entry;
let consume = ignore;
let rootPathPrefixLength;
const saxStream = sax.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', () => fullfill(entries));
saxStream.on('error', reject);
response.body.pipe(saxStream);
});
}
async list(context, url, options) {
return this.propfind(context, url, options);
}
async *_list(context, url, options) {
const list = await this.propfind(context, url, options);
for (const entry of list) {
yield entry;
}
}
history(context, url, options = {}) {
const p =
options.version === undefined
? this.list(context, url).then(entries =>
Promise.resolve(entries[0].version)
)
: Promise.resolve(options.version);
return p.then(start => {
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;
}
return this._history(context, url, start, end).then(entries => {
const self = this;
return Promise.resolve(function*() {
for (const i in entries) {
yield Promise.resolve(entries[i]);
}
let i = 0;
const p = self._history(url, end + 1, end + chunkSize);
for (let j = 0; j < 10; j++) {
yield p.then(entries => {
return Promise.resolve(entries[i++]);
});
}
/*
yield p.then(entries => {
return Promise.resolve(entries[i++]);
});
*/
});
});
});
}
_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>');
return this.fetch(context, url, {
method: 'REPORT',
body: xmls.join('\n'),
headers: {
dav: this.davHeader,
'content-type': XML_CONTENT_TYPE
}
}).then(
response =>
new Promise((fullfill, 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 = sax.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', () => fullfill(entries));
saxStream.on('error', reject);
response.body.pipe(saxStream);
})
);
}
}
export { headerIntoSet, encodeProperties };