diff --git a/backend/.env.example b/backend/.env.example index 1099225..e4cc9f8 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -9,3 +9,10 @@ MEDIA_DIR=./data/media # header). It changes on every restart, so check the console output each time. NODE_ENV=development + +# Optional — unlocks "private" categories (marked in the admin panel's Category +# priority list) for visitors who log in with this password on the public site's +# lock icon. Leave unset to disable private categories entirely (they stay hidden +# from everyone, with no way to unlock them). Unlike the admin API key above, this +# is a fixed password you choose, and the resulting login persists across restarts. +# PRIVATE_ACCESS_PASSWORD= diff --git a/backend/package-lock.json b/backend/package-lock.json index 9a48c20..5538108 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "UNLICENSED", "dependencies": { + "@fastify/cookie": "^11.1.2", "@fastify/cors": "^11.3.0", "@mozilla/readability": "^0.6.0", "fastify": "^5.10.0", @@ -695,6 +696,26 @@ "fast-uri": "^3.0.0" } }, + "node_modules/@fastify/cookie": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.2.tgz", + "integrity": "sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==", + "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", @@ -1274,6 +1295,19 @@ "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", @@ -1403,9 +1437,9 @@ } }, "node_modules/fast-json-stringify/node_modules/fast-uri": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.0.tgz", - "integrity": "sha512-ZodJ2cRiLVWGi9IgPb3mbgSqM4CD3LexCHkuv0FfBXHJI1ADfucTD06m6clO2Cy5RZYsw/SiCVl/dyrFI/SYWA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.1.tgz", + "integrity": "sha512-YPOs1zD5TG2+EZt+r88LwF6mclA7TPkpwMP7ZN3TO2HiHS8TXvq7QA/17iJsV9dubcLo/f8eEYqMBruyQV21hQ==", "funding": [ { "type": "github", @@ -1428,9 +1462,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "funding": [ { "type": "github", diff --git a/backend/package.json b/backend/package.json index 6bd1d52..b5d6c27 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,6 +12,7 @@ }, "license": "UNLICENSED", "dependencies": { + "@fastify/cookie": "^11.1.2", "@fastify/cors": "^11.3.0", "@mozilla/readability": "^0.6.0", "fastify": "^5.10.0", diff --git a/backend/src/api/admin.ts b/backend/src/api/admin.ts index af7527a..72ac85f 100644 --- a/backend/src/api/admin.ts +++ b/backend/src/api/admin.ts @@ -25,11 +25,11 @@ export async function registerAdminRoutes(app: FastifyInstance) { return { ...settings, categoryPriority: categoriesDb.listCategories() }; }); - // --- Categories (add/remove — reordering is via PATCH /settings above) --- + // --- Categories (add/remove — reordering/privacy is via PATCH /settings above) --- app.post('/api/admin/categories', async (req, reply) => { - const { name } = req.body as { name?: string }; + 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()); + const created = categoriesDb.createCategory(name.trim(), !!isPrivate); return reply.code(201).send(created); }); diff --git a/backend/src/api/privateAccess.ts b/backend/src/api/privateAccess.ts new file mode 100644 index 0000000..38ed587 --- /dev/null +++ b/backend/src/api/privateAccess.ts @@ -0,0 +1,75 @@ +// Gates "private" categories (see storage/db/categories.ts's is_private column) behind a +// single shared password configured in the backend's own .env — deliberately separate +// from the admin API key (that's a header-based credential for the admin SPA only; this +// is a cookie so a plain visitor's browser can carry it across ordinary page loads). +// +// There's no per-visitor session store: the cookie's value is a deterministic hash of the +// configured password, so any request can be checked statelessly by recomputing that same +// hash and comparing — same "no session table" philosophy as the admin API key. + +import type { FastifyInstance } from 'fastify'; +import crypto from 'node:crypto'; +import { logger } from '../storage/db/logs.js'; + +const PRIVATE_ACCESS_PASSWORD = process.env.PRIVATE_ACCESS_PASSWORD || ''; +export const PRIVATE_ACCESS_COOKIE = 'hf_private'; +// Browsers cap persistent cookies at ~400 days regardless of what's requested (Chrome, +// Firefox, Safari all enforce this) — asking for 10 years just means "the maximum they'll +// actually allow," which is as close to "retained indefinitely" as a cookie can get. +const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365 * 10; + +function expectedToken(): string { + return crypto.createHash('sha256').update(PRIVATE_ACCESS_PASSWORD).digest('hex'); +} + +/** Feature is off entirely (no visitor can ever unlock private categories) until a password is configured. */ +export function privateAccessConfigured(): boolean { + return PRIVATE_ACCESS_PASSWORD.length > 0; +} + +export function hasPrivateAccess(req: { cookies?: Record }): boolean { + if (!privateAccessConfigured()) return false; + const token = req.cookies?.[PRIVATE_ACCESS_COOKIE]; + if (!token) return false; + const expected = expectedToken(); + // Buffers must be equal length for timingSafeEqual — a mismatched length (e.g. a + // tampered/truncated cookie) would throw rather than just failing the comparison. + if (token.length !== expected.length) return false; + try { + return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected)); + } catch { + return false; + } +} + +export async function registerPrivateAccess(app: FastifyInstance) { + app.post('/api/private-access/login', async (req, reply) => { + if (!privateAccessConfigured()) { + return reply.code(503).send({ error: 'Private categories are not configured on this server' }); + } + const { password } = req.body as { password?: string }; + const attempt = Buffer.from(password ?? ''); + const expected = Buffer.from(PRIVATE_ACCESS_PASSWORD); + const valid = attempt.length === expected.length && crypto.timingSafeEqual(attempt, expected); + if (!valid) { + logger.warn('private-access', 'Rejected private-category login attempt with wrong password'); + return reply.code(401).send({ error: 'Incorrect password' }); + } + reply.setCookie(PRIVATE_ACCESS_COOKIE, expectedToken(), { + httpOnly: true, + sameSite: 'lax', + path: '/', + maxAge: COOKIE_MAX_AGE_SECONDS + }); + return { ok: true }; + }); + + app.post('/api/private-access/logout', async (_req, reply) => { + reply.clearCookie(PRIVATE_ACCESS_COOKIE, { path: '/' }); + return { ok: true }; + }); + + app.get('/api/private-access/status', async (req) => { + return { authenticated: hasPrivateAccess(req as any), configured: privateAccessConfigured() }; + }); +} diff --git a/backend/src/api/public.ts b/backend/src/api/public.ts index 9688653..252120c 100644 --- a/backend/src/api/public.ts +++ b/backend/src/api/public.ts @@ -3,24 +3,37 @@ import * as articlesDb from '../storage/db/articles.js'; import * as tagsDb from '../storage/db/tags.js'; import * as eventsDb from '../storage/db/events.js'; import * as categoriesDb from '../storage/db/categories.js'; +import { hasPrivateAccess } from './privateAccess.js'; export async function registerPublicRoutes(app: FastifyInstance) { app.get('/api/feed', async (req) => { const { category, geo, eventId, tag, before, limit } = req.query as Record; - return articlesDb.queryFeed({ - category, - geo, - eventId, - tag, - before, - limit: limit ? Number(limit) : undefined - }); + return articlesDb.queryFeed( + { + category, + geo, + eventId, + tag, + before, + limit: limit ? Number(limit) : undefined + }, + hasPrivateAccess(req) + ); }); app.get('/api/article/:id', async (req, reply) => { const { id } = req.params as { id: string }; const article = articlesDb.getArticle(id); if (!article) return reply.code(404).send({ error: 'not found' }); + // 404 rather than 403 for a private article behind a paywall of sorts — an + // unauthenticated visitor shouldn't be able to tell the difference between + // "doesn't exist" and "exists but is private." + if (!hasPrivateAccess(req)) { + const privateNames = new Set(categoriesDb.listPrivateCategoryNames()); + if (article.category.some((c) => privateNames.has(c))) { + return reply.code(404).send({ error: 'not found' }); + } + } return article; }); @@ -34,8 +47,12 @@ export async function registerPublicRoutes(app: FastifyInstance) { }); // Drives the site nav — admin-editable (add/remove/reorder) via /api/admin/categories, - // per the "user may have no interest in Business or Culture" requirement. - app.get('/api/categories', async () => { - return categoriesDb.listCategories(); + // per the "user may have no interest in Business or Culture" requirement. Private + // categories are omitted entirely for anyone without a valid private-access cookie, + // so they don't even show up as a nav tab to unlock. + app.get('/api/categories', async (req) => { + const categories = categoriesDb.listCategories(); + if (hasPrivateAccess(req)) return categories; + return categories.filter((c) => !c.isPrivate); }); } diff --git a/backend/src/index.ts b/backend/src/index.ts index 1848fb3..0e4c485 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,5 +1,6 @@ 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'; @@ -8,6 +9,7 @@ import { registerAuth } from './api/auth.js'; import { registerPublicRoutes } from './api/public.js'; import { registerAdminRoutes } from './api/admin.js'; import { registerMediaProxy } from './api/mediaProxy.js'; +import { registerPrivateAccess, privateAccessConfigured } from './api/privateAccess.js'; import { startScheduler } from './queue/scheduler.js'; import { logger } from './storage/db/logs.js'; @@ -39,11 +41,17 @@ async function main() { // every PATCH (settings saves) and DELETE (removing sources/events) gets silently // blocked by the browser at the CORS preflight stage, before the request ever // reaches a route handler. + // credentials: true is required for the browser to send/accept the private-category + // login cookie cross-origin — safe only because origin is a specific value above, + // never a wildcard (the two are mutually exclusive per the CORS spec anyway). 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, // or POST with no payload) that still carries a Content-Type header — exactly what @@ -61,6 +69,7 @@ async function main() { await registerAuth(app); await registerPublicRoutes(app); await registerAdminRoutes(app); + await registerPrivateAccess(app); // Fastify's own logger is off (see below) — without this, an unhandled exception // in any route handler produces a bare 500 with zero trace anywhere, including the @@ -89,6 +98,9 @@ async function main() { await app.listen({ port: PORT, host: '0.0.0.0' }); logger.info('server', `Listening on :${PORT} (frontend origin: ${FRONTEND_ORIGIN})`); + if (!privateAccessConfigured()) { + logger.info('server', 'Private categories disabled — set PRIVATE_ACCESS_PASSWORD to enable'); + } startScheduler(); } diff --git a/backend/src/storage/db/articles.ts b/backend/src/storage/db/articles.ts index 17d5bbe..8c08ff3 100644 --- a/backend/src/storage/db/articles.ts +++ b/backend/src/storage/db/articles.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto'; import { db } from './index.js'; import type { MergedArticle } from './types.js'; +import { listPrivateCategoryNames } from './categories.js'; function rowToArticle(row: any): MergedArticle { return { @@ -70,17 +71,30 @@ export function allArticlesNewestFirst(): MergedArticle[] { return rows.map(rowToArticle); } -export function queryFeed(filters: { - category?: string; - geo?: string; - eventId?: string; - tag?: string; - before?: string; - limit?: number; -}): MergedArticle[] { +export function queryFeed( + filters: { + category?: string; + geo?: string; + eventId?: string; + tag?: string; + before?: string; + limit?: number; + }, + includePrivate = false +): MergedArticle[] { let sql = 'SELECT * FROM merged_articles WHERE 1=1'; const params: unknown[] = []; + // Without a valid private-access cookie, an article belonging to ANY private + // category is excluded entirely — including from a public category it's also + // tagged with, so a private source can't leak in sideways through a shared tag. + if (!includePrivate) { + for (const name of listPrivateCategoryNames()) { + sql += ' AND category NOT LIKE ?'; + params.push(`%"${name}"%`); + } + } + // The bare feed (no category/geo/eventId/tag — i.e. the homepage/"Top stories") only // shows articles whose contributing source(s) opted into "Push to Top Stories?" — // otherwise every ingested article from every source would flood the homepage. diff --git a/backend/src/storage/db/categories.ts b/backend/src/storage/db/categories.ts index 0ac2ded..da670d8 100644 --- a/backend/src/storage/db/categories.ts +++ b/backend/src/storage/db/categories.ts @@ -3,7 +3,13 @@ import { db } from './index.js'; import type { Category } from './types.js'; function rowToCategory(row: any): Category { - return { id: row.id, name: row.name, priorityRank: row.priority_rank, isDefault: !!row.is_default }; + return { + id: row.id, + name: row.name, + priorityRank: row.priority_rank, + isDefault: !!row.is_default, + isPrivate: !!row.is_private + }; } export function listCategories(): Category[] { @@ -11,16 +17,27 @@ export function listCategories(): Category[] { return rows.map(rowToCategory); } -export function setCategoryOrder(order: { id: string; priorityRank: number }[]) { - const stmt = db.prepare('UPDATE categories SET priority_rank = ? WHERE id = ?'); - for (const c of order) stmt.run(c.priorityRank, c.id); +/** Names of every category marked private — used to filter articles/feed for unauthenticated visitors. */ +export function listPrivateCategoryNames(): string[] { + const rows = db.prepare('SELECT name FROM categories WHERE is_private = 1').all() as { name: string }[]; + return rows.map((r) => r.name); } -export function createCategory(name: string): Category { +export function setCategoryOrder(order: { id: string; priorityRank: number; isPrivate: boolean }[]) { + const stmt = db.prepare('UPDATE categories SET priority_rank = ?, is_private = ? WHERE id = ?'); + for (const c of order) stmt.run(c.priorityRank, c.isPrivate ? 1 : 0, c.id); +} + +export function createCategory(name: string, isPrivate = false): Category { const id = `cat-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')}-${randomUUID().slice(0, 6)}`; const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM categories').get() as { m: number }; - db.prepare('INSERT INTO categories (id, name, priority_rank, is_default) VALUES (?, ?, ?, 0)').run(id, name, maxRank.m + 1); - return { id, name, priorityRank: maxRank.m + 1, isDefault: false }; + db.prepare('INSERT INTO categories (id, name, priority_rank, is_default, is_private) VALUES (?, ?, ?, 0, ?)').run( + id, + name, + maxRank.m + 1, + isPrivate ? 1 : 0 + ); + return { id, name, priorityRank: maxRank.m + 1, isDefault: false, isPrivate }; } export function deleteCategory(id: string) { diff --git a/backend/src/storage/db/index.ts b/backend/src/storage/db/index.ts index 525c35b..220dcaa 100644 --- a/backend/src/storage/db/index.ts +++ b/backend/src/storage/db/index.ts @@ -139,7 +139,8 @@ export function migrate() { id TEXT PRIMARY KEY, name TEXT NOT NULL, priority_rank INTEGER NOT NULL, - is_default INTEGER NOT NULL DEFAULT 0 + is_default INTEGER NOT NULL DEFAULT 0, + is_private INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS logs ( @@ -203,6 +204,9 @@ export function migrate() { if (!hasColumn('global_settings', 'fxtwitter_base_url')) { db.exec("ALTER TABLE global_settings ADD COLUMN fxtwitter_base_url TEXT NOT NULL DEFAULT 'https://api.fxtwitter.com'"); } + if (!hasColumn('categories', 'is_private')) { + db.exec('ALTER TABLE categories ADD COLUMN is_private INTEGER NOT NULL DEFAULT 0'); + } // Seed default categories if none exist yet. "News" sits right under "Top stories" — // general news sources belong here, not on "Top stories" itself, which isn't a real diff --git a/backend/src/storage/db/types.ts b/backend/src/storage/db/types.ts index d267eea..a71712e 100644 --- a/backend/src/storage/db/types.ts +++ b/backend/src/storage/db/types.ts @@ -116,6 +116,8 @@ export interface Category { name: string; priorityRank: number; isDefault: boolean; + /** Hidden from /api/categories, /api/feed, and article detail for anyone without a valid private-access cookie. */ + isPrivate: boolean; } export interface GlobalSettings { diff --git a/frontend/src/lib/adminApi.ts b/frontend/src/lib/adminApi.ts index 7207ac0..c0c1ca6 100644 --- a/frontend/src/lib/adminApi.ts +++ b/frontend/src/lib/adminApi.ts @@ -59,10 +59,10 @@ export const updateSettings = (patch: Partial, fetchFn?: typeof f request('/api/admin/settings', { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn); // Categories -export const createCategory = (name: string, fetchFn?: typeof fetch) => - request<{ id: string; name: string; priorityRank: number; isDefault: boolean }>( +export const createCategory = (name: string, isPrivate = false, fetchFn?: typeof fetch) => + request<{ id: string; name: string; priorityRank: number; isDefault: boolean; isPrivate: boolean }>( '/api/admin/categories', - { method: 'POST', body: JSON.stringify({ name }) }, + { method: 'POST', body: JSON.stringify({ name, isPrivate }) }, fetchFn ); diff --git a/frontend/src/lib/adminTypes.ts b/frontend/src/lib/adminTypes.ts index 66b5791..f5357e2 100644 --- a/frontend/src/lib/adminTypes.ts +++ b/frontend/src/lib/adminTypes.ts @@ -12,6 +12,7 @@ export interface CategoryPriority { name: string; priorityRank: number; isDefault: boolean; + isPrivate: boolean; } export interface AdminSettings { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1bb57dc..f4583c4 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -2,7 +2,10 @@ import { getBackendUrl } from './config'; import type { MergedArticle, Tag, TrackedEventPublic, Category } from './types'; async function get(path: string, fetchFn: typeof fetch = fetch): Promise { - const res = await fetchFn(`${getBackendUrl()}${path}`); + // credentials: 'include' so the private-access cookie (see lib/privateAccess.ts) + // is sent cross-origin to the backend, revealing private categories/articles to + // anyone who's logged in — without it every request would look unauthenticated. + const res = await fetchFn(`${getBackendUrl()}${path}`, { credentials: 'include' }); if (!res.ok) throw new Error(`Request failed: ${path} (${res.status})`); return res.json(); } diff --git a/frontend/src/lib/components/PrivateAccessModal.svelte b/frontend/src/lib/components/PrivateAccessModal.svelte new file mode 100644 index 0000000..b4b5f45 --- /dev/null +++ b/frontend/src/lib/components/PrivateAccessModal.svelte @@ -0,0 +1,103 @@ + + +
e.key === 'Escape' && onClose()} role="presentation"> + +
+ + diff --git a/frontend/src/lib/components/admin/MergeTab.svelte b/frontend/src/lib/components/admin/MergeTab.svelte index bf2a64e..5298704 100644 --- a/frontend/src/lib/components/admin/MergeTab.svelte +++ b/frontend/src/lib/components/admin/MergeTab.svelte @@ -9,6 +9,7 @@ let status = $state<'idle' | 'saving' | 'saved' | 'error'>('idle'); let saveTimer: ReturnType; let newCategoryName = $state(''); + let newCategoryPrivate = $state(false); let addingCategory = $state(false); function scheduleSave() { @@ -39,14 +40,20 @@ if (!name) return; addingCategory = true; try { - const created = await createCategory(name); + const created = await createCategory(name, newCategoryPrivate); local.categoryPriority = [...local.categoryPriority, created]; newCategoryName = ''; + newCategoryPrivate = false; } finally { addingCategory = false; } } + function togglePrivate(id: string) { + local.categoryPriority = local.categoryPriority.map((c) => (c.id === id ? { ...c, isPrivate: !c.isPrivate } : c)); + scheduleSave(); + } + async function removeCategory(id: string, isDefault: boolean, name: string) { if (isDefault) { // Sensible-default categories can still be removed — e.g. a fresh install's @@ -144,13 +151,21 @@

Synthesis queue processes higher-ranked categories first. Nothing is dropped — lower categories just wait longer when the queue is busy. This list also drives the site's nav — - remove anything you're not interested in (Business, Culture, etc.) or add your own. + remove anything you're not interested in (Business, Culture, etc.) or add your own. A + private category (and everything in it) is hidden from the public site until a visitor + logs in with the lock icon in the masthead.

{#each local.categoryPriority as cat, i (cat.id)}
{i + 1} {cat.name} + {#if cat.name.toLowerCase() !== 'top stories'} + + {/if} @@ -283,6 +302,17 @@ color: var(--text-muted); width: 16px; } + .private-toggle { + display: flex; + align-items: center; + gap: 5px; + font-size: 11px; + color: var(--text-secondary); + white-space: nowrap; + } + .private-toggle input { + width: auto; + } .name { font-size: 13px; flex: 1; diff --git a/frontend/src/lib/privateAccess.ts b/frontend/src/lib/privateAccess.ts new file mode 100644 index 0000000..de83a03 --- /dev/null +++ b/frontend/src/lib/privateAccess.ts @@ -0,0 +1,35 @@ +// Client for the "private categories" cookie login (see backend/src/api/privateAccess.ts). +// Deliberately separate from adminAuth.ts/adminApi.ts: that's a header-based API key for +// the admin SPA only, while this is a plain cookie so an ordinary visitor's browser +// carries it across normal page loads with no localStorage/header plumbing needed. + +import { getBackendUrl } from './config'; + +export interface PrivateAccessStatus { + authenticated: boolean; + configured: boolean; +} + +export async function getPrivateAccessStatus(fetchFn: typeof fetch = fetch): Promise { + const res = await fetchFn(`${getBackendUrl()}/api/private-access/status`, { credentials: 'include' }); + if (!res.ok) return { authenticated: false, configured: false }; + return res.json(); +} + +/** Throws with a user-facing message on failure (wrong password, or the feature isn't configured). */ +export async function loginPrivateAccess(password: string): Promise { + const res = await fetch(`${getBackendUrl()}/api/private-access/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ password }) + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? `Login failed (${res.status})`); + } +} + +export async function logoutPrivateAccess(): Promise { + await fetch(`${getBackendUrl()}/api/private-access/logout`, { method: 'POST', credentials: 'include' }); +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index fd889a1..08ec8ab 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -58,4 +58,5 @@ export interface Category { name: string; priorityRank: number; isDefault: boolean; + isPrivate: boolean; } diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 515475d..a87439b 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,12 +1,31 @@