Merge pull request #4 from Salastil/claude/source-management-content-1w2yno

Replace admin username/password with a per-launch API key, and disabl…
This commit is contained in:
Salastil
2026-07-21 16:06:17 -04:00
committed by GitHub
23 changed files with 206 additions and 222 deletions
+5 -2
View File
@@ -15,11 +15,14 @@ doesn't know or care which one it's talking to. Switch between them by changing
```bash ```bash
cd backend cd backend
cp .env.example .env # set ADMIN_PASSWORD at minimum cp .env.example .env
npm install npm install
npm run dev 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, 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 image-selection heuristic vs. vision model, etc.), and how it behaves when Ollama
isn't reachable. 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 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 - **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`). - **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 - **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 - **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 - **Models** — AI service status, per-task model selection (embedding/image/synthesis), fetched from the mock's simulated Ollama catalog
+4 -3
View File
@@ -3,8 +3,9 @@ FRONTEND_ORIGIN=http://localhost:5173
DB_PATH=./data/homefeed.db DB_PATH=./data/homefeed.db
MEDIA_DIR=./data/media MEDIA_DIR=./data/media
# Seeded once on first run — change the password after logging in. # There's no admin username/password to configure here — the backend generates a
ADMIN_USERNAME=admin # random API key on every startup and prints it to the console. Copy that key into
ADMIN_PASSWORD=change-me-immediately # 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 NODE_ENV=development
+12 -3
View File
@@ -10,7 +10,6 @@ contract the frontend already consumes from the mock backend — plus the full
```bash ```bash
cp .env.example .env cp .env.example .env
# edit .env — at minimum set ADMIN_PASSWORD to something real
npm install npm install
npm run dev 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 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. 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 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 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. 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): disk, real tag dedup across separate synthesis calls):
- SQLite schema + repository layer for every entity in `homefeed-data-schema.md` - SQLite schema + repository layer for every entity in `homefeed-data-schema.md`
- Session auth (scrypt password hashing, httpOnly cookie, CORS locked to the - Per-launch API key auth (random key printed to the console on every startup,
configured frontend origin) protecting all `/api/admin/*` routes 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 - RSS adapter (real parsing, images/video extraction) and a generic JSON API adapter
(configurable field mapping) (configurable field mapping)
- Poller respecting per-source poll intervals - Poller respecting per-source poll intervals
-34
View File
@@ -9,7 +9,6 @@
"version": "1.0.0", "version": "1.0.0",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.1.1",
"@fastify/cors": "^11.3.0", "@fastify/cors": "^11.3.0",
"@mozilla/readability": "^0.6.0", "@mozilla/readability": "^0.6.0",
"fastify": "^5.10.0", "fastify": "^5.10.0",
@@ -696,26 +695,6 @@
"fast-uri": "^3.0.0" "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": { "node_modules/@fastify/cors": {
"version": "11.3.0", "version": "11.3.0",
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.3.0.tgz", "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.3.0.tgz",
@@ -1295,19 +1274,6 @@
"require-from-string": "^2.0.2" "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": { "node_modules/css-tree": {
"version": "3.2.1", "version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
-1
View File
@@ -12,7 +12,6 @@
}, },
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.1.1",
"@fastify/cors": "^11.3.0", "@fastify/cors": "^11.3.0",
"@mozilla/readability": "^0.6.0", "@mozilla/readability": "^0.6.0",
"fastify": "^5.10.0", "fastify": "^5.10.0",
+8
View File
@@ -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');
+22 -35
View File
@@ -1,44 +1,31 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { getAdminUserByUsername, createSession, isSessionValid, deleteSession } from '../storage/db/auth.js'; import { timingSafeEqual } from 'node:crypto';
import { verifyPassword } from './password.js'; 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) { 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) => { 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]; const header = req.headers['x-api-key'];
if (!sessionId || !isSessionValid(sessionId)) { const provided = Array.isArray(header) ? header[0] : header;
if (!isValidKey(provided)) {
return reply.code(401).send({ error: 'unauthorized' }); return reply.code(401).send({ error: 'unauthorized' });
} }
}); });
-19
View File
@@ -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);
}
+14 -8
View File
@@ -1,10 +1,9 @@
import Fastify from 'fastify'; import Fastify from 'fastify';
import cors from '@fastify/cors'; import cors from '@fastify/cors';
import cookie from '@fastify/cookie';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { migrate } from './storage/db/index.js'; 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 { registerAuth } from './api/auth.js';
import { registerPublicRoutes } from './api/public.js'; import { registerPublicRoutes } from './api/public.js';
import { registerAdminRoutes } from './api/admin.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 FRONTEND_ORIGIN = process.env.FRONTEND_ORIGIN || 'http://localhost:5173';
const MEDIA_DIR = process.env.MEDIA_DIR || './data/media'; 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() { async function main() {
migrate(); migrate();
ensureAdminUserSeeded( printApiKeyBanner();
process.env.ADMIN_USERNAME || 'admin',
process.env.ADMIN_PASSWORD || 'change-me-immediately'
);
const app = Fastify({ logger: false }); const app = Fastify({ logger: false });
@@ -32,10 +40,8 @@ async function main() {
// reaches a route handler. // reaches a route handler.
await app.register(cors, { await app.register(cors, {
origin: FRONTEND_ORIGIN, origin: FRONTEND_ORIGIN,
credentials: true,
methods: ['GET', 'POST', 'PATCH', 'DELETE', 'PUT', 'OPTIONS'] methods: ['GET', 'POST', 'PATCH', 'DELETE', 'PUT', 'OPTIONS']
}); });
await app.register(cookie);
// Overrides Fastify's default JSON body parser, which throws "Body cannot be empty // 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, // when content-type is set to 'application/json'" for any bodyless request (DELETE,
-2
View File
@@ -4,7 +4,6 @@ import { runEventRecaps } from './eventsRecap.js';
import { runRetentionSweep } from './retention.js'; import { runRetentionSweep } from './retention.js';
import { OllamaProvider } from '../inference/ollama-provider.js'; import { OllamaProvider } from '../inference/ollama-provider.js';
import * as settingsDb from '../storage/db/settings.js'; import * as settingsDb from '../storage/db/settings.js';
import { pruneExpiredSessions } from '../storage/db/auth.js';
import { logger } from '../storage/db/logs.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 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(() => { setInterval(() => {
try { try {
runRetentionSweep(settingsDb.getSettings()); runRetentionSweep(settingsDb.getSettings());
pruneExpiredSessions();
logger.info('retention', 'Retention sweep completed'); logger.info('retention', 'Retention sweep completed');
} catch (err) { } catch (err) {
logger.error('retention', `Retention tick failed: ${(err as Error).message}`); logger.error('retention', `Retention tick failed: ${(err as Error).message}`);
-53
View File
@@ -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());
}
+7 -14
View File
@@ -18,6 +18,13 @@ db.exec('PRAGMA journal_mode = WAL;');
db.exec('PRAGMA foreign_keys = ON;'); db.exec('PRAGMA foreign_keys = ON;');
export function migrate() { 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(` db.exec(`
CREATE TABLE IF NOT EXISTS sources ( CREATE TABLE IF NOT EXISTS sources (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -133,20 +140,6 @@ export function migrate() {
is_default INTEGER NOT NULL DEFAULT 0 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 ( CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL, timestamp TEXT NOT NULL,
+11
View File
@@ -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
+18
View File
@@ -11,6 +11,7 @@
"@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/kit": "^2.63.0", "@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/node": "^26.1.1",
"svelte": "^5.56.1", "svelte": "^5.56.1",
"svelte-check": "^4.6.0", "svelte-check": "^4.6.0",
"typescript": "^6.0.3", "typescript": "^6.0.3",
@@ -525,6 +526,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/trusted-types": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -1281,6 +1292,13 @@
"node": ">=14.17" "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": { "node_modules/vite": {
"version": "8.1.4", "version": "8.1.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz",
+1
View File
@@ -15,6 +15,7 @@
"@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/kit": "^2.63.0", "@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/node": "^26.1.1",
"svelte": "^5.56.1", "svelte": "^5.56.1",
"svelte-check": "^4.6.0", "svelte-check": "^4.6.0",
"typescript": "^6.0.3", "typescript": "^6.0.3",
+19 -15
View File
@@ -1,4 +1,5 @@
import { getBackendUrl } from './config'; import { getBackendUrl } from './config';
import { getApiKey, setApiKey, clearApiKey } from './adminAuth';
import type { import type {
AdminSettings, AdminSettings,
AdminSource, AdminSource,
@@ -13,10 +14,13 @@ async function request<T>(path: string, options: RequestInit = {}, fetchFn: type
// application/json ("Body cannot be empty when content-type is set to // 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 // '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. // 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<string, string> = { ...(options.headers as Record<string, string> | 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}`, { const res = await fetchFn(`${getBackendUrl()}${path}`, {
...options, ...options,
credentials: 'include',
headers headers
}); });
if (res.status === 401) { if (res.status === 401) {
@@ -29,22 +33,22 @@ async function request<T>(path: string, options: RequestInit = {}, fetchFn: type
return res.json(); return res.json();
} }
// Auth // Auth — there's no backend session to create; "logging in" means storing the
export async function login(username: string, password: string, fetchFn: typeof fetch = fetch): Promise<void> { // entered key locally and confirming it actually works with one real authenticated
const res = await fetchFn(`${getBackendUrl()}/api/admin/login`, { // call (getSettings has no side effects), and "logging out" is just discarding it.
method: 'POST', export async function login(apiKey: string, fetchFn: typeof fetch = fetch): Promise<void> {
credentials: 'include', setApiKey(apiKey);
headers: { 'Content-Type': 'application/json' }, try {
body: JSON.stringify({ username, password }) await getSettings(fetchFn);
}); } catch (err) {
if (!res.ok) { clearApiKey();
const body = await res.json().catch(() => ({})); if ((err as { status?: number }).status === 401) throw new Error('Invalid API key');
throw new Error(body.error || `Login failed (${res.status})`); throw err;
} }
} }
export async function logout(fetchFn: typeof fetch = fetch): Promise<void> { export async function logout(): Promise<void> {
await fetchFn(`${getBackendUrl()}/api/admin/logout`, { method: 'POST', credentials: 'include' }); clearApiKey();
} }
// Settings // Settings
+23
View File
@@ -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);
}
}
+9
View File
@@ -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' };
};
+2
View File
@@ -37,6 +37,7 @@
</nav> </nav>
<div class="controls"> <div class="controls">
<ThemeToggle /> <ThemeToggle />
{#if data.adminPanelEnabled}
<a class="cog" href="/admin/settings" aria-label="Admin settings" title="Admin settings"> <a class="cog" href="/admin/settings" aria-label="Admin settings" title="Admin settings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="3" /> <circle cx="12" cy="12" r="3" />
@@ -45,6 +46,7 @@
/> />
</svg> </svg>
</a> </a>
{/if}
</div> </div>
</div> </div>
</header> </header>
+2 -2
View File
@@ -1,7 +1,7 @@
import type { LayoutLoad } from './$types'; import type { LayoutLoad } from './$types';
import { getCategories } from '$lib/api'; import { getCategories } from '$lib/api';
export const load: LayoutLoad = async ({ fetch }) => { export const load: LayoutLoad = async ({ fetch, data }) => {
const categories = await getCategories(fetch); const categories = await getCategories(fetch);
return { categories }; return { ...data, categories };
}; };
+16 -3
View File
@@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { logout } from '$lib/adminApi'; import { logout } from '$lib/adminApi';
import type { LayoutData } from './$types';
let { children } = $props(); let { children, data }: { children: any; data: LayoutData } = $props();
async function handleLogout() { async function handleLogout() {
await logout(); await logout();
@@ -10,7 +11,12 @@
} }
</script> </script>
<div class="admin-shell"> {#if !data.adminPanelEnabled}
<div class="page disabled-notice">
<p>The admin panel is disabled on this deployment.</p>
</div>
{:else}
<div class="admin-shell">
<div class="page admin-inner"> <div class="page admin-inner">
<div class="top-row"> <div class="top-row">
<a class="back" href="/">← Back to site</a> <a class="back" href="/">← Back to site</a>
@@ -18,9 +24,16 @@
</div> </div>
{@render children()} {@render children()}
</div> </div>
</div> </div>
{/if}
<style> <style>
.disabled-notice {
padding-top: 60px;
text-align: center;
color: var(--text-secondary);
font-size: 14px;
}
.admin-shell { .admin-shell {
min-height: 100vh; min-height: 100vh;
} }
+5 -6
View File
@@ -1,7 +1,6 @@
// The admin section talks to a different origin (the backend) than the frontend // The admin API key lives in this browser's localStorage (see adminAuth.ts), which
// itself. During SSR, the `load` function's fetch runs on the Node server, which has // is only reachable from client-side code — a server-rendered `load` function
// no access to the browser's cookie jar — it can't attach the session cookie to a // running on the Node server during SSR has no access to it and couldn't attach it
// cross-origin request. Disabling SSR here means all admin data fetching happens in // to a cross-origin request. Disabling SSR here means all admin data fetching
// the actual browser instead, where credentials: 'include' works correctly against // happens in the actual browser instead, where the stored key is available.
// whatever cookie the browser already holds from login.
export const ssr = false; export const ssr = false;
+14 -8
View File
@@ -3,8 +3,7 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { login } from '$lib/adminApi'; import { login } from '$lib/adminApi';
let username = $state('admin'); let apiKey = $state('');
let password = $state('');
let error = $state(''); let error = $state('');
let loading = $state(false); let loading = $state(false);
@@ -13,7 +12,7 @@
error = ''; error = '';
loading = true; loading = true;
try { try {
await login(username, password); await login(apiKey);
const redirectTo = $page.url.searchParams.get('redirectTo') || '/admin/settings'; const redirectTo = $page.url.searchParams.get('redirectTo') || '/admin/settings';
await goto(redirectTo); await goto(redirectTo);
} catch (err) { } catch (err) {
@@ -27,12 +26,13 @@
<div class="wrap"> <div class="wrap">
<form onsubmit={handleSubmit}> <form onsubmit={handleSubmit}>
<span class="title">Admin login</span> <span class="title">Admin login</span>
<p class="hint">
Find the API key printed in your backend server's console output when it starts up. It's
generated fresh every restart, so check there again if this one stops working.
</p>
<label class="field-label" for="username">Username</label> <label class="field-label" for="apiKey">API Key</label>
<input id="username" type="text" bind:value={username} autocomplete="username" /> <input id="apiKey" type="password" bind:value={apiKey} autocomplete="off" />
<label class="field-label" for="password">Password</label>
<input id="password" type="password" bind:value={password} autocomplete="current-password" />
{#if error}<div class="error">{error}</div>{/if} {#if error}<div class="error">{error}</div>{/if}
@@ -58,6 +58,12 @@
font-weight: 500; font-weight: 500;
margin-bottom: 8px; margin-bottom: 8px;
} }
.hint {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
margin: 0 0 4px;
}
.field-label { .field-label {
font-size: 11px; font-size: 11px;
color: var(--text-muted); color: var(--text-muted);