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:
@@ -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