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}