diff --git a/apps/server/drizzle/0001_destinations.sql b/apps/server/drizzle/0001_destinations.sql index 0b1ae1e..8e60a40 100644 --- a/apps/server/drizzle/0001_destinations.sql +++ b/apps/server/drizzle/0001_destinations.sql @@ -1,4 +1,4 @@ --- Chapter 10 — Destinations + Subscriptions migration. +-- Destinations + Subscriptions migration. -- -- 1. Create `destinations` (named CRUD list). -- 2. Backfill destinations from each distinct `subscriptions.destination_chat_id` diff --git a/apps/server/drizzle/0002_library_filters.sql b/apps/server/drizzle/0002_library_filters.sql index e964c09..25f79f0 100644 --- a/apps/server/drizzle/0002_library_filters.sql +++ b/apps/server/drizzle/0002_library_filters.sql @@ -1,4 +1,4 @@ --- Chapter 11 — Library filters + M:N join. +-- Library filters + M:N join. -- -- Reusable named filter rules attachable to many subscriptions. The -- evaluator UNIONs these with per-sub filters at evaluation time. diff --git a/apps/server/drizzle/0005_drop_tg_session.sql b/apps/server/drizzle/0005_drop_tg_session.sql index a4d6c57..c98210e 100644 --- a/apps/server/drizzle/0005_drop_tg_session.sql +++ b/apps/server/drizzle/0005_drop_tg_session.sql @@ -2,7 +2,7 @@ -- -- Originally created in 0000 as an optional encrypted-at-rest cache for the -- gramjs StringSession. The MVP design ended up using `TG_SESSION_STRING` --- from .env as the source of truth (see PLAN.md "Session storage"). The +-- from .env as the source of truth. The -- table was never written to from app code, and `TG_SESSION_ENCRYPTION_KEY` -- — the env knob that would have driven AES around it — was removed in -- favour of plain env-only storage. diff --git a/apps/server/drizzle/0007_filter_mode.sql b/apps/server/drizzle/0007_filter_mode.sql index a591277..cdba104 100644 --- a/apps/server/drizzle/0007_filter_mode.sql +++ b/apps/server/drizzle/0007_filter_mode.sql @@ -1,12 +1,11 @@ --- Per-filter include/exclude mode (Ch 14). +-- Per-filter include/exclude mode. -- -- 'include' is the legacy AND-pass default — every include filter must match -- for the message to forward. 'exclude' inverts the rule for that one row: -- a message is rejected when its rule matches. -- --- The CHECK keeps hand-written rows honest (mirrors the precedent set by --- `forward_log.status` and `library_filters.rule_type`). Existing rows --- back-fill to 'include' via the DEFAULT, preserving prior behavior. +-- The CHECK keeps hand-written rows honest. Existing rows back-fill to +-- 'include' via the DEFAULT, preserving prior behavior. ALTER TABLE `subscription_filters` ADD COLUMN `mode` TEXT NOT NULL DEFAULT 'include' CHECK (`mode` IN ('include', 'exclude')); --> statement-breakpoint diff --git a/apps/server/src/api/auth.ts b/apps/server/src/api/auth.ts index a467cf4..110cbe9 100644 --- a/apps/server/src/api/auth.ts +++ b/apps/server/src/api/auth.ts @@ -1,25 +1,4 @@ -/** - * Auth primitives for the API server. - * - * Single-user model. A successful `POST /api/auth/login` mints a fresh - * opaque random token (256-bit, base64url), stores it in `web_sessions` with - * an expiry, and sets a signed cookie carrying that token as the value. - * - * The signature exists for tamper detection; the *security* comes from the - * token being unguessable and existing in the DB. Logout deletes the row, - * so a leaked cookie can be revoked. Sliding refresh on each authed request - * keeps the user signed in while limiting damage from a long-unused - * captured cookie. - * - * `requireWebAuthEnv` parallels `tg/client.ts#requireTelegramEnv`. Both - * `WEB_PASSWORD` and `SESSION_SECRET` stay `.optional()` in `config.ts` - * so `pnpm db:migrate` and `pnpm tg:login` can run without them; only - * the server boot path requires them. - * - * `verifyPassword` hashes both inputs to a fixed-size SHA-256 digest - * before `timingSafeEqual` so password length isn't observable as a - * timing signal and the equal-length precondition is automatic. - */ +// Opaque 256-bit DB-backed token in a signed cookie (the token is the security; the signature is tamper detection); sliding refresh, server-side revoke. import { createHash, timingSafeEqual } from 'node:crypto'; import type { CookieSerializeOptions } from '@fastify/cookie'; import type { FastifyReply, FastifyRequest } from 'fastify'; @@ -35,7 +14,6 @@ export interface WebAuth { sessionSecret: string; } -/** Bot token + admin allowlist for the Telegram Web App sign-in route. */ export interface TelegramAuth { botToken: string; adminIds: string[]; @@ -58,10 +36,9 @@ export function requireWebAuthEnv(cfg: Config): WebAuth { } export function verifyPassword(plain: string, expected: string): boolean { - // Defensive: refuse to match empty/missing credentials even if upstream - // validation lets them through. SHA-256 of '' is a deterministic constant — - // without this guard, two empty strings would compare equal and grant entry. + // Reject empty: SHA-256('')==SHA-256('') would grant entry. if (!plain || !expected) return false; + // Hash both first so password length isn't a timing signal. const a = createHash('sha256').update(plain).digest(); const b = createHash('sha256').update(expected).digest(); return timingSafeEqual(a, b); @@ -87,11 +64,7 @@ export function clearedCookieOptions(isProd: boolean): CookieSerializeOptions { }; } -/** - * Read a session token from the incoming signed cookie, or null if absent / - * tampered. Does NOT verify against the DB — the auth pre-handler does that - * via `sessionStore.verifyAndRefresh`. - */ +// Unwraps the signed cookie only; the DB check is verifyAndRefresh. export function readSessionToken(request: FastifyRequest): string | null { const raw = request.cookies[SESSION_COOKIE_NAME]; if (!raw) return null; @@ -102,11 +75,6 @@ export function readSessionToken(request: FastifyRequest): string | null { return result.value; } -/** - * Build the Fastify pre-handler that enforces auth on a scoped route plugin. - * The factory takes the session store so the pre-handler closes over a real - * DB-backed lookup; tests build their own via `buildTestApp`. - */ export function makeRequireAuth(sessionStore: SessionStore) { return async function requireAuth(request: FastifyRequest, _reply: FastifyReply): Promise { const token = readSessionToken(request); diff --git a/apps/server/src/api/errorHandler.ts b/apps/server/src/api/errorHandler.ts index f905215..40b4ded 100644 --- a/apps/server/src/api/errorHandler.ts +++ b/apps/server/src/api/errorHandler.ts @@ -1,17 +1,4 @@ -/** - * Single Fastify error handler. - * - * All errors thrown from route handlers, pre-handlers, and Fastify itself - * pass through here. The mapping: - * - * - `ZodError` (route handler called `schema.parse(body)` and it threw) - * → 400 with `{ code: 'validation_error', issues }` - * - `AppError` subclasses (`UnauthorizedError`, `NotFoundError`, ...) - * → `err.statusCode` with `{ code, message, ...(issues if ValidationError) }` - * - anything else → 500 generic; original message NOT echoed (no leaking - * of internal details). Logged with `request.log.error({ err })` for - * diagnosability. - */ +// Single Fastify error handler: ZodError → 400, AppError → its statusCode, anything else → generic 500 with the original message never echoed. import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify'; import { ZodError } from 'zod'; import type { ErrorResponse } from '@tg-feed/shared'; @@ -48,10 +35,7 @@ export function makeErrorHandler(logger: Logger): ApiErrorHandler { return; } - // Fastify built-in errors (rate-limit, body too large, malformed JSON, - // etc.) carry an HTTP statusCode + a stable code. Pass 4xx through with - // their own message — they're already client-safe. 5xx still gets the - // generic 500 path below to avoid leaking internals. + // Fastify built-in 4xx (rate-limit, body too large, bad JSON) are client-safe; pass through. 5xx falls to the generic path. if (typeof err.statusCode === 'number' && err.statusCode >= 400 && err.statusCode < 500) { const body: ErrorResponse = { error: { code: err.code ?? 'client_error', message: err.message }, diff --git a/apps/server/src/api/routes/_params.ts b/apps/server/src/api/routes/_params.ts index 10534ad..2166e03 100644 --- a/apps/server/src/api/routes/_params.ts +++ b/apps/server/src/api/routes/_params.ts @@ -1,7 +1,4 @@ -/** - * Shared route param schemas. The single-`id` shape is used by every CRUD - * route; compound shapes live in their own route files. - */ +// Shared by every CRUD route; compound param shapes live in their own route files. import { z } from 'zod'; export const idParamsSchema = z.object({ diff --git a/apps/server/src/api/routes/auth.ts b/apps/server/src/api/routes/auth.ts index 5f27d1e..ff5fae3 100644 --- a/apps/server/src/api/routes/auth.ts +++ b/apps/server/src/api/routes/auth.ts @@ -1,21 +1,3 @@ -/** - * Auth routes. - * - * Split between two registration functions — `registerLoginRoute` for - * the unauthenticated `POST /api/auth/login`, and `registerAuthRoutes` - * for the authed-scope `POST /api/auth/logout` and `GET /api/me`. The - * factory wires each into the appropriate Fastify scope. - * - * Login mints an opaque random token (256-bit, base64url) via the session - * store and sets it as the signed cookie value. Logout deletes the row, so - * a leaked cookie is revoked on the server — not just cleared on the - * client. The login-route rate limit only counts failed attempts so a - * legitimate user mid-debugging doesn't burn the brute-force bucket. - * - * `registerTelegramAuthRoute` is the Mini App counterpart: it verifies a - * Telegram `initData` payload and, if the embedded user is on the admin - * allowlist, mints a session via the same store as the password login. - */ import type { FastifyInstance, FastifyRequest } from 'fastify'; import { loginRequestSchema, @@ -48,11 +30,7 @@ export function registerLoginRoute(app: FastifyInstance, deps: RegisterLoginDeps app.post( '/auth/login', { - // Rate-limit brute-force on the single-user password. Counted per IP - // (trustProxy is enabled at the factory level so we key on the real - // client IP behind a reverse proxy). `skipOnError: true` and the - // `skipSuccessfulRequests`-equivalent below keep the bucket reserved - // for failed attempts only. + // Brute-force limit on the password, per real client IP; reset on success below so only failures count. config: { rateLimit: { max: 10, @@ -69,15 +47,12 @@ export function registerLoginRoute(app: FastifyInstance, deps: RegisterLoginDeps ); throw new UnauthorizedError('invalid password'); } - // Successful login — reset the rate-limit bucket for this IP so a - // legitimate operator who logged in mid-debugging doesn't get throttled - // moments later. `request.rateLimit()` is provided by @fastify/rate-limit - // when the route's `config.rateLimit` is set. + // Reset the rate-limit bucket for this IP on success so only failures count. try { const rl = (request as FastifyRequest & { rateLimit?: () => Promise }).rateLimit; if (typeof rl === 'function') await rl(); } catch { - // Plugin not loaded (test path with custom server). Safe to ignore. + // Plugin not loaded (test path with custom server). } const token = deps.sessionStore.create(); reply.setCookie(SESSION_COOKIE_NAME, token, signedCookieOptions(deps.isProd)); @@ -91,11 +66,7 @@ export function registerLoginRoute(app: FastifyInstance, deps: RegisterLoginDeps const TELEGRAM_INITDATA_MAX_AGE_SEC = 5 * 60; export interface RegisterTelegramAuthDeps { - /** - * Live getter for the bot auth config (token + admin allowlist), resolved - * DB-over-env. Read per request so a config change made via Settings → Bot - * applies without a restart. Yields null when the bot isn't configured. - */ + // read per request (DB-over-env) so a Settings → Bot change applies without a restart; null when unconfigured getTelegramAuth: () => TelegramAuth | null; isProd: boolean; logger: Logger; @@ -154,7 +125,7 @@ export function registerTelegramAuthRoute( const rl = (request as FastifyRequest & { rateLimit?: () => Promise }).rateLimit; if (typeof rl === 'function') await rl(); } catch { - // Plugin not loaded (test path with custom server). Safe to ignore. + // Plugin not loaded (test path with custom server). } const token = deps.sessionStore.create(); @@ -173,9 +144,7 @@ export interface RegisterAuthDeps { export function registerAuthRoutes(app: FastifyInstance, deps: RegisterAuthDeps): void { app.post('/auth/logout', async (request, reply) => { - // Read the token from the signed cookie before clearing it; deleting the - // server-side row is what actually invalidates the session (the cookie - // bytes are otherwise still valid until they expire client-side). + // Deleting the server-side row is what invalidates the session; the cookie bytes stay valid until expiry. const token = readSessionToken(request); if (token) deps.sessionStore.revoke(token); reply.clearCookie(SESSION_COOKIE_NAME, clearedCookieOptions(deps.isProd)); diff --git a/apps/server/src/api/routes/botConfig.ts b/apps/server/src/api/routes/botConfig.ts index dfb9304..106b918 100644 --- a/apps/server/src/api/routes/botConfig.ts +++ b/apps/server/src/api/routes/botConfig.ts @@ -1,22 +1,4 @@ -/** - * Bot configuration routes — Settings → Bot. - * - * GET /api/config/bot → masked state (sources, key status, botRunning) - * PUT /api/config/bot → partial save (token / admins / publicUrl) - * DELETE /api/config/bot → clear the DB row, env fallback resumes - * POST /api/config/bot/resolve-admin → resolve @username → user (preview, no write) - * - * The token is encrypted with `TG_SESSION_ENCRYPTION_KEY` before it touches - * the DB, so a token write refuses with 412 when no key is configured (admins - * and public URL are not gated). After any change `reloadBot()` live-swaps the - * long-polling bot so the new config takes effect without a restart. The GET - * payload is masked — it never returns the token, only whether one exists and - * from where. - * - * `resolve-admin` turns a `@username` into a user id via the universal chat - * resolver (rejecting channels/groups); the resolved `{ id, displayName, - * username }` is then saved into `admins`. - */ +// Token encrypted with TG_SESSION_ENCRYPTION_KEY (token write 412s without a key); GET is masked, never returns the token. import type { FastifyInstance } from 'fastify'; import { type BotAdmin, @@ -36,22 +18,16 @@ import { BOT_TOKEN_AAD, encryptSecret } from '../../lib/sessionCrypto.js'; import type { ChatResolver } from '../../tg/chatResolver.js'; export interface RegisterBotConfigRoutesDeps { - /** Parsed env config — supplies the env fallbacks behind the masked view. */ + // Supplies the env fallbacks behind the masked view. cfg: Config; db: Db; - /** Returns the configured 32-byte key, or null when unset. */ + // 32-byte key, or null when unset. getEncryptionKey?: () => Buffer | null; - /** Live-swaps the bot so a saved config takes effect immediately. */ reloadBot?: () => Promise; - /** Whether the long-polling bot is currently running. */ getBotRunning?: () => boolean; - /** - * Lazy lookup for the universal chat resolver — reused by `resolve-admin` - * to turn a `@username` into a user id. Read per request (populated after - * Telegram init). When absent, the route returns 503. - */ + // Lazy (populated after Telegram init), read per request; absent → 503. getChatResolver?: () => ChatResolver | undefined; - /** Telegram lifecycle — picks the right 503 (`initializing` vs `unavailable`). */ + // Picks the right 503: `initializing` vs `unavailable`. getTelegramStatus: () => TelegramStatus; } @@ -112,8 +88,7 @@ export function registerBotConfigRoutes( writeBotConfig(db, patch); if (reloadBot) { - // Live-swap failures are logged inside `reloadBot`; the row is already - // saved, so the next restart picks it up. Don't fail the save. + // Row is already saved; don't fail the save on a live-swap error (logged in reloadBot). await reloadBot().catch(() => {}); } return info(); @@ -134,8 +109,7 @@ export function registerBotConfigRoutes( } const body = resolveBotAdminRequestSchema.parse(request.body); const resolved = await chatResolver(body.query); - // Admins are *users* — a bare positive id. Channels/supergroups resolve to - // a `-100…` id and unjoined private invites to null; reject both. + // Admins are users (bare positive id); reject `-100…` channels/groups and null invites. if (!resolved.chatId || resolved.chatId.startsWith('-')) { throw new AppError( 422, diff --git a/apps/server/src/api/routes/destinations.ts b/apps/server/src/api/routes/destinations.ts index efc0b5a..86d919d 100644 --- a/apps/server/src/api/routes/destinations.ts +++ b/apps/server/src/api/routes/destinations.ts @@ -1,10 +1,4 @@ -/** - * Destinations CRUD routes (Chapter 10). - * - * Destinations are the named CRUD list subscriptions pick from. Delete is - * pre-checked for usage in this layer; the FK is `ON DELETE RESTRICT` as a - * belt-and-suspenders DB-level guard. - */ +// Delete is usage-checked here; the FK is also ON DELETE RESTRICT as a DB-level guard. import { asc, count, eq } from 'drizzle-orm'; import type { FastifyInstance } from 'fastify'; import { @@ -49,36 +43,12 @@ interface DestinationRow { export interface RegisterDestinationDeps { db: Db; - /** - * Live status getter used to distinguish "Telegram is starting up — try - * again in a moment" from "Telegram is not configured at all". Drives - * the error code returned when a tg-dep getter yields undefined. - */ getTelegramStatus: () => TelegramStatus; - /** - * Lazy lookup for the universal "paste-anything" resolver — backs - * `POST /destinations/resolve`. Read per request because the boot path - * fills it asynchronously after `app.listen()`. When undefined the - * route returns 503 with `telegram_initializing` (during the boot - * window) or `telegram_unavailable` (steady-state disconnected). - */ + // Lazy getters: filled after app.listen(), so read per request. Undefined → 503. getChatResolver?: () => ChatResolver | undefined; - /** - * Lazy lookup for the `messages.ImportChatInvite` wrapper invoked from - * `POST /destinations` when the body carries `inviteHash`. Same - * lifecycle as `getChatResolver`. - */ getImportInvite?: () => ImportInviteFn | undefined; - /** - * Lazy lookup for the best-effort profile-photo fetcher invoked after - * the row is inserted. When undefined the row's `iconDataUrl` stays - * null and the access monitor's lazy backfill catches it later. - */ + // Undefined leaves iconDataUrl null; the access monitor backfills it later. getFetchProfilePhoto?: () => ProfilePhotoFetcher | undefined; - /** - * Lazy lookup for the forum-topic lister backing `POST /destinations/topics`. - * Same lifecycle as `getChatResolver`. When undefined the route returns 503. - */ getListForumTopics?: () => ForumTopicLister | undefined; } @@ -146,8 +116,7 @@ export function registerDestinationRoutes( throw new UpstreamError('failed to join via invite link', 'invite_join_failed'); } chatId = join.chatId; - // We just successfully joined — record access ok now so the access - // monitor's first sweep doesn't briefly mis-report. + // Just joined — record access ok so the monitor's first sweep doesn't mis-report. initialAccessStatus = 'ok'; accessCheckedAt = new Date(); } else { @@ -168,9 +137,7 @@ export function registerDestinationRoutes( .returning() .all(); const row = inserted[0]!; - // Best-effort: fetch the profile photo and stamp it on the row. The - // fetcher already swallows errors and returns null on failure, so we - // don't need a try/catch — the response just goes out without an icon. + // Best-effort icon: the fetcher swallows errors and returns null. let iconDataUrl: string | null = row.iconDataUrl; const fetchProfilePhoto = getFetchProfilePhoto?.(); if (fetchProfilePhoto) { @@ -244,8 +211,6 @@ export function registerDestinationRoutes( } function listDestinations(db: Db): DestinationRow[] { - // One query: destinations LEFT JOIN subscriptions GROUP BY destination, - // counting subscription references. const rows = db .select({ id: destinations.id, diff --git a/apps/server/src/api/routes/filters.ts b/apps/server/src/api/routes/filters.ts index 341fcf3..3c66bda 100644 --- a/apps/server/src/api/routes/filters.ts +++ b/apps/server/src/api/routes/filters.ts @@ -1,20 +1,5 @@ -/** - * Filter routes — catalog + per-subscription filter CRUD. - * - * The catalog (`GET /api/filters/catalog`) reflects what's actually - * registered in the live filter registry — not a hardcoded list. Adding - * a rule = drop a file + register it in `createDefaultRegistry()`; the - * catalog endpoint picks it up on next restart. - * - * Filter PATCH validates `params` against the EXISTING row's `ruleType` - * (rule type itself is immutable post-creation; to switch, delete and - * re-add). The shared schema's `updateSubscriptionFilterRequestSchema` - * keeps `params` loose (`z.record`); strict per-rule validation happens - * here in the handler. - * - * Cross-sub access (filter id belongs to a different sub than the URL - * names) returns 404 — prevents id-guessing across subscriptions. - */ +// ruleType is immutable; PATCH validates params against the existing row's ruleType. +// Cross-sub filter ids return 404 to block id-guessing across subscriptions. import { and, asc, eq } from 'drizzle-orm'; import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; diff --git a/apps/server/src/api/routes/forwardLog.ts b/apps/server/src/api/routes/forwardLog.ts index 7b4f049..63d140e 100644 --- a/apps/server/src/api/routes/forwardLog.ts +++ b/apps/server/src/api/routes/forwardLog.ts @@ -1,24 +1,5 @@ -/** - * Forward log route — paginated activity feed source. - * - * Two LEFT JOINs: `forward_log → subscriptions → destinations`. The - * subscription FK is `ON DELETE SET NULL`, so historic rows for deleted - * subscriptions survive with `subscriptionId`/`subscriptionTitle`/ - * `sourceHandle`/`destinationName` all `null`. The destination join hops - * through the subscription's current `destinationId` — `forward_log` - * doesn't store the destination, so a re-pointed subscription will surface - * its current destination on historical rows (matches the prior - * client-side enrichment behavior). - * - * Pagination uses the `limit + 1` trick: fetch one row past the asked - * `limit`, then if the extra row is present `nextOffset = offset + limit`, - * else `nextOffset = null`. Avoids a separate `COUNT(*)` query while still - * answering "is there more?". - * - * Sort is `desc(createdAt), desc(id)` — albums write N rows in one - * transaction sharing a `createdAt` ms; sorting by `id` as a tiebreaker - * makes pagination deterministic across calls. - */ +// Paginated activity feed. Sub FK is ON DELETE SET NULL, so rows for deleted subs survive with null titles; the dest join hops through the sub's current destinationId, so re-pointing surfaces the new dest on old rows. +// Pagination is the limit+1 trick (one row past limit → nextOffset, else null), avoiding a COUNT(*). Sort desc(createdAt),desc(id) so albums sharing a createdAt ms paginate deterministically. import { desc, eq, sql } from 'drizzle-orm'; import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; @@ -55,10 +36,7 @@ export function registerForwardLogRoutes(app: FastifyInstance, deps: RegisterFor status: forwardLog.status, error: forwardLog.error, createdAt: forwardLog.createdAt, - // Surface presence-only here; the raw JSON itself is fetched on - // demand via the dedicated /:id/raw route so list responses stay - // small. SQLite renders the boolean as `1`/`0` which the cast - // below normalises for the wire format. + // Presence only (raw JSON is fetched via /:id/raw); SQLite returns 1/0. hasRawMessage: sql`(${forwardLog.rawMessage} IS NOT NULL)`, }) .from(forwardLog) @@ -90,10 +68,7 @@ export function registerForwardLogRoutes(app: FastifyInstance, deps: RegisterFor return response; }); - // Deferred-fetch endpoint for the raw JSON snapshot. Kept off the list - // route so a page load doesn't drag tens of KB per row over the wire — - // the JSON is only useful when the user clicks "View raw" on a specific - // entry, and an album's array is identical across its N rows anyway. + // Deferred raw-JSON fetch, off the list route so page loads don't drag tens of KB per row. app.get('/forward-log/:id/raw', async (request, reply) => { const { id } = rawParamsSchema.parse(request.params); const row = db diff --git a/apps/server/src/api/routes/health.test.ts b/apps/server/src/api/routes/health.test.ts new file mode 100644 index 0000000..b0239af --- /dev/null +++ b/apps/server/src/api/routes/health.test.ts @@ -0,0 +1,28 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { buildTestApp, type TestApp } from '../testing.js'; + +describe('GET /api/health', () => { + let testApp: TestApp; + beforeEach(async () => { + testApp = await buildTestApp(); + }); + afterEach(async () => { + await testApp.close(); + }); + + it('returns 200 + { status: ok } without authentication', async () => { + const res = await testApp.app.inject({ method: 'GET', url: '/api/health' }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ status: 'ok' }); + // Public route — it must not mint or require a session. + expect(res.headers['set-cookie']).toBeUndefined(); + }); + + it('returns 503 + { status: error } when the database is unreachable', async () => { + // Drop the underlying SQLite connection so the `select 1` ping throws. + testApp.dbHandle.close(); + const res = await testApp.app.inject({ method: 'GET', url: '/api/health' }); + expect(res.statusCode).toBe(503); + expect(res.json()).toEqual({ status: 'error' }); + }); +}); diff --git a/apps/server/src/api/routes/health.ts b/apps/server/src/api/routes/health.ts new file mode 100644 index 0000000..f84142f --- /dev/null +++ b/apps/server/src/api/routes/health.ts @@ -0,0 +1,22 @@ +// Unauthenticated on purpose (container/proxy probe, not the web client); `select 1` gives a real 503 not-ready signal. +import { sql } from 'drizzle-orm'; +import type { FastifyInstance } from 'fastify'; +import type { Db } from '../../db/client.js'; +import type { Logger } from '../../lib/logger.js'; + +export interface RegisterHealthDeps { + db: Db; + logger: Logger; +} + +export function registerHealthRoute(app: FastifyInstance, deps: RegisterHealthDeps): void { + app.get('/health', async (_request, reply) => { + try { + deps.db.get(sql`select 1`); + } catch (err) { + deps.logger.error({ err }, 'health: database ping failed'); + return reply.status(503).send({ status: 'error' }); + } + return { status: 'ok' }; + }); +} diff --git a/apps/server/src/api/routes/libraryFilters.test.ts b/apps/server/src/api/routes/libraryFilters.test.ts index 09ff3e4..e214e2c 100644 --- a/apps/server/src/api/routes/libraryFilters.test.ts +++ b/apps/server/src/api/routes/libraryFilters.test.ts @@ -74,6 +74,20 @@ describe('library filter routes', () => { expect(res.statusCode).toBe(400); }); + it('POST /api/library-filters rejects an uncompilable text-regex pattern', async () => { + const res = await testApp.app.inject({ + method: 'POST', + url: '/api/library-filters', + headers: { cookie }, + payload: { + name: 'bad regex', + ruleType: 'text-regex', + params: { pattern: '(', flags: '' }, + }, + }); + expect(res.statusCode).toBe(400); + }); + it('GET /api/library-filters carries usageCount via JOIN', async () => { const [lib] = testApp.db .insert(libraryFilters) diff --git a/apps/server/src/api/routes/libraryFilters.ts b/apps/server/src/api/routes/libraryFilters.ts index b9d1fb7..a0841a4 100644 --- a/apps/server/src/api/routes/libraryFilters.ts +++ b/apps/server/src/api/routes/libraryFilters.ts @@ -1,16 +1,3 @@ -/** - * Library filter CRUD routes (Chapter 11). - * - * Reusable named filter rules attachable to many subscriptions via the - * `subscription_library_filters` join table. Delete is pre-checked for - * usage with a 409 `library_filter_in_use` error; the FK is `ON DELETE - * RESTRICT` as a belt-and-suspenders DB-level guard. - * - * `ruleType` is immutable post-creation (matches the per-sub filter - * convention from Ch 7); to change it, delete and re-add. PATCH bodies - * with `params` are revalidated against the existing row's `ruleType` - * via the matching `filterRuleParamsSchemas` entry. - */ import { asc, count, eq } from 'drizzle-orm'; import type { FastifyInstance } from 'fastify'; import { @@ -25,6 +12,7 @@ import type { Db } from '../../db/client.js'; import { libraryFilters, subscriptionLibraryFilters } from '../../db/schema.js'; import { countWhere } from '../../lib/dbHelpers.js'; import { ConflictError, NotFoundError, ValidationError } from '../../lib/errors.js'; +import { assertFilterParamsCompilable } from '../../filters/validateParams.js'; import { idParamsSchema } from './_params.js'; interface LibraryFilterRow { @@ -55,6 +43,7 @@ export function registerLibraryFilterRoutes( app.post('/library-filters', async (request, reply) => { const body = createLibraryFilterRequestSchema.parse(request.body); + assertFilterParamsCompilable(body.ruleType, body.params); const inserted = db .insert(libraryFilters) .values({ @@ -81,8 +70,7 @@ export function registerLibraryFilterRoutes( app.patch('/library-filters/:id', async (request) => { const { id } = idParamsSchema.parse(request.params); const body = updateLibraryFilterRequestSchema.parse(request.body); - // Need existing.ruleType to validate body.params against the matching - // params schema — ruleType is immutable post-creation. + // ruleType is immutable; needed to pick the params schema for validation. const existing = db.select().from(libraryFilters).where(eq(libraryFilters.id, id)).get(); if (!existing) throw new NotFoundError('library filter'); @@ -94,6 +82,7 @@ export function registerLibraryFilterRoutes( throw new ValidationError('invalid params for rule type', result.error.issues); } validatedParams = result.data; + assertFilterParamsCompilable(existing.ruleType, validatedParams); } const updated = db diff --git a/apps/server/src/api/routes/settings.ts b/apps/server/src/api/routes/settings.ts index 67d5ec5..0483e90 100644 --- a/apps/server/src/api/routes/settings.ts +++ b/apps/server/src/api/routes/settings.ts @@ -1,18 +1,4 @@ -/** - * Settings routes. - * - * Wire format is the flat `{ delayMs, albumDebounceMs }`; internally this - * maps to the `app_settings` row keyed `'global'` whose `value` JSON is the - * same shape. PUT accepts a partial body (either field optional) and merges - * into the existing row, so the UI can edit one knob at a time without - * round-tripping the other. - * - * `GET` never 404s. If the row is missing or malformed, fall back to the - * documented defaults (8 s throttle, 2 s album window) — same defensive - * read the forwarding pipeline does in `getGlobalDelayMs` / - * `getAlbumDebounceMs`. Settings always exist with sane defaults from the - * client's perspective. - */ +// GET never 404s: a missing/malformed row falls back to the same defaults the forwarding pipeline uses. import type { FastifyInstance } from 'fastify'; import { updateSettingsRequestSchema, type SettingsDto } from '@tg-feed/shared'; import type { Db } from '../../db/client.js'; @@ -49,10 +35,7 @@ export function registerSettingsRoutes(app: FastifyInstance, deps: RegisterSetti app.put('/settings', async (request) => { const body = updateSettingsRequestSchema.parse(request.body); - // Merge: read current values (with defaults for missing/malformed rows), - // overlay any fields the client supplied, write the unified row back. The - // throttle and digest knobs share this one row, so the merge keeps a - // caller that only knows about one of them from clobbering the others. + // Throttle and digest knobs share one row; merge so a partial body doesn't clobber the others. const digest = getStatsDigestConfig(db); const merged = { delayMs: body.delayMs ?? getGlobalDelayMs(db), diff --git a/apps/server/src/api/routes/stream.ts b/apps/server/src/api/routes/stream.ts index 8749976..e5b29be 100644 --- a/apps/server/src/api/routes/stream.ts +++ b/apps/server/src/api/routes/stream.ts @@ -1,42 +1,7 @@ -/** - * SSE stream — `GET /api/stream`. - * - * One persistent text/event-stream per authenticated client. The route - * subscribes to the in-process `EventBus` and pipes every emitted event to - * the socket as a single SSE frame; a `: heartbeat` comment frame goes out - * every 25 s so reverse proxies and Telegram-paranoid networks don't decide - * the idle connection is dead. - * - * Hardening (added during the security pass): - * - **Per-session connection cap.** A single authed cookie can hold at - * most `MAX_CONNECTIONS_PER_TOKEN` streams open at once; further - * attempts get a clean 429. Without the cap a stolen cookie or a - * misbehaving extension could open thousands of streams and pin event- - * loop time on every bus emit (the bus walks every listener O(N)). - * - **Backpressure-aware writes.** `socket.write` returns false when the - * OS send buffer fills; if we ignore that, a slow consumer accumulates - * unbounded data in node's internal write buffer. We drop events while - * backpressured and resume on the next `'drain'`. This loses fidelity - * for very slow clients (intentional) but never OOMs the process. - * - * Implementation notes: - * - `reply.hijack()` tells Fastify "I'm taking over the response — don't - * try to call .send() or .end() yourself." After hijack the handler - * owns `reply.raw` (the underlying `http.ServerResponse`). We never - * return data via `reply.send`; everything goes via `socket.write`. - * - The handler resolves immediately after wiring listeners. Without - * `hijack` Fastify would call `.end()` on return; with it the - * connection stays open until the client disconnects. - * - `request.raw.once('close')` fires whether the client gracefully - * closes the EventSource, the network drops, or the test aborts via - * `AbortController`. That's where the heartbeat interval is cleared - * and the bus listener is unsubscribed — no per-client leak. - * - `socket.writableEnded` guards the listener and the heartbeat against - * a write-after-close race. The actual safety net is the per-listener - * try/catch inside the bus, which logs and swallows any thrown errors. - * - `X-Accel-Buffering: no` disables nginx response buffering for SSE - * (`Cache-Control: no-cache, no-transform` covers most other proxies). - */ +// GET /api/stream: one text/event-stream per client piping every EventBus event; `: heartbeat` every 25s so proxies don't reap idle connections. +// Connection cap (429 past MAX_CONNECTIONS_PER_TOKEN): a stolen cookie could otherwise open thousands of streams and pin the O(N) bus walk. +// Backpressure: drop events while socket.write is false, resume on 'drain' — loses fidelity for slow clients but never OOMs. +// reply.hijack() stops Fastify calling .end() on return so the connection stays open until close. import type { FastifyInstance } from 'fastify'; import type { EventBus } from '../../events/bus.js'; import { readSessionToken } from '../auth.js'; @@ -46,21 +11,13 @@ const MAX_CONNECTIONS_PER_TOKEN = 4; export interface RegisterStreamDeps { bus: EventBus; - /** - * Override the heartbeat interval for tests. Real timers + a small value - * (e.g. 50 ms) is more reliable than fake-timer-driven heartbeat tests - * because `light-my-request` chunk delivery is `process.nextTick`-driven, - * which races with `vi.advanceTimersByTimeAsync`. - */ heartbeatMs?: number; } export function registerStreamRoutes(app: FastifyInstance, deps: RegisterStreamDeps): void { const { bus } = deps; const heartbeatMs = deps.heartbeatMs ?? SSE_HEARTBEAT_MS; - // Per-token open-connection counter. Lives in module scope so each Fastify - // factory instance gets its own map (tests build fresh instances per - // `beforeEach`, so no cross-test bleed). + // Scoped per factory instance so fresh test instances don't bleed across each other. const openByToken = new Map(); app.get('/stream', (request, reply) => { @@ -85,13 +42,9 @@ export function registerStreamRoutes(app: FastifyInstance, deps: RegisterStreamD Connection: 'keep-alive', 'X-Accel-Buffering': 'no', }); - // Initial flush so the client sees the stream is live before the first - // real event (which may be many seconds away). + // Initial flush so the client sees the stream is live before the first real event. socket.write(': open\n\n'); - // Backpressure: when `socket.write` returns false, the kernel send - // buffer is full and node's internal `writable` buffer would start - // growing without bound. Drop events until the socket emits `'drain'`. let backpressured = false; socket.on('drain', () => { backpressured = false; diff --git a/apps/server/src/api/routes/subscriptions.ts b/apps/server/src/api/routes/subscriptions.ts index 96319a6..f7a06c0 100644 --- a/apps/server/src/api/routes/subscriptions.ts +++ b/apps/server/src/api/routes/subscriptions.ts @@ -1,37 +1,3 @@ -/** - * Subscription CRUD + resolve routes. - * - * `sourceChatId` and `handle` are immutable post-creation (matches the - * shared schema's `updateSubscriptionRequestSchema`); to change the source - * channel, delete and recreate. - * - * DELETE returns 204 No Content (REST convention). Cascades down to - * `subscription_filters` and `subscription_library_filters` (Ch 11); - * `forward_log` rows survive with `subscriptionId` set to NULL via - * `ON DELETE SET NULL` (Ch 2). - * - * The resolve endpoint is preview-only — it never writes to the DB. The - * UI submits the resolved fields back to POST /subscriptions to commit. - * Routes that need the gramjs entity resolver gate on its presence and - * return 503 — `telegram_initializing` while the Telegram subsystem is - * still bootstrapping (background init in `apps/server/src/index.ts`), - * `telegram_unavailable` once it's settled in a disconnected state (test - * mode, missing Telegram env). The web UI keys on the code: the first is - * a transient retry; the second points the operator at configuration. - * - * Library filter attachments are exposed two ways: - * - bulk-replace via `libraryFilterIds` on POST/PATCH /subscriptions[/:id] - * (used by the SubSheet's checkbox group) - * - granular at POST /:id/library-filters and DELETE /:id/library-filters/:libId - * (used by the per-sub Filters view's `+` and `X` buttons) - * - * Inline (private) filters work the same way: bulk-replace via - * `inlineFilters` on POST/PATCH /subscriptions[/:id], plus the granular - * CRUD at /:id/filters[/:filterId] in `filters.ts`. Both library and - * inline writes happen inside a single `db.transaction(...)` so a partial - * failure can't leave the subscription with one set replaced and the other - * untouched. - */ import { and, asc, eq, inArray, sql } from 'drizzle-orm'; import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; @@ -56,6 +22,7 @@ import { subscriptions, } from '../../db/schema.js'; import type { EventBus } from '../../events/bus.js'; +import { assertFilterParamsCompilable } from '../../filters/validateParams.js'; import { InternalError, NotFoundError, @@ -69,9 +36,6 @@ import type { JoinChannelFn } from '../../tg/joinChannel.js'; import type { ProfilePhotoFetcher } from '../../tg/profilePhoto.js'; import { idParamsSchema } from './_params.js'; -// `db.transaction(cb)` passes `cb` a tx handle whose query interface matches -// `Db`'s, but its TS type is the more specific `SQLiteTransaction`. Helpers -// that need to run under either branch take this union. type DbOrTx = Db | Parameters[0]>[0]; const libraryFilterAttachmentParamsSchema = z.object({ @@ -82,38 +46,14 @@ const libraryFilterAttachmentParamsSchema = z.object({ export interface RegisterSubscriptionDeps { db: Db; bus: EventBus; - /** - * Live status getter used to distinguish "Telegram is starting up — try - * again in a moment" from "Telegram is not configured at all". Drives - * the error code returned when a tg-dep getter yields undefined. - */ + // Distinguishes "starting up" from "not configured" for the tg-dep error code. getTelegramStatus: () => TelegramStatus; - /** - * Lazy lookup for the universal "paste-anything" resolver — backs - * `POST /subscriptions/resolve`. Read per request because the boot path - * fills it asynchronously after `app.listen()`. Accepts any of: - * `@username`, `t.me/username`, `t.me/+HASH`, `+HASH`, or numeric chat id. - */ + // Lazy getters: boot fills them asynchronously after `app.listen()`, so read per request. getChatResolver?: () => ChatResolver | undefined; - /** - * Lazy lookup for the `messages.ImportChatInvite` wrapper invoked from - * `POST /subscriptions` when the body carries `inviteHash`. Same - * lifecycle as `getChatResolver`. - */ getImportInvite?: () => ImportInviteFn | undefined; - /** - * Lazy lookup for the auto-join helper invoked after a successful - * POST /subscriptions insert (chatId path). When the getter yields - * undefined the row keeps the default 'ok' status and the access - * monitor's first sweep corrects it. The `inviteHash` path bypasses - * this — `importInvite` already joined. - */ + // undefined => keep default status; access monitor's first sweep corrects it. getJoinChannel?: () => JoinChannelFn | undefined; - /** - * Lazy lookup for the best-effort profile-photo fetcher invoked after - * the row is inserted. When undefined the row's `iconDataUrl` stays - * null and the access monitor's lazy backfill catches it later. - */ + // undefined => iconDataUrl stays null; access monitor backfills later. getFetchProfilePhoto?: () => ProfilePhotoFetcher | undefined; } @@ -156,9 +96,7 @@ export function registerSubscriptionRoutes( app.post('/subscriptions', async (request, reply) => { const body = createSubscriptionRequestSchema.parse(request.body); - // `destinationId` is now optional/nullable — only validate when provided. - // FK SET NULL would coalesce a bad id silently, so we still 400 up-front - // for a missing one to surface a clear UI message. + // FK SET NULL would coalesce a bad id silently; 400 up-front for a clear UI message. if (body.destinationId !== undefined && body.destinationId !== null) { const dest = db .select() @@ -170,13 +108,7 @@ export function registerSubscriptionRoutes( if (body.libraryFilterIds && body.libraryFilterIds.length > 0) { assertLibraryFiltersExist(db, body.libraryFilterIds); } - // Source==destination guard. A subscription whose source chat id is also - // a destination chat id would forward into itself: the bot's own forwards - // would (via the history poller, which bypasses the `incoming:true` - // filter on the live listener) be re-ingested and forwarded again on the - // next sweep, ad infinitum. Reject up-front; safe to skip when the body - // uses an `inviteHash` since we don't know the chat id until after join, - // and the post-join shape is checked below. + // source==destination = forwarding loop (poller re-ingests own forwards); inviteHash re-checked post-join below. if (body.sourceChatId !== undefined) { const conflict = db .select({ id: destinations.id }) @@ -190,10 +122,7 @@ export function registerSubscriptionRoutes( } } - // Resolve the source chat id from either the resolved id (existing flow) - // or by joining via invite hash (new flow). The hash path is destructive - // and runs *before* the insert so a join failure doesn't leave a - // half-baked row behind. + // Invite-hash join is destructive; run it before the insert so a failure leaves no half-baked row. let sourceChatId: string; let preJoined = false; if (body.inviteHash) { @@ -210,11 +139,7 @@ export function registerSubscriptionRoutes( } else { sourceChatId = body.sourceChatId!; } - // Re-check post-resolve in case the invite path landed on a chat that is - // already a destination. (Same loop-prevention rationale as above.) We - // intentionally don't undo the join — at this point the bot has joined - // the channel; the operator just can't use it as a subscription with - // that destination configuration. + // Re-check post-resolve for the loop guard; the join is intentionally not undone. { const conflict = db .select({ id: destinations.id }) @@ -249,12 +174,8 @@ export function registerSubscriptionRoutes( } return row.id; }); - // For the chatId path: auto-join the source channel so the userbot - // starts receiving its events. Done outside the transaction (gramjs - // network I/O can't be atomic with SQLite) and before the SSE emit so - // the DTO read by the UI carries the post-join status. + // Outside the tx (gramjs I/O isn't atomic with SQLite) and before the SSE emit so the DTO carries post-join status. if (preJoined) { - // Already joined via importInvite; just stamp the access check. db.update(subscriptions) .set({ sourceAccessStatus: 'ok', sourceAccessCheckedAt: new Date() }) .where(eq(subscriptions.id, newId)) @@ -269,10 +190,7 @@ export function registerSubscriptionRoutes( .run(); } } - // Best-effort profile photo fetch. Same reasoning as on the - // destination create path: the fetcher swallows errors and returns - // null, so failure means the row stays icon-less until the access - // monitor's lazy backfill kicks in. + // Best-effort; fetcher swallows errors, so the row stays icon-less until backfill. const fetchProfilePhoto = getFetchProfilePhoto?.(); if (fetchProfilePhoto) { const iconDataUrl = await fetchProfilePhoto(sourceChatId); @@ -334,7 +252,6 @@ export function registerSubscriptionRoutes( return null; }); - /** Granular attach — backs the per-sub view's `+ library filter` action. */ app.post('/subscriptions/:id/library-filters', async (request, reply) => { const { id } = idParamsSchema.parse(request.params); const body = attachLibraryFilterRequestSchema.parse(request.body); @@ -361,7 +278,6 @@ export function registerSubscriptionRoutes( return dto; }); - /** Granular detach — the design's "X" button on attached library chips. */ app.delete('/subscriptions/:id/library-filters/:libId', async (request, reply) => { const { id, libId } = libraryFilterAttachmentParamsSchema.parse(request.params); const result = db @@ -429,10 +345,7 @@ export function replaceLibraryFilterAttachments( .run(); } -// Bulk-replace the private inline filter set for a subscription. The input -// array has already been zod-validated as a discriminated union, so each -// element's `params` is guaranteed to match its `ruleType`. Empty array = -// drop all (matches `replaceLibraryFilterAttachments` semantics). +// Empty array = drop all. Inputs are pre-validated as a discriminated union (params match ruleType). export function replaceInlineFilters( db: DbOrTx, subscriptionId: number, @@ -442,6 +355,7 @@ export function replaceInlineFilters( .where(eq(subscriptionFilters.subscriptionId, subscriptionId)) .run(); if (inputs.length === 0) return; + for (const f of inputs) assertFilterParamsCompilable(f.ruleType, f.params); db.insert(subscriptionFilters) .values( inputs.map((f) => ({ @@ -484,9 +398,7 @@ function loadLibraryFilterIdsBatch(db: Db, subscriptionIds: number[]): Map`( SELECT COUNT(*) FROM ${subscriptionFilters} WHERE ${subscriptionFilters.subscriptionId} = ${subscriptions.id} diff --git a/apps/server/src/api/routes/system.ts b/apps/server/src/api/routes/system.ts index de2b715..b2b3d7e 100644 --- a/apps/server/src/api/routes/system.ts +++ b/apps/server/src/api/routes/system.ts @@ -1,22 +1,4 @@ -/** - * System routes. - * - * - `GET /system/status`: Surface enough info for the web UI to explain - * why some features are unavailable (e.g. Telegram disconnected → - * subscribing/forwarding disabled). The status is supplied as a getter - * so the health monitor (apps/server/src/tg/healthMonitor.ts) can update - * it on each probe and the route always reads the current value. - * - * - `POST /system/export`, `POST /system/import`, `POST /system/wipe`: - * Settings → Data section. Versioned JSON envelope (`exportFileSchema`) - * carries selected sections (subscriptions / destinations / library - * filters / app settings) round-trip without ID collisions — IDs are - * intentionally omitted and import remaps via natural keys (chatId+name - * for destinations, name for library filters, sourceChatId for - * subscriptions). Import body limit is bumped to 2 MB on the route - * level — large enough for thousands of subscriptions, small enough to - * refuse abuse. - */ +// Import remaps by natural key (destinations: chatId+name; library filters: name; subscriptions: sourceChatId). import { eq, inArray } from 'drizzle-orm'; import type { FastifyInstance } from 'fastify'; import { @@ -64,9 +46,6 @@ import { import { getStatsDigestConfig } from '../../forwarding/statsDigestConfig.js'; import { replaceInlineFilters, replaceLibraryFilterAttachments } from './subscriptions.js'; -// Read the app version from the root package.json so exports carry a -// truthful tag. The path goes from this module up four levels: -// apps/server/src/api/routes → apps/server → repo root. import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; @@ -92,26 +71,10 @@ export interface RegisterSystemRoutesDeps { bus: EventBus; logger: Logger; getTelegramStatus: () => TelegramStatus; - /** - * Returns the loaded `TG_SESSION_ENCRYPTION_KEY` as a 32-byte Buffer, or - * null when not set. Used by import to decide whether to write the - * encrypted account blob carried inside `appSettings.telegramAccount`. - */ + // null gates writing the imported account blob. getEncryptionKey?: () => Buffer | null; - /** - * Triggers a live-swap of the gramjs runtime so an imported account row - * activates without a process restart. Optional; when absent, the row is - * still written but takes effect on next boot. - */ + // Live-swaps gramjs so an imported account activates without restart. reloadTelegramSession?: () => Promise; - /** - * Best-effort Telegram profile-photo fetcher. Import never carries icon - * bytes (they're derived data, refetched per host), so after a successful - * import we backfill `iconDataUrl` for the rows we just created — mirroring - * the inline fetch that POST /subscriptions and POST /destinations already - * do. Optional: when absent (Telegram-less boot, tests) imported rows stay - * icon-less until the access monitor's 24h sweep catches them. - */ getFetchProfilePhoto?: () => ProfilePhotoFetcher | undefined; } @@ -133,10 +96,7 @@ export function registerSystemRoutes(app: FastifyInstance, deps: RegisterSystemR app.post( '/system/export', { - // Behind cookie auth and SameSite=strict, but cap nonetheless: a stolen - // cookie or compromised browser extension shouldn't be able to grind - // out unlimited full-DB dumps. Generous enough that legitimate - // back-to-back exports work; tight enough that abuse is bounded. + // Cap full-DB dumps against a stolen cookie. config: { rateLimit: { max: 10, timeWindow: '1 minute' } }, }, async (request): Promise => { @@ -158,23 +118,15 @@ export function registerSystemRoutes(app: FastifyInstance, deps: RegisterSystemR `unsupported export schemaVersion ${body.data.schemaVersion}`, ); } - // v1 files lack `appSettings.telegramAccount`; v2 importers handle - // both transparently because the field is `.optional()`. const sectionSet = new Set(body.sections); const result = applyImport(db, body.data, sectionSet, body.conflictStrategy, { ...(getEncryptionKey !== undefined ? { getEncryptionKey } : {}), }); - // If a telegram account was actually written, kick the live-swap so - // the running app picks it up without a restart. Failures are - // swallowed — the row is saved either way. + // Failures swallowed: the row is saved either way. if (result.telegramAccountWritten && reloadTelegramSession) { await reloadTelegramSession().catch(() => {}); } - // Backfill profile photos for the rows we just imported. Fire-and-forget: - // a large import shouldn't block the HTTP response on hundreds of - // sequential Telegram downloads. Icons stream into the UI as they land - // via the `subscription.changed` / `destination.changed` SSE events the - // backfill emits (the web client invalidates its lists on those). + // Fire-and-forget so a big import doesn't block on sequential TG downloads. const fetchProfilePhoto = getFetchProfilePhoto?.(); if (fetchProfilePhoto && result.iconTargets.length > 0) { void backfillImportedIcons({ @@ -194,9 +146,7 @@ export function registerSystemRoutes(app: FastifyInstance, deps: RegisterSystemR app.post( '/system/wipe', { - // Destructive — cap aggressively. A leaked cookie or stored-XSS - // shouldn't be able to nuke the DB in a tight loop. Three calls per - // minute is still plenty for a legitimate "clear & re-import" flow. + // Destructive — cap aggressively against a leaked cookie. config: { rateLimit: { max: 3, timeWindow: '1 minute' } }, }, async (request): Promise => { @@ -351,9 +301,7 @@ function buildExport(db: Db, sections: Set): ExportFile { statsDigestTime: digest.time, statsDigestTimezone: digest.timezone, }; - // v2: include the encrypted Telegram account row when present. The - // ciphertext + fingerprint travel as-is — the importing host decides - // (via fingerprint match) whether to write the row to its own DB. + // Ciphertext + fingerprint travel as-is; importing host decides via fingerprint match. const accountRow = db.select().from(telegramAccount).get(); if (accountRow) { settings.telegramAccount = { @@ -379,7 +327,6 @@ function destKey(chatId: string, name: string): string { const emptySectionResult = (): ImportSectionResult => ({ created: 0, skipped: 0, replaced: 0 }); -/** A subscription/destination row that import created, needing a profile photo. */ export interface IconBackfillTarget { kind: 'subscription' | 'destination'; id: number; @@ -388,13 +335,7 @@ export interface IconBackfillTarget { interface ApplyImportResult { body: ImportResult; - /** True when a `telegram_account` row was created or replaced. */ telegramAccountWritten: boolean; - /** - * Subscription/destination rows this import touched (created or replaced) - * that still have a null `iconDataUrl` — fed to `backfillImportedIcons` - * after the transaction commits. Empty when nothing needs an icon. - */ iconTargets: IconBackfillTarget[]; } @@ -413,18 +354,11 @@ function applyImport( warnings: [], }; let telegramAccountWritten = false; - // IDs of subscription / destination rows this import created or replaced. - // After the transaction commits we re-read these and keep only the ones - // still missing an icon, so the post-import backfill targets exactly the - // rows that need one (created rows always do; replaced rows only when they - // were icon-less to begin with). const touchedDestIds: number[] = []; const touchedSubIds: number[] = []; db.transaction((tx) => { - // Build initial maps from existing rows so subscriptions can resolve - // refs even when the user opted out of importing destinations / library - // filters in this run (and the records already exist). + // Seed from existing rows so subscriptions resolve refs even when those sections weren't imported. const destMap = new Map(); for (const d of tx.select().from(destinations).all() as Destination[]) { destMap.set(destKey(d.chatId, d.name), d.id); @@ -477,8 +411,7 @@ function applyImport( for (const item of data.libraryFilters) { const existingId = libMap.get(item.name); if (existingId !== undefined) { - // ruleType is immutable post-create; if it changed, surface a - // warning and skip this row regardless of strategy. + // ruleType is immutable; on mismatch, warn and skip regardless of strategy. const existingRow = tx .select() .from(libraryFilters) @@ -534,8 +467,6 @@ function applyImport( result.subscriptions.skipped += 1; continue; } - // Replace: overwrite mutable fields + bulk-replace filter sets via - // the existing helpers (same code path as PATCH /subscriptions/:id). tx.update(subscriptions) .set({ sourceTitle: item.sourceTitle, @@ -579,11 +510,7 @@ function applyImport( } if (sections.has('appSettings') && data.appSettings) { - // The throttle/debouncer fields go into `app_settings`. The optional - // `telegramAccount` sub-object is split off and routed to its own - // table (`telegram_account`) — the wire format embeds it under - // `appSettings` so the user can manage both with one checkbox in the - // UI, but at rest they live separately. + // telegramAccount rides under appSettings on the wire but persists to its own table. const { telegramAccount: importedAccount, ...appSettingsValue } = data.appSettings; const existing = tx .select() @@ -593,9 +520,7 @@ function applyImport( if (existing && strategy === 'skip') { result.appSettings.skipped += 1; } else { - // Merge onto the existing row rather than replacing it: the export - // may omit fields (e.g. an older file with no digest schedule), and a - // wholesale overwrite would wipe whatever the row already held. + // Merge, don't overwrite: an older export may omit fields the row already holds. const mergedValue = { ...((existing?.value as Record | undefined) ?? {}), ...appSettingsValue, @@ -626,10 +551,7 @@ function applyImport( } }); - // Re-read the touched rows (post-commit) and keep only those still missing - // an icon. Created rows are always null here; replaced rows pass through - // only when they were already icon-less, so we never re-download a photo a - // row already has. + // Keep only touched rows still missing an icon, so we never re-download. const iconTargets: IconBackfillTarget[] = []; if (touchedDestIds.length > 0) { const rows = db @@ -667,23 +589,7 @@ function applyImport( return { body: result, telegramAccountWritten, iconTargets }; } -/** - * Post-import icon backfill. Import envelopes never carry profile-photo bytes - * (they're derived data — large, and tied to the importing host's session), - * so freshly imported subscriptions / destinations land with `iconDataUrl = - * null` and render the lucide fallback. This mirrors the inline fetch that the - * create routes (POST /subscriptions, POST /destinations) run, but batched and - * fired after the import response so a big import isn't held hostage to - * sequential Telegram downloads. - * - * Best-effort throughout: `fetchProfilePhoto` already swallows its own errors - * and returns null (no photo, gramjs error, oversize), so a failure just - * leaves that one row icon-less for the access monitor's 24h sweep to retry. - * Each successful stamp emits the same `*.changed` event the access monitor - * uses, so the web client's SSE listener invalidates its lists and the icon - * appears without a manual refresh. Fetches are deduped per chatId so a chat - * used by several rows is downloaded once. - */ +// Best-effort, deduped per chatId; each stamp emits a *.changed event so the UI refreshes. export async function backfillImportedIcons(deps: { db: Db; bus: EventBus; @@ -717,12 +623,7 @@ export async function backfillImportedIcons(deps: { return stamped; } -/** - * Returns true when the row was actually upserted (caller triggers - * live-swap). Returns false when the row was skipped (no key, fingerprint - * mismatch, or strategy=skip with an existing row); the warning array is - * mutated in place either way. - */ +// false = skipped: no key, fingerprint mismatch, or skip strategy. type DbOrTx = Db | Parameters[0]>[0]; function importTelegramAccount( @@ -817,11 +718,7 @@ function resolveLibraryFilterIds( return ids; } -// The exported subscription's inline filters are already validated by the -// `exportFileSchema`'s discriminated union — every entry carries valid -// per-rule params. Pass them through, but keep the warning hook so a future -// schema bump or hand-edited file with stray entries gets a clear surface -// rather than a silent throw. +// Already validated by exportFileSchema; warning hook retained for future schema drift. function importableInlineFilters( item: ExportedSubscription, _warnings: ImportWarning[], @@ -840,16 +737,13 @@ function applyWipe(db: Db, sections: Set): WipeResult { deleted.subscriptions = result.changes; } if (sections.has('libraryFilters')) { - // Pre-detach to avoid the FK RESTRICT on `subscription_library_filters - // → library_filters` when subscriptions weren't selected for wipe. + // Pre-detach to dodge FK RESTRICT on subscription_library_filters → library_filters. tx.delete(subscriptionLibraryFilters).run(); const result = tx.delete(libraryFilters).run(); deleted.libraryFilters = result.changes; } if (sections.has('destinations')) { - // FK is ON DELETE SET NULL on `subscriptions.destination_id`, so - // subscriptions survive — just lose their destination. Order matters - // only relative to subscriptions wipe (already done above if selected). + // FK is ON DELETE SET NULL on subscriptions.destination_id, so subscriptions survive. const result = tx.delete(destinations).run(); deleted.destinations = result.changes; } diff --git a/apps/server/src/api/routes/telegramAccount.ts b/apps/server/src/api/routes/telegramAccount.ts index f4a0f66..7774a8a 100644 --- a/apps/server/src/api/routes/telegramAccount.ts +++ b/apps/server/src/api/routes/telegramAccount.ts @@ -1,25 +1,4 @@ -/** - * Telegram account routes — settings-page sign-in / sign-out. - * - * GET /api/tg/account → current state, source, key status - * POST /api/tg/login/start → send code to a phone, returns sessionId - * POST /api/tg/login/verify → verify code; may signal needsPassword - * POST /api/tg/login/password → 2FA password - * POST /api/tg/login/raw → paste a raw session string (validated) - * POST /api/tg/login/cancel → drop an in-progress login - * DELETE /api/tg/account → remove the DB row, env fallback resumes - * - * "Save" branches refuse with 412 unless `TG_SESSION_ENCRYPTION_KEY` is - * configured. The encrypted blob is written to `telegram_account` (single - * row, id=1) and `reloadTelegramSession()` is called so the gramjs runtime - * picks up the new credentials without a restart. - * - * Each in-progress login session is bound to the caller's web-session cookie - * token via `readSessionToken(request)`; verify/password/cancel reject if a - * second authed tab tries to drive someone else's flow. The phone-code and - * password endpoints are rate-limited per IP — brute-forcing a 5-digit - * Telegram code via this surface would shadow-ban the operator's phone. - */ +// Settings-page Telegram sign-in/out. Save needs TG_SESSION_ENCRYPTION_KEY (else 412); login sessions are bound to the caller's cookie token so a second tab can't drive someone else's flow. import type { FastifyInstance } from 'fastify'; import { type TelegramAccountInfo, @@ -43,28 +22,18 @@ import { readSessionToken } from '../auth.js'; export interface RegisterTelegramAccountRoutesDeps { db: Db; - /** Returns the configured 32-byte key, or null when unset. */ getEncryptionKey?: () => Buffer | null; - /** In-memory store for in-progress sign-ins. */ loginSessionStore?: LoginSessionStore; - /** Triggers the live-swap so a freshly-saved session takes effect immediately. */ reloadTelegramSession?: () => Promise; - /** Lifecycle of the gramjs subsystem (used to compute `present`). */ getTelegramStatus: () => TelegramStatus; - /** - * Profile-photo fetcher over the live userbot. Used to fetch the account's - * own avatar ("me"); absent when no client is up (tests / disconnected). - */ + // Over the live userbot; absent when no client is up (tests / disconnected). getFetchProfilePhoto?: () => ProfilePhotoFetcher | undefined; } -// Avatar cache TTL — long enough to avoid re-downloading on every settings -// poll, short enough that a changed/swapped photo or a transient miss is -// picked up without a sign-out or restart. const AVATAR_TTL_MS = 10 * 60_000; const AVATAR_FETCH_TIMEOUT_MS = 2500; -/** Resolves to null after the timeout so a stalled download can't hang the route. */ +// Resolves to null after the timeout so a stalled download can't hang the route. function avatarFetchTimeout(): Promise { return new Promise((resolve) => { const t = setTimeout(() => resolve(null), AVATAR_FETCH_TIMEOUT_MS); @@ -85,9 +54,7 @@ export function registerTelegramAccountRoutes( getFetchProfilePhoto, } = deps; - // The userbot's own avatar, downloaded lazily and cached with a short TTL so - // a transient miss or a changed/swapped photo is eventually picked up - // without re-downloading on every poll. Cleared outright on sign-in / out. + // Userbot's own avatar, lazy + short-TTL cached; cleared on sign-in/out. let avatarCache: { key: string; dataUrl: string | null; fetchedAt: number } | null = null; async function resolveAvatar(base: TelegramAccountInfo): Promise { @@ -102,7 +69,6 @@ export function registerTelegramAccountRoutes( if (!fetcher) return avatarCache?.key === key ? avatarCache.dataUrl : null; let dataUrl: string | null = null; try { - // Don't let a hung gramjs download block this frequently-polled route. dataUrl = await Promise.race([fetcher('me'), avatarFetchTimeout()]); } catch { dataUrl = null; @@ -125,9 +91,7 @@ export function registerTelegramAccountRoutes( app.post( '/tg/login/start', { - // Telegram throttles SendCode aggressively per phone number; cap our own - // call rate to avoid getting the operator's phone number shadow-banned - // when the UI fires repeated start requests. + // Cap SendCode rate: repeated starts can shadow-ban the operator's phone. config: { rateLimit: { max: 5, timeWindow: '5 minutes' } }, }, async (request) => { @@ -142,9 +106,7 @@ export function registerTelegramAccountRoutes( app.post( '/tg/login/verify', { - // Brute-forcing the SMS code via this surface would shadow-ban the - // phone — Telegram counts attempts per phone+app, not per IP. Stay - // well under the limit; legitimate users only need a handful of tries. + // SMS-code brute-force shadow-bans the phone (counted per phone+app, not per IP). config: { rateLimit: { max: 8, timeWindow: '5 minutes' } }, }, async (request): Promise => { @@ -170,8 +132,7 @@ export function registerTelegramAccountRoutes( app.post( '/tg/login/password', { - // Same reasoning as `/verify` — 2FA password brute-force is upstream- - // rate-limited but the operator pays the bill. + // Cap 2FA-password rate; the operator pays for upstream brute-force throttling. config: { rateLimit: { max: 8, timeWindow: '5 minutes' } }, }, async (request): Promise => { @@ -218,9 +179,7 @@ export function registerTelegramAccountRoutes( deleteAccount(db); avatarCache = null; if (reloadTelegramSession) { - // Live-swap to env (or degraded) — failures are logged inside - // `reloadTelegramSession` and don't block the user. The DB row is - // already gone; next boot will pick up the env fallback. + // Live-swap to env; failures don't block (row's gone, next boot picks up env). await reloadTelegramSession().catch(() => {}); } return { ok: true }; @@ -238,10 +197,7 @@ function buildAccountInfo(deps: { const row = getActiveAccount(deps.db); const status = deps.getTelegramStatus(); const connected = status.state === 'connected'; - // A row whose fingerprint doesn't match the current key (or no key set) - // is "stale" — the resolver fell through to env. Report the mismatch - // separately so the UI can prompt the operator without claiming the row - // is the live account. + // Fingerprint mismatch (or no key) = stale row, resolver fell through to env; reported separately so the UI can prompt. const rowUsable = row !== null && key !== null && getKeyFingerprint(key) === row.keyFingerprint; const source: 'db' | 'env' | null = !connected ? null : rowUsable ? 'db' : 'env'; @@ -300,9 +256,7 @@ async function commitLogin(deps: { try { await deps.reloadTelegramSession(); } catch { - // Live-swap failed; the DB row is still saved. The next process restart - // picks it up. The caller rebuilds the account info regardless so the UI - // can confirm the save. + // Live-swap failed; row is saved, next restart picks it up. } } } diff --git a/apps/server/src/api/server.ts b/apps/server/src/api/server.ts index a2c74f4..60c8a78 100644 --- a/apps/server/src/api/server.ts +++ b/apps/server/src/api/server.ts @@ -1,23 +1,3 @@ -/** - * Fastify factory. - * - * Pure factory — does NOT call `.listen()`. The boot path in - * `apps/server/src/index.ts` constructs the app and calls listen; tests - * construct the app and use `.inject()` directly. Same code, two - * call sites. - * - * Plugin order: - * 1. `@fastify/cookie` (signed cookies; secret = `webAuth.sessionSecret`) - * 2. `@fastify/cors` — dev only (allow Vite at :5173). Production is - * same-origin via `@fastify/static`, so no CORS surface to expose. - * 3. `@fastify/static` — serves `apps/web/dist` (no-op until Ch 9/14 - * bundles a real SPA). The plugin requires the directory to exist; - * we `mkdirSync(root, { recursive: true })` on registration so it - * doesn't throw before the SPA lands. - * 4. Custom error handler from `errorHandler.ts` - * 5. Public scope (just `POST /api/auth/login`) - * 6. Authed scope (`requireAuth` preHandler + everything else) - */ import { createHash } from 'node:crypto'; import { mkdirSync, readFileSync } from 'node:fs'; import path from 'node:path'; @@ -52,6 +32,7 @@ import { registerBotConfigRoutes } from './routes/botConfig.js'; import { registerDestinationRoutes } from './routes/destinations.js'; import { registerFilterRoutes } from './routes/filters.js'; import { registerForwardLogRoutes } from './routes/forwardLog.js'; +import { registerHealthRoute } from './routes/health.js'; import { registerLibraryFilterRoutes } from './routes/libraryFilters.js'; import { registerSettingsRoutes } from './routes/settings.js'; import { registerStreamRoutes } from './routes/stream.js'; @@ -70,113 +51,27 @@ export interface CreateApiServerDeps { logger: Logger; filterRegistry: FilterRegistry; webAuth: WebAuth; - /** - * Parsed env config — supplies the env fallbacks behind the bot-config - * route's DB-over-env resolver. Optional for tests; defaults to a config - * parsed from an empty env (all bot/web vars unset). - */ cfg?: Config; - /** - * Live getter for the Telegram Web App auth config (bot token + admin - * allowlist), resolved DB-over-env. Read per request by - * `POST /api/auth/telegram` so a config change made via Settings → Bot - * applies without a restart. Read once at boot to decide whether to relax - * the CSP `frame-ancestors` for the Mini App. Null/omitted (or a getter - * that yields null) = password-only with the strict default CSP. - */ + // Resolved DB-over-env; read per request by POST /api/auth/telegram so config changes apply live. null = password-only. getTelegramAuth?: () => TelegramAuth | null; - /** - * Live-swaps the long-polling bot after a config change. Wired to the - * bot-config route's PUT/DELETE. Optional for tests (no swap occurs). - */ reloadBot?: () => Promise; - /** Whether the long-polling bot is currently running (for the masked GET). */ getBotRunning?: () => boolean; isProd: boolean; bus: EventBus; - /** Override the SSE heartbeat interval — primarily for tests. */ heartbeatMs?: number; - /** - * Lazy lookup for the universal "paste-anything" resolver used by - * `POST /subscriptions/resolve` and `POST /destinations/resolve`. The - * boot path in `apps/server/src/index.ts` populates this asynchronously - * after `app.listen()` returns, so route handlers must dereference per - * request rather than at registration time. Optional for tests and - * Telegram-less boots; routes return 503 `telegram_unavailable` (or - * `telegram_initializing` while `getTelegramStatus().state === - * 'connecting'`) when the getter yields undefined. - */ + // Lazy: boot populates these after app.listen(), so handlers must deref per request, not at registration. getChatResolver?: () => ChatResolver | undefined; - /** - * Lazy lookup for the `messages.ImportChatInvite` wrapper used by both - * subscription and destination create endpoints when the body carries - * `inviteHash`. Same lifecycle as `getChatResolver`. - */ getImportInvite?: () => ImportInviteFn | undefined; - /** - * Lazy lookup for the auto-join helper invoked from POST /subscriptions - * after the row is inserted. Same lifecycle as `getChatResolver`; when - * undefined, new subscriptions get the default 'ok' status and rely on - * the access monitor's first sweep to correct it. - */ getJoinChannel?: () => JoinChannelFn | undefined; - /** - * Lazy lookup for the best-effort profile-photo fetcher used by both - * create endpoints (`POST /subscriptions`, `POST /destinations`) to - * populate the new row's `iconDataUrl` immediately. Same lifecycle as - * `getChatResolver`; without it new rows stay icon-less until the access - * monitor's lazy backfill catches them. - */ getFetchProfilePhoto?: () => ProfilePhotoFetcher | undefined; - /** - * Lazy lookup for the forum-topic lister backing `POST /destinations/topics`. - * Same lifecycle as `getChatResolver`. - */ getListForumTopics?: () => ForumTopicLister | undefined; - /** - * Live getter for the Telegram subsystem state. Surfaced via - * `GET /api/system/status` so the web UI can warn the operator when - * Telegram is unavailable. The getter is read on each request, so the - * boot path can flip 'connecting' → 'connected' (or the health monitor - * can flip 'connected' → 'disconnected') and clients pick up the change - * without a restart. Optional for tests; defaults to a generic "not - * initialized" reason when absent. - */ + // Read per request so 'connecting' → 'connected' transitions reach clients without a restart. getTelegramStatus?: () => TelegramStatus; - /** - * Returns the loaded `TG_SESSION_ENCRYPTION_KEY` as a 32-byte Buffer, or - * null when the env var is not set. Used by the telegram-account routes - * to refuse account writes when no key is configured, and by the system - * import route to skip encrypted blobs whose fingerprint doesn't match. - * Optional — when absent, both paths behave as if the key is unset. - */ getEncryptionKey?: () => Buffer | null; - /** - * In-memory store for in-progress sign-ins from the Settings page. - * Optional — telegram-account routes return 503 when missing (for tests - * that don't exercise the login flow). - */ loginSessionStore?: LoginSessionStore; - /** - * Triggers a live-swap of the gramjs client and dependent runtime - * (pipeline, debouncer, monitors, resolvers). Called after a successful - * sign-in or sign-out so the running app picks up the new credentials - * without a restart. Optional for tests; in tests the routes still write - * the row but no swap occurs. - */ + // Live-swaps the gramjs client + dependent runtime after sign-in/out, no restart. reloadTelegramSession?: () => Promise; - /** - * Optional override for the DB-backed session store. Defaults to - * `createSessionStore({ db })`. Tests may inject a custom one but the - * default is fine for both production and the standard test scaffolding. - */ sessionStore?: SessionStore; - /** - * Override the directory the SPA is served from (static root, CSP inline - * hashes, and the history fallback below). Defaults to the built - * `apps/web/dist`. Tests point this at a temp dir to exercise the SPA - * fallback without a real web build. - */ webDistRoot?: string; } @@ -186,22 +81,12 @@ const DEFAULT_TELEGRAM_STATUS: TelegramStatus = { reason: 'Telegram client not initialized', }; -/** - * Compute the sha256 hashes of every inline `