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 main 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}