返回 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 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
241 lines TYPESCRIPT