2018-03-09 17:49:35 +00:00
|
|
|
const BaseItem = require("lib/models/BaseItem.js");
|
|
|
|
const { time } = require("lib/time-utils.js");
|
|
|
|
const { basicDelta } = require("lib/file-api");
|
|
|
|
const { rtrimSlashes, ltrimSlashes } = require("lib/path-utils.js");
|
|
|
|
const Entities = require("html-entities").AllHtmlEntities;
|
|
|
|
const html_entity_decode = new Entities().decode;
|
|
|
|
const { shim } = require("lib/shim");
|
|
|
|
const { basename } = require("lib/path-utils");
|
|
|
|
const JoplinError = require("lib/JoplinError");
|
|
|
|
|
|
|
|
class FileApiDriverWebDav {
|
2018-01-21 17:01:37 +00:00
|
|
|
constructor(api) {
|
|
|
|
this.api_ = api;
|
|
|
|
}
|
|
|
|
|
|
|
|
api() {
|
|
|
|
return this.api_;
|
|
|
|
}
|
|
|
|
|
2018-02-07 19:46:07 +00:00
|
|
|
requestRepeatCount() {
|
|
|
|
return 3;
|
|
|
|
}
|
|
|
|
|
2018-01-21 17:01:37 +00:00
|
|
|
async stat(path) {
|
2018-01-25 19:01:14 +00:00
|
|
|
try {
|
2018-03-09 17:49:35 +00:00
|
|
|
const result = await this.api().execPropFind(path, 0, ["d:getlastmodified", "d:resourcetype"]);
|
2018-01-21 17:01:37 +00:00
|
|
|
|
2018-03-09 17:49:35 +00:00
|
|
|
const resource = this.api().objectFromJson(result, ["d:multistatus", "d:response", 0]);
|
2018-01-25 19:01:14 +00:00
|
|
|
return this.statFromResource_(resource, path);
|
|
|
|
} catch (error) {
|
|
|
|
if (error.code === 404) return null;
|
|
|
|
throw error;
|
|
|
|
}
|
2018-01-21 17:01:37 +00:00
|
|
|
}
|
|
|
|
|
2018-01-25 19:01:14 +00:00
|
|
|
statFromResource_(resource, path) {
|
2018-02-14 19:08:07 +00:00
|
|
|
// WebDAV implementations are always slighly different from one server to another but, at the minimum,
|
|
|
|
// a resource should have a propstat key - if not it's probably an error.
|
2018-03-09 17:49:35 +00:00
|
|
|
const propStat = this.api().arrayFromJson(resource, ["d:propstat"]);
|
|
|
|
if (!Array.isArray(propStat)) throw new Error("Invalid WebDAV resource format: " + JSON.stringify(resource));
|
2018-02-14 19:08:07 +00:00
|
|
|
|
2018-03-09 17:49:35 +00:00
|
|
|
const resourceTypes = this.api().resourcePropByName(resource, "array", "d:resourcetype");
|
2018-02-14 19:08:07 +00:00
|
|
|
let isDir = false;
|
|
|
|
if (Array.isArray(resourceTypes)) {
|
|
|
|
for (let i = 0; i < resourceTypes.length; i++) {
|
|
|
|
const t = resourceTypes[i];
|
2018-03-09 17:49:35 +00:00
|
|
|
if (typeof t === "object" && "d:collection" in t) {
|
2018-02-14 19:08:07 +00:00
|
|
|
isDir = true;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-03-09 17:49:35 +00:00
|
|
|
const lastModifiedString = this.api().resourcePropByName(resource, "string", "d:getlastmodified");
|
2018-01-21 17:01:37 +00:00
|
|
|
|
2018-02-14 19:08:07 +00:00
|
|
|
// Note: Not all WebDAV servers return a getlastmodified date (eg. Seafile, which doesn't return the
|
|
|
|
// property for folders) so we can only throw an error if it's a file.
|
2018-03-09 17:49:35 +00:00
|
|
|
if (!lastModifiedString && !isDir) throw new Error("Could not get lastModified date for resource: " + JSON.stringify(resource));
|
2018-02-14 19:08:07 +00:00
|
|
|
const lastModifiedDate = lastModifiedString ? new Date(lastModifiedString) : new Date();
|
2018-03-09 17:49:35 +00:00
|
|
|
if (isNaN(lastModifiedDate.getTime())) throw new Error("Invalid date: " + lastModifiedString);
|
2018-01-21 17:01:37 +00:00
|
|
|
|
|
|
|
return {
|
|
|
|
path: path,
|
|
|
|
updated_time: lastModifiedDate.getTime(),
|
2018-02-14 19:08:07 +00:00
|
|
|
isDir: isDir,
|
2018-01-21 17:01:37 +00:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
async setTimestamp(path, timestampMs) {
|
2018-03-09 17:49:35 +00:00
|
|
|
throw new Error("Not implemented"); // Not needed anymore
|
2018-01-21 17:01:37 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
async delta(path, options) {
|
2018-03-09 17:49:35 +00:00
|
|
|
const getDirStats = async path => {
|
2018-01-25 21:15:58 +00:00
|
|
|
const result = await this.list(path);
|
|
|
|
return result.items;
|
2018-01-25 19:01:14 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
return await basicDelta(path, getDirStats, options);
|
2018-01-21 17:01:37 +00:00
|
|
|
}
|
|
|
|
|
2018-02-01 23:40:05 +00:00
|
|
|
// A file href, as found in the result of a PROPFIND, can be either an absolute URL or a
|
|
|
|
// relative URL (an absolute URL minus the protocol and domain), while the sync algorithm
|
|
|
|
// works with paths relative to the base URL.
|
|
|
|
hrefToRelativePath_(href, baseUrl, relativeBaseUrl) {
|
2018-03-09 17:49:35 +00:00
|
|
|
let output = "";
|
2018-02-01 23:40:05 +00:00
|
|
|
if (href.indexOf(baseUrl) === 0) {
|
|
|
|
output = href.substr(baseUrl.length);
|
|
|
|
} else if (href.indexOf(relativeBaseUrl) === 0) {
|
|
|
|
output = href.substr(relativeBaseUrl.length);
|
|
|
|
} else {
|
2018-03-09 17:49:35 +00:00
|
|
|
throw new Error("href " + href + " not in baseUrl " + baseUrl + " nor relativeBaseUrl " + relativeBaseUrl);
|
2018-02-01 23:40:05 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return rtrimSlashes(ltrimSlashes(output));
|
|
|
|
}
|
|
|
|
|
|
|
|
statsFromResources_(resources) {
|
2018-01-29 20:51:14 +00:00
|
|
|
const relativeBaseUrl = this.api().relativeBaseUrl();
|
2018-02-01 23:40:05 +00:00
|
|
|
const baseUrl = this.api().baseUrl();
|
|
|
|
let output = [];
|
|
|
|
for (let i = 0; i < resources.length; i++) {
|
|
|
|
const resource = resources[i];
|
2018-03-09 17:49:35 +00:00
|
|
|
const href = this.api().stringFromJson(resource, ["d:href", 0]);
|
2018-02-01 23:40:05 +00:00
|
|
|
const path = this.hrefToRelativePath_(href, baseUrl, relativeBaseUrl);
|
|
|
|
// if (href.indexOf(relativeBaseUrl) !== 0) throw new Error('Path "' + href + '" not inside base URL: ' + relativeBaseUrl);
|
|
|
|
// const path = rtrimSlashes(ltrimSlashes(href.substr(relativeBaseUrl.length)));
|
2018-03-09 17:49:35 +00:00
|
|
|
if (path === "") continue; // The list of resources includes the root dir too, which we don't want
|
2018-02-01 23:40:05 +00:00
|
|
|
const stat = this.statFromResource_(resources[i], path);
|
|
|
|
output.push(stat);
|
|
|
|
}
|
|
|
|
return output;
|
|
|
|
}
|
|
|
|
|
|
|
|
async list(path, options) {
|
|
|
|
// const relativeBaseUrl = this.api().relativeBaseUrl();
|
2018-01-29 20:51:14 +00:00
|
|
|
|
|
|
|
// function parsePropFindXml(xmlString) {
|
|
|
|
// return new Promise(async (resolve, reject) => {
|
|
|
|
// const saxOptions = {};
|
|
|
|
// const saxParser = require('sax').parser(false, { position: false });
|
|
|
|
|
|
|
|
// let stats = [];
|
|
|
|
// let currentStat = null;
|
|
|
|
// let currentText = '';
|
|
|
|
|
2018-03-09 17:49:35 +00:00
|
|
|
// // When this is on, the tags from the bloated XML string are replaced by shorter ones,
|
2018-01-29 20:51:14 +00:00
|
|
|
// // which makes parsing about 25% faster. However it's a bit of a hack so keep it as
|
|
|
|
// // an option so that it can be disabled if it causes problems.
|
|
|
|
// const optimizeXml = true;
|
|
|
|
|
|
|
|
// const tagResponse = optimizeXml ? 'd:r' : 'd:response';
|
|
|
|
// const tagGetLastModified = optimizeXml ? 'd:glm' : 'd:getlastmodified';
|
|
|
|
// const tagPropStat = optimizeXml ? 'd:ps' : 'd:propstat';
|
|
|
|
// const replaceUrls = optimizeXml;
|
|
|
|
|
|
|
|
// saxParser.onerror = function (error) {
|
|
|
|
// reject(new Error(e.toString()));
|
|
|
|
// };
|
|
|
|
|
|
|
|
// saxParser.ontext = function (t) {
|
|
|
|
// currentText += t;
|
|
|
|
// };
|
|
|
|
|
|
|
|
// saxParser.onopentag = function (node) {
|
|
|
|
// const tagName = node.name.toLowerCase();
|
|
|
|
|
|
|
|
// currentText = '';
|
|
|
|
|
|
|
|
// if (tagName === tagResponse) {
|
|
|
|
// currentStat = { isDir: false };
|
|
|
|
// }
|
|
|
|
// };
|
|
|
|
|
|
|
|
// saxParser.onclosetag = function(tagName) {
|
|
|
|
// tagName = tagName.toLowerCase();
|
2018-03-09 17:49:35 +00:00
|
|
|
|
2018-01-29 20:51:14 +00:00
|
|
|
// if (tagName === tagResponse) {
|
|
|
|
// if (currentStat.path) { // The list of resources includes the root dir too, which we don't want
|
|
|
|
// if (!currentStat.updated_time) throw new Error('Resource does not have a getlastmodified prop');
|
|
|
|
// stats.push(currentStat);
|
|
|
|
// }
|
|
|
|
// currentStat = null;
|
|
|
|
// }
|
|
|
|
|
|
|
|
// if (tagName === 'd:href') {
|
|
|
|
// const href = currentText;
|
|
|
|
|
|
|
|
// if (replaceUrls) {
|
|
|
|
// currentStat.path = rtrimSlashes(ltrimSlashes(href));
|
|
|
|
// } else {
|
|
|
|
// if (href.indexOf(relativeBaseUrl) < 0) throw new Error('Path not inside base URL: ' + relativeBaseUrl); // Normally not possible
|
|
|
|
// currentStat.path = rtrimSlashes(ltrimSlashes(href.substr(relativeBaseUrl.length)));
|
|
|
|
// }
|
|
|
|
// }
|
|
|
|
|
|
|
|
// if (tagName === tagGetLastModified) {
|
|
|
|
// const lastModifiedDate = new Date(currentText);
|
|
|
|
// if (isNaN(lastModifiedDate.getTime())) throw new Error('Invalid date: ' + currentText);
|
|
|
|
// currentStat.updated_time = lastModifiedDate.getTime();
|
|
|
|
// currentStat.created_time = currentStat.updated_time;
|
|
|
|
// }
|
|
|
|
|
|
|
|
// if (tagName === 'd:collection') {
|
|
|
|
// currentStat.isDir = true;
|
|
|
|
// }
|
|
|
|
|
|
|
|
// currentText = '';
|
|
|
|
// }
|
|
|
|
|
|
|
|
// saxParser.onend = function () {
|
|
|
|
// resolve(stats);
|
|
|
|
// };
|
|
|
|
|
|
|
|
// if (optimizeXml) {
|
2018-03-09 17:49:35 +00:00
|
|
|
// xmlString = xmlString.replace(/<d:status>HTTP\/1\.1 200 OK<\/d:status>/ig, '');
|
|
|
|
// xmlString = xmlString.replace(/<d:resourcetype\/>/ig, '');
|
|
|
|
// xmlString = xmlString.replace(/d:getlastmodified/ig, tagGetLastModified);
|
|
|
|
// xmlString = xmlString.replace(/d:response/ig, tagResponse);
|
|
|
|
// xmlString = xmlString.replace(/d:propstat/ig, tagPropStat);
|
|
|
|
// if (replaceUrls) xmlString = xmlString.replace(new RegExp(relativeBaseUrl, 'gi'), '');
|
2018-01-29 20:51:14 +00:00
|
|
|
// }
|
|
|
|
|
|
|
|
// let idx = 0;
|
|
|
|
// let size = 1024 * 100;
|
|
|
|
// while (true) {
|
|
|
|
// sub = xmlString.substr(idx, size);
|
|
|
|
// if (!sub.length) break;
|
|
|
|
// saxParser.write(sub);
|
|
|
|
// idx += size;
|
|
|
|
// //await time.msleep(500);
|
|
|
|
// }
|
|
|
|
|
|
|
|
// saxParser.close();
|
|
|
|
|
|
|
|
// //saxParser.write(xmlString).close();
|
|
|
|
// });
|
|
|
|
// }
|
|
|
|
|
|
|
|
// For performance reasons, the response of the PROPFIND call is manually parsed with a regex below
|
|
|
|
// instead of being processed by xml2json like the other WebDAV responses. This is over 2 times faster
|
2018-03-09 17:49:35 +00:00
|
|
|
// and it means the mobile app does not freeze during sync.
|
2018-01-29 20:51:14 +00:00
|
|
|
|
2018-02-01 23:40:05 +00:00
|
|
|
// async function parsePropFindXml2(xmlString) {
|
|
|
|
// const regex = /<d:response>[\S\s]*?<d:href>([\S\s]*?)<\/d:href>[\S\s]*?<d:getlastmodified>(.*?)<\/d:getlastmodified>/g;
|
2018-01-29 20:51:14 +00:00
|
|
|
|
2018-02-01 23:40:05 +00:00
|
|
|
// let output = [];
|
|
|
|
// let match = null;
|
2018-01-29 20:51:14 +00:00
|
|
|
|
2018-02-01 23:40:05 +00:00
|
|
|
// while (match = regex.exec(xmlString)) {
|
|
|
|
// const href = html_entity_decode(match[1]);
|
|
|
|
// if (href.indexOf(relativeBaseUrl) < 0) throw new Error('Path not inside base URL: ' + relativeBaseUrl); // Normally not possible
|
|
|
|
// const path = rtrimSlashes(ltrimSlashes(href.substr(relativeBaseUrl.length)));
|
2018-01-29 20:51:14 +00:00
|
|
|
|
2018-02-01 23:40:05 +00:00
|
|
|
// if (!path) continue; // The list of resources includes the root dir too, which we don't want
|
2018-01-29 20:51:14 +00:00
|
|
|
|
2018-02-01 23:40:05 +00:00
|
|
|
// const lastModifiedDate = new Date(match[2]);
|
|
|
|
// if (isNaN(lastModifiedDate.getTime())) throw new Error('Invalid date: ' + match[2]);
|
2018-01-29 20:51:14 +00:00
|
|
|
|
2018-02-01 23:40:05 +00:00
|
|
|
// output.push({
|
|
|
|
// path: path,
|
|
|
|
// updated_time: lastModifiedDate.getTime(),
|
|
|
|
// created_time: lastModifiedDate.getTime(),
|
|
|
|
// isDir: !BaseItem.isSystemPath(path),
|
|
|
|
// });
|
|
|
|
// }
|
2018-01-29 20:51:14 +00:00
|
|
|
|
2018-02-01 23:40:05 +00:00
|
|
|
// return output;
|
|
|
|
// }
|
|
|
|
|
|
|
|
// const resultXml = await this.api().execPropFind(path, 1, [
|
|
|
|
// 'd:getlastmodified',
|
|
|
|
// //'d:resourcetype', // Include this to use parsePropFindXml()
|
|
|
|
// ], { responseFormat: 'text' });
|
|
|
|
|
|
|
|
// const stats = await parsePropFindXml2(resultXml);
|
2018-01-29 20:51:14 +00:00
|
|
|
|
2018-02-01 23:40:05 +00:00
|
|
|
// return {
|
|
|
|
// items: stats,
|
|
|
|
// hasMore: false,
|
|
|
|
// context: null,
|
|
|
|
// };
|
|
|
|
|
2018-03-09 17:49:35 +00:00
|
|
|
const result = await this.api().execPropFind(path, 1, ["d:getlastmodified", "d:resourcetype"]);
|
2018-01-25 21:15:58 +00:00
|
|
|
|
2018-03-09 17:49:35 +00:00
|
|
|
const resources = this.api().arrayFromJson(result, ["d:multistatus", "d:response"]);
|
2018-02-14 19:08:07 +00:00
|
|
|
const stats = this.statsFromResources_(resources);
|
2018-01-25 21:15:58 +00:00
|
|
|
|
|
|
|
return {
|
2018-01-29 20:51:14 +00:00
|
|
|
items: stats,
|
2018-01-25 21:15:58 +00:00
|
|
|
hasMore: false,
|
|
|
|
context: null,
|
|
|
|
};
|
2018-01-21 17:01:37 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
async get(path, options) {
|
2018-01-23 20:10:20 +00:00
|
|
|
if (!options) options = {};
|
2018-03-09 17:49:35 +00:00
|
|
|
if (!options.responseFormat) options.responseFormat = "text";
|
2018-01-23 20:10:20 +00:00
|
|
|
try {
|
2018-03-09 17:49:35 +00:00
|
|
|
const response = await this.api().exec("GET", path, null, null, options);
|
2018-02-01 23:40:05 +00:00
|
|
|
|
|
|
|
// This is awful but instead of a 404 Not Found, Microsoft IIS returns an HTTP code 200
|
|
|
|
// with a response body "The specified file doesn't exist." for non-existing files,
|
|
|
|
// so we need to check for this.
|
|
|
|
if (response === "The specified file doesn't exist.") throw new JoplinError(response, 404);
|
|
|
|
return response;
|
2018-01-23 20:10:20 +00:00
|
|
|
} catch (error) {
|
|
|
|
if (error.code !== 404) throw error;
|
|
|
|
}
|
2018-01-21 17:01:37 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
async mkdir(path) {
|
2018-01-23 20:10:20 +00:00
|
|
|
try {
|
2018-03-09 17:49:35 +00:00
|
|
|
await this.api().exec("MKCOL", path);
|
2018-01-23 20:10:20 +00:00
|
|
|
} catch (error) {
|
2018-02-01 23:40:05 +00:00
|
|
|
if (error.code === 405) return; // 405 means that the collection already exists (Method Not Allowed)
|
|
|
|
|
|
|
|
// 409 should only be returned if a parent path does not exists (eg. when trying to create a/b/c when a/b does not exist)
|
|
|
|
// however non-compliant servers (eg. Microsoft IIS) also return this code when the directory already exists. So here, if
|
|
|
|
// we get this code, verify that indeed the directory already exists and exit if it does.
|
|
|
|
if (error.code === 409) {
|
|
|
|
const stat = await this.stat(path);
|
|
|
|
if (stat) return;
|
|
|
|
}
|
2018-03-09 17:49:35 +00:00
|
|
|
|
2018-02-01 23:40:05 +00:00
|
|
|
throw error;
|
2018-01-23 20:10:20 +00:00
|
|
|
}
|
2018-01-21 17:01:37 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
async put(path, content, options = null) {
|
2018-03-09 17:49:35 +00:00
|
|
|
return await this.api().exec("PUT", path, content, null, options);
|
2018-01-21 17:01:37 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
async delete(path) {
|
2018-01-23 20:10:20 +00:00
|
|
|
try {
|
2018-03-09 17:49:35 +00:00
|
|
|
await this.api().exec("DELETE", path);
|
2018-01-23 20:10:20 +00:00
|
|
|
} catch (error) {
|
|
|
|
if (error.code !== 404) throw error;
|
|
|
|
}
|
2018-01-21 17:01:37 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
async move(oldPath, newPath) {
|
2018-03-09 17:49:35 +00:00
|
|
|
await this.api().exec("MOVE", oldPath, null, {
|
|
|
|
Destination: this.api().baseUrl() + "/" + newPath,
|
|
|
|
Overwrite: "T",
|
2018-01-30 20:10:36 +00:00
|
|
|
});
|
2018-01-21 17:01:37 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
format() {
|
2018-03-09 17:49:35 +00:00
|
|
|
throw new Error("Not supported");
|
2018-01-21 17:01:37 +00:00
|
|
|
}
|
|
|
|
|
2018-01-25 21:15:58 +00:00
|
|
|
async clearRoot() {
|
2018-03-09 17:49:35 +00:00
|
|
|
await this.delete("");
|
|
|
|
await this.mkdir("");
|
2018-01-25 21:15:58 +00:00
|
|
|
}
|
2018-01-21 17:01:37 +00:00
|
|
|
}
|
|
|
|
|
2018-03-09 17:49:35 +00:00
|
|
|
module.exports = { FileApiDriverWebDav };
|