diff --git a/docs/PLAN.md b/docs/PLAN.md index d64f864..853ccbb 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 @@ -206,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/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 $ = (sel: string, root: ParentNode = document) => root.querySelector(sel); const $$ = (sel: string, root: ParentNode = document) => [...root.querySelectorAll(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 bae5144..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; } @@ -174,7 +202,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; } @@ -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 43f2d4c..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", + "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 ea08a67..5e8d45b 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, @@ -70,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(); @@ -84,11 +89,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" }); }); @@ -103,7 +109,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 +136,8 @@ 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, ), @@ -129,6 +145,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 +185,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 +205,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 +221,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 +265,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 +285,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[] { @@ -286,10 +467,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); @@ -309,10 +490,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 { + 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 { 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 { 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/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/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 { + 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.replace(/&/g, "&").replace(//g, ">")}

`) + .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 { - 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 { + 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/src/views/icons.ts b/web/src/views/icons.ts index 9887e0b..9322fb4 100644 --- a/web/src/views/icons.ts +++ b/web/src/views/icons.ts @@ -39,7 +39,10 @@ const PATHS = { "search": "", "circle-help": "", "quote": "", - "archive": "" + "archive": "", + "rss": "", + "sun": "", + "moon": "" } as const; export type IconName = keyof typeof PATHS; diff --git a/web/src/views/layout.ts b/web/src/views/layout.ts index f3700fa..1b2024a 100644 --- a/web/src/views/layout.ts +++ b/web/src/views/layout.ts @@ -24,6 +24,10 @@ export interface LayoutProps { query?: string; /** Extra class on
, 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 }[]; } /** The NodeBox Live mark (logo.svg from Frederik), drawn in the current text colour. */ @@ -75,6 +79,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 })} `; @@ -84,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` - + @@ -94,6 +99,7 @@ export function layout(props: LayoutProps, content: Html): Html { + ${(props.feeds ?? []).map((f) => html`\n `)} @@ -110,6 +116,7 @@ export function layout(props: LayoutProps, content: Html): Html {