WIP! A BB-style forum, on the ATmosphere!
We're still working... we'll be back soon when we have something to show off!
node
typescript
hono
htmx
atproto
1import { Hono } from "hono";
2import { isProgrammingError } from "../lib/errors.js";
3import { logger } from "../lib/logger.js";
4import type { WebAppEnv } from "../lib/theme-resolution.js";
5
6/**
7 * Single proxy endpoint for all moderation actions.
8 *
9 * Reads `action`, `id`, and `reason` from the form body and dispatches
10 * to the correct AppView mod endpoint. Returns HX-Refresh on success
11 * so HTMX reloads the current page to show updated state.
12 *
13 * Action dispatch table:
14 * lock → POST /api/mod/lock body: { topicId, reason }
15 * unlock → DELETE /api/mod/lock/:id body: { reason }
16 * hide → POST /api/mod/hide body: { postId, reason }
17 * unhide → DELETE /api/mod/hide/:id body: { reason }
18 * ban → POST /api/mod/ban body: { targetDid, reason }
19 * unban → DELETE /api/mod/ban/:id body: { reason }
20 */
21export function createModActionRoute(appviewUrl: string) {
22 return new Hono<WebAppEnv>().post("/mod/action", async (c) => {
23 let body: Record<string, string | File>;
24 try {
25 body = await c.req.parseBody();
26 } catch {
27 return c.html(`<p class="form-error">Invalid form submission.</p>`);
28 }
29
30 const action = typeof body.action === "string" ? body.action.trim() : "";
31 const id = typeof body.id === "string" ? body.id.trim() : "";
32 const reason = typeof body.reason === "string" ? body.reason.trim() : "";
33 const cookieHeader = c.req.header("cookie") ?? "";
34
35 // Validate action
36 const validActions = ["lock", "unlock", "hide", "unhide", "ban", "unban"];
37 if (!validActions.includes(action)) {
38 return c.html(`<p class="form-error">Unknown action.</p>`);
39 }
40
41 // Validate reason
42 if (!reason) {
43 return c.html(`<p class="form-error">Reason is required.</p>`);
44 }
45 if (reason.length > 3000) {
46 return c.html(`<p class="form-error">Reason must not exceed 3000 characters.</p>`);
47 }
48
49 // Validate id
50 if (!id) {
51 return c.html(`<p class="form-error">Target ID is required.</p>`);
52 }
53
54 // Build AppView request
55 let appviewEndpoint: string;
56 let method: "POST" | "DELETE";
57 let appviewBody: Record<string, string>;
58
59 switch (action) {
60 case "lock":
61 appviewEndpoint = `${appviewUrl}/api/mod/lock`;
62 method = "POST";
63 appviewBody = { topicId: id, reason };
64 break;
65 case "unlock":
66 appviewEndpoint = `${appviewUrl}/api/mod/lock/${id}`;
67 method = "DELETE";
68 appviewBody = { reason };
69 break;
70 case "hide":
71 appviewEndpoint = `${appviewUrl}/api/mod/hide`;
72 method = "POST";
73 appviewBody = { postId: id, reason };
74 break;
75 case "unhide":
76 appviewEndpoint = `${appviewUrl}/api/mod/hide/${id}`;
77 method = "DELETE";
78 appviewBody = { reason };
79 break;
80 case "ban":
81 appviewEndpoint = `${appviewUrl}/api/mod/ban`;
82 method = "POST";
83 appviewBody = { targetDid: id, reason };
84 break;
85 case "unban":
86 appviewEndpoint = `${appviewUrl}/api/mod/ban/${id}`;
87 method = "DELETE";
88 appviewBody = { reason };
89 break;
90 default:
91 return c.html(`<p class="form-error">Unknown action.</p>`);
92 }
93
94 // Forward to AppView
95 let appviewRes: Response;
96 try {
97 appviewRes = await fetch(appviewEndpoint, {
98 method,
99 headers: {
100 "Content-Type": "application/json",
101 Cookie: cookieHeader,
102 },
103 body: JSON.stringify(appviewBody),
104 });
105 } catch (error) {
106 if (isProgrammingError(error)) throw error;
107 logger.error("Failed to proxy mod action to AppView", {
108 operation: `${method} ${appviewEndpoint}`,
109 action,
110 error: error instanceof Error ? error.message : String(error),
111 });
112 return c.html(
113 `<p class="form-error">Forum temporarily unavailable. Please try again.</p>`
114 );
115 }
116
117 if (appviewRes.ok) {
118 return new Response(null, {
119 status: 200,
120 headers: { "HX-Refresh": "true" },
121 });
122 }
123
124 // Handle error responses
125 let errorMessage = "Something went wrong. Please try again.";
126
127 if (appviewRes.status === 401) {
128 logger.error("AppView returned 401 for mod action — session may have expired", {
129 operation: `${method} ${appviewEndpoint}`,
130 action,
131 });
132 errorMessage = "You must be logged in to perform this action.";
133 } else if (appviewRes.status === 403) {
134 logger.error("AppView returned 403 for mod action — permission mismatch", {
135 operation: `${method} ${appviewEndpoint}`,
136 action,
137 });
138 errorMessage = "You don't have permission for this action.";
139 } else if (appviewRes.status === 404) {
140 errorMessage = "Target not found.";
141 } else if (appviewRes.status === 409) {
142 // 409 = already active (e.g. locking an already-locked topic)
143 errorMessage = "This action is already active.";
144 } else if (appviewRes.status >= 500) {
145 logger.error("AppView returned server error for mod action", {
146 operation: `${method} ${appviewEndpoint}`,
147 action,
148 status: appviewRes.status,
149 });
150 }
151
152 return c.html(`<p class="form-error">${errorMessage}</p>`);
153 });
154}