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
This commit is contained in:
Claude
2026-07-23 02:46:08 +00:00
parent 41e474653f
commit b5e155fb72
20 changed files with 457 additions and 44 deletions
+7
View File
@@ -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=
+40 -6
View File
@@ -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",
+1
View File
@@ -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",
+3 -3
View File
@@ -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);
});
+75
View File
@@ -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<string, string | undefined> }): 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() };
});
}
+28 -11
View File
@@ -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<string, string | undefined>;
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);
});
}
+12
View File
@@ -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';
@@ -7,6 +8,7 @@ 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';
import { registerPrivateAccess, privateAccessConfigured } from './api/privateAccess.js';
import { startScheduler } from './queue/scheduler.js';
import { logger } from './storage/db/logs.js';
@@ -38,11 +40,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
@@ -60,6 +68,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
@@ -83,6 +92,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();
}
+22 -8
View File
@@ -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 {
@@ -68,17 +69,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.
+24 -7
View File
@@ -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) {
+5 -1
View File
@@ -137,7 +137,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 (
@@ -187,6 +188,9 @@ export function migrate() {
if (!hasColumn('merged_articles', 'top_stories')) {
db.exec('ALTER TABLE merged_articles ADD COLUMN top_stories INTEGER NOT NULL DEFAULT 0');
}
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
+2
View File
@@ -102,6 +102,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 {