| 1 | // Reasonix Community forum API. Identity from id.reasonix.io; content + anti-abuse |
| 2 | // state in D1. The Hono app is itself the Workers fetch handler. |
| 3 | import { Hono } from "hono"; |
| 4 | import type { Context } from "hono"; |
| 5 | import type { ContentfulStatusCode } from "hono/utils/http-status"; |
| 6 | import { cors } from "hono/cors"; |
| 7 | import { z } from "zod"; |
| 8 | import type { AppEnv } from "./env"; |
| 9 | import { loadMember, currentMember, HttpError } from "./identity"; |
| 10 | import { assertCanFlag, assertCanInteract, assertCanPost, dailyPostCap, rateLimited, AUTO_HIDE_FLAGS } from "./antispam"; |
| 11 | |
| 12 | const app = new Hono<AppEnv>(); |
| 13 | |
| 14 | app.onError((err, c) => { |
| 15 | if (err instanceof HttpError) return c.json({ error: { code: err.code, message: err.message } }, err.status as ContentfulStatusCode); |
| 16 | if (err instanceof z.ZodError) { |
| 17 | const issue = err.issues[0]; |
| 18 | const path = issue?.path.join("."); |
| 19 | const message = issue ? (path ? `${path}: ${issue.message}` : issue.message) : "Some fields are invalid."; |
| 20 | return c.json({ error: { code: "invalid_input", message } }, 422); |
| 21 | } |
| 22 | if (err instanceof SyntaxError) { |
| 23 | return c.json({ error: { code: "invalid_json", message: "Request body must be valid JSON." } }, 400); |
| 24 | } |
| 25 | console.error("forum error:", err); |
| 26 | return c.json({ error: { code: "internal", message: "Something went wrong." } }, 500); |
| 27 | }); |
| 28 | |
| 29 | app.use("*", (c, next) => { |
| 30 | const allowed = (c.env.ALLOWED_ORIGINS ?? "").split(",").map((s) => s.trim()).filter(Boolean); |
| 31 | return cors({ |
| 32 | origin: (o) => (allowed.includes(o) ? o : null), |
| 33 | credentials: true, |
| 34 | allowMethods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"], |
| 35 | allowHeaders: ["Content-Type", "Authorization"], |
| 36 | })(c, next); |
| 37 | }); |
| 38 | app.use("*", loadMember); |
| 39 | |
| 40 | const slugify = (s: string) => |
| 41 | s.toLowerCase().replace(/[^a-z0-9一-鿿]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "topic"; |
| 42 | |
| 43 | async function postsToday(c: { env: AppEnv["Bindings"] }, email: string): Promise<number> { |
| 44 | const since = new Date(Date.now() - 86_400_000).toISOString(); |
| 45 | const row = await c.env.DB.prepare("SELECT COUNT(*) AS n FROM posts WHERE author = ?1 AND created_at > ?2") |
| 46 | .bind(email, since) |
| 47 | .first<{ n: number }>(); |
| 48 | return row?.n ?? 0; |
| 49 | } |
| 50 | |
| 51 | async function enforceBurstRate(c: { env: AppEnv["Bindings"]; req: { header: (k: string) => string | undefined } }, member: Parameters<typeof rateLimited>[0]): Promise<void> { |
| 52 | if (!rateLimited(member)) return; |
| 53 | const limiter = c.env.POST_LIMITER; |
| 54 | if (limiter) { |
| 55 | const ip = c.req.header("cf-connecting-ip") ?? member.email; |
| 56 | const { success } = await limiter.limit({ key: ip }); |
| 57 | if (!success) throw new HttpError(429, "rate_limited", "You're posting too fast — take a short break."); |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | async function enforcePostRate(c: { env: AppEnv["Bindings"]; req: { header: (k: string) => string | undefined } }, member: Parameters<typeof rateLimited>[0]): Promise<void> { |
| 62 | await enforceBurstRate(c, member); |
| 63 | if ((await postsToday(c, member.email)) >= dailyPostCap(member.trust)) { |
| 64 | throw new HttpError(429, "daily_limit", "You've hit today's posting limit for your trust level."); |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | app.get("/health", (c) => c.json({ ok: true, service: "forum" })); |
| 69 | |
| 70 | app.get("/categories", async (c) => { |
| 71 | const rows = await c.env.DB.prepare( |
| 72 | `SELECT c.id, c.slug, c.name, c.description, c.min_trust_to_post AS minTrust, |
| 73 | (SELECT COUNT(*) FROM topics t WHERE t.category_id = c.id) AS topicCount, |
| 74 | (SELECT MAX(last_post_at) FROM topics t WHERE t.category_id = c.id) AS lastActivity |
| 75 | FROM categories c ORDER BY c.position, c.id`, |
| 76 | ).all(); |
| 77 | return c.json({ categories: rows.results }); |
| 78 | }); |
| 79 | |
| 80 | const TopicList = z.object({ category: z.string().optional(), sort: z.enum(["latest", "top"]).optional() }); |
| 81 | app.get("/topics", async (c) => { |
| 82 | const q = TopicList.parse(Object.fromEntries(new URL(c.req.url).searchParams)); |
| 83 | const order = q.sort === "top" ? "t.reply_count DESC, t.last_post_at DESC" : "t.pinned DESC, t.last_post_at DESC"; |
| 84 | const where = q.category ? "WHERE cat.slug = ?1 AND t.status != 'hidden'" : "WHERE t.status != 'hidden'"; |
| 85 | const stmt = c.env.DB.prepare( |
| 86 | `SELECT t.id, t.title, t.slug, t.status, t.pinned, t.reply_count AS replyCount, t.view_count AS viewCount, |
| 87 | COALESCE(m.handle, 'deleted') AS author, t.created_at AS createdAt, t.last_post_at AS lastPostAt, |
| 88 | cat.slug AS category, cat.name AS categoryName |
| 89 | FROM topics t JOIN categories cat ON cat.id = t.category_id |
| 90 | LEFT JOIN members m ON m.email = t.author ${where} ORDER BY ${order} LIMIT 50`, |
| 91 | ); |
| 92 | const rows = await (q.category ? stmt.bind(q.category) : stmt).all(); |
| 93 | return c.json({ topics: rows.results }); |
| 94 | }); |
| 95 | |
| 96 | app.get("/topics/:id", async (c) => { |
| 97 | const id = Number(c.req.param("id")); |
| 98 | const viewer = c.get("member")?.email ?? ""; |
| 99 | const topic = await c.env.DB.prepare( |
| 100 | `SELECT t.id, t.title, t.slug, t.status, t.pinned, COALESCE(m.handle, 'deleted') AS author, |
| 101 | t.accepted_post_id AS acceptedPostId, |
| 102 | t.reply_count AS replyCount, t.view_count AS viewCount, t.created_at AS createdAt, cat.slug AS category |
| 103 | FROM topics t JOIN categories cat ON cat.id = t.category_id |
| 104 | LEFT JOIN members m ON m.email = t.author WHERE t.id = ?1 AND t.status != 'hidden'`, |
| 105 | ).bind(id).first(); |
| 106 | if (!topic) throw new HttpError(404, "not_found", "That topic doesn't exist."); |
| 107 | await c.env.DB.prepare("UPDATE topics SET view_count = view_count + 1 WHERE id = ?1").bind(id).run(); |
| 108 | const posts = await c.env.DB.prepare( |
| 109 | `SELECT p.id, COALESCE(m.handle, 'deleted') AS author, p.body, p.status, p.like_count AS likeCount, |
| 110 | p.created_at AS createdAt, p.edited_at AS editedAt, COALESCE(m.handle, 'deleted') AS handle, m.trust, m.role, |
| 111 | CASE WHEN ?2 != '' AND EXISTS ( |
| 112 | SELECT 1 FROM reactions r WHERE r.post_id = p.id AND r.member = ?2 AND r.emoji = 'like' |
| 113 | ) THEN 1 ELSE 0 END AS liked |
| 114 | FROM posts p LEFT JOIN members m ON m.email = p.author |
| 115 | WHERE p.topic_id = ?1 AND p.status IN ('visible') ORDER BY p.created_at`, |
| 116 | ).bind(id, viewer).all(); |
| 117 | return c.json({ topic, posts: posts.results }); |
| 118 | }); |
| 119 | |
| 120 | const NewTopic = z.object({ |
| 121 | categoryId: z.number().int().positive(), |
| 122 | title: z.string().trim().min(6).max(160), |
| 123 | body: z.string().trim().min(10).max(20000), |
| 124 | }); |
| 125 | app.post("/topics", async (c) => { |
| 126 | const member = currentMember(c); |
| 127 | const input = NewTopic.parse(await c.req.json()); |
| 128 | const cat = await c.env.DB.prepare("SELECT id, min_trust_to_post AS minTrust FROM categories WHERE id = ?1") |
| 129 | .bind(input.categoryId) |
| 130 | .first<{ id: number; minTrust: number }>(); |
| 131 | if (!cat) throw new HttpError(404, "no_category", "That category doesn't exist."); |
| 132 | assertCanPost(member, { minTrust: cat.minTrust, body: input.body }); |
| 133 | await enforcePostRate(c, member); |
| 134 | |
| 135 | const now = new Date().toISOString(); |
| 136 | const [topicRes] = await c.env.DB.batch([ |
| 137 | c.env.DB.prepare( |
| 138 | `INSERT INTO topics (category_id, author, title, slug, created_at, last_post_at) |
| 139 | VALUES (?1, ?2, ?3, ?4, ?5, ?5)`, |
| 140 | ).bind(cat.id, member.email, input.title, slugify(input.title), now), |
| 141 | c.env.DB.prepare( |
| 142 | `INSERT INTO posts (topic_id, author, body, created_at) |
| 143 | VALUES (last_insert_rowid(), ?1, ?2, ?3)`, |
| 144 | ).bind(member.email, input.body, now), |
| 145 | c.env.DB.prepare("UPDATE members SET post_count = post_count + 1 WHERE email = ?1").bind(member.email), |
| 146 | ]); |
| 147 | const topicId = Number(topicRes.meta.last_row_id); |
| 148 | return c.json({ topic: { id: topicId, slug: slugify(input.title) } }, 201); |
| 149 | }); |
| 150 | |
| 151 | const Reply = z.object({ body: z.string().trim().min(2).max(20000) }); |
| 152 | app.post("/topics/:id/posts", async (c) => { |
| 153 | const member = currentMember(c); |
| 154 | const topicId = Number(c.req.param("id")); |
| 155 | const input = Reply.parse(await c.req.json()); |
| 156 | const topic = await c.env.DB.prepare( |
| 157 | "SELECT t.id, t.status, c.min_trust_to_post AS minTrust FROM topics t JOIN categories c ON c.id = t.category_id WHERE t.id = ?1", |
| 158 | ) |
| 159 | .bind(topicId) |
| 160 | .first<{ id: number; status: string; minTrust: number }>(); |
| 161 | if (!topic || topic.status === "hidden") throw new HttpError(404, "not_found", "That topic doesn't exist."); |
| 162 | if (topic.status === "closed") throw new HttpError(403, "closed", "This topic is closed to new replies."); |
| 163 | assertCanPost(member, { minTrust: topic.minTrust, body: input.body }); |
| 164 | await enforcePostRate(c, member); |
| 165 | |
| 166 | const now = new Date().toISOString(); |
| 167 | const [res] = await c.env.DB.batch([ |
| 168 | c.env.DB.prepare("INSERT INTO posts (topic_id, author, body, created_at) VALUES (?1, ?2, ?3, ?4)") |
| 169 | .bind(topicId, member.email, input.body, now), |
| 170 | c.env.DB.prepare("UPDATE topics SET reply_count = reply_count + 1, last_post_at = ?2 WHERE id = ?1") |
| 171 | .bind(topicId, now), |
| 172 | c.env.DB.prepare("UPDATE members SET post_count = post_count + 1 WHERE email = ?1").bind(member.email), |
| 173 | ]); |
| 174 | return c.json({ post: { id: Number(res.meta.last_row_id) } }, 201); |
| 175 | }); |
| 176 | |
| 177 | const Flag = z.object({ reason: z.enum(["spam", "offensive", "off_topic", "other"]), note: z.string().trim().max(500).optional() }); |
| 178 | app.post("/posts/:id/flags", async (c) => { |
| 179 | const member = currentMember(c); |
| 180 | const postId = Number(c.req.param("id")); |
| 181 | const input = Flag.parse(await c.req.json()); |
| 182 | const post = await c.env.DB.prepare("SELECT id, status, author FROM posts WHERE id = ?1") |
| 183 | .bind(postId) |
| 184 | .first<{ id: number; status: string; author: string }>(); |
| 185 | if (!post) throw new HttpError(404, "not_found", "That post doesn't exist."); |
| 186 | assertCanFlag(member, post.author); |
| 187 | await enforceBurstRate(c, member); |
| 188 | |
| 189 | const now = new Date().toISOString(); |
| 190 | const results = await c.env.DB.batch([ |
| 191 | c.env.DB.prepare( |
| 192 | "INSERT INTO flags (post_id, reporter, reason, note, created_at) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(post_id, reporter) DO NOTHING", |
| 193 | ).bind(postId, member.email, input.reason, input.note ?? "", now), |
| 194 | c.env.DB.prepare( |
| 195 | "UPDATE posts SET flag_count = (SELECT COUNT(*) FROM flags WHERE post_id = ?1) WHERE id = ?1", |
| 196 | ).bind(postId), |
| 197 | c.env.DB.prepare( |
| 198 | "UPDATE posts SET status = 'hidden' WHERE id = ?1 AND status = 'visible' AND flag_count >= ?2", |
| 199 | ).bind(postId, AUTO_HIDE_FLAGS), |
| 200 | c.env.DB.prepare( |
| 201 | `INSERT INTO mod_log (at, actor, action, target, detail) |
| 202 | SELECT ?1, 'system', 'auto_hide_post', ?2, 'flag threshold reached' WHERE changes() = 1`, |
| 203 | ).bind(now, String(postId)), |
| 204 | c.env.DB.prepare("SELECT flag_count AS flagCount, status FROM posts WHERE id = ?1").bind(postId), |
| 205 | ]); |
| 206 | const state = results[4]?.results[0] as { flagCount?: number; status?: string } | undefined; |
| 207 | return c.json({ ok: true, flagCount: state?.flagCount ?? 0, hidden: state?.status === "hidden" }); |
| 208 | }); |
| 209 | |
| 210 | async function setLike(c: Context<AppEnv>, liked: boolean): Promise<Response> { |
| 211 | const member = currentMember(c); |
| 212 | assertCanInteract(member); |
| 213 | await enforceBurstRate(c, member); |
| 214 | const postId = Number(c.req.param("id")); |
| 215 | const post = await c.env.DB.prepare("SELECT id FROM posts WHERE id = ?1 AND status = 'visible'") |
| 216 | .bind(postId) |
| 217 | .first<{ id: number }>(); |
| 218 | if (!post) throw new HttpError(404, "not_found", "That post doesn't exist."); |
| 219 | |
| 220 | const mutation = liked |
| 221 | ? c.env.DB.prepare( |
| 222 | "INSERT INTO reactions (post_id, member, emoji, created_at) VALUES (?1, ?2, 'like', ?3) ON CONFLICT(post_id, member, emoji) DO NOTHING", |
| 223 | ).bind(postId, member.email, new Date().toISOString()) |
| 224 | : c.env.DB.prepare("DELETE FROM reactions WHERE post_id = ?1 AND member = ?2 AND emoji = 'like'").bind(postId, member.email); |
| 225 | const [, updated] = await c.env.DB.batch([ |
| 226 | mutation, |
| 227 | c.env.DB.prepare( |
| 228 | `UPDATE posts SET like_count = ( |
| 229 | SELECT COUNT(*) FROM reactions WHERE post_id = ?1 AND emoji = 'like' |
| 230 | ) WHERE id = ?1 RETURNING like_count AS likeCount`, |
| 231 | ).bind(postId), |
| 232 | ]); |
| 233 | const state = updated?.results[0] as { likeCount?: number } | undefined; |
| 234 | return c.json({ ok: true, liked, likeCount: state?.likeCount ?? 0 }); |
| 235 | } |
| 236 | |
| 237 | app.post("/posts/:id/likes", (c) => setLike(c, true)); |
| 238 | app.delete("/posts/:id/likes", (c) => setLike(c, false)); |
| 239 | |
| 240 | export default app; |
| 241 |