From 71f3a19237d51420afe1426f293f082c6ba8d69b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 12:05:15 +0000 Subject: [PATCH 1/4] Add RSS feeds New topics, every post and unanswered topics for the whole forum; new topics or every post per category; replies to one topic; new posts matching a search. Items carry the whole post with absolute links and its attachments, and /p/ gives each post a stable link. Every page announces its feed with and gets a quiet RSS button; /feeds lists them all. Feeds show only visible posts, are cached for five minutes and answer If-Modified-Since. --- docs/PLAN.md | 7 ++ web/public/styles.css | 2 +- web/scripts/build-icons.mjs | 2 +- web/src/app.ts | 184 +++++++++++++++++++++++++++++++++++- web/src/feeds.ts | 168 ++++++++++++++++++++++++++++++++ web/src/views/icons.ts | 3 +- web/src/views/layout.ts | 4 + web/src/views/pages.ts | 37 +++++++- web/test/app.test.ts | 80 ++++++++++++++++ 9 files changed, 476 insertions(+), 11 deletions(-) create mode 100644 web/src/feeds.ts diff --git a/docs/PLAN.md b/docs/PLAN.md index d64f864..bea1ab0 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -129,6 +129,13 @@ Kept out on purpose: tags, signatures, private messages, ranks, badges, polls, category subscriptions, rich-text toolbar. Posts are Markdown with a Write/Preview toggle and a one-line hint. +RSS feeds (RSS 2.0, under `/feeds`, listed on `/feeds`): new topics, every +post, unanswered; per category new topics or every post; replies to one +topic; new posts matching a search. Items carry the whole post with absolute +links and attachments. Every page announces its own feed with `` and has a quiet RSS button. Feeds show only visible posts, +also to admins, and sit in Cloudflare's cache for five minutes. + Hearts and reactions: every post has a heart, always visible, one click to like (as on Discourse). Five more emoji (πŸ‘ πŸ˜„ πŸŽ‰ πŸ€” πŸ‘€) sit behind "+ React" and only show once someone used them. Counts carry the names of who reacted diff --git a/web/public/styles.css b/web/public/styles.css index bae5144..b716a32 100644 --- a/web/public/styles.css +++ b/web/public/styles.css @@ -174,7 +174,7 @@ button, input, select, textarea { font: inherit; color: inherit; } .main-head { display: grid; grid-template-columns: subgrid; align-items: end; margin-bottom: 32px; } .main-head-text { grid-column: 1 / 7; } -.main-head-action { grid-column: 7 / -1; justify-self: end; } +.main-head-action { grid-column: 7 / -1; justify-self: end; display: flex; gap: 8px; } .main.narrow > .main-head { grid-column: 1 / -1; } .main-head h1, .topic-head h1, .auth-card h1, article.prose > h1 { font-size: 40px; line-height: 44px; font-weight: 800; letter-spacing: -0.025em; } .lede { font-size: 18px; line-height: 28px; color: var(--ink-2); margin-top: 8px; } diff --git a/web/scripts/build-icons.mjs b/web/scripts/build-icons.mjs index 43f2d4c..40c9497 100644 --- a/web/scripts/build-icons.mjs +++ b/web/scripts/build-icons.mjs @@ -8,7 +8,7 @@ const NAMES = [ "plus", "heart", "smile-plus", "link", "reply", "paperclip", "eye", "eye-off", "circle-check", "pin", "lock", "settings", "log-out", "log-in", "chevron-left", "chevron-right", "x", "menu", "download", "file-archive", "file", "box", "image", "ban", "rotate-ccw", "trash-2", "user-round", - "search", "circle-help", "quote", "archive", + "search", "circle-help", "quote", "archive", "rss", ]; const dir = path.join(import.meta.dirname, "../node_modules/lucide-static/icons"); diff --git a/web/src/app.ts b/web/src/app.ts index ea08a67..85cc3ff 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -43,6 +43,7 @@ import { UploadError, fileUrl, imageUrls, isImage, settleAttachments, storeUploa import { refusalReason, refusePost } from "./spamfilter.ts"; import { referencedAttachmentIds, renderMarkdown, excerpt } from "./markdown.ts"; import { sendMail } from "./mailer.ts"; +import { feedPosts, feedSearch, feedTopics, lastModified, renderFeed, type Feed } from "./feeds.ts"; import { isEmail, normalizeEmail, safeNext } from "./util.ts"; import { banUser, moderationQueue, recentLog, searchUsers, setAdmin, setPostStatus, setTopicFlag, unbanUser } from "./moderation.ts"; import { logPage, queuePage, usersPage } from "./views/admin.ts"; @@ -52,6 +53,7 @@ import { checkEmailPage, confirmSignInPage, aupPage, + feedsPage, guidelinesPage, homePage, searchPage, @@ -103,7 +105,15 @@ async function sidebarCategories(db: D1Database): Promise { async function page( c: Context, content: Html, - opts: { title?: string; description?: string; status?: 200 | 400 | 403 | 404 | 422 | 429; mainClass?: string; query?: string } = {}, + opts: { + title?: string; + description?: string; + status?: 200 | 400 | 403 | 404 | 422 | 429; + mainClass?: string; + query?: string; + /** This page's own feed, offered to feed readers ahead of the forum-wide ones. */ + feed?: { title: string; href: string }; + } = {}, ) { const url = new URL(c.req.url); const [categories, unanswered] = await Promise.all([sidebarCategories(c.env.DB), unansweredCount(c.env.DB)]); @@ -122,6 +132,7 @@ async function page( unanswered, query: opts.query, mainClass: opts.mainClass, + feeds: [...(opts.feed ? [opts.feed] : []), ...siteFeeds(c.env.SITE_NAME).filter((f) => f.href !== opts.feed?.href)], }, content, ), @@ -129,6 +140,12 @@ async function page( ); } +/** Offered on every page through . */ +const siteFeeds = (siteName: string) => [ + { title: `${siteName}: new topics`, href: "/feeds/topics" }, + { title: `${siteName}: all posts`, href: "/feeds/posts" }, +]; + const NOTICES: Record = { "signed-in": "You're signed in.", "signed-out": "You're signed out.", @@ -163,14 +180,19 @@ app.get("/", async (c) => { latestTopics(c.env.DB, TOPICS_PER_PAGE + 1, (p - 1) * TOPICS_PER_PAGE), ]); const hasMore = topics.length > TOPICS_PER_PAGE; - return page(c, homePage(categories, topics.slice(0, TOPICS_PER_PAGE), p, hasMore, Boolean(c.get("user")))); + return page(c, homePage(categories, topics.slice(0, TOPICS_PER_PAGE), p, hasMore, Boolean(c.get("user"))), { + feed: { title: `${c.env.SITE_NAME}: new topics`, href: "/feeds/topics" }, + }); }); app.get("/unanswered", async (c) => { const p = pageNumber(c); const topics = await latestTopics(c.env.DB, TOPICS_PER_PAGE + 1, (p - 1) * TOPICS_PER_PAGE, { unanswered: true }); const hasMore = topics.length > TOPICS_PER_PAGE; - return page(c, unansweredPage(topics.slice(0, TOPICS_PER_PAGE), p, hasMore, Boolean(c.get("user"))), { title: "Unanswered" }); + return page(c, unansweredPage(topics.slice(0, TOPICS_PER_PAGE), p, hasMore, Boolean(c.get("user"))), { + title: "Unanswered", + feed: { title: `${c.env.SITE_NAME}: unanswered`, href: "/feeds/unanswered" }, + }); }); app.get("/search", async (c) => { @@ -178,7 +200,11 @@ app.get("/search", async (c) => { const p = pageNumber(c); const hits = q ? await searchTopics(c.env.DB, q, TOPICS_PER_PAGE + 1, (p - 1) * TOPICS_PER_PAGE) : []; const hasMore = hits.length > TOPICS_PER_PAGE; - return page(c, searchPage(q, hits.slice(0, TOPICS_PER_PAGE), p, hasMore, Boolean(c.get("user"))), { title: q ? `Search: ${q}` : "Search", query: q }); + return page(c, searchPage(q, hits.slice(0, TOPICS_PER_PAGE), p, hasMore, Boolean(c.get("user"))), { + title: q ? `Search: ${q}` : "Search", + query: q, + feed: q ? { title: `Posts matching β€œ${q}”`, href: `/feeds/search?q=${encodeURIComponent(q)}` } : undefined, + }); }); app.get("/c/:slug", async (c) => { @@ -190,6 +216,7 @@ app.get("/c/:slug", async (c) => { return page(c, categoryPage(category, topics.slice(0, TOPICS_PER_PAGE), p, hasMore, Boolean(c.get("user"))), { title: category.name, description: category.description, + feed: { title: `${category.name}: new topics`, href: `/feeds/c/${category.slug}` }, }); }); @@ -233,6 +260,7 @@ async function showTopic(c: Context) { return page(c, topicPage(topic, rendered, user, { page: p, hasMore, firstIndex: (p - 1) * POSTS_PER_PAGE, watching: Boolean(state?.watching) }), { title: topic.title, description: shown[0] ? excerpt(shown[0].body) : undefined, + feed: { title: `Replies to β€œ${topic.title}”`, href: `/feeds/t/${topic.id}` }, }); } // "N new" links from Your topics land on the first post the reader hasn't seen. @@ -252,9 +280,157 @@ app.get("/t/:id/unread", async (c) => { app.get("/t/:id/:slug", showTopic); app.get("/t/:id", showTopic); +// A short, stable link to one post, used by feeds; it finds the page the post is on. +app.get("/p/:id", async (c) => { + const id = Number(c.req.param("id")); + if (!Number.isInteger(id)) return c.notFound(); + const post = await c.env.DB.prepare( + "SELECT p.id, t.id AS topic_id, t.slug FROM posts p JOIN topics t ON t.id = p.topic_id WHERE p.id = ? AND p.status = 'visible' AND t.status = 'visible'", + ) + .bind(id) + .first<{ id: number; topic_id: number; slug: string }>(); + if (!post) return c.notFound(); + return c.redirect(await postLink(c, { id: post.topic_id, slug: post.slug }, post.id), 301); +}); + app.get("/guidelines", (c) => page(c, guidelinesPage(), { title: "Guidelines" })); app.get("/aup", (c) => page(c, aupPage(), { title: "Acceptable use" })); +// --- Feeds -------------------------------------------------------------------- + +const FEED_TOPICS = 30; +const FEED_POSTS = 50; +const FEED_MAX_AGE = 300; + +/** + * Feed readers poll, so a feed is kept in Cloudflare's cache for a few minutes and + * answers If-Modified-Since with a 304. Feeds are the same for everyone. + */ +async function feedResponse(c: Context, build: () => Promise): Promise { + const cache = typeof caches === "undefined" ? undefined : (caches as unknown as { default?: Cache }).default; + const key = new Request(c.req.url); + const hit = await cache?.match(key); + // Responses from the cache have immutable headers; the middleware still adds its own. + let res = hit ? new Response(hit.body, hit) : undefined; + if (!res) { + const feed = await build(); + if (!feed) return c.notFound(); + const headers = new Headers({ + "Content-Type": "application/rss+xml; charset=utf-8", + "Cache-Control": `public, max-age=${FEED_MAX_AGE}`, + "X-Content-Type-Options": "nosniff", + }); + const modified = lastModified(feed.rows); + if (modified) headers.set("Last-Modified", modified); + res = new Response(await renderFeed(c.env, c.env.DB, feed), { headers }); + if (cache) { + const put = cache.put(key, res.clone()); + try { + c.executionCtx.waitUntil(put); + } catch { + await put; + } + } + } + const since = Date.parse(c.req.header("if-modified-since") ?? ""); + const modified = Date.parse(res.headers.get("last-modified") ?? ""); + if (!Number.isNaN(since) && !Number.isNaN(modified) && modified <= since) { + return new Response(null, { status: 304, headers: { "Cache-Control": res.headers.get("cache-control")!, "Last-Modified": res.headers.get("last-modified")! } }); + } + return res; +} + +const siteName = (c: Context) => c.env.SITE_NAME; + +app.get("/feeds", async (c) => page(c, feedsPage(await listCategories(c.env.DB)), { title: "RSS feeds" })); + +app.get("/feeds/topics", (c) => + feedResponse(c, async () => ({ + title: `${siteName(c)}: new topics`, + description: "New topics in every category of the NodeBox forum.", + page: "/", + self: "/feeds/topics", + rows: await feedTopics(c.env.DB, FEED_TOPICS), + })), +); + +app.get("/feeds/posts", (c) => + feedResponse(c, async () => ({ + title: `${siteName(c)}: all posts`, + description: "Every new post on the NodeBox forum, replies included.", + page: "/", + self: "/feeds/posts", + rows: await feedPosts(c.env.DB, FEED_POSTS), + })), +); + +app.get("/feeds/unanswered", (c) => + feedResponse(c, async () => ({ + title: `${siteName(c)}: unanswered`, + description: "New topics nobody has replied to yet.", + page: "/unanswered", + self: "/feeds/unanswered", + rows: await feedTopics(c.env.DB, FEED_TOPICS, { unanswered: true }), + })), +); + +app.get("/feeds/c/:slug", (c) => + feedResponse(c, async () => { + const category = await getCategory(c.env.DB, c.req.param("slug")); + if (!category) return null; + return { + title: `${siteName(c)}: ${category.name}`, + description: category.description || `New topics in ${category.name}.`, + page: `/c/${category.slug}`, + self: `/feeds/c/${category.slug}`, + rows: await feedTopics(c.env.DB, FEED_TOPICS, { categoryId: category.id }), + }; + }), +); + +app.get("/feeds/c/:slug/posts", (c) => + feedResponse(c, async () => { + const category = await getCategory(c.env.DB, c.req.param("slug")); + if (!category) return null; + return { + title: `${siteName(c)}: ${category.name}, all posts`, + description: `Every new post in ${category.name}, replies included.`, + page: `/c/${category.slug}`, + self: `/feeds/c/${category.slug}/posts`, + rows: await feedPosts(c.env.DB, FEED_POSTS, { categoryId: category.id }), + }; + }), +); + +app.get("/feeds/t/:id", (c) => + feedResponse(c, async () => { + const id = Number(c.req.param("id")); + const topic = Number.isInteger(id) ? await getTopic(c.env.DB, id) : null; + if (!topic) return null; + return { + title: `${siteName(c)}: ${topic.title}`, + description: `New replies to β€œ${topic.title}” in ${topic.category_name}.`, + page: topicUrl(topic), + self: `/feeds/t/${topic.id}`, + rows: await feedPosts(c.env.DB, FEED_POSTS, { topicId: topic.id }), + }; + }), +); + +app.get("/feeds/search", (c) => + feedResponse(c, async () => { + const q = (c.req.query("q") ?? "").trim().slice(0, 200); + if (!q) return null; + return { + title: `${siteName(c)}: β€œ${q}”`, + description: `New posts that mention β€œ${q}”.`, + page: `/search?q=${encodeURIComponent(q)}`, + self: `/feeds/search?q=${encodeURIComponent(q)}`, + rows: await feedSearch(c.env.DB, q, FEED_POSTS), + }; + }), +); + // --- Writing ------------------------------------------------------------------ function attachmentIds(form: Record): string[] { diff --git a/web/src/feeds.ts b/web/src/feeds.ts new file mode 100644 index 0000000..a87eff2 --- /dev/null +++ b/web/src/feeds.ts @@ -0,0 +1,168 @@ +// RSS 2.0 feeds. Public and the same for everyone: only visible posts in visible topics, +// whoever asks, so a moderator's reader never shows hidden posts. +import { ftsQuery, attachmentsForPosts, type Attachment } from "./db.ts"; +import { fileUrl, imageUrls, isImage } from "./files.ts"; +import { excerpt, referencedAttachmentIds, renderMarkdown } from "./markdown.ts"; +import { formatBytes } from "./util.ts"; +import type { Bindings } from "./env.ts"; + +/** A post as a feed item, with what's needed to say where it belongs. */ +export interface FeedRow { + post_id: number; + topic_id: number; + title: string; + slug: string; + body: string; + created_at: string; + author_name: string; + category_slug: string; + category_name: string; + /** 1 when the post opens its topic. */ + is_first: number; +} + +const FEED_COLUMNS = ` + SELECT p.id AS post_id, t.id AS topic_id, t.title, t.slug, p.body, p.created_at, u.name AS author_name, + c.slug AS category_slug, c.name AS category_name`; +const FEED_JOINS = ` + JOIN users u ON u.id = p.user_id + JOIN categories c ON c.id = t.category_id`; +const OPENING_POST = "(SELECT f.id FROM posts f WHERE f.topic_id = t.id ORDER BY f.created_at, f.id LIMIT 1)"; +const FEED_SELECT = `${FEED_COLUMNS}, p.id = ${OPENING_POST} AS is_first + FROM posts p JOIN topics t ON t.id = p.topic_id${FEED_JOINS}`; + +export type FeedFilter = { categoryId?: number; topicId?: number }; + +function filterSql(f: FeedFilter): { sql: string; args: unknown[] } { + const parts: string[] = []; + const args: unknown[] = []; + if (f.categoryId !== undefined) (parts.push("t.category_id = ?"), args.push(f.categoryId)); + if (f.topicId !== undefined) (parts.push("t.id = ?"), args.push(f.topicId)); + return { sql: parts.map((p) => ` AND ${p}`).join(""), args }; +} + +/** New topics, newest first, each with its opening post. `unanswered` keeps those nobody replied to. */ +export async function feedTopics(db: D1Database, limit: number, f: FeedFilter & { unanswered?: boolean } = {}): Promise { + const { sql, args } = filterSql(f); + const unanswered = f.unanswered + ? " AND t.post_count <= 1 AND t.locked = 0 AND t.category_id NOT IN (SELECT id FROM categories WHERE archived = 1)" + : ""; + const { results } = await db + .prepare( + `${FEED_COLUMNS}, 1 AS is_first + FROM topics t JOIN posts p ON p.id = ${OPENING_POST}${FEED_JOINS} + WHERE t.status = 'visible' AND p.status = 'visible'${sql}${unanswered} + ORDER BY t.created_at DESC, t.id DESC LIMIT ?`, + ) + .bind(...args, limit) + .all(); + return results; +} + +/** Every visible post, replies included, newest first. */ +export async function feedPosts(db: D1Database, limit: number, f: FeedFilter = {}): Promise { + const { sql, args } = filterSql(f); + const { results } = await db + .prepare(`${FEED_SELECT} WHERE p.status = 'visible' AND t.status = 'visible'${sql} ORDER BY p.created_at DESC, p.id DESC LIMIT ?`) + .bind(...args, limit) + .all(); + return results; +} + +/** Posts that match a search, newest first; a topic whose title matches brings its opening post. */ +export async function feedSearch(db: D1Database, q: string, limit: number): Promise { + const match = ftsQuery(q); + if (!match) return []; + const { results } = await db + .prepare( + `SELECT * FROM (${FEED_SELECT} WHERE p.status = 'visible' AND t.status = 'visible' + AND (p.id IN (SELECT rowid FROM posts_fts WHERE posts_fts MATCH ?1) OR t.id IN (SELECT rowid FROM topics_fts WHERE topics_fts MATCH ?1))) + WHERE is_first = 1 OR post_id IN (SELECT rowid FROM posts_fts WHERE posts_fts MATCH ?1) + ORDER BY created_at DESC, post_id DESC LIMIT ?2`, + ) + .bind(match, limit) + .all(); + return results; +} + +export interface Feed { + title: string; + description: string; + /** The page this feed follows, as a path. */ + page: string; + /** The feed's own URL, as a path with its query. */ + self: string; + rows: FeedRow[]; +} + +const xml = (s: string) => + s + .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001fοΏΎοΏΏ]/g, "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + +/** Links in posts are site-relative; a feed reader needs them absolute. */ +function absolutize(htmlText: string, base: string): string { + return htmlText.replace(/(\s(?:href|src)=")\/(?!\/)/g, `$1${base}/`); +} + +/** A post's own HTML plus the attachments the topic page shows under it. */ +function itemHtml(env: Bindings, row: FeedRow, attachments: Attachment[], base: string): string { + const byId = new Map(attachments.map((a) => [a.id, a])); + let out = renderMarkdown(row.body, { attachments: byId, image: (a) => imageUrls(env, a) }); + const inline = referencedAttachmentIds(row.body); + const rest = attachments.filter((a) => !inline.has(a.id)); + for (const a of rest.filter((a) => isImage(a.content_type))) { + const u = imageUrls(env, a); + out += `

${xml(a.filename)}

`; + } + const files = rest.filter((a) => !isImage(a.content_type)); + if (files.length) { + out += ``; + } + return absolutize(out, base); +} + +/** Renders a feed as RSS 2.0, with the full post in content:encoded and a short excerpt in description. */ +export async function renderFeed(env: Bindings, db: D1Database, feed: Feed): Promise { + const base = env.PUBLIC_URL.replace(/\/+$/, ""); + const attachments = await attachmentsForPosts(db, feed.rows.map((r) => r.post_id)); + const items = feed.rows.map((row) => { + const topicLink = `${base}/t/${row.topic_id}/${row.slug}`; + const permalink = `${base}/p/${row.post_id}`; + const title = row.is_first ? row.title : `Re: ${row.title}`; + const mine = attachments.filter((a) => a.post_id === row.post_id); + return ` + ${xml(title)} + ${xml(row.is_first ? topicLink : permalink)} + ${xml(permalink)} + ${new Date(row.created_at).toUTCString()} + ${xml(row.author_name)} + ${xml(row.category_name)} + ${xml(topicLink)} + ${xml(excerpt(row.body, 300))} + ${xml(itemHtml(env, row, mine, base))} + `; + }); + const updated = lastModified(feed.rows); + return ` + + + ${xml(feed.title)} + ${xml(base + feed.page)} + + ${xml(feed.description)} + en + 15 +${updated ? ` ${updated}\n` : ""}${items.join("\n")}${items.length ? "\n" : ""} + +`; +} + +/** The newest item's time, for Last-Modified. */ +export function lastModified(rows: FeedRow[]): string | null { + const newest = rows.reduce((max, r) => (r.created_at > max ? r.created_at : max), ""); + return newest ? new Date(newest).toUTCString() : null; +} diff --git a/web/src/views/icons.ts b/web/src/views/icons.ts index 9887e0b..8964615 100644 --- a/web/src/views/icons.ts +++ b/web/src/views/icons.ts @@ -39,7 +39,8 @@ const PATHS = { "search": "", "circle-help": "", "quote": "", - "archive": "" + "archive": "", + "rss": "" } as const; export type IconName = keyof typeof PATHS; diff --git a/web/src/views/layout.ts b/web/src/views/layout.ts index f3700fa..0fd7ae6 100644 --- a/web/src/views/layout.ts +++ b/web/src/views/layout.ts @@ -24,6 +24,8 @@ export interface LayoutProps { query?: string; /** Extra class on
, e.g. "narrow". */ mainClass?: string; + /** RSS feeds offered to browsers and feed readers, this page's own first. */ + feeds?: { title: string; href: string }[]; } /** The NodeBox Live mark (logo.svg from Frederik), drawn in the current text colour. */ @@ -75,6 +77,7 @@ function sidebar(props: LayoutProps): Html { ${item("/guidelines", "Guidelines", icon("file-text"))} ${item("https://www.nodebox.net/node/", "Documentation", icon("book-open"), { external: true })} ${item("/aup", "Acceptable use", icon("scale"))} + ${item("/feeds", "RSS feeds", icon("rss"))} ${item("https://www.nodebox.net/", "nodebox.net", icon("external-link"), { external: true })} `; @@ -94,6 +97,7 @@ export function layout(props: LayoutProps, content: Html): Html { + ${(props.feeds ?? []).map((f) => html`\n `)} diff --git a/web/src/views/pages.ts b/web/src/views/pages.ts index 25131dd..17d1746 100644 --- a/web/src/views/pages.ts +++ b/web/src/views/pages.ts @@ -106,6 +106,10 @@ const newTopicButton = (signedIn: boolean, category?: string) => { return html`${icon("plus", { size: 16 })}New topic`; }; +/** A quiet link to the feed that follows this page. */ +const feedButton = (href: string, title: string) => + html`${icon("rss", { size: 16 })}RSS`; + function listTabs(active: "latest" | "unanswered"): Html { const tab = (href: string, key: string, label: string) => html`${label}`; @@ -117,7 +121,7 @@ export function homePage(_categories: Category[], latest: ListRow[], page: numbe ${mainHead({ title: page === 1 ? "Latest" : `Latest, page ${page}`, lede: page === 1 ? "Questions, projects and bugs from the NodeBox community. Search first: many questions have been answered before." : undefined, - action: newTopicButton(signedIn), + action: html`${feedButton("/feeds/topics", "Follow new topics in every category by RSS")}${newTopicButton(signedIn)}`, })} ${listTabs("latest")} ${topicList(latest, { showCategory: true })} @@ -129,7 +133,7 @@ export function unansweredPage(topics: ListRow[], page: number, hasMore: boolean ${mainHead({ title: "Unanswered", lede: "Topics nobody has replied to yet. If you know something that helps, this is where it counts most.", - action: newTopicButton(signedIn), + action: html`${feedButton("/feeds/unanswered", "Follow unanswered topics by RSS")}${newTopicButton(signedIn)}`, })} ${listTabs("unanswered")} ${topicList(topics, { showCategory: true, empty: "Every topic has a reply. Nice." })} @@ -141,7 +145,7 @@ export function searchPage(q: string, hits: (ListRow & { snippet: string | null ${mainHead({ title: q ? `Results for β€œ${q}”` : "Search", lede: q ? undefined : "Search topic titles and posts.", - action: newTopicButton(signedIn), + action: html`${q ? feedButton(`/feeds/search?q=${encodeURIComponent(q)}`, `Follow new posts that mention β€œ${q}” by RSS`) : ""}${newTopicButton(signedIn)}`, })} ${q ? hits.length === 0 @@ -158,7 +162,7 @@ export function categoryPage(category: Category, topics: ListRow[], page: number title: category.name, lede: category.description || undefined, kicker: html`

${category.archived ? html`${icon("archive", { size: 16 })}Archive` : ""}${count}

`, - action: category.archived ? undefined : newTopicButton(signedIn, category.slug), + action: html`${feedButton(`/feeds/c/${category.slug}`, `Follow new topics in ${category.name} by RSS`)}${category.archived ? "" : newTopicButton(signedIn, category.slug)}`, })} ${category.archived ? archiveNote(category.name) : ""} ${topicList(topics, { showCategory: false })} @@ -245,6 +249,7 @@ export function topicPage(
${canReply ? html`${icon("reply", { size: 16 })}Reply` : ""} ${user ? watchForm(topic, opts.watching) : ""} + ${feedButton(`/feeds/t/${topic.id}`, "Follow replies to this topic by RSS")}
${isAdmin ? topicModBar(topic) : ""} @@ -507,3 +512,27 @@ export function guidelinesPage(): Html {

The rules everyone agrees to are in the acceptable use policy.

`; } + +export function feedsPage(categories: Category[]): Html { + const feed = (href: string, label: string) => html`${label}`; + const categoryItem = (c: Category) => + html`
  • ${c.name}: ${feed(`/feeds/c/${c.slug}`, "new topics")} or ${feed(`/feeds/c/${c.slug}/posts`, "every post")}
  • `; + const archived = categories.filter((c) => c.archived); + return html`
    +

    RSS feeds

    +

    Follow the forum in a feed reader, no account needed. Each feed carries the newest posts in full, with their pictures and attachments. Copy a link below into your reader, or give it the address of any forum page and it finds that page's feed by itself.

    +

    The whole forum

    +
      +
    • ${feed("/feeds/topics", "New topics")}, in every category.
    • +
    • ${feed("/feeds/posts", "Every post")}, replies included. Busy, but nothing slips by.
    • +
    • ${feed("/feeds/unanswered", "Unanswered topics")}, for anyone who likes to help.
    • +
    +

    One category

    +
      ${categories.filter((c) => !c.archived).map(categoryItem)}
    + ${archived.length ? html`

    In the archive:

      ${archived.map(categoryItem)}
    ` : ""} +

    One topic

    +

    Every topic has an RSS button next to Reply. Its feed brings the new replies, like watching the topic but without the email.

    +

    A search

    +

    Search for something and use the RSS button on the results to hear about new posts that mention it, for example ${feed("/feeds/search?q=csv", "posts about CSV")}.

    +
    `; +} diff --git a/web/test/app.test.ts b/web/test/app.test.ts index 18d1b31..d3b4aee 100644 --- a/web/test/app.test.ts +++ b/web/test/app.test.ts @@ -401,3 +401,83 @@ test("read-only: everyone can read and sign in, only admins can write", async () assert.match(await refused.text(), /read-only/); assert.equal((await roReq("/new", { method: "POST", cookie: di, body: form({ title: "Admin test", category: "questions", body: "Hi" }) })).status, 302); }); + +test("RSS feeds", async () => { + await env.DB.prepare("INSERT INTO categories (id, slug, name, description) VALUES (20, 'feeds-cat', 'Feeds & Things', 'Feed test')").run(); + const rita = await signIn("rita@example.com"); + let res = await req("/new", { method: "POST", cookie: rita, body: form({ title: "Feeds great", category: "feeds-cat", body: "Look at [this](/guidelines) **now**" }) }); + const topicPath = res.headers.get("location")!; + const topicId = Number(/\/t\/(\d+)/.exec(topicPath)![1]); + const sam = await signIn("sam@example.com"); + res = await req(`/t/${topicId}/reply`, { method: "POST", cookie: sam, body: form({ body: "A reply about zeppelins" }) }); + const replyId = Number(/#post-(\d+)/.exec(res.headers.get("location")!)![1]); + res = await req(`/t/${topicId}/reply`, { method: "POST", cookie: sam, body: form({ body: "Hidden zeppelins" }) }); + const hiddenId = Number(/#post-(\d+)/.exec(res.headers.get("location")!)![1]); + await env.DB.prepare("UPDATE posts SET status = 'spam' WHERE id = ?").bind(hiddenId).run(); + + const feed = async (p: string) => { + const r = await req(p); + assert.equal(r.status, 200, p); + assert.match(r.headers.get("content-type")!, /^application\/rss\+xml/); + return r.text(); + }; + + // New topics: the opening post in full, with absolute links, escaped once. + let xml = await feed("/feeds/topics"); + assert.match(xml, /Feeds <are> great<\/title>/); + assert.match(xml, /<category domain="http:\/\/localhost\/c\/feeds-cat">Feeds & Things<\/category>/); + assert.match(xml, /href="http:\/\/localhost\/guidelines"/); + assert.match(xml, /<strong>now<\/strong>/); + assert.match(xml, new RegExp(`<link>http://localhost/t/${topicId}/feeds-are-great</link>`)); + assert.doesNotMatch(xml, /zeppelins/, "replies are not in the topics feed"); + + // All posts: replies too, but never hidden ones. + xml = await feed("/feeds/posts"); + assert.match(xml, /<title>Re: Feeds <are> great<\/title>/); + assert.match(xml, new RegExp(`<link>http://localhost/p/${replyId}</link>`)); + assert.doesNotMatch(xml, /Hidden zeppelins/); + + // Per category, per topic, unanswered, search. + assert.match(await feed("/feeds/c/feeds-cat"), /Feeds <are> great/); + assert.match(await feed("/feeds/c/feeds-cat/posts"), /A reply about zeppelins/); + assert.doesNotMatch(await feed("/feeds/c/questions"), /Feeds <are>/); + xml = await feed(`/feeds/t/${topicId}`); + assert.match(xml, /A reply about zeppelins/); + assert.doesNotMatch(xml, /Hidden zeppelins/); + assert.doesNotMatch(await feed("/feeds/unanswered"), /Feeds <are>/, "a topic with a reply is answered"); + xml = await feed("/feeds/search?q=zeppelins"); + assert.match(xml, /A reply about zeppelins/); + assert.doesNotMatch(xml, /Hidden zeppelins/); + assert.doesNotMatch(xml, /Look at/, "a match in a reply doesn't bring the opening post"); + assert.match(await feed("/feeds/search?q=great"), /Look at/, "a title match brings the opening post"); + assert.equal((await req("/feeds/c/nope")).status, 404); + assert.equal((await req("/feeds/t/999999")).status, 404); + assert.equal((await req("/feeds/t/abc")).status, 404); + assert.equal((await req("/p/abc")).status, 404); + assert.equal((await req("/feeds/search")).status, 404); + // A hidden topic has no feed. + await env.DB.prepare("UPDATE topics SET status = 'removed' WHERE id = ?").bind(topicId).run(); + assert.equal((await req(`/feeds/t/${topicId}`)).status, 404); + assert.doesNotMatch(await feed("/feeds/posts"), /zeppelins/); + await env.DB.prepare("UPDATE topics SET status = 'visible' WHERE id = ?").bind(topicId).run(); + + // Readers that send If-Modified-Since get a 304 until something new is posted. + const first = await req("/feeds/posts"); + const modified = first.headers.get("last-modified")!; + assert.ok(modified); + assert.equal((await req("/feeds/posts", { headers: { "if-modified-since": modified } })).status, 304); + assert.equal((await req("/feeds/posts", { headers: { "if-modified-since": "Thu, 01 Jan 2004 00:00:00 GMT" } })).status, 200); + + // Item links lead to the post. + res = await req(`/p/${replyId}`); + assert.equal(res.status, 301); + assert.equal(res.headers.get("location"), `${topicPath}#post-${replyId}`); + assert.equal((await req(`/p/${hiddenId}`)).status, 404); + + // Pages point feed readers at their feed, and the overview lists every category. + const page = await (await req(topicPath)).text(); + assert.match(page, new RegExp(`<link rel="alternate" type="application/rss\\+xml" title="Replies to β€œFeeds <are> great”" href="/feeds/t/${topicId}">`)); + assert.match(page, /href="\/feeds\/topics"/); + assert.match(await (await req("/c/feeds-cat")).text(), /<link rel="alternate" type="application\/rss\+xml" title="Feeds & Things: new topics" href="\/feeds\/c\/feeds-cat">/); + assert.match(await (await req("/feeds")).text(), /\/feeds\/c\/feeds-cat\/posts/); +}); From 28f5625a70d9253e2df4740953bee2487d81a926 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 16 Oct 2026 15:34:18 +0000 Subject: [PATCH 2/4] Store refused posts hidden from the start, and email admins when the spam filter is down A refused post used to be stored visible and hidden a moment later, so a reader or a feed fetched in between could see it. Now it is inserted as spam and never touches the topic or category counts. When the Anthropic API errors or times out, posts still go out unchecked, but the admins get an email about it, at most one every six hours. --- docs/PLAN.md | 7 +++- web/src/app.ts | 8 ++-- web/src/db.ts | 87 +++++++++++++++++++++++++++---------------- web/src/spamfilter.ts | 55 ++++++++++++++++++++++----- web/test/app.test.ts | 23 +++++++++++- 5 files changed, 130 insertions(+), 50 deletions(-) diff --git a/docs/PLAN.md b/docs/PLAN.md index bea1ab0..853ccbb 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -213,8 +213,11 @@ There is no queue to work through: - A post the filter calls spam at confidence 0.9 or higher is not published. Its author is told so, without a reason, and given a contact address. - Three refused posts ban the account, so the filter can't be probed for long. -- Everything else is published: a doubtful verdict, an error, a slow answer, - or no key at all. +- The check runs before the post is stored, so other people and the feeds + never see a refused post, not even briefly. +- Everything else is published: a doubtful verdict, an error, a slow answer + (5 seconds), or no key at all. When the filter can't be reached, the admins + get an email, at most one every 6 hours (Frederik, 2026-10-16). - A refused post is stored hidden, restorable from `/admin`, and deleted with its files after 30 days by the hourly cron. diff --git a/web/src/app.ts b/web/src/app.ts index 85cc3ff..35293b8 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -462,10 +462,10 @@ app.post("/new", async (c) => { if (body.length === 0) return fail("Write a message (or at least attach something and say what it is)."); if (!(await rateLimit(c.env.DB, `post:${user.id}`, 20, 3600))) return fail("You're posting very fast. Please wait a while and try again."); const refusal = await refusalReason(c.env, user, { topicTitle: title, body, opensTopic: true }); - const { topicId, postId } = await createTopic(c.env.DB, { categoryId: category.id, userId: user.id, title, body, attachmentIds: attachmentIds(form) }); + const { topicId, postId } = await createTopic(c.env.DB, { categoryId: category.id, userId: user.id, title, body, attachmentIds: attachmentIds(form), refused: refusal }); await settleAttachments(c.env, postId); if (refusal) { - await refusePost(c.env, postId, user.id, refusal); + await refusePost(c.env, { postId, topicId, userId: user.id, reason: refusal }); return page(c, refusedPage(), { title: "Not posted", status: 422, mainClass: "narrow" }); } const topic = await getTopic(c.env.DB, topicId); @@ -485,10 +485,10 @@ app.post("/t/:id/reply", async (c) => { return page(c, messagePage("Slow down a little", "You're posting very fast. Please wait a while and try again."), { status: 429 }); } const refusal = await refusalReason(c.env, user, { topicTitle: topic.title, body, opensTopic: false }); - const postId = await createReply(c.env.DB, { topicId: topic.id, userId: user.id, body, attachmentIds: attachmentIds(form) }); + const postId = await createReply(c.env.DB, { topicId: topic.id, userId: user.id, body, attachmentIds: attachmentIds(form), refused: refusal }); await settleAttachments(c.env, postId); if (refusal) { - await refusePost(c.env, postId, user.id, refusal); + await refusePost(c.env, { postId, topicId: topic.id, userId: user.id, reason: refusal }); return page(c, refusedPage(), { title: "Not posted", status: 422, mainClass: "narrow" }); } return c.redirect(await postLink(c, topic, postId)); diff --git a/web/src/db.ts b/web/src/db.ts index a09351d..684e60a 100644 --- a/web/src/db.ts +++ b/web/src/db.ts @@ -224,55 +224,76 @@ export async function reactionsForPosts(db: D1Database, postIds: number[], viewe export async function createTopic( db: D1Database, - input: { categoryId: number; userId: number; title: string; body: string; attachmentIds: string[] }, + input: { categoryId: number; userId: number; title: string; body: string; attachmentIds: string[]; refused?: string | null }, ): Promise<{ topicId: number; postId: number }> { const t = now(); + // A post the spam filter refused is stored hidden from the start, so nobody else ever sees it. + const status = input.refused ? "spam" : "visible"; const topic = await db .prepare( - `INSERT INTO topics (category_id, user_id, title, slug, created_at, last_post_at, last_post_user_id, post_count) - VALUES (?, ?, ?, ?, ?, ?, ?, 1) RETURNING id`, + `INSERT INTO topics (category_id, user_id, title, slug, created_at, last_post_at, last_post_user_id, post_count, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`, ) - .bind(input.categoryId, input.userId, input.title, slugify(input.title), t, t, input.userId) + .bind(input.categoryId, input.userId, input.title, slugify(input.title), t, t, input.userId, input.refused ? 0 : 1, status) .first<{ id: number }>(); const topicId = topic!.id; + const postId = await insertPost(db, topicId, input.userId, input.body, t, input.refused); + await batch(db, [ + ...(input.refused + ? [] + : [ + db.prepare("UPDATE categories SET topic_count = topic_count + 1 WHERE id = ?").bind(input.categoryId), + // People who start a topic watch it, so they hear about replies. + db + .prepare("INSERT INTO topic_users (user_id, topic_id, watching, last_seen_at) VALUES (?, ?, 1, ?) ON CONFLICT DO UPDATE SET watching = 1") + .bind(input.userId, topicId, t), + ]), + ...claimAttachments(db, postId, input.userId, input.attachmentIds), + ]); + return { topicId, postId }; +} + +/** D1 refuses an empty batch. */ +async function batch(db: D1Database, stmts: D1PreparedStatement[]): Promise<void> { + if (stmts.length) await db.batch(stmts); +} + +/** Insert a post, visible, or hidden as spam with the filter's reason. */ +async function insertPost(db: D1Database, topicId: number, userId: number, body: string, t: string, refused?: string | null): Promise<number> { const post = await db - .prepare("INSERT INTO posts (topic_id, user_id, body, created_at) VALUES (?, ?, ?, ?) RETURNING id") - .bind(topicId, input.userId, input.body, t) + .prepare( + `INSERT INTO posts (topic_id, user_id, body, created_at, status, moderated_at, moderation_reason) + VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id`, + ) + .bind(topicId, userId, body, t, refused ? "spam" : "visible", refused ? t : null, refused ? `Spam filter: ${refused}` : null) .first<{ id: number }>(); - await db.batch([ - db.prepare("UPDATE categories SET topic_count = topic_count + 1 WHERE id = ?").bind(input.categoryId), - // People who start a topic watch it, so they hear about replies. - db - .prepare("INSERT INTO topic_users (user_id, topic_id, watching, last_seen_at) VALUES (?, ?, 1, ?) ON CONFLICT DO UPDATE SET watching = 1") - .bind(input.userId, topicId, t), - ...claimAttachments(db, post!.id, input.userId, input.attachmentIds), - ]); - return { topicId, postId: post!.id }; + return post!.id; } export async function createReply( db: D1Database, - input: { topicId: number; userId: number; body: string; attachmentIds: string[] }, + input: { topicId: number; userId: number; body: string; attachmentIds: string[]; refused?: string | null }, ): Promise<number> { const t = now(); - const post = await db - .prepare("INSERT INTO posts (topic_id, user_id, body, created_at) VALUES (?, ?, ?, ?) RETURNING id") - .bind(input.topicId, input.userId, input.body, t) - .first<{ id: number }>(); - await db.batch([ - db - .prepare("UPDATE topics SET post_count = post_count + 1, last_post_at = ?, last_post_user_id = ? WHERE id = ?") - .bind(t, input.userId, input.topicId), - // Replying watches the topic, unless the person turned watching off for it before. - db - .prepare( - `INSERT INTO topic_users (user_id, topic_id, watching, last_seen_at) VALUES (?, ?, 1, ?) - ON CONFLICT DO UPDATE SET watching = coalesce(watching, 1), last_seen_at = excluded.last_seen_at`, - ) - .bind(input.userId, input.topicId, t), - ...claimAttachments(db, post!.id, input.userId, input.attachmentIds), + const postId = await insertPost(db, input.topicId, input.userId, input.body, t, input.refused); + await batch(db, [ + ...(input.refused + ? [] + : [ + db + .prepare("UPDATE topics SET post_count = post_count + 1, last_post_at = ?, last_post_user_id = ? WHERE id = ?") + .bind(t, input.userId, input.topicId), + // Replying watches the topic, unless the person turned watching off for it before. + db + .prepare( + `INSERT INTO topic_users (user_id, topic_id, watching, last_seen_at) VALUES (?, ?, 1, ?) + ON CONFLICT DO UPDATE SET watching = coalesce(watching, 1), last_seen_at = excluded.last_seen_at`, + ) + .bind(input.userId, input.topicId, t), + ]), + ...claimAttachments(db, postId, input.userId, input.attachmentIds), ]); - return post!.id; + return postId; } /** Attach uploads to a post. Only the uploader's own, still-unattached files can be claimed. */ diff --git a/web/src/spamfilter.ts b/web/src/spamfilter.ts index fc06552..10214fe 100644 --- a/web/src/spamfilter.ts +++ b/web/src/spamfilter.ts @@ -1,10 +1,12 @@ -// Spam filter for new posts. It reads only what new accounts write, refuses -// what it is sure about, and never stands in the way otherwise: no key, an -// error, a slow answer or a doubtful verdict all mean the post is published. +// Spam filter for new posts. It reads what new accounts write before the post +// is stored, refuses what it is sure about, and never stands in the way +// otherwise: no key, an error, a slow answer or a doubtful verdict all mean the +// post is published. When the filter can't be reached the admins hear about it. import type { Bindings } from "./env.ts"; -import type { User } from "./db.ts"; import { deletePostFiles } from "./files.ts"; -import { banUser, setPostStatus } from "./moderation.ts"; +import { banUser, log } from "./moderation.ts"; +import { sendMail } from "./mailer.ts"; +import { rateLimit, type User } from "./db.ts"; const MODEL = "claude-haiku-5-5"; /** The filter refuses a post only at this confidence or higher. */ @@ -16,6 +18,8 @@ const MAX_REFUSALS = 3; const TIMEOUT_MS = 5000; /** A refused post is kept this long, in case the filter was wrong, and then deleted. */ const KEEP_REFUSED_MS = 30 * 24 * 3600 * 1000; +/** Admins get at most one "filter unavailable" email in this window. */ +const ALERT_EVERY_SECONDS = 6 * 3600; const SYSTEM = `You check a new post on the NodeBox forum before it is published. NodeBox is a free, open-source family of tools for generative design and data visualisation. People ask questions @@ -86,20 +90,51 @@ export async function refusalReason(env: Bindings, user: User, post: PostToCheck }; return out.verdict === "spam" && Number(out.confidence) >= REFUSE_AT ? String(out.reason).slice(0, 280) : null; } catch (err) { - console.error(`spam filter unavailable, publishing: ${err instanceof Error ? err.message : err}`); + const message = err instanceof Error ? err.message : String(err); + console.error(`spam filter unavailable, publishing: ${message}`); + await alertAdmins(env, message); return null; } } +/** + * Tell the admins the filter is down, once per window: posts from new accounts + * are going out unchecked until it is back. Failing to send never blocks a post. + */ +async function alertAdmins(env: Bindings, error: string): Promise<void> { + try { + if (!(await rateLimit(env.DB, "alert:spam-filter", 1, ALERT_EVERY_SECONDS))) return; + const { results } = await env.DB.prepare("SELECT email FROM users WHERE is_admin = 1 AND email IS NOT NULL AND banned_at IS NULL").all<{ email: string }>(); + const base = env.PUBLIC_URL.replace(/\/+$/, ""); + const text = `The spam filter could not check a new post, so it was published unchecked: + +${error} + +Until the filter answers again, posts from new accounts go out without a check. Look at the latest posts, and hide spam from the topic page or ${base}/admin. + +You get this email at most once every ${ALERT_EVERY_SECONDS / 3600} hours.`; + const html = text + .split("\n\n") + .map((p) => `<p>${p.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")}</p>`) + .join("\n"); + for (const { email } of results) { + await sendMail(env, { to: email, subject: `${env.SITE_NAME}: the spam filter is unavailable`, text, html }); + } + } catch (err) { + console.error("could not alert the admins about the spam filter", err); + } +} + /** Posts the filter refused: hidden as spam by nobody, and not from the Tender import. */ const REFUSED = "status = 'spam' AND moderated_by IS NULL AND tender_number IS NULL"; /** - * Hide a post the filter refused, and ban the account after too many. The post - * stays restorable from /admin until the purge removes it. + * Log a post the filter refused (it was stored hidden), and ban the account + * after too many. The post stays restorable from /admin until the purge removes it. */ -export async function refusePost(env: Bindings, postId: number, userId: number, reason: string): Promise<void> { - await setPostStatus(env.DB, postId, "spam", null, `Spam filter: ${reason}`); +export async function refusePost(env: Bindings, p: { postId: number; topicId: number; userId: number; reason: string }): Promise<void> { + const { userId } = p; + await log(env.DB, { adminId: null, action: "spam", postId: p.postId, topicId: p.topicId, userId, reason: `Spam filter: ${p.reason}` }).run(); const refused = await env.DB.prepare(`SELECT count(*) AS n FROM posts WHERE user_id = ? AND ${REFUSED}`).bind(userId).first<{ n: number }>(); if ((refused?.n ?? 0) >= MAX_REFUSALS) await banUser(env.DB, userId, null, false); } diff --git a/web/test/app.test.ts b/web/test/app.test.ts index d3b4aee..8469ae9 100644 --- a/web/test/app.test.ts +++ b/web/test/app.test.ts @@ -305,11 +305,32 @@ test("the spam filter refuses spam from new accounts", async () => { assert.equal(newTopic.status, 422); assert.equal((await env.DB.prepare("SELECT status FROM topics WHERE title = 'Win big'").first()).status, "spam"); assert.equal((await env.DB.prepare("SELECT topic_count FROM categories WHERE id = 1").first()).topic_count, before); + // Refused posts are stored hidden from the start and logged; the author doesn't watch the topic. + const winBig = await env.DB.prepare("SELECT t.id, t.post_count, p.moderation_reason FROM topics t JOIN posts p ON p.topic_id = t.id WHERE t.title = 'Win big'").first(); + assert.equal(winBig.post_count, 0); + assert.equal(winBig.moderation_reason, "Spam filter: casino advert"); + assert.equal(await env.DB.prepare("SELECT 1 FROM topic_users WHERE topic_id = ?").bind(winBig.id).first(), null); + assert.ok(await env.DB.prepare("SELECT 1 FROM moderation_log WHERE topic_id = ? AND action = 'spam' AND admin_id IS NULL").bind(winBig.id).first()); // Posting keeps working when the filter is unsure, fails, or is switched off. const frank = await signIn("frank@example.com"); assert.equal((await reply(frank, "A borderline mention of my shop")).status, 302, "spam below 0.9 is published"); - assert.equal((await reply(frank, "Posted during an outage")).status, 302, "an API error publishes"); + const mails: string[] = []; + const realLog = console.log; + console.log = (...args: unknown[]) => void mails.push(args.join(" ")); + try { + assert.equal((await reply(frank, "Posted during an outage")).status, 302, "an API error publishes"); + assert.equal((await reply(frank, "Another outage post")).status, 302); + } finally { + console.log = realLog; + } + // ...and tells the admins, once, not for every post. + const alerts = mails.filter((m) => m.includes("the spam filter is unavailable")); + const admins = (await env.DB.prepare("SELECT count(*) AS n FROM users WHERE is_admin = 1 AND email IS NOT NULL AND banned_at IS NULL").first()).n; + assert.ok(admins > 0); + assert.equal(alerts.length, admins, "one alert per admin per window"); + assert.ok(alerts.some((m) => /mail to alice@example\.com/.test(m))); + assert.match(alerts[0]!, /HTTP 529/); delete env.ANTHROPIC_API_KEY; const callsBefore = api.calls.length; assert.equal((await reply(frank, "No key, casino or not")).status, 302); From a6470bd1fde1ea61fced9688a260019887d6c7df Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 16 Oct 2026 15:58:02 +0000 Subject: [PATCH 3/4] Let members save their settings while the forum is read-only The first sign-in ends on the name form, which saves through /settings, so read-only mode turned new members away at the last step. Settings don't touch posts, so they don't get in the way of the final import. --- web/src/app.ts | 5 +++-- web/test/app.test.ts | 3 +++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/web/src/app.ts b/web/src/app.ts index 35293b8..d0c6679 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -86,11 +86,12 @@ app.use(async (c, next) => { c.set("notice", null); await next(); }); -// Before the move from Tender: reading and signing in work, writing is for admins only. +// Before the move from Tender: reading, signing in and your own settings work; anything that +// touches posts is for admins only, so the final import doesn't have to merge new content. const READ_ONLY_NOTICE = "The forum is read-only while we move it over from support.nodebox.net. Keep posting there until then."; app.use(async (c, next) => { if (c.env.READ_ONLY !== "true" || c.req.method === "GET" || c.req.method === "HEAD" || c.get("user")?.is_admin) return next(); - if (/^\/(signin|signout|unsubscribe)(\/|$)/.test(c.req.path)) return next(); + if (/^\/(signin|signout|unsubscribe|settings)(\/|$)/.test(c.req.path)) return next(); return page(c, messagePage("Read-only for now", READ_ONLY_NOTICE, { href: "/", label: "Go to the forum" }), { title: "Read-only", status: 403, mainClass: "narrow" }); }); diff --git a/web/test/app.test.ts b/web/test/app.test.ts index 8469ae9..bead731 100644 --- a/web/test/app.test.ts +++ b/web/test/app.test.ts @@ -417,6 +417,9 @@ test("read-only: everyone can read and sign in, only admins can write", async () assert.match(await (await roReq("/")).text(), /read-only/); assert.equal((await roReq("/signin/email", { method: "POST", body: form({ email: "eve@example.com", next: "/" }) })).status, 200); + // The first-sign-in name form and settings save; they don't touch posts. + assert.equal((await roReq("/settings", { method: "POST", cookie: cy, body: form({ name: "Cy", next: "/" }) })).status, 302); + assert.equal((await env.DB.prepare("SELECT name FROM users WHERE email = 'cy@example.com'").first()).name, "Cy"); const refused = await roReq("/new", { method: "POST", cookie: cy, body: form({ title: "Hello", category: "questions", body: "Hi" }) }); assert.equal(refused.status, 403); assert.match(await refused.text(), /read-only/); From ad4a248d233383de5705e3ad745bb6661f78fd47 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 16 Oct 2026 16:01:52 +0000 Subject: [PATCH 4/4] Add a light/dark switch to the header The sun/moon button switches to the other mode and remembers it in a cookie, which the server reads so the page renders in the right mode without a flash. Without a choice the device decides; picking the device's own mode again forgets the choice. --- web/client/app.ts | 22 +++++++++++++++++++++- web/public/styles.css | 36 ++++++++++++++++++++++++++++++++++++ web/scripts/build-icons.mjs | 2 +- web/src/app.ts | 4 ++++ web/src/views/icons.ts | 4 +++- web/src/views/layout.ts | 5 ++++- web/test/app.test.ts | 8 ++++++++ 7 files changed, 77 insertions(+), 4 deletions(-) diff --git a/web/client/app.ts b/web/client/app.ts index c41afa0..4acca99 100644 --- a/web/client/app.ts +++ b/web/client/app.ts @@ -1,9 +1,29 @@ -// The forum's only script: dismissing the banner, the composer (drag and drop, +// The forum's only script: the light/dark switch, dismissing the banner, the composer (drag and drop, // paste, preview) and live reactions. Everything else is plain HTML forms. const $ = <T extends Element = HTMLElement>(sel: string, root: ParentNode = document) => root.querySelector<T>(sel); const $$ = <T extends Element = HTMLElement>(sel: string, root: ParentNode = document) => [...root.querySelectorAll<T>(sel)]; +// --- Light and dark ----------------------------------------------------------- + +// The page follows the device until someone picks the other mode. Picking the +// device's own mode again forgets the choice, so it follows the device again. +const deviceDark = matchMedia("(prefers-color-scheme: dark)"); +for (const btn of $$("[data-theme-toggle]")) { + btn.addEventListener("click", () => { + const root = document.documentElement; + const isDark = root.dataset.theme ? root.dataset.theme === "dark" : deviceDark.matches; + const next = isDark ? "light" : "dark"; + if ((next === "dark") === deviceDark.matches) { + delete root.dataset.theme; + document.cookie = "nbf_theme=; path=/; max-age=0; samesite=lax"; + } else { + root.dataset.theme = next; + document.cookie = `nbf_theme=${next}; path=/; max-age=31536000; samesite=lax`; + } + }); +} + // --- Banner ------------------------------------------------------------------- for (const btn of $$("[data-dismiss-banner]")) { diff --git a/web/public/styles.css b/web/public/styles.css index b716a32..1b58db5 100644 --- a/web/public/styles.css +++ b/web/public/styles.css @@ -67,6 +67,26 @@ color-scheme: dark; } } +/* Picked with the sun/moon button in the header; the device setting otherwise. */ +:root[data-theme="dark"] { + --blue: #8fbcef; + --heart: #e58a80; + --accent: #c98a5a; + --ink: #e8eef6; + --ink-2: #b6c4d5; + --ink-3: #93a3b8; + --rule: #1f3a5a; + --rule-strong: #e8eef6; + --wash: #0a2441; + --paper: #041a32; + --field: #061f3b; + --button: #e8eef6; + --button-hover: #8fbcef; + --danger: #ff8a8a; + --success: #6fd6a0; + --code-bg: #020f1f; + color-scheme: dark; +} :root { --gap: 24px; @@ -124,6 +144,14 @@ button, input, select, textarea { font: inherit; color: inherit; } .header-nav { grid-column: 10 / -1; justify-self: end; display: flex; align-items: center; gap: 8px; } .header-icon { color: #c9d5e3; width: 40px; height: 40px; } .header-icon:hover { color: #fff; background: rgb(255 255 255 / 8%); } +/* The theme button shows the moon in light mode and the sun in dark mode: what a click switches to. */ +.theme-toggle .icon-sun { display: none; } +:root[data-theme="dark"] .theme-toggle .icon-sun { display: block; } +:root[data-theme="dark"] .theme-toggle .icon-moon { display: none; } +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .theme-toggle .icon-sun { display: block; } + :root:not([data-theme="light"]) .theme-toggle .icon-moon { display: none; } +} .icon-button.nav-toggle { display: none; color: #fff; margin-left: -8px; } .user-menu { position: relative; } @@ -210,6 +238,7 @@ button, input, select, textarea { font: inherit; color: inherit; } .avatar-md { width: 40px; height: 40px; font-size: 15px; } .avatar-sm { width: 28px; height: 28px; font-size: 12px; } @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .avatar { background: hsl(var(--hue) 25% 26%); color: #e8eef6; } } +:root[data-theme="dark"] .avatar { background: hsl(var(--hue) 25% 26%); color: #e8eef6; } .cat { display: inline-flex; align-items: center; gap: 8px; font-weight: 700; color: var(--ink-2); text-decoration: none; } .cat:hover { color: var(--ink); text-decoration: underline; } @@ -313,6 +342,7 @@ button, input, select, textarea { font: inherit; color: inherit; } .prose pre { font-family: var(--mono); background: var(--code-bg); color: var(--code-ink); padding: 16px 20px; overflow-x: auto; font-size: 15px; line-height: 24px; border-radius: var(--radius); } .prose pre code { background: none; padding: 0; color: inherit; font-size: inherit; } @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .prose pre { border: 1px solid var(--rule); } } +:root[data-theme="dark"] .prose pre { border: 1px solid var(--rule); } .prose img { border: 1px solid var(--rule); border-radius: var(--radius); } .prose hr { border: 0; border-top: 1px solid var(--rule); margin: 24px 0; } .prose table { border-collapse: collapse; } @@ -436,12 +466,18 @@ article.prose > h1 { margin-bottom: 24px; } .nav-open .sidebar { display: block; position: fixed; z-index: 25; top: 0; left: 0; bottom: 0; width: 288px; overflow-y: auto; padding: 24px; background: var(--paper); border-right: 1px solid var(--rule); box-shadow: 0 0 0 100vmax rgb(4 26 50 / 40%); } } +@media (max-width: 400px) { + /* Room for the theme button next to "Sign in" on small phones. */ + .brand-sub { display: none; } +} @media (max-width: 720px) { :root { --pad: 16px; --gap: 16px; --header: 64px; } .header-row { grid-template-columns: minmax(0, 1fr) auto; row-gap: 8px; padding-block: 12px; } .header-brand, .header-nav { grid-column: auto; } .search { grid-column: 1 / -1; grid-row: 2; } .brand-word, .brand-sub { font-size: 20px; } + .header-nav { gap: 4px; } + .theme-toggle { width: 32px; } .shell { padding-top: 24px; } .main, .main-head, .topic, .posts, .topic-foot, .topics { display: block; } .main-head-action { margin-top: 16px; justify-self: start; } diff --git a/web/scripts/build-icons.mjs b/web/scripts/build-icons.mjs index 40c9497..799f9e5 100644 --- a/web/scripts/build-icons.mjs +++ b/web/scripts/build-icons.mjs @@ -8,7 +8,7 @@ const NAMES = [ "plus", "heart", "smile-plus", "link", "reply", "paperclip", "eye", "eye-off", "circle-check", "pin", "lock", "settings", "log-out", "log-in", "chevron-left", "chevron-right", "x", "menu", "download", "file-archive", "file", "box", "image", "ban", "rotate-ccw", "trash-2", "user-round", - "search", "circle-help", "quote", "archive", "rss", + "search", "circle-help", "quote", "archive", "rss", "sun", "moon", ]; const dir = path.join(import.meta.dirname, "../node_modules/lucide-static/icons"); diff --git a/web/src/app.ts b/web/src/app.ts index d0c6679..5e8d45b 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -72,6 +72,9 @@ import { const TOPICS_PER_PAGE = 30; const POSTS_PER_PAGE = 40; const BANNER_COOKIE = "nbf_banner"; +/** Set by the sun/moon button (client/app.ts); without it the device's setting decides. */ +const THEME_COOKIE = "nbf_theme"; +const themeFromCookie = (v: string | undefined) => (v === "light" || v === "dark" ? v : null); export const app = new Hono<AppEnv>(); @@ -133,6 +136,7 @@ async function page( unanswered, query: opts.query, mainClass: opts.mainClass, + theme: themeFromCookie(getCookie(c, THEME_COOKIE)), feeds: [...(opts.feed ? [opts.feed] : []), ...siteFeeds(c.env.SITE_NAME).filter((f) => f.href !== opts.feed?.href)], }, content, diff --git a/web/src/views/icons.ts b/web/src/views/icons.ts index 8964615..9322fb4 100644 --- a/web/src/views/icons.ts +++ b/web/src/views/icons.ts @@ -40,7 +40,9 @@ const PATHS = { "circle-help": "<circle cx=\"12\" cy=\"12\" r=\"10\" /><path d=\"M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3\" /><path d=\"M12 17h.01\" />", "quote": "<path d=\"M16 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z\" /><path d=\"M5 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z\" />", "archive": "<rect width=\"20\" height=\"5\" x=\"2\" y=\"3\" rx=\"1\" /><path d=\"M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8\" /><path d=\"M10 12h4\" />", - "rss": "<path d=\"M4 11a9 9 0 0 1 9 9\" /><path d=\"M4 4a16 16 0 0 1 16 16\" /><circle cx=\"5\" cy=\"19\" r=\"1\" />" + "rss": "<path d=\"M4 11a9 9 0 0 1 9 9\" /><path d=\"M4 4a16 16 0 0 1 16 16\" /><circle cx=\"5\" cy=\"19\" r=\"1\" />", + "sun": "<circle cx=\"12\" cy=\"12\" r=\"4\" /><path d=\"M12 2v2\" /><path d=\"M12 20v2\" /><path d=\"m4.93 4.93 1.41 1.41\" /><path d=\"m17.66 17.66 1.41 1.41\" /><path d=\"M2 12h2\" /><path d=\"M20 12h2\" /><path d=\"m6.34 17.66-1.41 1.41\" /><path d=\"m19.07 4.93-1.41 1.41\" />", + "moon": "<path d=\"M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401\" />" } as const; export type IconName = keyof typeof PATHS; diff --git a/web/src/views/layout.ts b/web/src/views/layout.ts index 0fd7ae6..1b2024a 100644 --- a/web/src/views/layout.ts +++ b/web/src/views/layout.ts @@ -24,6 +24,8 @@ export interface LayoutProps { query?: string; /** Extra class on <main>, e.g. "narrow". */ mainClass?: string; + /** "light" or "dark" when the visitor picked one with the header button; otherwise the device decides. */ + theme?: "light" | "dark" | null; /** RSS feeds offered to browsers and feed readers, this page's own first. */ feeds?: { title: string; href: string }[]; } @@ -87,7 +89,7 @@ export function layout(props: LayoutProps, content: Html): Html { const title = props.title ? `${props.title} Β· ${props.siteName}` : props.siteName; const signInHref = `/signin?next=${encodeURIComponent(props.path)}`; return html`<!doctype html> -<html lang="en"> +<html lang="en"${props.theme ? raw(` data-theme="${props.theme}"`) : ""}> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> @@ -114,6 +116,7 @@ export function layout(props: LayoutProps, content: Html): Html { <input id="search-q" name="q" type="search" value="${props.query ?? ""}" placeholder="Search questions and answers" autocomplete="off"> </form> <nav class="header-nav" aria-label="Account"> + <button type="button" class="icon-button header-icon theme-toggle" data-theme-toggle aria-label="Switch between light and dark" title="Switch between light and dark">${icon("moon", { size: 20, class: "icon-moon" })}${icon("sun", { size: 20, class: "icon-sun" })}</button> ${props.user ? html`<a class="icon-button header-icon" href="/topics/mine" aria-label="Your topics" title="Your topics">${icon("bell", { size: 20 })}</a> <details class="user-menu"> diff --git a/web/test/app.test.ts b/web/test/app.test.ts index bead731..e492a37 100644 --- a/web/test/app.test.ts +++ b/web/test/app.test.ts @@ -505,3 +505,11 @@ test("RSS feeds", async () => { assert.match(await (await req("/c/feeds-cat")).text(), /<link rel="alternate" type="application\/rss\+xml" title="Feeds & Things: new topics" href="\/feeds\/c\/feeds-cat">/); assert.match(await (await req("/feeds")).text(), /\/feeds\/c\/feeds-cat\/posts/); }); + +test("the light/dark choice is rendered from its cookie", async () => { + assert.match(await (await req("/")).text(), /<html lang="en">/, "no cookie: the device decides"); + assert.match(await (await req("/", { cookie: "nbf_theme=dark" })).text(), /<html lang="en" data-theme="dark">/); + assert.match(await (await req("/", { cookie: "nbf_theme=light" })).text(), /<html lang="en" data-theme="light">/); + assert.match(await (await req("/", { cookie: 'nbf_theme="><script>' })).text(), /<html lang="en">/); + assert.match(await (await req("/")).text(), /data-theme-toggle/); +});