返回 DeepSeek-Reasonix
index.ts
根目录 / workers / forum / src / index.ts
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 // Topic detail responses are keyset-paginated. `after` and `afterId` together
82 // identify the last row returned, so equal timestamps cannot duplicate or skip
83 // posts when a topic receives concurrent replies.
84 const TopicPostsQuery = z
85 .object({
86 limit: z.coerce.number().int().min(1).max(100).default(50),
87 after: z.string().trim().min(1).max(64).optional(),
88 afterId: z.coerce.number().int().positive().optional(),
89 })
90 .superRefine((value, ctx) => {
91 if ((value.after === undefined) !== (value.afterId === undefined)) {
92 ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["after"], message: "after and afterId must be provided together" });
93 }
94 });
95 app.get("/topics", async (c) => {
96 const q = TopicList.parse(Object.fromEntries(new URL(c.req.url).searchParams));
97 const order = q.sort === "top" ? "t.reply_count DESC, t.last_post_at DESC" : "t.pinned DESC, t.last_post_at DESC";
98 const where = q.category ? "WHERE cat.slug = ?1 AND t.status != 'hidden'" : "WHERE t.status != 'hidden'";
99 const stmt = c.env.DB.prepare(
100 `SELECT t.id, t.title, t.slug, t.status, t.pinned, t.reply_count AS replyCount, t.view_count AS viewCount,
101 COALESCE(m.handle, 'deleted') AS author, t.created_at AS createdAt, t.last_post_at AS lastPostAt,
102 cat.slug AS category, cat.name AS categoryName
103 FROM topics t JOIN categories cat ON cat.id = t.category_id
104 LEFT JOIN members m ON m.email = t.author ${where} ORDER BY ${order} LIMIT 50`,
105 );
106 const rows = await (q.category ? stmt.bind(q.category) : stmt).all();
107 return c.json({ topics: rows.results });
108 });
109
110 app.get("/topics/:id", async (c) => {
111 const id = Number(c.req.param("id"));
112 const viewer = c.get("member")?.email ?? "";
113 const page = TopicPostsQuery.parse(Object.fromEntries(new URL(c.req.url).searchParams));
114 const topic = await c.env.DB.prepare(
115 `SELECT t.id, t.title, t.slug, t.status, t.pinned, COALESCE(m.handle, 'deleted') AS author,
116 t.accepted_post_id AS acceptedPostId,
117 t.reply_count AS replyCount, t.view_count AS viewCount, t.created_at AS createdAt, cat.slug AS category
118 FROM topics t JOIN categories cat ON cat.id = t.category_id
119 LEFT JOIN members m ON m.email = t.author WHERE t.id = ?1 AND t.status != 'hidden'`,
120 ).bind(id).first();
121 if (!topic) throw new HttpError(404, "not_found", "That topic doesn't exist.");
122 if (!page.after) {
123 await c.env.DB.prepare("UPDATE topics SET view_count = view_count + 1 WHERE id = ?1").bind(id).run();
124 }
125 const cursor = page.after !== undefined && page.afterId !== undefined;
126 const postsSql =
127 `SELECT p.id, COALESCE(m.handle, 'deleted') AS author, p.body, p.status, p.like_count AS likeCount,
128 p.created_at AS createdAt, p.edited_at AS editedAt, COALESCE(m.handle, 'deleted') AS handle, m.trust, m.role,
129 CASE WHEN ?2 != '' AND EXISTS (
130 SELECT 1 FROM reactions r WHERE r.post_id = p.id AND r.member = ?2 AND r.emoji = 'like'
131 ) THEN 1 ELSE 0 END AS liked
132 FROM posts p LEFT JOIN members m ON m.email = p.author
133 WHERE p.topic_id = ?1 AND p.status = 'visible'${cursor ? " AND (p.created_at > ?3 OR (p.created_at = ?3 AND p.id > ?4))" : ""}
134 ORDER BY p.created_at, p.id LIMIT ?${cursor ? "5" : "3"}`;
135 const posts = await (cursor
136 ? c.env.DB.prepare(postsSql).bind(id, viewer, page.after, page.afterId, page.limit + 1)
137 : c.env.DB.prepare(postsSql).bind(id, viewer, page.limit + 1)
138 ).all();
139 const rows = posts.results ?? [];
140 const hasMore = rows.length > page.limit;
141 const visible = hasMore ? rows.slice(0, page.limit) : rows;
142 const last = visible.at(-1) as { createdAt?: string; id?: number } | undefined;
143 return c.json({
144 topic,
145 posts: visible,
146 pageInfo: {
147 limit: page.limit,
148 hasMore,
149 nextAfter: hasMore ? last?.createdAt ?? null : null,
150 nextAfterId: hasMore ? last?.id ?? null : null,
151 },
152 });
153 });
154
155 const NewTopic = z.object({
156 categoryId: z.number().int().positive(),
157 title: z.string().trim().min(6).max(160),
158 body: z.string().trim().min(10).max(20000),
159 });
160 app.post("/topics", async (c) => {
161 const member = currentMember(c);
162 const input = NewTopic.parse(await c.req.json());
163 const cat = await c.env.DB.prepare("SELECT id, min_trust_to_post AS minTrust FROM categories WHERE id = ?1")
164 .bind(input.categoryId)
165 .first<{ id: number; minTrust: number }>();
166 if (!cat) throw new HttpError(404, "no_category", "That category doesn't exist.");
167 assertCanPost(member, { minTrust: cat.minTrust, body: input.body });
168 await enforcePostRate(c, member);
169
170 const now = new Date().toISOString();
171 const [topicRes] = await c.env.DB.batch([
172 c.env.DB.prepare(
173 `INSERT INTO topics (category_id, author, title, slug, created_at, last_post_at)
174 VALUES (?1, ?2, ?3, ?4, ?5, ?5)`,
175 ).bind(cat.id, member.email, input.title, slugify(input.title), now),
176 c.env.DB.prepare(
177 `INSERT INTO posts (topic_id, author, body, created_at)
178 VALUES (last_insert_rowid(), ?1, ?2, ?3)`,
179 ).bind(member.email, input.body, now),
180 c.env.DB.prepare("UPDATE members SET post_count = post_count + 1 WHERE email = ?1").bind(member.email),
181 ]);
182 const topicId = Number(topicRes.meta.last_row_id);
183 return c.json({ topic: { id: topicId, slug: slugify(input.title) } }, 201);
184 });
185
186 const Reply = z.object({ body: z.string().trim().min(2).max(20000) });
187 app.post("/topics/:id/posts", async (c) => {
188 const member = currentMember(c);
189 const topicId = Number(c.req.param("id"));
190 const input = Reply.parse(await c.req.json());
191 const topic = await c.env.DB.prepare(
192 "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",
193 )
194 .bind(topicId)
195 .first<{ id: number; status: string; minTrust: number }>();
196 if (!topic || topic.status === "hidden") throw new HttpError(404, "not_found", "That topic doesn't exist.");
197 if (topic.status === "closed") throw new HttpError(403, "closed", "This topic is closed to new replies.");
198 assertCanPost(member, { minTrust: topic.minTrust, body: input.body });
199 await enforcePostRate(c, member);
200
201 const now = new Date().toISOString();
202 const [res] = await c.env.DB.batch([
203 c.env.DB.prepare("INSERT INTO posts (topic_id, author, body, created_at) VALUES (?1, ?2, ?3, ?4)")
204 .bind(topicId, member.email, input.body, now),
205 c.env.DB.prepare("UPDATE topics SET reply_count = reply_count + 1, last_post_at = ?2 WHERE id = ?1")
206 .bind(topicId, now),
207 c.env.DB.prepare("UPDATE members SET post_count = post_count + 1 WHERE email = ?1").bind(member.email),
208 ]);
209 return c.json({ post: { id: Number(res.meta.last_row_id) } }, 201);
210 });
211
212 const Flag = z.object({ reason: z.enum(["spam", "offensive", "off_topic", "other"]), note: z.string().trim().max(500).optional() });
213 app.post("/posts/:id/flags", async (c) => {
214 const member = currentMember(c);
215 const postId = Number(c.req.param("id"));
216 const input = Flag.parse(await c.req.json());
217 const post = await c.env.DB.prepare("SELECT id, status, author FROM posts WHERE id = ?1")
218 .bind(postId)
219 .first<{ id: number; status: string; author: string }>();
220 if (!post) throw new HttpError(404, "not_found", "That post doesn't exist.");
221 assertCanFlag(member, post.author);
222 await enforceBurstRate(c, member);
223
224 const now = new Date().toISOString();
225 const results = await c.env.DB.batch([
226 c.env.DB.prepare(
227 "INSERT INTO flags (post_id, reporter, reason, note, created_at) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(post_id, reporter) DO NOTHING",
228 ).bind(postId, member.email, input.reason, input.note ?? "", now),
229 c.env.DB.prepare(
230 "UPDATE posts SET flag_count = (SELECT COUNT(*) FROM flags WHERE post_id = ?1) WHERE id = ?1",
231 ).bind(postId),
232 c.env.DB.prepare(
233 "UPDATE posts SET status = 'hidden' WHERE id = ?1 AND status = 'visible' AND flag_count >= ?2",
234 ).bind(postId, AUTO_HIDE_FLAGS),
235 c.env.DB.prepare(
236 `INSERT INTO mod_log (at, actor, action, target, detail)
237 SELECT ?1, 'system', 'auto_hide_post', ?2, 'flag threshold reached' WHERE changes() = 1`,
238 ).bind(now, String(postId)),
239 c.env.DB.prepare("SELECT flag_count AS flagCount, status FROM posts WHERE id = ?1").bind(postId),
240 ]);
241 const state = results[4]?.results[0] as { flagCount?: number; status?: string } | undefined;
242 return c.json({ ok: true, flagCount: state?.flagCount ?? 0, hidden: state?.status === "hidden" });
243 });
244
245 async function setLike(c: Context<AppEnv>, liked: boolean): Promise<Response> {
246 const member = currentMember(c);
247 assertCanInteract(member);
248 await enforceBurstRate(c, member);
249 const postId = Number(c.req.param("id"));
250 const post = await c.env.DB.prepare("SELECT id FROM posts WHERE id = ?1 AND status = 'visible'")
251 .bind(postId)
252 .first<{ id: number }>();
253 if (!post) throw new HttpError(404, "not_found", "That post doesn't exist.");
254
255 const mutation = liked
256 ? c.env.DB.prepare(
257 "INSERT INTO reactions (post_id, member, emoji, created_at) VALUES (?1, ?2, 'like', ?3) ON CONFLICT(post_id, member, emoji) DO NOTHING",
258 ).bind(postId, member.email, new Date().toISOString())
259 : c.env.DB.prepare("DELETE FROM reactions WHERE post_id = ?1 AND member = ?2 AND emoji = 'like'").bind(postId, member.email);
260 const [, updated] = await c.env.DB.batch([
261 mutation,
262 c.env.DB.prepare(
263 `UPDATE posts SET like_count = (
264 SELECT COUNT(*) FROM reactions WHERE post_id = ?1 AND emoji = 'like'
265 ) WHERE id = ?1 RETURNING like_count AS likeCount`,
266 ).bind(postId),
267 ]);
268 const state = updated?.results[0] as { likeCount?: number } | undefined;
269 return c.json({ ok: true, liked, likeCount: state?.likeCount ?? 0 });
270 }
271
272 app.post("/posts/:id/likes", (c) => setLike(c, true));
273 app.delete("/posts/:id/likes", (c) => setLike(c, false));
274
275 export default app;
276
276 lines TYPESCRIPT