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-56-theme-caching-layer 145 lines 4.6 kB view raw
1import { Hono } from "hono"; 2import { TID } from "@atproto/common-web"; 3import type { AppContext } from "../lib/app-context.js"; 4import type { Variables } from "../types.js"; 5import { requireAuth } from "../middleware/auth.js"; 6import { requirePermission } from "../middleware/permissions.js"; 7import { requireNotBanned } from "../middleware/require-not-banned.js"; 8import { handleRouteError, safeParseJsonBody } from "../lib/route-errors.js"; 9import { 10 validatePostText, 11 parseBigIntParam, 12 getPostsByIds, 13 validateReplyParent, 14 getTopicModStatus, 15} from "./helpers.js"; 16 17export function createPostsRoutes(ctx: AppContext) { 18 // Ban check runs before permission check so banned users receive "You are banned" 19 // rather than a generic "Permission denied" response. 20 return new Hono<{ Variables: Variables }>().post("/", requireAuth(ctx), requireNotBanned(ctx), requirePermission(ctx, "space.atbb.permission.createPosts"), async (c) => { 21 const user = c.get("user")!; 22 23 // Parse and validate request body 24 const { body, error: parseError } = await safeParseJsonBody(c); 25 if (parseError) return parseError; 26 27 const { text, rootPostId: rootIdStr, parentPostId: parentIdStr } = body; 28 29 // Validate text 30 const validation = validatePostText(text); 31 if (!validation.valid) { 32 return c.json({ error: validation.error }, 400); 33 } 34 35 // Parse IDs 36 const rootId = parseBigIntParam(rootIdStr); 37 const parentId = parseBigIntParam(parentIdStr); 38 39 if (rootId === null || parentId === null) { 40 return c.json( 41 { 42 error: "Invalid post ID format. IDs must be numeric strings.", 43 }, 44 400 45 ); 46 } 47 48 // Check if topic is locked before processing request 49 try { 50 const modStatus = await getTopicModStatus(ctx.db, rootId, ctx.logger); 51 if (modStatus.locked) { 52 return c.json({ error: "This topic is locked and not accepting new replies" }, 403); 53 } 54 } catch (error) { 55 return handleRouteError(c, error, "Unable to verify topic status", { 56 operation: "POST /api/posts - lock check", 57 logger: ctx.logger, 58 userId: user.did, 59 rootId: rootIdStr, 60 }); 61 } 62 63 // Look up root and parent posts 64 let postsMap: Awaited<ReturnType<typeof getPostsByIds>>; 65 try { 66 postsMap = await getPostsByIds(ctx.db, [rootId, parentId]); 67 } catch (error) { 68 return handleRouteError(c, error, "Failed to look up posts for reply", { 69 operation: "POST /api/posts - post lookup", 70 logger: ctx.logger, 71 userId: user.did, 72 rootId: rootIdStr, 73 parentId: parentIdStr, 74 }); 75 } 76 77 const root = postsMap.get(rootId); 78 const parent = postsMap.get(parentId); 79 80 if (!root) { 81 return c.json({ error: "Root post not found" }, 404); 82 } 83 84 if (!parent) { 85 return c.json({ error: "Parent post not found" }, 404); 86 } 87 88 // Validate parent belongs to same thread 89 const parentValidation = validateReplyParent(root, parent, rootId); 90 if (!parentValidation.valid) { 91 return c.json({ error: parentValidation.error }, 400); 92 } 93 94 // Validate root post has forum reference 95 if (!root.forumUri) { 96 return c.json({ error: "Root post has no forum reference" }, 400); 97 } 98 99 const rootUri = `at://${root.did}/space.atbb.post/${root.rkey}`; 100 const parentUri = `at://${parent.did}/space.atbb.post/${parent.rkey}`; 101 102 // Generate TID for rkey 103 const rkey = TID.nextStr(); 104 105 // Write to user's PDS 106 try { 107 const result = await user.agent.com.atproto.repo.putRecord({ 108 repo: user.did, 109 collection: "space.atbb.post", 110 rkey, 111 record: { 112 $type: "space.atbb.post", 113 text: validation.trimmed!, 114 forum: { 115 $type: "space.atbb.post#forumRef", 116 forum: { uri: root.forumUri, cid: root.cid }, 117 }, 118 reply: { 119 $type: "space.atbb.post#replyRef", 120 root: { uri: rootUri, cid: root.cid }, 121 parent: { uri: parentUri, cid: parent.cid }, 122 }, 123 createdAt: new Date().toISOString(), 124 }, 125 }); 126 127 return c.json( 128 { 129 uri: result.data.uri, 130 cid: result.data.cid, 131 rkey, 132 }, 133 201 134 ); 135 } catch (error) { 136 return handleRouteError(c, error, "Failed to create post", { 137 operation: "POST /api/posts", 138 logger: ctx.logger, 139 userId: user.did, 140 rootId: rootIdStr, 141 parentId: parentIdStr, 142 }); 143 } 144 }); 145}