From 619515db966be77c82fef7281b64389093eb4860 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 20:03:48 +0000 Subject: [PATCH] Replace admin username/password with a per-launch API key, and disable the admin panel by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two hardening changes beyond just a password: - The admin panel no longer uses stored credentials at all. The backend generates a random API key on every startup and prints it to its own console (never through the DB-backed logger, since that's only reachable from inside the panel this key protects). Every /api/admin/* request must carry it as an X-Api-Key header, checked with a timing-safe comparison on every call — there's no session to create or steal, and restarting the backend invalidates the previous key immediately. The old admin_users and sessions tables, scrypt password hashing, and cookie-based session plumbing are removed entirely (dropped via migration for existing installs, not left behind unused). The login page keeps its existing layout but now asks for this key and explains where to find it, storing it in the browser's localStorage rather than relying on a server session. - The admin panel (the masthead's cog icon and the /admin/* pages themselves) is now disabled by default on every deployment, gated by a new frontend-only ADMIN_PANEL_ENABLED env var. This is a separate, UI-only visibility control — the API key above is what actually protects the backend regardless of this flag. --- README.md | 7 ++- backend/.env.example | 7 +-- backend/README.md | 15 ++++-- backend/package-lock.json | 34 ------------ backend/package.json | 1 - backend/src/api/apiKey.ts | 8 +++ backend/src/api/auth.ts | 57 ++++++++------------ backend/src/api/password.ts | 19 ------- backend/src/index.ts | 22 +++++--- backend/src/queue/scheduler.ts | 2 - backend/src/storage/db/auth.ts | 53 ------------------ backend/src/storage/db/index.ts | 21 +++----- frontend/.env.example | 11 ++++ frontend/package-lock.json | 18 +++++++ frontend/package.json | 1 + frontend/src/lib/adminApi.ts | 34 ++++++------ frontend/src/lib/adminAuth.ts | 23 ++++++++ frontend/src/routes/+layout.server.ts | 9 ++++ frontend/src/routes/+layout.svelte | 18 ++++--- frontend/src/routes/+layout.ts | 4 +- frontend/src/routes/admin/+layout.svelte | 31 +++++++---- frontend/src/routes/admin/+layout.ts | 11 ++-- frontend/src/routes/admin/login/+page.svelte | 22 +++++--- 23 files changed, 206 insertions(+), 222 deletions(-) create mode 100644 backend/src/api/apiKey.ts delete mode 100644 backend/src/api/password.ts delete mode 100644 backend/src/storage/db/auth.ts create mode 100644 frontend/.env.example create mode 100644 frontend/src/lib/adminAuth.ts create mode 100644 frontend/src/routes/+layout.server.ts diff --git a/README.md b/README.md index 9e4a003..6f793db 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,14 @@ doesn't know or care which one it's talking to. Switch between them by changing ```bash cd backend -cp .env.example .env # set ADMIN_PASSWORD at minimum +cp .env.example .env npm install npm run dev ``` +The console prints an admin API key on every startup (a fresh one each time) — copy +it into the admin login page. See `backend/README.md` for details. + See `backend/README.md` for what's fully implemented vs. stubbed (Telegram adapter, image-selection heuristic vs. vision model, etc.), and how it behaves when Ollama isn't reachable. @@ -49,7 +52,7 @@ Open http://localhost:5173. - **Article page** (`/article/:id`) — merge badge, hero image with single-source attribution, body, video slot, tag chips, thread continuation banners (both directions — "newer coverage" / "earlier coverage"), sources footer - **Article cards** — show source count (`⇄ N sources`), single-source attribution, or a video indicator, matching the design decided earlier - **Light/dark theme toggle** — slider in the masthead, top right, left of the settings cog. Dark is a genuine slate palette (not an inverted light theme). Persists via `localStorage`, respects system preference on first load, no flash-of-wrong-theme (set before hydration in `app.html`). -- **Admin panel** (`/admin/settings`) — six tabs, all wired to the mock backend's `/api/admin/*` routes: +- **Admin panel** (`/admin/settings`) — disabled by default; set `ADMIN_PANEL_ENABLED=true` in `frontend/.env` to turn on the cog icon and the `/admin/*` pages (see `frontend/.env.example`). Six tabs, all wired to the mock backend's `/api/admin/*` routes: - **Merge** — strictness slider, poll interval, hold-before-publish, follow-up thresholds, category priority (reorderable), tag dedup threshold, tag expiry - **Sources** — list, add, enable/disable, delete RSS/API/Telegram feeds - **Models** — AI service status, per-task model selection (embedding/image/synthesis), fetched from the mock's simulated Ollama catalog diff --git a/backend/.env.example b/backend/.env.example index ff2a53b..1099225 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -3,8 +3,9 @@ FRONTEND_ORIGIN=http://localhost:5173 DB_PATH=./data/homefeed.db MEDIA_DIR=./data/media -# Seeded once on first run — change the password after logging in. -ADMIN_USERNAME=admin -ADMIN_PASSWORD=change-me-immediately +# There's no admin username/password to configure here — the backend generates a +# random API key on every startup and prints it to the console. Copy that key into +# the admin login page (every /api/admin/* request requires it as an X-Api-Key +# header). It changes on every restart, so check the console output each time. NODE_ENV=development diff --git a/backend/README.md b/backend/README.md index 17af50d..6112807 100644 --- a/backend/README.md +++ b/backend/README.md @@ -10,7 +10,6 @@ contract the frontend already consumes from the mock backend — plus the full ```bash cp .env.example .env -# edit .env — at minimum set ADMIN_PASSWORD to something real npm install npm run dev ``` @@ -18,6 +17,14 @@ npm run dev Runs on `:4000` by default. Point the frontend's `VITE_BACKEND_URL` at it instead of the mock backend and everything else keeps working unchanged — same API contract. +On startup, the console prints an admin API key — a fresh random value generated +every time the process starts (see `api/apiKey.ts`), not stored anywhere and not +configurable via `.env`. Every `/api/admin/*` request must send it as an +`X-Api-Key` header (enforced in `api/auth.ts`); the admin login page just asks for +this key and stashes it in the browser's `localStorage` rather than issuing its own +session. Restarting the backend invalidates the previous key — check the console +each time. + You'll also need a running Ollama instance (see `AI_SERVICE_HOST`/`AI_SERVICE_PORT` in the admin panel's Connections tab, or `PATCH /api/admin/settings` directly) with at minimum an embedding model (e.g. `nomic-embed-text`) and a generation model (e.g. @@ -88,8 +95,10 @@ real RSS parsing, real HTTP calls to a stub Ollama server, real media download t disk, real tag dedup across separate synthesis calls): - SQLite schema + repository layer for every entity in `homefeed-data-schema.md` -- Session auth (scrypt password hashing, httpOnly cookie, CORS locked to the - configured frontend origin) protecting all `/api/admin/*` routes +- Per-launch API key auth (random key printed to the console on every startup, + checked via a timing-safe comparison against an `X-Api-Key` header on every + request, CORS locked to the configured frontend origin) protecting all + `/api/admin/*` routes - RSS adapter (real parsing, images/video extraction) and a generic JSON API adapter (configurable field mapping) - Poller respecting per-source poll intervals diff --git a/backend/package-lock.json b/backend/package-lock.json index 99e68c6..9a48c20 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,7 +9,6 @@ "version": "1.0.0", "license": "UNLICENSED", "dependencies": { - "@fastify/cookie": "^11.1.1", "@fastify/cors": "^11.3.0", "@mozilla/readability": "^0.6.0", "fastify": "^5.10.0", @@ -696,26 +695,6 @@ "fast-uri": "^3.0.0" } }, - "node_modules/@fastify/cookie": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.1.tgz", - "integrity": "sha512-sJ0NXzGVYjUB4OynPZRsIcQ1mKSP4rW45xLCN0aelRq5Vl37xVVbz5kJ6Y0a9m2T0mCUjYCuvlUA9QlTafrZWw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "cookie": "^2.0.0", - "fastify-plugin": "^6.0.0" - } - }, "node_modules/@fastify/cors": { "version": "11.3.0", "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.3.0.tgz", @@ -1295,19 +1274,6 @@ "require-from-string": "^2.0.2" } }, - "node_modules/cookie": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", - "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", - "license": "MIT", - "engines": { - "node": ">=22" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", diff --git a/backend/package.json b/backend/package.json index 154f1dc..6bd1d52 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,7 +12,6 @@ }, "license": "UNLICENSED", "dependencies": { - "@fastify/cookie": "^11.1.1", "@fastify/cors": "^11.3.0", "@mozilla/readability": "^0.6.0", "fastify": "^5.10.0", diff --git a/backend/src/api/apiKey.ts b/backend/src/api/apiKey.ts new file mode 100644 index 0000000..4208d87 --- /dev/null +++ b/backend/src/api/apiKey.ts @@ -0,0 +1,8 @@ +// Generated once per process start — not persisted, not configurable via env. Every +// restart invalidates the previous key, which is the whole point: the only way to +// learn the current key is to have console/log access to the running process (see +// index.ts's startup banner), which is a meaningfully different trust boundary than a +// password someone could guess or brute-force over the network. +import { randomBytes } from 'node:crypto'; + +export const ADMIN_API_KEY = randomBytes(24).toString('hex'); diff --git a/backend/src/api/auth.ts b/backend/src/api/auth.ts index e791c2f..089dd25 100644 --- a/backend/src/api/auth.ts +++ b/backend/src/api/auth.ts @@ -1,44 +1,31 @@ import type { FastifyInstance } from 'fastify'; -import { getAdminUserByUsername, createSession, isSessionValid, deleteSession } from '../storage/db/auth.js'; -import { verifyPassword } from './password.js'; +import { timingSafeEqual } from 'node:crypto'; +import { ADMIN_API_KEY } from './apiKey.js'; -const SESSION_COOKIE = 'homefeed_session'; +function isValidKey(provided: string | undefined): boolean { + if (!provided) return false; + // Buffers of mismatched length would make timingSafeEqual throw rather than + // return false — checking length first keeps this a normal "wrong key" case for + // any header of a different length rather than a runtime error. + const providedBuf = Buffer.from(provided); + const expectedBuf = Buffer.from(ADMIN_API_KEY); + if (providedBuf.length !== expectedBuf.length) return false; + return timingSafeEqual(providedBuf, expectedBuf); +} +/** + * Guards every /api/admin/* route with the process's current API key (see + * api/apiKey.ts) — there's no session or login endpoint anymore: the key itself is + * the credential, checked on every single request, exactly the way a bot or curl + * script hitting these routes unauthenticated is meant to be stopped cold. + */ export async function registerAuth(app: FastifyInstance) { - app.post('/api/admin/login', async (req, reply) => { - const { username, password } = req.body as { username?: string; password?: string }; - if (!username || !password) return reply.code(400).send({ error: 'username and password required' }); - - const user = getAdminUserByUsername(username); - if (!user || !verifyPassword(password, user.password_hash)) { - // Deliberately generic — doesn't reveal whether the username exists. - return reply.code(401).send({ error: 'invalid credentials' }); - } - - const session = createSession(req.ip ?? null); - reply.setCookie(SESSION_COOKIE, session.id, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax', - path: '/', - expires: new Date(session.expiresAt) - }); - return { ok: true }; - }); - - app.post('/api/admin/logout', async (req, reply) => { - const sessionId = req.cookies[SESSION_COOKIE]; - if (sessionId) deleteSession(sessionId); - reply.clearCookie(SESSION_COOKIE, { path: '/' }); - return { ok: true }; - }); - - // Guards every /api/admin/* route except login itself. app.addHook('preHandler', async (req, reply) => { - if (!req.url.startsWith('/api/admin/') || req.url === '/api/admin/login') return; + if (!req.url.startsWith('/api/admin/')) return; - const sessionId = req.cookies[SESSION_COOKIE]; - if (!sessionId || !isSessionValid(sessionId)) { + const header = req.headers['x-api-key']; + const provided = Array.isArray(header) ? header[0] : header; + if (!isValidKey(provided)) { return reply.code(401).send({ error: 'unauthorized' }); } }); diff --git a/backend/src/api/password.ts b/backend/src/api/password.ts deleted file mode 100644 index 82a9a7c..0000000 --- a/backend/src/api/password.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'; - -const KEY_LEN = 64; - -export function hashPassword(password: string): string { - const salt = randomBytes(16); - const hash = scryptSync(password, salt, KEY_LEN); - return `${salt.toString('hex')}:${hash.toString('hex')}`; -} - -export function verifyPassword(password: string, stored: string): boolean { - const [saltHex, hashHex] = stored.split(':'); - if (!saltHex || !hashHex) return false; - const salt = Buffer.from(saltHex, 'hex'); - const expected = Buffer.from(hashHex, 'hex'); - const actual = scryptSync(password, salt, KEY_LEN); - if (actual.length !== expected.length) return false; - return timingSafeEqual(actual, expected); -} diff --git a/backend/src/index.ts b/backend/src/index.ts index 21efe39..98746b5 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,10 +1,9 @@ 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 { ensureAdminUserSeeded } from './storage/db/auth.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'; @@ -15,12 +14,21 @@ 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'; +function printApiKeyBanner() { + const line = '='.repeat(64); + // Deliberately console.log, not the DB-backed logger — the Logs tab in the admin + // panel is itself behind this key, so printing there would be unreachable until + // you already have the key. This is the one and only place it's ever surfaced. + 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(`${line}\n`); +} + async function main() { migrate(); - ensureAdminUserSeeded( - process.env.ADMIN_USERNAME || 'admin', - process.env.ADMIN_PASSWORD || 'change-me-immediately' - ); + printApiKeyBanner(); const app = Fastify({ logger: false }); @@ -32,10 +40,8 @@ async function main() { // reaches a route handler. 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, diff --git a/backend/src/queue/scheduler.ts b/backend/src/queue/scheduler.ts index 783dd20..433a209 100644 --- a/backend/src/queue/scheduler.ts +++ b/backend/src/queue/scheduler.ts @@ -4,7 +4,6 @@ import { runEventRecaps } from './eventsRecap.js'; import { runRetentionSweep } from './retention.js'; import { OllamaProvider } from '../inference/ollama-provider.js'; import * as settingsDb from '../storage/db/settings.js'; -import { pruneExpiredSessions } from '../storage/db/auth.js'; import { logger } from '../storage/db/logs.js'; const POLL_TICK_MS = 60_000; // checks which sources are due every minute; each source's own interval governs actual fetch frequency @@ -56,7 +55,6 @@ export function startScheduler() { setInterval(() => { try { runRetentionSweep(settingsDb.getSettings()); - pruneExpiredSessions(); logger.info('retention', 'Retention sweep completed'); } catch (err) { logger.error('retention', `Retention tick failed: ${(err as Error).message}`); diff --git a/backend/src/storage/db/auth.ts b/backend/src/storage/db/auth.ts deleted file mode 100644 index cbaa42b..0000000 --- a/backend/src/storage/db/auth.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import { db } from './index.js'; -import { hashPassword } from '../../api/password.js'; -import { logger } from './logs.js'; - -const SESSION_TTL_HOURS = 24; - -export function ensureAdminUserSeeded(defaultUsername: string, defaultPassword: string) { - const existing = db.prepare('SELECT id FROM admin_users LIMIT 1').get(); - if (existing) return; - db.prepare('INSERT INTO admin_users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)').run( - randomUUID(), - defaultUsername, - hashPassword(defaultPassword), - new Date().toISOString() - ); - logger.warn('auth', `Seeded initial admin user "${defaultUsername}". Change this password after first login.`); -} - -export function getAdminUserByUsername(username: string) { - return db.prepare('SELECT * FROM admin_users WHERE username = ?').get(username) as - | { id: string; username: string; password_hash: string } - | undefined; -} - -export function createSession(ip: string | null): { id: string; expiresAt: string } { - const id = randomUUID(); - const now = new Date(); - const expiresAt = new Date(now.getTime() + SESSION_TTL_HOURS * 3600_000).toISOString(); - db.prepare('INSERT INTO sessions (id, created_at, expires_at, ip) VALUES (?, ?, ?, ?)').run( - id, - now.toISOString(), - expiresAt, - ip - ); - return { id, expiresAt }; -} - -export function isSessionValid(id: string): boolean { - const row = db.prepare('SELECT expires_at FROM sessions WHERE id = ?').get(id) as - | { expires_at: string } - | undefined; - if (!row) return false; - return new Date(row.expires_at).getTime() > Date.now(); -} - -export function deleteSession(id: string) { - db.prepare('DELETE FROM sessions WHERE id = ?').run(id); -} - -export function pruneExpiredSessions() { - db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(new Date().toISOString()); -} diff --git a/backend/src/storage/db/index.ts b/backend/src/storage/db/index.ts index 1cb89af..d14223b 100644 --- a/backend/src/storage/db/index.ts +++ b/backend/src/storage/db/index.ts @@ -18,6 +18,13 @@ db.exec('PRAGMA journal_mode = WAL;'); db.exec('PRAGMA foreign_keys = ON;'); export function migrate() { + // Admin auth moved from username/password + sessions to a per-launch API key (see + // api/apiKey.ts, api/auth.ts) — these tables, and any stored password hash or live + // session in them, have no further purpose and are dropped rather than left as + // orphaned schema/data. + db.exec('DROP TABLE IF EXISTS admin_users;'); + db.exec('DROP TABLE IF EXISTS sessions;'); + db.exec(` CREATE TABLE IF NOT EXISTS sources ( id TEXT PRIMARY KEY, @@ -133,20 +140,6 @@ export function migrate() { is_default INTEGER NOT NULL DEFAULT 0 ); - CREATE TABLE IF NOT EXISTS admin_users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, - created_at TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY, - created_at TEXT NOT NULL, - expires_at TEXT NOT NULL, - ip TEXT - ); - CREATE TABLE IF NOT EXISTS logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp TEXT NOT NULL, diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..acb3453 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,11 @@ +# Where the frontend talks to the backend (real or mock). Also settable at runtime +# via the connection setup screen, which saves to this browser's localStorage and +# takes priority over this build-time value — see src/lib/config.ts. +VITE_BACKEND_URL=http://localhost:4000 + +# The admin panel (cog icon in the masthead, and the /admin/* pages themselves) is +# disabled by default on every deployment. Set this to "true" to turn it on for a +# given deployment. This only controls whether the admin UI renders at all — the +# backend's per-launch API key (printed to its console on startup) is what actually +# protects every /api/admin/* request regardless of this setting. +ADMIN_PANEL_ENABLED=false diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7408bbe..075a917 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/kit": "^2.63.0", "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@types/node": "^26.1.1", "svelte": "^5.56.1", "svelte-check": "^4.6.0", "typescript": "^6.0.3", @@ -525,6 +526,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -1281,6 +1292,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "8.1.4", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index bd0f349..e773af8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/kit": "^2.63.0", "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@types/node": "^26.1.1", "svelte": "^5.56.1", "svelte-check": "^4.6.0", "typescript": "^6.0.3", diff --git a/frontend/src/lib/adminApi.ts b/frontend/src/lib/adminApi.ts index ed17f0e..7207ac0 100644 --- a/frontend/src/lib/adminApi.ts +++ b/frontend/src/lib/adminApi.ts @@ -1,4 +1,5 @@ import { getBackendUrl } from './config'; +import { getApiKey, setApiKey, clearApiKey } from './adminAuth'; import type { AdminSettings, AdminSource, @@ -13,10 +14,13 @@ async function request(path: string, options: RequestInit = {}, fetchFn: type // application/json ("Body cannot be empty when content-type is set to // 'application/json'") — so this header is only attached when there's actually a // body to send (PATCH/POST with a JSON payload), never for bodyless DELETE/POST calls. - const headers = options.body ? { 'Content-Type': 'application/json', ...(options.headers || {}) } : options.headers; + const headers: Record = { ...(options.headers as Record | undefined) }; + if (options.body) headers['Content-Type'] = 'application/json'; + const apiKey = getApiKey(); + if (apiKey) headers['X-Api-Key'] = apiKey; + const res = await fetchFn(`${getBackendUrl()}${path}`, { ...options, - credentials: 'include', headers }); if (res.status === 401) { @@ -29,22 +33,22 @@ async function request(path: string, options: RequestInit = {}, fetchFn: type return res.json(); } -// Auth -export async function login(username: string, password: string, fetchFn: typeof fetch = fetch): Promise { - const res = await fetchFn(`${getBackendUrl()}/api/admin/login`, { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, password }) - }); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.error || `Login failed (${res.status})`); +// Auth — there's no backend session to create; "logging in" means storing the +// entered key locally and confirming it actually works with one real authenticated +// call (getSettings has no side effects), and "logging out" is just discarding it. +export async function login(apiKey: string, fetchFn: typeof fetch = fetch): Promise { + setApiKey(apiKey); + try { + await getSettings(fetchFn); + } catch (err) { + clearApiKey(); + if ((err as { status?: number }).status === 401) throw new Error('Invalid API key'); + throw err; } } -export async function logout(fetchFn: typeof fetch = fetch): Promise { - await fetchFn(`${getBackendUrl()}/api/admin/logout`, { method: 'POST', credentials: 'include' }); +export async function logout(): Promise { + clearApiKey(); } // Settings diff --git a/frontend/src/lib/adminAuth.ts b/frontend/src/lib/adminAuth.ts new file mode 100644 index 0000000..0dacd35 --- /dev/null +++ b/frontend/src/lib/adminAuth.ts @@ -0,0 +1,23 @@ +// The admin API key isn't a backend-issued session — it lives entirely in this +// browser's localStorage, attached as an X-Api-Key header on every /api/admin/* +// request (see adminApi.ts). There's nothing to invalidate server-side on "logout"; +// clearing it here is the whole operation. + +const STORAGE_KEY = 'homefeed:adminApiKey'; + +export function getApiKey(): string | null { + if (typeof localStorage === 'undefined') return null; + return localStorage.getItem(STORAGE_KEY); +} + +export function setApiKey(key: string) { + if (typeof localStorage !== 'undefined') { + localStorage.setItem(STORAGE_KEY, key); + } +} + +export function clearApiKey() { + if (typeof localStorage !== 'undefined') { + localStorage.removeItem(STORAGE_KEY); + } +} diff --git a/frontend/src/routes/+layout.server.ts b/frontend/src/routes/+layout.server.ts new file mode 100644 index 0000000..65dcf89 --- /dev/null +++ b/frontend/src/routes/+layout.server.ts @@ -0,0 +1,9 @@ +import type { LayoutServerLoad } from './$types'; + +// The admin panel is off by default on every deployment — it only appears (cog icon +// and the /admin/* pages themselves, see admin/+layout.svelte) once this is +// explicitly turned on. This is a UI-visibility gate only; the backend's API key +// check on every /api/admin/* request is what actually protects it either way. +export const load: LayoutServerLoad = async () => { + return { adminPanelEnabled: process.env.ADMIN_PANEL_ENABLED === 'true' }; +}; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 92d4cd0..515475d 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -37,14 +37,16 @@
- - - - - - + {#if data.adminPanelEnabled} + + + + + + + {/if}
diff --git a/frontend/src/routes/+layout.ts b/frontend/src/routes/+layout.ts index d60fd71..00c50c0 100644 --- a/frontend/src/routes/+layout.ts +++ b/frontend/src/routes/+layout.ts @@ -1,7 +1,7 @@ import type { LayoutLoad } from './$types'; import { getCategories } from '$lib/api'; -export const load: LayoutLoad = async ({ fetch }) => { +export const load: LayoutLoad = async ({ fetch, data }) => { const categories = await getCategories(fetch); - return { categories }; + return { ...data, categories }; }; diff --git a/frontend/src/routes/admin/+layout.svelte b/frontend/src/routes/admin/+layout.svelte index f6514fe..1ad91ab 100644 --- a/frontend/src/routes/admin/+layout.svelte +++ b/frontend/src/routes/admin/+layout.svelte @@ -1,8 +1,9 @@ -
-
-
- ← Back to site - -
- {@render children()} +{#if !data.adminPanelEnabled} +
+

The admin panel is disabled on this deployment.

-
+{:else} +
+
+
+ ← Back to site + +
+ {@render children()} +
+
+{/if}