2018-01-21 19:01:37 +02:00
|
|
|
const { Logger } = require('lib/logger.js');
|
|
|
|
const { shim } = require('lib/shim.js');
|
|
|
|
const parseXmlString = require('xml2js').parseString;
|
|
|
|
const JoplinError = require('lib/JoplinError');
|
2018-01-25 21:01:14 +02:00
|
|
|
const URL = require('url-parse');
|
2018-01-25 21:01:14 +02:00
|
|
|
const { rtrimSlashes, ltrimSlashes } = require('lib/path-utils.js');
|
2018-01-25 21:01:14 +02:00
|
|
|
const base64 = require('base-64');
|
2018-01-21 19:01:37 +02:00
|
|
|
|
|
|
|
// Note that the d: namespace (the DAV namespace) is specific to Nextcloud. The RFC for example uses "D:" however
|
|
|
|
// we make all the tags and attributes lowercase so we handle both the Nextcloud style and RFC. Hopefully other
|
|
|
|
// implementations use the same namespaces. If not, extra processing can be done in `nameProcessor`, for
|
|
|
|
// example to convert a custom namespace to "d:" so that it can be used by the rest of the code.
|
|
|
|
// In general, we should only deal with things in "d:", which is the standard DAV namespace.
|
|
|
|
|
|
|
|
class WebDavApi {
|
|
|
|
|
2018-01-25 21:01:14 +02:00
|
|
|
constructor(options) {
|
2018-01-21 19:01:37 +02:00
|
|
|
this.logger_ = new Logger();
|
|
|
|
this.options_ = options;
|
|
|
|
}
|
|
|
|
|
|
|
|
setLogger(l) {
|
|
|
|
this.logger_ = l;
|
|
|
|
}
|
|
|
|
|
|
|
|
logger() {
|
|
|
|
return this.logger_;
|
|
|
|
}
|
|
|
|
|
|
|
|
authToken() {
|
2018-01-25 21:01:14 +02:00
|
|
|
if (!this.options_.username() || !this.options_.password()) return null;
|
2018-01-25 21:01:14 +02:00
|
|
|
return base64.encode(this.options_.username() + ':' + this.options_.password());
|
2018-01-21 19:01:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
baseUrl() {
|
2018-01-25 21:01:14 +02:00
|
|
|
return this.options_.baseUrl();
|
|
|
|
}
|
|
|
|
|
|
|
|
relativeBaseUrl() {
|
2018-01-25 21:01:14 +02:00
|
|
|
const url = new URL(this.baseUrl());
|
|
|
|
return url.pathname + url.query;
|
2018-01-21 19:01:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
async xmlToJson(xml) {
|
|
|
|
|
|
|
|
const nameProcessor = (name) => {
|
|
|
|
// const idx = name.indexOf(':');
|
|
|
|
// if (idx >= 0) {
|
|
|
|
// if (name.indexOf('xmlns:') !== 0) name = name.substr(idx + 1);
|
|
|
|
// }
|
|
|
|
return name.toLowerCase();
|
|
|
|
};
|
|
|
|
|
|
|
|
const options = {
|
|
|
|
tagNameProcessors: [nameProcessor],
|
|
|
|
attrNameProcessors: [nameProcessor],
|
|
|
|
}
|
|
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
parseXmlString(xml, options, (error, result) => {
|
|
|
|
if (error) {
|
2018-01-23 22:10:20 +02:00
|
|
|
resolve(null); // Error handled by caller which will display the XML text (or plain text) if null is returned from this function
|
2018-01-21 19:01:37 +02:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
resolve(result);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2018-01-25 21:01:14 +02:00
|
|
|
valueFromJson(json, keys, type) {
|
2018-01-21 19:01:37 +02:00
|
|
|
let output = json;
|
2018-01-25 21:01:14 +02:00
|
|
|
|
2018-01-21 19:01:37 +02:00
|
|
|
for (let i = 0; i < keys.length; i++) {
|
|
|
|
const key = keys[i];
|
2018-01-25 21:01:14 +02:00
|
|
|
|
|
|
|
// console.info(key, typeof key, typeof output, typeof output === 'object' && (key in output), Array.isArray(output));
|
|
|
|
|
|
|
|
if (typeof key === 'number' && !Array.isArray(output)) return null;
|
|
|
|
if (typeof key === 'string' && (typeof output !== 'object' || !(key in output))) return null;
|
2018-01-21 19:01:37 +02:00
|
|
|
output = output[key];
|
|
|
|
}
|
|
|
|
|
|
|
|
if (type === 'string') {
|
|
|
|
if (typeof output !== 'string') return null;
|
|
|
|
return output;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (type === 'object') {
|
|
|
|
if (!Array.isArray(output) && typeof output === 'object') return output;
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
2018-01-25 21:01:14 +02:00
|
|
|
if (type === 'array') {
|
|
|
|
return Array.isArray(output) ? output : null;
|
|
|
|
}
|
|
|
|
|
2018-01-21 19:01:37 +02:00
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
stringFromJson(json, keys) {
|
|
|
|
return this.valueFromJson(json, keys, 'string');
|
|
|
|
}
|
|
|
|
|
|
|
|
objectFromJson(json, keys) {
|
|
|
|
return this.valueFromJson(json, keys, 'object');
|
|
|
|
}
|
|
|
|
|
2018-01-25 21:01:14 +02:00
|
|
|
arrayFromJson(json, keys) {
|
|
|
|
return this.valueFromJson(json, keys, 'array');
|
|
|
|
}
|
|
|
|
|
2018-01-29 22:51:14 +02:00
|
|
|
async execPropFind(path, depth, fields = null, options = null) {
|
2018-01-21 19:01:37 +02:00
|
|
|
if (fields === null) fields = ['d:getlastmodified'];
|
|
|
|
|
|
|
|
let fieldsXml = '';
|
|
|
|
for (let i = 0; i < fields.length; i++) {
|
|
|
|
fieldsXml += '<' + fields[i] + '/>';
|
|
|
|
}
|
|
|
|
|
|
|
|
// To find all available properties:
|
|
|
|
//
|
2018-01-23 22:10:20 +02:00
|
|
|
// const body=`<?xml version="1.0" encoding="utf-8" ?>
|
|
|
|
// <propfind xmlns="DAV:">
|
|
|
|
// <propname/>
|
|
|
|
// </propfind>`;
|
|
|
|
|
|
|
|
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
|
|
|
<d:propfind xmlns:d="DAV:">
|
|
|
|
<d:prop xmlns:oc="http://owncloud.org/ns">
|
|
|
|
` + fieldsXml + `
|
|
|
|
</d:prop>
|
|
|
|
</d:propfind>`;
|
2018-01-21 19:01:37 +02:00
|
|
|
|
2018-01-29 22:51:14 +02:00
|
|
|
return this.exec('PROPFIND', path, body, { 'Depth': depth }, options);
|
2018-01-21 19:01:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// curl -u admin:123456 'http://nextcloud.local/remote.php/dav/files/admin/' -X PROPFIND --data '<?xml version="1.0" encoding="UTF-8"?>
|
|
|
|
// <d:propfind xmlns:d="DAV:">
|
|
|
|
// <d:prop xmlns:oc="http://owncloud.org/ns">
|
|
|
|
// <d:getlastmodified/>
|
|
|
|
// </d:prop>
|
|
|
|
// </d:propfind>'
|
|
|
|
|
2018-01-23 22:10:20 +02:00
|
|
|
async exec(method, path = '', body = null, headers = null, options = null) {
|
2018-01-21 19:01:37 +02:00
|
|
|
if (headers === null) headers = {};
|
2018-01-23 22:10:20 +02:00
|
|
|
if (options === null) options = {};
|
|
|
|
if (!options.responseFormat) options.responseFormat = 'json';
|
|
|
|
if (!options.target) options.target = 'string';
|
2018-01-21 19:01:37 +02:00
|
|
|
|
|
|
|
const authToken = this.authToken();
|
|
|
|
|
|
|
|
if (authToken) headers['Authorization'] = 'Basic ' + authToken;
|
|
|
|
|
2018-01-30 22:24:09 +02:00
|
|
|
if (typeof body === 'string') headers['Content-length'] = body.length;
|
|
|
|
|
2018-01-21 19:01:37 +02:00
|
|
|
const fetchOptions = {};
|
|
|
|
fetchOptions.headers = headers;
|
|
|
|
fetchOptions.method = method;
|
2018-01-23 22:10:20 +02:00
|
|
|
if (options.path) fetchOptions.path = options.path;
|
2018-01-21 19:01:37 +02:00
|
|
|
if (body) fetchOptions.body = body;
|
|
|
|
|
|
|
|
const url = this.baseUrl() + '/' + path;
|
|
|
|
|
2018-01-23 22:10:20 +02:00
|
|
|
let response = null;
|
2018-01-21 19:01:37 +02:00
|
|
|
|
2018-01-30 22:10:36 +02:00
|
|
|
// console.info('WebDAV', method + ' ' + path, headers, options);
|
|
|
|
|
2018-01-23 22:10:20 +02:00
|
|
|
if (options.source == 'file' && (method == 'POST' || method == 'PUT')) {
|
|
|
|
response = await shim.uploadBlob(url, fetchOptions);
|
|
|
|
} else if (options.target == 'string') {
|
|
|
|
response = await shim.fetch(url, fetchOptions);
|
|
|
|
} else { // file
|
|
|
|
response = await shim.fetchBlob(url, fetchOptions);
|
2018-01-21 19:01:37 +02:00
|
|
|
}
|
|
|
|
|
2018-01-23 22:10:20 +02:00
|
|
|
const responseText = await response.text();
|
2018-01-21 19:01:37 +02:00
|
|
|
|
2018-01-25 21:01:14 +02:00
|
|
|
// Gives a shorter response for error messages. Useful for cases where a full HTML page is accidentally loaded instead of
|
|
|
|
// JSON. That way the error message will still show there's a problem but without filling up the log or screen.
|
|
|
|
const shortResponseText = () => {
|
|
|
|
return (responseText + '').substr(0, 1024);
|
|
|
|
}
|
|
|
|
|
2018-01-23 22:10:20 +02:00
|
|
|
let responseJson_ = null;
|
|
|
|
const loadResponseJson = async () => {
|
|
|
|
if (!responseText) return null;
|
|
|
|
if (responseJson_) return responseJson_;
|
|
|
|
responseJson_ = await this.xmlToJson(responseText);
|
2018-01-25 21:01:14 +02:00
|
|
|
if (!responseJson_) throw new JoplinError('Cannot parse JSON response: ' + shortResponseText(), response.status);
|
2018-01-23 22:10:20 +02:00
|
|
|
return responseJson_;
|
|
|
|
}
|
2018-01-21 19:01:37 +02:00
|
|
|
|
2018-01-23 22:10:20 +02:00
|
|
|
if (!response.ok) {
|
|
|
|
// When using fetchBlob we only get a string (not xml or json) back
|
2018-01-25 21:01:14 +02:00
|
|
|
if (options.target === 'file') throw new JoplinError(shortResponseText(), response.status);
|
2018-01-21 19:01:37 +02:00
|
|
|
|
2018-01-23 22:10:20 +02:00
|
|
|
const json = await loadResponseJson();
|
2018-01-21 19:01:37 +02:00
|
|
|
|
2018-01-28 19:36:11 +02:00
|
|
|
if (json && json['d:error']) {
|
2018-01-23 22:10:20 +02:00
|
|
|
const code = json['d:error']['s:exception'] ? json['d:error']['s:exception'].join(' ') : response.status;
|
2018-01-25 21:01:14 +02:00
|
|
|
const message = json['d:error']['s:message'] ? json['d:error']['s:message'].join("\n") : shortResponseText();
|
2018-01-25 23:15:58 +02:00
|
|
|
throw new JoplinError(method + ' ' + path + ': ' + message + ' (' + code + ')', response.status);
|
2018-01-23 22:10:20 +02:00
|
|
|
}
|
2018-01-21 19:01:37 +02:00
|
|
|
|
2018-01-25 21:01:14 +02:00
|
|
|
throw new JoplinError(shortResponseText(), response.status);
|
2018-01-23 22:10:20 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
if (options.responseFormat === 'text') return responseText;
|
2018-01-21 19:01:37 +02:00
|
|
|
|
2018-01-25 21:01:14 +02:00
|
|
|
const output = await loadResponseJson();
|
|
|
|
|
|
|
|
// Check that we didn't get for example an HTML page (as an error) instead of the JSON response
|
|
|
|
// null responses are possible, for example for DELETE calls
|
|
|
|
if (output !== null && typeof output === 'object' && !('d:multistatus' in output)) throw new Error('Not a valid JSON response: ' + shortResponseText());
|
|
|
|
|
|
|
|
return output;
|
2018-01-21 19:01:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
module.exports = WebDavApi;
|