diff --git a/backend/src/api/admin.ts b/backend/src/api/admin.ts index 0912941..24820ca 100644 --- a/backend/src/api/admin.ts +++ b/backend/src/api/admin.ts @@ -13,8 +13,20 @@ import * as telegramClient from '../telegram/client.js'; import { loadedWidgets } from '../widgets/registry.js'; import { installUploadedWidget } from '../widgets/install.js'; import { uninstallWidget } from '../widgets/uninstall.js'; +import { swapLiveServer } from '../server.js'; import type { GlobalSettings } from '../storage/db/types.js'; +// Rebuilds and swaps in the live Fastify instance to pick up a widget's newly +// (de)registered routes — MUST run after the triggering request has already sent its +// response, never awaited inline in that handler, since the swap closes the very +// instance serving it (see widgets/install.ts's and uninstall.ts's doc comments for +// why — this dropped the response entirely when tried inline during testing). +function scheduleServerSwap(context: string) { + setImmediate(() => { + swapLiveServer().catch((err) => logger.error('server', `Route swap after ${context} failed: ${(err as Error).message}`)); + }); +} + // Not part of GlobalSettings itself (nothing to persist) — computed fresh on every // settings read/write so the Retention tab's "currently using" line and usage bar // always reflect the real total, not whatever was true when the row was last saved. @@ -235,7 +247,8 @@ export async function registerAdminRoutes(app: FastifyInstance) { const { manifest, files } = req.body as { manifest?: unknown; files?: unknown }; const result = await installUploadedWidget(manifest, files); if (!result.ok) return reply.code(400).send({ error: result.error }); - return reply.code(201).send({ id: result.id }); + reply.code(201).send({ id: result.id }); + if (result.needsServerSwap) scheduleServerSwap(`installing "${result.id}"`); }); app.patch('/api/admin/widgets/:id', async (req, reply) => { @@ -263,8 +276,9 @@ export async function registerAdminRoutes(app: FastifyInstance) { const widget = installedWidgetsDb.getInstalled(id); if (!widget) return reply.code(404).send({ error: 'not found' }); if (widget.source === 'builtin') return reply.code(400).send({ error: 'built-in widgets cannot be deleted' }); - await uninstallWidget(id); - return reply.code(204).send(); + const hadRoutes = await uninstallWidget(id); + reply.code(204).send(); + if (hadRoutes) scheduleServerSwap(`deleting "${id}"`); }); // --- Logs --- diff --git a/backend/src/index.ts b/backend/src/index.ts index 5563f60..e51250c 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,25 +1,11 @@ -import Fastify from 'fastify'; -import cors from '@fastify/cors'; -import cookie from '@fastify/cookie'; -import fs from 'node:fs'; -import path from 'node:path'; import { migrate } from './storage/db/index.js'; import { ADMIN_API_KEY } from './api/apiKey.js'; -import { registerAuth } from './api/auth.js'; -import { registerPublicRoutes } from './api/public.js'; -import { registerAdminRoutes } from './api/admin.js'; -import { registerMediaProxy } from './api/mediaProxy.js'; -import { registerTelegramMediaProxy } from './api/telegramMediaProxy.js'; -import { registerPrivateAccess, privateAccessConfigured } from './api/privateAccess.js'; +import { privateAccessConfigured } from './api/privateAccess.js'; import { startScheduler } from './queue/scheduler.js'; import { initFromSavedSession } from './telegram/client.js'; import { logger } from './storage/db/logs.js'; -import { loadAllWidgets, loadedWidgets } from './widgets/registry.js'; - -const PORT = Number(process.env.PORT) || 4000; -const FRONTEND_ORIGIN = process.env.FRONTEND_ORIGIN || 'http://localhost:5173'; -const MEDIA_DIR = process.env.MEDIA_DIR || './data/media'; -const WIDGETS_INSTALLED_DIR = process.env.WIDGETS_INSTALLED_DIR || './data/widgets-installed'; +import { loadAllWidgets } from './widgets/registry.js'; +import { reloadServerRoutes } from './server.js'; function printApiKeyBanner() { const line = '='.repeat(64); @@ -29,7 +15,9 @@ function printApiKeyBanner() { console.log(`\n${line}`); console.log(' Homefeed admin API key (required for every /api/admin/* request)'); console.log(` ${ADMIN_API_KEY}`); - console.log(' This key is generated fresh on every restart — it will not be the same next time.'); + console.log(' This key is generated fresh on every process restart — it will not be'); + console.log(' the same next time. Installing/deleting a widget does NOT restart the'); + console.log(' process (see server.ts) and does not change this key.'); console.log(`${line}\n`); } @@ -38,103 +26,7 @@ async function main() { printApiKeyBanner(); await initFromSavedSession(); await loadAllWidgets(); - - const app = Fastify({ logger: false }); - - // Cross-origin is expected — see project-structure.md "Cross-origin and security - // implications". Not a wildcard: only the configured frontend origin is allowed. - // @fastify/cors defaults to GET/HEAD/POST only — without an explicit methods list, - // every PATCH (settings saves) and DELETE (removing sources/events) gets silently - // blocked by the browser at the CORS preflight stage, before the request ever - // reaches a route handler. - // credentials: true is required for the browser to send/accept the private-category - // login cookie cross-origin — safe only because origin is a specific value above, - // never a wildcard (the two are mutually exclusive per the CORS spec anyway). - await app.register(cors, { - origin: FRONTEND_ORIGIN, - credentials: true, - methods: ['GET', 'POST', 'PATCH', 'DELETE', 'PUT', 'OPTIONS'] - }); - - await app.register(cookie); - - // Overrides Fastify's default JSON body parser, which throws "Body cannot be empty - // when content-type is set to 'application/json'" for any bodyless request (DELETE, - // or POST with no payload) that still carries a Content-Type header — exactly what - // browsers' fetch() does when a client sets that header unconditionally. An empty - // body is just as valid as `{}` for routes that don't read req.body at all. - app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => { - if (typeof body !== 'string' || body.trim() === '') return done(null, {}); - try { - done(null, JSON.parse(body)); - } catch (err) { - done(err as Error, undefined); - } - }); - - await registerAuth(app); - await registerPublicRoutes(app); - await registerAdminRoutes(app); - await registerPrivateAccess(app); - - // Each loaded widget (built-in or uploaded — see widgets/registry.ts) registers its - // own routes here rather than being hardcoded into public.ts/admin.ts. Runs after - // registerAuth so any /api/admin/* route a widget registers is gated by the same - // X-Api-Key preHandler automatically. - for (const plugin of loadedWidgets.values()) { - plugin.registerPublicRoutes?.(app); - plugin.registerAdminRoutes?.(app); - } - - // Fastify's own logger is off (see below) — without this, an unhandled exception - // in any route handler produces a bare 500 with zero trace anywhere, including the - // admin panel's own Logs tab. This is what "Save failed" with no log entry was. - app.setErrorHandler((err: Error & { statusCode?: number }, req, reply) => { - logger.error('server', `${req.method} ${req.url} failed: ${err.message}`); - reply.code(err.statusCode ?? 500).send({ error: err.message }); - }); - - // Locally hosted media (see storage/media) — served directly rather than via a - // heavier static-file plugin, since this is a small, flat directory. - app.get('/media/:filename', async (req, reply) => { - const { filename } = req.params as { filename: string }; - if (filename.includes('..') || filename.includes('/')) return reply.code(400).send(); - const filePath = path.join(MEDIA_DIR, filename); - if (!fs.existsSync(filePath)) return reply.code(404).send(); - return reply.send(fs.createReadStream(filePath)); - }); - - // A widget's optional pre-built frontend bundle (see widgets/manifest.ts's - // frontendEntry) — one generic wildcard route rather than one per widget, so it works - // for a widget uploaded after this process started, with no restart (unlike a - // widget's own custom API routes, which do need one — see widgets/install.ts). - // Explicit Content-Type is required here (unlike /media/:filename above) — browsers - // reject a dynamically-imported module whose response isn't served as a JS MIME - // type. The wildcard also lets a bundle's own relative imports (e.g. `import - // './helper.mjs'`) resolve automatically, since the browser requests those against - // this same route. - app.get('/widget-assets/:id/*', async (req, reply) => { - const { id } = req.params as { id: string }; - const rel = (req.params as { '*': string })['*']; - if (rel.includes('..')) return reply.code(400).send(); - const filePath = path.join(WIDGETS_INSTALLED_DIR, id, rel); - if (!fs.existsSync(filePath)) return reply.code(404).send(); - if (rel.endsWith('.mjs') || rel.endsWith('.js')) reply.type('text/javascript'); - else if (rel.endsWith('.css')) reply.type('text/css'); - return reply.send(fs.createReadStream(filePath)); - }); - - // Static "/media/proxy" and "/media/telegram-proxy" take priority over the - // "/media/:filename" param route above regardless of registration order - // (find-my-way, Fastify's router, always prefers a static segment over a parametric - // one at the same depth). - await registerMediaProxy(app); - await registerTelegramMediaProxy(app); - - app.get('/health', async () => ({ ok: true })); - - await app.listen({ port: PORT, host: '0.0.0.0' }); - logger.info('server', `Listening on :${PORT} (frontend origin: ${FRONTEND_ORIGIN})`); + await reloadServerRoutes(); if (!privateAccessConfigured()) { logger.info('server', 'Private categories disabled — set PRIVATE_ACCESS_PASSWORD to enable'); } diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..3b01a44 --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,178 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import cors from '@fastify/cors'; +import cookie from '@fastify/cookie'; +import fs from 'node:fs'; +import path from 'node:path'; +import { registerAuth } from './api/auth.js'; +import { registerPublicRoutes } from './api/public.js'; +import { registerAdminRoutes } from './api/admin.js'; +import { registerMediaProxy } from './api/mediaProxy.js'; +import { registerTelegramMediaProxy } from './api/telegramMediaProxy.js'; +import { registerPrivateAccess } from './api/privateAccess.js'; +import { loadedWidgets } from './widgets/registry.js'; +import { logger } from './storage/db/logs.js'; + +const PORT = Number(process.env.PORT) || 4000; +const FRONTEND_ORIGIN = process.env.FRONTEND_ORIGIN || 'http://localhost:5173'; +const MEDIA_DIR = process.env.MEDIA_DIR || './data/media'; +const WIDGETS_INSTALLED_DIR = process.env.WIDGETS_INSTALLED_DIR || './data/widgets-installed'; + +let currentApp: FastifyInstance | null = null; + +async function buildApp(): Promise { + const app = Fastify({ logger: false }); + + // Cross-origin is expected — see project-structure.md "Cross-origin and security + // implications". Not a wildcard: only the configured frontend origin is allowed. + // @fastify/cors defaults to GET/HEAD/POST only — without an explicit methods list, + // every PATCH (settings saves) and DELETE (removing sources/events) gets silently + // blocked by the browser at the CORS preflight stage, before the request ever + // reaches a route handler. + // credentials: true is required for the browser to send/accept the private-category + // login cookie cross-origin — safe only because origin is a specific value above, + // never a wildcard (the two are mutually exclusive per the CORS spec anyway). + await app.register(cors, { + origin: FRONTEND_ORIGIN, + credentials: true, + methods: ['GET', 'POST', 'PATCH', 'DELETE', 'PUT', 'OPTIONS'] + }); + + await app.register(cookie); + + // Overrides Fastify's default JSON body parser, which throws "Body cannot be empty + // when content-type is set to 'application/json'" for any bodyless request (DELETE, + // or POST with no payload) that still carries a Content-Type header — exactly what + // browsers' fetch() does when a client sets that header unconditionally. An empty + // body is just as valid as `{}` for routes that don't read req.body at all. + app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => { + if (typeof body !== 'string' || body.trim() === '') return done(null, {}); + try { + done(null, JSON.parse(body)); + } catch (err) { + done(err as Error, undefined); + } + }); + + await registerAuth(app); + await registerPublicRoutes(app); + await registerAdminRoutes(app); + await registerPrivateAccess(app); + + // Each loaded widget (built-in or uploaded — see widgets/registry.ts) registers its + // own routes here rather than being hardcoded into public.ts/admin.ts. Runs after + // registerAuth so any /api/admin/* route a widget registers is gated by the same + // X-Api-Key preHandler automatically. + for (const plugin of loadedWidgets.values()) { + plugin.registerPublicRoutes?.(app); + plugin.registerAdminRoutes?.(app); + } + + // Fastify's own logger is off (see below) — without this, an unhandled exception + // in any route handler produces a bare 500 with zero trace anywhere, including the + // admin panel's own Logs tab. This is what "Save failed" with no log entry was. + app.setErrorHandler((err: Error & { statusCode?: number }, req, reply) => { + logger.error('server', `${req.method} ${req.url} failed: ${err.message}`); + reply.code(err.statusCode ?? 500).send({ error: err.message }); + }); + + // Locally hosted media (see storage/media) — served directly rather than via a + // heavier static-file plugin, since this is a small, flat directory. + app.get('/media/:filename', async (req, reply) => { + const { filename } = req.params as { filename: string }; + if (filename.includes('..') || filename.includes('/')) return reply.code(400).send(); + const filePath = path.join(MEDIA_DIR, filename); + if (!fs.existsSync(filePath)) return reply.code(404).send(); + return reply.send(fs.createReadStream(filePath)); + }); + + // A widget's optional pre-built frontend bundle (see widgets/manifest.ts's + // frontendEntry) — one generic wildcard route rather than one per widget, so it works + // for a widget uploaded after this process started, with no restart (unlike a + // widget's own custom API routes, which need reloadServerRoutes() below to activate). + // Explicit Content-Type is required here (unlike /media/:filename above) — browsers + // reject a dynamically-imported module whose response isn't served as a JS MIME + // type. The wildcard also lets a bundle's own relative imports (e.g. `import + // './helper.mjs'`) resolve automatically, since the browser requests those against + // this same route. + app.get('/widget-assets/:id/*', async (req, reply) => { + const { id } = req.params as { id: string }; + const rel = (req.params as { '*': string })['*']; + if (rel.includes('..')) return reply.code(400).send(); + const filePath = path.join(WIDGETS_INSTALLED_DIR, id, rel); + if (!fs.existsSync(filePath)) return reply.code(404).send(); + if (rel.endsWith('.mjs') || rel.endsWith('.js')) reply.type('text/javascript'); + else if (rel.endsWith('.css')) reply.type('text/css'); + return reply.send(fs.createReadStream(filePath)); + }); + + // Static "/media/proxy" and "/media/telegram-proxy" take priority over the + // "/media/:filename" param route above regardless of registration order + // (find-my-way, Fastify's router, always prefers a static segment over a parametric + // one at the same depth). + await registerMediaProxy(app); + await registerTelegramMediaProxy(app); + + app.get('/health', async () => ({ ok: true })); + + return app; +} + +/** + * Builds a fresh Fastify instance with every currently loaded widget's routes and swaps + * it in for the running one — the only way to pick up a route a live-uploaded widget + * declares, since Fastify refuses to add routes to an already-listening instance (throws + * "instance is already listening" synchronously). Called once at process startup (with no + * previous instance to close) and again after any widget install/uninstall that changes + * the route set (see widgets/install.ts, uninstall.ts). + * + * Deliberately reuses everything else already live in this process — the DB connection, + * the in-memory widget registry, the scheduler's setInterval loops, the Telegram client's + * session, and the admin API key all stay untouched. Only the HTTP server + its router are + * rebuilt, which is what makes this meaningfully better than a full process restart: none + * of that state is lost, and in particular the admin API key (regenerated only on true + * process start) stays valid, so installing a widget never logs the admin out. + * + * Split into two steps rather than one, because of a real deadlock/dropped-response bug + * hit in testing: the install/delete admin routes that trigger a reload are themselves + * served BY the live app instance. Awaiting the full swap (which closes that very instance) + * from inside its own still-executing request handler closed the connection before the + * response could be flushed — the client saw a bare connection reset, not a 204/201. + * + * validateRoutesBuildable() never touches the live server at all (builds on an OS-assigned + * ephemeral port and closes it again), so it's safe to await synchronously inside a + * request handler — that's what lets a broken widget's install be rejected/rolled back in + * the same response. swapLiveServer() is the part that actually closes the current + * instance; callers that are themselves inside a request handler for the live instance + * MUST defer this past sending their response (e.g. via setImmediate — see + * api/admin.ts's widget install/delete routes). Boot-time startup (see index.ts) has no + * in-flight request to worry about, so it just awaits both in sequence via + * reloadServerRoutes() below. + */ +export async function validateRoutesBuildable(): Promise { + const candidate = await buildApp(); + await candidate.listen({ port: 0, host: '127.0.0.1' }); + await candidate.close(); +} + +export async function swapLiveServer(): Promise { + const oldApp = currentApp; + // The new instance binds the same fixed PORT the old one holds, so the old one has to + // let go of it first — there's a brief window (typically well under a second) where + // nothing is listening on PORT. Acceptable for a self-hosted single-admin tool where + // this only fires right after an admin's own widget install/delete action. + if (oldApp) await oldApp.close(); + const newApp = await buildApp(); + await newApp.listen({ port: PORT, host: '0.0.0.0' }); + currentApp = newApp; + + logger.info( + 'server', + `Listening on :${PORT} (frontend origin: ${FRONTEND_ORIGIN}) — ${loadedWidgets.size} widget(s) registered` + ); +} + +/** Boot-time convenience — validate then swap in one call. Only safe when there's no in-flight request being served by the instance being replaced (i.e. process startup). */ +export async function reloadServerRoutes(): Promise { + await validateRoutesBuildable(); + await swapLiveServer(); +} diff --git a/backend/src/widgets/install.ts b/backend/src/widgets/install.ts index 789e4f5..11b2a1a 100644 --- a/backend/src/widgets/install.ts +++ b/backend/src/widgets/install.ts @@ -1,25 +1,36 @@ import fs from 'node:fs'; import path from 'node:path'; import * as installedWidgetsDb from '../storage/db/installedWidgets.js'; -import { loadUploadedWidget } from './registry.js'; +import { loadUploadedWidget, loadedWidgets } from './registry.js'; import { startWidgetPolling } from '../queue/scheduler.js'; import { validateManifest, type WidgetManifest } from './manifest.js'; +import { validateRoutesBuildable } from '../server.js'; +import { logger } from '../storage/db/logs.js'; const WIDGETS_INSTALLED_DIR = process.env.WIDGETS_INSTALLED_DIR || './data/widgets-installed'; -export type InstallResult = { ok: true; id: string } | { ok: false; error: string }; +export type InstallResult = + | { ok: true; id: string; needsServerSwap: boolean } + | { ok: false; error: string }; // Installs and hot-loads a widget uploaded live to the running backend (see // api/admin.ts's POST /api/admin/widgets) — writes its files under ./data/, never // dist/ or src/, so it survives a rebuild/redeploy of the core app. Its migrate() -// runs and its poll interval (if declared) starts immediately, with no restart -// required. NOTE: its registerPublicRoutes/registerAdminRoutes, if declared, do NOT -// take effect until the next restart — Fastify throws "instance is already -// listening" if you try to add a route after app.listen() has resolved, and there's -// no supported way around that short of a much larger request-dispatch redesign. A -// live-installed widget's data/poll side works immediately; its custom HTTP routes -// don't until the process restarts (see widgets/registry.ts's startup discovery, -// which re-registers everything, routes included, on every boot). +// runs and its poll interval (if declared) starts immediately. If it declares +// registerPublicRoutes/registerAdminRoutes, `needsServerSwap` comes back true — the +// caller (api/admin.ts's POST route) must call server.ts's swapLiveServer() itself, +// AFTER sending its own response, never inline here: this function runs inside the +// very request handler whose underlying Fastify instance a swap would close, so +// awaiting the swap here would drop the response before the client ever sees it (hit +// this for real in testing). validateRoutesBuildable() is safe to await here — it +// never touches the live server, only a throwaway instance on an ephemeral port — so +// a widget whose routes are actually broken (e.g. a path collision) is still caught +// and rolled back within this same call, before anything user-visible commits. +// +// Either way this is a full HTTP-server rebuild within the running process, not a +// process restart — the DB connection, scheduler intervals, Telegram session, and +// (critically) the admin API key all survive; a process restart would regenerate the +// key and log the admin out. export async function installUploadedWidget(manifest: unknown, files: unknown): Promise { const validationError = validateManifest(manifest, files); if (validationError) return { ok: false, error: validationError }; @@ -55,6 +66,23 @@ export async function installUploadedWidget(manifest: unknown, files: unknown): frontendEntry: m.frontendEntry ?? null }); + const needsServerSwap = !!(plugin.registerPublicRoutes || plugin.registerAdminRoutes); + if (needsServerSwap) { + try { + await validateRoutesBuildable(); + } catch (err) { + // A widget whose routes break Fastify's registration (e.g. a path collision) + // isn't a successful install — the site itself was never at risk since this + // only ever touched a throwaway ephemeral-port instance, but this widget still + // needs to be fully rolled back rather than left half-installed. + logger.error('widgets', `Install of "${m.id}" rolled back — its routes failed to register: ${(err as Error).message}`); + loadedWidgets.delete(m.id); + installedWidgetsDb.deleteInstalled(m.id); + fs.rmSync(dir, { recursive: true, force: true }); + return { ok: false, error: `widget's routes failed to register: ${(err as Error).message}` }; + } + } + startWidgetPolling(plugin); - return { ok: true, id: m.id }; + return { ok: true, id: m.id, needsServerSwap }; } diff --git a/backend/src/widgets/uninstall.ts b/backend/src/widgets/uninstall.ts index 46f5b1a..6d6426e 100644 --- a/backend/src/widgets/uninstall.ts +++ b/backend/src/widgets/uninstall.ts @@ -15,10 +15,18 @@ const WIDGETS_DATA_DIR = process.env.WIDGETS_DATA_DIR || './data/widgets-data'; // guarantee, matching every table/kv row/on-disk file regardless of the widget's own // cooperation). Callers (see api/admin.ts's DELETE /api/admin/widgets/:id) are // responsible for rejecting built-in widgets before calling this. -export async function uninstallWidget(id: string): Promise { +// +// Returns whether the deleted widget had declared routes, i.e. whether the caller needs +// to swap the live server afterward (see server.ts's swapLiveServer()) — deliberately NOT +// done inline here: this runs inside the DELETE route's own request handler, and that +// handler is served by the very Fastify instance a swap would close, which drops the +// response before the client ever sees it (hit this for real in testing). The caller must +// send its response first, then swap — see api/admin.ts. +export async function uninstallWidget(id: string): Promise { stopWidgetPolling(id); const plugin = loadedWidgets.get(id); + const hadRoutes = !!(plugin?.registerPublicRoutes || plugin?.registerAdminRoutes); if (plugin?.uninstall) { try { plugin.uninstall(db); @@ -35,4 +43,6 @@ export async function uninstallWidget(id: string): Promise { loadedWidgets.delete(id); installedWidgetsDb.deleteInstalled(id); + + return hadRoutes; }