import { Hono } from "hono"; import { TID } from "@atproto/common-web"; import type { AppContext } from "../lib/app-context.js"; import type { Variables } from "../types.js"; import { requireAuth } from "../middleware/auth.js"; import { requirePermission } from "../middleware/permissions.js"; import { requireNotBanned } from "../middleware/require-not-banned.js"; import { handleRouteError, safeParseJsonBody } from "../lib/route-errors.js"; import { validatePostText, parseBigIntParam, getPostsByIds, validateReplyParent, getTopicModStatus, } from "./helpers.js"; export function createPostsRoutes(ctx: AppContext) { // Ban check runs before permission check so banned users receive "You are banned" // rather than a generic "Permission denied" response. return new Hono<{ Variables: Variables }>().post("/", requireAuth(ctx), requireNotBanned(ctx), requirePermission(ctx, "space.atbb.permission.createPosts"), async (c) => { const user = c.get("user")!; // Parse and validate request body const { body, error: parseError } = await safeParseJsonBody(c); if (parseError) return parseError; const { text, rootPostId: rootIdStr, parentPostId: parentIdStr } = body; // Validate text const validation = validatePostText(text); if (!validation.valid) { return c.json({ error: validation.error }, 400); } // Parse IDs const rootId = parseBigIntParam(rootIdStr); const parentId = parseBigIntParam(parentIdStr); if (rootId === null || parentId === null) { return c.json( { error: "Invalid post ID format. IDs must be numeric strings.", }, 400 ); } // Check if topic is locked before processing request try { const modStatus = await getTopicModStatus(ctx.db, rootId, ctx.logger); if (modStatus.locked) { return c.json({ error: "This topic is locked and not accepting new replies" }, 403); } } catch (error) { return handleRouteError(c, error, "Unable to verify topic status", { operation: "POST /api/posts - lock check", logger: ctx.logger, userId: user.did, rootId: rootIdStr, }); } // Look up root and parent posts let postsMap: Awaited>; try { postsMap = await getPostsByIds(ctx.db, [rootId, parentId]); } catch (error) { return handleRouteError(c, error, "Failed to look up posts for reply", { operation: "POST /api/posts - post lookup", logger: ctx.logger, userId: user.did, rootId: rootIdStr, parentId: parentIdStr, }); } const root = postsMap.get(rootId); const parent = postsMap.get(parentId); if (!root) { return c.json({ error: "Root post not found" }, 404); } if (!parent) { return c.json({ error: "Parent post not found" }, 404); } // Validate parent belongs to same thread const parentValidation = validateReplyParent(root, parent, rootId); if (!parentValidation.valid) { return c.json({ error: parentValidation.error }, 400); } // Validate root post has forum reference if (!root.forumUri) { return c.json({ error: "Root post has no forum reference" }, 400); } const rootUri = `at://${root.did}/space.atbb.post/${root.rkey}`; const parentUri = `at://${parent.did}/space.atbb.post/${parent.rkey}`; // Generate TID for rkey const rkey = TID.nextStr(); // Write to user's PDS try { const result = await user.agent.com.atproto.repo.putRecord({ repo: user.did, collection: "space.atbb.post", rkey, record: { $type: "space.atbb.post", text: validation.trimmed!, forum: { $type: "space.atbb.post#forumRef", forum: { uri: root.forumUri, cid: root.cid }, }, reply: { $type: "space.atbb.post#replyRef", root: { uri: rootUri, cid: root.cid }, parent: { uri: parentUri, cid: parent.cid }, }, createdAt: new Date().toISOString(), }, }); return c.json( { uri: result.data.uri, cid: result.data.cid, rkey, }, 201 ); } catch (error) { return handleRouteError(c, error, "Failed to create post", { operation: "POST /api/posts", logger: ctx.logger, userId: user.did, rootId: rootIdStr, parentId: parentIdStr, }); } }); }