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