import * as kdbxweb from 'kdbxweb'; import { StorageBase } from 'storage/storage-base'; import { Locale } from 'util/locale'; class StorageWebDav extends StorageBase { name = 'webdav'; icon = 'server'; enabled = true; uipos = 10; needShowOpenConfig() { return true; } getOpenConfig() { return { fields: [ { id: 'path', title: 'openUrl', desc: 'openUrlDesc', type: 'text', required: true, pattern: '^https://.+' }, { id: 'user', title: 'openUser', desc: 'openUserDesc', placeholder: 'openUserPlaceholder', type: 'text' }, { id: 'password', title: 'openPass', desc: 'openPassDesc', placeholder: 'openPassPlaceholder', type: 'password' } ] }; } getSettingsConfig() { return { fields: [ { id: 'webdavSaveMethod', title: 'webdavSaveMethod', type: 'select', value: this.appSettings.webdavSaveMethod || 'default', options: { default: 'webdavSaveMove', put: 'webdavSavePut' } }, { id: 'webdavStatReload', title: 'webdavStatReload', type: 'checkbox', value: !!this.appSettings.webdavStatReload } ] }; } applySetting(key, value) { this.appSettings[key] = value; } load(path, opts, callback) { this._request( { op: 'Load', method: 'GET', path, user: opts ? opts.user : null, password: opts ? opts.password : null, nostat: this.appSettings.webdavStatReload }, callback ? (err, xhr, stat) => { if (this.appSettings.webdavStatReload) { this._calcStatByContent(xhr).then((stat) => callback(err, xhr.response, stat) ); } else { callback(err, xhr.response, stat); } } : null ); } stat(path, opts, callback) { this._statRequest( path, opts, 'Stat', callback ? (err, xhr, stat) => callback(err, stat) : null ); } _statRequest(path, opts, op, callback) { if (this.appSettings.webdavStatReload) { this._request( { op, method: 'GET', path, user: opts ? opts.user : null, password: opts ? opts.password : null, nostat: true }, callback ? (err, xhr) => { this._calcStatByContent(xhr).then((stat) => callback(err, xhr, stat)); } : null ); } else { this._request( { op, method: 'HEAD', path, user: opts ? opts.user : null, password: opts ? opts.password : null }, callback ? (err, xhr, stat) => { callback(err, xhr, stat); } : null ); } } save(path, opts, data, callback, rev) { const cb = function (err, xhr, stat) { if (callback) { callback(err, stat); callback = null; } }; const tmpPath = path.replace(/[^\/]+$/, (m) => '.' + m) + '.' + Date.now(); const saveOpts = { path, user: opts ? opts.user : null, password: opts ? opts.password : null }; this._statRequest(path, opts, 'Save:stat', (err, xhr, stat) => { let useTmpPath = this.appSettings.webdavSaveMethod !== 'put'; if (err) { if (!err.notFound) { return cb(err); } else { this.logger.debug('Save: not found, creating'); useTmpPath = false; } } else if (stat.rev !== rev) { this.logger.debug('Save error', path, 'rev conflict', stat.rev, rev); return cb({ revConflict: true }, xhr, stat); } if (useTmpPath) { this._request( { ...saveOpts, op: 'Save:put', method: 'PUT', path: tmpPath, data, nostat: true }, (err) => { if (err) { return cb(err); } this._statRequest(path, opts, 'Save:stat', (err, xhr, stat) => { if (err) { this._request({ ...saveOpts, op: 'Save:delete', method: 'DELETE', path: tmpPath }); return cb(err, xhr, stat); } if (stat.rev !== rev) { this.logger.debug( 'Save error', path, 'rev conflict', stat.rev, rev ); this._request({ ...saveOpts, op: 'Save:delete', method: 'DELETE', path: tmpPath }); return cb({ revConflict: true }, xhr, stat); } let movePath = path; if (movePath.indexOf('://') < 0) { if (movePath.indexOf('/') === 0) { movePath = location.protocol + '//' + location.host + movePath; } else { movePath = location.href .replace(/\?(.*)/, '') .replace(/[^/]*$/, movePath); } } // prevent double encoding, see #1729 const encodedMovePath = /%[A-Z0-9]{2}/.test(movePath) ? movePath : encodeURI(movePath); this._request( { ...saveOpts, op: 'Save:move', method: 'MOVE', path: tmpPath, nostat: true, headers: { Destination: encodedMovePath, 'Overwrite': 'T' } }, (err) => { if (err) { return cb(err); } this._statRequest(path, opts, 'Save:stat', (err, xhr, stat) => { cb(err, xhr, stat); }); } ); }); } ); } else { this._request( { ...saveOpts, op: 'Save:put', method: 'PUT', data, nostat: true }, (err) => { if (err) { return cb(err); } this._statRequest(path, opts, 'Save:stat', (err, xhr, stat) => { cb(err, xhr, stat); }); } ); } }); } fileOptsToStoreOpts(opts, file) { const result = { user: opts.user, encpass: opts.encpass }; if (opts.password) { const fileId = file.uuid; const password = opts.password; const encpass = this._xorString(password, fileId); result.encpass = btoa(encpass); } return result; } storeOptsToFileOpts(opts, file) { const result = { user: opts.user, password: opts.password }; if (opts.encpass) { const fileId = file.uuid; const encpass = atob(opts.encpass); result.password = this._xorString(encpass, fileId); } return result; } _xorString(str, another) { let result = ''; for (let i = 0; i < str.length; i++) { const strCharCode = str.charCodeAt(i); const anotherIx = i % another.length; const anotherCharCode = another.charCodeAt(anotherIx); const resultCharCode = strCharCode ^ anotherCharCode; result += String.fromCharCode(resultCharCode); } return result; } _request(config, callback) { if (config.rev) { this.logger.debug(config.op, config.path, config.rev); } else { this.logger.debug(config.op, config.path); } const ts = this.logger.ts(); const xhr = new XMLHttpRequest(); xhr.addEventListener('load', () => { if ([200, 201, 204].indexOf(xhr.status) < 0) { this.logger.debug( config.op + ' error', config.path, xhr.status, this.logger.ts(ts) ); let err; switch (xhr.status) { case 404: err = { notFound: true }; break; case 412: err = { revConflict: true }; break; default: err = 'HTTP status ' + xhr.status; break; } if (callback) { callback(err, xhr); callback = null; } return; } const rev = xhr.getResponseHeader('Last-Modified'); if (!rev && !config.nostat) { this.logger.debug( config.op + ' error', config.path, 'no headers', this.logger.ts(ts) ); if (callback) { callback(Locale.webdavNoLastModified, xhr); callback = null; } return; } const completedOpName = config.op + (config.op.charAt(config.op.length - 1) === 'e' ? 'd' : 'ed'); this.logger.debug(completedOpName, config.path, rev, this.logger.ts(ts)); if (callback) { callback(null, xhr, rev ? { rev } : null); callback = null; } }); xhr.addEventListener('error', () => { this.logger.debug(config.op + ' error', config.path, this.logger.ts(ts)); if (callback) { callback('network error', xhr); callback = null; } }); xhr.addEventListener('abort', () => { this.logger.debug(config.op + ' error', config.path, 'aborted', this.logger.ts(ts)); if (callback) { callback('aborted', xhr); callback = null; } }); xhr.open(config.method, config.path); xhr.responseType = 'arraybuffer'; if (config.user) { xhr.setRequestHeader( 'Authorization', 'Basic ' + btoa(config.user + ':' + config.password) ); } if (config.headers) { for (const [header, value] of Object.entries(config.headers)) { xhr.setRequestHeader(header, value); } } if (['GET', 'HEAD'].indexOf(config.method) >= 0) { xhr.setRequestHeader('Cache-Control', 'no-cache'); } if (config.data) { const blob = new Blob([config.data], { type: 'application/octet-stream' }); xhr.send(blob); } else { xhr.send(); } } _calcStatByContent(xhr) { if ( xhr.status !== 200 || xhr.responseType !== 'arraybuffer' || !xhr.response || !xhr.response.byteLength ) { this.logger.debug('Cannot calculate rev by content'); return null; } return kdbxweb.CryptoEngine.sha256(xhr.response).then((hash) => { const rev = kdbxweb.ByteUtils.bytesToHex(hash).substr(0, 10); this.logger.debug('Calculated rev by content', `${xhr.response.byteLength} bytes`, rev); return { rev }; }); } } export { StorageWebDav };