diff --git a/db-server/lib/error.js b/db-server/lib/error.js index ca8fe788..d27595f6 100644 --- a/db-server/lib/error.js +++ b/db-server/lib/error.js @@ -83,7 +83,22 @@ AppError.invalidVerificationMethod = function () { ) } +AppError.unknownDeviceCapability = function () { + return new AppError( + { + code: 400, + error: 'Bad request', + errno: 139, + message: 'Unknown device capability' + } + ) +} + AppError.wrap = function (err) { + // Don't re-wrap! + if (err instanceof AppError) { + return err + } return new AppError( { code: 500, diff --git a/db-server/test/backend/db_tests.js b/db-server/test/backend/db_tests.js index 899a9b81..96dccd0d 100644 --- a/db-server/test/backend/db_tests.js +++ b/db-server/test/backend/db_tests.js @@ -103,7 +103,8 @@ function makeMockDevice(tokenId) { callbackURL: 'https://push.server', callbackPublicKey: 'foo', callbackAuthKey: 'bar', - callbackIsExpired: false + callbackIsExpired: false, + capabilities: ['pushbox'] } device.deviceId = newUuid() return device @@ -272,7 +273,7 @@ module.exports = function (config, DB) { assert(Array.isArray(sessions), 'sessions is an array') assert.equal(sessions.length, 1, 'sessions has one item') - assert.equal(Object.keys(sessions[0]).length, 19, 'session has correct properties') + assert.equal(Object.keys(sessions[0]).length, 20, 'session has correct properties') assert.equal(sessions[0].tokenId.toString('hex'), sessionTokenData.tokenId.toString('hex'), 'tokenId is correct') assert.equal(sessions[0].uid.toString('hex'), accountData.uid.toString('hex'), 'uid is correct') assert.equal(sessions[0].createdAt, sessionTokenData.createdAt, 'createdAt is correct') @@ -479,6 +480,7 @@ module.exports = function (config, DB) { assert.equal(sessions[0].deviceCallbackPublicKey, 'foo') assert.equal(sessions[0].deviceCallbackAuthKey, 'bar') assert.equal(sessions[0].deviceCallbackIsExpired, false) + assert.deepEqual(sessions[0].deviceCapabilities, ['pushbox']) }) }) @@ -887,7 +889,9 @@ module.exports = function (config, DB) { return db.createSessionToken(sessionTokenData.tokenId, sessionTokenData) .then(() => db.createDevice(accountData.uid, deviceInfo.deviceId, deviceInfo)) - .then((result) => assert.deepEqual(result, {}, 'returned empty object')) + .then((result) => { + return assert.deepEqual(result, {}, 'returned empty object') + }) }) it('should have created device', () => { @@ -902,6 +906,7 @@ module.exports = function (config, DB) { assert.equal(s.deviceCallbackPublicKey, deviceInfo.callbackPublicKey, 'callbackPublicKey') assert.equal(s.deviceCallbackAuthKey, deviceInfo.callbackAuthKey, 'callbackAuthKey') assert.equal(s.deviceCallbackIsExpired, deviceInfo.callbackIsExpired, 'callbackIsExpired') + assert.deepEqual(s.deviceCapabilities, deviceInfo.capabilities, 'capabilities') assert.equal(!! s.mustVerify, !! sessionTokenData.mustVerify, 'mustVerify is correct') assert.deepEqual(s.tokenVerificationId, sessionTokenData.tokenVerificationId, 'tokenVerificationId is correct') }) @@ -921,6 +926,7 @@ module.exports = function (config, DB) { assert.equal(device.callbackPublicKey, deviceInfo.callbackPublicKey, 'callbackPublicKey') assert.equal(device.callbackAuthKey, deviceInfo.callbackAuthKey, 'callbackAuthKey') assert.equal(device.callbackIsExpired, deviceInfo.callbackIsExpired, 'callbackIsExpired') + assert.deepEqual(device.capabilities, deviceInfo.capabilities, 'capabilities') assert(device.lastAccessTime > 0, 'has a lastAccessTime') assert.equal(device.email, accountData.email, 'email should be account email') }) @@ -933,6 +939,7 @@ module.exports = function (config, DB) { deviceInfo.callbackPublicKey = '' deviceInfo.callbackAuthKey = '' deviceInfo.callbackIsExpired = true + deviceInfo.capabilities = [] const newSessionTokenData = makeMockSessionToken(accountData.uid) deviceInfo.sessionTokenId = newSessionTokenData.tokenId @@ -955,6 +962,7 @@ module.exports = function (config, DB) { assert.equal(device.callbackPublicKey, '', 'callbackPublicKey unchanged') assert.equal(device.callbackAuthKey, '', 'callbackAuthKey unchanged') assert.equal(device.callbackIsExpired, true, 'callbackIsExpired unchanged') + assert.deepEqual(device.capabilities, [], 'capabilities updated') }) }) @@ -988,6 +996,40 @@ module.exports = function (config, DB) { }) }) + it('should fail to update a device with unknown capabilities', () => { + const newDevice = Object.assign({}, deviceInfo, { + capabilities: ['unknown', 'newpushbox'] + }) + return db.updateDevice(accountData.uid, deviceInfo.deviceId, newDevice) + .then(assert.fail, (err) => { + assert.equal(err.code, 400, 'err.code') + assert.equal(err.errno, 139, 'err.errno') + return db.accountDevices(accountData.uid) + }) + .then((devices) => assert.deepEqual(devices[0].capabilities, ['pushbox'])) + }) + + it('capabilities are not cleared if not specified', () => { + const newDevice = Object.assign({}, deviceInfo) + delete newDevice.capabilities + return db.updateDevice(accountData.uid, deviceInfo.deviceId, newDevice) + .then(() => { + return db.accountDevices(accountData.uid) + }) + .then((devices) => assert.deepEqual(devices[0].capabilities, ['pushbox'])) + }) + + it('capabilities are overwritten on update', () => { + const newDevice = Object.assign({}, deviceInfo, { + capabilities: [] + }) + return db.updateDevice(accountData.uid, deviceInfo.deviceId, newDevice) + .then(() => { + return db.accountDevices(accountData.uid) + }) + .then((devices) => assert.deepEqual(devices[0].capabilities, [])) + }) + it('should fail to delete non-existent device', () => { return db.deleteDevice(accountData.uid, hex16()) .then(assert.fail, (err) => { diff --git a/db-server/test/backend/remote.js b/db-server/test/backend/remote.js index fc2ee846..48cd729f 100644 --- a/db-server/test/backend/remote.js +++ b/db-server/test/backend/remote.js @@ -337,7 +337,7 @@ module.exports = function(cfg, makeServer) { respOk(r) var sessions = r.obj assert.equal(sessions.length, 1, 'sessions contains one item') - assert.equal(Object.keys(sessions[0]).length, 19, 'session has correct properties') + assert.equal(Object.keys(sessions[0]).length, 20, 'session has correct properties') assert.equal(sessions[0].tokenId, user.sessionTokenId, 'tokenId is correct') assert.equal(sessions[0].uid, user.accountId, 'uid is correct') assert.equal(sessions[0].createdAt, user.sessionToken.createdAt, 'createdAt is correct') @@ -525,6 +525,7 @@ module.exports = function(cfg, makeServer) { assert(s.deviceCallbackPublicKey) assert.equal(s.deviceCallbackURL, 'fake callback URL') assert.equal(s.deviceCallbackIsExpired, false) + assert.deepEqual(s.deviceCapabilities, ['pushbox']) assert(s.deviceCreatedAt) assert(s.deviceId) assert.equal(s.deviceName, 'fake device name') @@ -592,6 +593,27 @@ module.exports = function(cfg, makeServer) { .then(function() { return client.getThen('/account/' + user.accountId + '/devices') }) + .then(function(r) { + assert.equal(r.obj.length, 0, 'devices is empty') + const myDevice = Object.assign({}, user.device, { + capabilities: ['unknown', 'pushbox'] + }) + return client.putThen('/account/' + user.accountId + '/device/' + user.deviceId, myDevice) + .then(() => { + assert(false, 'a device with an unknown capability should make the request fail') + }) + .catch(err => { + assert.equal(err.statusCode, 400, 'err.statusCode should be 400') + assert.deepEqual(err.body, { + message: 'Unknown device capability', + errno: 139, + error: 'Bad request', + code: 400 + }, 'err.body should have correct properties set') + }) + }).then(function () { + return client.getThen('/account/' + user.accountId + '/devices') + }) .then(function(r) { assert.equal(r.obj.length, 0, 'devices is empty') return client.putThen('/account/' + user.accountId + '/device/' + user.deviceId, user.device) @@ -604,7 +626,7 @@ module.exports = function(cfg, makeServer) { respOk(r) var devices = r.obj assert.equal(devices.length, 1, 'devices contains one item') - assert.equal(Object.keys(devices[0]).length, 18, 'device has eighteen properties') + assert.equal(Object.keys(devices[0]).length, 19, 'device has nineteen properties') assert.equal(devices[0].uid, user.accountId, 'uid is correct') assert.equal(devices[0].id, user.deviceId, 'id is correct') assert.equal(devices[0].sessionTokenId, user.sessionTokenId, 'sessionTokenId is correct') @@ -615,6 +637,7 @@ module.exports = function(cfg, makeServer) { assert.equal(devices[0].callbackPublicKey, user.device.callbackPublicKey, 'callbackPublicKey is correct') assert.equal(devices[0].callbackAuthKey, user.device.callbackAuthKey, 'callbackAuthKey is correct') assert.equal(devices[0].callbackIsExpired, user.device.callbackIsExpired, 'callbackIsExpired is correct') + assert.deepEqual(devices[0].capabilities, user.device.capabilities, 'capabilities is correct') assert.equal(devices[0].uaBrowser, user.sessionToken.uaBrowser, 'uaBrowser is correct') assert.equal(devices[0].uaBrowserVersion, user.sessionToken.uaBrowserVersion, 'uaBrowserVersion is correct') assert.equal(devices[0].uaOS, user.sessionToken.uaOS, 'uaOS is correct') @@ -638,6 +661,7 @@ module.exports = function(cfg, makeServer) { assert.equal(device.callbackPublicKey, user.device.callbackPublicKey, 'callbackPublicKey is correct') assert.equal(device.callbackAuthKey, user.device.callbackAuthKey, 'callbackAuthKey is correct') assert.equal(device.callbackIsExpired, user.device.callbackIsExpired, 'callbackIsExpired is correct') + assert.deepEqual(device.capabilities, user.device.capabilities, 'capabilities is correct') return client.postThen('/account/' + user.accountId + '/device/' + user.deviceId + '/update', { name: 'wibble', @@ -645,7 +669,8 @@ module.exports = function(cfg, makeServer) { callbackURL: '', callbackPublicKey: null, callbackAuthKey: null, - callbackIsExpired: null + callbackIsExpired: null, + capabilities: [] }) }) .then(function(r) { @@ -666,6 +691,7 @@ module.exports = function(cfg, makeServer) { assert.equal(devices[0].callbackPublicKey, user.device.callbackPublicKey, 'callbackPublicKey is correct') assert.equal(devices[0].callbackAuthKey, user.device.callbackAuthKey, 'callbackAuthKey is correct') assert.equal(devices[0].callbackIsExpired, false, 'callbackIsExpired is correct') + assert.deepEqual(devices[0].capabilities, [], 'capabilities is correct') assert.equal(devices[0].uaBrowser, user.sessionToken.uaBrowser, 'uaBrowser is correct') assert.equal(devices[0].uaBrowserVersion, user.sessionToken.uaBrowserVersion, 'uaBrowserVersion is correct') assert.equal(devices[0].uaOS, user.sessionToken.uaOS, 'uaOS is correct') diff --git a/db-server/test/fake.js b/db-server/test/fake.js index 7fcee559..5c15dd89 100644 --- a/db-server/test/fake.js +++ b/db-server/test/fake.js @@ -75,7 +75,8 @@ module.exports.newUserDataHex = function() { callbackURL: 'fake callback URL', callbackPublicKey: base64_65(), callbackAuthKey: base64_16(), - callbackIsExpired: false + callbackIsExpired: false, + capabilities: ['pushbox'] } // keyFetchToken diff --git a/docs/API.md b/docs/API.md index 86620cbb..dea79589 100644 --- a/docs/API.md +++ b/docs/API.md @@ -663,6 +663,7 @@ Content-Type: application/json "callbackPublicKey": "BCp93zru09_hab2Bg37LpTNG__Pw6eMPEP2hrQpwuytoj3h4chXpGc-3qqdKyqjuvAiEupsnOd_RLyc7erJHWgA", "callbackAuthKey": "w3b14Zjc-Afj2SDOLOyong", "callbackIsExpired": false, + "capabilities": ["pushbox"], "uaBrowser": "Firefox", "uaBrowserVersion": "42", "uaOS": "Android", @@ -720,7 +721,8 @@ Content-Type: application/json "callbackURL": "https://updates.push.services.mozilla.com/update/abcdef01234567890abcdefabcdef01234567890abcdef", "callbackPublicKey": "BCp93zru09_hab2Bg37LpTNG__Pw6eMPEP2hrQpwuytoj3h4chXpGc-3qqdKyqjuvAiEupsnOd_RLyc7erJHWgA", "callbackAuthKey": "w3b14Zjc-Afj2SDOLOyong", - "callbackIsExpired": false + "callbackIsExpired": false, + "capabilities": ["pushbox"] } ``` @@ -755,7 +757,8 @@ curl \ "callbackURL": "https://updates.push.services.mozilla.com/update/abcdef01234567890abcdefabcdef01234567890abcdef", "callbackPublicKey": "BCp93zru09_hab2Bg37LpTNG__Pw6eMPEP2hrQpwuytoj3h4chXpGc-3qqdKyqjuvAiEupsnOd_RLyc7erJHWgA", "callbackAuthKey": "w3b14Zjc-Afj2SDOLOyong", - "callbackIsExpired": false + "callbackIsExpired": false, + "capabilities": ["pushbox"] }' ``` @@ -775,6 +778,10 @@ Content-Type: application/json * Conditions: if id already exists or sessionTokenId is already used by a different device * Content-Type : 'application/json' * Body : `{"errno":101",message":"Record already exists"}` +* Status Code : 400 Bad Request + * Conditions: if the device in the request body contained an unknown capability name + * Content-Type : 'application/json' + * Body : `{"errno":139",message":"Unknown device capability"}` * Status Code : 500 Internal Server Error * Conditions: if something goes wrong on the server * Content-Type : 'application/json' @@ -799,7 +806,8 @@ curl \ "callbackURL": "https://updates.push.services.mozilla.com/update/abcdef01234567890abcdefabcdef01234567890abcdef", "callbackPublicKey": "BCp93zru09_hab2Bg37LpTNG__Pw6eMPEP2hrQpwuytoj3h4chXpGc-3qqdKyqjuvAiEupsnOd_RLyc7erJHWgA", "callbackAuthKey": "w3b14Zjc-Afj2SDOLOyong", - "callbackIsExpired": false + "callbackIsExpired": false, + "capabilities": ["pushbox"] }' ``` @@ -819,6 +827,10 @@ Content-Type: application/json * Conditions: if sessionTokenId is already used by a different device * Content-Type : 'application/json' * Body : `{"errno":101",message":"Record already exists"}` +* Status Code : 400 Bad Request + * Conditions: if the device in the request body contained an unknown capability name + * Content-Type : 'application/json' + * Body : `{"errno":139",message":"Unknown device capability"}` * Status Code : 404 Not Found * Conditions: if device(uid,id) is not found in the database * Content-Type : 'application/json' @@ -909,6 +921,7 @@ Content-Length: 285 "deviceCallbackURL":null, "deviceCallbackPublicKey":null, "deviceCallbackIsExpired":false, + "deviceCapabilities":["pushbox"], "mustVerify":true, "tokenVerificationId":"12c41fac80fd6149f3f695e188b5f846" } @@ -975,6 +988,7 @@ Content-Length: 285 "deviceCallbackURL":null, "deviceCallbackPublicKey":null, "deviceCallbackIsExpired":false, + "deviceCapabilities":["pushbox"], "mustVerify":true, "tokenVerificationId":"12c41fac80fd6149f3f695e188b5f846" } diff --git a/docs/DB_API.md b/docs/DB_API.md index 04fecc69..323b20cb 100644 --- a/docs/DB_API.md +++ b/docs/DB_API.md @@ -510,6 +510,7 @@ The deviceCallbackPublicKey and deviceCallbackAuthKey fields are urlsafe-base64 d.callbackPublicKey AS deviceCallbackPublicKey, d.callbackAuthKey AS deviceCallbackAuthKey, d.callbackIsExpired AS deviceCallbackIsExpired, + d.capabilities AS deviceCapabilities, ut.mustVerify, ut.tokenVerificationId * keyFetchToken : t.authKey, t.uid, t.keyBundle, t.createdAt, a.emailVerified, a.verifierSetAt * keyFetchTokenWithVerificationStatus : t.authKey, t.uid, t.keyBundle, t.createdAt, a.emailVerified, @@ -743,6 +744,8 @@ Parameters: Public key for push service * `callbackAuthKey` (string): Auth key for push service + * `capabilities` (array): + Array of strings describing the current device capabilities Returns: @@ -750,6 +753,7 @@ Returns: * An empty object `{}` * Rejects with: * `error.duplicate()` if a device already exists with the same `uid` and `deviceId` + * `error.unknownDeviceCapability()` if the input device contained an unknown capability name * Any error from the underlying storage system (wrapped in `error.wrap()`) ## updateDevice(uid, deviceId, device) @@ -777,12 +781,15 @@ Parameters: Public key for push service * `callbackAuthKey` (string): Auth key for push service + * `capabilities` (array): + Array of strings describing the current device capabilities Returns: * Resolves with: * An empty object `{}` * Rejects with: + * `error.unknownDeviceCapability()` if the input device contained an unknown capability name * Any error from the underlying storage system (wrapped in `error.wrap()`) ## deleteDevice(uid, deviceId) diff --git a/lib/db/mem.js b/lib/db/mem.js index 73ac80d5..97ebd1ad 100644 --- a/lib/db/mem.js +++ b/lib/db/mem.js @@ -35,7 +35,8 @@ var DEVICE_FIELDS = [ 'callbackURL', 'callbackPublicKey', 'callbackAuthKey', - 'callbackIsExpired' + 'callbackIsExpired', + 'capabilities' ] const SESSION_DEVICE_FIELDS = [ @@ -201,6 +202,16 @@ module.exports = function (log, error) { return P.resolve({}) } + function checkCapabilities(deviceInfo) { + if (deviceInfo.capabilities) { + for (const capability of deviceInfo.capabilities) { + if (dbUtil.mapDeviceCapability(capability) == null) { + throw error.unknownDeviceCapability() + } + } + } + } + Memory.prototype.createDevice = function (uid, deviceId, deviceInfo) { return getAccountByUid(uid) .then( @@ -213,6 +224,7 @@ module.exports = function (log, error) { uid: uid, id: deviceId } + checkCapabilities(deviceInfo) deviceInfo.callbackIsExpired = false // mimic the db behavior assigning a default false value account.devices[deviceKey] = updateDeviceRecord(device, deviceInfo, deviceKey) return {} @@ -260,6 +272,7 @@ module.exports = function (log, error) { if (! account.devices[deviceKey]) { throw error.notFound() } + checkCapabilities(deviceInfo) var device = account.devices[deviceKey] if (device.sessionTokenId) { if (deviceInfo.sessionTokenId) { @@ -515,7 +528,8 @@ module.exports = function (log, error) { callbackURL: device.callbackURL, callbackPublicKey: device.callbackPublicKey, callbackAuthKey: device.callbackAuthKey, - callbackIsExpired: device.callbackIsExpired + callbackIsExpired: device.callbackIsExpired, + capabilities: device.capabilities || [] }) } ) @@ -593,6 +607,7 @@ module.exports = function (log, error) { item.deviceCallbackPublicKey = device.callbackPublicKey item.deviceCallbackAuthKey = device.callbackAuthKey item.deviceCallbackIsExpired = device.callbackIsExpired + item.deviceCapabilities = device.capabilities || [] } return item @@ -671,6 +686,7 @@ module.exports = function (log, error) { deviceCallbackPublicKey: deviceInfo.callbackPublicKey || null, deviceCallbackAuthKey: deviceInfo.callbackAuthKey || null, deviceCallbackIsExpired: deviceInfo.callbackIsExpired !== undefined ? deviceInfo.callbackIsExpired : null, + deviceCapabilities: deviceInfo.capabilities || [] } return session diff --git a/lib/db/mysql.js b/lib/db/mysql.js index 0fe279f1..ab2166d2 100644 --- a/lib/db/mysql.js +++ b/lib/db/mysql.js @@ -288,49 +288,79 @@ module.exports = function (log, error) { ) } + const ADD_CAPABILITY = 'CALL addCapability_1(?, ?, ?)' + const PURGE_CAPABILITIES = 'CALL purgeCapabilities_1(?, ?)' + var CREATE_DEVICE = 'CALL createDevice_4(?, ?, ?, ?, ?, ?, ?, ?, ?)' + function makeCapabilitiesStatements(uid, deviceId, deviceInfo) { + const capabilities = deviceInfo.capabilities || [] + return capabilities.reduce((acc, c) => { + const index = dbUtil.mapDeviceCapability(c) + if (index == null) { + throw error.unknownDeviceCapability() + } + acc.push({sql: ADD_CAPABILITY, params: [uid, deviceId, index]}) + return acc + }, []) + } + MySql.prototype.createDevice = function (uid, deviceId, deviceInfo) { - return this.write( - CREATE_DEVICE, - [ - uid, - deviceId, - deviceInfo.sessionTokenId, - deviceInfo.name, // inNameUtf8 - deviceInfo.type, - deviceInfo.createdAt, - deviceInfo.callbackURL, - deviceInfo.callbackPublicKey, - deviceInfo.callbackAuthKey - ] - ) + // The server assumes that we reject and do not throw. + // makeCapabilitiesStatements does throw. Let's wrap everything in a promise. + return Promise.resolve().then(() => { + const statements = [{ + sql: CREATE_DEVICE, + params: [ + uid, + deviceId, + deviceInfo.sessionTokenId, + deviceInfo.name, // inNameUtf8 + deviceInfo.type, + deviceInfo.createdAt, + deviceInfo.callbackURL, + deviceInfo.callbackPublicKey, + deviceInfo.callbackAuthKey + ] + }] + if (deviceInfo.hasOwnProperty('capabilities')) { + statements.push(...makeCapabilitiesStatements(uid, deviceId, deviceInfo)) + } + return this.writeMultiple(statements) + }) } var UPDATE_DEVICE = 'CALL updateDevice_5(?, ?, ?, ?, ?, ?, ?, ?, ?)' MySql.prototype.updateDevice = function (uid, deviceId, deviceInfo) { - return this.write( - UPDATE_DEVICE, - [ - uid, - deviceId, - deviceInfo.sessionTokenId, - deviceInfo.name, // inNameUtf8 - deviceInfo.type, - deviceInfo.callbackURL, - deviceInfo.callbackPublicKey, - deviceInfo.callbackAuthKey, - deviceInfo.callbackIsExpired - ], - function (result) { - if (result.affectedRows === 0) { - log.error('MySql.updateDevice', { err: result }) - throw error.notFound() + return Promise.resolve().then(() => { + const statements = [{ + sql: UPDATE_DEVICE, + params: [ + uid, + deviceId, + deviceInfo.sessionTokenId, + deviceInfo.name, // inNameUtf8 + deviceInfo.type, + deviceInfo.callbackURL, + deviceInfo.callbackPublicKey, + deviceInfo.callbackAuthKey, + deviceInfo.callbackIsExpired + ], + resultHandler(result) { + // If the UPDATE_DEVICE fails, no need to continue! + if (result.affectedRows === 0) { + log.error('MySql.updateDevice', { err: result }) + throw error.notFound() + } } - return {} + }] + if (deviceInfo.hasOwnProperty('capabilities')) { + statements.push({sql: PURGE_CAPABILITIES, params: [uid, deviceId]}, + ...makeCapabilitiesStatements(uid, deviceId, deviceInfo)) } - ) + return this.writeMultiple(statements) + }) } // READ @@ -363,32 +393,59 @@ module.exports = function (log, error) { }) } - // Select : devices d, sessionTokens s, accounts a + // This function takes a result and unwraps comma-separated values to Arrays + // and eventually applies transforms. + function unwrapArrays(result, fields, separator = ',') { + for (const {name, transform} of fields) { + const arr = result[name] ? result[name].split(separator) : [] + result[name] = transform ? transform(arr) : arr + } + } + + function unwrapCapabilities(result, fieldName) { + const field = { + name: fieldName, + transform(capabilityIds) { + return capabilityIds.reduce((acc, c) => { + const capabilityStr = dbUtil.mapDeviceCapability(parseInt(c)) + if (capabilityStr != null) { + acc.push(capabilityStr) + } + return acc + }, []) + } + } + const toUnwrap = Array.isArray(result) ? result : [result] + toUnwrap.forEach(r => unwrapArrays(r, [field])) + return result + } + + // Select : devices d, deviceCapabilities dc, sessionTokens s, accounts a // Fields : d.uid, d.id, d.sessionTokenId, d.name, d.type, d.createdAt, d.callbackURL, - // d.callbackPublicKey, d.callbackAuthKey, d.callbackIsExpired, + // d.callbackPublicKey, d.callbackAuthKey, d.callbackIsExpired, dc.capabilities, // s.uaBrowser, s.uaBrowserVersion, s.uaOS, s.uaOSVersion, s.uaDeviceType, // s.uaFormFactor, s.lastAccessTime, a.email // Where : d.uid = $1 - var ACCOUNT_DEVICES = 'CALL accountDevices_12(?)' + var ACCOUNT_DEVICES = 'CALL accountDevices_13(?)' MySql.prototype.accountDevices = function (uid) { return this.readOneFromFirstResult(ACCOUNT_DEVICES, [uid]) + .then(results => unwrapCapabilities(results, 'capabilities')) } - // Select : devices d, unverifiedTokens u - // Fields : d.id AS deviceId, d.name AS deviceName, d.type AS deviceType, d.createdAt - // AS deviceCreatedAt, d.callbackURL AS deviceCallbackURL, d.callbackPublicKey - // AS deviceCallbackPublicKey, d.callbackAuthKey AS deviceCallbackAuthKey, - // d.callbackIsExpired AS deviceCallbackIsExpired + // Select : devices d, deviceCapabilities dc, unverifiedTokens u + // Fields : d.id, d.name, d.type, d.createdAt, d.callbackURL, d.callbackPublicKey, + // d.callbackAuthKey, d.callbackIsExpired, dc.capabilities // Where : u.uid = $1 AND u.tokenVerificationId = $2 AND // u.tokenId = d.sessionTokenId AND u.uid = d.uid - var DEVICE_FROM_TOKEN_VERIFICATION_ID = 'CALL deviceFromTokenVerificationId_3(?, ?)' + var DEVICE_FROM_TOKEN_VERIFICATION_ID = 'CALL deviceFromTokenVerificationId_4(?, ?)' MySql.prototype.deviceFromTokenVerificationId = function (uid, tokenVerificationId) { return this.readFirstResult(DEVICE_FROM_TOKEN_VERIFICATION_ID, [uid, tokenVerificationId]) + .then(result => unwrapCapabilities(result, 'capabilities')) } - // Select : sessionTokens t, accounts a, devices d, unverifiedTokens ut + // Select : sessionTokens t, accounts a, devices d, deviceCapabilities dc, unverifiedTokens ut // Fields : t.tokenData, t.uid, t.createdAt, t.uaBrowser, t.uaBrowserVersion, t.uaOS, // t.uaOSVersion, t.uaDeviceType, t.uaFormFactor, t.lastAccessTime, t.authAt, // a.emailVerified, a.email, a.emailCode, a.verifierSetAt, a.locale, @@ -397,26 +454,31 @@ module.exports = function (log, error) { // AS deviceCreatedAt, d.callbackURL AS deviceCallbackURL, d.callbackPublicKey // AS deviceCallbackPublicKey, d.callbackAuthKey AS deviceCallbackAuthKey, // d.callbackIsExpired AS deviceCallbackIsExpired, + // dc.capabilities AS deviceCapabilities // ut.tokenVerificationId, ut.mustVerify // Where : t.tokenId = $1 AND t.uid = a.uid AND t.tokenId = d.sessionTokenId AND // t.uid = d.uid AND t.tokenId = u.tokenId - var SESSION_DEVICE = 'CALL sessionWithDevice_12(?)' + var SESSION_DEVICE = 'CALL sessionWithDevice_13(?)' MySql.prototype.sessionToken = function (id) { return this.readFirstResult(SESSION_DEVICE, [id]) + .then(result => unwrapCapabilities(result, 'deviceCapabilities')) } - // Select : sessionTokens - // Fields : tokenId, uid, createdAt, uaBrowser, uaBrowserVersion, - // uaOS, uaOSVersion, uaDeviceType, uaFormFactor, lastAccessTime, authAt, - // deviceId, deviceName, deviceType, deviceCreatedAt, deviceCallbackURL, - // deviceCallbackPublicKey, deviceCallbackAuthKey, deviceCallbackIsExpired + // Select : sessionTokens t, devices d, deviceCapabilities dc + // Fields : t.tokenId, t.uid, t.createdAt, t.uaBrowser, t.uaBrowserVersion, + // t.uaOS, t.uaOSVersion, t.uaDeviceType, t.uaFormFactor, t.lastAccessTime, t.authAt, + // d.id AS deviceId, d.name AS deviceName, d.type AS deviceType, + // d.createdAt AS deviceCreatedAt, d.callbackURL AS deviceCallbackURL, + // d.callbackPublicKey AS deviceCallbackPublicKey, d.callbackAuthKey AS deviceCallbackAuthKey, + // d.callbackIsExpired AS deviceCallbackIsExpired, dc.capabilities AS deviceCapabilities // Where : t.uid = $1 AND t.tokenId = d.sessionTokenId AND // t.uid = d.uid AND t.tokenId = u.tokenId - var SESSIONS = 'CALL sessions_8(?)' + var SESSIONS = 'CALL sessions_9(?)' MySql.prototype.sessions = function (uid) { return this.readOneFromFirstResult(SESSIONS, [uid]) + .then(results => unwrapCapabilities(results, 'deviceCapabilities')) } // Select : keyFetchTokens t, accounts a @@ -1040,6 +1102,35 @@ module.exports = function (log, error) { ) } + MySql.prototype.writeMultiple = function (queries) { + return this.transaction(connection => { + return P.each(queries, ({sql, params, resultHandler}) => { + return query(connection, sql, params) + .then( + function (result) { + log.trace('MySql.writeMultiple', { sql, result }) + if (resultHandler) { + return resultHandler(result) + } + }, + function (err) { + log.error('MySql.writeMultiple', { sql, err }) + if (err.errno === ER_DUP_ENTRY) { + err = error.duplicate() + } + else { + err = error.wrap(err) + } + throw err + } + ) + }) + }) + .then(() => { + return {} + }) + } + MySql.prototype.getConnection = function (name) { return new P((resolve, reject) => { retryable( diff --git a/lib/db/patch.js b/lib/db/patch.js index 66cc2487..72c10be7 100644 --- a/lib/db/patch.js +++ b/lib/db/patch.js @@ -4,4 +4,4 @@ // The expected patch level of the database. Update if you add a new // patch in the ./schema/ directory. -module.exports.level = 73 +module.exports.level = 74 diff --git a/lib/db/schema/patch-073-074.sql b/lib/db/schema/patch-073-074.sql new file mode 100644 index 00000000..b91bd91e --- /dev/null +++ b/lib/db/schema/patch-073-074.sql @@ -0,0 +1,179 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_bin; + +CREATE TABLE IF NOT EXISTS deviceCapabilities ( + uid BINARY(16) NOT NULL, + deviceId BINARY(16) NOT NULL, + capability TINYINT UNSIGNED NOT NULL, + PRIMARY KEY (uid, deviceId, capability), + FOREIGN KEY (uid, deviceId) REFERENCES devices(uid, id) ON DELETE CASCADE +) ENGINE=InnoDB; + +CREATE PROCEDURE `addCapability_1` ( + IN `inUid` BINARY(16), + IN `inDeviceId` BINARY(16), + IN `inCapability` TINYINT UNSIGNED +) +BEGIN + INSERT INTO deviceCapabilities( + uid, + deviceId, + capability + ) + VALUES ( + inUid, + inDeviceId, + inCapability + ); +END; + +CREATE PROCEDURE `purgeCapabilities_1` ( + IN `inUid` BINARY(16), + IN `inDeviceId` BINARY(16) +) +BEGIN + DELETE FROM deviceCapabilities WHERE uid = inUid AND deviceId = inDeviceId; +END; + +CREATE PROCEDURE `accountDevices_13` ( + IN `uidArg` BINARY(16) +) +BEGIN + SELECT + d.uid, + d.id, + d.sessionTokenId, + d.nameUtf8 AS name, + d.type, + d.createdAt, + d.callbackURL, + d.callbackPublicKey, + d.callbackAuthKey, + d.callbackIsExpired, + (SELECT GROUP_CONCAT(dc.capability) + FROM deviceCapabilities dc + WHERE dc.uid = d.uid AND dc.deviceId = d.id) AS capabilities, + s.uaBrowser, + s.uaBrowserVersion, + s.uaOS, + s.uaOSVersion, + s.uaDeviceType, + s.uaFormFactor, + s.lastAccessTime, + e.email + FROM devices AS d + INNER JOIN sessionTokens AS s + ON d.sessionTokenId = s.tokenId + INNER JOIN emails AS e + ON d.uid = e.uid + AND e.isPrimary = true + WHERE d.uid = uidArg; +END; + +CREATE PROCEDURE `deviceFromTokenVerificationId_4` ( + IN inUid BINARY(16), + IN inTokenVerificationId BINARY(16) +) +BEGIN + SELECT + d.id, + d.nameUtf8 AS name, + d.type, + d.createdAt, + d.callbackURL, + d.callbackPublicKey, + d.callbackAuthKey, + d.callbackIsExpired, + (SELECT GROUP_CONCAT(dc.capability) + FROM deviceCapabilities dc + WHERE dc.uid = d.uid AND dc.deviceId = d.id) AS capabilities + FROM unverifiedTokens AS u + INNER JOIN devices AS d + ON (u.tokenId = d.sessionTokenId AND u.uid = d.uid) + WHERE u.uid = inUid AND u.tokenVerificationId = inTokenVerificationId; +END; + + +CREATE PROCEDURE `sessionWithDevice_13` ( + IN `tokenIdArg` BINARY(32) +) +BEGIN + SELECT + t.tokenData, + t.uid, + t.createdAt, + t.uaBrowser, + t.uaBrowserVersion, + t.uaOS, + t.uaOSVersion, + t.uaDeviceType, + t.uaFormFactor, + t.lastAccessTime, + t.verificationMethod, + t.verifiedAt, + COALESCE(t.authAt, t.createdAt) AS authAt, + e.isVerified AS emailVerified, + e.email, + e.emailCode, + a.verifierSetAt, + a.locale, + a.createdAt AS accountCreatedAt, + d.id AS deviceId, + d.nameUtf8 AS deviceName, + d.type AS deviceType, + d.createdAt AS deviceCreatedAt, + d.callbackURL AS deviceCallbackURL, + d.callbackPublicKey AS deviceCallbackPublicKey, + d.callbackAuthKey AS deviceCallbackAuthKey, + d.callbackIsExpired AS deviceCallbackIsExpired, + (SELECT GROUP_CONCAT(dc.capability) + FROM deviceCapabilities dc + WHERE dc.uid = d.uid AND dc.deviceId = d.id) AS deviceCapabilities, + ut.tokenVerificationId, + COALESCE(t.mustVerify, ut.mustVerify) AS mustVerify + FROM sessionTokens AS t + LEFT JOIN accounts AS a + ON t.uid = a.uid + LEFT JOIN emails AS e + ON t.uid = e.uid + AND e.isPrimary = true + LEFT JOIN devices AS d + ON (t.tokenId = d.sessionTokenId AND t.uid = d.uid) + LEFT JOIN unverifiedTokens AS ut + ON t.tokenId = ut.tokenId + WHERE t.tokenId = tokenIdArg; +END; + +CREATE PROCEDURE `sessions_9` ( + IN `uidArg` BINARY(16) +) +BEGIN + SELECT + t.tokenId, + t.uid, + t.createdAt, + t.uaBrowser, + t.uaBrowserVersion, + t.uaOS, + t.uaOSVersion, + t.uaDeviceType, + t.uaFormFactor, + t.lastAccessTime, + COALESCE(t.authAt, t.createdAt) AS authAt, + d.id AS deviceId, + d.nameUtf8 AS deviceName, + d.type AS deviceType, + d.createdAt AS deviceCreatedAt, + d.callbackURL AS deviceCallbackURL, + d.callbackPublicKey AS deviceCallbackPublicKey, + d.callbackAuthKey AS deviceCallbackAuthKey, + d.callbackIsExpired AS deviceCallbackIsExpired, + (SELECT GROUP_CONCAT(dc.capability) + FROM deviceCapabilities dc + WHERE dc.uid = d.uid AND dc.deviceId = d.id) AS deviceCapabilities + FROM sessionTokens AS t + LEFT JOIN devices AS d + ON (t.tokenId = d.sessionTokenId AND t.uid = d.uid) + WHERE t.uid = uidArg; +END; + +UPDATE dbMetadata SET value = '74' WHERE name = 'schema-patch-level'; diff --git a/lib/db/schema/patch-074-073.sql b/lib/db/schema/patch-074-073.sql new file mode 100644 index 00000000..69d29a39 --- /dev/null +++ b/lib/db/schema/patch-074-073.sql @@ -0,0 +1,13 @@ +-- SET NAMES utf8mb4 COLLATE utf8mb4_bin; + +-- DROP TABLE `deviceCapabilities`; + +-- DROP PROCEDURE `addCapability_1`; +-- DROP PROCEDURE `purgeCapabilities_1`; +-- DROP PROCEDURE `accountDevices_13`; +-- DROP PROCEDURE `deviceFromTokenVerificationId_4`; +-- DROP PROCEDURE `sessionWithDevice_13`; +-- DROP PROCEDURE `sessions_9`; + +-- UPDATE dbMetadata SET value = '73' WHERE name = 'schema-patch-level'; + diff --git a/lib/db/util.js b/lib/db/util.js index 9d45bf5f..8cd45b12 100644 --- a/lib/db/util.js +++ b/lib/db/util.js @@ -37,8 +37,24 @@ const VERIFICATION_METHODS = new Map([ ['totp-2fa', 2] // TOTP code ]) +// If you modify one of these maps, modify the other. +const DEVICE_CAPABILITIES = new Map([ + ['pushbox', 1] +]) +const DEVICE_CAPABILITIES_IDS = new Map([ + [1, 'pushbox'] +]) + module.exports = { + mapDeviceCapability(val) { + if (typeof val === 'number') { + return DEVICE_CAPABILITIES_IDS.get(val) || null + } else { + return DEVICE_CAPABILITIES.get(val) || null + } + }, + mapEmailBounceType(val) { if (typeof val === 'number') { return val