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
at root/atb-54-add-lightdark-mode-toggle 154 lines 5.3 kB view raw
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}