mirror of
https://github.com/the-djmaze/snappymail.git
synced 2026-08-25 10:09:20 +03:00
Merge 56409dcbb4 into c154d23cfe
This commit is contained in:
commit
b5e881db0e
15 changed files with 258 additions and 22 deletions
|
|
@ -260,7 +260,8 @@ AskPopupView.password = function(sAskDesc, btnText, ask) {
|
|||
view => resolve({
|
||||
password:view.passphrase(),
|
||||
username:/*ask & 2 ? */view.username(),
|
||||
remember:/*ask & 4 ? */view.remember()
|
||||
remember:/*ask & 4 ? */view.remember(),
|
||||
rememberPermanent: view.rememberPermanent(),
|
||||
}),
|
||||
() => resolve(null),
|
||||
true,
|
||||
|
|
|
|||
4
dev/External/ko.js
vendored
4
dev/External/ko.js
vendored
|
|
@ -176,6 +176,6 @@ ko.extenders.falseTimeout = (target, option) => {
|
|||
|
||||
// functions
|
||||
|
||||
ko.observable.fn.askDeleteHelper = function() {
|
||||
return this.extend({ falseTimeout: 3000, toggleSubscribeProperty: [this, 'askDelete'] });
|
||||
ko.observable.fn.askDeleteHelper = function(prop='askDelete') {
|
||||
return this.extend({ falseTimeout: 3000, toggleSubscribeProperty: [this, prop] });
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,21 +1,155 @@
|
|||
import { AskPopupView } from 'View/Popup/Ask';
|
||||
import { SettingsUserStore } from 'Stores/User/Settings';
|
||||
import { SettingsGet } from '../Common/Globals';
|
||||
import { isArray } from '../Common/Utils';
|
||||
|
||||
export const Passphrases = new WeakMap();
|
||||
|
||||
Passphrases.ask = async (key, sAskDesc, btnText) =>
|
||||
Passphrases.has(key)
|
||||
? {password:Passphrases.handle(key)/*, remember:false*/}
|
||||
: await AskPopupView.password(sAskDesc, btnText, 5);
|
||||
Passphrases.ask = async (key, sAskDesc, btnText) => {
|
||||
if (Passphrases.has(key)) {
|
||||
return { password: Passphrases.handle(key)/*, remember:false*/ };
|
||||
} else if (Passphrases.hasInLocalStorage(key)) {
|
||||
return { password: await getFromLocalStorage(key) };
|
||||
} else {
|
||||
const pass = await AskPopupView.password(sAskDesc, btnText,
|
||||
window.crypto.subtle && canUseLocalStorage(key) ? 0b1101 : 0b0101);
|
||||
pass.rememberPermanent && await saveToLocalStorage(key, pass.password);
|
||||
return pass;
|
||||
}
|
||||
};
|
||||
|
||||
Passphrases._deleteFromSession = Passphrases.delete;
|
||||
|
||||
const timeouts = {};
|
||||
// get/set accessor to control deletion after N minutes of inactivity
|
||||
Passphrases.handle = (key, pass) => {
|
||||
const timeout = SettingsUserStore.keyPassForget();
|
||||
if (timeout && !timeouts[key]) {
|
||||
timeouts[key] = (()=>Passphrases.delete(key)).debounce(timeout * 1000);
|
||||
timeouts[key] = (() => Passphrases._deleteFromSession(key)).debounce(timeout * 60 * 1000);
|
||||
}
|
||||
pass && Passphrases.set(key, pass);
|
||||
timeout && timeouts[key]();
|
||||
return Passphrases.get(key);
|
||||
};
|
||||
|
||||
const deleteFromLocalStorage = (key) => {
|
||||
const keyId = getKeyId(key);
|
||||
if (keyId) {
|
||||
localStorage.removeItem(keyId);
|
||||
}
|
||||
};
|
||||
Passphrases.delete = (key) => {
|
||||
deleteFromLocalStorage(key);
|
||||
return Passphrases._deleteFromSession(key);
|
||||
};
|
||||
|
||||
Passphrases.hasInLocalStorage = (key) => {
|
||||
const keyId = getKeyId(key);
|
||||
return keyId && localStorage.getItem(keyId) !== null;
|
||||
};
|
||||
|
||||
const saveToLocalStorage = async (key, pass) => {
|
||||
const keyId = getKeyId(key);
|
||||
if (!keyId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pass) {
|
||||
localStorage.removeItem(keyId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const salt = window.crypto.getRandomValues(new Uint8Array(16));
|
||||
const derivedKey = await deriveKeyFromHash(SettingsGet('accountHash'), salt);
|
||||
|
||||
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
||||
const encrypted = await window.crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: iv },
|
||||
derivedKey,
|
||||
new TextEncoder().encode(pass)
|
||||
);
|
||||
localStorage.setItem(keyId, JSON.stringify([
|
||||
btoa(String.fromCharCode.apply(null, salt)),
|
||||
btoa(String.fromCharCode.apply(null, iv)),
|
||||
btoa(String.fromCharCode.apply(null, new Uint8Array(encrypted)))
|
||||
]));
|
||||
} catch (e) {
|
||||
console.error('Passphrases.saveToLocalStorage failed', e);
|
||||
}
|
||||
};
|
||||
|
||||
const getFromLocalStorage = async (key) => {
|
||||
const keyId = getKeyId(key);
|
||||
if (!keyId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const jsonData = localStorage.getItem(keyId);
|
||||
if (!jsonData) {
|
||||
console.error('Passphrases.getFromLocalStorage failed: no data found');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const saltIvData = JSON.parse(jsonData);
|
||||
if (!saltIvData || !isArray(saltIvData) || saltIvData.length !== 3) {
|
||||
// noinspection ExceptionCaughtLocallyJS
|
||||
throw new Error('invalid passphrase data');
|
||||
}
|
||||
const toUint8 = (str) => new Uint8Array(atob(str).split('').map(c => c.charCodeAt(0)));
|
||||
|
||||
const derivedKey = await deriveKeyFromHash(SettingsGet('accountHash'), toUint8(saltIvData[0]));
|
||||
const decrypted = await window.crypto.subtle.decrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: toUint8(saltIvData[1])
|
||||
},
|
||||
derivedKey,
|
||||
toUint8(saltIvData[2])
|
||||
);
|
||||
return String.fromCharCode.apply(null, new Uint8Array(decrypted));
|
||||
} catch (e) {
|
||||
localStorage.removeItem(keyId);
|
||||
console.error('Passphrases.getFromLocalStorage failed', e);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const canUseLocalStorage = (key) => getKeyId(key) !== undefined;
|
||||
|
||||
const getKeyId = (key) => {
|
||||
if (key && typeof key.id === 'string' && key.id.length > 4 && typeof key.forgetPass === 'function') {
|
||||
// only deal with keys that we can forget (OpenPGB, GnuPG)
|
||||
return key.id + '_local_key';
|
||||
} else {
|
||||
console.info('Passphrases.getKeyId: unsupported key type');
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const deriveKeyFromHash = async (hash, salt) => {
|
||||
if (!hash) {
|
||||
throw new Error('empty accountHash');
|
||||
}
|
||||
return window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(hash),
|
||||
{ 'name': 'PBKDF2' },
|
||||
false,
|
||||
['deriveKey']
|
||||
).then(keyMaterial => {
|
||||
return window.crypto.subtle.deriveKey(
|
||||
{
|
||||
'name': 'PBKDF2',
|
||||
'salt': salt,
|
||||
'iterations': 512,
|
||||
'hash': 'SHA-256'
|
||||
},
|
||||
keyMaterial,
|
||||
{ 'name': 'AES-GCM', 'length': 256 },
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -51,6 +51,13 @@ export const GnuPGUserStore = new class {
|
|||
key.for = email => aEmails.includes(IDN.toASCII(email));
|
||||
key.askDelete = ko.observable(false);
|
||||
key.openForDeletion = ko.observable(null).askDeleteHelper();
|
||||
key.askForgetPass = ko.observable(false);
|
||||
key.openForPassForget = ko.observable(null).askDeleteHelper('askForgetPass');
|
||||
key.forgetPass = () => {
|
||||
Passphrases.delete(key);
|
||||
key.hasStoredPass(false);
|
||||
};
|
||||
key.hasStoredPass = ko.observable(Passphrases.hasInLocalStorage(key));
|
||||
key.remove = () => {
|
||||
if (key.askDelete()) {
|
||||
Remote.request('GnupgDeleteKey',
|
||||
|
|
@ -69,6 +76,7 @@ export const GnuPGUserStore = new class {
|
|||
isPrivate: isPrivate
|
||||
}
|
||||
);
|
||||
isPrivate && key.forgetPass();
|
||||
}
|
||||
};
|
||||
if (isPrivate) {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { OpenPgpKeyPopupView } from 'View/Popup/OpenPgpKey';
|
|||
|
||||
import { Passphrases } from 'Storage/Passphrases';
|
||||
|
||||
import { baseCollator } from 'Common/Translator';
|
||||
import { baseCollator, getNotification } from 'Common/Translator';
|
||||
|
||||
const
|
||||
loaded = () => !!window.openpgp,
|
||||
|
|
@ -91,6 +91,9 @@ class OpenPgpKeyModel {
|
|||
this.armor = armor;
|
||||
this.askDelete = ko.observable(false);
|
||||
this.openForDeletion = ko.observable(null).askDeleteHelper();
|
||||
this.hasStoredPass = ko.observable(Passphrases.hasInLocalStorage(this));
|
||||
this.askForgetPass = ko.observable(false);
|
||||
this.openForPassForget = ko.observable(null).askDeleteHelper('askForgetPass');
|
||||
// key.getUserIDs()
|
||||
// key.getPrimaryUser()
|
||||
}
|
||||
|
|
@ -116,12 +119,29 @@ class OpenPgpKeyModel {
|
|||
if (this.key.isPrivate()) {
|
||||
OpenPGPUserStore.privateKeys.remove(this);
|
||||
storeOpenPgpKeys(OpenPGPUserStore.privateKeys, privateKeysItem);
|
||||
this.forgetPass()
|
||||
} else {
|
||||
OpenPGPUserStore.publicKeys.remove(this);
|
||||
storeOpenPgpKeys(OpenPGPUserStore.publicKeys, publicKeysItem);
|
||||
}
|
||||
Remote.request('DeletePGPKey',
|
||||
(iError, oData) => {
|
||||
if (oData) {
|
||||
if (iError || oData.Result === false) {
|
||||
alert(oData.message || getNotification(iError));
|
||||
}
|
||||
}
|
||||
}, {
|
||||
key: this.armor
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
forgetPass() {
|
||||
Passphrases.delete(this);
|
||||
this.hasStoredPass(false);
|
||||
}
|
||||
/*
|
||||
toJSON() {
|
||||
return this.armor;
|
||||
|
|
|
|||
|
|
@ -15,10 +15,14 @@ export class AskPopupView extends AbstractViewPopup {
|
|||
askUsername: false,
|
||||
passphrase: '',
|
||||
askPass: false,
|
||||
remember: true,
|
||||
askRemeber: false
|
||||
remember: true, // remember for session
|
||||
askRemeber: false,
|
||||
rememberPermanent: false,
|
||||
askRememberPermanent: false,
|
||||
});
|
||||
|
||||
this.rememberPermanent.subscribe(value => value && this.remember(true));
|
||||
|
||||
this.fYesAction = null;
|
||||
this.fNoAction = null;
|
||||
|
||||
|
|
@ -48,10 +52,12 @@ export class AskPopupView extends AbstractViewPopup {
|
|||
this.askDesc(sAskDesc || '');
|
||||
this.askUsername(ask & 2);
|
||||
this.askPass(ask & 1);
|
||||
this.askRemeber(ask & 4);
|
||||
this.askRemeber(ask & 4); // 0b0100
|
||||
this.askRememberPermanent(ask & 0b1000)
|
||||
this.username('');
|
||||
this.passphrase('');
|
||||
this.remember(true);
|
||||
this.rememberPermanent(false);
|
||||
this.yesButton(i18n(btnText || 'GLOBAL/YES'));
|
||||
this.noButton(i18n(ask ? 'GLOBAL/CANCEL' : 'GLOBAL/NO'));
|
||||
this.fYesAction = fYesFunc;
|
||||
|
|
|
|||
|
|
@ -1593,6 +1593,7 @@ export class ComposePopupView extends AbstractViewPopup {
|
|||
*/
|
||||
break;
|
||||
} catch (e) {
|
||||
Passphrases.delete(signOptions[i][1])
|
||||
console.error(e);
|
||||
}
|
||||
} else if ('GnuPG' == signOptions[i][0]) {
|
||||
|
|
|
|||
|
|
@ -287,6 +287,14 @@ trait Pgp
|
|||
return $this->DefaultResponse(($key && $keyId && Backup::PGPKey($key, $keyId)));
|
||||
}
|
||||
|
||||
public function DoDeletePGPKey(): array
|
||||
{
|
||||
$key = $this->GetActionParam('key', '');
|
||||
$keyId = $this->GetActionParam('keyId', '');
|
||||
$success = $key && Backup::PGPKey($key, $keyId, true);
|
||||
return $this->DefaultResponse($success, ['code' => (int)!$success]);
|
||||
}
|
||||
|
||||
/**
|
||||
* https://datatracker.ietf.org/doc/html/rfc3156#section-5
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ namespace SnappyMail\PGP;
|
|||
|
||||
class Backup
|
||||
{
|
||||
public static function PGPKey(string $key, string $keyId = '') : bool
|
||||
public static function PGPKey(string $key, string $keyId = '', $doDelete = false) : bool
|
||||
{
|
||||
$oActions = \RainLoop\Api::Actions();
|
||||
$oAccount = $oActions->getMainAccountFromToken();
|
||||
|
|
@ -21,15 +21,23 @@ class Backup
|
|||
true
|
||||
);
|
||||
if (\str_contains($key, 'PGP PRIVATE KEY')) {
|
||||
$fileName = "{$dir}{$keyId}.key";
|
||||
if ($doDelete) {
|
||||
return !file_exists($fileName) || unlink($fileName);
|
||||
}
|
||||
$hash = $oAccount->CryptKey();
|
||||
$key = \SnappyMail\Crypt::Encrypt($key, $hash);
|
||||
$key[1] = \base64_encode($key[1]);
|
||||
$key[2] = \base64_encode($key[2]);
|
||||
$key[3] = \hash_hmac('sha1', $key[2], $hash);
|
||||
return !!\file_put_contents("{$dir}{$keyId}.key", \json_encode($key));
|
||||
return !!\file_put_contents($fileName, \json_encode($key));
|
||||
}
|
||||
if (\str_contains($key, 'PGP PUBLIC KEY')) {
|
||||
return !!\file_put_contents("{$dir}{$keyId}_public.asc", $key);
|
||||
$fileName = "{$dir}{$keyId}_public.asc";
|
||||
if ($doDelete) {
|
||||
return !file_exists($fileName) || unlink($fileName);
|
||||
}
|
||||
return !!\file_put_contents($fileName, $key);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@
|
|||
"USERNAME": "Nutzername",
|
||||
"PASSWORD": "Passwort",
|
||||
"REMEMBER": "Zugangsdaten merken",
|
||||
"REMEMBER_FOR_SESSION": "Aktuelle Sitzung merken",
|
||||
"REMEMBER_PERMANENT": "Dauerhaft merken",
|
||||
"REPLY_TO": "Antwort an",
|
||||
"SAVE": "Speichern",
|
||||
"SAVE_CHANGES": "Änderungen speichern?",
|
||||
|
|
@ -458,7 +460,8 @@
|
|||
"LEGEND_SECURITY": "Sicherheit",
|
||||
"LABEL_AUTOLOGOUT": "Automatische Abmeldung",
|
||||
"FORGET_KEY_PASS": "Passphrase für privaten Schlüssel vergessen",
|
||||
"NEVER": "Nie"
|
||||
"NEVER": "Nie",
|
||||
"LABEL_STORED_PASS": "Passphrase gespeichert"
|
||||
},
|
||||
"SETTINGS_GENERAL": {
|
||||
"LANGUAGE": "Sprache",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@
|
|||
"USERNAME": "Username",
|
||||
"PASSWORD": "Passphrase",
|
||||
"REMEMBER": "Remember",
|
||||
"REMEMBER_FOR_SESSION": "Remember for this Session",
|
||||
"REMEMBER_PERMANENT": "Remember Permanently",
|
||||
"REPLY_TO": "Reply-To",
|
||||
"SAVE": "Save",
|
||||
"SAVE_CHANGES": "Save changes?",
|
||||
|
|
@ -458,7 +460,8 @@
|
|||
"LEGEND_SECURITY": "Security",
|
||||
"LABEL_AUTOLOGOUT": "Auto Logout",
|
||||
"FORGET_KEY_PASS": "Forget private key passphrase",
|
||||
"NEVER": "Never"
|
||||
"NEVER": "Never",
|
||||
"LABEL_STORED_PASS": "Remembered Passphrase"
|
||||
},
|
||||
"SETTINGS_GENERAL": {
|
||||
"LANGUAGE": "Language",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@
|
|||
"USERNAME": "Gebruikersnaam",
|
||||
"PASSWORD": "Wachtwoord",
|
||||
"REMEMBER": "Onthouden",
|
||||
"REMEMBER_FOR_SESSION": "Onthoud voor deze sessie",
|
||||
"REMEMBER_PERMANENT": "Onthoud permanent",
|
||||
"REPLY_TO": "Antwoordadres",
|
||||
"SAVE": "Opslaan",
|
||||
"SAVE_CHANGES": "Wijzigingen opslaan?",
|
||||
|
|
@ -458,7 +460,8 @@
|
|||
"LEGEND_SECURITY": "Beveiliging",
|
||||
"LABEL_AUTOLOGOUT": "Automatisch uitloggen",
|
||||
"FORGET_KEY_PASS": "Vergeet privésleutel wachtwoord",
|
||||
"NEVER": "Nooit"
|
||||
"NEVER": "Nooit",
|
||||
"LABEL_STORED_PASS": "Wachtwoordzin opgeslagen"
|
||||
},
|
||||
"SETTINGS_GENERAL": {
|
||||
"LANGUAGE": "Taal",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@
|
|||
"USERNAME": "Имя пользователя",
|
||||
"PASSWORD": "Пароль",
|
||||
"REMEMBER": "Запомнить",
|
||||
"REMEMBER_FOR_SESSION": "Запомнить Временное",
|
||||
"REMEMBER_PERMANENT": "Запомнить Навсегда",
|
||||
"REPLY_TO": "Ответить-на",
|
||||
"SAVE": "Сохранить",
|
||||
"SAVE_CHANGES": "Сохранить изменения?",
|
||||
|
|
@ -458,7 +460,8 @@
|
|||
"LEGEND_SECURITY": "Безопасность",
|
||||
"LABEL_AUTOLOGOUT": "Автоматический выход",
|
||||
"FORGET_KEY_PASS": "Забыть парольную фразу закрытого ключа",
|
||||
"NEVER": "Никогда"
|
||||
"NEVER": "Никогда",
|
||||
"LABEL_STORED_PASS": "Remembered Passphrase"
|
||||
},
|
||||
"SETTINGS_GENERAL": {
|
||||
"LANGUAGE": "Язык",
|
||||
|
|
|
|||
|
|
@ -9,13 +9,23 @@
|
|||
<label data-i18n="GLOBAL/PASSWORD"></label>
|
||||
<input type="password" data-bind="value: passphrase">
|
||||
</div>
|
||||
<div data-bind="visible: askRemeber, component: {
|
||||
<div class="control-group" style="display: inline-flex; align-items: start;flex-direction: column;">
|
||||
<div data-bind="visible: askRemeber, component: {
|
||||
name: 'Checkbox',
|
||||
params: {
|
||||
label: 'GLOBAL/REMEMBER',
|
||||
value: remember
|
||||
label: 'GLOBAL/REMEMBER_FOR_SESSION',
|
||||
value: remember,
|
||||
enable: !rememberPermanent()
|
||||
}
|
||||
}"></div>
|
||||
<div data-bind="visible: askRememberPermanent, component: {
|
||||
name: 'Checkbox',
|
||||
params: {
|
||||
label: 'GLOBAL/REMEMBER_PERMANENT',
|
||||
value: rememberPermanent
|
||||
}
|
||||
}"></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<footer>
|
||||
|
|
|
|||
|
|
@ -58,6 +58,20 @@
|
|||
<span class="delete-key fontastic" data-bind="visible: !askDelete(), click: openForDeletion">🗑</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-bind="visible: hasStoredPass">
|
||||
<td>
|
||||
<span class="fontastic" data-i18n="[title]SETTINGS_OPENPGP/TITLE_PRIVATE">🔒</span>
|
||||
<span class="key-id" data-bind="text: id"></span>
|
||||
<span>🔑</span><span class="key-user" data-i18n="SETTINGS_SECURITY/LABEL_STORED_PASS"></span>
|
||||
</td>
|
||||
<td>
|
||||
<a class="btn btn-small btn-danger button-confirm-delete" data-bind="css: {'delete-access': askForgetPass()}, click: forgetPass"
|
||||
data-i18n="GLOBAL/ARE_YOU_SURE"></a>
|
||||
</td>
|
||||
<td>
|
||||
<span class="delete-key fontastic" data-bind="visible: !askForgetPass(), click: openForPassForget">🗑</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tbody><tr><th colspan="4" data-i18n="SETTINGS_OPENPGP/TITLE_PUBLIC">Public keys</th></tr></tbody>
|
||||
<tbody data-bind="foreach: gnupgPublicKeys, i18nUpdate: gnupgPublicKeys">
|
||||
|
|
@ -105,6 +119,20 @@
|
|||
<span class="delete-key fontastic" data-bind="visible: !askDelete(), click: openForDeletion">🗑</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-bind="visible: hasStoredPass">
|
||||
<td>
|
||||
<span class="fontastic" data-i18n="[title]SETTINGS_OPENPGP/TITLE_PRIVATE">🔒</span>
|
||||
<span class="key-id" data-bind="text: id"></span>
|
||||
<span>🔑</span><span class="key-user" data-i18n="SETTINGS_SECURITY/LABEL_STORED_PASS"></span>
|
||||
</td>
|
||||
<td>
|
||||
<a class="btn btn-small btn-danger button-confirm-delete" data-bind="css: {'delete-access': askForgetPass()}, click: forgetPass"
|
||||
data-i18n="GLOBAL/ARE_YOU_SURE"></a>
|
||||
</td>
|
||||
<td>
|
||||
<span class="delete-key fontastic" data-bind="visible: !askForgetPass(), click: openForPassForget">🗑</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tbody><tr><th colspan="4" data-i18n="SETTINGS_OPENPGP/TITLE_PUBLIC">Public keys</th></tr></tbody>
|
||||
<tbody data-bind="foreach: openpgpkeysPublic, i18nUpdate: openpgpkeysPublic">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue