mirror of
https://github.com/the-djmaze/snappymail.git
synced 2026-08-31 21:19:21 +03:00
OpenPGP improvements (first step)
openpgpjs: 0.7.2 -> 0.10.1 additional fixes
This commit is contained in:
parent
09334159c2
commit
34a9b8cbc5
28 changed files with 1363 additions and 644 deletions
|
|
@ -109,6 +109,7 @@
|
|||
'Settings': 'settings',
|
||||
'Menu': 'menu',
|
||||
'PopupComposeOpenPGP': 'compose-open-pgp',
|
||||
'PopupMessageOpenPGP': 'message-open-pgp',
|
||||
'PopupKeyboardShortcutsHelp': 'popup-keyboard-shortcuts-help',
|
||||
'PopupAsk': 'popup-ask'
|
||||
};
|
||||
|
|
|
|||
|
|
@ -390,7 +390,7 @@
|
|||
*/
|
||||
Links.prototype.openPgpJs = function ()
|
||||
{
|
||||
return this.sStaticPrefix + 'js/min/openpgp.js';
|
||||
return this.sStaticPrefix + 'js/min/openpgp.js';
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
10
dev/External/ko.js
vendored
10
dev/External/ko.js
vendored
|
|
@ -7,6 +7,7 @@
|
|||
window = require('window'),
|
||||
_ = require('_'),
|
||||
$ = require('$'),
|
||||
JSON = require('JSON'),
|
||||
Opentip = require('Opentip'),
|
||||
|
||||
fDisposalTooltipHelper = function (oElement) {
|
||||
|
|
@ -63,6 +64,15 @@
|
|||
}
|
||||
};
|
||||
|
||||
ko.bindingHandlers.json = {
|
||||
'init': function (oElement, fValueAccessor) {
|
||||
$(oElement).text(JSON.stringify(ko.unwrap(fValueAccessor())));
|
||||
},
|
||||
'update': function (oElement, fValueAccessor) {
|
||||
$(oElement).text(JSON.stringify(ko.unwrap(fValueAccessor())));
|
||||
}
|
||||
};
|
||||
|
||||
ko.bindingHandlers.tooltip = {
|
||||
'init': function (oElement, fValueAccessor) {
|
||||
|
||||
|
|
|
|||
|
|
@ -14,8 +14,6 @@
|
|||
Globals = require('Common/Globals'),
|
||||
Links = require('Common/Links'),
|
||||
|
||||
PgpStore = require('Stores/User/Pgp'),
|
||||
|
||||
AttachmentModel = require('Model/Attachment'),
|
||||
|
||||
MessageHelper = require('Helper/Message'),
|
||||
|
|
@ -82,7 +80,6 @@
|
|||
}, this);
|
||||
|
||||
this.body = null;
|
||||
this.plainRaw = '';
|
||||
|
||||
this.isHtml = ko.observable(false);
|
||||
this.hasImages = ko.observable(false);
|
||||
|
|
@ -984,25 +981,6 @@
|
|||
{
|
||||
this.body.data('rl-is-html', !!this.isHtml());
|
||||
this.body.data('rl-has-images', !!this.hasImages());
|
||||
|
||||
this.body.data('rl-plain-raw', this.plainRaw);
|
||||
|
||||
if (require('Stores/User/Pgp').capaOpenPGP())
|
||||
{
|
||||
this.body.data('rl-plain-pgp-signed', !!this.isPgpSigned());
|
||||
this.body.data('rl-plain-pgp-encrypted', !!this.isPgpEncrypted());
|
||||
this.body.data('rl-pgp-verify-status', this.pgpSignedVerifyStatus());
|
||||
this.body.data('rl-pgp-verify-user', this.pgpSignedVerifyUser());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
MessageModel.prototype.storePgpVerifyDataInDom = function ()
|
||||
{
|
||||
if (this.body && require('Stores/User/Pgp').capaOpenPGP())
|
||||
{
|
||||
this.body.data('rl-pgp-verify-status', this.pgpSignedVerifyStatus());
|
||||
this.body.data('rl-pgp-verify-user', this.pgpSignedVerifyUser());
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -1012,157 +990,6 @@
|
|||
{
|
||||
this.isHtml(!!this.body.data('rl-is-html'));
|
||||
this.hasImages(!!this.body.data('rl-has-images'));
|
||||
|
||||
this.plainRaw = Utils.pString(this.body.data('rl-plain-raw'));
|
||||
|
||||
if (require('Stores/User/Pgp').capaOpenPGP())
|
||||
{
|
||||
this.isPgpSigned(!!this.body.data('rl-plain-pgp-signed'));
|
||||
this.isPgpEncrypted(!!this.body.data('rl-plain-pgp-encrypted'));
|
||||
this.pgpSignedVerifyStatus(this.body.data('rl-pgp-verify-status'));
|
||||
this.pgpSignedVerifyUser(this.body.data('rl-pgp-verify-user'));
|
||||
}
|
||||
else
|
||||
{
|
||||
this.isPgpSigned(false);
|
||||
this.isPgpEncrypted(false);
|
||||
this.pgpSignedVerifyStatus(Enums.SignedVerifyStatus.None);
|
||||
this.pgpSignedVerifyUser('');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
MessageModel.prototype.verifyPgpSignedClearMessage = function ()
|
||||
{
|
||||
if (this.isPgpSigned())
|
||||
{
|
||||
var
|
||||
aRes = [],
|
||||
mPgpMessage = null,
|
||||
sFrom = this.from && this.from[0] && this.from[0].email ? this.from[0].email : '',
|
||||
aPublicKeys = PgpStore.findPublicKeysByEmail(sFrom),
|
||||
oValidKey = null,
|
||||
oValidSysKey = null,
|
||||
sPlain = ''
|
||||
;
|
||||
|
||||
this.pgpSignedVerifyStatus(Enums.SignedVerifyStatus.Error);
|
||||
this.pgpSignedVerifyUser('');
|
||||
|
||||
try
|
||||
{
|
||||
mPgpMessage = PgpStore.openpgp.cleartext.readArmored(this.plainRaw);
|
||||
if (mPgpMessage && mPgpMessage.getText)
|
||||
{
|
||||
this.pgpSignedVerifyStatus(
|
||||
aPublicKeys.length ? Enums.SignedVerifyStatus.Unverified : Enums.SignedVerifyStatus.UnknownPublicKeys);
|
||||
|
||||
aRes = mPgpMessage.verify(aPublicKeys);
|
||||
if (aRes && 0 < aRes.length)
|
||||
{
|
||||
oValidKey = _.find(aRes, function (oItem) {
|
||||
return oItem && oItem.keyid && oItem.valid;
|
||||
});
|
||||
|
||||
if (oValidKey)
|
||||
{
|
||||
oValidSysKey = PgpStore.findPublicKeyByHex(oValidKey.keyid.toHex());
|
||||
if (oValidSysKey)
|
||||
{
|
||||
sPlain = mPgpMessage.getText();
|
||||
|
||||
this.pgpSignedVerifyStatus(Enums.SignedVerifyStatus.Success);
|
||||
this.pgpSignedVerifyUser(oValidSysKey.user);
|
||||
|
||||
sPlain =
|
||||
Globals.$div.empty().append(
|
||||
$('<pre class="b-plain-openpgp signed verified"></pre>').text(sPlain)
|
||||
).html()
|
||||
;
|
||||
|
||||
Globals.$div.empty();
|
||||
|
||||
this.replacePlaneTextBody(sPlain);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (oExc) {}
|
||||
|
||||
this.storePgpVerifyDataInDom();
|
||||
}
|
||||
};
|
||||
|
||||
MessageModel.prototype.decryptPgpEncryptedMessage = function (sPassword)
|
||||
{
|
||||
if (this.isPgpEncrypted())
|
||||
{
|
||||
var
|
||||
aRes = [],
|
||||
mPgpMessage = null,
|
||||
mPgpMessageDecrypted = null,
|
||||
sFrom = this.from && this.from[0] && this.from[0].email ? this.from[0].email : '',
|
||||
aPublicKey = PgpStore.findPublicKeysByEmail(sFrom),
|
||||
oPrivateKey = PgpStore.findSelfPrivateKey(sPassword),
|
||||
oValidKey = null,
|
||||
oValidSysKey = null,
|
||||
sPlain = ''
|
||||
;
|
||||
|
||||
this.pgpSignedVerifyStatus(Enums.SignedVerifyStatus.Error);
|
||||
this.pgpSignedVerifyUser('');
|
||||
|
||||
if (!oPrivateKey)
|
||||
{
|
||||
this.pgpSignedVerifyStatus(Enums.SignedVerifyStatus.UnknownPrivateKey);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
mPgpMessage = PgpStore.openpgp.message.readArmored(this.plainRaw);
|
||||
if (mPgpMessage && oPrivateKey && mPgpMessage.decrypt)
|
||||
{
|
||||
this.pgpSignedVerifyStatus(Enums.SignedVerifyStatus.Unverified);
|
||||
|
||||
mPgpMessageDecrypted = mPgpMessage.decrypt(oPrivateKey);
|
||||
if (mPgpMessageDecrypted)
|
||||
{
|
||||
aRes = mPgpMessageDecrypted.verify(aPublicKey);
|
||||
if (aRes && 0 < aRes.length)
|
||||
{
|
||||
oValidKey = _.find(aRes, function (oItem) {
|
||||
return oItem && oItem.keyid && oItem.valid;
|
||||
});
|
||||
|
||||
if (oValidKey)
|
||||
{
|
||||
oValidSysKey = PgpStore.findPublicKeyByHex(oValidKey.keyid.toHex());
|
||||
if (oValidSysKey)
|
||||
{
|
||||
this.pgpSignedVerifyStatus(Enums.SignedVerifyStatus.Success);
|
||||
this.pgpSignedVerifyUser(oValidSysKey.user);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sPlain = mPgpMessageDecrypted.getText();
|
||||
|
||||
sPlain =
|
||||
Globals.$div.empty().append(
|
||||
$('<pre class="b-plain-openpgp signed verified"></pre>').text(sPlain)
|
||||
).html()
|
||||
;
|
||||
|
||||
Globals.$div.empty();
|
||||
|
||||
this.replacePlaneTextBody(sPlain);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (oExc) {}
|
||||
|
||||
this.storePgpVerifyDataInDom();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
_ = require('_'),
|
||||
ko = require('ko'),
|
||||
|
||||
PgpStore = require('Stores/User/Pgp'),
|
||||
|
||||
AbstractModel = require('Knoin/AbstractModel')
|
||||
;
|
||||
|
||||
|
|
@ -45,6 +47,22 @@
|
|||
OpenPgpKeyModel.prototype.armor = '';
|
||||
OpenPgpKeyModel.prototype.isPrivate = false;
|
||||
|
||||
OpenPgpKeyModel.prototype.getNativeKeys = function ()
|
||||
{
|
||||
var oKey = null;
|
||||
try
|
||||
{
|
||||
oKey = PgpStore.openpgp.key.readArmored(this.armor);
|
||||
if (oKey && !oKey.err && oKey.keys && oKey.keys[0])
|
||||
{
|
||||
return oKey.keys;
|
||||
}
|
||||
}
|
||||
catch (e) {}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
module.exports = OpenPgpKeyModel;
|
||||
|
||||
}());
|
||||
|
|
@ -22,6 +22,7 @@
|
|||
|
||||
AppStore = require('Stores/User/App'),
|
||||
FolderStore = require('Stores/User/Folder'),
|
||||
PgpStore = require('Stores/User/Pgp'),
|
||||
SettingsStore = require('Stores/User/Settings'),
|
||||
|
||||
Remote = require('Remote/User/Ajax'),
|
||||
|
|
@ -502,6 +503,19 @@
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Object} oMessageTextBody
|
||||
*/
|
||||
MessageUserStore.prototype.initOpenPgpControls = function (oMessageTextBody)
|
||||
{
|
||||
if (oMessageTextBody && oMessageTextBody.find)
|
||||
{
|
||||
oMessageTextBody.find('.b-plain-openpgp:not(.inited)').each(function () {
|
||||
PgpStore.initMessageBodyControls($(this));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
MessageUserStore.prototype.setMessage = function (oData, bCached)
|
||||
{
|
||||
var
|
||||
|
|
@ -512,6 +526,7 @@
|
|||
oBody = null,
|
||||
oTextBody = null,
|
||||
sId = '',
|
||||
sPlain = '',
|
||||
sResultHtml = '',
|
||||
bPgpSigned = false,
|
||||
bPgpEncrypted = false,
|
||||
|
|
@ -579,13 +594,13 @@
|
|||
|
||||
if ((oMessage.isPgpSigned() || oMessage.isPgpEncrypted()) && require('Stores/User/Pgp').capaOpenPGP())
|
||||
{
|
||||
oMessage.plainRaw = Utils.pString(oData.Result.Plain);
|
||||
sPlain = Utils.pString(oData.Result.Plain);
|
||||
|
||||
bPgpEncrypted = /---BEGIN PGP MESSAGE---/.test(oMessage.plainRaw);
|
||||
bPgpEncrypted = /---BEGIN PGP MESSAGE---/.test(sPlain);
|
||||
if (!bPgpEncrypted)
|
||||
{
|
||||
bPgpSigned = /-----BEGIN PGP SIGNED MESSAGE-----/.test(oMessage.plainRaw) &&
|
||||
/-----BEGIN PGP SIGNATURE-----/.test(oMessage.plainRaw);
|
||||
bPgpSigned = /-----BEGIN PGP SIGNED MESSAGE-----/.test(sPlain) &&
|
||||
/-----BEGIN PGP SIGNATURE-----/.test(sPlain);
|
||||
}
|
||||
|
||||
Globals.$div.empty();
|
||||
|
|
@ -593,7 +608,7 @@
|
|||
{
|
||||
sResultHtml =
|
||||
Globals.$div.append(
|
||||
$('<pre class="b-plain-openpgp signed"></pre>').text(oMessage.plainRaw)
|
||||
$('<pre class="b-plain-openpgp signed"></pre>').text(sPlain)
|
||||
).html()
|
||||
;
|
||||
}
|
||||
|
|
@ -601,11 +616,13 @@
|
|||
{
|
||||
sResultHtml =
|
||||
Globals.$div.append(
|
||||
$('<pre class="b-plain-openpgp encrypted"></pre>').text(oMessage.plainRaw)
|
||||
$('<pre class="b-plain-openpgp encrypted"></pre>').text(sPlain)
|
||||
).html()
|
||||
;
|
||||
}
|
||||
|
||||
sPlain = '';
|
||||
|
||||
Globals.$div.empty();
|
||||
|
||||
oMessage.isPgpSigned(bPgpSigned);
|
||||
|
|
@ -624,8 +641,6 @@
|
|||
|
||||
oMessage.isHtml(!!bIsHtml);
|
||||
oMessage.hasImages(!!bHasExternals);
|
||||
oMessage.pgpSignedVerifyStatus(Enums.SignedVerifyStatus.None);
|
||||
oMessage.pgpSignedVerifyUser('');
|
||||
|
||||
oMessage.body = oBody;
|
||||
if (oMessage.body)
|
||||
|
|
@ -663,6 +678,8 @@
|
|||
|
||||
if (oBody)
|
||||
{
|
||||
this.initOpenPgpControls(oBody);
|
||||
|
||||
this.initBlockquoteSwitcher(oBody);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@
|
|||
var
|
||||
_ = require('_'),
|
||||
ko = require('ko'),
|
||||
$ = require('$'),
|
||||
kn = require('Knoin/Knoin'),
|
||||
|
||||
Translator = require('Common/Translator'),
|
||||
|
||||
Utils = require('Common/Utils')
|
||||
;
|
||||
|
|
@ -41,32 +45,41 @@
|
|||
PgpUserStore.prototype.findPublicKeyByHex = function (sHash)
|
||||
{
|
||||
return _.find(this.openpgpkeysPublic(), function (oItem) {
|
||||
return oItem && sHash === oItem.id;
|
||||
return sHash && oItem && sHash === oItem.id;
|
||||
});
|
||||
};
|
||||
|
||||
PgpUserStore.prototype.findPrivateKeyByHex = function (sHash)
|
||||
{
|
||||
return _.find(this.openpgpkeysPrivate(), function (oItem) {
|
||||
return sHash && oItem && sHash === oItem.id;
|
||||
});
|
||||
};
|
||||
|
||||
PgpUserStore.prototype.findPublicKeysByEmail = function (sEmail)
|
||||
{
|
||||
return _.compact(_.flatten(_.map(this.openpgpkeysPublic(), function (oItem) {
|
||||
var oKey = oItem && sEmail === oItem.email ? oItem : null;
|
||||
return oKey ? oKey.getNativeKeys() : [null];
|
||||
}), true));
|
||||
};
|
||||
|
||||
PgpUserStore.prototype.findPublicKeysBySigningKeyIds = function (aSigningKeyIds)
|
||||
{
|
||||
var self = this;
|
||||
return _.compact(_.map(this.openpgpkeysPublic(), function (oItem) {
|
||||
return _.compact(_.flatten(_.map(aSigningKeyIds, function (oId) {
|
||||
var oKey = oId && oId.toHex ? self.findPublicKeyByHex(oId.toHex()) : null;
|
||||
return oKey ? oKey.getNativeKeys() : [null];
|
||||
}), true));
|
||||
};
|
||||
|
||||
var oKey = null;
|
||||
if (oItem && sEmail === oItem.email)
|
||||
{
|
||||
try
|
||||
{
|
||||
oKey = self.openpgp.key.readArmored(oItem.armor);
|
||||
if (oKey && !oKey.err && oKey.keys && oKey.keys[0])
|
||||
{
|
||||
return oKey.keys[0];
|
||||
}
|
||||
}
|
||||
catch (e) {}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
}));
|
||||
PgpUserStore.prototype.findPrivateKeysByEncryptionKeyIds = function (aEncryptionKeyIds, bReturnWrapKeys)
|
||||
{
|
||||
var self = this;
|
||||
return _.compact(_.flatten(_.map(aEncryptionKeyIds, function (oId) {
|
||||
var oKey = oId && oId.toHex ? self.findPrivateKeyByHex(oId.toHex()) : null;
|
||||
return oKey ? (bReturnWrapKeys ? [oKey] : oKey.getNativeKeys()) : [null];
|
||||
}), true));
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -77,6 +90,7 @@
|
|||
PgpUserStore.prototype.findPrivateKeyByEmail = function (sEmail, sPassword)
|
||||
{
|
||||
var
|
||||
oPrivateKeys = [],
|
||||
oPrivateKey = null,
|
||||
oKey = _.find(this.openpgpkeysPrivate(), function (oItem) {
|
||||
return oItem && sEmail === oItem.email;
|
||||
|
|
@ -85,18 +99,15 @@
|
|||
|
||||
if (oKey)
|
||||
{
|
||||
oPrivateKeys = oKey.getNativeKeys();
|
||||
oPrivateKey = oPrivateKeys[0] || null;
|
||||
|
||||
try
|
||||
{
|
||||
oPrivateKey = this.openpgp.key.readArmored(oKey.armor);
|
||||
if (oPrivateKey && !oPrivateKey.err && oPrivateKey.keys && oPrivateKey.keys[0])
|
||||
if (oPrivateKey)
|
||||
{
|
||||
oPrivateKey = oPrivateKey.keys[0];
|
||||
oPrivateKey.decrypt(Utils.pString(sPassword));
|
||||
}
|
||||
else
|
||||
{
|
||||
oPrivateKey = null;
|
||||
}
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
|
|
@ -116,6 +127,259 @@
|
|||
return this.findPrivateKeyByEmail(require('Stores/User/Account').email(), sPassword);
|
||||
};
|
||||
|
||||
PgpUserStore.prototype.decryptMessage = function (oMessage, fCallback)
|
||||
{
|
||||
var self = this, aPrivateKeys = [], aEncryptionKeyIds = [];
|
||||
if (oMessage && oMessage.getSigningKeyIds)
|
||||
{
|
||||
aEncryptionKeyIds = oMessage.getEncryptionKeyIds();
|
||||
if (aEncryptionKeyIds)
|
||||
{
|
||||
aPrivateKeys = this.findPrivateKeysByEncryptionKeyIds(aEncryptionKeyIds, true);
|
||||
if (aPrivateKeys && 0 < aPrivateKeys.length)
|
||||
{
|
||||
kn.showScreenPopup(require('View/Popup/MessageOpenPgp'), [function (oDecriptedKey) {
|
||||
|
||||
if (oDecriptedKey)
|
||||
{
|
||||
var oPrivateKey = null, oDecryptedMessage = null;
|
||||
try
|
||||
{
|
||||
oDecryptedMessage = oMessage.decrypt(oDecriptedKey);
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
oDecryptedMessage = null;
|
||||
}
|
||||
|
||||
if (oDecryptedMessage)
|
||||
{
|
||||
oPrivateKey = self.findPrivateKeyByHex(oDecriptedKey.primaryKey.keyid.toHex());
|
||||
if (oPrivateKey)
|
||||
{
|
||||
self.verifyMessage(oDecryptedMessage, function (oValidKey, aSigningKeyIds) {
|
||||
fCallback(oPrivateKey, oDecryptedMessage, oValidKey || null, aSigningKeyIds || null);
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
fCallback(oPrivateKey, oDecryptedMessage);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
fCallback(oPrivateKey, oDecryptedMessage);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
fCallback(null, null);
|
||||
}
|
||||
|
||||
}, aPrivateKeys]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fCallback(null, null);
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
PgpUserStore.prototype.verifyMessage = function (oMessage, fCallback)
|
||||
{
|
||||
var oValid = null, aResult = [], aPublicKeys = [], aSigningKeyIds = [];
|
||||
if (oMessage && oMessage.getSigningKeyIds)
|
||||
{
|
||||
aSigningKeyIds = oMessage.getSigningKeyIds();
|
||||
if (aSigningKeyIds && 0 < aSigningKeyIds.length)
|
||||
{
|
||||
aPublicKeys = this.findPublicKeysBySigningKeyIds(aSigningKeyIds);
|
||||
if (aPublicKeys && 0 < aPublicKeys.length)
|
||||
{
|
||||
try
|
||||
{
|
||||
aResult = oMessage.verify(aPublicKeys);
|
||||
oValid = _.find(_.isArray(aResult) ? aResult : [], function (oItem) {
|
||||
return oItem && oItem.valid && oItem.keyid;
|
||||
});
|
||||
|
||||
if (oValid && oValid.keyid && oValid.keyid && oValid.keyid.toHex)
|
||||
{
|
||||
fCallback(this.findPublicKeyByHex(oValid.keyid.toHex()));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (e) {}
|
||||
}
|
||||
|
||||
fCallback(null, aSigningKeyIds);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
fCallback(null);
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {*} mDom
|
||||
*/
|
||||
PgpUserStore.prototype.controlsHelper = function (mDom, oVerControl, bSuccess, sTitle, sText)
|
||||
{
|
||||
if (bSuccess)
|
||||
{
|
||||
mDom.removeClass('error').addClass('success').attr('title', sTitle);
|
||||
oVerControl.removeClass('error').addClass('success').attr('title', sTitle);
|
||||
}
|
||||
else
|
||||
{
|
||||
mDom.removeClass('success').addClass('error').attr('title', sTitle);
|
||||
oVerControl.removeClass('success').addClass('error').attr('title', sTitle);
|
||||
}
|
||||
|
||||
if (undefined !== sText)
|
||||
{
|
||||
mDom.text(Utils.trim(sText.replace(/(\u200C|\u0002)/g, '')));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {*} mDom
|
||||
*/
|
||||
PgpUserStore.prototype.initMessageBodyControls = function (mDom)
|
||||
{
|
||||
if (mDom && !mDom.hasClass('inited'))
|
||||
{
|
||||
mDom.addClass('inited');
|
||||
|
||||
var
|
||||
self = this,
|
||||
bEncrypted = mDom.hasClass('encrypted'),
|
||||
bSigned = mDom.hasClass('signed'),
|
||||
oVerControl = null,
|
||||
sData = ''
|
||||
;
|
||||
|
||||
if (bEncrypted || bSigned)
|
||||
{
|
||||
sData = mDom.text();
|
||||
mDom.data('openpgp-original', sData);
|
||||
|
||||
if (bEncrypted)
|
||||
{
|
||||
oVerControl = $('<div class="b-openpgp-control"><i class="icon-lock"></i></div>')
|
||||
.attr('title', Translator.i18n('MESSAGE/PGP_ENCRYPTED_MESSAGE_DESC'));
|
||||
|
||||
oVerControl.on('click', function () {
|
||||
if ($(this).hasClass('success'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var oMessage = self.openpgp.message.readArmored(sData);
|
||||
if (oMessage && oMessage.getText && oMessage.verify && oMessage.decrypt)
|
||||
{
|
||||
self.decryptMessage(oMessage, function (oValidPrivateKey, oDecriptedMessage, oValidPublicKey, aSigningKeyIds) {
|
||||
|
||||
if (oDecriptedMessage)
|
||||
{
|
||||
if (oValidPublicKey)
|
||||
{
|
||||
self.controlsHelper(mDom, oVerControl, true, Translator.i18n('PGP_NOTIFICATIONS/GOOD_SIGNATURE', {
|
||||
'USER': oValidPublicKey.user + ' (' + oValidPublicKey.id + ')'
|
||||
}), oDecriptedMessage.getText());
|
||||
}
|
||||
else if (oValidPrivateKey)
|
||||
{
|
||||
var
|
||||
aKeyIds = Utils.isNonEmptyArray(aSigningKeyIds) ? aSigningKeyIds : null,
|
||||
sAdditional = aKeyIds ? _.compact(_.map(aKeyIds, function (oItem) {
|
||||
return oItem && oItem.toHex ? oItem.toHex() : null;
|
||||
})).join(', ') : ''
|
||||
;
|
||||
|
||||
self.controlsHelper(mDom, oVerControl, false,
|
||||
Translator.i18n('PGP_NOTIFICATIONS/UNVERIFIRED_SIGNATURE') +
|
||||
(sAdditional ? ' (' + sAdditional + ')' : ''),
|
||||
oDecriptedMessage.getText());
|
||||
}
|
||||
else
|
||||
{
|
||||
self.controlsHelper(mDom, oVerControl, false,
|
||||
Translator.i18n('PGP_NOTIFICATIONS/DECRYPTION_ERROR'));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
self.controlsHelper(mDom, oVerControl, false,
|
||||
Translator.i18n('PGP_NOTIFICATIONS/DECRYPTION_ERROR'));
|
||||
}
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
self.controlsHelper(mDom, oVerControl, false, Translator.i18n('PGP_NOTIFICATIONS/DECRYPTION_ERROR'));
|
||||
return false;
|
||||
|
||||
});
|
||||
}
|
||||
else if (bSigned)
|
||||
{
|
||||
oVerControl = $('<div class="b-openpgp-control"><i class="icon-lock"></i></div>')
|
||||
.attr('title', Translator.i18n('MESSAGE/PGP_SIGNED_MESSAGE_DESC'));
|
||||
|
||||
oVerControl.on('click', function () {
|
||||
|
||||
if ($(this).hasClass('success') || $(this).hasClass('error'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var oMessage = self.openpgp.cleartext.readArmored(sData);
|
||||
if (oMessage && oMessage.getText && oMessage.verify)
|
||||
{
|
||||
self.verifyMessage(oMessage, function (oValidKey, aSigningKeyIds) {
|
||||
if (oValidKey)
|
||||
{
|
||||
self.controlsHelper(mDom, oVerControl, true, Translator.i18n('PGP_NOTIFICATIONS/GOOD_SIGNATURE', {
|
||||
'USER': oValidKey.user + ' (' + oValidKey.id + ')'
|
||||
}), oMessage.getText());
|
||||
}
|
||||
else
|
||||
{
|
||||
var
|
||||
aKeyIds = Utils.isNonEmptyArray(aSigningKeyIds) ? aSigningKeyIds : null,
|
||||
sAdditional = aKeyIds ? _.compact(_.map(aKeyIds, function (oItem) {
|
||||
return oItem && oItem.toHex ? oItem.toHex() : null;
|
||||
})).join(', ') : ''
|
||||
;
|
||||
|
||||
self.controlsHelper(mDom, oVerControl, false,
|
||||
Translator.i18n('PGP_NOTIFICATIONS/UNVERIFIRED_SIGNATURE') +
|
||||
(sAdditional ? ' (' + sAdditional + ')' : ''));
|
||||
}
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
self.controlsHelper(mDom, oVerControl, false, Translator.i18n('PGP_NOTIFICATIONS/DECRYPTION_ERROR'));
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
if (oVerControl)
|
||||
{
|
||||
mDom.before(oVerControl).before('<div></div>');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = new PgpUserStore();
|
||||
|
||||
}());
|
||||
|
|
|
|||
|
|
@ -407,6 +407,16 @@ html.rl-no-preview-pane {
|
|||
display: inline-block;
|
||||
padding: 6px 10px;
|
||||
border: 1px dashed #666;
|
||||
background: #fff;
|
||||
|
||||
&.success {
|
||||
border-color: green;
|
||||
background-color: rgba(0, 255, 0, 0.03);
|
||||
}
|
||||
&.error {
|
||||
border-color: red;
|
||||
background-color: rgba(255, 0, 0, 0.03);
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
|
|
@ -424,6 +434,27 @@ html.rl-no-preview-pane {
|
|||
color: red;
|
||||
}
|
||||
}
|
||||
|
||||
.b-openpgp-control {
|
||||
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
color: #777;
|
||||
/*float: left;*/
|
||||
|
||||
&:hover {
|
||||
color: #111;
|
||||
}
|
||||
|
||||
&.success {
|
||||
color: green;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
.popups {
|
||||
.b-open-pgp-key-view-content, .b-open-pgp-key-generate-content, .b-open-pgp-key-add-content, .b-compose-open-pgp-content {
|
||||
.b-open-pgp-key-view-content, .b-open-pgp-key-generate-content, .b-open-pgp-key-add-content, .b-compose-open-pgp-content, .b-message-open-pgp-content {
|
||||
|
||||
.modal-header {
|
||||
background-color: #fff;
|
||||
|
|
@ -18,4 +18,35 @@
|
|||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.b-message-open-pgp-content {
|
||||
&.modal {
|
||||
width: 700px;
|
||||
}
|
||||
|
||||
.key-list {
|
||||
|
||||
margin-top: 5px;
|
||||
overflow: hidden;
|
||||
|
||||
&__item {
|
||||
|
||||
color: #555;
|
||||
cursor: pointer;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&__radio {
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
|
||||
&:hover {
|
||||
border-bottom: 1px dashed #555;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -632,7 +632,7 @@
|
|||
oEditor.setPlain(sResult);
|
||||
});
|
||||
},
|
||||
this.oEditor.getData(),
|
||||
this.oEditor.getData(false, true),
|
||||
this.currentIdentity(),
|
||||
this.to(),
|
||||
this.cc(),
|
||||
|
|
|
|||
|
|
@ -109,25 +109,20 @@
|
|||
|
||||
if (self.resultCallback && bResult)
|
||||
{
|
||||
try {
|
||||
|
||||
var oPromise = null;
|
||||
try
|
||||
{
|
||||
if (oPrivateKey && 0 === aPublicKeys.length)
|
||||
{
|
||||
self.resultCallback(
|
||||
PgpStore.openpgp.signClearMessage([oPrivateKey], self.text())
|
||||
);
|
||||
oPromise = PgpStore.openpgp.signClearMessage([oPrivateKey], self.text());
|
||||
}
|
||||
else if (oPrivateKey && 0 < aPublicKeys.length)
|
||||
{
|
||||
self.resultCallback(
|
||||
PgpStore.openpgp.signAndEncryptMessage(aPublicKeys, oPrivateKey, self.text())
|
||||
);
|
||||
oPromise = PgpStore.openpgp.signAndEncryptMessage(aPublicKeys, oPrivateKey, self.text());
|
||||
}
|
||||
else if (!oPrivateKey && 0 < aPublicKeys.length)
|
||||
{
|
||||
self.resultCallback(
|
||||
PgpStore.openpgp.encryptMessage(aPublicKeys, self.text())
|
||||
);
|
||||
oPromise = PgpStore.openpgp.encryptMessage(aPublicKeys, self.text());
|
||||
}
|
||||
}
|
||||
catch (e)
|
||||
|
|
@ -135,14 +130,30 @@
|
|||
self.notification(Translator.i18n('PGP_NOTIFICATIONS/PGP_ERROR', {
|
||||
'ERROR': '' + e
|
||||
}));
|
||||
|
||||
bResult = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (bResult)
|
||||
{
|
||||
self.cancelCommand();
|
||||
if (oPromise)
|
||||
{
|
||||
try
|
||||
{
|
||||
oPromise.then(function (mData) {
|
||||
|
||||
self.resultCallback(mData);
|
||||
self.cancelCommand();
|
||||
|
||||
})['catch'](function (e) {
|
||||
self.notification(Translator.i18n('PGP_NOTIFICATIONS/PGP_ERROR', {
|
||||
'ERROR': '' + e
|
||||
}));
|
||||
});
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
self.notification(Translator.i18n('PGP_NOTIFICATIONS/PGP_ERROR', {
|
||||
'ERROR': '' + e
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.submitRequest(false);
|
||||
|
|
|
|||
179
dev/View/Popup/MessageOpenPgp.js
Normal file
179
dev/View/Popup/MessageOpenPgp.js
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
|
||||
(function () {
|
||||
|
||||
'use strict';
|
||||
|
||||
var
|
||||
_ = require('_'),
|
||||
ko = require('ko'),
|
||||
key = require('key'),
|
||||
|
||||
Utils = require('Common/Utils'),
|
||||
Enums = require('Common/Enums'),
|
||||
|
||||
kn = require('Knoin/Knoin'),
|
||||
AbstractView = require('Knoin/AbstractView')
|
||||
;
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
* @extends AbstractView
|
||||
*/
|
||||
function MessageOpenPgpPopupView()
|
||||
{
|
||||
AbstractView.call(this, 'Popups', 'PopupsMessageOpenPgp');
|
||||
|
||||
this.notification = ko.observable('');
|
||||
|
||||
this.selectedKey = ko.observable(null);
|
||||
this.privateKeys = ko.observableArray([]);
|
||||
|
||||
this.password = ko.observable('');
|
||||
this.password.focus = ko.observable(false);
|
||||
this.buttonFocus = ko.observable(false);
|
||||
|
||||
this.resultCallback = null;
|
||||
|
||||
this.submitRequest = ko.observable(false);
|
||||
|
||||
// commands
|
||||
this.doCommand = Utils.createCommand(this, function () {
|
||||
|
||||
this.submitRequest(true);
|
||||
|
||||
_.delay(_.bind(function() {
|
||||
|
||||
var
|
||||
oPrivateKeys = [],
|
||||
oPrivateKey = null
|
||||
;
|
||||
|
||||
try
|
||||
{
|
||||
if (this.resultCallback && this.selectedKey())
|
||||
{
|
||||
oPrivateKeys = this.selectedKey().getNativeKeys();
|
||||
oPrivateKey = oPrivateKeys && oPrivateKeys[0] ? oPrivateKeys[0] : null;
|
||||
|
||||
if (oPrivateKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!oPrivateKey.decrypt(Utils.pString(this.password())))
|
||||
{
|
||||
oPrivateKey = null;
|
||||
}
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
oPrivateKey = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (oExc)
|
||||
{
|
||||
oPrivateKey = null;
|
||||
}
|
||||
|
||||
this.submitRequest(false);
|
||||
|
||||
this.cancelCommand();
|
||||
this.resultCallback(oPrivateKey);
|
||||
|
||||
}, this), 100);
|
||||
|
||||
}, function () {
|
||||
return !this.submitRequest();
|
||||
});
|
||||
|
||||
this.sDefaultKeyScope = Enums.KeyState.PopupMessageOpenPGP;
|
||||
|
||||
kn.constructorEnd(this);
|
||||
}
|
||||
|
||||
kn.extendAsViewModel(['View/Popup/MessageOpenPgp'], MessageOpenPgpPopupView);
|
||||
_.extend(MessageOpenPgpPopupView.prototype, AbstractView.prototype);
|
||||
|
||||
MessageOpenPgpPopupView.prototype.clearPopup = function ()
|
||||
{
|
||||
this.notification('');
|
||||
|
||||
this.password('');
|
||||
this.password.focus(false);
|
||||
this.buttonFocus(false);
|
||||
|
||||
this.selectedKey(false);
|
||||
this.submitRequest(false);
|
||||
|
||||
this.resultCallback = null;
|
||||
this.privateKeys([]);
|
||||
};
|
||||
|
||||
MessageOpenPgpPopupView.prototype.onBuild = function (oDom)
|
||||
{
|
||||
key('tab,shift+tab', Enums.KeyState.PopupMessageOpenPGP, _.bind(function () {
|
||||
|
||||
switch (true)
|
||||
{
|
||||
case this.password.focus():
|
||||
this.buttonFocus(true);
|
||||
break;
|
||||
case this.buttonFocus():
|
||||
this.password.focus(true);
|
||||
break;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
}, this));
|
||||
|
||||
var self = this;
|
||||
|
||||
oDom
|
||||
.on('click', '.key-list__item', function () {
|
||||
|
||||
oDom.find('.key-list__item .key-list__item__radio')
|
||||
.addClass('icon-radio-unchecked')
|
||||
.removeClass('icon-radio-checked')
|
||||
;
|
||||
|
||||
$(this).find('.key-list__item__radio')
|
||||
.removeClass('icon-radio-unchecked')
|
||||
.addClass('icon-radio-checked')
|
||||
;
|
||||
|
||||
self.selectedKey(ko.dataFor(this));
|
||||
|
||||
self.password.focus(true);
|
||||
})
|
||||
;
|
||||
};
|
||||
|
||||
MessageOpenPgpPopupView.prototype.onHideWithDelay = function ()
|
||||
{
|
||||
this.clearPopup();
|
||||
};
|
||||
|
||||
MessageOpenPgpPopupView.prototype.onShowWithDelay = function ()
|
||||
{
|
||||
this.password.focus(true);
|
||||
// this.buttonFocus(true);
|
||||
};
|
||||
|
||||
MessageOpenPgpPopupView.prototype.onShow = function (fCallback, aPrivateKeys)
|
||||
{
|
||||
this.clearPopup();
|
||||
|
||||
this.resultCallback = fCallback;
|
||||
this.privateKeys(aPrivateKeys);
|
||||
|
||||
if (this.viewModelDom)
|
||||
{
|
||||
this.viewModelDom.find('.key-list__item').first().click();
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = MessageOpenPgpPopupView;
|
||||
|
||||
}());
|
||||
|
|
@ -62,29 +62,36 @@
|
|||
|
||||
_.delay(function () {
|
||||
|
||||
mKeyPair = false;
|
||||
var mPromise = false;
|
||||
|
||||
try {
|
||||
mKeyPair = PgpStore.openpgp.generateKeyPair({
|
||||
|
||||
mPromise = PgpStore.openpgp.generateKeyPair({
|
||||
'userId': sUserID,
|
||||
'numBits': Utils.pInt(self.keyBitLength()),
|
||||
'passphrase': Utils.trim(self.password())
|
||||
});
|
||||
} catch (e) {
|
||||
// window.console.log(e);
|
||||
}
|
||||
|
||||
if (mKeyPair && mKeyPair.privateKeyArmored)
|
||||
{
|
||||
oOpenpgpKeyring.privateKeys.importKey(mKeyPair.privateKeyArmored);
|
||||
oOpenpgpKeyring.publicKeys.importKey(mKeyPair.publicKeyArmored);
|
||||
oOpenpgpKeyring.store();
|
||||
mPromise.then(function () {
|
||||
|
||||
require('App/User').reloadOpenPgpKeys();
|
||||
Utils.delegateRun(self, 'cancelCommand');
|
||||
}
|
||||
self.submitRequest(false);
|
||||
|
||||
if (mKeyPair && mKeyPair.privateKeyArmored)
|
||||
{
|
||||
oOpenpgpKeyring.privateKeys.importKey(mKeyPair.privateKeyArmored);
|
||||
oOpenpgpKeyring.publicKeys.importKey(mKeyPair.publicKeyArmored);
|
||||
oOpenpgpKeyring.store();
|
||||
|
||||
require('App/User').reloadOpenPgpKeys();
|
||||
Utils.delegateRun(self, 'cancelCommand');
|
||||
}
|
||||
|
||||
})['catch'](function() {
|
||||
self.submitRequest(false);
|
||||
});
|
||||
|
||||
} catch (e) {}
|
||||
|
||||
self.submitRequest(false);
|
||||
|
||||
}, 100);
|
||||
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -303,16 +303,6 @@
|
|||
this.viewIsImportant = ko.observable(false);
|
||||
this.viewIsFlagged = ko.observable(false);
|
||||
|
||||
// PGP
|
||||
this.viewPgpPassword = ko.observable('');
|
||||
this.viewPgpSignedVerifyStatus = ko.computed(function () {
|
||||
return this.message() ? this.message().pgpSignedVerifyStatus() : Enums.SignedVerifyStatus.None;
|
||||
}, this);
|
||||
|
||||
this.viewPgpSignedVerifyUser = ko.computed(function () {
|
||||
return this.message() ? this.message().pgpSignedVerifyUser() : '';
|
||||
}, this);
|
||||
|
||||
this.viewFromDkimStatusIconClass = ko.computed(function () {
|
||||
|
||||
var sResult = 'icon-none iconcolor-display-none';
|
||||
|
|
@ -361,8 +351,6 @@
|
|||
|
||||
this.messageActiveDom(null);
|
||||
|
||||
this.viewPgpPassword('');
|
||||
|
||||
if (oMessage)
|
||||
{
|
||||
this.showAttachmnetControls(false);
|
||||
|
|
@ -523,48 +511,6 @@
|
|||
return sResult;
|
||||
};
|
||||
|
||||
MessageViewMailBoxUserView.prototype.isPgpActionVisible = function ()
|
||||
{
|
||||
return Enums.SignedVerifyStatus.Success !== this.viewPgpSignedVerifyStatus();
|
||||
};
|
||||
|
||||
MessageViewMailBoxUserView.prototype.isPgpStatusVerifyVisible = function ()
|
||||
{
|
||||
return Enums.SignedVerifyStatus.None !== this.viewPgpSignedVerifyStatus();
|
||||
};
|
||||
|
||||
MessageViewMailBoxUserView.prototype.isPgpStatusVerifySuccess = function ()
|
||||
{
|
||||
return Enums.SignedVerifyStatus.Success === this.viewPgpSignedVerifyStatus();
|
||||
};
|
||||
|
||||
MessageViewMailBoxUserView.prototype.pgpStatusVerifyMessage = function ()
|
||||
{
|
||||
var sResult = '';
|
||||
switch (this.viewPgpSignedVerifyStatus())
|
||||
{
|
||||
case Enums.SignedVerifyStatus.UnknownPublicKeys:
|
||||
sResult = Translator.i18n('PGP_NOTIFICATIONS/NO_PUBLIC_KEYS_FOUND');
|
||||
break;
|
||||
case Enums.SignedVerifyStatus.UnknownPrivateKey:
|
||||
sResult = Translator.i18n('PGP_NOTIFICATIONS/NO_PRIVATE_KEY_FOUND');
|
||||
break;
|
||||
case Enums.SignedVerifyStatus.Unverified:
|
||||
sResult = Translator.i18n('PGP_NOTIFICATIONS/UNVERIFIRED_SIGNATURE');
|
||||
break;
|
||||
case Enums.SignedVerifyStatus.Error:
|
||||
sResult = Translator.i18n('PGP_NOTIFICATIONS/DECRYPTION_ERROR');
|
||||
break;
|
||||
case Enums.SignedVerifyStatus.Success:
|
||||
sResult = Translator.i18n('PGP_NOTIFICATIONS/GOOD_SIGNATURE', {
|
||||
'USER': this.viewPgpSignedVerifyUser()
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
return sResult;
|
||||
};
|
||||
|
||||
MessageViewMailBoxUserView.prototype.fullScreen = function ()
|
||||
{
|
||||
this.fullScreenMode(true);
|
||||
|
|
@ -1256,31 +1202,6 @@
|
|||
return 0 < iCnt ? (100 > iCnt ? iCnt : '99+') : '';
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* @param {MessageModel} oMessage
|
||||
*/
|
||||
MessageViewMailBoxUserView.prototype.verifyPgpSignedClearMessage = function (oMessage)
|
||||
{
|
||||
if (oMessage)
|
||||
{
|
||||
oMessage.verifyPgpSignedClearMessage();
|
||||
}
|
||||
|
||||
this.checkHeaderHeight();
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {MessageModel} oMessage
|
||||
*/
|
||||
MessageViewMailBoxUserView.prototype.decryptPgpEncryptedMessage = function (oMessage)
|
||||
{
|
||||
if (oMessage)
|
||||
{
|
||||
oMessage.decryptPgpEncryptedMessage(this.viewPgpPassword());
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {MessageModel} oMessage
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue