manage renewals perfectly :-)
This commit is contained in:
parent
a6b1b5cfa6
commit
563f3ae3eb
7
index.js
7
index.js
|
@ -137,8 +137,11 @@ LE.create = function (defaults, handlers, backend) {
|
||||||
// If you do not check these things, then someone could attack you
|
// If you do not check these things, then someone could attack you
|
||||||
// and cause you, in return, to have your ip be rate-limit blocked
|
// and cause you, in return, to have your ip be rate-limit blocked
|
||||||
//
|
//
|
||||||
console.warn("\n[TODO]: node-letsencrypt: `validate(hostnames, cb)` needs to be implemented");
|
//console.warn("\n[TODO]: node-letsencrypt: `validate(hostnames, cb)` needs to be implemented");
|
||||||
console.warn("(it'll work fine without it, but for security - and convenience - it should be implemented\n");
|
//console.warn("(it'll work fine without it, but for security - and convenience - it should be implemented\n");
|
||||||
|
// UPDATE:
|
||||||
|
// it's actually probably better that we don't do this here and instead
|
||||||
|
// take care of it in the approveRegistrationCallback in letsencrypt-express
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
}
|
}
|
||||||
, _registerHelper: function (args, cb) {
|
, _registerHelper: function (args, cb) {
|
||||||
|
|
|
@ -72,7 +72,8 @@ function createAccount(args, handlers) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAccount(accountId, args, handlers) {
|
function getAccount(args, handlers) {
|
||||||
|
var accountId = args.accountId;
|
||||||
var accountDir = path.join(args.accountsDir, accountId);
|
var accountDir = path.join(args.accountsDir, accountId);
|
||||||
var files = {};
|
var files = {};
|
||||||
var configs = ['meta.json', 'private_key.json', 'regr.json'];
|
var configs = ['meta.json', 'private_key.json', 'regr.json'];
|
||||||
|
|
242
lib/core.js
242
lib/core.js
|
@ -31,23 +31,137 @@ function getAcmeUrls(args) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeCertificateAsync(result, args, defaults, handlers) {
|
function readRenewalConfig(args) {
|
||||||
|
var pyconf = PromiseA.promisifyAll(require('pyconf'));
|
||||||
|
|
||||||
|
return pyconf.readFileAsync(args.renewalPath).then(function (pyobj) {
|
||||||
|
return pyobj;
|
||||||
|
}, function () {
|
||||||
|
return pyconf.readFileAsync(path.join(__dirname, 'renewal.conf.tpl')).then(function (pyobj) {
|
||||||
|
return pyobj;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeRenewalConfig(args) {
|
||||||
|
var pyobj = args.pyobj;
|
||||||
|
pyobj.checkpoints = parseInt(pyobj.checkpoints, 10) || 0;
|
||||||
|
|
||||||
|
var pyconf = PromiseA.promisifyAll(require('pyconf'));
|
||||||
|
|
||||||
|
var liveDir = args.liveDir || path.join(args.configDir, 'live', args.domains[0]);
|
||||||
|
|
||||||
|
var certPath = args.certPath || pyobj.cert || path.join(liveDir, 'cert.pem');
|
||||||
|
var fullchainPath = args.fullchainPath || pyobj.fullchain || path.join(liveDir, 'fullchain.pem');
|
||||||
|
var chainPath = args.chainPath || pyobj.chain || path.join(liveDir, 'chain.pem');
|
||||||
|
var privkeyPath = args.privkeyPath || pyobj.privkey
|
||||||
|
//|| args.domainPrivateKeyPath || args.domainKeyPath || pyobj.keyPath
|
||||||
|
|| path.join(liveDir, 'privkey.pem');
|
||||||
|
|
||||||
|
if (args.debug) {
|
||||||
|
console.log('################ privkeyPath ################');
|
||||||
|
console.log(privkeyPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var updates = {
|
||||||
|
account: args.account.id
|
||||||
|
, configDir: args.configDir
|
||||||
|
, domains: args.domains
|
||||||
|
|
||||||
|
, email: args.email
|
||||||
|
, tos: args.agreeTos && true
|
||||||
|
// yes, it's an array. weird, right?
|
||||||
|
, webrootPath: args.webrootPath && [args.webrootPath] || []
|
||||||
|
, server: args.server || args.acmeDiscoveryUrl
|
||||||
|
|
||||||
|
, privkey: privkeyPath
|
||||||
|
, fullchain: fullchainPath
|
||||||
|
, cert: certPath
|
||||||
|
, chain: chainPath
|
||||||
|
|
||||||
|
, http01Port: args.http01Port
|
||||||
|
, keyPath: args.domainPrivateKeyPath || args.privkeyPath
|
||||||
|
, rsaKeySize: args.rsaKeySize
|
||||||
|
, checkpoints: pyobj.checkpoints
|
||||||
|
/* // TODO XXX what's the deal with these? they don't make sense
|
||||||
|
// are they just old junk? or do they have a meaning that I don't know about?
|
||||||
|
, fullchainPath: path.join(args.configDir, 'chain.pem')
|
||||||
|
, certPath: path.join(args.configDir, 'cert.pem')
|
||||||
|
, chainPath: path.join(args.configDir, 'chain.pem')
|
||||||
|
*/ // TODO XXX end
|
||||||
|
, workDir: args.workDir
|
||||||
|
, logsDir: args.logsDir
|
||||||
|
};
|
||||||
|
|
||||||
|
// final section is completely dynamic
|
||||||
|
// :hostname = :webroot_path
|
||||||
|
args.domains.forEach(function (hostname) {
|
||||||
|
updates[hostname] = args.webrootPath;
|
||||||
|
});
|
||||||
|
|
||||||
|
// must write back to the original pyobject or
|
||||||
|
// annotations will be lost
|
||||||
|
Object.keys(updates).forEach(function (key) {
|
||||||
|
pyobj[key] = updates[key];
|
||||||
|
});
|
||||||
|
|
||||||
|
return mkdirpAsync(path.dirname(args.renewalPath)).then(function () {
|
||||||
|
return pyconf.writeFileAsync(args.renewalPath, pyobj);
|
||||||
|
}).then(function () {
|
||||||
|
return pyobj;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrCreateRenewal(args) {
|
||||||
|
return readRenewalConfig(args).then(function (pyobj) {
|
||||||
|
var minver = pyobj.checkpoints >= 0;
|
||||||
|
|
||||||
|
args.pyobj = pyobj;
|
||||||
|
|
||||||
|
if (!minver) {
|
||||||
|
args.checkpoints = 0;
|
||||||
|
pyobj.checkpoints = 0;
|
||||||
|
return writeRenewalConfig(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
// args.account.id = pyobj.account
|
||||||
|
// args.configDir = args.configDir || pyobj.configDir;
|
||||||
|
|
||||||
|
args.checkpoints = pyobj.checkpoints;
|
||||||
|
|
||||||
|
args.agreeTos = (args.agreeTos || pyobj.tos) && true;
|
||||||
|
args.email = args.email || pyobj.email;
|
||||||
|
args.domains = args.domains || pyobj.domains;
|
||||||
|
|
||||||
|
// yes, it's an array. weird, right?
|
||||||
|
args.webrootPath = args.webrootPath || pyobj.webrootPath[0];
|
||||||
|
args.server = args.server || args.acmeDiscoveryUrl || pyobj.server;
|
||||||
|
|
||||||
|
args.certPath = args.certPath || pyobj.cert;
|
||||||
|
args.privkeyPath = args.privkeyPath || pyobj.privkey;
|
||||||
|
args.chainPath = args.chainPath || pyobj.chain;
|
||||||
|
args.fullchainPath = args.fullchainPath || pyobj.fullchain;
|
||||||
|
|
||||||
|
//, workDir: args.workDir
|
||||||
|
//, logsDir: args.logsDir
|
||||||
|
args.rsaKeySize = args.rsaKeySize || pyobj.rsaKeySize;
|
||||||
|
args.http01Port = args.http01Port || pyobj.http01Port;
|
||||||
|
args.domainKeyPath = args.domainPrivateKeyPath || args.domainKeyPath || args.keyPath || pyobj.keyPath;
|
||||||
|
|
||||||
|
return writeRenewalConfig(args);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCertificateAsync(args, defaults, handlers) {
|
||||||
if (args.debug) {
|
if (args.debug) {
|
||||||
console.log("got certificate!");
|
console.log("got certificate!");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var obj = args.pyobj;
|
||||||
|
var result = args.pems;
|
||||||
|
|
||||||
result.fullchain = result.cert + '\n' + result.ca;
|
result.fullchain = result.cert + '\n' + result.ca;
|
||||||
|
obj.checkpoints = parseInt(obj.checkpoints, 10) || 0;
|
||||||
var pyconf = PromiseA.promisifyAll(require('pyconf'));
|
|
||||||
|
|
||||||
return pyconf.readFileAsync(args.renewalPath).then(function (obj) {
|
|
||||||
return obj;
|
|
||||||
}, function () {
|
|
||||||
return pyconf.readFileAsync(path.join(__dirname, 'renewal.conf.tpl')).then(function (obj) {
|
|
||||||
return obj;
|
|
||||||
});
|
|
||||||
}).then(function (obj) {
|
|
||||||
obj.checkpoint = parseInt(obj.checkpoint, 10) || 0;
|
|
||||||
|
|
||||||
var liveDir = args.liveDir || path.join(args.configDir, 'live', args.domains[0]);
|
var liveDir = args.liveDir || path.join(args.configDir, 'live', args.domains[0]);
|
||||||
|
|
||||||
|
@ -65,11 +179,11 @@ function writeCertificateAsync(result, args, defaults, handlers) {
|
||||||
|
|
||||||
var archiveDir = args.archiveDir || path.join(args.configDir, 'archive', args.domains[0]);
|
var archiveDir = args.archiveDir || path.join(args.configDir, 'archive', args.domains[0]);
|
||||||
|
|
||||||
var checkpoint = obj.checkpoint.toString();
|
var checkpoints = obj.checkpoints.toString();
|
||||||
var certArchive = path.join(archiveDir, 'cert' + checkpoint + '.pem');
|
var certArchive = path.join(archiveDir, 'cert' + checkpoints + '.pem');
|
||||||
var fullchainArchive = path.join(archiveDir, 'fullchain' + checkpoint + '.pem');
|
var fullchainArchive = path.join(archiveDir, 'fullchain' + checkpoints + '.pem');
|
||||||
var chainArchive = path.join(archiveDir, 'chain'+ checkpoint + '.pem');
|
var chainArchive = path.join(archiveDir, 'chain'+ checkpoints + '.pem');
|
||||||
var privkeyArchive = path.join(archiveDir, 'privkey' + checkpoint + '.pem');
|
var privkeyArchive = path.join(archiveDir, 'privkey' + checkpoints + '.pem');
|
||||||
|
|
||||||
return mkdirpAsync(archiveDir).then(function () {
|
return mkdirpAsync(archiveDir).then(function () {
|
||||||
return PromiseA.all([
|
return PromiseA.all([
|
||||||
|
@ -88,51 +202,10 @@ function writeCertificateAsync(result, args, defaults, handlers) {
|
||||||
, sfs.writeFileAsync(privkeyPath, result.key || result.privkey || args.domainPrivateKeyPem, 'ascii')
|
, sfs.writeFileAsync(privkeyPath, result.key || result.privkey || args.domainPrivateKeyPem, 'ascii')
|
||||||
]);
|
]);
|
||||||
}).then(function () {
|
}).then(function () {
|
||||||
obj.checkpoint += 1;
|
obj.checkpoints += 1;
|
||||||
|
args.checkpoints += 1;
|
||||||
|
|
||||||
var updates = {
|
return writeRenewalConfig(args);
|
||||||
account: args.accountId || args.account.id
|
|
||||||
|
|
||||||
, cert: certPath
|
|
||||||
, privkey: privkeyPath
|
|
||||||
, chain: chainPath
|
|
||||||
, fullchain: fullchainPath
|
|
||||||
, configDir: args.configDir
|
|
||||||
, workDir: args.workDir
|
|
||||||
, tos: args.agreeTos && true
|
|
||||||
, http01Port: args.http01Port
|
|
||||||
, keyPath: args.domainPrivateKeyPath || args.privkeyPath
|
|
||||||
, email: args.email
|
|
||||||
, domains: args.domains
|
|
||||||
, rsaKeySize: args.rsaKeySize
|
|
||||||
, checkpoints: obj.checkpoint
|
|
||||||
// TODO XXX what's the deal with these? they don't make sense
|
|
||||||
// are they just old junk? or do they have a meaning that I don't know about?
|
|
||||||
, fullchainPath: path.join(args.configDir, 'chain.pem')
|
|
||||||
, certPath: path.join(args.configDir, 'cert.pem')
|
|
||||||
, chainPath: path.join(args.configDir, 'chain.pem')
|
|
||||||
// TODO XXX end
|
|
||||||
// yes, it's an array. weird, right?
|
|
||||||
, webrootPath: args.webrootPath && [args.webrootPath] || []
|
|
||||||
, server: args.server || args.acmeDiscoveryUrl
|
|
||||||
, logsDir: args.logsDir
|
|
||||||
};
|
|
||||||
|
|
||||||
// final section is completely dynamic
|
|
||||||
// :hostname = :webroot_path
|
|
||||||
args.domains.forEach(function (hostname) {
|
|
||||||
updates[hostname] = args.webrootPath;
|
|
||||||
});
|
|
||||||
|
|
||||||
// must write back to the original object or
|
|
||||||
// annotations will be lost
|
|
||||||
Object.keys(updates).forEach(function (key) {
|
|
||||||
obj[key] = updates[key];
|
|
||||||
});
|
|
||||||
|
|
||||||
return mkdirpAsync(path.dirname(args.renewalPath)).then(function () {
|
|
||||||
return pyconf.writeFileAsync(args.renewalPath, obj);
|
|
||||||
});
|
|
||||||
}).then(function () {
|
}).then(function () {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -152,10 +225,11 @@ function writeCertificateAsync(result, args, defaults, handlers) {
|
||||||
, lifetime: defaults.lifetime || handlers.lifetime
|
, lifetime: defaults.lifetime || handlers.lifetime
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCertificateAsync(account, args, defaults, handlers) {
|
function getCertificateAsync(args, defaults, handlers) {
|
||||||
|
var account = args.account;
|
||||||
|
|
||||||
return leCrypto.generateRsaKeypairAsync(args.rsaKeySize, 65537).then(function (domainKey) {
|
return leCrypto.generateRsaKeypairAsync(args.rsaKeySize, 65537).then(function (domainKey) {
|
||||||
if (args.debug) {
|
if (args.debug) {
|
||||||
console.log("get certificate");
|
console.log("get certificate");
|
||||||
|
@ -214,11 +288,12 @@ function getCertificateAsync(account, args, defaults, handlers) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}).then(function (results) {
|
}).then(function (results) {
|
||||||
return writeCertificateAsync(results, args, defaults, handlers);
|
args.pems = results;
|
||||||
|
return writeCertificateAsync(args, defaults, handlers);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOrCreateDomainCertificate(account, args, defaults, handlers) {
|
function getOrCreateDomainCertificate(args, defaults, handlers) {
|
||||||
return fetchFromConfigLiveDir(args).then(function (certs) {
|
return fetchFromConfigLiveDir(args).then(function (certs) {
|
||||||
// if nothing, register and save
|
// if nothing, register and save
|
||||||
// if something, check date (don't register unless 30+ days)
|
// if something, check date (don't register unless 30+ days)
|
||||||
|
@ -228,15 +303,15 @@ function getOrCreateDomainCertificate(account, args, defaults, handlers) {
|
||||||
//console.log(certs);
|
//console.log(certs);
|
||||||
if (!certs) {
|
if (!certs) {
|
||||||
// no certs, seems like a good time to get some
|
// no certs, seems like a good time to get some
|
||||||
return getCertificateAsync(account, args, defaults, handlers);
|
return getCertificateAsync(args, defaults, handlers);
|
||||||
}
|
}
|
||||||
else if (certs.issuedAt > (27 * 24 * 60 * 60 * 1000)) {
|
else if (certs.issuedAt > (27 * 24 * 60 * 60 * 1000)) {
|
||||||
// cert is at least 27 days old we can renew that
|
// cert is at least 27 days old we can renew that
|
||||||
return getCertificateAsync(account, args, defaults, handlers);
|
return getCertificateAsync(args, defaults, handlers);
|
||||||
}
|
}
|
||||||
else if (args.duplicate) {
|
else if (args.duplicate) {
|
||||||
// YOLO! I be gettin' fresh certs 'erday! Yo!
|
// YOLO! I be gettin' fresh certs 'erday! Yo!
|
||||||
return getCertificateAsync(account, args, defaults, handlers);
|
return getCertificateAsync(args, defaults, handlers);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
console.warn('[WARN] Ignoring renewal attempt for certificate less than 27 days old. Use args.duplicate to force.');
|
console.warn('[WARN] Ignoring renewal attempt for certificate less than 27 days old. Use args.duplicate to force.');
|
||||||
|
@ -244,16 +319,10 @@ function getOrCreateDomainCertificate(account, args, defaults, handlers) {
|
||||||
return certs;
|
return certs;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
function getOrCreateAcmeAccount(args, defaults, handlers) {
|
function getOrCreateAcmeAccount(args, defaults, handlers) {
|
||||||
var pyconf = PromiseA.promisifyAll(require('pyconf'));
|
var pyconf = PromiseA.promisifyAll(require('pyconf'));
|
||||||
var server = args.server;
|
|
||||||
var acmeHostname = require('url').parse(server).hostname;
|
|
||||||
var configDir = args.configDir;
|
|
||||||
|
|
||||||
args.renewalPath = args.renewalPath || path.join(configDir, 'renewal', args.domains[0] + '.conf');
|
|
||||||
args.accountsDir = args.accountsDir || path.join(configDir, 'accounts', acmeHostname, 'directory');
|
|
||||||
|
|
||||||
return pyconf.readFileAsync(args.renewalPath).then(function (renewal) {
|
return pyconf.readFileAsync(args.renewalPath).then(function (renewal) {
|
||||||
var accountId = renewal.account;
|
var accountId = renewal.account;
|
||||||
|
@ -279,7 +348,8 @@ function getOrCreateAcmeAccount(args, defaults, handlers) {
|
||||||
if (args.debug) {
|
if (args.debug) {
|
||||||
console.log('[LE] use account');
|
console.log('[LE] use account');
|
||||||
}
|
}
|
||||||
return Accounts.getAccount(accountId, args, handlers);
|
args.accountId = accountId;
|
||||||
|
return Accounts.getAccount(args, handlers);
|
||||||
} else {
|
} else {
|
||||||
if (args.debug) {
|
if (args.debug) {
|
||||||
console.log('[LE] create account');
|
console.log('[LE] create account');
|
||||||
|
@ -322,13 +392,25 @@ module.exports.create = function (defaults, handlers) {
|
||||||
copy = merge(args, defaults);
|
copy = merge(args, defaults);
|
||||||
tplCopy(copy);
|
tplCopy(copy);
|
||||||
|
|
||||||
if (args.debug) {
|
if (copy.debug) {
|
||||||
console.log('[LE DEBUG] reg domains', args.domains);
|
console.log('[LE DEBUG] reg domains', args.domains);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var url = require('url');
|
||||||
|
var acmeLocation = url.parse(copy.server);
|
||||||
|
var acmeHostpath = path.join(acmeLocation.hostname, acmeLocation.pathname);
|
||||||
|
copy.renewalPath = copy.renewalPath || path.join(copy.configDir, 'renewal', copy.domains[0] + '.conf');
|
||||||
|
copy.accountsDir = copy.accountsDir || path.join(copy.configDir, 'accounts', acmeHostpath);
|
||||||
|
|
||||||
return getOrCreateAcmeAccount(copy, defaults, handlers).then(function (account) {
|
return getOrCreateAcmeAccount(copy, defaults, handlers).then(function (account) {
|
||||||
console.log("account", account);
|
//console.log("account", account);
|
||||||
args.account = account;
|
copy.account = account;
|
||||||
return getOrCreateDomainCertificate(account, copy, defaults, handlers);
|
|
||||||
|
return getOrCreateRenewal(copy).then(function (pyobj) {
|
||||||
|
|
||||||
|
copy.pyobj = pyobj;
|
||||||
|
return getOrCreateDomainCertificate(copy, defaults, handlers);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
, fetchAsync: function (args) {
|
, fetchAsync: function (args) {
|
||||||
|
|
|
@ -40,7 +40,7 @@ authenticator = webroot
|
||||||
domains = :hostnames #comma,delimited,list
|
domains = :hostnames #comma,delimited,list
|
||||||
rsa_key_size = :rsa_key_size
|
rsa_key_size = :rsa_key_size
|
||||||
# starts at 0 and increments at every renewal
|
# starts at 0 and increments at every renewal
|
||||||
checkpoints = :checkpoint_count
|
checkpoints = -1
|
||||||
manual_test_mode = False
|
manual_test_mode = False
|
||||||
apache = False
|
apache = False
|
||||||
cert_path = :cert_path
|
cert_path = :cert_path
|
||||||
|
|
|
@ -0,0 +1,50 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var PromiseA = require('bluebird');
|
||||||
|
var pyconf = PromiseA.promisifyAll(require('pyconf'));
|
||||||
|
var mkdirpAsync = PromiseA.promisify(require('mkdirp'));
|
||||||
|
var path = require('path');
|
||||||
|
|
||||||
|
pyconf.readFileAsync(path.join(__dirname, 'lib', 'renewal.conf.tpl')).then(function (obj) {
|
||||||
|
var domains = ['example.com', 'www.example.com'];
|
||||||
|
var webrootPath = '/tmp/www/example.com';
|
||||||
|
|
||||||
|
console.log(obj);
|
||||||
|
|
||||||
|
var keys = obj.__keys;
|
||||||
|
var lines = obj.__lines;
|
||||||
|
|
||||||
|
obj.__keys = null;
|
||||||
|
obj.__lines = null;
|
||||||
|
|
||||||
|
var updates = {
|
||||||
|
account: 'ACCOUNT_ID'
|
||||||
|
|
||||||
|
, cert: 'CERT_PATH'
|
||||||
|
, privkey: 'PRIVATEKEY_PATH'
|
||||||
|
, configDir: 'CONFIG_DIR'
|
||||||
|
, tos: true
|
||||||
|
, http01Port: 80
|
||||||
|
, domains: domains
|
||||||
|
};
|
||||||
|
|
||||||
|
// final section is completely dynamic
|
||||||
|
// :hostname = :webroot_path
|
||||||
|
domains.forEach(function (hostname) {
|
||||||
|
updates[hostname] = webrootPath;
|
||||||
|
});
|
||||||
|
|
||||||
|
// must write back to the original object or
|
||||||
|
// annotations will be lost
|
||||||
|
Object.keys(updates).forEach(function (key) {
|
||||||
|
obj[key] = updates[key];
|
||||||
|
});
|
||||||
|
|
||||||
|
var renewalPath = '/tmp/letsencrypt/renewal/example.com.conf';
|
||||||
|
return mkdirpAsync(path.dirname(renewalPath)).then(function () {
|
||||||
|
console.log(obj);
|
||||||
|
obj.__keys = keys;
|
||||||
|
obj.__lines = lines;
|
||||||
|
return pyconf.writeFileAsync(renewalPath, obj);
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue