Replace admin username/password with a per-launch API key, and disable the admin panel by default
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+12
-3
@@ -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
|
||||
|
||||
Generated
-34
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Generated
+18
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<T>(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<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}`, {
|
||||
...options,
|
||||
credentials: 'include',
|
||||
headers
|
||||
});
|
||||
if (res.status === 401) {
|
||||
@@ -29,22 +33,22 @@ async function request<T>(path: string, options: RequestInit = {}, fetchFn: type
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Auth
|
||||
export async function login(username: string, password: string, fetchFn: typeof fetch = fetch): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await fetchFn(`${getBackendUrl()}/api/admin/logout`, { method: 'POST', credentials: 'include' });
|
||||
export async function logout(): Promise<void> {
|
||||
clearApiKey();
|
||||
}
|
||||
|
||||
// Settings
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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' };
|
||||
};
|
||||
@@ -37,14 +37,16 @@
|
||||
</nav>
|
||||
<div class="controls">
|
||||
<ThemeToggle />
|
||||
<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">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path
|
||||
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{#if data.adminPanelEnabled}
|
||||
<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">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path
|
||||
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { logout } from '$lib/adminApi';
|
||||
import type { LayoutData } from './$types';
|
||||
|
||||
let { children } = $props();
|
||||
let { children, data }: { children: any; data: LayoutData } = $props();
|
||||
|
||||
async function handleLogout() {
|
||||
await logout();
|
||||
@@ -10,17 +11,29 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="admin-shell">
|
||||
<div class="page admin-inner">
|
||||
<div class="top-row">
|
||||
<a class="back" href="/">← Back to site</a>
|
||||
<button class="logout" onclick={handleLogout}>Log out</button>
|
||||
</div>
|
||||
{@render children()}
|
||||
{#if !data.adminPanelEnabled}
|
||||
<div class="page disabled-notice">
|
||||
<p>The admin panel is disabled on this deployment.</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="admin-shell">
|
||||
<div class="page admin-inner">
|
||||
<div class="top-row">
|
||||
<a class="back" href="/">← Back to site</a>
|
||||
<button class="logout" onclick={handleLogout}>Log out</button>
|
||||
</div>
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.disabled-notice {
|
||||
padding-top: 60px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
.admin-shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// The admin section talks to a different origin (the backend) than the frontend
|
||||
// itself. During SSR, the `load` function's fetch runs on the Node server, which has
|
||||
// no access to the browser's cookie jar — it can't attach the session cookie to a
|
||||
// cross-origin request. Disabling SSR here means all admin data fetching happens in
|
||||
// the actual browser instead, where credentials: 'include' works correctly against
|
||||
// whatever cookie the browser already holds from login.
|
||||
// The admin API key lives in this browser's localStorage (see adminAuth.ts), which
|
||||
// is only reachable from client-side code — a server-rendered `load` function
|
||||
// running on the Node server during SSR has no access to it and couldn't attach it
|
||||
// to a cross-origin request. Disabling SSR here means all admin data fetching
|
||||
// happens in the actual browser instead, where the stored key is available.
|
||||
export const ssr = false;
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
import { page } from '$app/stores';
|
||||
import { login } from '$lib/adminApi';
|
||||
|
||||
let username = $state('admin');
|
||||
let password = $state('');
|
||||
let apiKey = $state('');
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
@@ -13,7 +12,7 @@
|
||||
error = '';
|
||||
loading = true;
|
||||
try {
|
||||
await login(username, password);
|
||||
await login(apiKey);
|
||||
const redirectTo = $page.url.searchParams.get('redirectTo') || '/admin/settings';
|
||||
await goto(redirectTo);
|
||||
} catch (err) {
|
||||
@@ -27,12 +26,13 @@
|
||||
<div class="wrap">
|
||||
<form onsubmit={handleSubmit}>
|
||||
<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>
|
||||
<input id="username" type="text" bind:value={username} autocomplete="username" />
|
||||
|
||||
<label class="field-label" for="password">Password</label>
|
||||
<input id="password" type="password" bind:value={password} autocomplete="current-password" />
|
||||
<label class="field-label" for="apiKey">API Key</label>
|
||||
<input id="apiKey" type="password" bind:value={apiKey} autocomplete="off" />
|
||||
|
||||
{#if error}<div class="error">{error}</div>{/if}
|
||||
|
||||
@@ -58,6 +58,12 @@
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.field-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
|
||||
Reference in New Issue
Block a user