@signalapp/mock-server
Version:
Mock Signal Server for writing tests
575 lines (574 loc) • 24.6 kB
JavaScript
"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 long_1 = __importDefault(require("long"));
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 util_1 = require("../util");
const path_1 = require("path");
const crypto_1 = require("crypto");
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];
const parsePassword = (req) => {
return (0, util_1.parseAuthHeader)(req.headers.authorization);
};
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) {
(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) {
(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');
if (req.params.folder === 'backups') {
const { username, password, error } = parsePassword(req);
if (error) {
debug('%s %s backup cdn auth failed, error %j', req.method, req.url, error);
(0, micro_1.send)(res, 401, { error });
return;
}
if (!username || !password) {
(0, micro_1.send)(res, 401, { error: 'Missing username and/or password' });
return;
}
const authorized = await server.authorizeBackupCDN(username, password);
if (!authorized) {
(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) => {
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) => {
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' });
};
//
// Calling
//
function toCallLinkResponse(callLink) {
return {
name: callLink.encryptedName,
restrictions: String(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 {};
});
//
// Authorized requests
//
async function auth(req, res) {
const { username, password, error } = parsePassword(req);
if (error) {
debug('%s %s auth failed, error %j', req.method, req.url, error);
(0, micro_1.send)(res, 401, { error });
return;
}
const device = await server.auth(username ?? '', password ?? '');
if (!device) {
debug('%s %s auth failed, need re-provisioning', req.method, req.url);
(0, micro_1.send)(res, 401, { error: 'Need re-provisioning' });
return;
}
return device;
}
async function groupAuth(req, res) {
const { error, username, password } = parsePassword(req);
if (error) {
(0, micro_1.send)(res, 400, { error });
return undefined;
}
if (!username || !password) {
(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();
(0, assert_1.default)(maybePni, 'Auth credentials must have PNI');
pniCiphertext = maybePni;
}
catch (_) {
(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) {
(0, micro_1.send)(res, 404, { error: 'Group not found' });
return undefined;
}
return { group, ...auth };
}
async function storageAuth(req, res) {
const { error, username, password } = parsePassword(req);
if (error) {
(0, micro_1.send)(res, 400, { error });
return undefined;
}
if (!username || !password) {
(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);
(0, micro_1.send)(res, 403, { error: 'Invalid authorization' });
return undefined;
}
return device;
}
//
// GV2
//
const getGroupV1 = (0, microrouter_1.get)('/v1/groups', async (req, res) => {
const auth = await groupAuthAndFetch(req, res);
if (!auth) {
return;
}
const { group } = auth;
return (0, micro_1.send)(res, 200, compiled_1.signalservice.Group.encode(group.state).finish());
});
const getGroup = (0, microrouter_1.get)('/v2/groups', async (req, res) => {
const auth = await groupAuthAndFetch(req, res);
if (!auth) {
return;
}
const { group } = auth;
const groupSendEndorsementResponse = group.getGroupSendEndorsementResponse(auth.aciCiphertext);
return (0, micro_1.send)(res, 200, compiled_1.signalservice.GroupResponse.encode({
group: group.state,
groupSendEndorsementResponse,
}).finish());
});
const getGroupVersion = (0, microrouter_1.get)('/v1/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({
joinedAtVersion: member.joinedAtVersion,
}).finish());
});
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' });
}
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 getGroupLogsV1 = (0, microrouter_1.get)('/v1/groups/logs/:since', async (req, res) => {
const result = await getGroupLogsInner(req, res);
if (!result) {
return;
}
return (0, micro_1.send)(res, 200, compiled_1.signalservice.GroupChanges.encode({
groupChanges: result.groupChanges.groupChanges,
}).finish());
});
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.promotePendingMembers.length > 0);
});
let groupSendEndorsementResponse = null;
if (membershipChange || expiresInLessThanSixHours) {
groupSendEndorsementResponse = group.getGroupSendEndorsementResponse(auth.aciCiphertext);
}
return (0, micro_1.send)(res, 200, compiled_1.signalservice.GroupChanges.encode({
groupChanges,
groupSendEndorsementResponse,
}).finish());
});
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) {
return (0, micro_1.send)(res, 400, { error: 'Missing group title' });
}
if (!groupData.publicKey ||
!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 createGroupV1 = (0, microrouter_1.put)('/v1/groups', async (req, res) => {
const result = await createGroupInner(req, res);
if (!result) {
return;
}
return (0, micro_1.send)(res, 200);
});
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,
groupSendEndorsementResponse: group.getGroupSendEndorsementResponse(auth.aciCiphertext),
}).finish());
});
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) {
return (0, micro_1.send)(res, 409, { error: 'Conflict' });
}
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 modifyGroupV1 = (0, microrouter_1.patch)('/v1/groups', async (req, res) => {
const result = await modifyGroupInner(req, res);
if (!result) {
return;
}
const { signedChange } = result;
return (0, micro_1.send)(res, 200, compiled_1.signalservice.GroupChange.encode(signedChange).finish());
});
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,
groupSendEndorsementResponse: group.getGroupSendEndorsementResponse(aciCiphertext),
}).finish());
});
//
// Storage Service
//
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).finish());
});
const getStorageManifestByVersion = (0, microrouter_1.get)('/v1/storage/manifest/version/:after', async (req, res) => {
const device = await storageAuth(req, res);
if (!device) {
return;
}
const after = long_1.default.fromString(req.params.after);
const manifest = await server.getStorageManifest(device);
if (!manifest?.version?.gt(after)) {
return (0, micro_1.send)(res, 204);
}
return (0, micro_1.send)(res, 200, compiled_1.signalservice.StorageManifest.encode(manifest).finish());
});
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).finish());
}
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,
}).finish());
});
const notFoundAfterAuth = async (req, res) => {
const device = await auth(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());
})), getGroupV1, getGroup, getGroupVersion, getGroupLogsV1, getGroupLogs, createGroupV1, createGroup, modifyGroupV1, modifyGroup,
// TODO(indutny): support this
(0, microrouter_1.get)('/v1/groups/token', notFound), getStorageManifest, getStorageManifestByVersion, putStorage, putStorageRead), 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);
});
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;