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 atb-52-css-token-extraction 821 lines 24 kB view raw
1import { Hono } from "hono"; 2import { eq, desc } from "drizzle-orm"; 3import { modActions, memberships, posts } from "@atbb/db"; 4import { TID } from "@atproto/common-web"; 5import { requireAuth } from "../middleware/auth.js"; 6import { requirePermission } from "../middleware/permissions.js"; 7import { isProgrammingError } from "../lib/errors.js"; 8import { 9 handleRouteError, 10 safeParseJsonBody, 11 getForumAgentOrError, 12} from "../lib/route-errors.js"; 13import { parseBigIntParam } from "./helpers.js"; 14import type { AppContext } from "../lib/app-context.js"; 15import type { Variables } from "../types.js"; 16 17/** 18 * Subject of a moderation action - either a user (DID) or a post (URI). 19 */ 20export type ModSubject = { did: string } | { postUri: string }; 21 22/** 23 * Validate reason field (required, 1-3000 chars). 24 * @returns null if valid, error message string if invalid 25 */ 26export function validateReason(reason: unknown): string | null { 27 if (typeof reason !== "string") { 28 return "Reason is required and must be a string"; 29 } 30 31 const trimmed = reason.trim(); 32 if (trimmed.length === 0) { 33 return "Reason is required and must not be empty"; 34 } 35 36 if (trimmed.length > 3000) { 37 return "Reason must not exceed 3000 characters"; 38 } 39 40 return null; 41} 42 43/** 44 * Check if a specific moderation action is currently active for a subject. 45 * Queries the most recent modAction record for the subject. 46 * 47 * @returns true if action is active, false if reversed/inactive, null if no actions exist 48 */ 49export async function checkActiveAction( 50 ctx: AppContext, 51 subject: ModSubject, 52 actionType: string 53): Promise<boolean | null> { 54 try { 55 // Build WHERE clause based on subject type 56 const whereClause = 57 "did" in subject 58 ? eq(modActions.subjectDid, subject.did) 59 : eq(modActions.subjectPostUri, subject.postUri); 60 61 // Query most recent action for this subject 62 const [mostRecent] = await ctx.db 63 .select() 64 .from(modActions) 65 .where(whereClause) 66 .orderBy(desc(modActions.createdAt)) 67 .limit(1); 68 69 // No actions exist for this subject 70 if (!mostRecent) { 71 return null; 72 } 73 74 // Action is active if most recent action matches the requested type 75 return mostRecent.action === actionType; 76 } catch (error) { 77 // Re-throw programming errors (code bugs) - don't hide them 78 if (isProgrammingError(error)) { 79 ctx.logger.error("CRITICAL: Programming error in checkActiveAction", { 80 operation: "checkActiveAction", 81 subject: JSON.stringify(subject), 82 actionType, 83 error: error instanceof Error ? error.message : String(error), 84 stack: error instanceof Error ? error.stack : undefined, 85 }); 86 throw error; 87 } 88 89 // Fail safe: return null on runtime errors (don't expose DB errors to callers) 90 ctx.logger.error("Failed to check active moderation action", { 91 operation: "checkActiveAction", 92 subject: JSON.stringify(subject), 93 actionType, 94 error: error instanceof Error ? error.message : String(error), 95 }); 96 return null; 97 } 98} 99 100export function createModRoutes(ctx: AppContext) { 101 const app = new Hono<{ Variables: Variables }>(); 102 103 // POST /api/mod/ban - Ban a user 104 app.post( 105 "/ban", 106 requireAuth(ctx), 107 requirePermission(ctx, "space.atbb.permission.banUsers"), 108 async (c) => { 109 // Parse request body 110 const { body, error: parseError } = await safeParseJsonBody(c); 111 if (parseError) return parseError; 112 113 const { targetDid, reason } = body; 114 115 // Validate targetDid 116 if (typeof targetDid !== "string" || !targetDid.startsWith("did:")) { 117 return c.json({ error: "Invalid DID format" }, 400); 118 } 119 120 // Validate reason 121 const reasonError = validateReason(reason); 122 if (reasonError) { 123 return c.json({ error: reasonError }, 400); 124 } 125 126 // Check if target user has membership (404 if not found) 127 try { 128 const [membership] = await ctx.db 129 .select() 130 .from(memberships) 131 .where(eq(memberships.did, targetDid)) 132 .limit(1); 133 134 if (!membership) { 135 return c.json({ error: "Target user not found" }, 404); 136 } 137 } catch (error) { 138 return handleRouteError(c, error, "Failed to check user membership", { 139 operation: "POST /api/mod/ban", 140 logger: ctx.logger, 141 targetDid, 142 }); 143 } 144 145 // Check if user is already banned 146 const isAlreadyBanned = await checkActiveAction( 147 ctx, 148 { did: targetDid }, 149 "space.atbb.modAction.ban" 150 ); 151 152 if (isAlreadyBanned === true) { 153 return c.json({ 154 success: true, 155 action: "space.atbb.modAction.ban", 156 targetDid, 157 uri: null, 158 cid: null, 159 alreadyActive: true, 160 }); 161 } 162 163 // Get ForumAgent 164 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/mod/ban"); 165 if (agentError) return agentError; 166 167 // Write modAction record to Forum DID's PDS 168 const user = c.get("user")!; 169 const rkey = TID.nextStr(); 170 171 try { 172 const result = await agent.com.atproto.repo.putRecord({ 173 repo: ctx.config.forumDid, 174 collection: "space.atbb.modAction", 175 rkey, 176 record: { 177 $type: "space.atbb.modAction", 178 action: "space.atbb.modAction.ban", 179 subject: { did: targetDid }, 180 reason: reason.trim(), 181 createdBy: user.did, 182 createdAt: new Date().toISOString(), 183 }, 184 }); 185 186 return c.json({ 187 success: true, 188 action: "space.atbb.modAction.ban", 189 targetDid, 190 uri: result.data.uri, 191 cid: result.data.cid, 192 alreadyActive: false, 193 }); 194 } catch (error) { 195 return handleRouteError(c, error, "Failed to record moderation action", { 196 operation: "POST /api/mod/ban", 197 logger: ctx.logger, 198 moderatorDid: user.did, 199 targetDid, 200 forumDid: ctx.config.forumDid, 201 action: "space.atbb.modAction.ban", 202 }); 203 } 204 } 205 ); 206 207 // DELETE /api/mod/ban/:did - Unban a user 208 app.delete( 209 "/ban/:did", 210 requireAuth(ctx), 211 requirePermission(ctx, "space.atbb.permission.banUsers"), 212 async (c) => { 213 // Get DID from route param 214 const targetDid = c.req.param("did"); 215 216 // Validate targetDid format 217 if (!targetDid || !targetDid.startsWith("did:")) { 218 return c.json({ error: "Invalid DID format" }, 400); 219 } 220 221 // Parse request body 222 const { body, error: parseError } = await safeParseJsonBody(c); 223 if (parseError) return parseError; 224 225 const { reason } = body; 226 227 // Validate reason 228 const reasonError = validateReason(reason); 229 if (reasonError) { 230 return c.json({ error: reasonError }, 400); 231 } 232 233 // Check if target user has membership (404 if not found) 234 try { 235 const [membership] = await ctx.db 236 .select() 237 .from(memberships) 238 .where(eq(memberships.did, targetDid)) 239 .limit(1); 240 241 if (!membership) { 242 return c.json({ error: "Target user not found" }, 404); 243 } 244 } catch (error) { 245 return handleRouteError(c, error, "Failed to check user membership", { 246 operation: "DELETE /api/mod/ban/:did", 247 logger: ctx.logger, 248 targetDid, 249 }); 250 } 251 252 // Check if user is already unbanned (not banned) 253 const isBanned = await checkActiveAction( 254 ctx, 255 { did: targetDid }, 256 "space.atbb.modAction.ban" 257 ); 258 259 // If user is not banned (false) or no actions exist (null), they're already unbanned 260 if (isBanned === false || isBanned === null) { 261 return c.json({ 262 success: true, 263 action: "space.atbb.modAction.unban", 264 targetDid, 265 uri: null, 266 cid: null, 267 alreadyActive: true, 268 }); 269 } 270 271 // Get ForumAgent 272 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/mod/ban/:did"); 273 if (agentError) return agentError; 274 275 // Write unban modAction record to Forum DID's PDS 276 const user = c.get("user")!; 277 const rkey = TID.nextStr(); 278 279 try { 280 const result = await agent.com.atproto.repo.putRecord({ 281 repo: ctx.config.forumDid, 282 collection: "space.atbb.modAction", 283 rkey, 284 record: { 285 $type: "space.atbb.modAction", 286 action: "space.atbb.modAction.unban", 287 subject: { did: targetDid }, 288 reason: reason.trim(), 289 createdBy: user.did, 290 createdAt: new Date().toISOString(), 291 }, 292 }); 293 294 return c.json({ 295 success: true, 296 action: "space.atbb.modAction.unban", 297 targetDid, 298 uri: result.data.uri, 299 cid: result.data.cid, 300 alreadyActive: false, 301 }); 302 } catch (error) { 303 return handleRouteError(c, error, "Failed to record moderation action", { 304 operation: "DELETE /api/mod/ban/:did", 305 logger: ctx.logger, 306 moderatorDid: user.did, 307 targetDid, 308 forumDid: ctx.config.forumDid, 309 action: "space.atbb.modAction.unban", 310 }); 311 } 312 } 313 ); 314 315 // POST /api/mod/lock - Lock a topic (prevent new replies) 316 app.post( 317 "/lock", 318 requireAuth(ctx), 319 requirePermission(ctx, "space.atbb.permission.lockTopics"), 320 async (c) => { 321 // Parse request body 322 const { body, error: parseError } = await safeParseJsonBody(c); 323 if (parseError) return parseError; 324 325 const { topicId, reason } = body; 326 327 // Validate topicId 328 if (typeof topicId !== "string") { 329 return c.json({ error: "Topic ID is required and must be a string" }, 400); 330 } 331 332 const topicIdBigInt = parseBigIntParam(topicId); 333 if (topicIdBigInt === null) { 334 return c.json({ error: "Invalid topic ID format" }, 400); 335 } 336 337 // Validate reason 338 const reasonError = validateReason(reason); 339 if (reasonError) { 340 return c.json({ error: reasonError }, 400); 341 } 342 343 // Get topic from posts table 344 let topic; 345 try { 346 const [result] = await ctx.db 347 .select() 348 .from(posts) 349 .where(eq(posts.id, topicIdBigInt)) 350 .limit(1); 351 352 if (!result) { 353 return c.json({ error: "Topic not found" }, 404); 354 } 355 356 topic = result; 357 } catch (error) { 358 return handleRouteError(c, error, "Failed to check topic", { 359 operation: "POST /api/mod/lock", 360 logger: ctx.logger, 361 topicId, 362 }); 363 } 364 365 // Validate it's a root post (topic, not reply) 366 if (topic.rootPostId !== null) { 367 return c.json({ error: "Can only lock topic posts, not replies" }, 400); 368 } 369 370 // Build post URI 371 const postUri = `at://${topic.did}/space.atbb.post/${topic.rkey}`; 372 373 // Check if already locked 374 const isAlreadyLocked = await checkActiveAction( 375 ctx, 376 { postUri }, 377 "space.atbb.modAction.lock" 378 ); 379 380 if (isAlreadyLocked === true) { 381 return c.json({ 382 success: true, 383 action: "space.atbb.modAction.lock", 384 topicId: topicId, 385 uri: null, 386 cid: null, 387 alreadyActive: true, 388 }); 389 } 390 391 // Get ForumAgent 392 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/mod/lock"); 393 if (agentError) return agentError; 394 395 // Write modAction record to Forum DID's PDS 396 const user = c.get("user")!; 397 const rkey = TID.nextStr(); 398 399 try { 400 const result = await agent.com.atproto.repo.putRecord({ 401 repo: ctx.config.forumDid, 402 collection: "space.atbb.modAction", 403 rkey, 404 record: { 405 $type: "space.atbb.modAction", 406 action: "space.atbb.modAction.lock", 407 subject: { 408 post: { 409 uri: postUri, 410 cid: topic.cid, 411 }, 412 }, 413 reason: reason.trim(), 414 createdBy: user.did, 415 createdAt: new Date().toISOString(), 416 }, 417 }); 418 419 return c.json({ 420 success: true, 421 action: "space.atbb.modAction.lock", 422 topicId: topicId, 423 uri: result.data.uri, 424 cid: result.data.cid, 425 alreadyActive: false, 426 }); 427 } catch (error) { 428 return handleRouteError(c, error, "Failed to record moderation action", { 429 operation: "POST /api/mod/lock", 430 logger: ctx.logger, 431 moderatorDid: user.did, 432 topicId, 433 postUri, 434 forumDid: ctx.config.forumDid, 435 action: "space.atbb.modAction.lock", 436 }); 437 } 438 } 439 ); 440 441 // DELETE /api/mod/lock/:topicId - Unlock a topic (allow new replies) 442 app.delete( 443 "/lock/:topicId", 444 requireAuth(ctx), 445 requirePermission(ctx, "space.atbb.permission.lockTopics"), 446 async (c) => { 447 // Get topicId from route param 448 const topicIdParam = c.req.param("topicId"); 449 450 // Validate topicId format 451 const topicIdBigInt = parseBigIntParam(topicIdParam); 452 if (topicIdBigInt === null) { 453 return c.json({ error: "Invalid topic ID format" }, 400); 454 } 455 456 // Parse request body 457 const { body, error: parseError } = await safeParseJsonBody(c); 458 if (parseError) return parseError; 459 460 const { reason } = body; 461 462 // Validate reason 463 const reasonError = validateReason(reason); 464 if (reasonError) { 465 return c.json({ error: reasonError }, 400); 466 } 467 468 // Get topic from posts table 469 let topic; 470 try { 471 const [result] = await ctx.db 472 .select() 473 .from(posts) 474 .where(eq(posts.id, topicIdBigInt)) 475 .limit(1); 476 477 if (!result) { 478 return c.json({ error: "Topic not found" }, 404); 479 } 480 481 topic = result; 482 } catch (error) { 483 return handleRouteError(c, error, "Failed to check topic", { 484 operation: "DELETE /api/mod/lock/:topicId", 485 logger: ctx.logger, 486 topicId: topicIdParam, 487 }); 488 } 489 490 // Validate it's a root post (topic, not reply) 491 if (topic.rootPostId !== null) { 492 return c.json({ error: "Can only unlock topic posts, not replies" }, 400); 493 } 494 495 // Build post URI 496 const postUri = `at://${topic.did}/space.atbb.post/${topic.rkey}`; 497 498 // Check if topic is already unlocked (not locked) 499 const isLocked = await checkActiveAction( 500 ctx, 501 { postUri }, 502 "space.atbb.modAction.lock" 503 ); 504 505 // If topic is not locked (false) or no actions exist (null), it's already unlocked 506 if (isLocked === false || isLocked === null) { 507 return c.json({ 508 success: true, 509 action: "space.atbb.modAction.unlock", 510 topicId: topicIdParam, 511 uri: null, 512 cid: null, 513 alreadyActive: true, 514 }); 515 } 516 517 // Get ForumAgent 518 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/mod/lock/:topicId"); 519 if (agentError) return agentError; 520 521 // Write unlock modAction record to Forum DID's PDS 522 const user = c.get("user")!; 523 const rkey = TID.nextStr(); 524 525 try { 526 const result = await agent.com.atproto.repo.putRecord({ 527 repo: ctx.config.forumDid, 528 collection: "space.atbb.modAction", 529 rkey, 530 record: { 531 $type: "space.atbb.modAction", 532 action: "space.atbb.modAction.unlock", 533 subject: { 534 post: { 535 uri: postUri, 536 cid: topic.cid, 537 }, 538 }, 539 reason: reason.trim(), 540 createdBy: user.did, 541 createdAt: new Date().toISOString(), 542 }, 543 }); 544 545 return c.json({ 546 success: true, 547 action: "space.atbb.modAction.unlock", 548 topicId: topicIdParam, 549 uri: result.data.uri, 550 cid: result.data.cid, 551 alreadyActive: false, 552 }); 553 } catch (error) { 554 return handleRouteError(c, error, "Failed to record moderation action", { 555 operation: "DELETE /api/mod/lock/:topicId", 556 logger: ctx.logger, 557 moderatorDid: user.did, 558 topicId: topicIdParam, 559 postUri, 560 forumDid: ctx.config.forumDid, 561 action: "space.atbb.modAction.unlock", 562 }); 563 } 564 } 565 ); 566 567 /** 568 * POST /api/mod/hide 569 * Hide a post from the forum (soft-delete). 570 * Note: Uses "space.atbb.modAction.delete" action type. The read-path logic 571 * determines the current state by looking at the most recent action chronologically. 572 * Unhide uses a separate "space.atbb.modAction.undelete" action type. 573 */ 574 app.post( 575 "/hide", 576 requireAuth(ctx), 577 requirePermission(ctx, "space.atbb.permission.moderatePosts"), 578 async (c) => { 579 const user = c.get("user")!; 580 581 // Parse request body 582 const { body, error: parseError } = await safeParseJsonBody(c); 583 if (parseError) return parseError; 584 585 const { postId, reason } = body; 586 587 // Validate postId 588 if (typeof postId !== "string") { 589 return c.json({ error: "postId is required and must be a string" }, 400); 590 } 591 592 const postIdBigInt = parseBigIntParam(postId); 593 if (postIdBigInt === null) { 594 return c.json({ error: "Invalid post ID" }, 400); 595 } 596 597 // Validate reason 598 const reasonError = validateReason(reason); 599 if (reasonError) { 600 return c.json({ error: reasonError }, 400); 601 } 602 603 // Get post (can be topic or reply) 604 let post; 605 try { 606 const [result] = await ctx.db 607 .select() 608 .from(posts) 609 .where(eq(posts.id, postIdBigInt)) 610 .limit(1); 611 612 if (!result) { 613 return c.json({ error: "Post not found" }, 404); 614 } 615 616 post = result; 617 } catch (error) { 618 return handleRouteError(c, error, "Failed to retrieve post", { 619 operation: "POST /api/mod/hide", 620 logger: ctx.logger, 621 postId, 622 }); 623 } 624 625 const postUri = `at://${post.did}/space.atbb.post/${post.rkey}`; 626 627 // Check if post is already hidden 628 const isHidden = await checkActiveAction( 629 ctx, 630 { postUri }, 631 "space.atbb.modAction.delete" 632 ); 633 634 if (isHidden) { 635 return c.json({ 636 success: true, 637 action: "space.atbb.modAction.delete", 638 postId: postId, 639 postUri: postUri, 640 uri: null, 641 cid: null, 642 alreadyActive: true, 643 }, 200); 644 } 645 646 // Get ForumAgent 647 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/mod/hide"); 648 if (agentError) return agentError; 649 650 // Write hide modAction record (action type is "delete" per lexicon) 651 try { 652 const rkey = TID.nextStr(); 653 const result = await agent.com.atproto.repo.putRecord({ 654 repo: ctx.config.forumDid, 655 collection: "space.atbb.modAction", 656 rkey, 657 record: { 658 $type: "space.atbb.modAction", 659 action: "space.atbb.modAction.delete", 660 subject: { 661 post: { 662 uri: postUri, 663 cid: post.cid, 664 }, 665 }, 666 reason: reason.trim(), 667 createdBy: user.did, 668 createdAt: new Date().toISOString(), 669 }, 670 }); 671 672 return c.json({ 673 success: true, 674 action: "space.atbb.modAction.delete", 675 postId: postId, 676 postUri: postUri, 677 uri: result.data.uri, 678 cid: result.data.cid, 679 alreadyActive: false, 680 }, 200); 681 } catch (error) { 682 return handleRouteError(c, error, "Failed to record moderation action", { 683 operation: "POST /api/mod/hide", 684 logger: ctx.logger, 685 moderatorDid: user.did, 686 postId, 687 postUri, 688 forumDid: ctx.config.forumDid, 689 action: "space.atbb.modAction.delete", 690 }); 691 } 692 } 693 ); 694 695 /** 696 * DELETE /api/mod/hide/:postId 697 * Unhide a post (reversal action). 698 * Note: Uses "space.atbb.modAction.undelete" action type to reverse hide. 699 * The read-path logic determines the current state by looking at the most 700 * recent action chronologically (alternating hide/unhide creates a toggle effect). 701 */ 702 app.delete( 703 "/hide/:postId", 704 requireAuth(ctx), 705 requirePermission(ctx, "space.atbb.permission.moderatePosts"), 706 async (c) => { 707 const user = c.get("user")!; 708 const postIdParam = c.req.param("postId"); 709 710 const postIdBigInt = parseBigIntParam(postIdParam); 711 if (postIdBigInt === null) { 712 return c.json({ error: "Invalid post ID" }, 400); 713 } 714 715 // Parse request body 716 const { body, error: parseError } = await safeParseJsonBody(c); 717 if (parseError) return parseError; 718 719 const { reason } = body; 720 721 // Validate reason 722 const reasonError = validateReason(reason); 723 if (reasonError) { 724 return c.json({ error: reasonError }, 400); 725 } 726 727 // Get post 728 let post; 729 try { 730 const [result] = await ctx.db 731 .select() 732 .from(posts) 733 .where(eq(posts.id, postIdBigInt)) 734 .limit(1); 735 736 if (!result) { 737 return c.json({ error: "Post not found" }, 404); 738 } 739 740 post = result; 741 } catch (error) { 742 return handleRouteError(c, error, "Failed to retrieve post", { 743 operation: "DELETE /api/mod/hide/:postId", 744 logger: ctx.logger, 745 postId: postIdParam, 746 }); 747 } 748 749 const postUri = `at://${post.did}/space.atbb.post/${post.rkey}`; 750 751 // Check if post is currently hidden 752 const isHidden = await checkActiveAction( 753 ctx, 754 { postUri }, 755 "space.atbb.modAction.delete" 756 ); 757 758 if (isHidden === false || isHidden === null) { 759 return c.json({ 760 success: true, 761 action: "space.atbb.modAction.undelete", 762 postId: postIdParam, 763 postUri: postUri, 764 uri: null, 765 cid: null, 766 alreadyActive: true, 767 }, 200); 768 } 769 770 // Get ForumAgent 771 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/mod/hide/:postId"); 772 if (agentError) return agentError; 773 774 // Write unhide modAction record 775 // Uses "undelete" action type for reversal (hide→unhide toggle) 776 try { 777 const rkey = TID.nextStr(); 778 const result = await agent.com.atproto.repo.putRecord({ 779 repo: ctx.config.forumDid, 780 collection: "space.atbb.modAction", 781 rkey, 782 record: { 783 $type: "space.atbb.modAction", 784 action: "space.atbb.modAction.undelete", 785 subject: { 786 post: { 787 uri: postUri, 788 cid: post.cid, 789 }, 790 }, 791 reason: reason.trim(), 792 createdBy: user.did, 793 createdAt: new Date().toISOString(), 794 }, 795 }); 796 797 return c.json({ 798 success: true, 799 action: "space.atbb.modAction.undelete", 800 postId: postIdParam, 801 postUri: postUri, 802 uri: result.data.uri, 803 cid: result.data.cid, 804 alreadyActive: false, 805 }, 200); 806 } catch (error) { 807 return handleRouteError(c, error, "Failed to record moderation action", { 808 operation: "DELETE /api/mod/hide/:postId", 809 logger: ctx.logger, 810 moderatorDid: user.did, 811 postId: postIdParam, 812 postUri, 813 forumDid: ctx.config.forumDid, 814 action: "space.atbb.modAction.undelete", 815 }); 816 } 817 } 818 ); 819 820 return app; 821}