mirror of
https://github.com/therootcompany/acme.js.git
synced 2024-11-16 17:29:00 +00:00
Compare commits
3 Commits
54cda5a888
...
b1c591b6ed
Author | SHA1 | Date | |
---|---|---|---|
b1c591b6ed | |||
4e7ff0d9e8 | |||
b39a3763cf |
30
README.md
30
README.md
@ -1,4 +1,4 @@
|
|||||||
# [ACME.js](https://git.rootprojects.org/root/acme.js) v3
|
# [ACME.js](https://git.rootprojects.org/root/acme.js) (RFC 8555 / November 2019)
|
||||||
|
|
||||||
| Built by [Root](https://therootcompany.com) for [Greenlock](https://greenlock.domains)
|
| Built by [Root](https://therootcompany.com) for [Greenlock](https://greenlock.domains)
|
||||||
|
|
||||||
@ -52,6 +52,31 @@ If they don't, please open an issue to let us know why.
|
|||||||
We'd much rather improve the app than have a hundred different versions running in the wild.
|
We'd much rather improve the app than have a hundred different versions running in the wild.
|
||||||
However, in keeping to our values we've made the source visible for others to inspect, improve, and modify.
|
However, in keeping to our values we've made the source visible for others to inspect, improve, and modify.
|
||||||
|
|
||||||
|
# API Overview
|
||||||
|
|
||||||
|
```js
|
||||||
|
ACME.create({ maintainerEmail, packageAgent });
|
||||||
|
acme.init(directoryUrl);
|
||||||
|
acme.accounts.create({ subscriberEmail, agreeToTerms, accountKey });
|
||||||
|
acme.certificates.create({
|
||||||
|
customerEmail, // do not use
|
||||||
|
account,
|
||||||
|
accountKey,
|
||||||
|
serverKey,
|
||||||
|
csr,
|
||||||
|
domains,
|
||||||
|
challenges
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
ACME.computeChallenge({
|
||||||
|
accountKey: jwk,
|
||||||
|
hostname: 'example.com',
|
||||||
|
challenge: { type: 'dns-01', token: 'xxxx' }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
# Install
|
# Install
|
||||||
|
|
||||||
To make it easy to generate, encode, and decode keys and certificates,
|
To make it easy to generate, encode, and decode keys and certificates,
|
||||||
@ -234,9 +259,6 @@ is a required part of the process, which requires `set` and `remove` callbacks/p
|
|||||||
|
|
||||||
```js
|
```js
|
||||||
var certinfo = await acme.certificates.create({
|
var certinfo = await acme.certificates.create({
|
||||||
agreeToTerms: function(tos) {
|
|
||||||
return tos;
|
|
||||||
},
|
|
||||||
account: account,
|
account: account,
|
||||||
accountKey: accountPrivateJwk,
|
accountKey: accountPrivateJwk,
|
||||||
csr: csr,
|
csr: csr,
|
||||||
|
27
acme.js
27
acme.js
@ -14,7 +14,8 @@ var sha2 = require('@root/keypairs/lib/node/sha2.js');
|
|||||||
var http = require('./lib/node/http.js');
|
var http = require('./lib/node/http.js');
|
||||||
var A = require('./account.js');
|
var A = require('./account.js');
|
||||||
var U = require('./utils.js');
|
var U = require('./utils.js');
|
||||||
var E = require('./errors');
|
var E = require('./errors.js');
|
||||||
|
var M = require('./maintainers.js');
|
||||||
|
|
||||||
var native = require('./lib/native.js');
|
var native = require('./lib/native.js');
|
||||||
|
|
||||||
@ -27,6 +28,20 @@ ACME.create = function create(me) {
|
|||||||
me._nonces = [];
|
me._nonces = [];
|
||||||
me._canCheck = {};
|
me._canCheck = {};
|
||||||
|
|
||||||
|
if (!/.+@.+\..+/.test(me.maintainerEmail)) {
|
||||||
|
throw new Error(
|
||||||
|
'you should supply `maintainerEmail` as a contact for security and critical bug notices'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/\w\/v?\d/.test(me.packageAgent) && false !== me.packageAgent) {
|
||||||
|
console.error(
|
||||||
|
"\nyou should supply `packageAgent` as an rfc7231-style User-Agent such as Foo/v1.1\n\n\t// your package agent should be this:\n\tvar pkg = require('./package.json');\n\tvar agent = pkg.name + '/' + pkg.version\n"
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!me.dns01) {
|
if (!me.dns01) {
|
||||||
me.dns01 = function(ch) {
|
me.dns01 = function(ch) {
|
||||||
return native._dns01(me, ch);
|
return native._dns01(me, ch);
|
||||||
@ -43,15 +58,17 @@ ACME.create = function create(me) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!me.request) {
|
if (!me.__request) {
|
||||||
me.request = http.request;
|
me.__request = http.request;
|
||||||
}
|
}
|
||||||
// passed to dependencies
|
// passed to dependencies
|
||||||
me._urequest = function(opts) {
|
me.request = function(opts) {
|
||||||
return U._request(me, opts);
|
return U._request(me, opts);
|
||||||
};
|
};
|
||||||
|
|
||||||
me.init = function(opts) {
|
me.init = function(opts) {
|
||||||
|
M.init(me);
|
||||||
|
|
||||||
function fin(dir) {
|
function fin(dir) {
|
||||||
me._directoryUrls = dir;
|
me._directoryUrls = dir;
|
||||||
me._tos = dir.meta.termsOfService;
|
me._tos = dir.meta.termsOfService;
|
||||||
@ -1241,7 +1258,7 @@ ACME._prepRequest = function(me, options) {
|
|||||||
!presenter._acme_initialized
|
!presenter._acme_initialized
|
||||||
) {
|
) {
|
||||||
presenter._acme_initialized = true;
|
presenter._acme_initialized = true;
|
||||||
return presenter.init({ type: '*', request: me._urequest });
|
return presenter.init({ type: '*', request: me.request });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
{ type: 'dns-01',
|
{
|
||||||
status: 'pending',
|
"type": "dns-01",
|
||||||
url:
|
"status": "pending",
|
||||||
'https://acme-staging-v02.api.letsencrypt.org/acme/chall-v3/16603341/yCthUw',
|
"url": "https://acme-staging-v02.api.letsencrypt.org/acme/chall-v3/16603341/yCthUw",
|
||||||
token: 'DiO9DFHuFTpNsJxIbOxfVCSPVkpe4lJUjozeSyzkMjI' }
|
"token": "DiO9DFHuFTpNsJxIbOxfVCSPVkpe4lJUjozeSyzkMjI"
|
||||||
|
}
|
||||||
|
@ -18,9 +18,11 @@ native._canCheck = function(me) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
native._dns01 = function(me, ch) {
|
native._dns01 = function(me, ch) {
|
||||||
return new me.request({
|
return me
|
||||||
|
.request({
|
||||||
url: me._baseUrl + '/api/dns/' + ch.dnsHost + '?type=TXT'
|
url: me._baseUrl + '/api/dns/' + ch.dnsHost + '?type=TXT'
|
||||||
}).then(function(resp) {
|
})
|
||||||
|
.then(function(resp) {
|
||||||
var err;
|
var err;
|
||||||
if (!resp.body || !Array.isArray(resp.body.answer)) {
|
if (!resp.body || !Array.isArray(resp.body.answer)) {
|
||||||
err = new Error('failed to get DNS response');
|
err = new Error('failed to get DNS response');
|
||||||
@ -42,9 +44,11 @@ native._dns01 = function(me, ch) {
|
|||||||
|
|
||||||
native._http01 = function(me, ch) {
|
native._http01 = function(me, ch) {
|
||||||
var url = encodeURIComponent(ch.challengeUrl);
|
var url = encodeURIComponent(ch.challengeUrl);
|
||||||
return new me.request({
|
return me
|
||||||
|
.request({
|
||||||
url: me._baseUrl + '/api/http?url=' + url
|
url: me._baseUrl + '/api/http?url=' + url
|
||||||
}).then(function(resp) {
|
})
|
||||||
|
.then(function(resp) {
|
||||||
return resp.body;
|
return resp.body;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var UserAgent = module.exports;
|
var UserAgent = module.exports;
|
||||||
UserAgent.get = function () {
|
UserAgent.get = function() {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
var native = module.exports;
|
var native = module.exports;
|
||||||
var promisify = require('util').promisify;
|
var promisify = require('util').promisify;
|
||||||
var resolveTxt = promisify(require('dns').resolveTxt);
|
var resolveTxt = promisify(require('dns').resolveTxt);
|
||||||
|
var crypto = require('crypto');
|
||||||
|
|
||||||
native._canCheck = function(me) {
|
native._canCheck = function(me) {
|
||||||
me._canCheck = {};
|
me._canCheck = {};
|
||||||
@ -31,3 +32,57 @@ native._http01 = function(me, ch) {
|
|||||||
return resp.body;
|
return resp.body;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// the hashcash here is for browser parity only
|
||||||
|
// basically we ask the client to find a needle in a haystack
|
||||||
|
// (very similar to CloudFlare's api protection)
|
||||||
|
native._hashcash = function(ch) {
|
||||||
|
if (!ch || !ch.nonce) {
|
||||||
|
ch = { nonce: 'xxx' };
|
||||||
|
}
|
||||||
|
return Promise.resolve()
|
||||||
|
.then(function() {
|
||||||
|
// only get easy answers
|
||||||
|
var len = ch.needle.length;
|
||||||
|
var start = ch.start || 0;
|
||||||
|
var end = ch.end || Math.ceil(len / 2);
|
||||||
|
var window = parseInt(end - start, 10) || 0;
|
||||||
|
|
||||||
|
var maxLen = 6;
|
||||||
|
var maxTries = Math.pow(2, maxLen * 8);
|
||||||
|
if (
|
||||||
|
len > maxLen ||
|
||||||
|
window < Math.ceil(len / 2) ||
|
||||||
|
ch.needle.toLowerCase() !== ch.needle ||
|
||||||
|
ch.alg !== 'SHA-256'
|
||||||
|
) {
|
||||||
|
// bail unless the server is issuing very easy challenges
|
||||||
|
throw new Error('possible and easy answers only, please');
|
||||||
|
}
|
||||||
|
|
||||||
|
var haystack;
|
||||||
|
var i;
|
||||||
|
var answer;
|
||||||
|
var needle = Buffer.from(ch.needle, 'hex');
|
||||||
|
for (i = 0; i < maxTries; i += 1) {
|
||||||
|
answer = i.toString(16);
|
||||||
|
if (answer.length % 2) {
|
||||||
|
answer = '0' + answer;
|
||||||
|
}
|
||||||
|
haystack = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(Buffer.from(ch.nonce + answer, 'hex'))
|
||||||
|
.digest()
|
||||||
|
.slice(ch.start, ch.end);
|
||||||
|
if (-1 !== haystack.indexOf(needle)) {
|
||||||
|
return ch.nonce + ':' + answer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ch.nonce + ':xxx';
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
//console.log('[debug]', err);
|
||||||
|
// ignore any error
|
||||||
|
return ch.nonce + ':xxx';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var os = require('os');
|
var os = require('os');
|
||||||
var ver = require('../../package.json');
|
var ver = require('../../package.json').version;
|
||||||
|
|
||||||
var UserAgent = module.exports;
|
var UserAgent = module.exports;
|
||||||
UserAgent.get = function(me) {
|
UserAgent.get = function(me) {
|
||||||
|
@ -5,15 +5,5 @@ var promisify = require('util').promisify;
|
|||||||
var request = promisify(require('@root/request'));
|
var request = promisify(require('@root/request'));
|
||||||
|
|
||||||
http.request = function(opts) {
|
http.request = function(opts) {
|
||||||
if (!opts.headers) {
|
|
||||||
opts.headers = {};
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!Object.keys(opts.headers).some(function(key) {
|
|
||||||
return 'user-agent' === key.toLowerCase();
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
// TODO opts.headers['User-Agent'] = 'TODO';
|
|
||||||
}
|
|
||||||
return request(opts);
|
return request(opts);
|
||||||
};
|
};
|
||||||
|
85
maintainers.js
Normal file
85
maintainers.js
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
var M = module.exports;
|
||||||
|
var native = require('./lib/native.js');
|
||||||
|
|
||||||
|
// Keep track of active maintainers so that we know who to inform if
|
||||||
|
// something breaks or has a serious bug or flaw.
|
||||||
|
|
||||||
|
var oldCollegeTries = {};
|
||||||
|
M.init = function(me) {
|
||||||
|
if (oldCollegeTries[me.maintainerEmail]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tz = '';
|
||||||
|
try {
|
||||||
|
// Use timezone to stagger messages to maintainers
|
||||||
|
tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
} catch (e) {
|
||||||
|
// ignore node versions with no or incomplete Intl
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use locale to know what language to use
|
||||||
|
var env = process.env;
|
||||||
|
var locale = env.LC_ALL || env.LC_MESSAGES || env.LANG || env.LANGUAGE;
|
||||||
|
|
||||||
|
try {
|
||||||
|
M._init(me, tz, locale);
|
||||||
|
} catch (e) {
|
||||||
|
//console.log(e);
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
M._init = function(me, tz, locale) {
|
||||||
|
// prevent a stampede from misconfigured clients in an eternal loop
|
||||||
|
setTimeout(function() {
|
||||||
|
me.request({
|
||||||
|
method: 'GET',
|
||||||
|
url: 'https://api.rootprojects.org/api/nonce',
|
||||||
|
json: true
|
||||||
|
})
|
||||||
|
.then(function(resp) {
|
||||||
|
// in the browser this will work until solved, but in
|
||||||
|
// node this will bail unless the challenge is trivial
|
||||||
|
return native._hashcash(resp.body || {});
|
||||||
|
})
|
||||||
|
.then(function(hashcash) {
|
||||||
|
var req = {
|
||||||
|
headers: {
|
||||||
|
'x-root-nonce-v1': hashcash
|
||||||
|
},
|
||||||
|
method: 'POST',
|
||||||
|
url:
|
||||||
|
'https://api.rootprojects.org/api/projects/ACME.js/dependents',
|
||||||
|
json: {
|
||||||
|
maintainer: me.maintainerEmail,
|
||||||
|
tz: tz,
|
||||||
|
locale: locale
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return me
|
||||||
|
.request(req)
|
||||||
|
.catch(function(err) {
|
||||||
|
if (true || me.debug) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(function(/*resp*/) {
|
||||||
|
oldCollegeTries[me.maintainerEmail] = true;
|
||||||
|
//console.log(resp);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, me.__timeout || 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
var ACME = require('./');
|
||||||
|
var acme = ACME.create({
|
||||||
|
maintainerEmail: 'aj+acme-test@rootprojects.org',
|
||||||
|
packageAgent: 'test/v0',
|
||||||
|
__timeout: 100
|
||||||
|
});
|
||||||
|
M.init(acme);
|
||||||
|
}
|
@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
|
var pkg = require('../package.json');
|
||||||
var CSR = require('@root/csr');
|
var CSR = require('@root/csr');
|
||||||
var Enc = require('@root/encoding/base64');
|
var Enc = require('@root/encoding/base64');
|
||||||
var PEM = require('@root/pem');
|
var PEM = require('@root/pem');
|
||||||
var punycode = require('punycode');
|
var punycode = require('punycode');
|
||||||
var ACME = require('../acme.js');
|
var ACME = require('../acme.js');
|
||||||
var Keypairs = require('@root/keypairs');
|
var Keypairs = require('@root/keypairs');
|
||||||
|
var ecJwk = require('../fixtures/account.jwk.json');
|
||||||
|
|
||||||
// TODO exec npm install --save-dev CHALLENGE_MODULE
|
// TODO exec npm install --save-dev CHALLENGE_MODULE
|
||||||
if (!process.env.CHALLENGE_OPTIONS) {
|
if (!process.env.CHALLENGE_OPTIONS) {
|
||||||
@ -36,6 +38,7 @@ module.exports = function() {
|
|||||||
var acme = ACME.create({
|
var acme = ACME.create({
|
||||||
// debug: true
|
// debug: true
|
||||||
maintainerEmail: config.email,
|
maintainerEmail: config.email,
|
||||||
|
packageAgent: 'test-' + pkg.name + '/' + pkg.version,
|
||||||
notify: function(ev, params) {
|
notify: function(ev, params) {
|
||||||
console.info(
|
console.info(
|
||||||
'\t' + ev,
|
'\t' + ev,
|
||||||
@ -104,6 +107,10 @@ module.exports = function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var accountKeypair = await Keypairs.generate({ kty: accKty });
|
var accountKeypair = await Keypairs.generate({ kty: accKty });
|
||||||
|
if (/EC/i.test(accKty)) {
|
||||||
|
// to test that an existing account gets back data
|
||||||
|
accountKeypair = ecJwk;
|
||||||
|
}
|
||||||
var accountKey = accountKeypair.private;
|
var accountKey = accountKeypair.private;
|
||||||
if (config.debug) {
|
if (config.debug) {
|
||||||
console.info('Account Key Created');
|
console.info('Account Key Created');
|
||||||
|
74
tests/maintainer.js
Normal file
74
tests/maintainer.js
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
var native = require('../lib/native.js');
|
||||||
|
var crypto = require('crypto');
|
||||||
|
|
||||||
|
native
|
||||||
|
._hashcash({
|
||||||
|
alg: 'SHA-256',
|
||||||
|
nonce: '00',
|
||||||
|
needle: '0000',
|
||||||
|
start: 0,
|
||||||
|
end: 2
|
||||||
|
})
|
||||||
|
.then(function(hashcash) {
|
||||||
|
if ('00:76de' !== hashcash) {
|
||||||
|
throw new Error('hashcash algorthim changed');
|
||||||
|
}
|
||||||
|
console.info('PASS: known hash solves correctly');
|
||||||
|
|
||||||
|
return native
|
||||||
|
._hashcash({
|
||||||
|
alg: 'SHA-256',
|
||||||
|
nonce: '10',
|
||||||
|
needle: '',
|
||||||
|
start: 0,
|
||||||
|
end: 2
|
||||||
|
})
|
||||||
|
.then(function(hashcash) {
|
||||||
|
if ('10:00' !== hashcash) {
|
||||||
|
throw new Error('hashcash algorthim changed');
|
||||||
|
}
|
||||||
|
console.info('PASS: empty hash solves correctly');
|
||||||
|
|
||||||
|
var now = Date.now();
|
||||||
|
var nonce = '20';
|
||||||
|
var needle = crypto
|
||||||
|
.randomBytes(3)
|
||||||
|
.toString('hex')
|
||||||
|
.slice(0, 5);
|
||||||
|
native
|
||||||
|
._hashcash({
|
||||||
|
alg: 'SHA-256',
|
||||||
|
nonce: nonce,
|
||||||
|
needle: needle,
|
||||||
|
start: 0,
|
||||||
|
end: Math.ceil(needle.length / 2)
|
||||||
|
})
|
||||||
|
.then(function(hashcash) {
|
||||||
|
var later = Date.now();
|
||||||
|
var parts = hashcash.split(':');
|
||||||
|
var answer = parts[1];
|
||||||
|
if (parts[0] !== nonce) {
|
||||||
|
throw new Error('incorrect nonce');
|
||||||
|
}
|
||||||
|
var haystack = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(Buffer.from(nonce + answer, 'hex'))
|
||||||
|
.digest()
|
||||||
|
.slice(0, Math.ceil(needle.length / 2));
|
||||||
|
if (
|
||||||
|
-1 === haystack.indexOf(Buffer.from(needle, 'hex'))
|
||||||
|
) {
|
||||||
|
throw new Error('incorrect solution');
|
||||||
|
}
|
||||||
|
if (later - now > 2000) {
|
||||||
|
throw new Error('took too long to solve');
|
||||||
|
}
|
||||||
|
console.info(
|
||||||
|
'PASS: rando hash solves correctly (and in good time - %dms)',
|
||||||
|
later - now
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
17
utils.js
17
utils.js
@ -82,26 +82,25 @@ U._request = function(me, opts) {
|
|||||||
if (ua && !opts.headers['User-Agent']) {
|
if (ua && !opts.headers['User-Agent']) {
|
||||||
opts.headers['User-Agent'] = ua;
|
opts.headers['User-Agent'] = ua;
|
||||||
}
|
}
|
||||||
if (opts.json && true !== opts.json) {
|
if (opts.json) {
|
||||||
opts.headers['Content-Type'] = 'application/jose+json';
|
opts.headers.Accept = 'application/json';
|
||||||
|
if (true !== opts.json) {
|
||||||
opts.body = JSON.stringify(opts.json);
|
opts.body = JSON.stringify(opts.json);
|
||||||
}
|
}
|
||||||
|
if (/*opts.jose ||*/ opts.json.protected) {
|
||||||
|
opts.headers['Content-Type'] = 'application/jose+json';
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!opts.method) {
|
if (!opts.method) {
|
||||||
opts.method = 'GET';
|
opts.method = 'GET';
|
||||||
if (opts.body) {
|
if (opts.body) {
|
||||||
opts.method = 'POST';
|
opts.method = 'POST';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (opts.json) {
|
|
||||||
opts.headers.Accept = 'application/json';
|
|
||||||
if (true !== opts.json) {
|
|
||||||
opts.body = JSON.stringify(opts.json);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//console.log('\n[debug] REQUEST');
|
//console.log('\n[debug] REQUEST');
|
||||||
//console.log(opts);
|
//console.log(opts);
|
||||||
return me.request(opts).then(function(resp) {
|
return me.__request(opts).then(function(resp) {
|
||||||
if (resp.toJSON) {
|
if (resp.toJSON) {
|
||||||
resp = resp.toJSON();
|
resp = resp.toJSON();
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user