Spaces:
Paused
Paused
Download server.js from M-hv1/ghost-link: direct link, hf CLI and curl.
- Browser
- Download file 92.5 kB
-
https://huggingface.co/spaces/M-hv1/ghost-link/resolve/main/server.js
- Command line
-
hf download hf://spaces/M-hv1/ghost-link/server.js
-
curl -L -o server.js https://huggingface.co/spaces/M-hv1/ghost-link/resolve/main/server.js
92.5 kB
| /** | |
| * Titan Messenger β Real-Time Messaging Server (v3.1) | |
| * ================================================== | |
| * Production-ready WebSocket + REST server for Titan Connect encrypted chat. | |
| * v3.1: Merged Titan Cloud Relay (upload/download) from standalone Space. | |
| * Relay routes available at /relay/* β see cloudRelay.js | |
| * | |
| * Stack : Node.js 20 LTS Β· Express 4 Β· ws 8 Β· better-sqlite3 9 Β· firebase-admin 12 | |
| * node-fetch 2 (CommonJS) Β· zlib (built-in) Β· multer Β· node-cron | |
| * Port : 7860 | |
| * Storage: /data/titan_messenger.db (WAL-mode SQLite, HF Spaces persistent volume) | |
| * Archive: Private Hugging Face Dataset (HF_DATASET_REPO) β gzipped JSONL | |
| * | |
| * Security guarantees | |
| * β’ Two-layer auth on every protected request (x-titan-key + Firebase ID Token) | |
| * β’ uid ALWAYS derived from verified Firebase token β never from client payload | |
| * β’ chat_id membership validated on every read / write | |
| * β’ Parameterised SQL throughout β NO string interpolation | |
| * β’ Encrypted payloads are NEVER logged; only innocuous metadata reaches stdout | |
| * | |
| * Changelog v4 (reliability / sync pass β "no message is ever lost") | |
| * β’ ADDED: monotonic `rev` / `crev` columns. EVERY state change (insert, edit, | |
| * delete, reaction, delivered, seen) bumps a global revision counter. Clients | |
| * keep a cursor and call `sync_request {since_rev}` (WS) or GET /sync β the | |
| * server replays everything the user missed (Telegram "getDifference" style). | |
| * β’ ADDED: `recipient_uid` column (+ indexes) so sync is an index lookup. | |
| * β’ CHANGED: message_ack is sent IMMEDIATELY after the DB commit. Push (FCM) | |
| * and the Firestore ping run in the background and can no longer delay / fail | |
| * the ack (they were awaited before, so a slow FCM call stalled the sender). | |
| * β’ CHANGED: commit + live delivery is one synchronous block β frames reach a | |
| * client in strictly increasing rev order (lets the client advance its cursor | |
| * from live frames safely). | |
| * β’ CHANGED: `delivered` is only set on a real client confirmation | |
| * (`message_received`) or when the message is seen. The old 8 s timer marked | |
| * messages delivered even if the device never got them. | |
| * β’ FIXED (security): react_message / mark_seen (WS + REST) had no membership | |
| * check β any logged-in user could react to / mark-seen any message id. | |
| * β’ FIXED: message_updated frames never carried the new payload, so edits did | |
| * not reach the other side. They do now. | |
| * β’ FIXED: per-socket frame ordering (frames of one socket are processed | |
| * sequentially), group delivery (recipientUidFrom returned garbage for | |
| * groups), stale /history cache after new messages, unbounded WS payload, | |
| * no send rate-limit, retry re-pushing FCM on every resend. | |
| * β’ ADDED: error frames carry `code` (invalid | forbidden | rate_limited | | |
| * internal) so the client knows what is retryable. | |
| * β’ ADDED: archive_floor_rev (tells a long-offline client to refetch history), | |
| * graceful shutdown closes sockets with 1001 and checkpoints the WAL. | |
| * | |
| * Changelog v3 | |
| * β’ ADDED: message_delivered receipt β sets delivered_at in DB and sends | |
| * { type: "message_delivered", data: { id, delivered_at } } to the sender | |
| * immediately after a successful live WebSocket delivery to the recipient. | |
| * β’ FIXED: retryPendingArchives β pending_archive now stores both the local tmp | |
| * file path and the dataset path so the gzip buffer can actually be re-uploaded. | |
| * β’ FIXED: ALL SQLite statements are pre-compiled in `stmts` β no inline | |
| * db.prepare() calls in hot paths (HTTP handlers, WS message loop). | |
| * β’ FIXED: archive path-date parsing uses correct segment indices after split("/"). | |
| * β’ FIXED: message_id inputs from REST body are explicitly coerced to integers. | |
| * β’ ADDED: guard against editing/deleting already-deleted messages (409 response). | |
| * β’ ADDED: temp_id type-guard (must be string, else null). | |
| * β’ FIXED: mark_seen runs inside a SQLite transaction for atomicity. | |
| * β’ FIXED: chat_id split uses indexOf("_") β safe against UIDs that contain "_". | |
| */ | |
| ; | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // 0. Imports | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const http = require("http"); | |
| const fs = require("fs"); | |
| const path = require("path"); | |
| const zlib = require("zlib"); | |
| const os = require("os"); | |
| const { promisify } = require("util"); | |
| const express = require("express"); | |
| const { WebSocketServer, WebSocket } = require("ws"); | |
| const Database = require("better-sqlite3"); | |
| const admin = require("firebase-admin"); | |
| const fetch = require("node-fetch"); | |
| const gzip = promisify(zlib.gzip); | |
| const gunzip = promisify(zlib.gunzip); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // 1. Environment variables & validation | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const PORT = parseInt(process.env.PORT || "7860", 10); | |
| const TITAN_API_KEY = process.env.TITAN_API_KEY; | |
| const FB_SA_JSON = process.env.FIREBASE_SERVICE_ACCOUNT; | |
| const HF_TOKEN = process.env.HF_TOKEN || ""; | |
| const HF_DATASET_REPO = process.env.HF_DATASET_REPO || ""; | |
| const ARCHIVE_DAYS = parseInt(process.env.ARCHIVE_DAYS || "4", 10); | |
| const ARCHIVE_INTERVAL_HRS = parseInt(process.env.ARCHIVE_INTERVAL_HOURS || "6", 10); | |
| const ARCHIVE_DAILY_HOUR = parseInt(process.env.ARCHIVE_DAILY_HOUR || "3", 10); | |
| const EMERGENCY_SIZE_MB = parseInt(process.env.EMERGENCY_SIZE_MB || "800", 10); | |
| if (!TITAN_API_KEY) { | |
| console.error("[FATAL] TITAN_API_KEY env var is not set."); | |
| process.exit(1); | |
| } | |
| if (!FB_SA_JSON) { | |
| console.error("[FATAL] FIREBASE_SERVICE_ACCOUNT env var is not set."); | |
| process.exit(1); | |
| } | |
| if (!HF_TOKEN || !HF_DATASET_REPO) { | |
| console.warn("[WARN] HF_TOKEN or HF_DATASET_REPO not set β cold-storage archiving will be disabled."); | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // 2. Firebase Admin initialisation | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let serviceAccount; | |
| try { | |
| serviceAccount = JSON.parse(FB_SA_JSON); | |
| } catch (err) { | |
| console.error("[FATAL] FIREBASE_SERVICE_ACCOUNT is not valid JSON:", err.message); | |
| process.exit(1); | |
| } | |
| admin.initializeApp({ credential: admin.credential.cert(serviceAccount) }); | |
| console.log("[INFO] Firebase Admin initialised."); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // 3. SQLite database initialisation | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const DATA_DIR = process.env.DATA_DIR || "/data"; | |
| const DB_PATH = path.join(DATA_DIR, "titan_messenger.db"); | |
| if (!fs.existsSync(DATA_DIR)) { | |
| fs.mkdirSync(DATA_DIR, { recursive: true }); | |
| } | |
| let db; | |
| try { | |
| db = new Database(DB_PATH); | |
| } catch (err) { | |
| console.error("[FATAL] Cannot open SQLite database at", DB_PATH, "β", err.message); | |
| process.exit(1); | |
| } | |
| // Performance + safety pragmas | |
| db.pragma("journal_mode = WAL"); | |
| db.pragma("busy_timeout = 5000"); | |
| db.pragma("synchronous = NORMAL"); // safe with WAL, far fewer fsyncs per message | |
| db.pragma("foreign_keys = ON"); | |
| // ββ Full schema βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| db.exec(` | |
| CREATE TABLE IF NOT EXISTS messages ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| chat_id TEXT NOT NULL, | |
| sender_uid TEXT NOT NULL, | |
| encrypted_payload TEXT NOT NULL, | |
| type TEXT NOT NULL DEFAULT 'text', | |
| edited INTEGER NOT NULL DEFAULT 0, | |
| edited_at REAL, | |
| delivered_at REAL, | |
| seen_at REAL, | |
| created_at REAL NOT NULL DEFAULT (unixepoch()) | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_messages_chat_created | |
| ON messages(chat_id, created_at); | |
| CREATE TABLE IF NOT EXISTS fcm_tokens ( | |
| user_id TEXT PRIMARY KEY, | |
| token TEXT NOT NULL, | |
| updated_at REAL NOT NULL DEFAULT (unixepoch()) | |
| ); | |
| CREATE TABLE IF NOT EXISTS meta ( | |
| key TEXT PRIMARY KEY, | |
| value TEXT | |
| ); | |
| -- Stores both the local tmp file path (for re-uploading on retry) | |
| -- and the dataset destination path. | |
| CREATE TABLE IF NOT EXISTS pending_archive ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| tmp_file_path TEXT NOT NULL, | |
| dataset_file_path TEXT NOT NULL UNIQUE, | |
| created_at REAL NOT NULL DEFAULT (unixepoch()) | |
| ); | |
| `); | |
| // Idempotent migrations | |
| const _migs = [ | |
| `ALTER TABLE messages ADD COLUMN reactions TEXT`, | |
| `ALTER TABLE messages ADD COLUMN reply_to TEXT`, | |
| `ALTER TABLE messages ADD COLUMN status TEXT NOT NULL DEFAULT 'sent'`, | |
| `CREATE TABLE IF NOT EXISTS group_members ( | |
| group_id TEXT NOT NULL, user_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'member', | |
| joined_at REAL NOT NULL DEFAULT (unixepoch()), PRIMARY KEY (group_id, user_id))`, | |
| `CREATE INDEX IF NOT EXISTS idx_gm_group ON group_members(group_id)`, | |
| // v3.2 β reliability pass ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // client_msg_id: echoes the client's temp_id. Lets processMessage() treat a | |
| // retried send_message as a replay instead of inserting a duplicate row. | |
| `ALTER TABLE messages ADD COLUMN client_msg_id TEXT`, | |
| // id is already the natural monotonic order (AUTOINCREMENT, no ties, unlike | |
| // created_at which only has 1-second resolution). This index makes | |
| // "WHERE chat_id = ? AND id < ? ORDER BY id DESC" (used for pagination) fast. | |
| `CREATE INDEX IF NOT EXISTS idx_messages_chat_id ON messages(chat_id, id)`, | |
| // v4 β sync pass βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // rev : bumped on EVERY change (what the SENDER cares about: receipts too) | |
| // crev : bumped only on content-level changes (insert/edit/delete/reaction) | |
| // β what the RECIPIENT cares about (they don't need a re-send because | |
| // somebody flipped a delivered/seen flag). | |
| `ALTER TABLE messages ADD COLUMN recipient_uid TEXT`, | |
| `ALTER TABLE messages ADD COLUMN rev INTEGER NOT NULL DEFAULT 0`, | |
| `ALTER TABLE messages ADD COLUMN crev INTEGER NOT NULL DEFAULT 0`, | |
| `CREATE INDEX IF NOT EXISTS idx_messages_sender_rev ON messages(sender_uid, rev)`, | |
| `CREATE INDEX IF NOT EXISTS idx_messages_rcpt_crev ON messages(recipient_uid, crev)`, | |
| ]; | |
| for (const sql of _migs) { try { db.exec(sql); } catch {} } | |
| // Partial unique index β must run after the column exists, and can't use | |
| // "IF NOT EXISTS" safely inside the generic try/catch loop above on every | |
| // startup without risking a silent skip if the column migration hasn't | |
| // landed yet on some older deployments, so it's kept as its own guarded step. | |
| try { | |
| db.exec( | |
| `CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_dedup | |
| ON messages(chat_id, sender_uid, client_msg_id) | |
| WHERE client_msg_id IS NOT NULL` | |
| ); | |
| } catch (err) { | |
| console.error("[ERROR] Could not create idx_messages_dedup:", err.message); | |
| } | |
| console.log("[INFO] SQLite ready β WAL mode enabled."); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // 4. Pre-compiled SQL statements | |
| // ALL statements are compiled here at startup. No db.prepare() calls in | |
| // hot paths (request handlers, WS message loop). | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const MSG_COLS = | |
| "id, chat_id, sender_uid, encrypted_payload, type, edited, edited_at, delivered_at, seen_at, " + | |
| "created_at, reactions, reply_to, client_msg_id, recipient_uid, rev, crev"; | |
| const stmts = { | |
| // ββ FCM tokens ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| upsertFcmToken : db.prepare( | |
| "INSERT OR REPLACE INTO fcm_tokens (user_id, token, updated_at) VALUES (?, ?, unixepoch())" | |
| ), | |
| lookupFcmToken : db.prepare( | |
| "SELECT token FROM fcm_tokens WHERE user_id = ?" | |
| ), | |
| deleteFcmToken : db.prepare( | |
| "DELETE FROM fcm_tokens WHERE user_id = ?" | |
| ), | |
| // ββ Messages β insert & full read-back βββββββββββββββββββββββββββββββββββββ | |
| insertMessage : db.prepare( | |
| `INSERT INTO messages (chat_id, sender_uid, encrypted_payload, type, reply_to, client_msg_id, recipient_uid, rev, crev) | |
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` | |
| ), | |
| selectMessageFull : db.prepare(`SELECT ${MSG_COLS} FROM messages WHERE id = ?`), | |
| // Lightweight ownership / participant check β does NOT return encrypted_payload | |
| selectMessageMeta : db.prepare( | |
| "SELECT id, chat_id, sender_uid, type, recipient_uid, seen_at, delivered_at FROM messages WHERE id = ?" | |
| ), | |
| // Idempotency lookup β a retried send_message (same temp_id) finds the row | |
| // that was already created instead of inserting a duplicate. | |
| selectByClientMsgId : db.prepare( | |
| `SELECT ${MSG_COLS} FROM messages WHERE chat_id = ? AND sender_uid = ? AND client_msg_id = ?` | |
| ), | |
| // ββ History βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| selectHistory : db.prepare( | |
| `SELECT ${MSG_COLS} FROM messages | |
| WHERE chat_id = ? AND created_at < ? | |
| ORDER BY created_at DESC, id DESC | |
| LIMIT ?` | |
| ), | |
| selectHistoryBeforeId : db.prepare( | |
| `SELECT ${MSG_COLS} FROM messages | |
| WHERE chat_id = ? AND id < ? | |
| ORDER BY id DESC | |
| LIMIT ?` | |
| ), | |
| // ββ Receipts (every flip bumps rev so the SENDER's sync sees it) βββββββββββ | |
| setDelivered : db.prepare( | |
| "UPDATE messages SET delivered_at = unixepoch(), rev = ? WHERE id = ? AND delivered_at IS NULL" | |
| ), | |
| markOneSeen : db.prepare( | |
| `UPDATE messages SET seen_at = unixepoch(), rev = ?, | |
| delivered_at = COALESCE(delivered_at, unixepoch()) | |
| WHERE id = ? AND seen_at IS NULL` | |
| ), | |
| selectUnseenInChat : db.prepare( | |
| "SELECT id FROM messages WHERE chat_id = ? AND sender_uid != ? AND seen_at IS NULL AND type != 'deleted'" | |
| ), | |
| // ββ Edit / soft delete / reaction (content-level β bump rev AND crev) ββββββ | |
| editMessage : db.prepare( | |
| `UPDATE messages SET encrypted_payload = ?, edited = 1, edited_at = unixepoch(), rev = ?, crev = ? | |
| WHERE id = ? AND sender_uid = ? AND type != 'deleted'` | |
| ), | |
| selectEditedAt : db.prepare( | |
| "SELECT id, chat_id, edited, edited_at, rev FROM messages WHERE id = ?" | |
| ), | |
| softDeleteMessage : db.prepare( | |
| `UPDATE messages SET type = 'deleted', encrypted_payload = '[deleted]', rev = ?, crev = ? | |
| WHERE id = ? AND sender_uid = ? AND type != 'deleted'` | |
| ), | |
| updateReaction : db.prepare( | |
| "UPDATE messages SET reactions = ?, rev = ?, crev = ? WHERE id = ? AND type != 'deleted'" | |
| ), | |
| // ββ Sync (the heart of v4) βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // sender sees a row when `rev` moves (incl. receipts); recipient when `crev` | |
| // moves; group members (recipient_uid IS NULL) via group_members. | |
| selectSync : db.prepare( | |
| `SELECT ${MSG_COLS}, | |
| CASE WHEN sender_uid = @uid THEN rev ELSE crev END AS sync_rev | |
| FROM messages | |
| WHERE (sender_uid = @uid AND rev > @since) | |
| OR (recipient_uid = @uid AND crev > @since) | |
| OR (recipient_uid IS NULL AND sender_uid != @uid AND crev > @since | |
| AND chat_id IN (SELECT group_id FROM group_members WHERE user_id = @uid)) | |
| ORDER BY sync_rev ASC | |
| LIMIT @lim` | |
| ), | |
| // First-ever sync (cursor 0): don't replay history, just the unread backlog. | |
| selectBaselineUnseen : db.prepare( | |
| `SELECT ${MSG_COLS}, crev AS sync_rev FROM messages | |
| WHERE recipient_uid = ? AND seen_at IS NULL AND type != 'deleted' | |
| ORDER BY crev ASC LIMIT ?` | |
| ), | |
| // ββ Archive β read βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| selectOldMessages : db.prepare( | |
| `SELECT ${MSG_COLS} FROM messages WHERE created_at < ? ORDER BY created_at ASC` | |
| ), | |
| selectOldMessagesLimit : db.prepare( | |
| `SELECT ${MSG_COLS} FROM messages WHERE created_at < ? ORDER BY created_at ASC LIMIT ?` | |
| ), | |
| // For emergency archive: find the timestamp of the Nth oldest message (0-indexed offset) | |
| selectNthOldestTs : db.prepare( | |
| "SELECT created_at FROM messages ORDER BY created_at ASC LIMIT 1 OFFSET ?" | |
| ), | |
| // ββ Archive β delete βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| deleteMessageById : db.prepare( | |
| "DELETE FROM messages WHERE id = ?" | |
| ), | |
| // ββ Meta ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| getMeta : db.prepare("SELECT value FROM meta WHERE key = ?"), | |
| setMeta : db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)"), | |
| // ββ Pending archive βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| insertPending : db.prepare( | |
| "INSERT OR IGNORE INTO pending_archive (tmp_file_path, dataset_file_path) VALUES (?, ?)" | |
| ), | |
| allPending : db.prepare( | |
| "SELECT id, tmp_file_path, dataset_file_path FROM pending_archive ORDER BY created_at ASC" | |
| ), | |
| deletePending : db.prepare( | |
| "DELETE FROM pending_archive WHERE id = ?" | |
| ), | |
| // ββ Group members βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| isGroupMember : db.prepare( | |
| "SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? LIMIT 1" | |
| ), | |
| insertGroupMember : db.prepare( | |
| "INSERT OR IGNORE INTO group_members (group_id, user_id, role) VALUES (?, ?, ?)" | |
| ), | |
| getGroupMembers : db.prepare( | |
| "SELECT user_id FROM group_members WHERE group_id = ?" | |
| ), | |
| }; | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // 4b. Revision counter + one-time backfill | |
| // `rev` is a single global, strictly increasing integer. It is persisted in | |
| // the meta table so it can never go backwards across restarts (even after | |
| // old rows were archived away). | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let _rev = 0; | |
| (function initRevCounter() { | |
| const a = db.prepare("SELECT COALESCE(MAX(rev), 0) AS m FROM messages").get().m; | |
| const metaRow = stmts.getMeta.get("rev_counter"); | |
| const b = parseInt((metaRow && metaRow.value) || "0", 10) || 0; | |
| _rev = Math.max(a, b); | |
| })(); | |
| function nextRev() { | |
| _rev += 1; | |
| stmts.setMeta.run("rev_counter", String(_rev)); | |
| return _rev; | |
| } | |
| // Rows written by v3 have rev = 0: give them a rev (in id order) and a recipient. | |
| (function backfillRevs() { | |
| const rows = db.prepare("SELECT id, chat_id, sender_uid FROM messages WHERE rev = 0 ORDER BY id ASC").all(); | |
| if (!rows.length) return; | |
| const upd = db.prepare("UPDATE messages SET recipient_uid = ?, rev = ?, crev = ? WHERE id = ?"); | |
| db.transaction(() => { | |
| for (const r of rows) { | |
| const sep = r.chat_id.indexOf("_"); | |
| let rcpt = null; | |
| if (sep > 0 && sep < r.chat_id.length - 1) { | |
| const a = r.chat_id.slice(0, sep), b = r.chat_id.slice(sep + 1); | |
| rcpt = a === r.sender_uid ? b : a; | |
| } | |
| const rv = nextRev(); | |
| upd.run(rcpt, rv, rv, r.id); | |
| } | |
| })(); | |
| console.log(`[INFO] Backfilled rev/recipient for ${rows.length} legacy message(s).`); | |
| })(); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // 5. Utility helpers | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /** Returns the first 8 chars of a string for safe debug logging. Never logs full IDs. */ | |
| function redact(s) { | |
| return typeof s === "string" ? s.slice(0, 8) + "β¦" : "<nil>"; | |
| } | |
| /** | |
| * Validate that `uid` is one of the two participants encoded in `chatId`. | |
| * chatId format: "<uidA>_<uidB>" where uidA < uidB lexicographically. | |
| * Uses indexOf to find the first underscore, supporting UIDs that contain "_". | |
| */ | |
| async function isChatMemberAsync(chatId, uid) { | |
| if (!chatId || !uid) return false; | |
| try { if (stmts.isGroupMember.get(chatId, uid)) return true; } catch {} | |
| const sep = chatId.indexOf("_"); | |
| if (sep > 0 && sep < chatId.length - 1) { | |
| // FIX: split on the FIRST underscore only (same as recipientUidFrom). | |
| // The previous `chatId.split("_")` broke for any UID that itself | |
| // contains an underscore: split() cuts on EVERY "_", so a 3+-segment | |
| // result meant `[a, b]` never equalled either real UID and BOTH | |
| // legitimate participants were wrongly denied membership (silent | |
| // "Forbidden" on send_message / history for those accounts). | |
| const a = chatId.slice(0, sep); | |
| const b = chatId.slice(sep + 1); | |
| return a === uid || b === uid; | |
| } | |
| // Fallback to Firestore for Legacy Groups | |
| try { | |
| const doc = await admin.firestore().collection('titan_groups').doc(chatId).get(); | |
| if (doc.exists) { | |
| const data = doc.data(); | |
| if (data.members && data.members.includes(uid)) { | |
| // Cache EVERY member (not just the caller) so group fan-out knows | |
| // all recipients without another Firestore round-trip. | |
| for (const m of data.members) { try { stmts.insertGroupMember.run(chatId, String(m), 'member'); } catch {} } | |
| return true; | |
| } | |
| } | |
| } catch (e) { console.error("[ERROR] Firestore Group Sync:", e.message); } | |
| return false; | |
| } | |
| /** Return the other participant in a two-person chat. */ | |
| function recipientUidFrom(chatId, uid) { | |
| const sep = chatId.indexOf("_"); | |
| const a = chatId.slice(0, sep); | |
| const b = chatId.slice(sep + 1); | |
| return a === uid ? b : a; | |
| } | |
| /** Current Unix timestamp (whole seconds). */ | |
| function nowSec() { | |
| return Math.floor(Date.now() / 1000); | |
| } | |
| /** Safely coerce `v` to a positive integer, or return null. */ | |
| function toInt(v) { | |
| const n = parseInt(v, 10); | |
| return Number.isFinite(n) && n > 0 ? n : null; | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // 6. Authentication helpers | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function checkTitanKey(headerValue) { | |
| return typeof headerValue === "string" && headerValue === TITAN_API_KEY; | |
| } | |
| async function verifyFirebaseToken(bearerHeader) { | |
| if (!bearerHeader || !bearerHeader.startsWith("Bearer ")) { | |
| throw new Error("Missing or malformed Authorization header."); | |
| } | |
| const idToken = bearerHeader.slice(7).trim(); | |
| return admin.auth().verifyIdToken(idToken); | |
| } | |
| /** | |
| * Express middleware: enforces both auth layers. | |
| * Attaches req.uid (from verified Firebase token ONLY) on success. | |
| */ | |
| async function authMiddleware(req, res, next) { | |
| if (!checkTitanKey(req.headers["x-titan-key"])) { | |
| return res.status(401).json({ error: "Unauthorized" }); | |
| } | |
| try { | |
| const decoded = await verifyFirebaseToken(req.headers["authorization"]); | |
| req.uid = decoded.uid; // uid NEVER from request body | |
| next(); | |
| } catch { | |
| return res.status(401).json({ error: "Unauthorized" }); | |
| } | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // 7. FCM push notifications | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const FCM_BODY_MAP = { | |
| text : "Ψ±Ψ³Ψ§ΩΨ© Ψ¬Ψ―ΩΨ―Ψ© π©", | |
| image : "π· Ψ΅ΩΨ±Ψ©", | |
| video : "π₯ ΩΩΨ―ΩΩ", | |
| voice : "π€ Ψ±Ψ³Ψ§ΩΨ© Ψ΅ΩΨͺΩΨ©", | |
| file : "π Ω ΩΩ", | |
| audio : "π΅ Ψ΅ΩΨͺ", | |
| sticker : "π¨ Ψ³ΨͺΩΩΨ±", | |
| "cloud-drop" : "βοΈ Ω ΩΩ Ω Ψ΄ΨͺΨ±Ω", | |
| }; | |
| /** | |
| * Sends an FCM push notification to `recipientUid`. | |
| * Silently skips if no token is registered. | |
| * Removes stale tokens automatically on registration-not-found errors. | |
| */ | |
| async function sendPushNotification(recipientUid, senderName, type, extraData = {}) { | |
| const row = stmts.lookupFcmToken.get(recipientUid); | |
| if (!row) return; | |
| const body = FCM_BODY_MAP[type] || "Ψ±Ψ³Ψ§ΩΨ© Ψ¬Ψ―ΩΨ―Ψ© π©"; | |
| // For text messages, show the message type as body and sender as title | |
| // (content stays encrypted β never include encrypted_payload in FCM) | |
| try { | |
| await admin.messaging().send({ | |
| token : row.token, | |
| notification : { title: senderName || "Titan", body }, | |
| // data payload: available to service worker for badge/list update | |
| // ALL values must be strings for FCM data payloads | |
| data: { | |
| type : type || "text", | |
| senderUid : String(extraData.senderUid || ""), | |
| chatId : String(extraData.chatId || ""), | |
| messageId : String(extraData.messageId || ""), | |
| senderName : String(senderName || ""), | |
| senderIcon : String(extraData.senderIcon || ""), // β avatar URL for SW grouped notif | |
| }, | |
| // Android: high priority so notification wakes device | |
| android: { priority: "high" }, | |
| // APNS (iOS): alert + badge increment | |
| apns: { payload: { aps: { badge: 1, sound: "default" } } }, | |
| // Web push: set icon + badge | |
| webpush: { | |
| notification: { icon: "/icons/icon-192x192.png", badge: "/icons/badge-72x72.png" }, | |
| headers : { Urgency: "high" } | |
| }, | |
| }); | |
| console.log("[INFO] FCM push sent to", redact(recipientUid)); | |
| } catch (err) { | |
| if (err.code === "messaging/registration-token-not-found" || | |
| err.code === "messaging/registration-token-not-registered") { | |
| stmts.deleteFcmToken.run(recipientUid); | |
| console.log("[INFO] Stale FCM token removed for", redact(recipientUid)); | |
| // Tell the recipient's device (if online) to re-register a fresh token | |
| wsDeliver(recipientUid, { type: "refresh_fcm_token" }); | |
| } else { | |
| console.error("[ERROR] FCM send failed:", err.code || err.message); | |
| } | |
| } | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // 8. In-memory WebSocket connection map | |
| // uid β Set<WebSocket> (a user may have multiple device connections) | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /** @type {Map<string, Set<WebSocket>>} */ | |
| const wsConnections = new Map(); | |
| /** Cache of uid β displayName to avoid repeated admin.auth().getUser() calls. */ | |
| const wsNames = new Map(); | |
| /** Cache of uid β photoURL (avatar) for FCM notification icons. */ | |
| const wsIcons = new Map(); | |
| // Slow-consumer guard: a socket whose send buffer exceeds this is dead weight. | |
| // Terminating it is safe β the client reconnects and sync_request replays anything it missed. | |
| const WS_MAX_BUFFERED = 4 * 1024 * 1024; | |
| function wsRegister(uid, ws) { | |
| if (!wsConnections.has(uid)) wsConnections.set(uid, new Set()); | |
| wsConnections.get(uid).add(ws); | |
| } | |
| function wsUnregister(uid, ws) { | |
| const set = wsConnections.get(uid); | |
| if (!set) return; | |
| set.delete(ws); | |
| if (set.size === 0) wsConnections.delete(uid); | |
| } | |
| /** | |
| * Deliver a JSON frame to all open sockets of `uid`. | |
| * @returns {boolean} true if at least one socket accepted the frame. | |
| */ | |
| function wsDeliver(uid, frame) { | |
| const set = wsConnections.get(uid); | |
| if (!set || set.size === 0) return false; | |
| const payload = JSON.stringify(frame); | |
| let delivered = false; | |
| for (const ws of set) { | |
| if (ws.readyState !== WebSocket.OPEN) continue; | |
| if (ws.bufferedAmount > WS_MAX_BUFFERED) { | |
| console.warn("[WARN] Slow WS consumer β terminating", redact(uid)); | |
| try { ws.terminate(); } catch {} | |
| continue; | |
| } | |
| try { ws.send(payload); delivered = true; } catch {} | |
| } | |
| return delivered; | |
| } | |
| /** Deliver one frame to several users (deduplicated). */ | |
| function wsDeliverMany(uids, frame) { | |
| let any = false; | |
| for (const u of new Set(uids)) { if (u && wsDeliver(u, frame)) any = true; } | |
| return any; | |
| } | |
| /** Safely serialise and send an object to a single WebSocket. */ | |
| function wsSend(ws, obj) { | |
| if (ws && ws.readyState === WebSocket.OPEN) { | |
| ws.send(JSON.stringify(obj)); | |
| } | |
| } | |
| /** Broadcast a frame to every currently connected client (used for presence). */ | |
| function wsBroadcast(frame) { | |
| const payload = JSON.stringify(frame); | |
| for (const set of wsConnections.values()) { | |
| for (const ws of set) { | |
| if (ws.readyState === WebSocket.OPEN) ws.send(payload); | |
| } | |
| } | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // 9. Core message processing & state mutations (shared by HTTP and WS paths) | |
| // | |
| // INVARIANT: every mutation below is [bump rev + write row + live-deliver] | |
| // in ONE synchronous block (no `await` in between). Therefore frames reach a | |
| // given client in strictly increasing `rev` order, which is what lets the | |
| // client treat "rev of the last frame I processed" as a safe sync cursor. | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const MAX_PAYLOAD_CHARS = 64_000; | |
| const SYNC_MAX_ROWS = 300; | |
| const SYNC_MAX_BYTES = 1_500_000; | |
| // ββ /history response cache (burst-reconnect protection) βββββββββββββββββββββ | |
| // Key: `${uid}:${chat_id}` β TTL 5 s, non-paginated requests only. v4 invalidates | |
| // it on every mutation so a fresh message can never be hidden for 5 s. | |
| const _historyCache = new Map(); | |
| const _HIST_TTL = 5_000; | |
| setInterval(() => { | |
| const now = Date.now(); | |
| for (const [k, v] of _historyCache) if (now - v.ts > _HIST_TTL) _historyCache.delete(k); | |
| }, 60_000).unref(); | |
| function invalidateHistoryCache(chatId, uids) { | |
| for (const u of uids) _historyCache.delete(`${u}:${chatId}`); | |
| } | |
| // ββ Chat resolution ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /** Who else (besides the sender) should receive a message in `chatId`. */ | |
| function resolveChat(chatId, senderUid) { | |
| let members = []; | |
| try { members = stmts.getGroupMembers.all(chatId).map(r => r.user_id); } catch {} | |
| if (members.length) return { recipients: members.filter(u => u !== senderUid), direct: false }; | |
| const sep = chatId.indexOf("_"); | |
| if (sep > 0 && sep < chatId.length - 1) { | |
| const a = chatId.slice(0, sep), b = chatId.slice(sep + 1); | |
| return { recipients: [a === senderUid ? b : a], direct: true }; | |
| } | |
| return { recipients: [], direct: false }; | |
| } | |
| /** Everyone who should hear about a change to message `meta` (sender included). */ | |
| function audienceOf(meta) { | |
| return [meta.sender_uid, ...resolveChat(meta.chat_id, meta.sender_uid).recipients]; | |
| } | |
| /** Sync (no Firestore) participant test for an existing message row. */ | |
| function isParticipant(meta, uid) { | |
| if (meta.sender_uid === uid) return true; | |
| if (meta.recipient_uid && meta.recipient_uid === uid) return true; | |
| try { return !!stmts.isGroupMember.get(meta.chat_id, uid); } catch { return false; } | |
| } | |
| /** Strip server-internal columns before a row leaves the server. */ | |
| function publicRow(r) { | |
| if (!r) return r; | |
| const { recipient_uid, crev, ...rest } = r; // eslint-disable-line no-unused-vars | |
| return rest; | |
| } | |
| // ββ Input validation βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function validateSend(chatId, payload, type, tempId) { | |
| if (!chatId || typeof chatId !== "string" || chatId.length > 200) return "chat_id is required (string, <= 200 chars)."; | |
| if (!payload || typeof payload !== "string") return "encrypted_payload (non-empty string) is required."; | |
| if (payload.length > MAX_PAYLOAD_CHARS) return "encrypted_payload too large."; | |
| if (type !== undefined && type !== null && (typeof type !== "string" || type.length > 32)) return "type must be a short string."; | |
| if (tempId && (typeof tempId !== "string" || tempId.length > 100)) return "temp_id must be a string <= 100 chars."; | |
| return null; | |
| } | |
| // ββ Per-user send rate limit (flood / runaway-retry protection) ββββββββββββββ | |
| const _sendBuckets = new Map(); | |
| function allowSend(uid) { | |
| const now = Date.now(); | |
| let b = _sendBuckets.get(uid); | |
| if (!b || now - b.t > 5000) { b = { t: now, n: 0 }; _sendBuckets.set(uid, b); } | |
| b.n += 1; | |
| return b.n <= 40; // 40 messages / 5 s per user | |
| } | |
| setInterval(() => { | |
| const now = Date.now(); | |
| for (const [k, v] of _sendBuckets) if (now - v.t > 60_000) _sendBuckets.delete(k); | |
| }, 60_000).unref(); | |
| const fail = (status, code, message) => ({ ok: false, status, code, message }); | |
| const OK = { ok: true, status: 200 }; | |
| // ββ Background notifications (never block the ack) βββββββββββββββββββββββββββ | |
| async function notifyRecipient(recipient, senderName, type, extra, senderUid) { | |
| // FCM β sent ALWAYS (even when a socket is open): the socket can belong to an | |
| // app that is backgrounded. The service worker de-duplicates if the chat is visible. | |
| try { await sendPushNotification(recipient, senderName, type || "text", extra); } | |
| catch (e) { console.error("[ERROR] push:", e.message); } | |
| // Firestore "wake" ping (recipient's UI refetches via /sync or /history). | |
| try { | |
| await admin.firestore().collection("titan_sync").doc(recipient).set({ | |
| lastEvent : "new_msg", | |
| from : senderUid, | |
| ts : Date.now(), | |
| _trigger : Math.random(), | |
| }, { merge: true }); | |
| } catch (syncErr) { console.error("[ERROR] Firebase Sync Ping Failed:", syncErr.message); } | |
| } | |
| // ββ Insert (atomic: rev bump + row) ββββββββββββββββββββββββββββββββββββββββββ | |
| const commitInsert = db.transaction((chatId, senderUid, payload, type, replyToJSON, clientMsgId, recipientUid) => { | |
| const rev = nextRev(); | |
| const r = stmts.insertMessage.run(chatId, senderUid, payload, type, replyToJSON, clientMsgId, recipientUid, rev, rev); | |
| return stmts.selectMessageFull.get(r.lastInsertRowid); | |
| }); | |
| /** | |
| * Store a message (idempotent on temp_id), ACK the sender immediately, deliver | |
| * live, and schedule push notifications in the background. | |
| * Synchronous on purpose β see the INVARIANT above. Returns { stored, ack }. | |
| */ | |
| function processMessage({ chatId, senderUid, senderName, senderIcon = "", encryptedPayload, type, tempId, replyTo, senderWs }) { | |
| const clientMsgId = (typeof tempId === "string" && tempId) ? tempId : null; | |
| const { recipients, direct } = resolveChat(chatId, senderUid); | |
| let stored = clientMsgId ? stmts.selectByClientMsgId.get(chatId, senderUid, clientMsgId) : undefined; | |
| const isReplay = !!stored; | |
| let created = false; | |
| if (!stored) { | |
| const replyToJSON = replyTo ? JSON.stringify(replyTo) : null; | |
| try { | |
| stored = commitInsert(chatId, senderUid, encryptedPayload, type || "text", replyToJSON, | |
| clientMsgId, direct ? recipients[0] : null); | |
| created = true; | |
| } catch (err) { | |
| // Lost a race with a concurrent identical send β reuse the winner's row. | |
| if (clientMsgId && /UNIQUE/i.test(err.message || "")) { | |
| stored = stmts.selectByClientMsgId.get(chatId, senderUid, clientMsgId); | |
| } | |
| if (!stored) throw err; | |
| } | |
| invalidateHistoryCache(chatId, [senderUid, ...recipients]); | |
| console.log(`[INFO] Message stored (chat=${redact(chatId)}, len=${encryptedPayload.length}, rev=${stored.rev})`); | |
| } | |
| // ββ 1. ACK the sender FIRST β the message is durable, nothing below may delay it | |
| const ack = { | |
| type : "message_ack", | |
| temp_id : tempId || null, | |
| message_id : stored.id, | |
| server_ts : stored.created_at, | |
| rev : stored.rev, | |
| }; | |
| if (senderWs) wsSend(senderWs, ack); // HTTP path merges `ack` into its JSON response | |
| // A replay of a message the recipient already confirmed: nothing more to do. | |
| if (isReplay && stored.delivered_at) return { stored, ack }; | |
| // ββ 2. Live delivery (sync, so frame order == rev order) | |
| const frame = { type: "new_message", data: publicRow(stored) }; | |
| for (const r of recipients) wsDeliver(r, frame); | |
| // Delivered receipt is NOT set here β only when the device confirms with | |
| // `message_received` (see doDelivered) or when the message is seen. | |
| // ββ 3. Background push + Firestore ping (first send only β retries can't spam) | |
| if (created) { | |
| for (const r of recipients) { | |
| notifyRecipient(r, senderName, type || "text", { | |
| senderUid, chatId, messageId: stored.id, senderIcon: senderIcon || "", | |
| }, senderUid).catch(() => {}); | |
| } | |
| } | |
| return { stored, ack }; | |
| } | |
| // ββ Receipts βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /** Recipient confirms the device actually processed these message ids. */ | |
| function doDelivered(uid, rawIds) { | |
| const out = []; | |
| db.transaction((list) => { | |
| for (const raw of list) { | |
| const id = toInt(raw); if (!id) continue; | |
| const m = stmts.selectMessageMeta.get(id); | |
| if (!m || m.sender_uid === uid || m.delivered_at || m.type === "deleted") continue; | |
| if (!isParticipant(m, uid)) continue; | |
| const rev = nextRev(); | |
| if (stmts.setDelivered.run(rev, id).changes) out.push({ id, sender: m.sender_uid, chat_id: m.chat_id, rev }); | |
| } | |
| })(rawIds); | |
| for (const o of out) { | |
| wsDeliver(o.sender, { type: "message_delivered", message_id: o.id, chat_id: o.chat_id, delivered_at: nowSec(), rev: o.rev }); | |
| invalidateHistoryCache(o.chat_id, [o.sender, uid]); | |
| } | |
| return OK; | |
| } | |
| /** Recipient read these messages. Authorisation: must be a participant and not the sender. */ | |
| function doSeen(uid, rawIds) { | |
| const groups = new Map(); // `${sender}|${chat}` β { sender, chat_id, ids[], rev } | |
| db.transaction((list) => { | |
| for (const raw of list) { | |
| const id = toInt(raw); if (!id) continue; | |
| const m = stmts.selectMessageMeta.get(id); | |
| if (!m || m.type === "deleted" || m.seen_at || m.sender_uid === uid) continue; | |
| if (!isParticipant(m, uid)) continue; | |
| const rev = nextRev(); | |
| if (!stmts.markOneSeen.run(rev, id).changes) continue; | |
| const k = `${m.sender_uid}|${m.chat_id}`; | |
| if (!groups.has(k)) groups.set(k, { sender: m.sender_uid, chat_id: m.chat_id, ids: [], rev: 0 }); | |
| const g = groups.get(k); g.ids.push(id); g.rev = rev; // rev of the LAST row in the batch | |
| } | |
| })(rawIds); | |
| for (const g of groups.values()) { | |
| wsDeliver(g.sender, { type: "message_seen", message_ids: g.ids, seen_by: uid, chat_id: g.chat_id, rev: g.rev }); | |
| invalidateHistoryCache(g.chat_id, [g.sender, uid]); | |
| } | |
| return OK; | |
| } | |
| function doSeenChat(uid, chatId) { | |
| return doSeen(uid, stmts.selectUnseenInChat.all(chatId, uid).map(r => r.id)); | |
| } | |
| // ββ Edit / delete / react ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function doEdit(uid, rawId, payload) { | |
| const msgId = toInt(rawId); | |
| if (!msgId || !payload || typeof payload !== "string" || payload.length > MAX_PAYLOAD_CHARS) | |
| return fail(400, "invalid", "message_id (positive integer) and a payload string (<= 64k) are required."); | |
| const m = stmts.selectMessageMeta.get(msgId); | |
| if (!m) return fail(404, "not_found", "Message not found."); | |
| if (m.sender_uid !== uid) return fail(403, "forbidden", "Forbidden."); | |
| if (m.type === "deleted") return fail(409, "conflict", "Cannot edit a deleted message."); | |
| const rev = db.transaction(() => { const r = nextRev(); stmts.editMessage.run(payload, r, r, msgId, uid); return r; })(); | |
| const u = stmts.selectEditedAt.get(msgId); | |
| const aud = audienceOf(m); | |
| wsDeliverMany(aud, { | |
| type: "message_updated", message_id: u.id, chat_id: u.chat_id, rev, | |
| // v4: the NEW payload travels with the frame (it was missing before, so the | |
| // other side never saw the edited text). | |
| data: { id: u.id, chat_id: u.chat_id, edited: true, edited_at: u.edited_at, encrypted_payload: payload }, | |
| }); | |
| invalidateHistoryCache(m.chat_id, aud); | |
| return OK; | |
| } | |
| function doDelete(uid, rawId) { | |
| const msgId = toInt(rawId); | |
| if (!msgId) return fail(400, "invalid", "message_id (positive integer) is required."); | |
| const m = stmts.selectMessageMeta.get(msgId); | |
| if (!m) return fail(404, "not_found", "Message not found."); | |
| if (m.sender_uid !== uid) return fail(403, "forbidden", "Forbidden."); | |
| if (m.type === "deleted") return fail(409, "conflict", "Message already deleted."); | |
| const rev = db.transaction(() => { const r = nextRev(); stmts.softDeleteMessage.run(r, r, msgId, uid); return r; })(); | |
| const aud = audienceOf(m); | |
| wsDeliverMany(aud, { type: "message_deleted", message_id: msgId, chat_id: m.chat_id, rev }); | |
| invalidateHistoryCache(m.chat_id, aud); | |
| return OK; | |
| } | |
| function doReact(uid, rawId, reaction) { | |
| const msgId = toInt(rawId); | |
| if (!msgId) return fail(400, "invalid", "message_id (positive integer) is required."); | |
| if (reaction !== undefined && reaction !== null && (typeof reaction !== "string" || reaction.length > 32)) | |
| return fail(400, "invalid", "reaction must be a short string."); | |
| const m = stmts.selectMessageMeta.get(msgId); | |
| if (!m) return fail(404, "not_found", "Message not found."); | |
| if (!isParticipant(m, uid)) return fail(403, "forbidden", "Forbidden."); // v4: was missing | |
| if (m.type === "deleted") return fail(409, "conflict", "Message was deleted."); | |
| const rev = db.transaction(() => { | |
| const r = nextRev(); | |
| stmts.updateReaction.run(reaction ? JSON.stringify({ emoji: reaction }) : null, r, r, msgId); | |
| return r; | |
| })(); | |
| const aud = audienceOf(m); | |
| wsDeliverMany(aud, { type: "react_message", message_id: msgId, chat_id: m.chat_id, reaction: reaction || null, from: uid, rev }); | |
| invalidateHistoryCache(m.chat_id, aud); | |
| // Offline peers: wake them through Firestore so their UI refetches. | |
| for (const r of aud) { | |
| if (r === uid) continue; | |
| admin.firestore().collection("titan_sync").doc(r).set({ | |
| lastEvent: "react_msg", from: uid, msgId, reaction: reaction || null, ts: Date.now(), _trigger: Math.random(), | |
| }, { merge: true }).catch(() => {}); | |
| } | |
| return OK; | |
| } | |
| // ββ Sync βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /** | |
| * Everything user `uid` missed since revision `sinceRev`. | |
| * baseline : cursor was 0 (fresh install) β only the unread backlog, no history replay. | |
| * reset : the cursor is older than what was archived to cold storage β the | |
| * client must refetch history for the chats it cares about. | |
| * upto_rev : the cursor value the client should store after applying `rows`. | |
| */ | |
| function buildSync(uid, sinceRev, limit) { | |
| const lim = Math.min(Math.max(toInt(limit) || 100, 1), SYNC_MAX_ROWS); | |
| const since = Math.max(parseInt(sinceRev, 10) || 0, 0); | |
| const counter = _rev; | |
| const floorRow = stmts.getMeta.get("archive_floor_rev"); | |
| const floor = parseInt((floorRow && floorRow.value) || "0", 10) || 0; | |
| if (since === 0) { | |
| const rows = stmts.selectBaselineUnseen.all(uid, 500).map(r => ({ ...publicRow(r) })); | |
| return { rows, has_more: false, upto_rev: counter, baseline: true, reset: false }; | |
| } | |
| let rows = stmts.selectSync.all({ uid, since, lim: lim + 1 }); | |
| let has_more = rows.length > lim; | |
| if (has_more) rows.length = lim; | |
| // Byte cap β keep at least one row so progress is always made. | |
| let bytes = 0, keep = 0; | |
| for (const r of rows) { | |
| bytes += (r.encrypted_payload ? r.encrypted_payload.length : 0) + 400; | |
| if (keep > 0 && bytes > SYNC_MAX_BYTES) break; | |
| keep++; | |
| } | |
| if (keep < rows.length) { rows.length = keep; has_more = true; } | |
| const upto = has_more ? rows[rows.length - 1].sync_rev : counter; | |
| return { rows: rows.map(publicRow), has_more, upto_rev: upto, baseline: false, reset: since < floor }; | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // 10. Archive β Hugging Face Dataset cold storage | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const ARCHIVE_BASE = "archive"; | |
| const INDEX_PATH = "archive/.index.json"; | |
| /** Full URL for reading a file from the private dataset (raw content). */ | |
| function hfRawUrl(filePath) { | |
| return `https://huggingface.co/datasets/${HF_DATASET_REPO}/resolve/main/${filePath}`; | |
| } | |
| /** | |
| * Upload a single Buffer to the private HF Dataset using the commit endpoint. | |
| * | |
| * The old /upload/ endpoint was retired (HTTP 410). All uploads now go through | |
| * the NDJSON commit API β the same path used by hfBatchCommit and backupDatabase. | |
| * | |
| * @param {string} datasetFilePath destination path inside the dataset repo | |
| * @param {Buffer} contentBuffer | |
| * @param {string|null} tmpFilePath local tmp path for retry tracking (null = no retry) | |
| * @returns {Promise<boolean>} | |
| */ | |
| async function uploadToDataset(datasetFilePath, contentBuffer, tmpFilePath) { | |
| if (!HF_TOKEN || !HF_DATASET_REPO) { | |
| console.warn("[WARN] Archive upload skipped β HF credentials not configured."); | |
| return false; | |
| } | |
| const commitUrl = `https://huggingface.co/api/datasets/${HF_DATASET_REPO}/commit/main`; | |
| const ndjson = [ | |
| JSON.stringify({ key: "header", value: { summary: `Titan upload: ${datasetFilePath}` } }), | |
| JSON.stringify({ | |
| key : "file", | |
| value: { | |
| path : datasetFilePath, | |
| encoding: "base64", | |
| content : contentBuffer.toString("base64"), | |
| }, | |
| }), | |
| ].join("\n"); | |
| try { | |
| const res = await fetch(commitUrl, { | |
| method : "POST", | |
| headers: { | |
| Authorization : `Bearer ${HF_TOKEN}`, | |
| "Content-Type" : "application/x-ndjson", | |
| }, | |
| body: ndjson, | |
| }); | |
| if (!res.ok) { | |
| const text = await res.text().catch(() => ""); | |
| console.error(`[ERROR] HF upload failed (${res.status}): ${text.slice(0, 120)}`); | |
| if (tmpFilePath) stmts.insertPending.run(tmpFilePath, datasetFilePath); | |
| return false; | |
| } | |
| console.log(`[INFO] Archive uploaded: ${datasetFilePath}`); | |
| return true; | |
| } catch (err) { | |
| console.error("[ERROR] HF upload exception:", err.message); | |
| if (tmpFilePath) stmts.insertPending.run(tmpFilePath, datasetFilePath); | |
| return false; | |
| } | |
| } | |
| /** | |
| * Download a file from the HF Dataset and decompress if it ends with .gz. | |
| * @param {string} filePath | |
| * @returns {Promise<string|null>} raw string content, or null on error/404 | |
| */ | |
| async function downloadFromDataset(filePath) { | |
| if (!HF_TOKEN || !HF_DATASET_REPO) return null; | |
| try { | |
| const res = await fetch(hfRawUrl(filePath), { | |
| headers: { Authorization: `Bearer ${HF_TOKEN}` }, | |
| }); | |
| if (res.status === 404) return null; | |
| if (!res.ok) { | |
| console.error(`[ERROR] HF download failed (${res.status}) for ${filePath}`); | |
| return null; | |
| } | |
| const buf = await res.buffer(); | |
| if (filePath.endsWith(".gz")) { | |
| const decompressed = await gunzip(buf); | |
| return decompressed.toString("utf8"); | |
| } | |
| return buf.toString("utf8"); | |
| } catch (err) { | |
| console.error("[ERROR] HF download exception:", err.message); | |
| return null; | |
| } | |
| } | |
| /** | |
| * Archive messages older than `olderThanTimestamp`. | |
| * Groups rows by chat_id β gzip JSONL β upload β update index β delete from SQLite. | |
| * Failed uploads are queued in pending_archive (both tmp + dataset paths stored). | |
| * | |
| * @param {number} olderThanTimestamp Unix seconds | |
| * @param {number|null} limit cap for emergency runs; null = no cap | |
| */ | |
| async function archiveMessages(olderThanTimestamp, limit = null) { | |
| const rows = limit | |
| ? stmts.selectOldMessagesLimit.all(olderThanTimestamp, limit) | |
| : stmts.selectOldMessages.all(olderThanTimestamp); | |
| if (rows.length === 0) { | |
| console.log("[INFO] Archive: no messages to move."); | |
| return; | |
| } | |
| console.log(`[INFO] Archive: processing ${rows.length} message(s).`); | |
| // Group by chat_id, preserving the ASC order from the query | |
| const byChatId = new Map(); | |
| for (const row of rows) { | |
| if (!byChatId.has(row.chat_id)) byChatId.set(row.chat_id, []); | |
| byChatId.get(row.chat_id).push(row); | |
| } | |
| // Fetch the current index object (create a blank one if it doesn't exist yet) | |
| let indexObj = { last_updated: new Date().toISOString(), files: {} }; | |
| const indexRaw = await downloadFromDataset(INDEX_PATH); | |
| if (indexRaw) { | |
| try { indexObj = JSON.parse(indexRaw); } | |
| catch { /* leave default */ } | |
| } | |
| if (!indexObj.files || typeof indexObj.files !== "object") indexObj.files = {}; | |
| const successIds = []; // IDs to delete from SQLite after all uploads succeed | |
| let maxArchivedRev = 0; // v4: highest rev that leaves SQLite β archive_floor_rev | |
| for (const [chatId, messages] of byChatId) { | |
| // Use the date of the OLDEST message in this batch as the path date | |
| const msgDate = new Date(messages[0].created_at * 1000); | |
| const year = msgDate.getUTCFullYear(); | |
| const month = String(msgDate.getUTCMonth() + 1).padStart(2, "0"); | |
| const day = String(msgDate.getUTCDate()).padStart(2, "0"); | |
| const datasetPath = `${ARCHIVE_BASE}/${year}/${month}/${day}/${chatId}.jsonl.gz`; | |
| // Build JSONL β one JSON object per line, no array wrapper | |
| const jsonl = messages.map(m => JSON.stringify(m)).join("\n"); | |
| // Compress | |
| let compressed; | |
| try { | |
| compressed = await gzip(Buffer.from(jsonl, "utf8")); | |
| } catch (err) { | |
| console.error("[ERROR] Archive gzip failed for chat", redact(chatId), "β", err.message); | |
| continue; | |
| } | |
| // Write to a local tmp file. | |
| // This path is stored in pending_archive so retryPendingArchives can re-read the buffer. | |
| const tmpPath = path.join(os.tmpdir(), `titan_arc_${chatId}_${Date.now()}.jsonl.gz`); | |
| try { | |
| fs.writeFileSync(tmpPath, compressed); | |
| } catch (err) { | |
| console.error("[ERROR] Tmp file write failed:", err.message); | |
| continue; | |
| } | |
| // Attempt upload β on failure, insertPending is called with both paths | |
| const ok = await uploadToDataset(datasetPath, compressed, tmpPath); | |
| if (!ok) { | |
| // Tmp file is intentionally left on disk for retry | |
| continue; | |
| } | |
| // Update in-memory index | |
| if (!indexObj.files[chatId]) indexObj.files[chatId] = []; | |
| if (!indexObj.files[chatId].includes(datasetPath)) { | |
| indexObj.files[chatId].push(datasetPath); | |
| } | |
| successIds.push(...messages.map(m => m.id)); | |
| for (const m of messages) if ((m.rev || 0) > maxArchivedRev) maxArchivedRev = m.rev; | |
| // Upload succeeded β tmp file no longer needed | |
| try { fs.unlinkSync(tmpPath); } catch { /* best-effort */ } | |
| } | |
| // Upload the updated index (reflects only successfully archived files) | |
| if (successIds.length > 0) { | |
| indexObj.last_updated = new Date().toISOString(); | |
| const indexBuf = Buffer.from(JSON.stringify(indexObj, null, 2), "utf8"); | |
| // Index is small and non-critical; no retry tracking for it | |
| await uploadToDataset(INDEX_PATH, indexBuf, null); | |
| } | |
| // Delete successfully archived messages from SQLite inside a single transaction | |
| if (successIds.length > 0) { | |
| const txDelete = db.transaction((ids) => { | |
| for (const id of ids) stmts.deleteMessageById.run(id); | |
| }); | |
| txDelete(successIds); | |
| if (maxArchivedRev > 0) { | |
| const fr = stmts.getMeta.get("archive_floor_rev"); | |
| const old = parseInt((fr && fr.value) || "0", 10) || 0; | |
| stmts.setMeta.run("archive_floor_rev", String(Math.max(old, maxArchivedRev))); | |
| } | |
| console.log(`[INFO] Archive: ${successIds.length} message(s) moved to cold storage.`); | |
| } | |
| } | |
| /** | |
| * Retry uploads that previously failed. | |
| * | |
| * In v3, pending_archive stores the local tmp file path alongside the dataset path, | |
| * so the gzip buffer can be re-read and re-uploaded without going back to SQLite | |
| * (the messages may have already been deleted on a partial success run). | |
| */ | |
| async function retryPendingArchives() { | |
| const pending = stmts.allPending.all(); | |
| if (pending.length === 0) return; | |
| console.log(`[INFO] Retrying ${pending.length} pending archive upload(s).`); | |
| for (const row of pending) { | |
| const { id, tmp_file_path, dataset_file_path } = row; | |
| if (!fs.existsSync(tmp_file_path)) { | |
| // OS cleaned up the tmp file (or container was replaced) β data is unrecoverable | |
| console.warn(`[WARN] Tmp archive file missing: ${path.basename(tmp_file_path)} β removing stale entry.`); | |
| stmts.deletePending.run(id); | |
| continue; | |
| } | |
| let compressed; | |
| try { | |
| compressed = fs.readFileSync(tmp_file_path); | |
| } catch (err) { | |
| console.error("[ERROR] Cannot read tmp archive file:", err.message); | |
| stmts.deletePending.run(id); | |
| continue; | |
| } | |
| // Retry upload β do NOT pass tmpFilePath so we don't create a duplicate pending entry | |
| const ok = await uploadToDataset(dataset_file_path, compressed, null); | |
| if (ok) { | |
| stmts.deletePending.run(id); | |
| try { fs.unlinkSync(tmp_file_path); } catch { /* best-effort */ } | |
| console.log(`[INFO] Retry upload succeeded: ${dataset_file_path}`); | |
| } else { | |
| console.warn(`[WARN] Retry upload failed: ${dataset_file_path} β will try again next cycle.`); | |
| } | |
| } | |
| } | |
| /** | |
| * Emergency archive: archive the oldest 100,000 messages when the DB file | |
| * exceeds EMERGENCY_SIZE_MB in size. | |
| */ | |
| async function checkEmergencyArchive() { | |
| try { | |
| const stat = fs.statSync(DB_PATH); | |
| const sizeMB = stat.size / (1024 * 1024); | |
| if (sizeMB > EMERGENCY_SIZE_MB) { | |
| console.log(`[INFO] Emergency archive triggered (DB: ${sizeMB.toFixed(1)} MB > limit: ${EMERGENCY_SIZE_MB} MB).`); | |
| // Find the created_at of the 100,000th oldest message (offset 99999 is 0-indexed) | |
| const row = stmts.selectNthOldestTs.get(99999); | |
| const threshold = row ? row.created_at : nowSec(); | |
| await archiveMessages(threshold, 100000); | |
| } | |
| } catch (err) { | |
| console.error("[ERROR] Emergency archive check failed:", err.message); | |
| } | |
| } | |
| /** | |
| * Daily archive: move all messages older than ARCHIVE_DAYS days to cold storage, | |
| * then attempt to complete any previously failed uploads. | |
| */ | |
| async function dailyArchive() { | |
| console.log(`[INFO] Daily archive started (threshold: ${ARCHIVE_DAYS} day(s) ago).`); | |
| const threshold = nowSec() - ARCHIVE_DAYS * 86400; | |
| await archiveMessages(threshold); | |
| await retryPendingArchives(); | |
| console.log("[INFO] Daily archive complete."); | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // 11. Scheduler | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // In-memory guard (resets on restart β the meta table is the durable source of truth) | |
| let lastDailyArchiveDate = null; | |
| // Every 30 minutes: check whether the daily archive is due | |
| setInterval(async () => { | |
| const now = new Date(); | |
| const todayStr = now.toISOString().slice(0, 10); // "YYYY-MM-DD" | |
| const hour = now.getUTCHours(); | |
| if (hour !== ARCHIVE_DAILY_HOUR) return; | |
| if (lastDailyArchiveDate === todayStr) return; // already ran in-process today | |
| // Double-check the durable meta table (guards against duplicate runs after a restart) | |
| const metaRow = stmts.getMeta.get("last_daily_archive"); | |
| if (metaRow && metaRow.value === todayStr) { | |
| lastDailyArchiveDate = todayStr; // sync in-memory guard | |
| return; | |
| } | |
| // Mark as done BEFORE starting (prevents a second run if the job is slow) | |
| lastDailyArchiveDate = todayStr; | |
| stmts.setMeta.run("last_daily_archive", todayStr); | |
| await dailyArchive().catch(err => console.error("[ERROR] Daily archive job:", err.message)); | |
| }, 30 * 60 * 1000); | |
| // Every ARCHIVE_INTERVAL_HRS hours: check DB size and trigger emergency archive if needed | |
| setInterval(async () => { | |
| await checkEmergencyArchive().catch(err => console.error("[ERROR] Emergency archive check:", err.message)); | |
| }, ARCHIVE_INTERVAL_HRS * 60 * 60 * 1000); | |
| // On startup: attempt to complete any previously failed uploads from the last run | |
| retryPendingArchives().catch(err => console.error("[ERROR] Startup retry archives:", err.message)); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // 11b. Database Backup β gzip snapshot β Hugging Face Dataset | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const BACKUP_KEEP = 3; // keep only the 3 most recent backups | |
| const BACKUP_FOLDER = "backups"; | |
| async function backupDatabase() { | |
| if (!HF_TOKEN || !HF_DATASET_REPO) { | |
| console.warn("[WARN] backupDatabase: HF_TOKEN or HF_DATASET_REPO not set β skipping backup."); | |
| return; | |
| } | |
| const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); // safe for filenames | |
| const remotePath = `${BACKUP_FOLDER}/titan_messenger_${timestamp}.db.gz`; | |
| try { | |
| // Read the live DB file | |
| const dbBuf = fs.readFileSync(DB_PATH); | |
| // Compress with gzip | |
| const compressed = await gzip(dbBuf); | |
| // Upload to Hugging Face Dataset | |
| await uploadToDataset(remotePath, compressed, null); | |
| console.log(`[INFO] backupDatabase: snapshot uploaded β ${remotePath}`); | |
| // Prune old backups β keep only the BACKUP_KEEP most recent | |
| try { | |
| const entries = await listDatasetFolder(BACKUP_FOLDER); | |
| // entries are objects with a .path property; sort ascending by path (ISO timestamp sorts lexically) | |
| const backupFiles = entries | |
| .filter(e => e.path && e.path.startsWith(BACKUP_FOLDER + "/") && e.path.endsWith(".db.gz")) | |
| .map(e => e.path) | |
| .sort(); | |
| if (backupFiles.length > BACKUP_KEEP) { | |
| const toDelete = backupFiles.slice(0, backupFiles.length - BACKUP_KEEP); | |
| await deleteFromDataset(toDelete); | |
| console.log(`[INFO] backupDatabase: pruned ${toDelete.length} old backup(s), kept ${BACKUP_KEEP}`); | |
| } | |
| } catch (pruneErr) { | |
| console.error("[ERROR] backupDatabase: prune step failed:", pruneErr.message); | |
| } | |
| } catch (err) { | |
| console.error("[ERROR] backupDatabase:", err.message); | |
| } | |
| } | |
| /** List files inside a dataset folder via the HF API tree endpoint. */ | |
| async function listDatasetFolder(folder) { | |
| const url = `https://huggingface.co/api/datasets/${HF_DATASET_REPO}/tree/main/${encodeURIComponent(folder)}?recursive=false`; | |
| const res = await fetch(url, { headers: { Authorization: `Bearer ${HF_TOKEN}` } }); | |
| if (!res.ok) return []; | |
| const data = await res.json().catch(() => []); | |
| return Array.isArray(data) ? data : []; | |
| } | |
| /** Delete a list of file paths from the dataset via a single NDJSON commit. */ | |
| async function deleteFromDataset(filePaths) { | |
| if (!filePaths || !filePaths.length) return; | |
| const lines = [ | |
| JSON.stringify({ key: "header", value: { summary: `Titan backup prune: delete ${filePaths.length} file(s)` } }), | |
| ...filePaths.map(p => JSON.stringify({ key: "deletedFile", value: { path: p } })), | |
| ].join("\n"); | |
| const res = await fetch(`https://huggingface.co/api/datasets/${HF_DATASET_REPO}/commit/main`, { | |
| method: "POST", | |
| headers: { Authorization: `Bearer ${HF_TOKEN}`, "Content-Type": "application/x-ndjson" }, | |
| body: lines, | |
| }); | |
| if (!res.ok) console.error(`[ERROR] deleteFromDataset: HF commit failed (${res.status})`); | |
| } | |
| // Run every 6 hours | |
| setInterval(() => { | |
| backupDatabase().catch(err => console.error("[ERROR] Scheduled backup:", err.message)); | |
| }, 6 * 60 * 60 * 1000); | |
| // Also run 30 seconds after startup | |
| setTimeout(() => { | |
| backupDatabase().catch(err => console.error("[ERROR] Startup backup:", err.message)); | |
| }, 30_000); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // 12. Express application & REST routes | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const app = express(); | |
| // π’ Ψ₯ΨΆΨ§ΩΨ© CORS Middleware ΩΩΨ³Ω Ψ§Ψ ΩΩΩ PWA Ψ¨Ψ§ΩΨͺΩΨ§Ψ΅Ω Ω ΨΉ Ψ§ΩΨ³ΩΨ±ΩΨ± | |
| app.use((req, res, next) => { | |
| res.header("Access-Control-Allow-Origin", "*"); | |
| res.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); | |
| res.header("Access-Control-Allow-Headers", "Content-Type, Authorization, x-titan-key"); | |
| // Ψ§ΩΨ±Ψ― ΩΩΨ±Ψ§Ω ΨΉΩΩ Ψ·ΩΨ¨Ψ§Ψͺ Ψ§ΩΩ Preflight Ψ§ΩΩΩ Ψ¨ΩΨ¨ΨΉΨͺΩΨ§ Ψ§ΩΩ ΨͺΨ΅ΩΨ | |
| if (req.method === "OPTIONS") { | |
| return res.status(200).end(); | |
| } | |
| next(); | |
| }); | |
| app.use(express.json({ limit: "64kb" })); // Payloads are ciphertext β 64 KB is generous | |
| // ββ Cloud Relay (merged from titan-cloud-relay Space) ββββββββββββββββββββββββ | |
| // Handles media/file uploads + downloads via HF Dataset. | |
| // Routes: POST /relay/upload-chunk, GET /relay/download-chunk, GET /relay/health | |
| const cloudRelay = require("./cloudRelay"); | |
| app.use("/relay", cloudRelay); | |
| // ββ GET /health ββ public, no auth βββββββββββββββββββββββββββββββββββββββββββ | |
| app.get("/health", (_req, res) => { | |
| res.json({ status: "ok", uptime: process.uptime() }); | |
| }); | |
| // ββ GET /history βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Pagination: prefer `before_id` (exact, no ties β see selectHistoryBeforeId). | |
| // `before` (a Unix-seconds timestamp) is kept for backward compatibility with | |
| // any client that hasn't been rebuilt yet; created_at only has 1-second | |
| // resolution so it can tie between messages, which is why before_id exists. | |
| app.get("/history", authMiddleware, async (req, res) => { | |
| const { chat_id, limit, before, before_id } = req.query; | |
| if (!chat_id) return res.status(400).json({ error: "chat_id is required." }); | |
| const beforeId = toInt(before_id); // null if absent/invalid | |
| const isPaginated = !!before || !!beforeId; | |
| // Dedup guard: same user + same chat + no pagination cursor = serve cache | |
| const _ckey = `${req.uid}:${chat_id}`; | |
| const _cached = _historyCache.get(_ckey); | |
| if (_cached && !isPaginated && (Date.now() - _cached.ts < _HIST_TTL)) { | |
| return res.json(_cached.result); // silent β no log spam | |
| } | |
| console.log(`[INFO] Fetching history for chat: ${redact(chat_id)}`); | |
| if (!(await isChatMemberAsync(chat_id, req.uid))) return res.status(403).json({ error: "Forbidden." }); | |
| const safeLimit = Math.min(Math.max(parseInt(limit, 10) || 50, 1), 200); | |
| const safeBefore = parseFloat(before) || nowSec() + 9_999_999; | |
| // ββ 1. Hot data from SQLite βββββββββββββββββββββββββββββββββββββββββββββ | |
| let messages = beforeId | |
| ? stmts.selectHistoryBeforeId.all(chat_id, beforeId, safeLimit) | |
| : stmts.selectHistory.all(chat_id, safeBefore, safeLimit); | |
| // ββ 2. Cold data from archive (only if the page is incomplete) ββββββββββ | |
| if (messages.length < safeLimit && HF_TOKEN && HF_DATASET_REPO) { | |
| try { | |
| // We want archive rows strictly older than the oldest hot row. | |
| // In id mode, compare by id (exact); in legacy timestamp mode, by created_at. | |
| const oldestHotId = messages.length > 0 ? messages[messages.length - 1].id : beforeId; | |
| const oldestHotTs = messages.length > 0 ? messages[messages.length - 1].created_at : safeBefore; | |
| const indexRaw = await downloadFromDataset(INDEX_PATH); | |
| if (indexRaw) { | |
| const indexObj = JSON.parse(indexRaw); | |
| const filePaths = (indexObj.files && indexObj.files[chat_id]) || []; | |
| // Sort descending by path (lexicographic order = chronological for YYYY/MM/DD paths) | |
| // so we read the most-recent archive files first. | |
| const sorted = [...filePaths].sort().reverse(); | |
| const archiveMsgs = []; | |
| for (const fp of sorted) { | |
| if (archiveMsgs.length + messages.length >= safeLimit) break; | |
| // Path format: "archive/YYYY/MM/DD/chatId.jsonl.gz" | |
| // After split("/"): [0]="archive" [1]=YYYY [2]=MM [3]=DD [4]="chatId.jsonl.gz" | |
| const segs = fp.split("/"); | |
| if (segs.length < 5) continue; | |
| // Convert the file's date to a Unix timestamp (start of that UTC day) | |
| // Only usable as a skip-optimisation in legacy timestamp mode β id | |
| // has no fixed relationship to a calendar date, so skip the shortcut | |
| // (still correct, just reads a few more archive files) in id mode. | |
| if (!beforeId) { | |
| const fileStartTs = Date.UTC(+segs[1], +segs[2] - 1, +segs[3]) / 1000; | |
| if (fileStartTs >= safeBefore) continue; | |
| } | |
| const jsonl = await downloadFromDataset(fp); | |
| if (!jsonl) continue; | |
| const archRows = jsonl | |
| .split("\n") | |
| .filter(Boolean) | |
| .map(line => { try { return JSON.parse(line); } catch { return null; } }) | |
| .filter(r => r !== null && (beforeId ? r.id < oldestHotId : r.created_at < oldestHotTs)) | |
| .sort((a, b) => (beforeId ? b.id - a.id : b.created_at - a.created_at)); // newest first, matching hot rows | |
| archiveMsgs.push(...archRows); | |
| } | |
| const remaining = safeLimit - messages.length; | |
| messages = [...messages, ...archiveMsgs.slice(0, remaining)]; | |
| } | |
| } catch (err) { | |
| // Archive failure is non-fatal; return whatever SQLite provided | |
| console.error("[ERROR] Archive history fetch:", err.message); | |
| } | |
| } | |
| // v4: never leak server-internal columns (archive rows carry them too) | |
| messages = messages.map(publicRow); | |
| // Cache the result for the dedup guard (only for non-paginated requests) | |
| if (!isPaginated) { | |
| _historyCache.set(_ckey, { ts: Date.now(), result: { messages } }); | |
| } | |
| // oldest_id: exact cursor for the next page (pass back as before_id). | |
| // null once fewer than a full page came back β that's the "no more" signal. | |
| const oldestId = messages.length > 0 ? Math.min(...messages.map(m => m.id)) : null; | |
| res.json({ messages, oldest_id: oldestId, has_more: messages.length >= safeLimit }); | |
| }); | |
| // ββ POST /send_message ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app.post("/send_message", authMiddleware, async (req, res) => { | |
| const { chat_id, encrypted_payload, type, temp_id, reply_to } = req.body || {}; | |
| const bad = validateSend(chat_id, encrypted_payload, type, temp_id); | |
| if (bad) return res.status(400).json({ error: bad, code: "invalid" }); | |
| if (!allowSend(req.uid)) return res.status(429).json({ error: "Too many messages.", code: "rate_limited", retry_after_ms: 3000 }); | |
| if (!(await isChatMemberAsync(chat_id, req.uid))) { | |
| return res.status(403).json({ error: "Forbidden.", code: "forbidden" }); | |
| } | |
| try { | |
| // Look up displayName from WS connection cache first (avoids Firebase Admin call per message) | |
| let senderName = wsNames.get(req.uid) || "Someone"; | |
| if (senderName === "Someone") { | |
| try { | |
| const userRecord = await admin.auth().getUser(req.uid); | |
| senderName = userRecord.displayName || "Someone"; | |
| const _photoURL = userRecord.photoURL || ""; | |
| if (senderName !== "Someone") wsNames.set(req.uid, senderName); | |
| if (_photoURL) wsIcons.set(req.uid, _photoURL); | |
| } catch { /* non-fatal */ } | |
| } | |
| const { stored, ack } = processMessage({ | |
| chatId : chat_id, | |
| senderUid : req.uid, | |
| senderName, | |
| senderIcon : wsIcons.get(req.uid) || "", | |
| encryptedPayload : encrypted_payload, | |
| type : type || "text", | |
| tempId : (typeof temp_id === "string" && temp_id) ? temp_id : null, | |
| replyTo : reply_to || null, | |
| senderWs : null, // HTTP path β ACK merged into response below | |
| }); | |
| res.json({ success: true, message_id: stored.id, ...ack }); | |
| } catch (err) { | |
| console.error("[ERROR] /send_message:", err.message); | |
| res.status(500).json({ error: "Internal server error.", code: "internal" }); | |
| } | |
| }); | |
| const sendResult = (res, r) => r.ok | |
| ? res.json({ success: true }) | |
| : res.status(r.status).json({ error: r.message, code: r.code }); | |
| // ββ POST /edit_message ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app.post("/edit_message", authMiddleware, (req, res) => { | |
| const { message_id, new_encrypted_payload } = req.body || {}; | |
| sendResult(res, doEdit(req.uid, message_id, new_encrypted_payload)); | |
| }); | |
| // ββ POST /delete_message ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app.post("/delete_message", authMiddleware, (req, res) => { | |
| sendResult(res, doDelete(req.uid, (req.body || {}).message_id)); | |
| }); | |
| // ββ POST /mark_seen βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // v4: only participants can mark (and never their own messages) β previously | |
| // ANY authenticated user could mark ANY message id as seen. | |
| app.post("/mark_seen", authMiddleware, (req, res) => { | |
| const { message_ids } = req.body || {}; | |
| if (!Array.isArray(message_ids) || message_ids.length === 0) { | |
| return res.status(400).json({ error: "message_ids must be a non-empty array.", code: "invalid" }); | |
| } | |
| sendResult(res, doSeen(req.uid, message_ids.slice(0, 500))); | |
| }); | |
| // ββ POST /ack_delivered ββ native/background wake path: "I have these" βββββββ | |
| app.post("/ack_delivered", authMiddleware, (req, res) => { | |
| const { message_ids } = req.body || {}; | |
| if (!Array.isArray(message_ids) || message_ids.length === 0) { | |
| return res.status(400).json({ error: "message_ids must be a non-empty array.", code: "invalid" }); | |
| } | |
| sendResult(res, doDelivered(req.uid, message_ids.slice(0, 500))); | |
| }); | |
| // ββ GET /sync?since_rev=N&limit=M βββββββββββββββββββββββββββββββββββββββββββββ | |
| // Same payload as the WS `sync_batch` frame. Lets a push-woken native shell / | |
| // service worker pull what it missed without opening a socket. | |
| app.get("/sync", authMiddleware, (req, res) => { | |
| try { | |
| res.json(buildSync(req.uid, req.query.since_rev, req.query.limit)); | |
| } catch (err) { | |
| console.error("[ERROR] /sync:", err.message); | |
| res.status(500).json({ error: "Internal server error.", code: "internal" }); | |
| } | |
| }); | |
| // ββ POST /admin/backup β manually trigger a database backup ββββββββββββββββββ | |
| // Requires both x-titan-key and a valid Firebase ID Token (authMiddleware). | |
| // For emergency use before a planned Factory Rebuild. | |
| app.post("/admin/backup", authMiddleware, async (req, res) => { | |
| console.log(`[INFO] /admin/backup triggered by uid=${redact(req.uid)}`); | |
| try { | |
| await backupDatabase(); | |
| res.json({ success: true, message: "Backup completed successfully." }); | |
| } catch (err) { | |
| console.error("[ERROR] /admin/backup:", err.message); | |
| res.status(500).json({ error: err.message }); | |
| } | |
| }); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const httpServer = http.createServer(app); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // 14. WebSocket server (/ws) | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // maxPayload: ws defaults to 100 MB per frame β messages are small ciphertext (media goes via /relay). | |
| const wss = new WebSocketServer({ server: httpServer, path: "/ws", maxPayload: 256 * 1024 }); | |
| wss.on("connection", (ws) => { | |
| console.log("[INFO] WS connected (pending auth)"); | |
| let uid = null; | |
| let displayName = "Someone"; | |
| let authenticated = false; | |
| let pingTimer = null; | |
| let pongTimer = null; | |
| // FIX: Auth deadline β if no auth frame arrives within 15 s, close the socket. | |
| // Prevents ghost connections that appear as "connected (pending auth)" indefinitely | |
| // and eliminates the repeated connectβdisconnect(unauthenticated) loop caused by | |
| // clients that open a WS connection but fail to send a valid token in time. | |
| const authDeadline = setTimeout(() => { | |
| if (!authenticated) { | |
| console.log("[INFO] WS auth deadline exceeded β closing unauthenticated socket"); | |
| ws.close(); | |
| } | |
| }, 15_000); | |
| // ββ Application-level heartbeat: ping every 30 s, 10 s pong deadline ββββββ | |
| function startHeartbeat() { | |
| pingTimer = setInterval(() => { | |
| if (ws.readyState !== WebSocket.OPEN) return; | |
| wsSend(ws, { type: "ping" }); | |
| // FIX: increased pong deadline from 10 s β 25 s. | |
| // Mobile browsers throttle JS timers when screen is locked, | |
| // causing legitimate clients to miss the 10 s window β false termination. | |
| pongTimer = setTimeout(() => { | |
| console.log("[INFO] WS pong timeout β terminating", redact(uid)); | |
| ws.terminate(); | |
| }, 25_000); | |
| }, 30_000); | |
| } | |
| // Handle native WebSocket pong frames (sent by some clients/browsers) | |
| ws.on("pong", () => { | |
| if (pongTimer) { clearTimeout(pongTimer); pongTimer = null; } | |
| }); | |
| // ββ Incoming frame handler βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Frames of ONE socket are processed strictly in order (an `await` in one | |
| // handler used to let the next frame overtake it). | |
| let _chain = Promise.resolve(); | |
| const handleFrame = async (raw) => { | |
| let frame; | |
| try { frame = JSON.parse(raw); } | |
| catch { | |
| wsSend(ws, { type: "error", message: "Invalid JSON." }); | |
| return; | |
| } | |
| // ββ Auth frame (must arrive first) βββββββββββββββββββββββββββββββββββββββ | |
| if (!authenticated) { | |
| if (frame.type !== "auth") { | |
| wsSend(ws, { type: "auth_error", message: "Send an auth frame first." }); | |
| ws.close(); | |
| return; | |
| } | |
| // Layer A β Titan API Key | |
| if (!checkTitanKey(frame.x_titan_key)) { | |
| wsSend(ws, { type: "auth_error", message: "Invalid Titan key." }); | |
| ws.close(); | |
| return; | |
| } | |
| // Layer B β Firebase ID Token | |
| try { | |
| const decoded = await admin.auth().verifyIdToken(frame.firebase_token || ""); | |
| uid = decoded.uid; // ALWAYS from the verified token β never from the frame body | |
| try { | |
| const userRecord = await admin.auth().getUser(uid); | |
| displayName = userRecord.displayName || "Someone"; | |
| const photoURL = userRecord.photoURL || ""; | |
| if (displayName !== "Someone") wsNames.set(uid, displayName); | |
| // β Cache avatar URL so FCM can include it in notifications | |
| if (photoURL) wsIcons.set(uid, photoURL); | |
| } catch { /* non-fatal */ } | |
| authenticated = true; | |
| clearTimeout(authDeadline); // FIX: cancel auth deadline β client authenticated in time | |
| wsRegister(uid, ws); | |
| wsSend(ws, { type: "auth_ok", server_rev: _rev }); | |
| startHeartbeat(); | |
| console.log("[INFO] WS authenticated:", redact(uid)); | |
| // Optional: register FCM token sent inline with the auth frame. | |
| // This ensures the token is always current after every reconnect, | |
| // even when the REST /register_fcm_token call hasn't fired yet. | |
| if (frame.fcm_token && typeof frame.fcm_token === "string" && frame.fcm_token.trim()) { | |
| stmts.upsertFcmToken.run(uid, frame.fcm_token.trim()); | |
| console.log("[INFO] FCM token registered (WS auth) for", redact(uid)); | |
| } | |
| // Presence: tell all connected clients this user is online | |
| wsBroadcast({ type: "user_online", user_id: uid }); | |
| } catch { | |
| wsSend(ws, { type: "auth_error", message: "Firebase token invalid or expired." }); | |
| ws.close(); | |
| } | |
| return; | |
| } | |
| // ββ Application-level pong ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if (frame.type === "pong") { | |
| if (pongTimer) { clearTimeout(pongTimer); pongTimer = null; } | |
| return; | |
| } | |
| // ββ message_received β the device CONFIRMS it processed these messages. | |
| // This (and `seen`) is the only thing that sets delivered_at. Accepts one id | |
| // or a batch (used after applying a sync batch). βββββββββββββββββββββββββ | |
| if (frame.type === "message_received") { | |
| const ids = Array.isArray(frame.message_ids) | |
| ? frame.message_ids | |
| : [frame.message_id ?? (frame.data && frame.data.message_id)]; | |
| doDelivered(uid, ids.slice(0, 500)); | |
| return; | |
| } | |
| // ββ sync_request β "give me everything since rev N" βββββββββββββββββββββ | |
| if (frame.type === "sync_request") { | |
| const d = frame.data || {}; | |
| try { | |
| wsSend(ws, { type: "sync_batch", ...buildSync(uid, d.since_rev, d.limit) }); | |
| } catch (e) { | |
| console.error("[ERROR] sync_request:", e.message); | |
| wsSend(ws, { type: "error", code: "internal", message: "sync failed" }); | |
| } | |
| return; | |
| } | |
| // ββ update_fcm_token β propagate a rotated push token mid-session. βββββββ | |
| if (frame.type === "update_fcm_token") { | |
| const tok = (frame.data && frame.data.fcm_token) || frame.fcm_token; | |
| if (typeof tok === "string" && tok.trim()) { | |
| try { stmts.upsertFcmToken.run(uid, tok.trim()); } catch (e) { console.error("[ERROR] update_fcm_token:", e.message); } | |
| } | |
| return; | |
| } | |
| // ββ send_message ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if (frame.type === "send_message") { | |
| const data = frame.data || {}; | |
| const { chat_id, encrypted_payload, type, reply_to } = data; | |
| const temp_id = typeof data.temp_id === "string" ? data.temp_id : null; | |
| const bad = validateSend(chat_id, encrypted_payload, type, temp_id); | |
| if (bad) { wsSend(ws, { type: "error", code: "invalid", temp_id, message: bad }); return; } | |
| if (!allowSend(uid)) { | |
| // Transient β the client keeps the message in its outbox and retries. | |
| wsSend(ws, { type: "error", code: "rate_limited", temp_id, retry_after_ms: 3000, message: "Too many messages β slow down." }); | |
| return; | |
| } | |
| if (!(await isChatMemberAsync(chat_id, uid))) { | |
| wsSend(ws, { type: "error", code: "forbidden", temp_id, message: "Forbidden: not a member of this chat." }); | |
| return; | |
| } | |
| try { | |
| processMessage({ | |
| chatId : chat_id, | |
| senderUid : uid, | |
| senderName : displayName, | |
| senderIcon : wsIcons.get(uid) || "", | |
| encryptedPayload : encrypted_payload, | |
| type : type || "text", | |
| tempId : temp_id, | |
| replyTo : reply_to || null, | |
| senderWs : ws, | |
| }); | |
| } catch (err) { | |
| console.error("[ERROR] WS send_message:", err.message); | |
| // `internal` = transient (disk busy, ...) β the client retries with the same temp_id. | |
| wsSend(ws, { type: "error", code: "internal", temp_id, message: "Failed to store message." }); | |
| } | |
| return; | |
| } | |
| // ββ mark_seen βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if (frame.type === "mark_seen") { | |
| const data = frame.data || {}; | |
| if (data.chat_id && !data.message_ids) { | |
| if (typeof data.chat_id !== "string" || !(await isChatMemberAsync(data.chat_id, uid))) { | |
| wsSend(ws, { type: "error", code: "forbidden", message: "Forbidden." }); | |
| return; | |
| } | |
| try { doSeenChat(uid, data.chat_id); } catch (e) { console.error("[ERROR] mark_seen by chat_id:", e.message); } | |
| return; | |
| } | |
| if (!Array.isArray(data.message_ids) || data.message_ids.length === 0) { | |
| wsSend(ws, { type: "error", code: "invalid", message: "data.message_ids must be a non-empty array." }); | |
| return; | |
| } | |
| try { doSeen(uid, data.message_ids.slice(0, 500)); } catch (e) { console.error("[ERROR] mark_seen:", e.message); } | |
| return; | |
| } | |
| // ββ edit_message / delete_message / react_message ββββββββββββββββββββββββ | |
| if (frame.type === "edit_message" || frame.type === "delete_message" || frame.type === "react_message") { | |
| const d = frame.data || {}; | |
| let r; | |
| try { | |
| if (frame.type === "edit_message") r = doEdit(uid, d.message_id, d.encrypted_payload); | |
| else if (frame.type === "delete_message") r = doDelete(uid, d.message_id); | |
| else r = doReact(uid, d.message_id, d.reaction); | |
| } catch (e) { | |
| console.error(`[ERROR] ${frame.type}:`, e.message); | |
| r = fail(500, "internal", "Internal error."); | |
| } | |
| if (!r.ok) wsSend(ws, { type: "error", code: r.code, message: r.message }); | |
| return; | |
| } | |
| // ββ get_presence β query online status of a specific user ββββββββββββββββ | |
| if (frame.type === "get_presence") { | |
| const targetUid = (frame.data || {}).user_id; | |
| if (!targetUid) { | |
| wsSend(ws, { type: "error", message: "data.user_id is required for get_presence." }); | |
| return; | |
| } | |
| const isOnline = wsConnections.has(targetUid) && wsConnections.get(targetUid).size > 0; | |
| // FIX: Send flat frame β client reads frame.user_id at top-level | |
| wsSend(ws, { | |
| type : isOnline ? "user_online" : "user_offline", | |
| user_id : targetUid, | |
| requested : true, | |
| }); | |
| return; | |
| } | |
| wsSend(ws, { type: "error", code: "invalid", message: `Unknown frame type: ${frame.type}` }); | |
| }; | |
| ws.on("message", (raw) => { | |
| _chain = _chain.then(() => handleFrame(raw)) | |
| .catch(err => console.error("[ERROR] WS frame handler:", err.message)); | |
| }); | |
| // ββ Disconnect βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| ws.on("close", () => { | |
| clearTimeout(authDeadline); // FIX: cleanup on any close | |
| if (pingTimer) { clearInterval(pingTimer); pingTimer = null; } | |
| if (pongTimer) { clearTimeout(pongTimer); pongTimer = null; } | |
| if (uid) { | |
| wsUnregister(uid, ws); | |
| // Broadcast offline presence only when this was the user's last socket | |
| if (!wsConnections.has(uid)) { | |
| // FIX: Client reads frame.user_id directly (top-level), not frame.data.user_id | |
| wsBroadcast({ type: "user_offline", user_id: uid, last_seen: nowSec() }); | |
| } | |
| console.log("[INFO] WS disconnected:", redact(uid)); | |
| } else { | |
| console.log("[INFO] WS disconnected (unauthenticated)"); | |
| } | |
| }); | |
| ws.on("error", (err) => { | |
| console.error("[ERROR] WS socket error:", err.message); | |
| }); | |
| }); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // 15. Start listening | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| httpServer.listen(PORT, "0.0.0.0", () => { | |
| console.log(`[INFO] Titan Messenger v4 listening on port ${PORT}`); | |
| console.log( | |
| `[INFO] Archive β DAYS=${ARCHIVE_DAYS}, ` + | |
| `INTERVAL=${ARCHIVE_INTERVAL_HRS}h, DAILY_HOUR=${ARCHIVE_DAILY_HOUR}UTC, ` + | |
| `EMERGENCY=${EMERGENCY_SIZE_MB}MB` | |
| ); | |
| }); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // 16. Graceful shutdown | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function shutdown(signal) { | |
| console.log(`[INFO] ${signal} received β shutting down gracefully.`); | |
| // 1001 "going away": clients reconnect and `sync_request` replays whatever they missed. | |
| for (const set of wsConnections.values()) { | |
| for (const ws of set) { try { ws.close(1001, "server restarting"); } catch {} } | |
| } | |
| httpServer.close(() => { | |
| try { db.pragma("wal_checkpoint(TRUNCATE)"); } catch {} | |
| try { db.close(); } catch {} | |
| console.log("[INFO] HTTP server closed, DB checkpointed and released."); | |
| process.exit(0); | |
| }); | |
| // Force exit after 10 s if lingering connections prevent a clean close | |
| setTimeout(() => { | |
| console.error("[ERROR] Forced exit after shutdown timeout."); | |
| process.exit(1); | |
| }, 10_000).unref(); | |
| } | |
| process.on("SIGTERM", () => shutdown("SIGTERM")); | |
| process.on("SIGINT", () => shutdown("SIGINT")); | |
| process.on("unhandledRejection", (reason) => { | |
| console.error("[ERROR] Unhandled rejection:", reason && reason.message ? reason.message : reason); | |
| }); | |