Merge branch 'staging' of github.com:xwiki-labs/cryptpad into staging

pull/1/head
yflory 8 years ago
commit b76dcba1d6

@ -13,7 +13,8 @@ var RPC = module.exports;
var Store = require("./storage/file");
var isValidChannel = function (chan) {
return /^[a-fA-F0-9]/.test(chan);
return /^[a-fA-F0-9]/.test(chan) ||
[32, 48].indexOf(chan.length) !== -1;
};
var uint8ArrayToHex = function (a) {
@ -33,10 +34,10 @@ var uint8ArrayToHex = function (a) {
}).join('');
};
var createChannelId = function () {
var id = uint8ArrayToHex(Nacl.randomBytes(16));
if (id.length !== 32 || /[^a-f0-9]/.test(id)) {
throw new Error('channel ids must consist of 32 hex characters');
var createFileId = function () {
var id = uint8ArrayToHex(Nacl.randomBytes(24));
if (id.length !== 48 || /[^a-f0-9]/.test(id)) {
throw new Error('file ids must consist of 48 hex characters');
}
return id;
};
@ -245,14 +246,32 @@ var getChannelList = function (store, Sessions, publicKey, cb) {
});
};
var getUploadSize = function (store, channel, cb) {
var path = '';
Fs.stat(path, function (err, stats) {
if (err) { return void cb(err); }
cb(void 0, stats.size);
});
};
var getFileSize = function (store, channel, cb) {
if (!isValidChannel(channel)) { return void cb('INVALID_CHAN'); }
if (typeof(store.getChannelSize) !== 'function') {
return cb('GET_CHANNEL_SIZE_UNSUPPORTED');
if (channel.length === 32) {
if (typeof(store.getChannelSize) !== 'function') {
return cb('GET_CHANNEL_SIZE_UNSUPPORTED');
}
return void store.getChannelSize(channel, function (e, size) {
if (e) { return void cb(e.code); }
cb(void 0, size);
});
}
return void store.getChannelSize(channel, function (e, size) {
if (e) { return void cb(e.code); }
// 'channel' refers to a file, so you need anoter API
getUploadSize(null, channel, function (e, size) {
if (e) { return void cb(e); }
cb(void 0, size);
});
};
@ -332,6 +351,7 @@ var getHash = function (store, Sessions, publicKey, cb) {
store.message(publicKey, JSON.stringify(msg), cb);
}; */
// TODO check if new pinned size exceeds user quota
var pinChannel = function (store, Sessions, publicKey, channels, cb) {
if (!channels && channels.filter) {
// expected array
@ -383,7 +403,7 @@ var unpinChannel = function (store, Sessions, publicKey, channels, cb) {
function (e) {
if (e) { return void cb(e); }
toStore.forEach(function (channel) {
delete session.channels[channel]; // = false;
delete session.channels[channel];
});
getHash(store, Sessions, publicKey, cb);
@ -391,6 +411,7 @@ var unpinChannel = function (store, Sessions, publicKey, channels, cb) {
});
};
// TODO check if new pinned size exceeds user quota
var resetUserPins = function (store, Sessions, publicKey, channelList, cb) {
var session = beginSession(Sessions, publicKey);
@ -469,13 +490,13 @@ var makeFileStream = function (root, id, cb) {
});
};
var upload = function (stagingPath, Sessions, publicKey, content, cb) {
var upload = function (paths, Sessions, publicKey, content, cb) {
var dec = new Buffer(Nacl.util.decodeBase64(content)); // jshint ignore:line
var session = Sessions[publicKey];
session.atime = +new Date();
if (!session.blobstage) {
makeFileStream(stagingPath, publicKey, function (e, stream) {
makeFileStream(paths.staging, publicKey, function (e, stream) {
if (e) { return void cb(e); }
var blobstage = session.blobstage = stream;
@ -488,10 +509,10 @@ var upload = function (stagingPath, Sessions, publicKey, content, cb) {
}
};
var upload_cancel = function (stagingPath, Sessions, publicKey, cb) {
var path = makeFilePath(stagingPath, publicKey);
var upload_cancel = function (paths, Sessions, publicKey, cb) {
var path = makeFilePath(paths.staging, publicKey);
if (!path) {
console.log(stagingPath, publicKey);
console.log(paths.staging, publicKey);
console.log(path);
return void cb('NO_FILE');
}
@ -512,7 +533,13 @@ var isFile = function (filePath, cb) {
});
};
var upload_complete = function (stagingPath, storePath, Sessions, publicKey, cb) {
/* TODO
change channel IDs to a different length so that when we pin, we will be able
to tell that it is not a channel, but a file, just by its length.
also, when your upload is complete, pin the resulting file.
*/
var upload_complete = function (paths, Sessions, publicKey, cb) {
var session = Sessions[publicKey];
if (session.blobstage && session.blobstage.close) {
@ -520,14 +547,14 @@ var upload_complete = function (stagingPath, storePath, Sessions, publicKey, cb)
delete session.blobstage;
}
var oldPath = makeFilePath(stagingPath, publicKey);
var oldPath = makeFilePath(paths.staging, publicKey);
var tryRandomLocation = function (cb) {
var id = createChannelId();
var id = createFileId();
var prefix = id.slice(0, 2);
var newPath = makeFilePath(storePath, id);
var newPath = makeFilePath(paths.blob, id);
safeMkdir(Path.join(storePath, prefix), function (e) {
safeMkdir(Path.join(paths.blob, prefix), function (e) {
if (e) {
console.error(e);
return void cb('RENAME_ERR');
@ -558,8 +585,14 @@ var upload_complete = function (stagingPath, storePath, Sessions, publicKey, cb)
});
};
var upload_status = function (stagingPath, Sessions, publicKey, cb) {
var filePath = makeFilePath(stagingPath, publicKey);
/* TODO
when asking about your upload status, also send some information about how big
your upload is going to be. if that would exceed your limit, return TOO_LARGE
error.
*/
var upload_status = function (paths, Sessions, publicKey, cb) {
var filePath = makeFilePath(paths.staging, publicKey);
if (!filePath) { return void cb('E_INVALID_PATH'); }
isFile(filePath, function (e, yes) {
cb(e, yes);
@ -577,9 +610,10 @@ RPC.create = function (config /*:typeof(ConfigType)*/, cb /*:(?Error, ?Function)
return typeof(config[key]) === 'string'? config[key]: def;
};
var pinPath = keyOrDefaultString('pinPath', './pins');
var blobPath = keyOrDefaultString('blobPath', './blob');
var blobStagingPath = keyOrDefaultString('blobStagingPath', './blobstage');
var paths = {};
var pinPath = paths.pin = keyOrDefaultString('pinPath', './pins');
var blobPath = paths.blob = keyOrDefaultString('blobPath', './blob');
var blobStagingPath = paths.staging = keyOrDefaultString('blobStagingPath', './blobstage');
var store;
@ -695,22 +729,22 @@ RPC.create = function (config /*:typeof(ConfigType)*/, cb /*:(?Error, ?Function)
// restricted to privileged users...
case 'UPLOAD':
if (!privileged) { return deny(); }
return void upload(blobStagingPath, Sessions, safeKey, msg[1], function (e, len) {
return void upload(paths, Sessions, safeKey, msg[1], function (e, len) {
Respond(e, len);
});
case 'UPLOAD_STATUS':
if (!privileged) { return deny(); }
return void upload_status(blobStagingPath, Sessions, safeKey, function (e, stat) {
return void upload_status(paths, Sessions, safeKey, function (e, stat) {
Respond(e, stat);
});
case 'UPLOAD_COMPLETE':
if (!privileged) { return deny(); }
return void upload_complete(blobStagingPath, blobPath, Sessions, safeKey, function (e, hash) {
return void upload_complete(paths, Sessions, safeKey, function (e, hash) {
Respond(e, hash);
});
case 'UPLOAD_CANCEL':
if (!privileged) { return deny(); }
return void upload_cancel(blobStagingPath, Sessions, safeKey, function (e) {
return void upload_cancel(paths, Sessions, safeKey, function (e) {
Respond(e);
});
default:

@ -15,26 +15,40 @@ define([
var failMessages = [];
var ASSERTS = [];
var runASSERTS = function () {
var runASSERTS = function (cb) {
var count = ASSERTS.length;
var successes = 0;
var done = function (err) {
count--;
if (err) { failMessages.push(err); }
else { successes++; }
if (count === 0) { cb(); }
};
ASSERTS.forEach(function (f, index) {
f(index);
f(function (err) {
done(err, index);
}, index);
});
};
var assert = function (test, msg) {
ASSERTS.push(function (i) {
var returned = test();
if (returned === true) {
assertions++;
} else {
failed = true;
failedOn = assertions;
failMessages.push({
test: i,
message: msg,
output: returned,
});
}
ASSERTS.push(function (cb, i) {
test(function (result) {
if (result === true) {
assertions++;
cb();
} else {
failed = true;
failedOn = assertions;
cb({
test: i,
message: msg,
output: result,
});
}
});
});
};
@ -58,7 +72,7 @@ define([
};
var HJSON_equal = function (shjson) {
assert(function () {
assert(function (cb) {
// parse your stringified Hyperjson
var hjson;
@ -82,10 +96,10 @@ define([
var diff = TextPatcher.format(shjson, op);
if (success) {
return true;
return cb(true);
} else {
return '<br><br>insert: ' + diff.insert + '<br><br>' +
'remove: ' + diff.remove + '<br><br>';
return cb('<br><br>insert: ' + diff.insert + '<br><br>' +
'remove: ' + diff.remove + '<br><br>');
}
}, "expected hyperjson equality");
};
@ -94,7 +108,7 @@ define([
var roundTrip = function (sel) {
var target = $(sel)[0];
assert(function () {
assert(function (cb) {
var hjson = Hyperjson.fromDOM(target);
var cloned = Hyperjson.toDOM(hjson);
var success = cloned.outerHTML === target.outerHTML;
@ -111,7 +125,7 @@ define([
TextPatcher.log(target.outerHTML, op);
}
return success;
return cb(success);
}, "Round trip serialization introduced artifacts.");
};
@ -125,9 +139,9 @@ define([
var strungJSON = function (orig) {
var result;
assert(function () {
assert(function (cb) {
result = JSON.stringify(JSON.parse(orig));
return result === orig;
return cb(result === orig);
}, "expected result (" + result + ") to equal original (" + orig + ")");
};
@ -138,46 +152,56 @@ define([
});
// check that old hashes parse correctly
assert(function () {
assert(function (cb) {
var secret = Cryptpad.parseHash('67b8385b07352be53e40746d2be6ccd7XAYSuJYYqa9NfmInyHci7LNy');
return secret.channel === "67b8385b07352be53e40746d2be6ccd7" &&
return cb(secret.channel === "67b8385b07352be53e40746d2be6ccd7" &&
secret.key === "XAYSuJYYqa9NfmInyHci7LNy" &&
secret.version === 0;
secret.version === 0);
}, "Old hash failed to parse");
// make sure version 1 hashes parse correctly
assert(function () {
assert(function (cb) {
var secret = Cryptpad.parseHash('/1/edit/3Ujt4F2Sjnjbis6CoYWpoQ/usn4+9CqVja8Q7RZOGTfRgqI');
return secret.version === 1 &&
return cb(secret.version === 1 &&
secret.mode === "edit" &&
secret.channel === "3Ujt4F2Sjnjbis6CoYWpoQ" &&
secret.key === "usn4+9CqVja8Q7RZOGTfRgqI" &&
!secret.present;
!secret.present);
}, "version 1 hash failed to parse");
// test support for present mode in hashes
assert(function () {
assert(function (cb) {
var secret = Cryptpad.parseHash('/1/edit/CmN5+YJkrHFS3NSBg-P7Sg/DNZ2wcG683GscU4fyOyqA87G/present');
return secret.version === 1
return cb(secret.version === 1
&& secret.mode === "edit"
&& secret.channel === "CmN5+YJkrHFS3NSBg-P7Sg"
&& secret.key === "DNZ2wcG683GscU4fyOyqA87G"
&& secret.present;
&& secret.present);
}, "version 1 hash failed to parse");
// test support for present mode in hashes
assert(function (cb) {
var secret = Cryptpad.parseHash('/1/edit//CmN5+YJkrHFS3NSBg-P7Sg/DNZ2wcG683GscU4fyOyqA87G//present');
return cb(secret.version === 1
&& secret.mode === "edit"
&& secret.channel === "CmN5+YJkrHFS3NSBg-P7Sg"
&& secret.key === "DNZ2wcG683GscU4fyOyqA87G"
&& secret.present);
}, "Couldn't handle multiple successive slashes");
// test support for trailing slash
assert(function () {
assert(function (cb) {
var secret = Cryptpad.parseHash('/1/edit/3Ujt4F2Sjnjbis6CoYWpoQ/usn4+9CqVja8Q7RZOGTfRgqI/');
return secret.version === 1 &&
return cb(secret.version === 1 &&
secret.mode === "edit" &&
secret.channel === "3Ujt4F2Sjnjbis6CoYWpoQ" &&
secret.key === "usn4+9CqVja8Q7RZOGTfRgqI" &&
!secret.present;
!secret.present);
}, "test support for trailing slashes in version 1 hash failed to parse");
assert(function () {
assert(function (cb) {
// TODO
return true;
return cb(true);
}, "version 2 hash failed to parse correctly");
var swap = function (str, dict) {
@ -194,7 +218,7 @@ define([
return str || '';
};
var formatFailures = function () {
var formatFailures = function () {
var template = multiline(function () { /*
<p class="error">
Failed on test number {{test}} with error message:
@ -215,16 +239,15 @@ The test returned:
}).join("\n");
};
runASSERTS();
$("body").html(function (i, val) {
var dict = {
previous: val,
totalAssertions: ASSERTS.length,
passedAssertions: assertions,
plural: (assertions === 1? '' : 's'),
failMessages: formatFailures()
};
runASSERTS(function () {
$("body").html(function (i, val) {
var dict = {
previous: val,
totalAssertions: ASSERTS.length,
passedAssertions: assertions,
plural: (assertions === 1? '' : 's'),
failMessages: formatFailures()
};
var SUCCESS = swap(multiline(function(){/*
<div class="report">{{passedAssertions}} / {{totalAssertions}} test{{plural}} passed.
@ -237,12 +260,13 @@ The test returned:
{{previous}}
*/}), dict);
var report = SUCCESS;
var report = SUCCESS;
return report;
});
return report;
});
var $report = $('.report');
$report.addClass(failed?'failure':'success');
var $report = $('.report');
$report.addClass(failed?'failure':'success');
});
});

@ -66,6 +66,10 @@ define([
return '/' + parsed.type + '/#' + parsed.hash;
};
var fixDuplicateSlashes = function (s) {
return s.replace(/\/+/g, '/');
};
/*
* Returns all needed keys for a realtime channel
* - no argument: use the URL hash or create one if it doesn't exist
@ -95,7 +99,7 @@ define([
}
else {
// New hash
var hashArray = hash.split('/');
var hashArray = fixDuplicateSlashes(hash).split('/');
if (hashArray.length < 4) {
Hash.alert("Unable to parse the key");
throw new Error("Unable to parse the key");
@ -179,7 +183,7 @@ Version 2
parsed.version = 0;
return parsed;
}
var hashArr = hash.split('/');
var hashArr = fixDuplicateSlashes(hash).split('/');
if (hashArr[1] && hashArr[1] === '1') {
parsed.version = 1;
parsed.mode = hashArr[2];

@ -57,55 +57,30 @@ define([
}, []));
};
var padChunk = function (A) {
var padding;
if (A.length === plainChunkLength) { return A; }
if (A.length < plainChunkLength) {
padding = new Array(plainChunkLength - A.length).fill(32);
return A.concat(padding);
}
if (A.length > plainChunkLength) {
// how many times larger is it?
var chunks = Math.ceil(A.length / plainChunkLength);
padding = new Array((plainChunkLength * chunks) - A.length).fill(32);
return A.concat(padding);
}
};
var decrypt = function (u8, key, cb) {
var fail = function (e) {
cb(e || "DECRYPTION_ERROR");
};
var nonce = createNonce();
var i = 0;
decodePrefix([]); // TODO
var takeChunk = function () {
var start = i * cypherChunkLength;
var end = start + cypherChunkLength;
i++;
var box = new Uint8Array(u8.subarray(start, end));
// decrypt the chunk
var plaintext = Nacl.secretbox.open(box, nonce, key);
increment(nonce);
return plaintext;
};
var buffer = '';
var prefix = u8.subarray(0, 2);
var metadataLength = decodePrefix(prefix);
var res = {
metadata: undefined,
};
// decrypt metadata
var chunk;
for (; !res.metadata && i * cypherChunkLength < u8.length;) {
chunk = takeChunk();
buffer += Nacl.util.encodeUTF8(chunk);
try {
res.metadata = JSON.parse(buffer);
//console.log(res.metadata);
} catch (e) {
console.log('buffering another chunk for metadata');
}
var metaBox = new Uint8Array(u8.subarray(2, 2 + metadataLength));
var metaChunk = Nacl.secretbox.open(metaBox, nonce, key);
increment(nonce);
try {
res.metadata = JSON.parse(Nacl.util.encodeUTF8(metaChunk));
} catch (e) {
return fail('E_METADATA_DECRYPTION');
}
if (!res.metadata) {
@ -114,12 +89,21 @@ define([
});
}
var fail = function () {
cb("DECRYPTION_ERROR");
var takeChunk = function () {
var start = i * cypherChunkLength + 2 + metadataLength;
var end = start + cypherChunkLength;
i++;
var box = new Uint8Array(u8.subarray(start, end));
// decrypt the chunk
var plaintext = Nacl.secretbox.open(box, nonce, key);
increment(nonce);
return plaintext;
};
var chunks = [];
// decrypt file contents
var chunk;
for (;i * cypherChunkLength < u8.length;) {
chunk = takeChunk();
if (!chunk) {
@ -139,15 +123,12 @@ define([
var encrypt = function (u8, metadata, key) {
var nonce = createNonce();
encodePrefix(); // TODO
// encode metadata
var metaBuffer = Array.prototype.slice
.call(Nacl.util.decodeUTF8(JSON.stringify(metadata)));
var plaintext = new Uint8Array(padChunk(metaBuffer));
var plaintext = new Uint8Array(metaBuffer);
var j = 0;
var i = 0;
/*
@ -164,22 +145,21 @@ define([
var part;
var box;
if (state === 0) { // metadata...
start = j * plainChunkLength;
end = start + plainChunkLength;
// DONE
if (state === 2) { return void cb(); }
part = plaintext.subarray(start, end);
if (state === 0) { // metadata...
part = new Uint8Array(plaintext);
box = Nacl.secretbox(part, nonce, key);
increment(nonce);
j++;
// metadata is done
if (j * plainChunkLength >= plaintext.length) {
return void cb(state++, box);
if (box.length > 65535) {
return void cb('METADATA_TOO_LARGE');
}
return void cb(state, box);
var prefixed = new Uint8Array(encodePrefix(box.length)
.concat(slice(box)));
state++;
return void cb(void 0, prefixed);
}
// encrypt the rest of the file...
@ -194,7 +174,7 @@ define([
// regular data is done
if (i * plainChunkLength >= u8.length) { state = 2; }
return void cb(state, box);
return void cb(void 0, box);
};
return next;

@ -47,55 +47,43 @@ define([
});
};
var again = function (state, box) {
switch (state) {
case 0:
sendChunk(box, function (e) {
if (e) { return console.error(e); }
next(again);
});
break;
case 1:
sendChunk(box, function (e) {
if (e) { return console.error(e); }
next(again);
});
break;
case 2:
sendChunk(box, function (e) {
if (e) { return console.error(e); }
Cryptpad.rpc.send('UPLOAD_COMPLETE', '', function (e, res) {
if (e) { return void console.error(e); }
var id = res[0];
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);
window.location.hash = Cryptpad.getFileHashFromKeys(id, b64Key);
$form.hide();
APP.toolbar.addElement(['fileshare'], {});
// check if the uploaded file can be decrypted
var newU8 = FileCrypto.joinChunks(chunks);
FileCrypto.decrypt(newU8, key, function (e, res) {
if (e) { return console.error(e); }
var title = document.title = res.metadata.name;
myFile = res.content;
myDataType = res.metadata.type;
var defaultName = Cryptpad.getDefaultName(Cryptpad.parsePadUrl(window.location.href));
Title.updateTitle(title || defaultName);
APP.toolbar.title.show();
Cryptpad.alert("successfully uploaded: " + title);
});
});
});
break;
default:
throw new Error("E_INVAL_STATE");
var again = function (err, box) {
if (err) { throw new Error(err); }
if (box) {
return void sendChunk(box, function (e) {
if (e) { return console.error(e); }
next(again);
});
}
// if not box then done
Cryptpad.rpc.send('UPLOAD_COMPLETE', '', function (e, res) {
if (e) { return void console.error(e); }
var id = res[0];
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);
window.location.hash = Cryptpad.getFileHashFromKeys(id, b64Key);
$form.hide();
APP.toolbar.addElement(['fileshare'], {});
// check if the uploaded file can be decrypted
var newU8 = FileCrypto.joinChunks(chunks);
FileCrypto.decrypt(newU8, key, function (e, res) {
if (e) { return console.error(e); }
var title = document.title = res.metadata.name;
myFile = res.content;
myDataType = res.metadata.type;
var defaultName = Cryptpad.getDefaultName(Cryptpad.parsePadUrl(window.location.href));
Title.updateTitle(title || defaultName);
APP.toolbar.title.show();
Cryptpad.alert("successfully uploaded: " + title);
});
});
};
Cryptpad.rpc.send('UPLOAD_STATUS', '', function (e, pending) {

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html class="cp pad">
<head>
<title>CryptPad</title>
<meta content="text/html; charset=utf-8" http-equiv="content-type"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/bower_components/components-font-awesome/css/font-awesome.min.css">
<script data-bootload="main.js" data-main="/common/boot.js" src="/bower_components/requirejs/require.js"></script>
<link rel="icon" type="image/png"
href="/customize/main-favicon.png"
data-main-favicon="/customize/main-favicon.png"
data-alt-favicon="/customize/alt-favicon.png"
id="favicon" />
<link rel="stylesheet" href="/customize/main.css" />
</head>
<body>

@ -0,0 +1,87 @@
define([
'jquery',
'/bower_components/chainpad-crypto/crypto.js',
'/bower_components/chainpad-netflux/chainpad-netflux.js',
'/common/toolbar.js',
'/common/cryptpad-common.js',
'/common/visible.js',
'/common/notify.js',
'/file/file-crypto.js',
'/bower_components/tweetnacl/nacl-fast.min.js',
'/bower_components/file-saver/FileSaver.min.js',
], function ($, Crypto, realtimeInput, Toolbar, Cryptpad, Visible, Notify, FileCrypto) {
var Nacl = window.nacl;
$(function () {
var filesAreSame = function (a, b) {
var l = a.length;
if (l !== b.length) { return false; }
var i = 0;
for (; i < l; i++) { if (a[i] !== b[i]) { return false; } }
return true;
};
var metadataIsSame = function (A, B) {
return !Object.keys(A).some(function (k) {
return A[k] !== B[k];
});
};
var upload = function (blob, metadata) {
var u8 = new Uint8Array(blob);
var key = Nacl.randomBytes(32);
var next = FileCrypto.encrypt(u8, metadata, key);
var chunks = [];
var sendChunk = function (box, cb) {
chunks.push(box);
cb();
};
var again = function (err, box) {
if (err) { throw new Error(err); }
if (box) {
return void sendChunk(box, function (e) {
if (e) {
console.error(e);
return Cryptpad.alert('Something went wrong');
}
next(again);
});
}
// check if the uploaded file can be decrypted
var newU8 = FileCrypto.joinChunks(chunks);
console.log('encrypted file with metadata is %s uint8s', newU8.length);
FileCrypto.decrypt(newU8, key, function (e, res) {
if (e) { return Cryptpad.alert(e); }
if (filesAreSame(blob, res.content) &&
metadataIsSame(res.metadata, metadata)) {
Cryptpad.alert("successfully uploaded");
} else {
Cryptpad.alert('encryption failure!');
}
});
};
next(again);
};
var andThen = function () {
var src = '/customize/cryptofist_mini.png';
Cryptpad.fetch(src, function (e, file) {
console.log('original file is %s uint8s', file.length);
upload(file, {
pew: 'pew',
bang: 'bang',
});
});
};
andThen();
});
});
Loading…
Cancel
Save