UNPKG

@signalapp/mock-server

Version:
650 lines (649 loc) 29 kB
"use strict"; // Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.createHandler = void 0; const assert_1 = __importDefault(require("assert")); const buffer_1 = require("buffer"); const debug_1 = __importDefault(require("debug")); const micro_1 = require("micro"); const microrouter_1 = require("microrouter"); const promises_1 = require("node:fs/promises"); const promises_2 = require("node:stream/promises"); const server_1 = require("@tus/server"); const file_store_1 = require("@tus/file-store"); const compiled_1 = require("../../protos/compiled"); const schemas_1 = require("../data/schemas"); const common_1 = require("./common"); const path_1 = require("path"); const crypto_1 = require("crypto"); const zod_1 = __importDefault(require("zod")); const calling_1 = require("../calling"); const ice_1 = require("../sfu/ice"); const debug = (0, debug_1.default)('mock:http'); const ALL_METHODS = [microrouter_1.get, microrouter_1.post, microrouter_1.put, microrouter_1.patch, microrouter_1.del, microrouter_1.head, microrouter_1.options]; function getContentType(filePath) { const ext = filePath.toLowerCase().split('.').pop(); switch (ext) { case 'json': return 'application/json'; case 'png': return 'image/png'; default: return 'application/octet-stream'; } } const createHandler = (server, { cdn3Path, updates2Path, }) => { // // CDN // const tusServer = new server_1.Server({ path: '/cdn3', datastore: new file_store_1.FileStore({ directory: cdn3Path ?? '' }), namingFunction: (req) => { (0, assert_1.default)(req.url); return req.url.replace(/^(\/cdn3)?\/+/, ''); }, }); const getResourcesAttachment = (0, microrouter_1.get)('/updates2/*', async (req, res) => { const thePath = req.params._; (0, assert_1.default)(updates2Path, 'updates2Path must be provided to retrieve from updates2'); if (!thePath) { void (0, micro_1.send)(res, 400, { error: 'Missing path' }); return; } let file; try { file = await (0, promises_1.open)((0, path_1.join)(updates2Path, thePath), 'r'); const { size, mtime } = await file.stat(); const etag = `"${mtime.getTime().toString(16)}"`; res.writeHead(200, { 'Content-Length': size, 'Content-Type': getContentType(thePath), ETag: etag, }); await (0, promises_2.pipeline)(file.createReadStream(), res); } catch (e) { await file?.close(); (0, assert_1.default)(e instanceof Error); if ('code' in e && e.code === 'ENOENT') { return (0, micro_1.send)(res, 404); } return (0, micro_1.send)(res, 500, e.message); } }); const headResourcesAttachment = (0, microrouter_1.head)('/updates2/*', async (req, res) => { const thePath = req.params._; (0, assert_1.default)(updates2Path, 'updates2Path must be provided to retrieve from updates2'); if (!thePath) { void (0, micro_1.send)(res, 400, { error: 'Missing path' }); return; } const filePath = (0, path_1.join)(updates2Path, thePath); try { const { size } = await (0, promises_1.stat)(filePath); const fileContent = await (0, promises_1.readFile)(filePath); const etag = (0, crypto_1.createHash)('md5') .update(new Uint8Array(fileContent)) .digest('hex'); res.writeHead(200, { 'Content-Length': size, ETag: etag, }); res.end(); } catch (e) { (0, assert_1.default)(e instanceof Error); if ('code' in e && e.code === 'ENOENT') { return (0, micro_1.send)(res, 404); } return (0, micro_1.send)(res, 500, e.message); } }); const getCdn3Attachment = (0, microrouter_1.get)('/cdn3/:folder/*', async (req, res) => { (0, assert_1.default)(cdn3Path, 'cdn3Path must be set'); (0, assert_1.default)(req.params.folder != null, 'Missing folder param'); (0, assert_1.default)(req.params._ != null, 'Missing extra params'); if (req.params.folder === 'backups') { const { username, password, error } = (0, common_1.parsePassword)(req); if (error) { debug('%s %s backup cdn auth failed, error %j', req.method, req.url, error); void (0, micro_1.send)(res, 401, { error }); return; } if (!username || !password) { void (0, micro_1.send)(res, 401, { error: 'Missing username and/or password' }); return; } const authorized = await server.authorizeBackupCDN(username, password); if (!authorized) { void (0, micro_1.send)(res, 403, { error: 'Invalid password' }); return; } } let file; try { file = await (0, promises_1.open)((0, path_1.join)(cdn3Path, req.params.folder, req.params._), 'r'); const { size } = await file.stat(); res.writeHead(200, { 'Content-Length': size, }); await (0, promises_2.pipeline)(file.createReadStream(), res); } catch (e) { await file?.close(); (0, assert_1.default)(e instanceof Error); if ('code' in e && e.code === 'ENOENT') { return (0, micro_1.send)(res, 404); } return (0, micro_1.send)(res, 500, e.message); } }); const getAttachment = (0, microrouter_1.get)('/attachments/:key', async (req, res) => { // TODO(indutny): range requests const { key } = req.params; const result = await server.fetchAttachment(key); if (!result) { return (0, micro_1.send)(res, 404, { error: 'Attachment not found' }); } return result; }); const getStickerPack = (0, microrouter_1.get)('/stickers/:pack/manifest.proto', async (req, res) => { (0, assert_1.default)(req.params.pack != null, 'Missing pack param'); const { pack } = req.params; const result = await server.fetchStickerPack(pack); if (!result) { return (0, micro_1.send)(res, 404, { error: 'Sticker pack not found' }); } return result; }); const getSticker = (0, microrouter_1.get)('/stickers/:pack/full/:sticker', async (req, res) => { (0, assert_1.default)(req.params.pack != null, 'Missing pack param'); (0, assert_1.default)(req.params.sticker != null, 'Missing sticker param'); const { pack, sticker } = req.params; const result = await server.fetchSticker(pack, parseInt(sticker, 10)); if (!result) { return (0, micro_1.send)(res, 404, { error: 'Sticker not found' }); } return result; }); const notFound = async (req, res) => { debug('Unsupported request %s %s', req.method, req.url); return (0, micro_1.send)(res, 404, { error: 'Not supported yet' }); }; const getConferenceParticipants = (0, microrouter_1.get)('/v2/conference/participants', async (req, res) => { try { const authToken = (0, calling_1.parseCallingAuthHeader)(req.headers.authorization); const auth = (0, calling_1.verifyCallingAuthToken)(authToken, calling_1.CALLING_SERVICE_SECRET); const roomIdHeader = req.headers['x-room-id']; const epoch = req.headers.epoch; if (roomIdHeader != null || epoch != null) { throw new Error('unimplemented for call links'); } const info = await server.peekCall(auth.roomId, auth.userId); const response = { conferenceId: info.eraId, maxDevices: info.maxClients, participants: info.activeClients, creator: info.creatorUserId, pendingClients: info.pendingClients ?? [], callLinkState: null, }; await (0, micro_1.send)(res, 200, response); return; } catch (error) { if (error instanceof calling_1.CallingError) { const status = calling_1.CallingErrorCodesToHttpStatus[error.code]; return (0, micro_1.send)(res, status, { message: error.message }); } else { debug('Error: %', error); return (0, micro_1.send)(res, 500); } } }); const JoinConferenceParticipantsRequestBody = zod_1.default.object({ adminPasskey: zod_1.default.string().base64().optional(), iceUfrag: ice_1.IceUsernameFragmentSchema, icePwd: ice_1.IcePasswordSchema, dhePublicKey: calling_1.CallingPublicKeySchema, hkdfExtraInfo: zod_1.default.string().optional(), }); const joinConferenceParticipants = (0, microrouter_1.put)('/v2/conference/participants', async (req, res) => { try { const authToken = (0, calling_1.parseCallingAuthHeader)(req.headers.authorization); const auth = (0, calling_1.verifyCallingAuthToken)(authToken, calling_1.CALLING_SERVICE_SECRET); const roomIdHeader = req.headers['x-room-id']; const epochHeader = req.headers.epoch; if (roomIdHeader != null || epochHeader != null) { throw new Error('unimplemented for call links'); } const body = await (0, micro_1.json)(req); const data = JoinConferenceParticipantsRequestBody.parse(body); const clientHkdfExtraInfo = data.hkdfExtraInfo != null ? buffer_1.Buffer.from(data.hkdfExtraInfo) : null; const clientPublicKey = (0, calling_1.decodeCallingPublicKey)(data.dhePublicKey); const result = await server.joinCall({ roomId: auth.roomId, userId: auth.userId, isAllowedToInitiateGroupCall: auth.isAllowedToInitiateGroupCall, clientIceUsernameFragment: data.iceUfrag, clientIcePassword: data.icePwd, clientPublicKey, clientHkdfExtraInfo, callType: calling_1.CallType.Group, newClientsRequireApproval: false, isAdmin: false, approvedUsers: null, }); const response = { demuxId: result.demuxId, ips: result.serverMediaAddress.addresses, port: result.serverMediaAddress.ports.udp, portTcp: result.serverMediaAddress.ports.tcp, portTls: result.serverMediaAddress.ports.tls, hostname: result.serverMediaAddress.hostname, iceUfrag: result.serverIceUsernameFragment, icePwd: result.serverIcePassword, dhePublicKey: (0, calling_1.encodeCallingPublicKey)(result.serverPublicKey), callCreator: result.callCreatorUserId, conferenceId: result.callEraId, clientStatus: result.clientStatus, }; await (0, micro_1.send)(res, 200, response); return; } catch (error) { if (error instanceof calling_1.CallingError) { const status = calling_1.CallingErrorCodesToHttpStatus[error.code]; return (0, micro_1.send)(res, status, { message: error.message }); } else { debug('Error: %', error); return (0, micro_1.send)(res, 500); } } }); function toCallLinkResponse(callLink) { return { name: callLink.encryptedName, restrictions: callLink.restrictions, revoked: callLink.revoked, expiration: Math.floor(callLink.expiration / 1000), // unix }; } const getCallLink = (0, microrouter_1.get)('/v1/call-link/', async (req, res) => { const roomId = req.headers['x-room-id']; if (typeof roomId !== 'string') { return (0, micro_1.send)(res, 400, { error: 'Missing room ID' }); } const callLink = await server.getCallLink(roomId); if (!callLink) { return (0, micro_1.send)(res, 404, { error: 'Call link not found' }); } return toCallLinkResponse(callLink); }); const createOrUpdateCallLink = (0, microrouter_1.put)('/v1/call-link', async (req, res) => { const roomId = req.headers['x-room-id']; if (typeof roomId !== 'string') { return (0, micro_1.send)(res, 400, { error: 'Missing room ID' }); } const body = await (0, micro_1.json)(req); let callLink; if (!server.hasCallLink(roomId)) { const createParams = schemas_1.CreateCallLinkSchema.parse(body); callLink = await server.createCallLink(roomId, createParams); } else { const updateParams = schemas_1.UpdateCallLinkSchema.parse(body); callLink = await server.updateCallLink(roomId, updateParams); } return toCallLinkResponse(callLink); }); const deleteCallLink = (0, microrouter_1.del)('/v1/call-link', async (req, res) => { const roomId = req.headers['x-room-id']; if (typeof roomId !== 'string') { return (0, micro_1.send)(res, 400, { error: 'Missing room ID' }); } const deleteParams = schemas_1.DeleteCallLinkSchema.parse(await (0, micro_1.json)(req)); await server.deleteCallLink(roomId, deleteParams); return {}; }); async function groupAuth(req, res) { const { error, username, password } = (0, common_1.parsePassword)(req); if (error) { void (0, micro_1.send)(res, 400, { error }); return undefined; } if (!username || !password) { void (0, micro_1.send)(res, 400, { error: 'Invalid authorization header' }); return undefined; } const publicParams = buffer_1.Buffer.from(username, 'hex'); const credential = buffer_1.Buffer.from(password, 'hex'); let aciCiphertext; let pniCiphertext; try { const auth = await server.verifyGroupCredentials(publicParams, credential); aciCiphertext = auth.getUuidCiphertext(); const maybePni = auth.getPniCiphertext(); // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions (0, assert_1.default)(maybePni, 'Auth credentials must have PNI'); pniCiphertext = maybePni; } catch { void (0, micro_1.send)(res, 403, { error: 'Invalid credentials' }); return undefined; } return { publicParams, aciCiphertext, pniCiphertext }; } async function groupAuthAndFetch(req, res) { const auth = await groupAuth(req, res); if (!auth) { return; } const group = await server.getGroup(auth.publicParams); if (!group) { void (0, micro_1.send)(res, 404, { error: 'Group not found' }); return undefined; } return { group, ...auth }; } async function storageAuth(req, res) { const { error, username, password } = (0, common_1.parsePassword)(req); if (error) { void (0, micro_1.send)(res, 400, { error }); return undefined; } if (!username || !password) { void (0, micro_1.send)(res, 400, { error: 'Invalid authorization header' }); return undefined; } const device = await server.storageAuth(username, password); if (!device) { debug('%s %s storage auth failed', req.method, req.url); void (0, micro_1.send)(res, 403, { error: 'Invalid authorization' }); return undefined; } return device; } // // GV2 // const getGroup = (0, microrouter_1.get)('/v2/groups', async (req, res) => { const auth = await groupAuthAndFetch(req, res); if (!auth) { return; } const { group } = auth; const groupSendEndorsementsResponse = group.getGroupSendEndorsementResponse(auth.aciCiphertext); return (0, micro_1.send)(res, 200, compiled_1.signalservice.GroupResponse.encode({ group: group.state, groupSendEndorsementsResponse, })); }); const getGroupVersion = (0, microrouter_1.get)('/v2/groups/joined_at_version', async (req, res) => { const auth = await groupAuthAndFetch(req, res); if (!auth) { return; } const { group, aciCiphertext } = auth; const member = group.getMember(aciCiphertext); if (!member) { return (0, micro_1.send)(res, 403, { error: 'Not a member of this group' }); } return (0, micro_1.send)(res, 200, compiled_1.signalservice.Member.encode({ userId: null, role: null, profileKey: null, presentation: null, joinedAtVersion: member.joinedAtVersion, labelEmoji: null, labelString: null, })); }); const SECONDS_IN_SIX_HOURS = 6 * 60 * 60; async function getGroupLogsInner(req, res) { const auth = await groupAuthAndFetch(req, res); if (!auth) { return; } const { group, aciCiphertext } = auth; const member = group.getMember(aciCiphertext); if (!member) { return (0, micro_1.send)(res, 403, { error: 'Not a member of this group' }); } (0, assert_1.default)(req.params.since != null, 'Missing since param'); const since = parseInt(req.params.since, 10); if (since < (member.joinedAtVersion ?? 0)) { return (0, micro_1.send)(res, 403, { error: '`since` is before joinedAtVersion' }); } return { auth, groupChanges: group.getChangesSince(since), }; } const getGroupLogs = (0, microrouter_1.get)('/v2/groups/logs/:since', async (req, res) => { const result = await getGroupLogsInner(req, res); if (!result) { return; } const { groupChanges: { groupChanges }, auth, } = result; const { group } = auth; const expirationResult = schemas_1.PositiveInt.safeParse(req.headers['cached-send-endorsements']); if (!expirationResult.success) { return (0, micro_1.send)(res, 400); } const expirationTime = expirationResult.data; const currentTime = Math.floor(Date.now() / 1000); const expiresInLessThanSixHours = expirationTime < currentTime + SECONDS_IN_SIX_HOURS; const membershipChange = groupChanges?.find((change) => { const encodedActions = change.groupChange?.actions; if (!encodedActions) { return false; } const actions = compiled_1.signalservice.GroupChange.Actions.decode(encodedActions); return (actions.addMembers.length > 0 || actions.deleteMembers.length > 0 || actions.promoteMembersPendingPniAciProfileKey.length > 0 || actions.promoteMembersPendingProfileKey.length > 0); }); let groupSendEndorsementsResponse = null; if (membershipChange || expiresInLessThanSixHours) { groupSendEndorsementsResponse = group.getGroupSendEndorsementResponse(auth.aciCiphertext); } return (0, micro_1.send)(res, 200, compiled_1.signalservice.GroupChanges.encode({ groupChanges, groupSendEndorsementsResponse, })); }); async function createGroupInner(req, res) { const auth = await groupAuth(req, res); if (!auth) { return; } const groupData = compiled_1.signalservice.Group.decode(buffer_1.Buffer.from(await (0, micro_1.buffer)(req))); if (!groupData.title.length) { return (0, micro_1.send)(res, 400, { error: 'Missing group title' }); } if (!groupData.publicKey.length || !auth.publicParams.equals(groupData.publicKey)) { return (0, micro_1.send)(res, 400, { error: 'Invalid group public key' }); } const group = await server.createGroup(groupData); // TODO(indutny): verify that creator is a member return { auth, group }; } const createGroup = (0, microrouter_1.put)('/v2/groups', async (req, res) => { const result = await createGroupInner(req, res); if (!result) { return; } const { group, auth } = result; return (0, micro_1.send)(res, 200, compiled_1.signalservice.GroupResponse.encode({ group: group.state, groupSendEndorsementsResponse: group.getGroupSendEndorsementResponse(auth.aciCiphertext), })); }); async function modifyGroupInner(req, res) { const auth = await groupAuthAndFetch(req, res); if (!auth) { return; } const actions = compiled_1.signalservice.GroupChange.Actions.decode(buffer_1.Buffer.from(await (0, micro_1.buffer)(req))); if (actions.groupId.length) { return (0, micro_1.send)(res, 400, { error: 'Bad Request' }); } const { group, aciCiphertext, pniCiphertext } = auth; try { const modifyResult = await server.modifyGroup({ group, aciCiphertext: aciCiphertext.serialize(), pniCiphertext: pniCiphertext.serialize(), actions, }); if (modifyResult.conflict) { await (0, micro_1.send)(res, 409, { error: 'Conflict' }); return; } return { auth, signedChange: modifyResult.signedChange, }; } catch (error) { (0, assert_1.default)(error instanceof Error); debug('Failed to modify group', error.stack); // TODO(indutny): would be nice to give 403 here return (0, micro_1.send)(res, 500, { error: error.stack }); } } const modifyGroup = (0, microrouter_1.patch)('/v2/groups', async (req, res) => { const result = await modifyGroupInner(req, res); if (!result) { return; } const { signedChange, auth } = result; const { group, aciCiphertext } = auth; return (0, micro_1.send)(res, 200, compiled_1.signalservice.GroupChangeResponse.encode({ groupChange: signedChange, groupSendEndorsementsResponse: group.getGroupSendEndorsementResponse(aciCiphertext), })); }); // // Storage Service // const getGroupToken = (0, microrouter_1.get)('/v2/groups/token', async (req, res) => { const auth = await groupAuthAndFetch(req, res); if (!auth) { return; } const { group } = auth; const member = group.getMember(auth.aciCiphertext); if (member == null) { return (0, micro_1.send)(res, 403, { error: 'Not a member of this group' }); } (0, assert_1.default)(member.userId != null, 'Missing member.userId'); const token = (0, calling_1.generateCallingAuthToken)({ userId: member.userId, groupId: group.id, isAllowedToInitiateGroupCall: true, key: calling_1.CALLING_SERVICE_SECRET, }); return (0, micro_1.send)(res, 200, compiled_1.signalservice.ExternalGroupCredential.encode({ token })); }); const getStorageManifest = (0, microrouter_1.get)('/v1/storage/manifest', async (req, res) => { const device = await storageAuth(req, res); if (!device) { return; } const manifest = await server.getStorageManifest(device); if (!manifest) { return (0, micro_1.send)(res, 404, { error: 'Manifest not found' }); } return (0, micro_1.send)(res, 200, compiled_1.signalservice.StorageManifest.encode(manifest)); }); const getStorageManifestByVersion = (0, microrouter_1.get)('/v1/storage/manifest/version/:after', async (req, res) => { const device = await storageAuth(req, res); if (!device) { return; } (0, assert_1.default)(req.params.after != null, 'Missing after param'); const after = BigInt(req.params.after); const manifest = await server.getStorageManifest(device); if (manifest === undefined) { return (0, micro_1.send)(res, 404); } if (!manifest.version || manifest.version <= after) { return (0, micro_1.send)(res, 204); } return (0, micro_1.send)(res, 200, compiled_1.signalservice.StorageManifest.encode(manifest)); }); const putStorage = (0, microrouter_1.put)('/v1/storage/', async (req, res) => { const device = await storageAuth(req, res); if (!device) { return; } const writeOperation = compiled_1.signalservice.WriteOperation.decode(buffer_1.Buffer.from(await (0, micro_1.buffer)(req))); const result = await server.applyStorageWrite(device, writeOperation); if ('error' in result) { return (0, micro_1.send)(res, 400, { error: result.error }); } if (!result.updated) { return (0, micro_1.send)(res, 409, compiled_1.signalservice.StorageManifest.encode(result.manifest)); } return (0, micro_1.send)(res, 200); }); const putStorageRead = (0, microrouter_1.put)('/v1/storage/read', async (req, res) => { const device = await storageAuth(req, res); if (!device) { return; } const readOperation = compiled_1.signalservice.ReadOperation.decode(buffer_1.Buffer.from(await (0, micro_1.buffer)(req))); const keys = readOperation.readKey.map((key) => buffer_1.Buffer.from(key)); const items = await server.getStorageItems(device, keys); if (!items) { return (0, micro_1.send)(res, 413, { error: 'Requested too many items' }); } return (0, micro_1.send)(res, 200, compiled_1.signalservice.StorageItems.encode({ items, })); }); const notFoundAfterAuth = async (req, res) => { const device = await (0, common_1.auth)(server, req, res); if (!device) { return; } debug('Unsupported request %s %s', req.method, req.url); return (0, micro_1.send)(res, 404, { error: 'Not supported yet' }); }; const routes = (0, microrouter_1.router)(getAttachment, getStickerPack, getSticker, // Technically these should live on a separate server, but who cares (0, microrouter_1.withNamespace)('/storageService')( // All storage service routes have the X-Signal-Timestamp header ...ALL_METHODS.map((method) => method('/*', (_req, res) => { res.setHeader('X-Signal-Timestamp', Date.now()); })), getGroup, getGroupVersion, getGroupLogs, createGroup, modifyGroup, getGroupToken, getStorageManifest, getStorageManifestByVersion, putStorage, putStorageRead), (0, microrouter_1.withNamespace)('/callingService')(getConferenceParticipants, joinConferenceParticipants, getCallLink, createOrUpdateCallLink, deleteCallLink), ...[microrouter_1.head, microrouter_1.patch, microrouter_1.post].map((method) => method('/cdn3/*', async (req, res) => { await tusServer.handle(req, res); })), getCdn3Attachment, getResourcesAttachment, headResourcesAttachment, (0, microrouter_1.get)('/stickers/', notFound), ...ALL_METHODS.map((method) => method('/*', notFoundAfterAuth))); return (req, res) => { debug('got request %s %s', req.method, req.url); try { res.once('finish', () => { debug('response %s %s', req.method, req.url, res.statusCode); }); // eslint-disable-next-line @typescript-eslint/no-unsafe-return return routes(req, res); } catch (error) { (0, assert_1.default)(error instanceof Error); debug('request failure %s %s', req.method, req.url, error.stack); return (0, micro_1.send)(res, 500, error.message); } }; }; exports.createHandler = createHandler;