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
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 322 lines 12 kB view raw
1import { Hono } from "hono"; 2import type { AppContext } from "../lib/app-context.js"; 3import type { Variables } from "../types.js"; 4import { posts, users } from "@atbb/db"; 5import { eq, and, asc, count } from "drizzle-orm"; 6import { TID } from "@atproto/common-web"; 7import { requireAuth } from "../middleware/auth.js"; 8import { requirePermission } from "../middleware/permissions.js"; 9import { requireNotBanned } from "../middleware/require-not-banned.js"; 10import { parseAtUri } from "../lib/at-uri.js"; 11import { handleRouteError, safeParseJsonBody } from "../lib/route-errors.js"; 12import { isProgrammingError } from "../lib/errors.js"; 13import { 14 parseBigIntParam, 15 serializePost, 16 validatePostText, 17 validateTopicTitle, 18 getForumByUri, 19 getBoardByUri, 20 getActiveBans, 21 getHiddenPosts, 22 getTopicModStatus, 23} from "./helpers.js"; 24 25/** 26 * Factory function that creates topic routes with access to app context. 27 */ 28export function createTopicsRoutes(ctx: AppContext) { 29 return new Hono<{ Variables: Variables }>() 30 .get("/:id", async (c) => { 31 const { id } = c.req.param(); 32 33 const topicId = parseBigIntParam(id); 34 if (topicId === null) { 35 return c.json({ error: "Invalid topic ID format" }, 400); 36 } 37 38 // Parse pagination params (same pattern as boards/:id/topics, higher cap for bookmark support) 39 const offsetRaw = parseInt(c.req.query("offset") ?? "0", 10); 40 const limitRaw = parseInt(c.req.query("limit") ?? "25", 10); 41 const offset = isNaN(offsetRaw) || offsetRaw < 0 ? 0 : offsetRaw; 42 const limit = isNaN(limitRaw) || limitRaw < 1 ? 25 : Math.min(limitRaw, 250); 43 44 try { 45 // Query the thread starter post 46 const [topicResult] = await ctx.db 47 .select({ 48 post: posts, 49 author: users, 50 }) 51 .from(posts) 52 .leftJoin(users, eq(posts.did, users.did)) 53 .where(and(eq(posts.id, topicId), eq(posts.bannedByMod, false))) 54 .limit(1); 55 56 if (!topicResult) { 57 return c.json({ error: "Topic not found" }, 404); 58 } 59 60 // Query reply count + paginated replies in parallel — separate inner try so a 61 // failure here logs "replies" context rather than "topic" (CLAUDE.md try-block granularity) 62 const replyFilter = and(eq(posts.rootPostId, topicId), eq(posts.bannedByMod, false)); 63 let countResult: { count: number }[]; 64 let replyResults: { post: typeof posts.$inferSelect; author: typeof users.$inferSelect | null }[]; 65 try { 66 [countResult, replyResults] = await Promise.all([ 67 ctx.db 68 .select({ count: count() }) 69 .from(posts) 70 .where(replyFilter), 71 ctx.db 72 .select({ 73 post: posts, 74 author: users, 75 }) 76 .from(posts) 77 .leftJoin(users, eq(posts.did, users.did)) 78 .where(replyFilter) 79 .orderBy(asc(posts.createdAt)) 80 .limit(limit) 81 .offset(offset), 82 ]); 83 } catch (error) { 84 return handleRouteError(c, error, "Failed to retrieve replies for topic", { 85 operation: "GET /api/topics/:id - reply query", 86 logger: ctx.logger, 87 topicId: id, 88 }); 89 } 90 91 // total counts replies passing the SQL bannedByMod filter only. 92 // In-memory filters (getActiveBans, getHiddenPosts) may reduce the 93 // visible reply count further — clients should treat total as approximate. 94 const total = Number(countResult[0]?.count ?? 0); 95 96 // Get banned users - fail open (show content if ban lookup fails) 97 const allUserDids = [ 98 topicResult.post.did, 99 ...replyResults.map((r) => r.post.did), 100 ]; 101 let bannedUsers = new Set<string>(); 102 try { 103 bannedUsers = await getActiveBans(ctx.db, allUserDids, ctx.logger); 104 } catch (error) { 105 if (isProgrammingError(error)) throw error; 106 ctx.logger.error("Failed to query bans for topic view - showing all replies", { 107 operation: "GET /api/topics/:id - ban check", 108 topicId: id, 109 error: error instanceof Error ? error.message : String(error), 110 }); 111 } 112 113 // Get hidden posts - fail open (show content if hide lookup fails) 114 const allPostIds = replyResults.map((r) => r.post.id); 115 let hiddenPosts = new Set<bigint>(); 116 try { 117 hiddenPosts = await getHiddenPosts(ctx.db, allPostIds, ctx.logger); 118 } catch (error) { 119 if (isProgrammingError(error)) throw error; 120 ctx.logger.error("Failed to query hidden posts for topic view - showing all replies", { 121 operation: "GET /api/topics/:id - hidden posts", 122 topicId: id, 123 error: error instanceof Error ? error.message : String(error), 124 }); 125 } 126 127 // Filter replies - exclude banned users and hidden posts 128 const filteredReplies = replyResults.filter( 129 ({ post }) => !bannedUsers.has(post.did) && !hiddenPosts.has(post.id) 130 ); 131 132 // Get lock/pin status - fail open (default unlocked/unpinned if lookup fails) 133 let modStatus = { locked: false, pinned: false }; 134 try { 135 modStatus = await getTopicModStatus(ctx.db, topicId, ctx.logger); 136 } catch (error) { 137 if (isProgrammingError(error)) throw error; 138 ctx.logger.error("Failed to query topic mod status - showing as unlocked", { 139 operation: "GET /api/topics/:id - mod status", 140 topicId: id, 141 error: error instanceof Error ? error.message : String(error), 142 }); 143 } 144 145 const { post: topicPost, author: topicAuthor } = topicResult; 146 147 return c.json({ 148 topicId: id, 149 locked: modStatus.locked, 150 pinned: modStatus.pinned, 151 post: serializePost(topicPost, topicAuthor), 152 replies: filteredReplies.map(({ post, author }) => 153 serializePost(post, author) 154 ), 155 total, 156 offset, 157 limit, 158 }); 159 } catch (error) { 160 return handleRouteError(c, error, "Failed to retrieve topic", { 161 operation: "GET /api/topics/:id - topic query", 162 logger: ctx.logger, 163 topicId: id, 164 }); 165 } 166 }) 167 // Ban check runs before permission check so banned users receive "You are banned" 168 // rather than a generic "Permission denied" response. 169 .post("/", requireAuth(ctx), requireNotBanned(ctx), requirePermission(ctx, "space.atbb.permission.createTopics"), async (c) => { 170 // user is guaranteed to exist after requireAuth, requireNotBanned, and requirePermission middleware 171 const user = c.get("user")!; 172 173 // Parse and validate request body 174 const { body, error: parseError } = await safeParseJsonBody(c); 175 if (parseError) return parseError; 176 177 const { title, text, boardUri } = body; 178 179 // Validate title 180 // Note: the lexicon marks `title` as optional (required only for topics, not replies). 181 // AT Protocol record schemas cannot express per-use-case requirements. 182 // AppView enforces it as mandatory here for all topic creation requests. 183 const titleValidation = validateTopicTitle(title); 184 if (!titleValidation.valid) { 185 return c.json({ error: titleValidation.error }, 400); 186 } 187 188 // Validate text 189 const validation = validatePostText(text); 190 if (!validation.valid) { 191 return c.json({ error: validation.error }, 400); 192 } 193 194 // Validate boardUri is required 195 if (typeof boardUri !== "string" || !boardUri.trim()) { 196 return c.json({ error: "boardUri is required" }, 400); 197 } 198 199 // Validate boardUri format 200 const parsedBoardUri = parseAtUri(boardUri); 201 if (!parsedBoardUri) { 202 return c.json({ error: "Invalid boardUri format" }, 400); 203 } 204 205 // Validate collection type 206 if (!parsedBoardUri.collection.startsWith("space.atbb.forum.board")) { 207 return c.json({ error: "boardUri must reference a board" }, 400); 208 } 209 210 // Validate ownership 211 if (parsedBoardUri.did !== ctx.config.forumDid) { 212 return c.json({ error: "boardUri must belong to this forum" }, 400); 213 } 214 215 // Always use the configured singleton forum 216 const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 217 218 // Phase 1a: Look up forum — separated from the PDS write so a database 219 // error cannot be misclassified as a PDS failure. 220 let forum: { did: string; rkey: string; cid: string } | null = null; 221 222 try { 223 forum = await getForumByUri(ctx.db, forumUri); 224 if (!forum) { 225 return c.json({ error: "Forum not found" }, 404); 226 } 227 } catch (error) { 228 if (isProgrammingError(error)) { 229 ctx.logger.error("CRITICAL: Programming error in POST /api/topics (forum lookup)", { 230 operation: "POST /api/topics", 231 userId: user.did, 232 error: error instanceof Error ? error.message : String(error), 233 stack: error instanceof Error ? error.stack : undefined, 234 }); 235 throw error; 236 } 237 238 ctx.logger.error("Failed to look up forum record before writing topic to PDS", { 239 operation: "POST /api/topics", 240 userId: user.did, 241 error: error instanceof Error ? error.message : String(error), 242 }); 243 244 return c.json( 245 { error: "Database temporarily unavailable. Please try again later." }, 246 503 247 ); 248 } 249 250 // Phase 1b: Look up board 251 let board: { cid: string } | null = null; 252 253 try { 254 board = await getBoardByUri(ctx.db, boardUri); 255 if (!board) { 256 return c.json({ error: "Board not found" }, 404); 257 } 258 } catch (error) { 259 if (isProgrammingError(error)) { 260 ctx.logger.error("CRITICAL: Programming error in POST /api/topics (board lookup)", { 261 operation: "POST /api/topics", 262 userId: user.did, 263 error: error instanceof Error ? error.message : String(error), 264 stack: error instanceof Error ? error.stack : undefined, 265 }); 266 throw error; 267 } 268 269 ctx.logger.error("Failed to look up board record before writing topic to PDS", { 270 operation: "POST /api/topics", 271 userId: user.did, 272 error: error instanceof Error ? error.message : String(error), 273 }); 274 275 return c.json( 276 { error: "Database temporarily unavailable. Please try again later." }, 277 503 278 ); 279 } 280 281 // Generate TID for rkey 282 const rkey = TID.nextStr(); 283 284 // Phase 2: PDS write — forum and board are non-null (null case returns 404 above) 285 try { 286 const result = await user.agent.com.atproto.repo.putRecord({ 287 repo: user.did, 288 collection: "space.atbb.post", 289 rkey, 290 record: { 291 $type: "space.atbb.post", 292 title: titleValidation.trimmed!, 293 text: validation.trimmed!, 294 forum: { 295 $type: "space.atbb.post#forumRef", 296 forum: { uri: forumUri, cid: forum!.cid }, 297 }, 298 board: { 299 $type: "space.atbb.post#boardRef", 300 board: { uri: boardUri, cid: board!.cid }, 301 }, 302 createdAt: new Date().toISOString(), 303 }, 304 }); 305 306 return c.json( 307 { 308 uri: result.data.uri, 309 cid: result.data.cid, 310 rkey, 311 }, 312 201 313 ); 314 } catch (error) { 315 return handleRouteError(c, error, "Failed to create topic", { 316 operation: "POST /api/topics", 317 logger: ctx.logger, 318 userId: user.did, 319 }); 320 } 321 }); 322}