diff --git a/ReactNativeClient/lib/BaseApplication.js b/ReactNativeClient/lib/BaseApplication.js index 5dc05dcba..839c38816 100644 --- a/ReactNativeClient/lib/BaseApplication.js +++ b/ReactNativeClient/lib/BaseApplication.js @@ -27,6 +27,7 @@ const SyncTargetFilesystem = require('lib/SyncTargetFilesystem.js'); const SyncTargetOneDrive = require('lib/SyncTargetOneDrive.js'); const SyncTargetOneDriveDev = require('lib/SyncTargetOneDriveDev.js'); const SyncTargetNextcloud = require('lib/SyncTargetNextcloud.js'); +const SyncTargetWebDAV = require('lib/SyncTargetWebDAV.js'); const EncryptionService = require('lib/services/EncryptionService'); const DecryptionWorker = require('lib/services/DecryptionWorker'); @@ -34,6 +35,7 @@ SyncTargetRegistry.addClass(SyncTargetFilesystem); SyncTargetRegistry.addClass(SyncTargetOneDrive); SyncTargetRegistry.addClass(SyncTargetOneDriveDev); SyncTargetRegistry.addClass(SyncTargetNextcloud); +SyncTargetRegistry.addClass(SyncTargetWebDAV); class BaseApplication { diff --git a/ReactNativeClient/lib/SyncTargetWebDAV.js b/ReactNativeClient/lib/SyncTargetWebDAV.js new file mode 100644 index 000000000..7ce735368 --- /dev/null +++ b/ReactNativeClient/lib/SyncTargetWebDAV.js @@ -0,0 +1,52 @@ +const BaseSyncTarget = require('lib/BaseSyncTarget.js'); +const { _ } = require('lib/locale.js'); +const Setting = require('lib/models/Setting.js'); +const { FileApi } = require('lib/file-api.js'); +const { Synchronizer } = require('lib/synchronizer.js'); +const WebDavApi = require('lib/WebDavApi'); +const { FileApiDriverWebDav } = require('lib/file-api-driver-webdav'); + +class SyncTargetWebDAV extends BaseSyncTarget { + + static id() { + return 6; + } + + constructor(db, options = null) { + super(db, options); + } + + static targetName() { + return 'webdav'; + } + + static label() { + return _('WebDAV (Beta)'); + } + + isAuthenticated() { + return true; + } + + async initFileApi() { + const options = { + baseUrl: () => Setting.value('sync.6.path'), + username: () => Setting.value('sync.6.username'), + password: () => Setting.value('sync.6.password'), + }; + + const api = new WebDavApi(options); + const driver = new FileApiDriverWebDav(api); + const fileApi = new FileApi('', driver); + fileApi.setSyncTargetId(SyncTargetWebDAV.id()); + fileApi.setLogger(this.logger()); + return fileApi; + } + + async initSynchronizer() { + return new Synchronizer(this.db(), await this.fileApi(), Setting.value('appType')); + } + +} + +module.exports = SyncTargetWebDAV; \ No newline at end of file diff --git a/ReactNativeClient/lib/WebDavApi.js b/ReactNativeClient/lib/WebDavApi.js index 1983fada6..b6dfea366 100644 --- a/ReactNativeClient/lib/WebDavApi.js +++ b/ReactNativeClient/lib/WebDavApi.js @@ -42,18 +42,34 @@ class WebDavApi { } async xmlToJson(xml) { + let davNamespaces = []; // Yes, there can be more than one... xmlns:a="DAV:" xmlns:D="DAV:" const nameProcessor = (name) => { - // const idx = name.indexOf(':'); - // if (idx >= 0) { - // if (name.indexOf('xmlns:') !== 0) name = name.substr(idx + 1); - // } + if (name.indexOf('xmlns:') !== 0) { + // Check if the current name is within the DAV namespace. If it is, normalise it + // by moving it to the "d:" namespace, which is what all the functions are using. + const p = name.split(':'); + if (p.length == 2) { + const ns = p[0]; + if (davNamespaces.indexOf(ns) >= 0) { + name = 'd:' + p[1]; + } + } + } return name.toLowerCase(); }; + const attrValueProcessor = (value, name) => { + if (value.toLowerCase() === 'dav:') { + const p = name.split(':'); + davNamespaces.push(p[p.length - 1]); + } + } + const options = { tagNameProcessors: [nameProcessor], attrNameProcessors: [nameProcessor], + attrValueProcessors: [attrValueProcessor] } return new Promise((resolve, reject) => { @@ -81,6 +97,14 @@ class WebDavApi { } if (type === 'string') { + // If the XML has not attribute the value is directly a string + // If the XML node has attributes, the value is under "_". + // Eg for this XML, the string will be under {"_":"Thu, 01 Feb 2018 17:24:05 GMT"}: + // Thu, 01 Feb 2018 17:24:05 GMT + // For this XML, the value will be "Thu, 01 Feb 2018 17:24:05 GMT" + // Thu, 01 Feb 2018 17:24:05 GMT + + if (typeof output === 'object' && '_' in output) output = output['_']; if (typeof output !== 'string') return null; return output; } @@ -151,7 +175,7 @@ class WebDavApi { if (authToken) headers['Authorization'] = 'Basic ' + authToken; - if (typeof body === 'string') headers['Content-length'] = body.length; + if (typeof body === 'string') headers['Content-Length'] = body.length; const fetchOptions = {}; fetchOptions.headers = headers; @@ -163,7 +187,7 @@ class WebDavApi { let response = null; - // console.info('WebDAV', method + ' ' + path, headers, options); + // console.info('WebDAV', method + ' ' + url, headers, options); if (options.source == 'file' && (method == 'POST' || method == 'PUT')) { response = await shim.uploadBlob(url, fetchOptions); @@ -202,7 +226,7 @@ class WebDavApi { throw new JoplinError(method + ' ' + path + ': ' + message + ' (' + code + ')', response.status); } - throw new JoplinError(shortResponseText(), response.status); + throw new JoplinError(method + ' ' + path + ': ' + shortResponseText(), response.status); } if (options.responseFormat === 'text') return responseText; diff --git a/ReactNativeClient/lib/components/screen-header.js b/ReactNativeClient/lib/components/screen-header.js index f6fad349e..f25a7a287 100644 --- a/ReactNativeClient/lib/components/screen-header.js +++ b/ReactNativeClient/lib/components/screen-header.js @@ -372,7 +372,7 @@ class ScreenHeaderComponent extends Component { if (mustSelect) output.push({ label: _('Move to notebook...'), value: null }); for (let i = 0; i < this.props.folders.length; i++) { let f = this.props.folders[i]; - output.push({ label: f.title, value: f.id }); + output.push({ label: Folder.displayTitle(f), value: f.id }); } output.sort((a, b) => { if (a.value === null) return -1; diff --git a/ReactNativeClient/lib/file-api-driver-webdav.js b/ReactNativeClient/lib/file-api-driver-webdav.js index a42a7f555..74cbfba79 100644 --- a/ReactNativeClient/lib/file-api-driver-webdav.js +++ b/ReactNativeClient/lib/file-api-driver-webdav.js @@ -6,6 +6,7 @@ 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 { @@ -67,8 +68,41 @@ class FileApiDriverWebDav { return await basicDelta(path, getDirStats, options); } - async list(path, options) { + // 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) { + let output = ''; + if (href.indexOf(baseUrl) === 0) { + output = href.substr(baseUrl.length); + } else if (href.indexOf(relativeBaseUrl) === 0) { + output = href.substr(relativeBaseUrl.length); + } else { + throw new Error('href ' + href + ' not in baseUrl ' + baseUrl + ' nor relativeBaseUrl ' + relativeBaseUrl); + } + + return rtrimSlashes(ltrimSlashes(output)); + } + + statsFromResources_(resources) { const relativeBaseUrl = this.api().relativeBaseUrl(); + const baseUrl = this.api().baseUrl(); + let output = []; + for (let i = 0; i < resources.length; i++) { + const resource = resources[i]; + const href = this.api().stringFromJson(resource, ['d:href', 0]); + 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))); + if (path === '') continue; // The list of resources includes the root dir too, which we don't want + const stat = this.statFromResource_(resources[i], path); + output.push(stat); + } + return output; + } + + async list(path, options) { + // const relativeBaseUrl = this.api().relativeBaseUrl(); // function parsePropFindXml(xmlString) { // return new Promise(async (resolve, reject) => { @@ -176,39 +210,53 @@ class FileApiDriverWebDav { // instead of being processed by xml2json like the other WebDAV responses. This is over 2 times faster // and it means the mobile app does not freeze during sync. - async function parsePropFindXml2(xmlString) { - const regex = /[\S\s]*?([\S\s]*?)<\/d:href>[\S\s]*?(.*?)<\/d:getlastmodified>/g; + // async function parsePropFindXml2(xmlString) { + // const regex = /[\S\s]*?([\S\s]*?)<\/d:href>[\S\s]*?(.*?)<\/d:getlastmodified>/g; - let output = []; - let match = null; + // let output = []; + // let match = null; - 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))); + // 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))); - if (!path) continue; // The list of resources includes the root dir too, which we don't want + // if (!path) continue; // The list of resources includes the root dir too, which we don't want - const lastModifiedDate = new Date(match[2]); - if (isNaN(lastModifiedDate.getTime())) throw new Error('Invalid date: ' + match[2]); + // const lastModifiedDate = new Date(match[2]); + // if (isNaN(lastModifiedDate.getTime())) throw new Error('Invalid date: ' + match[2]); - output.push({ - path: path, - updated_time: lastModifiedDate.getTime(), - created_time: lastModifiedDate.getTime(), - isDir: !BaseItem.isSystemPath(path), - }); - } + // output.push({ + // path: path, + // updated_time: lastModifiedDate.getTime(), + // created_time: lastModifiedDate.getTime(), + // isDir: !BaseItem.isSystemPath(path), + // }); + // } - return output; - } + // return output; + // } - const resultXml = await this.api().execPropFind(path, 1, [ + // const resultXml = await this.api().execPropFind(path, 1, [ + // 'd:getlastmodified', + // //'d:resourcetype', // Include this to use parsePropFindXml() + // ], { responseFormat: 'text' }); + + // const stats = await parsePropFindXml2(resultXml); + + // return { + // items: stats, + // hasMore: false, + // context: null, + // }; + + const result = await this.api().execPropFind(path, 1, [ 'd:getlastmodified', - //'d:resourcetype', // Include this to use parsePropFindXml() - ], { responseFormat: 'text' }); + 'd:resourcetype', + ]); - const stats = await parsePropFindXml2(resultXml); + const resources = this.api().arrayFromJson(result, ['d:multistatus', 'd:response']); + const stats = this.statsFromResources_(resources) return { items: stats, @@ -221,7 +269,13 @@ class FileApiDriverWebDav { if (!options) options = {}; if (!options.responseFormat) options.responseFormat = 'text'; try { - return await this.api().exec('GET', path, null, null, options); + const response = await this.api().exec('GET', path, null, null, options); + + // 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; } catch (error) { if (error.code !== 404) throw error; } @@ -231,7 +285,17 @@ class FileApiDriverWebDav { try { await this.api().exec('MKCOL', path); } catch (error) { - if (error.code !== 405) throw error; // 405 means that the collection already exists (Method Not Allowed) + 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; + } + + throw error; } } @@ -275,6 +339,7 @@ class FileApiDriverWebDav { async move(oldPath, newPath) { await this.api().exec('MOVE', oldPath, null, { 'Destination': this.api().baseUrl() + '/' + newPath, + 'Overwrite': 'T', }); } diff --git a/ReactNativeClient/lib/models/Setting.js b/ReactNativeClient/lib/models/Setting.js index 0dc8a9edd..f30a3c290 100644 --- a/ReactNativeClient/lib/models/Setting.js +++ b/ReactNativeClient/lib/models/Setting.js @@ -106,6 +106,11 @@ class Setting extends BaseModel { 'sync.5.path': { value: '', type: Setting.TYPE_STRING, show: (settings) => { return settings['sync.target'] == SyncTargetRegistry.nameToId('nextcloud') }, public: true, label: () => _('Nexcloud WebDAV URL') }, 'sync.5.username': { value: '', type: Setting.TYPE_STRING, show: (settings) => { return settings['sync.target'] == SyncTargetRegistry.nameToId('nextcloud') }, public: true, label: () => _('Nexcloud username') }, 'sync.5.password': { value: '', type: Setting.TYPE_STRING, show: (settings) => { return settings['sync.target'] == SyncTargetRegistry.nameToId('nextcloud') }, public: true, label: () => _('Nexcloud password'), secure: true }, + + 'sync.6.path': { value: '', type: Setting.TYPE_STRING, show: (settings) => { return settings['sync.target'] == SyncTargetRegistry.nameToId('webdav') }, public: true, label: () => _('WebDAV URL') }, + 'sync.6.username': { value: '', type: Setting.TYPE_STRING, show: (settings) => { return settings['sync.target'] == SyncTargetRegistry.nameToId('webdav') }, public: true, label: () => _('WebDAV username') }, + 'sync.6.password': { value: '', type: Setting.TYPE_STRING, show: (settings) => { return settings['sync.target'] == SyncTargetRegistry.nameToId('webdav') }, public: true, label: () => _('WebDAV password'), secure: true }, + 'sync.3.auth': { value: '', type: Setting.TYPE_STRING, public: false }, 'sync.4.auth': { value: '', type: Setting.TYPE_STRING, public: false }, 'sync.1.context': { value: '', type: Setting.TYPE_STRING, public: false }, diff --git a/ReactNativeClient/lib/synchronizer.js b/ReactNativeClient/lib/synchronizer.js index f2d9d0359..d1beaa5e3 100644 --- a/ReactNativeClient/lib/synchronizer.js +++ b/ReactNativeClient/lib/synchronizer.js @@ -450,7 +450,7 @@ class Synchronizer { if (!BaseItem.isSystemPath(remote.path)) continue; // The delta API might return things like the .sync, .resource or the root folder const loadContent = async () => { - content = await this.api().get(path); + let content = await this.api().get(path); if (!content) return null; return await BaseItem.unserialize(content); } diff --git a/ReactNativeClient/root.js b/ReactNativeClient/root.js index 6184c5019..d396f9e94 100644 --- a/ReactNativeClient/root.js +++ b/ReactNativeClient/root.js @@ -52,9 +52,11 @@ const SyncTargetOneDrive = require('lib/SyncTargetOneDrive.js'); const SyncTargetFilesystem = require('lib/SyncTargetFilesystem.js'); const SyncTargetOneDriveDev = require('lib/SyncTargetOneDriveDev.js'); const SyncTargetNextcloud = require('lib/SyncTargetNextcloud.js'); +const SyncTargetWebDAV = require('lib/SyncTargetWebDAV.js'); SyncTargetRegistry.addClass(SyncTargetOneDrive); SyncTargetRegistry.addClass(SyncTargetOneDriveDev); SyncTargetRegistry.addClass(SyncTargetNextcloud); +SyncTargetRegistry.addClass(SyncTargetWebDAV); // Disabled because not fully working //SyncTargetRegistry.addClass(SyncTargetFilesystem);