mirror of https://github.com/keeweb/keeweb
fix #1523: support for WebDAV servers without Last-Modified header
parent
bb44c7cb3e
commit
d0b33ca0c1
|
@ -65,6 +65,7 @@ const DefaultAppSettings = {
|
|||
|
||||
webdav: true, // enable WebDAV integration
|
||||
webdavSaveMethod: 'move', // how to save files with WebDAV: "move" or "put"
|
||||
webdavStatReload: false, // WebDAV: reload the file instead of relying on Last-Modified
|
||||
|
||||
gdrive: true, // enable Google Drive integration
|
||||
gdriveClientId: null, // custom Google Drive client id
|
||||
|
|
|
@ -659,7 +659,9 @@
|
|||
|
||||
"webdavSaveMethod": "Save method",
|
||||
"webdavSaveMove": "Upload a temporary file and move",
|
||||
"webdavSavePut": "Overwrite kdbx file with PUT",
|
||||
"webdavSavePut": "Overwrite the kdbx file with PUT",
|
||||
"webdavNoLastModified": "Last-Modified HTTP header is absent",
|
||||
"webdavStatReload": "Always reload the file instead of relying on Last-Modified HTTP header",
|
||||
|
||||
"launcherSave": "Save Passwords Database",
|
||||
"launcherFileFilter": "KeePass files",
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import kdbxweb from 'kdbxweb';
|
||||
import { StorageBase } from 'storage/storage-base';
|
||||
import { Locale } from 'util/locale';
|
||||
|
||||
class StorageWebDav extends StorageBase {
|
||||
name = 'webdav';
|
||||
|
@ -48,6 +50,12 @@ class StorageWebDav extends StorageBase {
|
|||
type: 'select',
|
||||
value: this.appSettings.webdavSaveMethod || 'default',
|
||||
options: { default: 'webdavSaveMove', put: 'webdavSavePut' }
|
||||
},
|
||||
{
|
||||
id: 'webdavStatReload',
|
||||
title: 'webdavStatReload',
|
||||
type: 'checkbox',
|
||||
value: !!this.appSettings.webdavStatReload
|
||||
}
|
||||
]
|
||||
};
|
||||
|
@ -64,33 +72,67 @@ class StorageWebDav extends StorageBase {
|
|||
method: 'GET',
|
||||
path,
|
||||
user: opts ? opts.user : null,
|
||||
password: opts ? opts.password : null
|
||||
password: opts ? opts.password : null,
|
||||
nostat: this.appSettings.webdavStatReload
|
||||
},
|
||||
callback
|
||||
? (err, xhr, stat) => {
|
||||
callback(err, xhr.response, 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._request(
|
||||
{
|
||||
op: 'Stat',
|
||||
method: 'HEAD',
|
||||
path,
|
||||
user: opts ? opts.user : null,
|
||||
password: opts ? opts.password : null
|
||||
},
|
||||
callback
|
||||
? (err, xhr, stat) => {
|
||||
callback(err, stat);
|
||||
}
|
||||
: null
|
||||
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) {
|
||||
|
@ -104,142 +146,113 @@ class StorageWebDav extends StorageBase {
|
|||
user: opts ? opts.user : null,
|
||||
password: opts ? opts.password : null
|
||||
};
|
||||
this._request(
|
||||
{
|
||||
...saveOpts,
|
||||
op: 'Save:stat',
|
||||
method: 'HEAD'
|
||||
},
|
||||
(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._request(
|
||||
{
|
||||
...saveOpts,
|
||||
op: 'Save:stat',
|
||||
method: 'HEAD'
|
||||
},
|
||||
(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);
|
||||
}
|
||||
}
|
||||
this._request(
|
||||
{
|
||||
...saveOpts,
|
||||
op: 'Save:move',
|
||||
method: 'MOVE',
|
||||
path: tmpPath,
|
||||
nostat: true,
|
||||
headers: {
|
||||
Destination: encodeURI(movePath),
|
||||
'Overwrite': 'T'
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
this._request(
|
||||
{
|
||||
...saveOpts,
|
||||
op: 'Save:stat',
|
||||
method: 'HEAD'
|
||||
},
|
||||
(err, xhr, stat) => {
|
||||
cb(err, xhr, stat);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
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._request(
|
||||
{
|
||||
...saveOpts,
|
||||
op: 'Save:put',
|
||||
method: 'PUT',
|
||||
data,
|
||||
nostat: true
|
||||
},
|
||||
(err) => {
|
||||
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) {
|
||||
return cb(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);
|
||||
}
|
||||
}
|
||||
this._request(
|
||||
{
|
||||
...saveOpts,
|
||||
op: 'Save:stat',
|
||||
method: 'HEAD'
|
||||
op: 'Save:move',
|
||||
method: 'MOVE',
|
||||
path: tmpPath,
|
||||
nostat: true,
|
||||
headers: {
|
||||
Destination: encodeURI(movePath),
|
||||
'Overwrite': 'T'
|
||||
}
|
||||
},
|
||||
(err, xhr, stat) => {
|
||||
cb(err, xhr, stat);
|
||||
(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) {
|
||||
|
@ -317,7 +330,7 @@ class StorageWebDav extends StorageBase {
|
|||
this.logger.ts(ts)
|
||||
);
|
||||
if (callback) {
|
||||
callback('No Last-Modified header', xhr);
|
||||
callback(Locale.webdavNoLastModified, xhr);
|
||||
callback = null;
|
||||
}
|
||||
return;
|
||||
|
@ -367,6 +380,23 @@ class StorageWebDav extends StorageBase {
|
|||
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 };
|
||||
|
|
|
@ -7,7 +7,8 @@ class SettingsPrvView extends View {
|
|||
|
||||
events = {
|
||||
'change .settings__general-prv-field-sel': 'changeField',
|
||||
'input .settings__general-prv-field-txt': 'changeField'
|
||||
'input .settings__general-prv-field-txt': 'changeField',
|
||||
'change .settings__general-prv-field-check': 'changeCheckbox'
|
||||
};
|
||||
|
||||
render() {
|
||||
|
@ -29,6 +30,13 @@ class SettingsPrvView extends View {
|
|||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
changeCheckbox(e) {
|
||||
const id = e.target.dataset.id;
|
||||
const value = !!e.target.checked;
|
||||
const storage = Storage[this.model.name];
|
||||
storage.applySetting(id, value);
|
||||
}
|
||||
}
|
||||
|
||||
export { SettingsPrvView };
|
||||
|
|
|
@ -14,6 +14,16 @@
|
|||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
{{else ifeq type 'checkbox'}}
|
||||
<input type="checkbox"
|
||||
class="input-base settings__general-prv-field settings__input settings__general-prv-field-check"
|
||||
id="settings__general-prv-field-check-{{id}}"
|
||||
{{#if value}}checked{{/if}}
|
||||
value="{{value}}"
|
||||
data-id="{{id}}"
|
||||
/>
|
||||
<label for="settings__general-prv-field-check-{{id}}">{{res title}}</label>
|
||||
{{#if desc}}<div class="settings__general-prv-field-desc muted-color">{{res desc}}</div>{{/if}}
|
||||
{{else}}
|
||||
<label for="settings__general-prv-field-txt-{{id}}">{{res title}}:</label>
|
||||
{{#if desc}}<div class="settings__general-prv-field-desc muted-color">{{res desc}}</div>{{/if}}
|
||||
|
|
|
@ -4,6 +4,7 @@ Release notes
|
|||
`-` fixed a performance issue in searching entries
|
||||
`*` improved the "Show all file" checkbox behavior
|
||||
`+` shortcut to copy OTP
|
||||
`+` support for WebDAV servers without Last-Modified header
|
||||
`*` switched to Dropbox short-lived access tokens
|
||||
`-` fixed several issues in field editing
|
||||
`-` fix #1561: error during loading configs after reset
|
||||
|
|
Loading…
Reference in New Issue