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 atb-52-css-token-extraction 153 lines 5.3 kB view raw
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}