Files
homefeed/backend/src/api/admin.ts
T
Claude b5e155fb72 Add private categories, unlockable via a password-gated cookie login
Categories can now be marked "Private" in the admin panel's Category
priority list. Private categories (and every article tagged with
one, even if it's also tagged with a public category) are hidden
from /api/categories, /api/feed, and /api/article/:id for anyone
without a valid login — a plain visitor's browser, not the admin API
key, since that's a header-based credential for the admin SPA only.

Login is a single shared password set via PRIVATE_ACCESS_PASSWORD in
the backend's .env (unset by default, which disables the feature
entirely). On success the backend sets a stateless httpOnly cookie —
its value is a deterministic hash of the password, checked with a
timing-safe comparison on every request, so there's no session table
to maintain. The cookie is requested at the ~400-day cap browsers
enforce on persistent cookies, the closest a cookie can get to
"retained indefinitely."

On the frontend, an always-visible lock icon in the masthead (shown
whenever the feature is configured, independent of the admin panel's
own enabled/disabled toggle) opens a password prompt and reflects
locked/unlocked state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
2026-07-23 02:56:08 +00:00

149 lines
6.0 KiB
TypeScript

import type { FastifyInstance } from 'fastify';
import * as settingsDb from '../storage/db/settings.js';
import * as sourcesDb from '../storage/db/sources.js';
import * as eventsDb from '../storage/db/events.js';
import * as categoriesDb from '../storage/db/categories.js';
import { clearSourceContent, clearAllArticles, clearAllMedia } from '../storage/contentCascade.js';
import { OllamaProvider } from '../inference/ollama-provider.js';
import { pollSourceNow } from '../ingestion/poller.js';
import { logger, listLogs } from '../storage/db/logs.js';
export async function registerAdminRoutes(app: FastifyInstance) {
// --- Settings ---
app.get('/api/admin/settings', async () => {
const settings = settingsDb.getSettings();
return { ...settings, categoryPriority: categoriesDb.listCategories() };
});
app.patch('/api/admin/settings', async (req) => {
const body = req.body as any;
if (body.categoryPriority) {
categoriesDb.setCategoryOrder(body.categoryPriority);
delete body.categoryPriority;
}
const settings = settingsDb.updateSettings(body);
return { ...settings, categoryPriority: categoriesDb.listCategories() };
});
// --- Categories (add/remove — reordering/privacy is via PATCH /settings above) ---
app.post('/api/admin/categories', async (req, reply) => {
const { name, isPrivate } = req.body as { name?: string; isPrivate?: boolean };
if (!name || !name.trim()) return reply.code(400).send({ error: 'name required' });
const created = categoriesDb.createCategory(name.trim(), !!isPrivate);
return reply.code(201).send(created);
});
app.delete('/api/admin/categories/:id', async (req, reply) => {
const { id } = req.params as { id: string };
categoriesDb.deleteCategory(id);
return reply.code(204).send();
});
// --- Sources ---
app.get('/api/admin/sources', async () => sourcesDb.listSources());
app.post('/api/admin/sources', async (req, reply) => {
const created = sourcesDb.createSource(req.body as any);
// Poll immediately rather than waiting for the next scheduler tick (up to 1 minute)
// — the admin adding a feed expects to see it start working right away.
pollSourceNow(created).catch((err) => logger.error('poller', `Immediate poll failed for "${created.name}": ${err.message}`));
return reply.code(201).send(created);
});
app.patch('/api/admin/sources/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const updated = sourcesDb.updateSource(id, req.body as any);
if (!updated) return reply.code(404).send({ error: 'not found' });
return updated;
});
app.delete('/api/admin/sources/:id', async (req, reply) => {
const { id } = req.params as { id: string };
// Deleting a source deletes its raw content and any article composed entirely
// from it too — otherwise stale articles from a source the admin just removed
// keep showing up on the site pointing at nothing.
clearSourceContent(id);
sourcesDb.deleteSource(id);
return reply.code(204).send();
});
// --- Content clearing (re-populate a source, or the whole site, from scratch) ---
app.delete('/api/admin/content/sources/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const result = clearSourceContent(id);
return reply.code(200).send(result);
});
app.delete('/api/admin/content/articles', async (_req, reply) => {
const deleted = clearAllArticles();
return reply.code(200).send({ deleted });
});
app.delete('/api/admin/content/media', async (_req, reply) => {
const deleted = clearAllMedia();
return reply.code(200).send({ deleted });
});
// Manual "poll now" — the refresh icon on each source in the admin panel.
app.post('/api/admin/sources/:id/poll', async (req, reply) => {
const { id } = req.params as { id: string };
const source = sourcesDb.getSource(id);
if (!source) return reply.code(404).send({ error: 'not found' });
const ingested = await pollSourceNow(source);
logger.info('poller', `Manual poll of "${source.name}" — ${ingested} new item(s)`);
return { ingested, source: sourcesDb.getSource(id) };
});
// --- Tracked events ---
app.get('/api/admin/events', async () => eventsDb.listEvents());
app.post('/api/admin/events', async (req, reply) => {
const created = eventsDb.createEvent(req.body as any);
return reply.code(201).send(created);
});
app.patch('/api/admin/events/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const updated = eventsDb.updateEvent(id, req.body as any);
if (!updated) return reply.code(404).send({ error: 'not found' });
return updated;
});
app.delete('/api/admin/events/:id', async (req, reply) => {
const { id } = req.params as { id: string };
eventsDb.deleteEvent(id);
return reply.code(204).send();
});
// --- Models / AI service (fetched live from the configured Ollama host) ---
app.get('/api/admin/models', async (_req, reply) => {
const settings = settingsDb.getSettings();
const provider = new OllamaProvider(settings.aiServiceHost, settings.aiServicePort);
try {
const models = await provider.listModels();
// Ollama doesn't distinguish task type, so the catalog surfaces the full list
// for each dropdown — the admin picks which installed model to use for what.
return { embedding: models, image: models, synthesis: models };
} catch (err) {
return reply.code(502).send({ error: `AI service unreachable: ${(err as Error).message}` });
}
});
app.get('/api/admin/ai-status', async () => {
const settings = settingsDb.getSettings();
const provider = new OllamaProvider(settings.aiServiceHost, settings.aiServicePort);
const connected = await provider.isReachable();
return { connected, host: settings.aiServiceHost, port: settings.aiServicePort, ramGB: null, gpu: null };
});
// --- Logs ---
app.get('/api/admin/logs', async (req) => {
const { level, limit } = req.query as { level?: string; limit?: string };
return listLogs({
level: level === 'info' || level === 'warn' || level === 'error' ? level : undefined,
limit: limit ? Number(limit) : undefined
});
});
}