// Signal messages inside the hub, through signal-cli-rest-api (an unofficial but widely used bridge that links to your Signal account as // one more linked device, like Signal Desktop). The hub keeps the conversations it receives in /data/signal so they survive restarts. // Everything here is behind the hub's passkey sign-in and the "Signal" tab permission. const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { Readable } = require('stream'); const MAX_PER_CONV = 1500; const trace = []; // what has been seen lately (kinds of messages and connection changes, never the text), for working out why a message did not show const note = (kind, extra) => { trace.push({ at: new Date().toISOString(), kind, ...extra }); if (trace.length > 300) trace.shift(); }; const ids = () => (state.db.ids = state.db.ids || {}); // a contact can show up by phone number or only by Signal's private id (uuid); this remembers which is whose const state = { api: '', dir: '', account: '', db: { account: '', convs: {}, msgs: {} }, ws: null, wsUp: false, backoff: 1000, clients: new Set(), saveT: null, contacts: { at: 0, map: new Map() }, groups: { at: 0, list: [] }, started: false }; const file = () => path.join(state.dir, 'store.json'); function load() { try { state.db = { account: '', convs: {}, msgs: {}, ...JSON.parse(fs.readFileSync(file(), 'utf8')) }; } catch { /* first run */ } } function flushNow() { if (!state.saveT) return; clearTimeout(state.saveT); state.saveT = null; try { const f = file(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(state.db), { mode: 0o600 }); fs.renameSync(tmp, f); } catch (e) { console.error('[signal] could not save:', e.message); } } function saveSoon() { if (state.saveT) return; state.saveT = setTimeout(() => { state.saveT = null; try { const f = file(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(state.db), { mode: 0o600 }); fs.renameSync(tmp, f); } catch (e) { console.error('[signal] could not save:', e.message); } }, 400); state.saveT.unref && state.saveT.unref(); } // ---- talking to signal-cli-rest-api async function api(method, p, body, timeout = 30000) { const r = await fetch(state.api + p, { method, signal: AbortSignal.timeout(timeout), headers: body ? { 'content-type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined }); return r; } async function apiJson(method, p, body) { const r = await api(method, p, body); const t = await r.text(); let j = null; try { j = JSON.parse(t); } catch { /* not json */ } if (!r.ok) throw Object.assign(new Error((j && j.error) || ('Signal service answered ' + r.status)), { status: r.status }); return j; } // ---- who is who async function loadContacts(force) { if (!state.account) return; if (!force && Date.now() - state.contacts.at < 10 * 60e3) return; state.contacts.at = Date.now(); try { const list = await apiJson('GET', '/v1/contacts/' + encodeURIComponent(state.account)); const m = new Map(); for (const c of list || []) { const n = c.name || c.nickname && c.nickname.name || [c.given_name, c.family_name].filter(Boolean).join(' ') || c.profile_name || (c.profile && c.profile.given_name) || ''; if (c.number) m.set(c.number, n); if (c.number && c.uuid && ids()[c.uuid] !== c.number) { ids()[c.uuid] = c.number; mergeConv(c.uuid, c.number); } } state.contacts.map = m; for (const c of Object.values(state.db.convs)) if (c.type === 'direct') { const n = displayName(c.id, c.name !== c.id ? c.name : ''); if (n && c.name !== n) c.name = n; } saveSoon(); } catch (e) { state.contacts.at = Date.now() - 9 * 60e3; /* try again in a minute */ } } async function loadGroups(force) { if (!state.account) return; if (!force && Date.now() - state.groups.at < 5 * 60e3) return; state.groups.at = Date.now(); try { state.groups.list = (await apiJson('GET', '/v1/groups/' + encodeURIComponent(state.account))) || []; for (const g of state.groups.list) { const c = state.db.convs['g:' + g.internal_id]; if (c && g.name && c.name !== g.name) c.name = g.name; } saveSoon(); } catch (e) { state.groups.at = Date.now() - 4 * 60e3; } } const groupApiId = internal => { const g = state.groups.list.find(x => x.internal_id === internal); return g ? g.id : 'group.' + Buffer.from(internal).toString('base64'); }; const nicks = () => (state.db.nick = state.db.nick || {}); // names you chose for contacts in this app const displayName = (id, hint) => nicks()[id] || state.contacts.map.get(id) || hint || id; function setNick(number, name) { name = name.trim().slice(0, 80); if (name) nicks()[number] = name; else delete nicks()[number]; const c = state.db.convs[number]; if (c) { c.name = displayName(number, ''); emit({ t: 'update', conv: number }); } saveSoon(); return name; } function dropFiles(cid) { // pictures you sent that were kept here for (const m of state.db.msgs[cid] || []) for (const a of m.att || []) if (a.local && /^local-[0-9a-f]+$/.test(a.id)) { try { fs.unlinkSync(path.join(state.dir, 'out', a.id)); } catch { /* already gone */ } } } // ---- keeping the conversations function emit(ev) { const s = 'data: ' + JSON.stringify({ ...ev, unread: totalUnread() }) + '\n\n'; for (const res of state.clients) { try { res.write(s); } catch { state.clients.delete(res); } } } const totalUnread = () => Object.values(state.db.convs).reduce((a, c) => a + (c.unread || 0), 0); function conv(id, type, nameHint) { let c = state.db.convs[id]; if (!c) { c = state.db.convs[id] = { id, type, name: type === 'group' ? (nameHint || 'Group') : displayName(id, nameHint), ts: 0, preview: '', unread: 0 }; state.db.msgs[id] = state.db.msgs[id] || []; if (type === 'direct') loadContacts(true); else loadGroups(true); } else if (type === 'direct' && nameHint && (c.name === id || !c.name)) c.name = displayName(id, nameHint); else if (type === 'group' && nameHint && c.name === 'Group') c.name = nameHint; return c; } const findMsg = (cid, ts) => (state.db.msgs[cid] || []).find(m => m.ts === ts); const attOf = a => ({ id: a.id, type: a.contentType, name: a.filename, size: a.size, w: a.width, h: a.height, voice: !!a.isVoiceNote }); const previewOf = m => m.deleted ? 'Message deleted' : m.text || (m.att && m.att[0] ? (/^image\//.test(m.att[0].type) ? 'Photo' : /^audio\//.test(m.att[0].type) ? 'Voice message' : m.att[0].name || 'Attachment') : ''); function addMessage(cid, c, m) { const list = state.db.msgs[cid] = state.db.msgs[cid] || []; if (list.some(x => x.id === m.id)) return false; list.push(m); list.sort((a, b) => a.ts - b.ts); if (list.length > MAX_PER_CONV) list.splice(0, list.length - MAX_PER_CONV); if (m.ts >= c.ts) { c.ts = m.ts; c.preview = (m.out ? 'You: ' : '') + previewOf(m); } if (!m.out) c.unread = (c.unread || 0) + 1; saveSoon(); return true; } // One person, one conversation, whether Signal names them by number or only by private id. function peer(number, uuid) { const map = ids(); if (number && uuid && map[uuid] !== number) { map[uuid] = number; mergeConv(uuid, number); saveSoon(); } return number || (uuid && map[uuid]) || uuid || ''; } function mergeConv(from, to) { const a = state.db.convs[from]; if (!a || from === to) return; const b = state.db.convs[to] || (state.db.convs[to] = { id: to, type: 'direct', name: a.name, ts: 0, preview: '', unread: 0 }); const list = (state.db.msgs[to] = state.db.msgs[to] || []); for (const m of state.db.msgs[from] || []) if (!list.some(x => x.id === m.id || (x.ts === m.ts && x.out === m.out))) list.push(m); list.sort((x, y) => x.ts - y.ts); b.unread = (b.unread || 0) + (a.unread || 0); const last = list[list.length - 1]; if (last) { b.ts = last.ts; b.preview = (last.out ? 'You: ' : '') + previewOf(last); } if (b.name === to && a.name !== from) b.name = a.name; delete state.db.convs[from]; delete state.db.msgs[from]; emit({ t: 'update', conv: to }); } function ingestData(dm, from, fromName, ts, out, dest) { const gid = dm.groupInfo && dm.groupInfo.groupId; const cid = gid ? 'g:' + gid : (out ? dest : from); if (!cid) return; const c = conv(cid, gid ? 'group' : 'direct', gid ? dm.groupInfo.groupName : (out ? '' : fromName)); if (dm.reaction) { const m = findMsg(cid, dm.reaction.targetSentTimestamp); if (!m) return; m.react = m.react || {}; if (dm.reaction.isRemove) delete m.react[from]; else m.react[from] = dm.reaction.emoji; saveSoon(); return emit({ t: 'update', conv: cid }); } if (dm.remoteDelete) { const m = findMsg(cid, dm.remoteDelete.timestamp); if (!m) return; m.deleted = true; m.text = ''; m.att = []; saveSoon(); return emit({ t: 'update', conv: cid }); } const text = dm.message || '', att = (dm.attachments || []).map(attOf); if (!text && !att.length && !dm.sticker) return; const m = { id: ts + ':' + from, ts, out: !!out, from, name: out ? '' : fromName || displayName(from, ''), text, att }; if (dm.sticker) m.text = m.text || '(sticker)'; if (dm.quote) m.quote = { author: dm.quote.authorNumber || dm.quote.author, text: dm.quote.text || '', ts: dm.quote.id }; if (addMessage(cid, c, m)) emit({ t: 'msg', conv: cid, out: !!out, name: c.name, preview: previewOf(m) }); } function ingestEdit(ed, from, ts, out, dest) { const dm = ed.dataMessage || {}, gid = dm.groupInfo && dm.groupInfo.groupId, cid = gid ? 'g:' + gid : (out ? dest : from); const m = cid && findMsg(cid, ed.targetSentTimestamp); if (!m) return; m.text = dm.message || m.text; m.edited = true; const c = state.db.convs[cid]; if (c && m.ts >= c.ts) c.preview = (m.out ? 'You: ' : '') + previewOf(m); saveSoon(); emit({ t: 'update', conv: cid }); } function ingestReceipt(r, who) { const status = r.isRead || r.isViewed ? 'read' : r.isDelivery ? 'delivered' : ''; if (!status) return; let changed = null; for (const [cid, list] of Object.entries(state.db.msgs)) { for (const m of list) if (m.out && (r.timestamps || []).includes(m.ts) && (m.status !== 'read')) { m.status = status; changed = cid; } } if (changed) { saveSoon(); emit({ t: 'update', conv: changed }); } } function ingest(env) { if (!env) return; const ts = env.timestamp || Date.now(), who = peer(env.sourceNumber, env.sourceUuid || (/^[0-9a-f-]{36}$/i.test(env.source || '') ? env.source : '')) || env.source || '', name = env.sourceName || ''; if (env.dataMessage) ingestData(env.dataMessage, who, name, env.dataMessage.timestamp || ts, false); else if (env.editMessage) ingestEdit(env.editMessage, who, ts, false); else if (env.syncMessage && env.syncMessage.sentMessage) { const sm = env.syncMessage.sentMessage; if (sm.editMessage) ingestEdit(sm.editMessage, state.account, ts, true, peer(sm.destinationNumber, sm.destinationUuid) || sm.destination); else ingestData(sm, state.account, '', sm.timestamp || ts, true, peer(sm.destinationNumber, sm.destinationUuid) || sm.destination); } else if (env.receiptMessage) ingestReceipt(env.receiptMessage, who); else if (env.typingMessage) emit({ t: 'typing', conv: env.typingMessage.groupId ? 'g:' + env.typingMessage.groupId : who, stop: env.typingMessage.action === 'STOPPED' }); } // A frame normally holds one message, but messages that arrive close together could come glued together (one JSON per line, an array, or // back to back), so read every JSON value in it instead of giving up when the whole thing is not one value. function jsonValues(text) { try { const v = JSON.parse(text); return Array.isArray(v) ? v : [v]; } catch { /* more than one value, or damaged */ } const out = []; let depth = 0, start = -1, inStr = false, esc = false; for (let i = 0; i < text.length; i++) { const c = text[i]; if (inStr) { if (esc) esc = false; else if (c === '\\') esc = true; else if (c === '"') inStr = false; continue; } if (c === '"') inStr = true; else if (c === '{') { if (depth++ === 0) start = i; } else if (c === '}' && depth > 0 && --depth === 0) { try { out.push(JSON.parse(text.slice(start, i + 1))); } catch { /* skip this one */ } } } return out; } function handleRaw(text) { const values = jsonValues(text); if (!values.length) { note('unreadable', { bytes: String(text).length }); return; } if (values.length > 1) note('several-in-one-frame', { count: values.length }); for (const m of values) { const env = m && (m.envelope || (m.params && m.params.envelope) || (m.result && m.result.envelope)); const kinds = env ? Object.keys(env).filter(k => /Message$/.test(k)) : ['no-envelope:' + Object.keys(m || {}).join(',')]; note('envelope', { kinds, from: env && (env.sourceNumber || env.source || '').slice(-4), group: !!(env && env.dataMessage && env.dataMessage.groupInfo), text: !!(env && ((env.dataMessage && env.dataMessage.message) || (env.syncMessage && env.syncMessage.sentMessage && env.syncMessage.sentMessage.message))), att: !!(env && env.dataMessage && (env.dataMessage.attachments || []).length) }); try { ingest(env); } catch (e) { note('error', { message: e.message }); console.error('[signal] could not read a message:', e.message); } } } // ---- the link to signal-cli // signal-cli hands a message only to whoever is listening at that moment, so the hub reconnects quickly and says so when it was away. function connect() { if (!state.account || state.ws || typeof WebSocket === 'undefined') return; const url = state.api.replace(/^http/, 'ws') + '/v1/receive/' + encodeURIComponent(state.account); let ws; try { ws = new WebSocket(url); } catch (e) { return retrySoon(); } state.ws = ws; ws.onopen = () => { state.wsUp = true; state.backoff = 1000; note('connected'); if (state.downSince) { const from = state.downSince; state.downSince = 0; if (Date.now() - from > 2500) state.gap = { from, to: Date.now() }; console.log(`[signal] connected again after ${Math.round((Date.now() - from) / 1000)} s`); } else console.log('[signal] connected'); emit({ t: 'status' }); }; const text = d => typeof d === 'string' ? handleRaw(d) : d && typeof d.text === 'function' ? d.text().then(handleRaw) : d instanceof ArrayBuffer ? handleRaw(Buffer.from(d).toString('utf8')) : handleRaw(String(d)); // (a binary frame is read as text too) ws.onmessage = e => text(e.data); const gone = ev => { if (state.ws !== ws) return; state.ws = null; state.wsUp = false; if (!state.downSince) state.downSince = Date.now(); note('disconnected', { code: ev && ev.code, reason: ev && ev.reason }); console.log('[signal] connection lost' + (ev && ev.code ? ' (code ' + ev.code + ')' : '')); emit({ t: 'status' }); retrySoon(); }; ws.onclose = gone; ws.onerror = gone; } function retrySoon() { const t = setTimeout(connect, state.backoff); state.backoff = Math.min(state.backoff + 1000, 5000); t.unref && t.unref(); } // 1 s, 2 s ... never longer than 5 s async function refreshAccount() { try { const list = await apiJson('GET', '/v1/accounts'); const want = process.env.SIGNAL_NUMBER || ''; const acct = (list || []).find(n => !want || n === want) || ''; if (acct !== state.account) { state.account = acct; if (acct && state.db.account !== acct) { state.db = { account: acct, convs: {}, msgs: {} }; saveSoon(); } // a different number: start clean if (!acct && state.ws) { try { state.ws.close(); } catch { /* already closed */ } } emit({ t: 'status' }); } if (state.account) { connect(); loadContacts(false); loadGroups(false); } state.reachable = true; } catch (e) { state.reachable = false; } } function init({ dataDir, apiUrl }) { state.api = (apiUrl || '').replace(/\/$/, ''); state.dir = path.join(dataDir, 'signal'); if (!state.api || state.started) return; state.started = true; fs.mkdirSync(path.join(state.dir, 'out'), { recursive: true, mode: 0o700 }); load(); state.account = state.db.account || ''; for (const sig of ['SIGTERM', 'SIGINT']) process.once(sig, () => { flushNow(); process.exit(0); }); // do not lose the last moments of a chat when the container stops refreshAccount(); const t = setInterval(refreshAccount, +process.env.SIGNAL_POLL_MS || 10000); t.unref && t.unref(); } const enabled = () => !!state.api; // ---- what the app asks for const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); }; const readBody = (req, max = 40e6) => new Promise((ok, no) => { const c = []; let n = 0; req.on('data', d => { n += d.length; if (n > max) { no(new Error('That is too large to send')); req.destroy(); } else c.push(d); }); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); }); const safeId = s => /^[A-Za-z0-9+/=_.:-]{1,300}$/.test(s); async function send(cid, text, atts, quoteTs) { const c = state.db.convs[cid]; if (!c) throw new Error('Unknown conversation'); if (!state.account) throw new Error('Signal is not linked yet'); const recipients = c.type === 'group' ? [groupApiId(cid.slice(2))] : [cid]; const body = { message: text || '', number: state.account, recipients, base64_attachments: atts.map(a => `data:${a.type || 'application/octet-stream'};filename=${encodeURIComponent(a.name || 'file')};base64,${a.data}`) }; const q = quoteTs && findMsg(cid, quoteTs); if (q) { body.quote_timestamp = q.ts; body.quote_author = q.out ? state.account : q.from; body.quote_message = q.text || ''; } const r = await apiJson('POST', '/v2/send', body); const ts = parseInt(r && r.timestamp, 10) || Date.now(); const saved = atts.map(a => { const id = 'local-' + crypto.randomBytes(8).toString('hex'); try { fs.writeFileSync(path.join(state.dir, 'out', id), Buffer.from(a.data, 'base64'), { mode: 0o600 }); } catch { /* attachment kept only in Signal */ } return { id, type: a.type, name: a.name, size: Buffer.from(a.data, 'base64').length, local: true }; }); const m = { id: ts + ':' + state.account, ts, out: true, from: state.account, name: '', text: text || '', att: saved, status: 'sent' }; if (q) m.quote = { author: q.out ? state.account : q.from, text: q.text || '', ts: q.ts }; addMessage(cid, c, m); emit({ t: 'msg', conv: cid, out: true, name: c.name, preview: previewOf(m) }); return m; } async function handle(req, res) { const u = new URL(req.url, 'http://x'), p = u.pathname.replace(/^\/api\/signal/, '') || '/'; try { if (p === '/status') return json(res, 200, { linked: !!state.account, number: state.account, connected: state.wsUp, reachable: state.reachable !== false, unread: totalUnread(), gap: state.gap || null }); if (p === '/debug') return json(res, 200, { account: state.account ? '…' + state.account.slice(-4) : '', connected: state.wsUp, gap: state.gap || null, convs: Object.keys(state.db.convs).length, trace }); // what arrived lately: kinds only, no message text if (p === '/qr') { // a new link code each time; scanned in Signal -> Settings -> Linked devices const r = await api('GET', '/v1/qrcodelink?device_name=' + encodeURIComponent('NE Hub'), null, 60000); if (!r.ok) return json(res, 502, { error: 'Could not get a link code from the Signal service' }); res.writeHead(200, { 'content-type': r.headers.get('content-type') || 'image/png', 'cache-control': 'no-store' }); return res.end(Buffer.from(await r.arrayBuffer())); } if (p === '/events') { res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive', 'x-accel-buffering': 'no' }); res.write(': hello\n\n'); state.clients.add(res); const ka = setInterval(() => { try { res.write(': \n\n'); } catch { /* closed */ } }, 25000); res.on('close', () => { clearInterval(ka); state.clients.delete(res); }); return; } if (!state.account) return json(res, 409, { error: 'Signal is not linked yet' }); if (p === '/convs') return json(res, 200, { convs: Object.values(state.db.convs).sort((a, b) => b.ts - a.ts), unread: totalUnread() }); if (p === '/contacts') { await loadContacts(false); const all = new Map(state.contacts.map); for (const n of Object.keys(nicks())) if (!all.has(n)) all.set(n, ''); for (const c of Object.values(state.db.convs)) if (c.type === 'direct' && /^\+/.test(c.id) && !all.has(c.id)) all.set(c.id, ''); return json(res, 200, { contacts: [...all].map(([number, name]) => ({ number, name: nicks()[number] || name, mine: !!nicks()[number] })).sort((a, b) => (a.name || a.number).localeCompare(b.name || b.number)) }); } let m; if ((m = /^\/conv\/(.+)$/.exec(p)) && req.method === 'GET') { const id = decodeURIComponent(m[1]), c = state.db.convs[id]; if (!c) return json(res, 404, { error: 'Unknown conversation' }); const before = +u.searchParams.get('before') || Infinity, limit = Math.min(+u.searchParams.get('limit') || 100, 300); const all = (state.db.msgs[id] || []).filter(x => x.ts < before), msgs = all.slice(-limit); return json(res, 200, { conv: c, msgs, more: all.length > msgs.length, names: Object.fromEntries([...state.contacts.map]) }); } if (p === '/new' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 10000)), n = String(b.number || '').replace(/[\s()-]/g, ''); if (!/^\+\d{6,15}$/.test(n)) return json(res, 400, { error: 'Enter a phone number with its country code, like +15551234567' }); if (String(b.name || '').trim()) setNick(n, String(b.name)); const c = conv(n, 'direct', ''); saveSoon(); return json(res, 200, { conv: c }); } if (p === '/contact' && req.method === 'POST') { // rename a contact (an empty name goes back to the name Signal has) const b = JSON.parse(await readBody(req, 10000)), n = String(b.number || '').replace(/[\s()-]/g, ''); if (!/^\+\d{6,15}$/.test(n) && !/^[0-9a-f-]{36}$/i.test(n)) return json(res, 400, { error: 'That is not a phone number' }); const name = setNick(n, String(b.name || '')); let bridge = false; if (name && /^\+/.test(n)) { try { await apiJson('PUT', '/v1/contacts/' + encodeURIComponent(state.account), { recipient: n, name }); bridge = true; } catch { /* kept in this app anyway */ } } return json(res, 200, { ok: true, name: displayName(n, ''), bridge }); } if (p === '/conv/delete' && req.method === 'POST') { // remove a chat from this app (it stays on your phone; a new message starts it again) const b = JSON.parse(await readBody(req, 10000)); if (!state.db.convs[b.id]) return json(res, 404, { error: 'Unknown conversation' }); dropFiles(b.id); delete state.db.convs[b.id]; delete state.db.msgs[b.id]; saveSoon(); emit({ t: 'gone', conv: b.id }); return json(res, 200, { ok: true }); } if (p === '/conv/clear' && req.method === 'POST') { // empty a chat but keep it in the list const b = JSON.parse(await readBody(req, 10000)), c = state.db.convs[b.id]; if (!c) return json(res, 404, { error: 'Unknown conversation' }); dropFiles(b.id); state.db.msgs[b.id] = []; c.preview = ''; c.unread = 0; saveSoon(); emit({ t: 'update', conv: b.id }); return json(res, 200, { ok: true }); } if (p === '/read' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 10000)), c = state.db.convs[b.id]; if (!c) return json(res, 404, { error: 'Unknown conversation' }); c.unread = 0; saveSoon(); emit({ t: 'read', conv: c.id }); if (c.type === 'direct') for (const msg of (state.db.msgs[c.id] || [])) if (!msg.out && !msg.rs) { msg.rs = true; api('POST', '/v1/receipts/' + encodeURIComponent(state.account), { receipt_type: 'read', recipient: c.id, timestamp: msg.ts }).catch(() => {}); } return json(res, 200, { ok: true }); } if (p === '/send' && req.method === 'POST') { const b = JSON.parse(await readBody(req)); if (!String(b.text || '').trim() && !(b.attachments || []).length) return json(res, 400, { error: 'Write a message or add a file' }); const msg = await send(b.to, String(b.text || ''), (b.attachments || []).slice(0, 10), b.quote); return json(res, 200, { msg }); } if (p === '/react' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 10000)), c = state.db.convs[b.to], tm = c && findMsg(b.to, b.ts); if (!tm) return json(res, 404, { error: 'Unknown message' }); const author = tm.out ? state.account : tm.from, mine = state.account; await apiJson(b.emoji ? 'POST' : 'DELETE', '/v1/reactions/' + encodeURIComponent(state.account), { reaction: b.emoji || (tm.react && tm.react[mine]) || '👍', recipient: c.type === 'group' ? groupApiId(b.to.slice(2)) : b.to, target_author: author, timestamp: tm.ts }); tm.react = tm.react || {}; if (b.emoji) tm.react[mine] = b.emoji; else delete tm.react[mine]; saveSoon(); emit({ t: 'update', conv: b.to }); return json(res, 200, { ok: true }); } if ((m = /^\/attachment\/(.+)$/.exec(p))) { const id = decodeURIComponent(m[1]); if (!safeId(id) || id.includes('..')) return json(res, 400, { error: 'Bad attachment' }); let meta = null; for (const list of Object.values(state.db.msgs)) { for (const x of list) { const a = (x.att || []).find(q => q.id === id); if (a) { meta = a; break; } } if (meta) break; } if (!meta) return json(res, 404, { error: 'Unknown attachment' }); const head = { 'content-type': meta.type || 'application/octet-stream', 'cache-control': 'private, max-age=86400', 'content-disposition': (/^(image|audio|video)\//.test(meta.type || '') ? 'inline' : 'attachment') + `; filename*=UTF-8''${encodeURIComponent(meta.name || 'file')}`, 'x-content-type-options': 'nosniff' }; if (meta.local) { const f = path.join(state.dir, 'out', id); if (!fs.existsSync(f)) return json(res, 404, { error: 'Gone' }); res.writeHead(200, head); return fs.createReadStream(f).pipe(res); } const r = await api('GET', '/v1/attachments/' + encodeURIComponent(id), null, 120000); if (!r.ok) return json(res, 404, { error: 'Gone' }); res.writeHead(200, head); return Readable.fromWeb(r.body).on('error', () => res.destroy()).pipe(res); } return json(res, 404, { error: 'Not found' }); } catch (e) { if (res.headersSent) return res.destroy(); json(res, e.status && e.status < 500 ? e.status : 502, { error: e.message || 'Signal service error' }); } } module.exports = { init, handle, enabled, _state: state };