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