import { readFileSync, existsSync } from "node:fs"; import { resolve } from "node:path"; import { serve } from "@hono/node-server"; import { serveStatic } from "@hono/node-server/serve-static"; import { Hono } from "hono"; import { csrf } from "hono/csrf"; import { logger } from "hono/logger"; import { secureHeaders } from "hono/secure-headers"; import { config, isLoopback, minifluxAllowed, publicBase } from "./config.js"; import { MinifluxClient } from "./miniflux.js"; import { BodyCache } from "./body-cache.js"; import { DeviceTokenStore } from "./device-tokens.js"; import { buildOAuth } from "./oauth.js"; import { AtprotoRepo } from "./atproto.js"; import { Syncer } from "./sync.js"; import { RecordCache } from "./record-cache.js"; import { JetstreamListener } from "./jetstream.js"; import { sweepOauthState, OAUTH_STATE_TTL_MS } from "./oauth-stores.js"; import { authRoutes } from "./routes-auth.js"; import { apiRoutes } from "./routes-api.js"; import { deviceRoutes } from "./routes-device.js"; const mf = new MinifluxClient(config.miniflux.url, config.miniflux.token); const bodies = new BodyCache(); const deviceTokens = new DeviceTokenStore(config.dataDir); const oauth = await buildOAuth(); const cache = new RecordCache(); const app = new Hono(); app.use(logger()); app.use( secureHeaders({ xFrameOptions: "DENY", contentSecurityPolicy: { frameAncestors: ["'none'"], }, }), ); // CSRF guard for cookie-authenticated routes. The `/device/*` tree is Bearer- // authenticated (no cookie), so it's not CSRFable and is intentionally excluded. // In loopback/dev, the UI is served by Vite on :5173 and proxies to :8787, so we // must accept that origin too. const csrfOrigins = isLoopback() ? [ `http://localhost:${config.port}`, `http://127.0.0.1:${config.port}`, "http://localhost:5173", "http://127.0.0.1:5173", ] : [publicBase()]; const csrfGuard = csrf({ origin: csrfOrigins }); app.use("/auth/*", csrfGuard); app.use("/api/*", csrfGuard); // Production OAuth needs the client metadata document served at a stable URL. if (!isLoopback()) { app.get("/client-metadata.json", (c) => { return c.json(oauth.client.clientMetadata); }); app.get("/jwks.json", (c) => { return c.json(oauth.client.jwks); }); } app.route("/auth", authRoutes(oauth, deviceTokens)); app.route("/api", apiRoutes(oauth, mf, cache)); app.route("/device", deviceRoutes(oauth, mf, bodies, deviceTokens, cache)); const packageRoot = resolve(import.meta.dirname, "../.."); const publicDir = resolve(packageRoot, "dist/public"); const indexPath = resolve(publicDir, "index.html"); const indexHtml = existsSync(indexPath) ? readFileSync(indexPath, "utf8") : ""; app.use("/*", serveStatic({ root: publicDir })); app.get("*", (c) => { if (!indexHtml) return c.text("Frontend not built. Run `pnpm build`.\n"); return c.html(indexHtml); }); // Populate cache on startup for all stored DIDs. (async () => { for (const did of oauth.listDids()) { const session = await oauth.getSessionForDid(did); if (!session) continue; try { await cache.syncAll(new AtprotoRepo(session)); console.log(`cache: populated for ${did}`); } catch (e) { console.error(`cache: startup sync failed for ${did}:`, e); } } })(); // Drop orphaned oauth_state rows (abandoned authorize flows) now and on a // timer so the table can't grow without bound. sweepOauthState(); setInterval(() => sweepOauthState(), OAUTH_STATE_TTL_MS); // Jetstream listener for real-time cache updates. const jetstream = new JetstreamListener(cache, () => oauth.listDids()); jetstream.start(); // Background sync every 5 minutes for every stored DID. setInterval( async () => { for (const did of oauth.listDids()) { const session = await oauth.getSessionForDid(did); if (!session) continue; const repo = new AtprotoRepo(session); try { await cache.syncAll(repo); } catch (e) { console.error(`cache: periodic sync failed for ${did}:`, e); } if (!minifluxAllowed(did)) continue; const syncer = new Syncer(config.dataDir, repo, mf); try { const res = await syncer.run(); if (res.added || res.removed) { console.log(`sync[${did}]: +${res.added} -${res.removed}`); } } catch (e) { console.error(`sync[${did}] failed:`, e); } } }, 5 * 60 * 1000, ); serve( { fetch: app.fetch, port: config.port, hostname: "0.0.0.0" }, (info) => { console.log( `nightshade on http://${info.address}:${info.port} ` + `(public: ${publicBase()}, ${isLoopback() ? "loopback OAuth" : "production OAuth"})`, ); }, );