mirror of https://github.com/xwiki-labs/cryptpad
Password-protected files
This commit is contained in:
parent
95218f0fa1
commit
c7e08fedfb
|
@ -331,14 +331,11 @@ define([
|
|||
dropArea: $('.CodeMirror'),
|
||||
body: $('body'),
|
||||
onUploaded: function (ev, data) {
|
||||
//var cursor = editor.getCursor();
|
||||
//var cleanName = data.name.replace(/[\[\]]/g, '');
|
||||
//var text = '!['+cleanName+']('+data.url+')';
|
||||
// PASSWORD_FILES
|
||||
var parsed = Hash.parsePadUrl(data.url);
|
||||
var hexFileName = Util.base64ToHex(parsed.hashData.channel);
|
||||
var src = '/blob/' + hexFileName.slice(0,2) + '/' + hexFileName;
|
||||
var mt = '<media-tag src="' + src + '" data-crypto-key="cryptpad:' + parsed.hashData.key + '"></media-tag>';
|
||||
var secret = Hash.getSecrets('file', parsed.hash, data.password);
|
||||
var src = Hash.getBlobPathFromHex(secret.channel);
|
||||
var key = Hash.encodeBase64(secret.keys.cryptKey);
|
||||
var mt = '<media-tag src="' + src + '" data-crypto-key="cryptpad:' + key + '"></media-tag>';
|
||||
editor.replaceSelection(mt);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -11,6 +11,7 @@ define([
|
|||
var uint8ArrayToHex = Util.uint8ArrayToHex;
|
||||
var hexToBase64 = Util.hexToBase64;
|
||||
var base64ToHex = Util.base64ToHex;
|
||||
Hash.encodeBase64 = Nacl.util.encodeBase64;
|
||||
|
||||
// This implementation must match that on the server
|
||||
// it's used for a checksum
|
||||
|
@ -59,6 +60,11 @@ define([
|
|||
return '/1/' + hexToBase64(secret.channel) + '/' +
|
||||
Crypto.b64RemoveSlashes(data.fileKeyStr) + '/';
|
||||
}
|
||||
if (version === 2) {
|
||||
if (!data.fileKeyStr) { return; }
|
||||
var pass = secret.password ? 'p/' : '';
|
||||
return '/2/' + secret.type + '/' + Crypto.b64RemoveSlashes(data.fileKeyStr) + '/' + pass;
|
||||
}
|
||||
};
|
||||
|
||||
// V1
|
||||
|
@ -95,12 +101,22 @@ define([
|
|||
};
|
||||
|
||||
Hash.createRandomHash = function (type, password) {
|
||||
var cryptor = Crypto.createEditCryptor2(void 0, void 0, password);
|
||||
var cryptor;
|
||||
if (type === 'file') {
|
||||
cryptor = Crypto.createFileCryptor2(void 0, password);
|
||||
return getFileHashFromKeys({
|
||||
password: Boolean(password),
|
||||
version: 2,
|
||||
type: type,
|
||||
keys: cryptor.fileKeyStr
|
||||
});
|
||||
}
|
||||
cryptor = Crypto.createEditCryptor2(void 0, void 0, password);
|
||||
return getEditHashFromKeys({
|
||||
password: Boolean(password),
|
||||
version: 2,
|
||||
type: type,
|
||||
keys: { editKeyStr: cryptor.editKeyStr }
|
||||
keys: cryptor.editKeyStr
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -113,6 +129,7 @@ Version 1
|
|||
|
||||
var parseTypeHash = Hash.parseTypeHash = function (type, hash) {
|
||||
if (!hash) { return; }
|
||||
var options;
|
||||
var parsed = {};
|
||||
var hashArr = fixDuplicateSlashes(hash).split('/');
|
||||
if (['media', 'file', 'user', 'invite'].indexOf(type) === -1) {
|
||||
|
@ -125,7 +142,6 @@ Version 1
|
|||
parsed.version = 0;
|
||||
return parsed;
|
||||
}
|
||||
var options;
|
||||
if (hashArr[1] && hashArr[1] === '1') { // Version 1
|
||||
parsed.version = 1;
|
||||
parsed.mode = hashArr[2];
|
||||
|
@ -175,6 +191,25 @@ Version 1
|
|||
parsed.key = hashArr[3].replace(/-/g, '/');
|
||||
return parsed;
|
||||
}
|
||||
if (hashArr[1] && hashArr[1] === '2') { // Version 2
|
||||
parsed.version = 2;
|
||||
parsed.app = hashArr[2];
|
||||
parsed.key = hashArr[3];
|
||||
|
||||
options = hashArr.slice(4);
|
||||
parsed.password = options.indexOf('p') !== -1;
|
||||
parsed.present = options.indexOf('present') !== -1;
|
||||
parsed.embed = options.indexOf('embed') !== -1;
|
||||
|
||||
parsed.getHash = function (opts) {
|
||||
var hash = hashArr.slice(0, 4).join('/') + '/';
|
||||
if (parsed.password) { hash += 'p/'; }
|
||||
if (opts.embed) { hash += 'embed/'; }
|
||||
if (opts.present) { hash += 'present/'; }
|
||||
return hash;
|
||||
};
|
||||
return parsed;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
if (['user'].indexOf(type) !== -1) {
|
||||
|
@ -309,11 +344,12 @@ Version 1
|
|||
}
|
||||
}
|
||||
} else if (parsed.type === "file") {
|
||||
// version 2 hashes are to be used for encrypted blobs
|
||||
secret.channel = parsed.channel;
|
||||
secret.keys = { fileKeyStr: parsed.key };
|
||||
secret.channel = base64ToHex(parsed.channel);
|
||||
secret.keys = {
|
||||
fileKeyStr: parsed.key,
|
||||
cryptKey: Nacl.util.decodeBase64(parsed.key)
|
||||
};
|
||||
} else if (parsed.type === "user") {
|
||||
// version 2 hashes are to be used for encrypted blobs
|
||||
throw new Error("User hashes can't be opened (yet)");
|
||||
}
|
||||
} else if (parsed.version === 2) {
|
||||
|
@ -338,7 +374,12 @@ Version 1
|
|||
}
|
||||
}
|
||||
} else if (parsed.type === "file") {
|
||||
throw new Error("File hashes should be version 1");
|
||||
secret.channel = base64ToHex(secret.keys.chanId);
|
||||
secret.keys = Crypto.createFileCryptor2(parsed.key, password);
|
||||
secret.key = secret.keys.fileKeyStr;
|
||||
if (secret.channel.length !== 48 || secret.key.length !== 24) {
|
||||
throw new Error("The channel key and/or the encryption key is invalid");
|
||||
}
|
||||
} else if (parsed.type === "user") {
|
||||
throw new Error("User hashes can't be opened (yet)");
|
||||
}
|
||||
|
|
|
@ -250,17 +250,15 @@ define([
|
|||
var k = getKey(parsed.type, channel);
|
||||
common.setThumbnail(k, b64, cb);
|
||||
};
|
||||
Thumb.displayThumbnail = function (common, href, channel, $container, cb) {
|
||||
Thumb.displayThumbnail = function (common, href, channel, password, $container, cb) {
|
||||
cb = cb || function () {};
|
||||
var parsed = Hash.parsePadUrl(href);
|
||||
var k = getKey(parsed.type, channel);
|
||||
var whenNewThumb = function () {
|
||||
// PASSWORD_FILES
|
||||
var secret = Hash.getSecrets('file', parsed.hash);
|
||||
var hexFileName = Util.base64ToHex(secret.channel);
|
||||
var secret = Hash.getSecrets('file', parsed.hash, password);
|
||||
var hexFileName = channel;
|
||||
var src = Hash.getBlobPathFromHex(hexFileName);
|
||||
var cryptKey = secret.keys && secret.keys.fileKeyStr;
|
||||
var key = Nacl.util.decodeBase64(cryptKey);
|
||||
var key = secret.keys && secret.keys.cryptKey;
|
||||
FileCrypto.fetchDecryptedMetadata(src, key, function (e, metadata) {
|
||||
if (e) {
|
||||
if (e === 'XHR_ERROR') { return; }
|
||||
|
|
|
@ -1169,8 +1169,8 @@ define([
|
|||
// No password for avatars
|
||||
var secret = Hash.getSecrets('file', parsed.hash);
|
||||
if (secret.keys && secret.channel) {
|
||||
var cryptKey = secret.keys && secret.keys.fileKeyStr;
|
||||
var hexFileName = Util.base64ToHex(secret.channel);
|
||||
var hexFileName = secret.channel;
|
||||
var cryptKey = Hash.encodeBase64(secret.keys && secret.keys.cryptKey);
|
||||
var src = Hash.getBlobPathFromHex(hexFileName);
|
||||
Common.getFileSize(hexFileName, function (e, data) {
|
||||
if (e || !data) {
|
||||
|
|
|
@ -578,7 +578,6 @@ define([
|
|||
}
|
||||
var parsed = Hash.parsePadUrl(window.location.href);
|
||||
if (!parsed.type || !parsed.hashData) { return void cb('E_INVALID_HREF'); }
|
||||
if (parsed.type === 'file' && typeof(parsed.channel) === 'string') { secret.channel = Util.base64ToHex(secret.channel); }
|
||||
hashes = Hash.getHashes(secret);
|
||||
|
||||
if (secret.version === 0) {
|
||||
|
|
|
@ -41,11 +41,15 @@ define([
|
|||
};
|
||||
renderer.image = function (href, title, text) {
|
||||
if (href.slice(0,6) === '/file/') {
|
||||
// PASSWORD_FILES
|
||||
// DEPRECATED
|
||||
// Mediatag using markdown syntax should not be used anymore so they don't support
|
||||
// password-protected files
|
||||
console.log('DEPRECATED: mediatag using markdown syntax!');
|
||||
var parsed = Hash.parsePadUrl(href);
|
||||
var hexFileName = Util.base64ToHex(parsed.hashData.channel);
|
||||
var src = '/blob/' + hexFileName.slice(0,2) + '/' + hexFileName;
|
||||
var mt = '<media-tag src="' + src + '" data-crypto-key="cryptpad:' + parsed.hashData.key + '">';
|
||||
var secret = Hash.getSecrets('file', parsed.hash);
|
||||
var src = Hash.getBlobPathFromHex(secret.channel);
|
||||
var key = Hash.encodeBase64(secret.keys.cryptKey);
|
||||
var mt = '<media-tag src="' + src + '" data-crypto-key="cryptpad:' + key + '"></media-tag>';
|
||||
if (mediaMap[src]) {
|
||||
mt += mediaMap[src];
|
||||
}
|
||||
|
|
|
@ -115,13 +115,8 @@ define([
|
|||
parsed = Hash.parsePadUrl(el.href);
|
||||
if (!el.href) { return; }
|
||||
if (!el.channel) {
|
||||
if (parsed.hashData && parsed.hashData.type === "file") {
|
||||
// PASSWORD_FILES
|
||||
el.channel = Util.base64ToHex(parsed.hashData.channel);
|
||||
} else {
|
||||
var secret = Hash.getSecrets(parsed.type, parsed.hash, el.password);
|
||||
el.channel = secret.channel;
|
||||
}
|
||||
var secret = Hash.getSecrets(parsed.type, parsed.hash, el.password);
|
||||
el.channel = secret.channel;
|
||||
progress(6, Math.round(100*i/padsLength));
|
||||
console.log('Adding missing channel in filesData ', el.channel);
|
||||
}
|
||||
|
|
|
@ -92,7 +92,7 @@ define([
|
|||
var profileChan = profile.edit ? Hash.hrefToHexChannelId('/profile/#' + profile.edit, null) : null;
|
||||
if (profileChan) { list.push(profileChan); }
|
||||
var avatarChan = profile.avatar ? Hash.hrefToHexChannelId(profile.avatar, null) : null;
|
||||
if (avatarChan) { list.push(Util.base64ToHex(avatarChan)); }
|
||||
if (avatarChan) { list.push(avatarChan); }
|
||||
}
|
||||
|
||||
if (store.proxy.friends) {
|
||||
|
|
|
@ -13,7 +13,16 @@ define([
|
|||
// if it exists, path contains the new pad location in the drive
|
||||
var path = file.path;
|
||||
|
||||
var key = Nacl.randomBytes(32);
|
||||
// XXX
|
||||
// PASSWORD_FILES
|
||||
var password;
|
||||
var hash = Hash.createRandomHash('file', password);
|
||||
var secret = Hash.getSecrets('file', hash, password);
|
||||
var key = secret.keys.cryptKey;
|
||||
var id = secret.channel;
|
||||
//var key = Nacl.randomBytes(32);
|
||||
|
||||
// XXX provide channel id to "next"
|
||||
var next = FileCrypto.encrypt(u8, metadata, key);
|
||||
|
||||
var estimate = FileCrypto.computeEncryptedSize(u8.length, metadata);
|
||||
|
@ -44,21 +53,11 @@ define([
|
|||
}
|
||||
|
||||
// if not box then done
|
||||
common.uploadComplete(function (e, id) {
|
||||
common.uploadComplete(function (e/*, id*/) { // XXX id is given, not asked
|
||||
if (e) { return void console.error(e); }
|
||||
var uri = ['', 'blob', id.slice(0,2), id].join('/');
|
||||
console.log("encrypted blob is now available as %s", uri);
|
||||
|
||||
var b64Key = Nacl.util.encodeBase64(key);
|
||||
|
||||
var secret = {
|
||||
version: 1,
|
||||
channel: id,
|
||||
keys: {
|
||||
fileKeyStr: b64Key
|
||||
}
|
||||
};
|
||||
var hash = Hash.getFileHashFromKeys(secret);
|
||||
var href = '/file/#' + hash;
|
||||
|
||||
var title = metadata.name;
|
||||
|
|
|
@ -589,14 +589,9 @@ define([
|
|||
// Fix channel
|
||||
if (!el.channel) {
|
||||
try {
|
||||
if (parsed.hashData && parsed.hashData.type === "file") {
|
||||
// PASSWORD_FILES
|
||||
el.channel = Util.base64ToHex(parsed.hashData.channel);
|
||||
} else {
|
||||
var secret = Hash.getSecrets(parsed.type, parsed.hash, el.password);
|
||||
el.channel = secret.channel;
|
||||
}
|
||||
console.log('Adding missing channel in filesData ', el.channel);
|
||||
console.log('Adding missing channel in filesData ', el.channel);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
|
|
@ -329,15 +329,11 @@ define([
|
|||
dropArea: $('.CodeMirror'),
|
||||
body: $('body'),
|
||||
onUploaded: function (ev, data) {
|
||||
//var cursor = editor.getCursor();
|
||||
//var cleanName = data.name.replace(/[\[\]]/g, '');
|
||||
//var text = '!['+cleanName+']('+data.url+')';
|
||||
// PASSWORD_FILES
|
||||
var parsed = Hash.parsePadUrl(data.url);
|
||||
var hexFileName = Util.base64ToHex(parsed.hashData.channel);
|
||||
var src = '/blob/' + hexFileName.slice(0,2) + '/' + hexFileName;
|
||||
var mt = '<media-tag src="' + src + '" data-crypto-key="cryptpad:' +
|
||||
parsed.hashData.key + '"></media-tag>';
|
||||
var secret = Hash.getSecrets('file', parsed.hash, data.password);
|
||||
var src = Hash.getBlobPathFromHex(secret.channel);
|
||||
var key = Hash.encodeBase64(secret.keys.cryptKey);
|
||||
var mt = '<media-tag src="' + src + '" data-crypto-key="cryptpad:' + key + '"></media-tag>';
|
||||
editor.replaceSelection(mt);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -113,16 +113,16 @@ define([
|
|||
return '<script src="' + origin + '/common/media-tag-nacl.min.js"></script>';
|
||||
};
|
||||
funcs.getMediatagFromHref = function (href) {
|
||||
// PASSWORD_FILES
|
||||
var parsed = Hash.parsePadUrl(href);
|
||||
var secret = Hash.getSecrets('file', parsed.hash);
|
||||
// XXX: Should only be used with the current href
|
||||
var data = ctx.metadataMgr.getPrivateData();
|
||||
var parsed = Hash.parsePadUrl(href);
|
||||
var secret = Hash.getSecrets('file', parsed.hash, data.password);
|
||||
if (secret.keys && secret.channel) {
|
||||
var cryptKey = secret.keys && secret.keys.fileKeyStr;
|
||||
var hexFileName = Util.base64ToHex(secret.channel);
|
||||
var key = Hash.encodeBase64(secret.keys && secret.keys.cryptKey);
|
||||
var hexFileName = secret.channel;
|
||||
var origin = data.fileHost || data.origin;
|
||||
var src = origin + Hash.getBlobPathFromHex(hexFileName);
|
||||
return '<media-tag src="' + src + '" data-crypto-key="cryptpad:' + cryptKey + '">' +
|
||||
return '<media-tag src="' + src + '" data-crypto-key="cryptpad:' + key + '">' +
|
||||
'</media-tag>';
|
||||
}
|
||||
return;
|
||||
|
|
|
@ -590,7 +590,6 @@ define([
|
|||
}
|
||||
}, cb);
|
||||
}
|
||||
console.log(path, newName);
|
||||
if (path.length <= 1) {
|
||||
logError('Renaming `root` is forbidden');
|
||||
return;
|
||||
|
|
|
@ -1305,7 +1305,7 @@ define([
|
|||
$span.attr('title', name);
|
||||
|
||||
var type = Messages.type[hrefData.type] || hrefData.type;
|
||||
common.displayThumbnail(data.href, data.channel, $span, function ($thumb) {
|
||||
common.displayThumbnail(data.href, data.channel, data.password, $span, function ($thumb) {
|
||||
// Called only if the thumbnail exists
|
||||
// Remove the .hide() added by displayThumnail() because it hides the icon in
|
||||
// list mode too
|
||||
|
|
|
@ -54,17 +54,14 @@ define([
|
|||
|
||||
var uploadMode = false;
|
||||
var secret;
|
||||
var hexFileName;
|
||||
var metadataMgr = common.getMetadataMgr();
|
||||
var priv = metadataMgr.getPrivateData();
|
||||
|
||||
if (!priv.filehash) {
|
||||
uploadMode = true;
|
||||
} else {
|
||||
// PASSWORD_FILES
|
||||
secret = Hash.getSecrets('file', priv.filehash);
|
||||
secret = Hash.getSecrets('file', priv.filehash, priv.password);
|
||||
if (!secret.keys) { throw new Error("You need a hash"); }
|
||||
hexFileName = Util.base64ToHex(secret.channel);
|
||||
}
|
||||
|
||||
var Title = common.createTitle({});
|
||||
|
@ -87,9 +84,10 @@ define([
|
|||
toolbar.$rightside.html('');
|
||||
|
||||
if (!uploadMode) {
|
||||
var hexFileName = secret.channel;
|
||||
var src = Hash.getBlobPathFromHex(hexFileName);
|
||||
var cryptKey = secret.keys && secret.keys.fileKeyStr;
|
||||
var key = Nacl.util.decodeBase64(cryptKey);
|
||||
var key = secret.keys && secret.keys.cryptKey;
|
||||
var cryptKey = Nacl.util.encodeBase64(key);
|
||||
|
||||
FileCrypto.fetchDecryptedMetadata(src, key, function (e, metadata) {
|
||||
if (e) {
|
||||
|
@ -118,9 +116,7 @@ define([
|
|||
};
|
||||
|
||||
var $mt = $dlview.find('media-tag');
|
||||
var cryptKey = secret.keys && secret.keys.fileKeyStr;
|
||||
var hexFileName = Util.base64ToHex(secret.channel);
|
||||
$mt.attr('src', '/blob/' + hexFileName.slice(0,2) + '/' + hexFileName);
|
||||
$mt.attr('src', src);
|
||||
$mt.attr('data-crypto-key', 'cryptpad:'+cryptKey);
|
||||
|
||||
var rightsideDisplayed = false;
|
||||
|
@ -263,7 +259,7 @@ define([
|
|||
dropArea: $form,
|
||||
hoverArea: $label,
|
||||
body: $body,
|
||||
keepTable: true // Don't fadeOut the tbale with the uploaded files
|
||||
keepTable: true // Don't fadeOut the table with the uploaded files
|
||||
};
|
||||
|
||||
var FM = common.createFileManager(fmConfig);
|
||||
|
|
|
@ -40,14 +40,14 @@ define([
|
|||
var parsed = Hash.parsePadUrl(data.url);
|
||||
hideFileDialog();
|
||||
if (parsed.type === 'file') {
|
||||
// PASSWORD_FILES
|
||||
var hexFileName = Util.base64ToHex(parsed.hashData.channel);
|
||||
var src = '/blob/' + hexFileName.slice(0,2) + '/' + hexFileName;
|
||||
var secret = Hash.getSecrets('file', parsed.hash, data.password);
|
||||
var src = Hash.getBlobPathFromHex(secret.channel);
|
||||
var key = Hash.encodeBase64(secret.keys.cryptKey);
|
||||
sframeChan.event("EV_FILE_PICKED", {
|
||||
type: parsed.type,
|
||||
src: src,
|
||||
name: data.name,
|
||||
key: parsed.hashData.key
|
||||
key: key
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
@ -69,8 +69,8 @@ define([
|
|||
APP.FM = common.createFileManager(fmConfig);
|
||||
|
||||
// Create file picker
|
||||
var onSelect = function (url, name) {
|
||||
onFilePicked({url: url, name: name});
|
||||
var onSelect = function (url, name, password) {
|
||||
onFilePicked({url: url, name: name, password: password});
|
||||
};
|
||||
var data = {
|
||||
FM: APP.FM
|
||||
|
@ -135,11 +135,13 @@ define([
|
|||
$('<span>', {'class': 'cp-filepicker-content-element-name'}).text(name)
|
||||
.appendTo($span);
|
||||
$span.click(function () {
|
||||
if (typeof onSelect === "function") { onSelect(data.href, name); }
|
||||
if (typeof onSelect === "function") {
|
||||
onSelect(data.href, name, data.password);
|
||||
}
|
||||
});
|
||||
|
||||
// Add thumbnail if it exists
|
||||
common.displayThumbnail(data.href, data.channel, $span);
|
||||
common.displayThumbnail(data.href, data.channel, data.password, $span);
|
||||
});
|
||||
$input.focus();
|
||||
};
|
||||
|
|
|
@ -552,11 +552,11 @@ define([
|
|||
ckeditor: editor,
|
||||
body: $('body'),
|
||||
onUploaded: function (ev, data) {
|
||||
// PASSWORD_FILES
|
||||
var parsed = Hash.parsePadUrl(data.url);
|
||||
var hexFileName = Util.base64ToHex(parsed.hashData.channel);
|
||||
var src = '/blob/' + hexFileName.slice(0,2) + '/' + hexFileName;
|
||||
var mt = '<media-tag contenteditable="false" src="' + src + '" data-crypto-key="cryptpad:' + parsed.hashData.key + '" tabindex="1"></media-tag>';
|
||||
var secret = Hash.getSecrets('file', parsed.hash, data.password);
|
||||
var src = Hash.getBlobPathFromHex(secret.channel);
|
||||
var key = Hash.encodeBase64(secret.keys.cryptKey);
|
||||
var mt = '<media-tag contenteditable="false" src="' + src + '" data-crypto-key="cryptpad:' + key + '"></media-tag>';
|
||||
// MEDIATAG
|
||||
var element = window.CKEDITOR.dom.element.createFromHtml(mt);
|
||||
editor.insertElement(element);
|
||||
|
|
|
@ -500,11 +500,11 @@ define([
|
|||
dropArea: $('.CodeMirror'),
|
||||
body: $('body'),
|
||||
onUploaded: function (ev, data) {
|
||||
// PASSWORD_FILES
|
||||
var parsed = Hash.parsePadUrl(data.url);
|
||||
var hexFileName = Util.base64ToHex(parsed.hashData.channel);
|
||||
var src = '/blob/' + hexFileName.slice(0,2) + '/' + hexFileName;
|
||||
var mt = '<media-tag src="' + src + '" data-crypto-key="cryptpad:' + parsed.hashData.key + '"></media-tag>';
|
||||
var secret = Hash.getSecrets('file', parsed.hash, data.password);
|
||||
var src = Hash.getBlobPathFromHex(secret.channel);
|
||||
var key = Hash.encodeBase64(secret.keys.cryptKey);
|
||||
var mt = '<media-tag src="' + src + '" data-crypto-key="cryptpad:' + key + '"></media-tag>';
|
||||
editor.replaceSelection(mt);
|
||||
}
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue