extract all code for encoding and decoding hashes
parent
fdd2716ad5
commit
e618483395
|
@ -3,6 +3,7 @@ define([
|
|||
'/customize/messages.js?app=' + window.location.pathname.split('/').filter(function (x) { return x; }).join('.'),
|
||||
'/common/fsStore.js',
|
||||
'/common/common-util.js',
|
||||
'/common/hash.js',
|
||||
|
||||
'/bower_components/chainpad-crypto/crypto.js?v=0.1.5',
|
||||
'/bower_components/alertifyjs/dist/js/alertify.js',
|
||||
|
@ -12,7 +13,7 @@ load pinpad dynamically only after you know that it will be needed */
|
|||
'/customize/application_config.js',
|
||||
|
||||
'/bower_components/jquery/dist/jquery.min.js',
|
||||
], function (Config, Messages, Store, Util, Crypto, Alertify, Clipboard, Pinpad, AppConfig) {
|
||||
], function (Config, Messages, Store, Util, Hash, Crypto, Alertify, Clipboard, Pinpad, AppConfig) {
|
||||
/* This file exposes functionality which is specific to Cryptpad, but not to
|
||||
any particular pad type. This includes functions for committing metadata
|
||||
about pads to your local storage for future use and improved usability.
|
||||
|
@ -41,6 +42,7 @@ load pinpad dynamically only after you know that it will be needed */
|
|||
var store;
|
||||
var rpc;
|
||||
|
||||
// import common utilities for export
|
||||
var find = common.find = Util.find;
|
||||
var fixHTML = common.fixHTML = Util.fixHTML;
|
||||
var hexToBase64 = common.hexToBase64 = Util.hexToBase64;
|
||||
|
@ -51,6 +53,22 @@ load pinpad dynamically only after you know that it will be needed */
|
|||
var getHash = common.getHash = Util.getHash;
|
||||
var fixFileName = common.fixFileName = Util.fixFileName;
|
||||
|
||||
// import hash utilities for export
|
||||
var createRandomHash = Hash.createRandomHash;
|
||||
var parsePadUrl = common.parsePadUrl = Hash.parsePadUrl;
|
||||
var isNotStrongestStored = common.isNotStrongestStored = Hash.isNotStrongestStored;
|
||||
var hrefToHexChannelId = common.hrefToHexChannelId = Hash.hrefToHexChannelId;
|
||||
var parseHash = common.parseHash = Hash.parseHash;
|
||||
var getRelativeHref = common.getRelativeHref = Hash.getRelativeHref;
|
||||
|
||||
common.getEditHashFromKeys = Hash.getEditHashFromKeys;
|
||||
common.getViewHashFromKeys = Hash.getViewHashFromKeys;
|
||||
common.getSecrets = Hash.getSecrets;
|
||||
common.getHashes = Hash.getHashes;
|
||||
common.createChannelId = Hash.createChannelId;
|
||||
common.findWeaker = Hash.findWeaker;
|
||||
common.findStronger = Hash.findStronger;
|
||||
|
||||
var getStore = common.getStore = function () {
|
||||
if (store) { return store; }
|
||||
throw new Error("Store is not ready!");
|
||||
|
@ -197,134 +215,6 @@ load pinpad dynamically only after you know that it will be needed */
|
|||
return text;
|
||||
};
|
||||
|
||||
var parseHash = common.parseHash = function (hash) {
|
||||
var parsed = {};
|
||||
if (hash.slice(0,1) !== '/' && hash.length >= 56) {
|
||||
// Old hash
|
||||
parsed.channel = hash.slice(0, 32);
|
||||
parsed.key = hash.slice(32);
|
||||
parsed.version = 0;
|
||||
return parsed;
|
||||
}
|
||||
var hashArr = hash.split('/');
|
||||
if (hashArr[1] && hashArr[1] === '1') {
|
||||
parsed.version = 1;
|
||||
parsed.mode = hashArr[2];
|
||||
parsed.channel = hashArr[3];
|
||||
parsed.key = hashArr[4];
|
||||
parsed.present = hashArr[5] && hashArr[5] === 'present';
|
||||
return parsed;
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
// CRYPTO
|
||||
var getEditHashFromKeys = common.getEditHashFromKeys = function (chanKey, keys) {
|
||||
if (typeof keys === 'string') {
|
||||
return chanKey + keys;
|
||||
}
|
||||
if (!keys.editKeyStr) { return; }
|
||||
return '/1/edit/' + hexToBase64(chanKey) + '/' + Crypto.b64RemoveSlashes(keys.editKeyStr);
|
||||
};
|
||||
// CRYPTO
|
||||
var getViewHashFromKeys = common.getViewHashFromKeys = function (chanKey, keys) {
|
||||
if (typeof keys === 'string') {
|
||||
return;
|
||||
}
|
||||
return '/1/view/' + hexToBase64(chanKey) + '/' + Crypto.b64RemoveSlashes(keys.viewKeyStr);
|
||||
};
|
||||
|
||||
/*
|
||||
* Returns all needed keys for a realtime channel
|
||||
* - no argument: use the URL hash or create one if it doesn't exist
|
||||
* - secretHash provided: use secretHash to find the keys
|
||||
*/
|
||||
// CRYPTO
|
||||
var getSecrets = common.getSecrets = function (secretHash) {
|
||||
var secret = {};
|
||||
var generate = function () {
|
||||
secret.keys = Crypto.createEditCryptor();
|
||||
secret.key = Crypto.createEditCryptor().editKeyStr;
|
||||
};
|
||||
if (!secretHash && !/#/.test(window.location.href)) {
|
||||
generate();
|
||||
return secret;
|
||||
} else {
|
||||
var hash = secretHash || window.location.hash.slice(1);
|
||||
if (hash.length === 0) {
|
||||
generate();
|
||||
return secret;
|
||||
}
|
||||
// old hash system : #{hexChanKey}{cryptKey}
|
||||
// new hash system : #/{hashVersion}/{b64ChanKey}/{cryptKey}
|
||||
if (hash.slice(0,1) !== '/' && hash.length >= 56) {
|
||||
// Old hash
|
||||
secret.channel = hash.slice(0, 32);
|
||||
secret.key = hash.slice(32);
|
||||
}
|
||||
else {
|
||||
// New hash
|
||||
var hashArray = hash.split('/');
|
||||
if (hashArray.length < 4) {
|
||||
common.alert("Unable to parse the key");
|
||||
throw new Error("Unable to parse the key");
|
||||
}
|
||||
var version = hashArray[1];
|
||||
if (version === "1") {
|
||||
var mode = hashArray[2];
|
||||
if (mode === 'edit') {
|
||||
secret.channel = base64ToHex(hashArray[3]);
|
||||
var keys = Crypto.createEditCryptor(hashArray[4].replace(/-/g, '/'));
|
||||
secret.keys = keys;
|
||||
secret.key = keys.editKeyStr;
|
||||
if (secret.channel.length !== 32 || secret.key.length !== 24) {
|
||||
common.alert("The channel key and/or the encryption key is invalid");
|
||||
throw new Error("The channel key and/or the encryption key is invalid");
|
||||
}
|
||||
}
|
||||
else if (mode === 'view') {
|
||||
secret.channel = base64ToHex(hashArray[3]);
|
||||
secret.keys = Crypto.createViewCryptor(hashArray[4].replace(/-/g, '/'));
|
||||
if (secret.channel.length !== 32) {
|
||||
common.alert("The channel key is invalid");
|
||||
throw new Error("The channel key is invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return secret;
|
||||
};
|
||||
|
||||
// CRYPTO
|
||||
var getHashes = common.getHashes = function (channel, secret) {
|
||||
var hashes = {};
|
||||
if (secret.keys.editKeyStr) {
|
||||
hashes.editHash = getEditHashFromKeys(channel, secret.keys);
|
||||
}
|
||||
if (secret.keys.viewKeyStr) {
|
||||
hashes.viewHash = getViewHashFromKeys(channel, secret.keys);
|
||||
}
|
||||
return hashes;
|
||||
};
|
||||
|
||||
// CRYPTO
|
||||
var createChannelId = common.createChannelId = function () {
|
||||
var id = uint8ArrayToHex(Crypto.Nacl.randomBytes(16));
|
||||
if (id.length !== 32 || /[^a-f0-9]/.test(id)) {
|
||||
throw new Error('channel ids must consist of 32 hex characters');
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
// CRYPTO
|
||||
var createRandomHash = common.createRandomHash = function () {
|
||||
// 16 byte channel Id
|
||||
var channelId = hexToBase64(createChannelId());
|
||||
// 18 byte encryption key
|
||||
var key = Crypto.b64RemoveSlashes(Crypto.rand64(18));
|
||||
return '/1/edit/' + [channelId, key].join('/');
|
||||
};
|
||||
|
||||
/*
|
||||
* localStorage formatting
|
||||
|
@ -407,36 +297,6 @@ load pinpad dynamically only after you know that it will be needed */
|
|||
});
|
||||
};
|
||||
|
||||
var getRelativeHref = common.getRelativeHref = function (href) {
|
||||
if (!href) { return; }
|
||||
if (href.indexOf('#') === -1) { return; }
|
||||
var parsed = common.parsePadUrl(href);
|
||||
return '/' + parsed.type + '/#' + parsed.hash;
|
||||
};
|
||||
|
||||
var parsePadUrl = common.parsePadUrl = function (href) {
|
||||
var patt = /^https*:\/\/([^\/]*)\/(.*?)\//i;
|
||||
|
||||
var ret = {};
|
||||
|
||||
if (!href) { return ret; }
|
||||
|
||||
if (!/^https*:\/\//.test(href)) {
|
||||
var idx = href.indexOf('/#');
|
||||
ret.type = href.slice(1, idx);
|
||||
ret.hash = href.slice(idx + 2);
|
||||
return ret;
|
||||
}
|
||||
|
||||
var hash = href.replace(patt, function (a, domain, type, hash) {
|
||||
ret.domain = domain;
|
||||
ret.type = type;
|
||||
return '';
|
||||
});
|
||||
ret.hash = hash.replace(/#/g, '');
|
||||
return ret;
|
||||
};
|
||||
|
||||
var isNameAvailable = function (title, parsed, pads) {
|
||||
return !pads.some(function (pad) {
|
||||
// another pad is already using that title
|
||||
|
@ -619,55 +479,6 @@ load pinpad dynamically only after you know that it will be needed */
|
|||
}
|
||||
};
|
||||
|
||||
// STORAGE
|
||||
var findWeaker = common.findWeaker = function (href, recents) {
|
||||
var rHref = href || getRelativeHref(window.location.href);
|
||||
var parsed = parsePadUrl(rHref);
|
||||
if (!parsed.hash) { return false; }
|
||||
var weaker;
|
||||
recents.some(function (pad) {
|
||||
var p = parsePadUrl(pad.href);
|
||||
if (p.type !== parsed.type) { return; } // Not the same type
|
||||
if (p.hash === parsed.hash) { return; } // Same hash, not stronger
|
||||
var pHash = parseHash(p.hash);
|
||||
var parsedHash = parseHash(parsed.hash);
|
||||
if (!parsedHash || !pHash) { return; }
|
||||
if (pHash.version !== parsedHash.version) { return; }
|
||||
if (pHash.channel !== parsedHash.channel) { return; }
|
||||
if (pHash.mode === 'view' && parsedHash.mode === 'edit') {
|
||||
weaker = pad.href;
|
||||
return true;
|
||||
}
|
||||
return;
|
||||
});
|
||||
return weaker;
|
||||
};
|
||||
var findStronger = common.findStronger = function (href, recents) {
|
||||
var rHref = href || getRelativeHref(window.location.href);
|
||||
var parsed = parsePadUrl(rHref);
|
||||
if (!parsed.hash) { return false; }
|
||||
var stronger;
|
||||
recents.some(function (pad) {
|
||||
var p = parsePadUrl(pad.href);
|
||||
if (p.type !== parsed.type) { return; } // Not the same type
|
||||
if (p.hash === parsed.hash) { return; } // Same hash, not stronger
|
||||
var pHash = parseHash(p.hash);
|
||||
var parsedHash = parseHash(parsed.hash);
|
||||
if (!parsedHash || !pHash) { return; }
|
||||
if (pHash.version !== parsedHash.version) { return; }
|
||||
if (pHash.channel !== parsedHash.channel) { return; }
|
||||
if (pHash.mode === 'edit' && parsedHash.mode === 'view') {
|
||||
stronger = pad.href;
|
||||
return true;
|
||||
}
|
||||
return;
|
||||
});
|
||||
return stronger;
|
||||
};
|
||||
var isNotStrongestStored = common.isNotStrongestStored = function (href, recents) {
|
||||
return findStronger(href, recents);
|
||||
};
|
||||
|
||||
// TODO integrate pinning
|
||||
var setPadTitle = common.setPadTitle = function (name, cb) {
|
||||
var href = window.location.href;
|
||||
|
@ -746,7 +557,7 @@ load pinpad dynamically only after you know that it will be needed */
|
|||
var getPadTitle = common.getPadTitle = function (cb) {
|
||||
var href = window.location.href;
|
||||
var parsed = parsePadUrl(window.location.href);
|
||||
var hashSlice = window.location.hash.slice(1,9);
|
||||
var hashSlice = window.location.hash.slice(1,9); // TODO remove
|
||||
var title = '';
|
||||
|
||||
getRecentPads(function (err, pads) {
|
||||
|
@ -862,27 +673,6 @@ load pinpad dynamically only after you know that it will be needed */
|
|||
});
|
||||
};
|
||||
|
||||
var hrefToHexChannelId = common.hrefToHexChannelId = function (href) {
|
||||
var parsed = common.parsePadUrl(href);
|
||||
if (!parsed || !parsed.hash) { return; }
|
||||
|
||||
parsed = common.parseHash(parsed.hash);
|
||||
|
||||
if (parsed.version === 0) {
|
||||
return parsed.channel;
|
||||
} else if (parsed.version !== 1) {
|
||||
console.error("parsed href had no version");
|
||||
console.error(parsed);
|
||||
return;
|
||||
}
|
||||
|
||||
var channel = parsed.channel;
|
||||
if (!channel) { return; }
|
||||
|
||||
var hex = common.base64ToHex(channel);
|
||||
return hex;
|
||||
};
|
||||
|
||||
var getUserChannelList = common.getUserChannelList = function () {
|
||||
var store = common.getStore();
|
||||
var proxy = store.getProxy();
|
||||
|
|
|
@ -0,0 +1,236 @@
|
|||
define([
|
||||
'/Hash/Hash-util.js',
|
||||
'/bower_components/chainpad-crypto/crypto.js',
|
||||
], function (Util, Crypto) {
|
||||
var Hash = {};
|
||||
|
||||
var uint8ArrayToHex = Util.uint8ArrayToHex;
|
||||
var hexToBase64 = Util.hexToBase64;
|
||||
var base64ToHex = Util.base64ToHex;
|
||||
|
||||
var getEditHashFromKeys = Hash.getEditHashFromKeys = function (chanKey, keys) {
|
||||
if (typeof keys === 'string') {
|
||||
return chanKey + keys;
|
||||
}
|
||||
if (!keys.editKeyStr) { return; }
|
||||
return '/1/edit/' + hexToBase64(chanKey) + '/' + Crypto.b64RemoveSlashes(keys.editKeyStr);
|
||||
};
|
||||
var getViewHashFromKeys = Hash.getViewHashFromKeys = function (chanKey, keys) {
|
||||
if (typeof keys === 'string') {
|
||||
return;
|
||||
}
|
||||
return '/1/view/' + hexToBase64(chanKey) + '/' + Crypto.b64RemoveSlashes(keys.viewKeyStr);
|
||||
};
|
||||
|
||||
var parsePadUrl = Hash.parsePadUrl = function (href) {
|
||||
var patt = /^https*:\/\/([^\/]*)\/(.*?)\//i;
|
||||
|
||||
var ret = {};
|
||||
|
||||
if (!href) { return ret; }
|
||||
|
||||
if (!/^https*:\/\//.test(href)) {
|
||||
var idx = href.indexOf('/#');
|
||||
ret.type = href.slice(1, idx);
|
||||
ret.hash = href.slice(idx + 2);
|
||||
return ret;
|
||||
}
|
||||
|
||||
var hash = href.replace(patt, function (a, domain, type, hash) {
|
||||
ret.domain = domain;
|
||||
ret.type = type;
|
||||
return '';
|
||||
});
|
||||
ret.hash = hash.replace(/#/g, '');
|
||||
return ret;
|
||||
};
|
||||
|
||||
var getRelativeHref = Hash.getRelativeHref = function (href) {
|
||||
if (!href) { return; }
|
||||
if (href.indexOf('#') === -1) { return; }
|
||||
var parsed = parsePadUrl(href);
|
||||
return '/' + parsed.type + '/#' + parsed.hash;
|
||||
};
|
||||
|
||||
/*
|
||||
* Returns all needed keys for a realtime channel
|
||||
* - no argument: use the URL hash or create one if it doesn't exist
|
||||
* - secretHash provided: use secretHash to find the keys
|
||||
*/
|
||||
var getSecrets = Hash.getSecrets = function (secretHash) {
|
||||
var secret = {};
|
||||
var generate = function () {
|
||||
secret.keys = Crypto.createEditCryptor();
|
||||
secret.key = Crypto.createEditCryptor().editKeyStr;
|
||||
};
|
||||
if (!secretHash && !/#/.test(window.location.href)) {
|
||||
generate();
|
||||
return secret;
|
||||
} else {
|
||||
var hash = secretHash || window.location.hash.slice(1);
|
||||
if (hash.length === 0) {
|
||||
generate();
|
||||
return secret;
|
||||
}
|
||||
// old hash system : #{hexChanKey}{cryptKey}
|
||||
// new hash system : #/{hashVersion}/{b64ChanKey}/{cryptKey}
|
||||
if (hash.slice(0,1) !== '/' && hash.length >= 56) {
|
||||
// Old hash
|
||||
secret.channel = hash.slice(0, 32);
|
||||
secret.key = hash.slice(32);
|
||||
}
|
||||
else {
|
||||
// New hash
|
||||
var hashArray = hash.split('/');
|
||||
if (hashArray.length < 4) {
|
||||
Hash.alert("Unable to parse the key");
|
||||
throw new Error("Unable to parse the key");
|
||||
}
|
||||
var version = hashArray[1];
|
||||
if (version === "1") {
|
||||
var mode = hashArray[2];
|
||||
if (mode === 'edit') {
|
||||
secret.channel = base64ToHex(hashArray[3]);
|
||||
var keys = Crypto.createEditCryptor(hashArray[4].replace(/-/g, '/'));
|
||||
secret.keys = keys;
|
||||
secret.key = keys.editKeyStr;
|
||||
if (secret.channel.length !== 32 || secret.key.length !== 24) {
|
||||
Hash.alert("The channel key and/or the encryption key is invalid");
|
||||
throw new Error("The channel key and/or the encryption key is invalid");
|
||||
}
|
||||
}
|
||||
else if (mode === 'view') {
|
||||
secret.channel = base64ToHex(hashArray[3]);
|
||||
secret.keys = Crypto.createViewCryptor(hashArray[4].replace(/-/g, '/'));
|
||||
if (secret.channel.length !== 32) {
|
||||
Hash.alert("The channel key is invalid");
|
||||
throw new Error("The channel key is invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return secret;
|
||||
};
|
||||
|
||||
var getHashes = Hash.getHashes = function (channel, secret) {
|
||||
var hashes = {};
|
||||
if (secret.keys.editKeyStr) {
|
||||
hashes.editHash = getEditHashFromKeys(channel, secret.keys);
|
||||
}
|
||||
if (secret.keys.viewKeyStr) {
|
||||
hashes.viewHash = getViewHashFromKeys(channel, secret.keys);
|
||||
}
|
||||
return hashes;
|
||||
};
|
||||
|
||||
var createChannelId = Hash.createChannelId = function () {
|
||||
var id = uint8ArrayToHex(Crypto.Nacl.randomBytes(16));
|
||||
if (id.length !== 32 || /[^a-f0-9]/.test(id)) {
|
||||
throw new Error('channel ids must consist of 32 hex characters');
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
var createRandomHash = Hash.createRandomHash = function () {
|
||||
// 16 byte channel Id
|
||||
var channelId = Util.hexToBase64(createChannelId());
|
||||
// 18 byte encryption key
|
||||
var key = Crypto.b64RemoveSlashes(Crypto.rand64(18));
|
||||
return '/1/edit/' + [channelId, key].join('/');
|
||||
};
|
||||
|
||||
var parseHash = Hash.parseHash = function (hash) {
|
||||
var parsed = {};
|
||||
if (hash.slice(0,1) !== '/' && hash.length >= 56) {
|
||||
// Old hash
|
||||
parsed.channel = hash.slice(0, 32);
|
||||
parsed.key = hash.slice(32);
|
||||
parsed.version = 0;
|
||||
return parsed;
|
||||
}
|
||||
var hashArr = hash.split('/');
|
||||
if (hashArr[1] && hashArr[1] === '1') {
|
||||
parsed.version = 1;
|
||||
parsed.mode = hashArr[2];
|
||||
parsed.channel = hashArr[3];
|
||||
parsed.key = hashArr[4];
|
||||
parsed.present = hashArr[5] && hashArr[5] === 'present';
|
||||
return parsed;
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
// STORAGE
|
||||
var findWeaker = Hash.findWeaker = function (href, recents) {
|
||||
var rHref = href || getRelativeHref(window.location.href);
|
||||
var parsed = parsePadUrl(rHref);
|
||||
if (!parsed.hash) { return false; }
|
||||
var weaker;
|
||||
recents.some(function (pad) {
|
||||
var p = parsePadUrl(pad.href);
|
||||
if (p.type !== parsed.type) { return; } // Not the same type
|
||||
if (p.hash === parsed.hash) { return; } // Same hash, not stronger
|
||||
var pHash = parseHash(p.hash);
|
||||
var parsedHash = parseHash(parsed.hash);
|
||||
if (!parsedHash || !pHash) { return; }
|
||||
if (pHash.version !== parsedHash.version) { return; }
|
||||
if (pHash.channel !== parsedHash.channel) { return; }
|
||||
if (pHash.mode === 'view' && parsedHash.mode === 'edit') {
|
||||
weaker = pad.href;
|
||||
return true;
|
||||
}
|
||||
return;
|
||||
});
|
||||
return weaker;
|
||||
};
|
||||
var findStronger = Hash.findStronger = function (href, recents) {
|
||||
var rHref = href || getRelativeHref(window.location.href);
|
||||
var parsed = parsePadUrl(rHref);
|
||||
if (!parsed.hash) { return false; }
|
||||
var stronger;
|
||||
recents.some(function (pad) {
|
||||
var p = parsePadUrl(pad.href);
|
||||
if (p.type !== parsed.type) { return; } // Not the same type
|
||||
if (p.hash === parsed.hash) { return; } // Same hash, not stronger
|
||||
var pHash = parseHash(p.hash);
|
||||
var parsedHash = parseHash(parsed.hash);
|
||||
if (!parsedHash || !pHash) { return; }
|
||||
if (pHash.version !== parsedHash.version) { return; }
|
||||
if (pHash.channel !== parsedHash.channel) { return; }
|
||||
if (pHash.mode === 'edit' && parsedHash.mode === 'view') {
|
||||
stronger = pad.href;
|
||||
return true;
|
||||
}
|
||||
return;
|
||||
});
|
||||
return stronger;
|
||||
};
|
||||
var isNotStrongestStored = Hash.isNotStrongestStored = function (href, recents) {
|
||||
return findStronger(href, recents);
|
||||
};
|
||||
|
||||
var hrefToHexChannelId = Hash.hrefToHexChannelId = function (href) {
|
||||
var parsed = Hash.parsePadUrl(href);
|
||||
if (!parsed || !parsed.hash) { return; }
|
||||
|
||||
parsed = Hash.parseHash(parsed.hash);
|
||||
|
||||
if (parsed.version === 0) {
|
||||
return parsed.channel;
|
||||
} else if (parsed.version !== 1) {
|
||||
console.error("parsed href had no version");
|
||||
console.error(parsed);
|
||||
return;
|
||||
}
|
||||
|
||||
var channel = parsed.channel;
|
||||
if (!channel) { return; }
|
||||
|
||||
var hex = Hash.base64ToHex(channel);
|
||||
return hex;
|
||||
};
|
||||
|
||||
|
||||
return Hash;
|
||||
});
|
Loading…
Reference in New Issue