fix(recovery): hash recovery key
This commit is contained in:
Родитель
13ca41572f
Коммит
fe12332ef6
|
@ -244,7 +244,8 @@ function createServer(db) {
|
||||||
op((req) => db.consumeRecoveryCode(req.params.id, req.params.code))
|
op((req) => db.consumeRecoveryCode(req.params.id, req.params.code))
|
||||||
)
|
)
|
||||||
|
|
||||||
api.get('/account/:id/recoveryKey', withParams(db.getRecoveryKey))
|
api.get('/account/:id/recoveryKey/:recoveryKeyId', withParams(db.getRecoveryKey))
|
||||||
|
api.get('/account/:id/recoveryKey', withIdAndBody(db.recoveryKeyExists))
|
||||||
api.del('/account/:id/recoveryKey', withParams(db.deleteRecoveryKey))
|
api.del('/account/:id/recoveryKey', withParams(db.deleteRecoveryKey))
|
||||||
api.post('/account/:id/recoveryKey', withIdAndBody(db.createRecoveryKey))
|
api.post('/account/:id/recoveryKey', withIdAndBody(db.createRecoveryKey))
|
||||||
|
|
||||||
|
|
|
@ -83,6 +83,15 @@ AppError.invalidVerificationMethod = function () {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AppError.recoveryKeyInvalid = () => {
|
||||||
|
return new AppError({
|
||||||
|
code: 400,
|
||||||
|
error: 'Bad Request',
|
||||||
|
errno: 159,
|
||||||
|
message: 'Recovery key is not valid'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
AppError.wrap = function (err) {
|
AppError.wrap = function (err) {
|
||||||
// Don't re-wrap!
|
// Don't re-wrap!
|
||||||
if (err instanceof AppError) {
|
if (err instanceof AppError) {
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
const assert = require('insist')
|
const assert = require('insist')
|
||||||
const crypto = require('crypto')
|
const crypto = require('crypto')
|
||||||
const P = require('bluebird')
|
const P = require('bluebird')
|
||||||
|
const util = require('../../../lib/db/util')
|
||||||
|
|
||||||
const zeroBuffer16 = Buffer.from('00000000000000000000000000000000', 'hex')
|
const zeroBuffer16 = Buffer.from('00000000000000000000000000000000', 'hex')
|
||||||
const zeroBuffer32 = Buffer.from('0000000000000000000000000000000000000000000000000000000000000000', 'hex')
|
const zeroBuffer32 = Buffer.from('0000000000000000000000000000000000000000000000000000000000000000', 'hex')
|
||||||
|
@ -2279,18 +2280,21 @@ module.exports = function (config, DB) {
|
||||||
|
|
||||||
it('should get account recovery key', () => {
|
it('should get account recovery key', () => {
|
||||||
const options = {
|
const options = {
|
||||||
id: account.uid
|
id: account.uid,
|
||||||
|
recoveryKeyId: data.recoveryKeyId
|
||||||
}
|
}
|
||||||
return db.getRecoveryKey(options)
|
return db.getRecoveryKey(options)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
assert.equal(res.recoveryData, data.recoveryData, 'recovery data set')
|
assert.equal(res.recoveryData, data.recoveryData, 'recovery data set')
|
||||||
assert.equal(res.recoveryKeyId.toString('hex'), data.recoveryKeyId.toString('hex'), 'recovery data set')
|
const recoveryKeyIdHash = util.createHash(data.recoveryKeyId)
|
||||||
|
assert.equal(res.recoveryKeyIdHash.toString('hex'), recoveryKeyIdHash.toString('hex'), 'recoveryKeyId set')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should fail to get key for incorrect user', () => {
|
it('should fail to get key for incorrect user', () => {
|
||||||
const options = {
|
const options = {
|
||||||
id: 'unknown'
|
id: 'unknown',
|
||||||
|
recoveryKeyId: '123'
|
||||||
}
|
}
|
||||||
return db.getRecoveryKey(options)
|
return db.getRecoveryKey(options)
|
||||||
.then(assert.fail, (err) => {
|
.then(assert.fail, (err) => {
|
||||||
|
@ -2298,12 +2302,13 @@ module.exports = function (config, DB) {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should fail to get unknown key', () => {
|
it('should fail to get non-existent key', () => {
|
||||||
account = createAccount()
|
account = createAccount()
|
||||||
return db.createAccount(account.uid, account)
|
return db.createAccount(account.uid, account)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
const options = {
|
const options = {
|
||||||
id: account.uid
|
id: account.uid,
|
||||||
|
recoveryKeyId: 'unknown'
|
||||||
}
|
}
|
||||||
return db.getRecoveryKey(options)
|
return db.getRecoveryKey(options)
|
||||||
.then(assert.fail, (err) => {
|
.then(assert.fail, (err) => {
|
||||||
|
@ -2312,14 +2317,39 @@ module.exports = function (config, DB) {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should delete account recovery key', () => {
|
it('should fail to get key with invalid recoveryKeyId', () => {
|
||||||
const options = {
|
const options = {
|
||||||
id: account.uid,
|
id: account.uid,
|
||||||
recoveryKeyId: data.recoveryKeyId
|
recoveryKeyId: 'incorrect recoveryKeyId'
|
||||||
}
|
}
|
||||||
return db.deleteRecoveryKey(options)
|
return db.getRecoveryKey(options)
|
||||||
|
.then(assert.fail, (err) => {
|
||||||
|
assert.equal(err.errno, 159, 'incorrect recoveryKeyId')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return true if recovery key exists', () => {
|
||||||
|
return db.recoveryKeyExists(account.uid)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
assert.ok(res)
|
assert.equal(res.exists, true, 'key exists')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false if recovery key doesn\'t exist', () => {
|
||||||
|
account = createAccount()
|
||||||
|
return db.createAccount(account.uid, account)
|
||||||
|
.then(() => {
|
||||||
|
return db.recoveryKeyExists(account.uid)
|
||||||
|
.then((res) => {
|
||||||
|
assert.equal(res.exists, false, 'key doesn\'t exist')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw when checking for recovery key on non-existent user', () => {
|
||||||
|
return db.recoveryKeyExists('nonexistent')
|
||||||
|
.then((res) => {
|
||||||
|
assert.equal(res.exists, false, 'key doesn\'t exist')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -1749,7 +1749,7 @@ module.exports = function(cfg, makeServer) {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should get a recovery key', () => {
|
it('should get a recovery key', () => {
|
||||||
return client.getThen('/account/' + user.accountId + '/recoveryKey')
|
return client.getThen('/account/' + user.accountId + '/recoveryKey/' + recoveryKey.recoveryKeyId)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const recoveryKeyResult = res.obj
|
const recoveryKeyResult = res.obj
|
||||||
assert.equal(recoveryKeyResult.recoveryData, recoveryKey.recoveryData, 'recoveryData match')
|
assert.equal(recoveryKeyResult.recoveryData, recoveryKey.recoveryData, 'recoveryData match')
|
||||||
|
@ -1762,6 +1762,14 @@ module.exports = function(cfg, makeServer) {
|
||||||
respOkEmpty(r)
|
respOkEmpty(r)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should check if recovery key exists', () => {
|
||||||
|
return client.getThen('/account/' + user.accountId + '/recoveryKey')
|
||||||
|
.then((res) => {
|
||||||
|
const result = res.obj
|
||||||
|
assert.equal(result.exists, true, 'recovery key exists')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
after(() => server.close())
|
after(() => server.close())
|
||||||
|
|
38
docs/API.md
38
docs/API.md
|
@ -2281,3 +2281,41 @@ Content-Length: 2
|
||||||
* Conditions: if something goes wrong on the server
|
* Conditions: if something goes wrong on the server
|
||||||
* Content-Type : `application/json`
|
* Content-Type : `application/json`
|
||||||
* Body : `{"code":"InternalError","message":"..."}`
|
* Body : `{"code":"InternalError","message":"..."}`
|
||||||
|
|
||||||
|
## recoveryKeyExists : `GET /account/:uid/recoveryKey`
|
||||||
|
|
||||||
|
Check if this user has a recovery key
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```
|
||||||
|
curl \
|
||||||
|
-v \
|
||||||
|
-X GET \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
http://localhost:8000/account/1234567890ab/recoveryKey
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
* Method : `GET`
|
||||||
|
* Path : `/account/<uid>/recoveryKey`
|
||||||
|
* `uid` : hex
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
```
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 2
|
||||||
|
|
||||||
|
{"exists": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
* Status Code : `200 OK`
|
||||||
|
* Content-Type : `application/json`
|
||||||
|
* Body : `{"exists": true}`
|
||||||
|
* Status Code : `500 Internal Server Error`
|
||||||
|
* Conditions: if something goes wrong on the server
|
||||||
|
* Content-Type : `application/json`
|
||||||
|
* Body : `{"code":"InternalError","message":"..."}`
|
|
@ -72,8 +72,9 @@ There are a number of methods that a DB storage backend should implement:
|
||||||
* .consumeRecoveryCode(uid, code)
|
* .consumeRecoveryCode(uid, code)
|
||||||
* Recovery keys
|
* Recovery keys
|
||||||
* .createRecoveryKey(uid, data)
|
* .createRecoveryKey(uid, data)
|
||||||
* .getRecoveryKey(uid)
|
* .getRecoveryKey(data)
|
||||||
* .deleteRecoveryKey(uid)
|
* .deleteRecoveryKey(uid)
|
||||||
|
* .recoveryKeyExists(uid)
|
||||||
* General
|
* General
|
||||||
* .ping()
|
* .ping()
|
||||||
* .close()
|
* .close()
|
||||||
|
@ -954,7 +955,7 @@ Returns:
|
||||||
* Any error from the underlying storage system (wrapped in `error.wrap()`)
|
* Any error from the underlying storage system (wrapped in `error.wrap()`)
|
||||||
* `error.notFound()` if this user found
|
* `error.notFound()` if this user found
|
||||||
|
|
||||||
## getRecoveryKey(uid)
|
## getRecoveryKey(data)
|
||||||
|
|
||||||
Get the recovery key for this user.
|
Get the recovery key for this user.
|
||||||
|
|
||||||
|
@ -962,6 +963,8 @@ Parameters:
|
||||||
|
|
||||||
* `uid` (Buffer16):
|
* `uid` (Buffer16):
|
||||||
The uid of the owning account
|
The uid of the owning account
|
||||||
|
* `recoveryKeyId` (Buffer32):
|
||||||
|
The recoveryKeyId for account
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
||||||
|
@ -988,3 +991,19 @@ Returns:
|
||||||
* Rejects with:
|
* Rejects with:
|
||||||
* Any error from the underlying storage system (wrapped in `error.wrap()`)
|
* Any error from the underlying storage system (wrapped in `error.wrap()`)
|
||||||
* `error.notFound()` if this user or recovery key not found
|
* `error.notFound()` if this user or recovery key not found
|
||||||
|
|
||||||
|
## recoveryKeyExists(uid)
|
||||||
|
|
||||||
|
Check to see if a recovery key exists for this user.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
* `uid` (Buffer16):
|
||||||
|
The uid of the owning account
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
|
||||||
|
* Resolves with:
|
||||||
|
* object {"exists": true}
|
||||||
|
* Rejects with:
|
||||||
|
* Any error from the underlying storage system (wrapped in `error.wrap()`)
|
|
@ -1371,7 +1371,7 @@ module.exports = function (log, error) {
|
||||||
|
|
||||||
recoveryKeys[uid] = {
|
recoveryKeys[uid] = {
|
||||||
uid,
|
uid,
|
||||||
recoveryKeyId: data.recoveryKeyId,
|
recoveryKeyIdHash: dbUtil.createHash(data.recoveryKeyId).toString('hex'),
|
||||||
recoveryData: data.recoveryData
|
recoveryData: data.recoveryData
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1381,6 +1381,7 @@ module.exports = function (log, error) {
|
||||||
|
|
||||||
Memory.prototype.getRecoveryKey = function (options) {
|
Memory.prototype.getRecoveryKey = function (options) {
|
||||||
const uid = options.id.toString('hex')
|
const uid = options.id.toString('hex')
|
||||||
|
const recoveryKeyIdHash = dbUtil.createHash(options.recoveryKeyId).toString('hex')
|
||||||
return getAccountByUid(uid)
|
return getAccountByUid(uid)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
const recoveryKey = recoveryKeys[uid]
|
const recoveryKey = recoveryKeys[uid]
|
||||||
|
@ -1389,10 +1390,39 @@ module.exports = function (log, error) {
|
||||||
return P.reject(error.notFound())
|
return P.reject(error.notFound())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (recoveryKey.recoveryKeyIdHash !== recoveryKeyIdHash) {
|
||||||
|
return P.reject(error.recoveryKeyInvalid())
|
||||||
|
}
|
||||||
|
|
||||||
return recoveryKey
|
return recoveryKey
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Memory.prototype.recoveryKeyExists = function (uid) {
|
||||||
|
uid = uid.toString('hex')
|
||||||
|
let exists = true
|
||||||
|
return getAccountByUid(uid)
|
||||||
|
.then(() => {
|
||||||
|
const recoveryKey = recoveryKeys[uid]
|
||||||
|
|
||||||
|
if (! recoveryKey) {
|
||||||
|
exists = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {exists}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
// To match the mysql implementation, we return false when the
|
||||||
|
// user does not exist.
|
||||||
|
if (err.errno === error.notFound().errno) {
|
||||||
|
exists = false
|
||||||
|
return {exists}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
Memory.prototype.deleteRecoveryKey = function (options) {
|
Memory.prototype.deleteRecoveryKey = function (options) {
|
||||||
const uid = options.id.toString('hex')
|
const uid = options.id.toString('hex')
|
||||||
return getAccountByUid(uid)
|
return getAccountByUid(uid)
|
||||||
|
|
|
@ -1531,11 +1531,11 @@ module.exports = function (log, error) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const CREATE_RECOVERY_KEY = 'CALL createRecoveryKey_2(?, ?, ?)'
|
const CREATE_RECOVERY_KEY = 'CALL createRecoveryKey_3(?, ?, ?)'
|
||||||
MySql.prototype.createRecoveryKey = function (uid, data) {
|
MySql.prototype.createRecoveryKey = function (uid, data) {
|
||||||
const recoveryKeyId = data.recoveryKeyId
|
const recoveryKeyIdHash = dbUtil.createHash(data.recoveryKeyId)
|
||||||
const recoveryData = data.recoveryData
|
const recoveryData = data.recoveryData
|
||||||
return this.write(CREATE_RECOVERY_KEY, [uid, recoveryKeyId, recoveryData])
|
return this.write(CREATE_RECOVERY_KEY, [uid, recoveryKeyIdHash, recoveryData])
|
||||||
.then(() => {
|
.then(() => {
|
||||||
return {}
|
return {}
|
||||||
})
|
})
|
||||||
|
@ -1548,7 +1548,7 @@ module.exports = function (log, error) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const GET_RECOVERY_KEY = 'CALL getRecoveryKey_2(?)'
|
const GET_RECOVERY_KEY = 'CALL getRecoveryKey_3(?)'
|
||||||
MySql.prototype.getRecoveryKey = function (options) {
|
MySql.prototype.getRecoveryKey = function (options) {
|
||||||
return this.readFirstResult(GET_RECOVERY_KEY, [options.id])
|
return this.readFirstResult(GET_RECOVERY_KEY, [options.id])
|
||||||
.then((results) => {
|
.then((results) => {
|
||||||
|
@ -1557,10 +1557,30 @@ module.exports = function (log, error) {
|
||||||
throw error.notFound()
|
throw error.notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Currently, a user can only have one recovery key. Instead of
|
||||||
|
// simply returning the key, lets double check that the right recoveryKeyId
|
||||||
|
// was specified and throw a custom error if they don't match.
|
||||||
|
const recoveryKeyIdHash = dbUtil.createHash(options.recoveryKeyId)
|
||||||
|
if (! results.recoveryKeyIdHash.equals(recoveryKeyIdHash)) {
|
||||||
|
throw error.recoveryKeyInvalid()
|
||||||
|
}
|
||||||
|
|
||||||
return results
|
return results
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MySql.prototype.recoveryKeyExists = function (uid) {
|
||||||
|
let exists = true
|
||||||
|
return this.read(GET_RECOVERY_KEY, [uid])
|
||||||
|
.then((results) => {
|
||||||
|
if (results[0].length === 0) {
|
||||||
|
exists = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {exists}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const DELETE_RECOVERY_KEY = 'CALL deleteRecoveryKey_2(?)'
|
const DELETE_RECOVERY_KEY = 'CALL deleteRecoveryKey_2(?)'
|
||||||
MySql.prototype.deleteRecoveryKey = function (options) {
|
MySql.prototype.deleteRecoveryKey = function (options) {
|
||||||
return this.write(DELETE_RECOVERY_KEY, [options.id])
|
return this.write(DELETE_RECOVERY_KEY, [options.id])
|
||||||
|
|
|
@ -4,4 +4,4 @@
|
||||||
|
|
||||||
// The expected patch level of the database. Update if you add a new
|
// The expected patch level of the database. Update if you add a new
|
||||||
// patch in the ./schema/ directory.
|
// patch in the ./schema/ directory.
|
||||||
module.exports.level = 85
|
module.exports.level = 86
|
||||||
|
|
|
@ -0,0 +1,60 @@
|
||||||
|
-- This migration updates recoveryKey table to store a hashed value of
|
||||||
|
-- the `recoveryKeyId` instead of the raw value. As part of this migration,
|
||||||
|
-- we will remove all existing recoveryKeys. This will help to ensure a consistent
|
||||||
|
-- migration.
|
||||||
|
SET NAMES utf8mb4 COLLATE utf8mb4_bin;
|
||||||
|
|
||||||
|
CALL assertPatchLevel('85');
|
||||||
|
|
||||||
|
DELETE FROM recoveryKeys;
|
||||||
|
|
||||||
|
-- We can drop this since we will be using the `recoveryKeyIdHash`
|
||||||
|
-- column instead.
|
||||||
|
ALTER TABLE recoveryKeys DROP COLUMN recoveryKeyId,
|
||||||
|
ALGORITHM = INPLACE, LOCK = NONE;
|
||||||
|
|
||||||
|
ALTER TABLE recoveryKeys ADD COLUMN recoveryKeyIdHash BINARY(32) NOT NULL,
|
||||||
|
ALGORITHM = INPLACE, LOCK = NONE;
|
||||||
|
|
||||||
|
-- If we are in the process of a rollout and a user calls `createRecoveryKey_2`
|
||||||
|
-- then that request will fail. But that is ok since the previous create
|
||||||
|
-- procedure would have put the raw recoveryKeyId in database, which
|
||||||
|
-- would not work anyways.
|
||||||
|
CREATE PROCEDURE `createRecoveryKey_3` (
|
||||||
|
IN `uidArg` BINARY(16),
|
||||||
|
IN `recoveryKeyIdHashArg` BINARY(32),
|
||||||
|
IN `recoveryDataArg` TEXT
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
|
||||||
|
DECLARE EXIT HANDLER FOR SQLEXCEPTION
|
||||||
|
BEGIN
|
||||||
|
ROLLBACK;
|
||||||
|
RESIGNAL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
START TRANSACTION;
|
||||||
|
|
||||||
|
SET @accountCount = 0;
|
||||||
|
|
||||||
|
-- Signal error if no user found
|
||||||
|
SELECT COUNT(*) INTO @accountCount FROM `accounts` WHERE `uid` = `uidArg`;
|
||||||
|
IF @accountCount = 0 THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1643, MESSAGE_TEXT = 'Can not create recovery key for unknown user.';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO recoveryKeys (uid, recoveryKeyIdHash, recoveryData) VALUES (uidArg, recoveryKeyIdHashArg, recoveryDataArg);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE PROCEDURE `getRecoveryKey_3` (
|
||||||
|
IN `uidArg` BINARY(16)
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
|
||||||
|
SELECT recoveryKeyIdHash, recoveryData FROM recoveryKeys WHERE uid = uidArg;
|
||||||
|
|
||||||
|
END;
|
||||||
|
|
||||||
|
UPDATE dbMetadata SET value = '86' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,14 @@
|
||||||
|
-- SET NAMES utf8mb4 COLLATE utf8mb4_bin;
|
||||||
|
|
||||||
|
-- ALTER TABLE `recoveryKeys`
|
||||||
|
-- ADD COLUMN recoveryKeyId
|
||||||
|
-- ALGORITHM = INPLACE, LOCK = NONE;
|
||||||
|
|
||||||
|
-- ALTER TABLE `recoveryKeys`
|
||||||
|
-- DROP COLUMN recoveryKeyIdHash
|
||||||
|
-- ALGORITHM = INPLACE, LOCK = NONE;
|
||||||
|
|
||||||
|
-- DROP PROCEDURE createRecoveryKey_3;
|
||||||
|
-- DROP PROCEDURE getRecoveryKey_3;
|
||||||
|
|
||||||
|
-- UPDATE dbMetadata SET value = '85' WHERE name = 'schema-patch-level';
|
|
@ -187,5 +187,4 @@ module.exports = {
|
||||||
return items
|
return items
|
||||||
}, [])
|
}, [])
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
Загрузка…
Ссылка в новой задаче