show challenges
This commit is contained in:
parent
ec9a2606f6
commit
d646edf045
11
index.html
11
index.html
|
@ -25,10 +25,12 @@
|
||||||
|
|
||||||
<!-- Step 3 Set Challanges -->
|
<!-- Step 3 Set Challanges -->
|
||||||
<form class="js-acme-form js-acme-form-challenges">
|
<form class="js-acme-form js-acme-form-challenges">
|
||||||
|
<div class="js-acme-challenges">
|
||||||
|
|
||||||
<label>How will you validate your domain?</label>
|
<label>How will you validate your domain?</label>
|
||||||
<label><input class="js-acme-challenge-type" type="radio" value="http-01" checked required>
|
<label><input class="js-acme-challenge-type" name="acme-challenge-type" type="radio" value="http-01" checked required>
|
||||||
File Upload to HTTP Web Server</label>
|
File Upload to HTTP Web Server</label>
|
||||||
<label><input class="js-acme-challenge-type" type="radio" value="dns-01" required>
|
<label><input class="js-acme-challenge-type" name="acme-challenge-type" type="radio" value="dns-01" required>
|
||||||
TXT Records on DNS Name Server</label>
|
TXT Records on DNS Name Server</label>
|
||||||
|
|
||||||
Verify Domains & Sub-Domains:
|
Verify Domains & Sub-Domains:
|
||||||
|
@ -66,11 +68,12 @@
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="js-acme-wildcards">
|
<div class="js-acme-wildcard">
|
||||||
Verify Wildcard Domains:
|
Verify Wildcard Domains:
|
||||||
|
|
||||||
<table class="js-acme-table-wildcards">
|
<table class="js-acme-table-wildcard">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Hostname</th>
|
<th>Hostname</th>
|
||||||
|
|
179
js/app.js
179
js/app.js
|
@ -18,13 +18,32 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function submitForm(ev) {
|
||||||
|
steps[i].submit(ev);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
$qsa('.js-acme-form').forEach(function ($el) {
|
$qsa('.js-acme-form').forEach(function ($el) {
|
||||||
$el.addEventListener('submit', function (ev) {
|
$el.addEventListener('submit', function (ev) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
steps[i].submit(ev);
|
submitForm(ev);
|
||||||
i += 1;
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
function updateChallengeType() {
|
||||||
|
var input = this || $qs('.js-acme-challenge-type');
|
||||||
|
console.log('ch type radio:', input.value);
|
||||||
|
$qs('.js-acme-table-wildcard').hidden = true;
|
||||||
|
$qs('.js-acme-table-http-01').hidden = true;
|
||||||
|
$qs('.js-acme-table-dns-01').hidden = true;
|
||||||
|
if (info.challenges.wildcard) {
|
||||||
|
$qs('.js-acme-table-wildcard').hidden = false;
|
||||||
|
}
|
||||||
|
if (info.challenges[input.value]) {
|
||||||
|
$qs('.js-acme-table-' + input.value).hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$qsa('.js-acme-challenge-type').forEach(function ($el) {
|
||||||
|
$el.addEventListener('change', updateChallengeType);
|
||||||
|
});
|
||||||
|
|
||||||
steps[1] = function () {
|
steps[1] = function () {
|
||||||
hideForms();
|
hideForms();
|
||||||
|
@ -85,6 +104,7 @@
|
||||||
console.log('account jwk:');
|
console.log('account jwk:');
|
||||||
console.log(jwk);
|
console.log(jwk);
|
||||||
delete jwk.key_ops;
|
delete jwk.key_ops;
|
||||||
|
info.jwk = jwk;
|
||||||
return BACME.accounts.sign({
|
return BACME.accounts.sign({
|
||||||
jwk: jwk
|
jwk: jwk
|
||||||
, contacts: [ 'mailto:' + email ]
|
, contacts: [ 'mailto:' + email ]
|
||||||
|
@ -117,6 +137,7 @@
|
||||||
|
|
||||||
return p2.then(function (_kid) {
|
return p2.then(function (_kid) {
|
||||||
kid = _kid;
|
kid = _kid;
|
||||||
|
info.kid = kid;
|
||||||
return BACME.orders.sign({
|
return BACME.orders.sign({
|
||||||
jwk: jwk
|
jwk: jwk
|
||||||
, identifiers: info.identifiers
|
, identifiers: info.identifiers
|
||||||
|
@ -124,13 +145,96 @@
|
||||||
}).then(function (signedOrder) {
|
}).then(function (signedOrder) {
|
||||||
return BACME.orders.create({
|
return BACME.orders.create({
|
||||||
signedOrder: signedOrder
|
signedOrder: signedOrder
|
||||||
}).then(function (/*challengeIndexes*/) {
|
}).then(function (order) {
|
||||||
return BACME.challenges.all().then(function (challenges) {
|
info.finalizeUrl = order.finalize;
|
||||||
console.log('challenges:');
|
return BACME.thumbprint({ jwk: jwk }).then(function (thumbprint) {
|
||||||
console.log(challenges);
|
return BACME.challenges.all().then(function (claims) {
|
||||||
// TODO populate challenges in table
|
console.log('claims:');
|
||||||
|
console.log(claims);
|
||||||
|
var obj = { 'dns-01': [], 'http-01': [], 'wildcard': [] };
|
||||||
|
var map = {
|
||||||
|
'http-01': '.js-acme-table-http-01'
|
||||||
|
, 'dns-01': '.js-acme-table-dns-01'
|
||||||
|
, 'wildcard': '.js-acme-table-wildcard'
|
||||||
|
}
|
||||||
|
var tpls = {};
|
||||||
|
info.challenges = obj;
|
||||||
|
Object.keys(map).forEach(function (k) {
|
||||||
|
var sel = map[k] + ' tbody';
|
||||||
|
console.log(sel);
|
||||||
|
tpls[k] = $qs(sel).innerHTML;
|
||||||
|
$qs(map[k] + ' tbody').innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO make Promise-friendly
|
||||||
|
return Promise.all(claims.map(function (claim) {
|
||||||
|
var hostname = claim.identifier.value;
|
||||||
|
return Promise.all(claim.challenges.map(function (c) {
|
||||||
|
var keyAuth = BACME.challenges['http-01']({
|
||||||
|
token: c.token
|
||||||
|
, thumbprint: thumbprint
|
||||||
|
, challengeDomain: hostname
|
||||||
|
});
|
||||||
|
return BACME.challenges['dns-01']({
|
||||||
|
keyAuth: keyAuth
|
||||||
|
, challengeDomain: hostname
|
||||||
|
}).then(function (dnsAuth) {
|
||||||
|
var data = {
|
||||||
|
type: c.type
|
||||||
|
, hostname: hostname
|
||||||
|
, url: c.url
|
||||||
|
, token: c.token
|
||||||
|
, keyAuthorization: keyAuth
|
||||||
|
, httpPath: keyAuth.path
|
||||||
|
, httpAuth: keyAuth.value
|
||||||
|
, dnsType: dnsAuth.type
|
||||||
|
, dnsHost: dnsAuth.host
|
||||||
|
, dnsAnswer: dnsAuth.answer
|
||||||
|
};
|
||||||
|
|
||||||
|
obj[c.type].push(data);
|
||||||
|
console.log('');
|
||||||
|
console.log('CHALLENGE');
|
||||||
|
console.log(claim);
|
||||||
|
console.log(c);
|
||||||
|
console.log(data);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
if (claim.wildcard) {
|
||||||
|
obj.wildcard.push(data);
|
||||||
|
$qs(map.wildcard).innerHTML += '<tr><td>' + data.hostname + '</td><td>' + data.dnsHost + '</td><td>' + data.dnsAnswer + '</td></tr>';
|
||||||
|
} else {
|
||||||
|
obj[data.type].push(data);
|
||||||
|
if ('dns-01' === data.type) {
|
||||||
|
$qs(map[data.type]).innerHTML += '<tr><td>' + data.hostname + '</td><td>' + data.dnsHost + '</td><td>' + data.dnsAnswer + '</td></tr>';
|
||||||
|
} else if ('http-01' === data.type) {
|
||||||
|
$qs(map[data.type]).innerHTML += '<tr><td>' + data.hostname + '</td><td>' + data.httpPath + '</td><td>' + data.httpAuth + '</td></tr>';
|
||||||
|
} else {
|
||||||
|
throw new Error('Unexpected type: ' + data.type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
}));
|
||||||
|
})).then(function () {
|
||||||
|
|
||||||
|
// hide wildcard if no wildcard
|
||||||
|
// hide http-01 and dns-01 if only wildcard
|
||||||
|
if (!obj.wildcard.length) {
|
||||||
|
$qs('.js-acme-wildcard').hidden = true;
|
||||||
|
}
|
||||||
|
if (!obj['http-01'].length) {
|
||||||
|
$qs('.js-acme-challenges').hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateChallengeType();
|
||||||
|
|
||||||
steps[i]();
|
steps[i]();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -144,11 +248,72 @@
|
||||||
hideForms();
|
hideForms();
|
||||||
$qs('.js-acme-form-challenges').hidden = false;
|
$qs('.js-acme-form-challenges').hidden = false;
|
||||||
};
|
};
|
||||||
|
steps[3].submit = function () {
|
||||||
|
var chType = $qs('.js-acme-challenge-type').value;
|
||||||
|
var ps = [];
|
||||||
|
|
||||||
|
// do each wildcard, if any
|
||||||
|
// do each challenge, by selected type only
|
||||||
|
[ 'wildcard', chType].forEach(function (typ) {
|
||||||
|
info.challenges[typ].forEach(function (ch) {
|
||||||
|
// { jwk, challengeUrl, accountId (kid) }
|
||||||
|
ps.push(BACME.challenges.accept({
|
||||||
|
jwk: info.jwk
|
||||||
|
, challengeUrl: ch.url
|
||||||
|
, accountId: info.kid
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.all(ps).then(function (results) {
|
||||||
|
console.log('challenge status:', results);
|
||||||
|
var polls = results.slice(0);
|
||||||
|
var allsWell = true;
|
||||||
|
|
||||||
|
function checkPolls() {
|
||||||
|
return new Promise(function (resolve) {
|
||||||
|
setTimeout(resolve, 1000);
|
||||||
|
}).then(function () {
|
||||||
|
return Promise.all(polls.map(function (poll) {
|
||||||
|
return BACME.challenges.check({ challengePollUrl: poll.url });
|
||||||
|
})).then(function () {
|
||||||
|
polls = polls.filter(function (poll) {
|
||||||
|
//return 'valid' !== poll.status && 'invalid' !== poll.status;
|
||||||
|
if ('pending' === poll.status) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ('valid' !== poll.status) {
|
||||||
|
allsWell = false;
|
||||||
|
console.warn('BAD POLL STATUS', poll);
|
||||||
|
}
|
||||||
|
// TODO show status in HTML
|
||||||
|
});
|
||||||
|
|
||||||
|
if (polls.length) {
|
||||||
|
return checkPolls();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkPolls().then(function () {
|
||||||
|
if (allsWell) {
|
||||||
|
return submitForm();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// spinner
|
||||||
steps[4] = function () {
|
steps[4] = function () {
|
||||||
hideForms();
|
hideForms();
|
||||||
$qs('.js-acme-form-poll').hidden = false;
|
$qs('.js-acme-form-poll').hidden = false;
|
||||||
}
|
}
|
||||||
|
steps[4].submit = function () {
|
||||||
|
console.log('Congrats! Auto advancing...');
|
||||||
|
return BACME.order
|
||||||
|
};
|
||||||
|
|
||||||
steps[5] = function () {
|
steps[5] = function () {
|
||||||
hideForms();
|
hideForms();
|
||||||
|
|
111
js/bacme.js
111
js/bacme.js
|
@ -289,11 +289,10 @@ BACME.orders = {};
|
||||||
BACME.orders.sign = function (opts) {
|
BACME.orders.sign = function (opts) {
|
||||||
var payload64 = BACME._jsto64({ identifiers: opts.identifiers });
|
var payload64 = BACME._jsto64({ identifiers: opts.identifiers });
|
||||||
|
|
||||||
var protected64 = BACME._jsto64(
|
|
||||||
{ nonce: nonce, alg: 'ES256', url: orderUrl, kid: opts.kid }
|
|
||||||
);
|
|
||||||
|
|
||||||
return BACME._importKey(opts.jwk).then(function (abstractKey) {
|
return BACME._importKey(opts.jwk).then(function (abstractKey) {
|
||||||
|
var protected64 = BACME._jsto64(
|
||||||
|
{ nonce: nonce, alg: abstractKey.meta.alg/*'ES256'*/, url: orderUrl, kid: opts.kid }
|
||||||
|
);
|
||||||
console.log('abstractKey:');
|
console.log('abstractKey:');
|
||||||
console.log(abstractKey);
|
console.log(abstractKey);
|
||||||
return BACME._sign({
|
return BACME._sign({
|
||||||
|
@ -378,7 +377,16 @@ BACME.challenges.view = function () {
|
||||||
|
|
||||||
BACME._logBody(result);
|
BACME._logBody(result);
|
||||||
|
|
||||||
return { token: challenge.token, url: challenge.url, domain: result.identifier.value, challenges: result.challenges };
|
return {
|
||||||
|
challenges: result.challenges
|
||||||
|
, expires: result.expires
|
||||||
|
, identifier: result.identifier
|
||||||
|
, status: result.status
|
||||||
|
, wildcard: result.wildcard
|
||||||
|
//, token: challenge.token
|
||||||
|
//, url: challenge.url
|
||||||
|
//, domain: result.identifier.value,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -420,12 +428,13 @@ BACME.thumbprint = function (opts) {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
BACME.challenges['http-01'] = function () {
|
// { token, thumbprint, challengeDomain }
|
||||||
|
BACME.challenges['http-01'] = function (opts) {
|
||||||
// The contents of the key authorization file
|
// The contents of the key authorization file
|
||||||
keyAuth = token + '.' + thumbprint;
|
keyAuth = opts.token + '.' + opts.thumbprint;
|
||||||
|
|
||||||
// Where the key authorization file goes
|
// Where the key authorization file goes
|
||||||
httpPath = 'http://' + challengeDomain + '/.well-known/acme-challenge/' + token;
|
httpPath = 'http://' + opts.challengeDomain + '/.well-known/acme-challenge/' + opts.token;
|
||||||
|
|
||||||
console.log("echo '" + keyAuth + "' > '" + httpPath + "'");
|
console.log("echo '" + keyAuth + "' > '" + httpPath + "'");
|
||||||
|
|
||||||
|
@ -435,16 +444,17 @@ BACME.challenges['http-01'] = function () {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
BACME.challenges['dns-01'] = function () {
|
// { keyAuth }
|
||||||
|
BACME.challenges['dns-01'] = function (opts) {
|
||||||
return window.crypto.subtle.digest(
|
return window.crypto.subtle.digest(
|
||||||
{ name: "SHA-256", }
|
{ name: "SHA-256", }
|
||||||
, textEncoder.encode(keyAuth)
|
, textEncoder.encode(opts.keyAuth)
|
||||||
).then(function(hash){
|
).then(function(hash){
|
||||||
dnsAuth = btoa(Array.prototype.map.call(new Uint8Array(hash), function (ch) {
|
dnsAuth = btoa(Array.prototype.map.call(new Uint8Array(hash), function (ch) {
|
||||||
return String.fromCharCode(ch);
|
return String.fromCharCode(ch);
|
||||||
}).join('')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
}).join('')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||||
|
|
||||||
dnsRecord = '_acme-challenge.' + challengeDomain;
|
dnsRecord = '_acme-challenge.' + opts.challengeDomain;
|
||||||
|
|
||||||
console.log('DNS TXT Auth:');
|
console.log('DNS TXT Auth:');
|
||||||
// The name of the record
|
// The name of the record
|
||||||
|
@ -462,38 +472,30 @@ BACME.challenges['dns-01'] = function () {
|
||||||
|
|
||||||
var challengePollUrl;
|
var challengePollUrl;
|
||||||
|
|
||||||
BACME.challenges.accept = function () {
|
// { jwk, challengeUrl, accountId (kid) }
|
||||||
|
BACME.challenges.accept = function (opts) {
|
||||||
var payload64 = BACME._jsto64(
|
var payload64 = BACME._jsto64(
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
|
|
||||||
var protected64 = BACME._jsto64(
|
|
||||||
{ nonce: nonce, alg: 'ES256', url: challengeUrl, kid: accountId }
|
|
||||||
);
|
|
||||||
|
|
||||||
nonce = null;
|
nonce = null;
|
||||||
return window.crypto.subtle.sign(
|
return BACME._import(opts.jwk).then(function (abstractKey) {
|
||||||
{ name: "ECDSA", hash: { name: "SHA-256" } }
|
var protected64 = BACME._jsto64(
|
||||||
, accountKeypair.privateKey
|
{ nonce: nonce, alg: abstractKey.meta.alg/*'ES256'*/, url: opts.challengeUrl, kid: opts.accountId }
|
||||||
, textEncoder.encode(protected64 + '.' + payload64)
|
);
|
||||||
).then(function (signature) {
|
return BACME._sign({
|
||||||
|
abstractKey: abstractKey
|
||||||
var sig64 = btoa(Array.prototype.map.call(new Uint8Array(signature), function (ch) {
|
, payload64: payload64
|
||||||
return String.fromCharCode(ch);
|
, protected64: protected64
|
||||||
}).join('')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
});
|
||||||
|
}).then(function (signedAccept) {
|
||||||
var body = {
|
|
||||||
protected: protected64
|
|
||||||
, payload: payload64
|
|
||||||
, signature: sig64
|
|
||||||
};
|
|
||||||
|
|
||||||
return window.fetch(
|
return window.fetch(
|
||||||
challengeUrl
|
opts.challengeUrl
|
||||||
, { mode: 'cors'
|
, { mode: 'cors'
|
||||||
, method: 'POST'
|
, method: 'POST'
|
||||||
, headers: { 'Content-Type': 'application/jose+json' }
|
, headers: { 'Content-Type': 'application/jose+json' }
|
||||||
, body: JSON.stringify(body)
|
, body: JSON.stringify(signedAccept)
|
||||||
}
|
}
|
||||||
).then(function (resp) {
|
).then(function (resp) {
|
||||||
BACME._logHeaders(resp);
|
BACME._logHeaders(resp);
|
||||||
|
@ -504,13 +506,14 @@ BACME.challenges.accept = function () {
|
||||||
|
|
||||||
console.log('Challenge ACK:');
|
console.log('Challenge ACK:');
|
||||||
console.log(JSON.stringify(reply));
|
console.log(JSON.stringify(reply));
|
||||||
|
return reply;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
BACME.challenges.check = function () {
|
BACME.challenges.check = function (opts) {
|
||||||
return window.fetch(challengePollUrl, { mode: 'cors' }).then(function (resp) {
|
return window.fetch(opts.challengePollUrl, { mode: 'cors' }).then(function (resp) {
|
||||||
BACME._logHeaders(resp);
|
BACME._logHeaders(resp);
|
||||||
nonce = resp.headers.get('replay-nonce');
|
nonce = resp.headers.get('replay-nonce');
|
||||||
|
|
||||||
|
@ -558,38 +561,30 @@ BACME.orders.generateCsr = function (keypair, domains) {
|
||||||
|
|
||||||
var certificateUrl;
|
var certificateUrl;
|
||||||
|
|
||||||
BACME.orders.finalize = function () {
|
// { csr, jwk, finalizeUrl, accountId }
|
||||||
|
BACME.orders.finalize = function (opts) {
|
||||||
var payload64 = BACME._jsto64(
|
var payload64 = BACME._jsto64(
|
||||||
{ csr: csr }
|
{ csr: opts.csr }
|
||||||
);
|
|
||||||
|
|
||||||
var protected64 = BACME._jsto64(
|
|
||||||
{ nonce: nonce, alg: 'ES256', url: finalizeUrl, kid: accountId }
|
|
||||||
);
|
);
|
||||||
|
|
||||||
nonce = null;
|
nonce = null;
|
||||||
return window.crypto.subtle.sign(
|
return BACME._import(opts.jwk).then(function (abstractKey) {
|
||||||
{ name: "ECDSA", hash: { name: "SHA-256" } }
|
var protected64 = BACME._jsto64(
|
||||||
, accountKeypair.privateKey
|
{ nonce: nonce, alg: abstractKey.meta.alg/*'ES256'*/, url: opts.finalizeUrl, kid: opts.accountId }
|
||||||
, textEncoder.encode(protected64 + '.' + payload64)
|
);
|
||||||
).then(function (signature) {
|
return BACME._sign({
|
||||||
|
abstractKey: abstractKey
|
||||||
var sig64 = btoa(Array.prototype.map.call(new Uint8Array(signature), function (ch) {
|
, payload64: payload64
|
||||||
return String.fromCharCode(ch);
|
, protected64: protected64
|
||||||
}).join('')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
});
|
||||||
|
}).then(function (signedFinal) {
|
||||||
var body = {
|
|
||||||
protected: protected64
|
|
||||||
, payload: payload64
|
|
||||||
, signature: sig64
|
|
||||||
};
|
|
||||||
|
|
||||||
return window.fetch(
|
return window.fetch(
|
||||||
finalizeUrl
|
finalizeUrl
|
||||||
, { mode: 'cors'
|
, { mode: 'cors'
|
||||||
, method: 'POST'
|
, method: 'POST'
|
||||||
, headers: { 'Content-Type': 'application/jose+json' }
|
, headers: { 'Content-Type': 'application/jose+json' }
|
||||||
, body: JSON.stringify(body)
|
, body: JSON.stringify(signedFinal)
|
||||||
}
|
}
|
||||||
).then(function (resp) {
|
).then(function (resp) {
|
||||||
BACME._logHeaders(resp);
|
BACME._logHeaders(resp);
|
||||||
|
@ -598,6 +593,8 @@ BACME.orders.finalize = function () {
|
||||||
return resp.json().then(function (reply) {
|
return resp.json().then(function (reply) {
|
||||||
certificateUrl = reply.certificate;
|
certificateUrl = reply.certificate;
|
||||||
BACME._logBody(reply);
|
BACME._logBody(reply);
|
||||||
|
|
||||||
|
return reply;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in New Issue