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 root/atb-56-theme-caching-layer 1634 lines 56 kB view raw
1import { Hono } from "hono"; 2import type { AppContext } from "../lib/app-context.js"; 3import type { Variables } from "../types.js"; 4import { requireAuth } from "../middleware/auth.js"; 5import { requirePermission, requireAnyPermission, getUserRole } from "../middleware/permissions.js"; 6import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors, categories, boards, posts, modActions, themes, themePolicies } from "@atbb/db"; 7import { eq, and, sql, asc, desc, count, inArray, or } from "drizzle-orm"; 8import { isProgrammingError } from "../lib/errors.js"; 9import { BackfillStatus } from "../lib/backfill-manager.js"; 10import { CursorManager } from "../lib/cursor-manager.js"; 11import { 12 handleRouteError, 13 safeParseJsonBody, 14 getForumAgentOrError, 15} from "../lib/route-errors.js"; 16import { TID } from "@atproto/common-web"; 17import { parseBigIntParam, serializeBigInt, serializeDate } from "./helpers.js"; 18import { sanitizeCssOverrides } from "@atbb/css-sanitizer"; 19 20export function createAdminRoutes(ctx: AppContext) { 21 const app = new Hono<{ Variables: Variables }>(); 22 23 /** 24 * POST /api/admin/members/:did/role 25 * 26 * Assign a role to a forum member. 27 */ 28 app.post( 29 "/members/:did/role", 30 requireAuth(ctx), 31 requirePermission(ctx, "space.atbb.permission.manageRoles"), 32 async (c) => { 33 const targetDid = c.req.param("did"); 34 const user = c.get("user")!; 35 36 // Parse and validate request body 37 const { body, error: parseError } = await safeParseJsonBody(c); 38 if (parseError) return parseError; 39 40 const { roleUri } = body; 41 42 if (typeof roleUri !== "string") { 43 return c.json({ error: "roleUri is required and must be a string" }, 400); 44 } 45 46 // Validate roleUri format 47 if (!roleUri.startsWith("at://") || !roleUri.includes("/space.atbb.forum.role/")) { 48 return c.json({ error: "Invalid roleUri format" }, 400); 49 } 50 51 // Extract role rkey from roleUri 52 const roleRkey = roleUri.split("/").pop(); 53 if (!roleRkey) { 54 return c.json({ error: "Invalid roleUri format" }, 400); 55 } 56 57 try { 58 // Validate role exists 59 const [role] = await ctx.db 60 .select() 61 .from(roles) 62 .where( 63 and( 64 eq(roles.did, ctx.config.forumDid), 65 eq(roles.rkey, roleRkey) 66 ) 67 ) 68 .limit(1); 69 70 if (!role) { 71 return c.json({ error: "Role not found" }, 404); 72 } 73 74 // Priority check: Can't assign role with equal or higher authority 75 const assignerRole = await getUserRole(ctx, user.did); 76 if (!assignerRole) { 77 return c.json({ error: "You do not have a role assigned" }, 403); 78 } 79 80 if (role.priority <= assignerRole.priority) { 81 return c.json({ 82 error: "Cannot assign role with equal or higher authority", 83 }, 403); 84 } 85 86 // Get target user's membership 87 const [membership] = await ctx.db 88 .select() 89 .from(memberships) 90 .where(eq(memberships.did, targetDid)) 91 .limit(1); 92 93 if (!membership) { 94 return c.json({ error: "User is not a member of this forum" }, 404); 95 } 96 97 // Fetch forum CID for membership record 98 const [forum] = await ctx.db 99 .select({ cid: forums.cid }) 100 .from(forums) 101 .where(eq(forums.did, ctx.config.forumDid)) 102 .limit(1); 103 104 if (!forum) { 105 return c.json({ error: "Forum record not found in database" }, 500); 106 } 107 108 // Get ForumAgent for PDS write operations 109 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/admin/members/:did/role"); 110 if (agentError) return agentError; 111 112 try { 113 // Update membership record on user's PDS using ForumAgent 114 await agent.com.atproto.repo.putRecord({ 115 repo: targetDid, 116 collection: "space.atbb.membership", 117 rkey: membership.rkey, 118 record: { 119 $type: "space.atbb.membership", 120 forum: { forum: { uri: membership.forumUri, cid: forum.cid } }, 121 role: { role: { uri: roleUri, cid: role.cid } }, 122 joinedAt: membership.joinedAt?.toISOString(), 123 createdAt: membership.createdAt.toISOString(), 124 }, 125 }); 126 127 return c.json({ 128 success: true, 129 roleAssigned: role.name, 130 targetDid, 131 }); 132 } catch (error) { 133 return handleRouteError(c, error, "Failed to assign role", { 134 operation: "POST /api/admin/members/:did/role", 135 logger: ctx.logger, 136 targetDid, 137 roleUri, 138 }); 139 } 140 } catch (error) { 141 return handleRouteError(c, error, "Failed to process role assignment", { 142 operation: "POST /api/admin/members/:did/role", 143 logger: ctx.logger, 144 targetDid, 145 roleUri, 146 }); 147 } 148 } 149 ); 150 151 /** 152 * GET /api/admin/roles 153 * 154 * List all available roles for the forum. 155 */ 156 app.get( 157 "/roles", 158 requireAuth(ctx), 159 requirePermission(ctx, "space.atbb.permission.manageRoles"), 160 async (c) => { 161 try { 162 const rolesList = await ctx.db 163 .select({ 164 id: roles.id, 165 name: roles.name, 166 description: roles.description, 167 priority: roles.priority, 168 rkey: roles.rkey, 169 did: roles.did, 170 }) 171 .from(roles) 172 .where(eq(roles.did, ctx.config.forumDid)) 173 .orderBy(asc(roles.priority)); 174 175 const rolesWithPermissions = await Promise.all( 176 rolesList.map(async (role) => { 177 const perms = await ctx.db 178 .select({ permission: rolePermissions.permission }) 179 .from(rolePermissions) 180 .where(eq(rolePermissions.roleId, role.id)); 181 return { 182 id: role.id.toString(), 183 name: role.name, 184 description: role.description, 185 permissions: perms.map((p) => p.permission), 186 priority: role.priority, 187 uri: `at://${role.did}/space.atbb.forum.role/${role.rkey}`, 188 }; 189 }) 190 ); 191 192 return c.json({ roles: rolesWithPermissions }); 193 } catch (error) { 194 return handleRouteError(c, error, "Failed to retrieve roles", { 195 operation: "GET /api/admin/roles", 196 logger: ctx.logger, 197 }); 198 } 199 } 200 ); 201 202 /** 203 * GET /api/admin/members 204 * 205 * List all forum members with their assigned roles. 206 */ 207 app.get( 208 "/members", 209 requireAuth(ctx), 210 requirePermission(ctx, "space.atbb.permission.manageMembers"), 211 async (c) => { 212 try { 213 const membersList = await ctx.db 214 .select({ 215 did: memberships.did, 216 handle: users.handle, 217 role: roles.name, 218 roleUri: memberships.roleUri, 219 joinedAt: memberships.joinedAt, 220 }) 221 .from(memberships) 222 .leftJoin(users, eq(memberships.did, users.did)) 223 .leftJoin( 224 roles, 225 sql`${memberships.roleUri} LIKE 'at://' || ${roles.did} || '/space.atbb.forum.role/' || ${roles.rkey}` 226 ) 227 .where(eq(memberships.forumUri, `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`)) 228 .orderBy(asc(roles.priority), asc(users.handle)) 229 .limit(100); 230 231 return c.json({ 232 members: membersList.map(member => ({ 233 did: member.did, 234 handle: member.handle || member.did, 235 role: member.role || "Guest", 236 roleUri: member.roleUri, 237 joinedAt: member.joinedAt?.toISOString(), 238 })), 239 isTruncated: membersList.length === 100, 240 }); 241 } catch (error) { 242 return handleRouteError(c, error, "Failed to retrieve members", { 243 operation: "GET /api/admin/members", 244 logger: ctx.logger, 245 }); 246 } 247 } 248 ); 249 250 251 /** 252 * GET /api/admin/members/me 253 * 254 * Returns the calling user's own membership, role name, and permissions. 255 * Any authenticated user may call this — no special permission required. 256 * Returns 404 if the user has no membership record for this forum. 257 */ 258 app.get("/members/me", requireAuth(ctx), async (c) => { 259 const user = c.get("user")!; 260 261 try { 262 const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 263 const [member] = await ctx.db 264 .select({ 265 did: memberships.did, 266 handle: users.handle, 267 roleUri: memberships.roleUri, 268 roleName: roles.name, 269 roleId: roles.id, 270 }) 271 .from(memberships) 272 .leftJoin(users, eq(memberships.did, users.did)) 273 .leftJoin( 274 roles, 275 sql`${memberships.roleUri} LIKE 'at://' || ${roles.did} || '/space.atbb.forum.role/' || ${roles.rkey}` 276 ) 277 .where( 278 and( 279 eq(memberships.did, user.did), 280 eq(memberships.forumUri, forumUri) 281 ) 282 ) 283 .limit(1); 284 285 if (!member) { 286 return c.json({ error: "Membership not found" }, 404); 287 } 288 289 let permissions: string[] = []; 290 if (member.roleId) { 291 const perms = await ctx.db 292 .select({ permission: rolePermissions.permission }) 293 .from(rolePermissions) 294 .where(eq(rolePermissions.roleId, member.roleId)); 295 permissions = perms.map((p) => p.permission); 296 } 297 298 return c.json({ 299 did: member.did, 300 handle: member.handle || user.did, 301 role: member.roleName || "Guest", 302 roleUri: member.roleUri, 303 permissions, 304 }); 305 } catch (error) { 306 return handleRouteError(c, error, "Failed to retrieve your membership", { 307 operation: "GET /api/admin/members/me", 308 logger: ctx.logger, 309 did: user.did, 310 }); 311 } 312 }); 313 314 /** 315 * POST /api/admin/backfill 316 * 317 * Trigger a backfill operation. Runs asynchronously. 318 * Returns 202 Accepted immediately. 319 * Use ?force=catch_up or ?force=full_sync to override gap detection. 320 */ 321 app.post( 322 "/backfill", 323 requireAuth(ctx), 324 requirePermission(ctx, "space.atbb.permission.manageForum"), 325 async (c) => { 326 const backfillManager = ctx.backfillManager; 327 if (!backfillManager) { 328 return c.json({ error: "Backfill manager not available" }, 503); 329 } 330 331 if (backfillManager.getIsRunning()) { 332 return c.json({ error: "A backfill is already in progress" }, 409); 333 } 334 335 // Determine backfill type 336 const force = c.req.query("force"); 337 let type: BackfillStatus; 338 339 if (force === "catch_up" || force === "full_sync") { 340 type = force === "catch_up" ? BackfillStatus.CatchUp : BackfillStatus.FullSync; 341 } else { 342 try { 343 const cursor = await new CursorManager(ctx.db, ctx.logger).load(); 344 type = await backfillManager.checkIfNeeded(cursor); 345 } catch (error) { 346 if (isProgrammingError(error)) throw error; 347 ctx.logger.error("Failed to check backfill status", { 348 event: "backfill.admin_trigger.check_failed", 349 error: error instanceof Error ? error.message : String(error), 350 }); 351 return c.json({ error: "Failed to check backfill status. Please try again later." }, 500); 352 } 353 354 if (type === BackfillStatus.NotNeeded) { 355 return c.json({ 356 message: "No backfill needed. Use ?force=catch_up or ?force=full_sync to override.", 357 }, 200); 358 } 359 } 360 361 // Create progress row first so we can return the ID immediately in the 202 response 362 let progressId: bigint; 363 try { 364 progressId = await backfillManager.prepareBackfillRow(type); 365 } catch (error) { 366 if (isProgrammingError(error)) throw error; 367 ctx.logger.error("Failed to create backfill row", { 368 event: "backfill.admin_trigger.create_row_failed", 369 error: error instanceof Error ? error.message : String(error), 370 }); 371 return c.json({ error: "Failed to start backfill. Please try again later." }, 500); 372 } 373 374 // Fire and forget — don't await so response is immediate 375 backfillManager.performBackfill(type, progressId).catch((err) => { 376 ctx.logger.error("Background backfill failed", { 377 event: "backfill.admin_trigger_failed", 378 backfillId: progressId.toString(), 379 error: err instanceof Error ? err.message : String(err), 380 }); 381 }); 382 383 return c.json({ 384 message: "Backfill started", 385 type, 386 status: "in_progress", 387 id: progressId.toString(), 388 }, 202); 389 } 390 ); 391 392 /** 393 * GET /api/admin/backfill/:id 394 * 395 * Get status and progress for a specific backfill by ID. 396 */ 397 app.get( 398 "/backfill/:id", 399 requireAuth(ctx), 400 requirePermission(ctx, "space.atbb.permission.manageForum"), 401 async (c) => { 402 const id = c.req.param("id"); 403 if (!/^\d+$/.test(id)) { 404 return c.json({ error: "Invalid backfill ID" }, 400); 405 } 406 const parsedId = BigInt(id); 407 408 try { 409 const [row] = await ctx.db 410 .select() 411 .from(backfillProgress) 412 .where(eq(backfillProgress.id, parsedId)) 413 .limit(1); 414 415 if (!row) { 416 return c.json({ error: "Backfill not found" }, 404); 417 } 418 419 const [errorCount] = await ctx.db 420 .select({ count: count() }) 421 .from(backfillErrors) 422 .where(eq(backfillErrors.backfillId, row.id)); 423 424 return c.json({ 425 id: row.id.toString(), 426 status: row.status, 427 type: row.backfillType, 428 didsTotal: row.didsTotal, 429 didsProcessed: row.didsProcessed, 430 recordsIndexed: row.recordsIndexed, 431 errorCount: errorCount?.count ?? 0, 432 startedAt: row.startedAt.toISOString(), 433 completedAt: row.completedAt?.toISOString() ?? null, 434 errorMessage: row.errorMessage, 435 }); 436 } catch (error) { 437 return handleRouteError(c, error, "Failed to fetch backfill progress", { 438 operation: "GET /api/admin/backfill/:id", 439 logger: ctx.logger, 440 id, 441 }); 442 } 443 } 444 ); 445 446 /** 447 * GET /api/admin/backfill/:id/errors 448 * 449 * List per-DID errors for a specific backfill. 450 */ 451 app.get( 452 "/backfill/:id/errors", 453 requireAuth(ctx), 454 requirePermission(ctx, "space.atbb.permission.manageForum"), 455 async (c) => { 456 const id = c.req.param("id"); 457 if (!/^\d+$/.test(id)) { 458 return c.json({ error: "Invalid backfill ID" }, 400); 459 } 460 const parsedId = BigInt(id); 461 462 try { 463 const errors = await ctx.db 464 .select() 465 .from(backfillErrors) 466 .where(eq(backfillErrors.backfillId, parsedId)) 467 .orderBy(asc(backfillErrors.createdAt)) 468 .limit(1000); 469 470 return c.json({ 471 errors: errors.map((e) => ({ 472 id: e.id.toString(), 473 did: e.did, 474 collection: e.collection, 475 errorMessage: e.errorMessage, 476 createdAt: e.createdAt.toISOString(), 477 })), 478 }); 479 } catch (error) { 480 return handleRouteError(c, error, "Failed to fetch backfill errors", { 481 operation: "GET /api/admin/backfill/:id/errors", 482 logger: ctx.logger, 483 id, 484 }); 485 } 486 } 487 ); 488 489 /** 490 * POST /api/admin/categories 491 * 492 * Create a new forum category. Writes space.atbb.forum.category to Forum DID's PDS. 493 * The firehose indexer creates the DB row asynchronously. 494 */ 495 app.post( 496 "/categories", 497 requireAuth(ctx), 498 requirePermission(ctx, "space.atbb.permission.manageCategories"), 499 async (c) => { 500 const { body, error: parseError } = await safeParseJsonBody(c); 501 if (parseError) return parseError; 502 503 const { name, description, sortOrder } = body; 504 505 if (typeof name !== "string" || name.trim().length === 0) { 506 return c.json({ error: "name is required and must be a non-empty string" }, 400); 507 } 508 509 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/admin/categories"); 510 if (agentError) return agentError; 511 512 const rkey = TID.nextStr(); 513 const now = new Date().toISOString(); 514 515 try { 516 const result = await agent.com.atproto.repo.putRecord({ 517 repo: ctx.config.forumDid, 518 collection: "space.atbb.forum.category", 519 rkey, 520 record: { 521 $type: "space.atbb.forum.category", 522 name: name.trim(), 523 ...(typeof description === "string" && { description: description.trim() }), 524 ...(Number.isInteger(sortOrder) && sortOrder >= 0 && { sortOrder }), 525 createdAt: now, 526 }, 527 }); 528 529 return c.json({ uri: result.data.uri, cid: result.data.cid }, 201); 530 } catch (error) { 531 return handleRouteError(c, error, "Failed to create category", { 532 operation: "POST /api/admin/categories", 533 logger: ctx.logger, 534 }); 535 } 536 } 537 ); 538 539 /** 540 * PUT /api/admin/categories/:id 541 * 542 * Update an existing category. Fetches existing rkey from DB, calls putRecord 543 * with updated fields preserving the original createdAt. 544 * The firehose indexer updates the DB row asynchronously. 545 */ 546 app.put( 547 "/categories/:id", 548 requireAuth(ctx), 549 requirePermission(ctx, "space.atbb.permission.manageCategories"), 550 async (c) => { 551 const idParam = c.req.param("id"); 552 const id = parseBigIntParam(idParam); 553 if (id === null) { 554 return c.json({ error: "Invalid category ID" }, 400); 555 } 556 557 const { body, error: parseError } = await safeParseJsonBody(c); 558 if (parseError) return parseError; 559 560 const { name, description, sortOrder } = body; 561 562 if (typeof name !== "string" || name.trim().length === 0) { 563 return c.json({ error: "name is required and must be a non-empty string" }, 400); 564 } 565 566 let category: typeof categories.$inferSelect; 567 try { 568 const [row] = await ctx.db 569 .select() 570 .from(categories) 571 .where(and(eq(categories.id, id), eq(categories.did, ctx.config.forumDid))) 572 .limit(1); 573 574 if (!row) { 575 return c.json({ error: "Category not found" }, 404); 576 } 577 category = row; 578 } catch (error) { 579 return handleRouteError(c, error, "Failed to look up category", { 580 operation: "PUT /api/admin/categories/:id", 581 logger: ctx.logger, 582 id: idParam, 583 }); 584 } 585 586 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/categories/:id"); 587 if (agentError) return agentError; 588 589 // putRecord is a full replacement — fall back to existing values for 590 // optional fields not provided in the request body, to avoid data loss. 591 const resolvedDescription = typeof description === "string" 592 ? description.trim() 593 : category.description; 594 const resolvedSortOrder = (Number.isInteger(sortOrder) && sortOrder >= 0) 595 ? sortOrder 596 : category.sortOrder; 597 598 try { 599 const result = await agent.com.atproto.repo.putRecord({ 600 repo: ctx.config.forumDid, 601 collection: "space.atbb.forum.category", 602 rkey: category.rkey, 603 record: { 604 $type: "space.atbb.forum.category", 605 name: name.trim(), 606 ...(resolvedDescription != null && { description: resolvedDescription }), 607 ...(resolvedSortOrder != null && { sortOrder: resolvedSortOrder }), 608 createdAt: category.createdAt.toISOString(), 609 }, 610 }); 611 612 return c.json({ uri: result.data.uri, cid: result.data.cid }); 613 } catch (error) { 614 return handleRouteError(c, error, "Failed to update category", { 615 operation: "PUT /api/admin/categories/:id", 616 logger: ctx.logger, 617 id: idParam, 618 }); 619 } 620 } 621 ); 622 623 /** 624 * DELETE /api/admin/categories/:id 625 * 626 * Delete a category. Pre-flight: refuses with 409 if any boards reference this 627 * category in the DB. If clear, calls deleteRecord on the Forum DID's PDS. 628 * The firehose indexer removes the DB row asynchronously. 629 */ 630 app.delete( 631 "/categories/:id", 632 requireAuth(ctx), 633 requirePermission(ctx, "space.atbb.permission.manageCategories"), 634 async (c) => { 635 const idParam = c.req.param("id"); 636 const id = parseBigIntParam(idParam); 637 if (id === null) { 638 return c.json({ error: "Invalid category ID" }, 400); 639 } 640 641 let category: typeof categories.$inferSelect; 642 try { 643 const [row] = await ctx.db 644 .select() 645 .from(categories) 646 .where(and(eq(categories.id, id), eq(categories.did, ctx.config.forumDid))) 647 .limit(1); 648 649 if (!row) { 650 return c.json({ error: "Category not found" }, 404); 651 } 652 category = row; 653 } catch (error) { 654 return handleRouteError(c, error, "Failed to look up category", { 655 operation: "DELETE /api/admin/categories/:id", 656 logger: ctx.logger, 657 id: idParam, 658 }); 659 } 660 661 // Pre-flight: refuse if any boards reference this category 662 try { 663 const [boardCount] = await ctx.db 664 .select({ count: count() }) 665 .from(boards) 666 .where(eq(boards.categoryId, id)); 667 668 if (boardCount && boardCount.count > 0) { 669 return c.json( 670 { error: "Cannot delete category with boards. Remove all boards first." }, 671 409 672 ); 673 } 674 } catch (error) { 675 return handleRouteError(c, error, "Failed to check category boards", { 676 operation: "DELETE /api/admin/categories/:id", 677 logger: ctx.logger, 678 id: idParam, 679 }); 680 } 681 682 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/admin/categories/:id"); 683 if (agentError) return agentError; 684 685 try { 686 await agent.com.atproto.repo.deleteRecord({ 687 repo: ctx.config.forumDid, 688 collection: "space.atbb.forum.category", 689 rkey: category.rkey, 690 }); 691 692 return c.json({ success: true }); 693 } catch (error) { 694 return handleRouteError(c, error, "Failed to delete category", { 695 operation: "DELETE /api/admin/categories/:id", 696 logger: ctx.logger, 697 id: idParam, 698 }); 699 } 700 } 701 ); 702 703 /** 704 * POST /api/admin/boards 705 * 706 * Create a new forum board within a category. Fetches the category's CID from DB 707 * to build the categoryRef strongRef required by the lexicon. Writes 708 * space.atbb.forum.board to the Forum DID's PDS via putRecord. 709 * The firehose indexer creates the DB row asynchronously. 710 */ 711 app.post( 712 "/boards", 713 requireAuth(ctx), 714 requirePermission(ctx, "space.atbb.permission.manageCategories"), 715 async (c) => { 716 const { body, error: parseError } = await safeParseJsonBody(c); 717 if (parseError) return parseError; 718 719 const { name, description, sortOrder, categoryUri } = body; 720 721 if (typeof name !== "string" || name.trim().length === 0) { 722 return c.json({ error: "name is required and must be a non-empty string" }, 400); 723 } 724 725 if (typeof categoryUri !== "string" || !categoryUri.startsWith("at://")) { 726 return c.json({ error: "categoryUri is required and must be a valid AT URI" }, 400); 727 } 728 729 // Derive rkey from the categoryUri to look up the category in the DB 730 const categoryRkey = categoryUri.split("/").pop(); 731 732 let category: typeof categories.$inferSelect; 733 try { 734 const [row] = await ctx.db 735 .select() 736 .from(categories) 737 .where( 738 and( 739 eq(categories.did, ctx.config.forumDid), 740 eq(categories.rkey, categoryRkey ?? "") 741 ) 742 ) 743 .limit(1); 744 745 if (!row) { 746 return c.json({ error: "Category not found" }, 404); 747 } 748 category = row; 749 } catch (error) { 750 return handleRouteError(c, error, "Failed to look up category", { 751 operation: "POST /api/admin/boards", 752 logger: ctx.logger, 753 categoryUri, 754 }); 755 } 756 757 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/admin/boards"); 758 if (agentError) return agentError; 759 760 const rkey = TID.nextStr(); 761 const now = new Date().toISOString(); 762 763 try { 764 const result = await agent.com.atproto.repo.putRecord({ 765 repo: ctx.config.forumDid, 766 collection: "space.atbb.forum.board", 767 rkey, 768 record: { 769 $type: "space.atbb.forum.board", 770 name: name.trim(), 771 ...(typeof description === "string" && { description: description.trim() }), 772 ...(Number.isInteger(sortOrder) && sortOrder >= 0 && { sortOrder }), 773 category: { category: { uri: categoryUri, cid: category.cid } }, 774 createdAt: now, 775 }, 776 }); 777 778 return c.json({ uri: result.data.uri, cid: result.data.cid }, 201); 779 } catch (error) { 780 return handleRouteError(c, error, "Failed to create board", { 781 operation: "POST /api/admin/boards", 782 logger: ctx.logger, 783 categoryUri, 784 }); 785 } 786 } 787 ); 788 789 /** 790 * PUT /api/admin/boards/:id 791 * 792 * Update an existing board's name, description, and sortOrder. 793 * Fetches existing rkey + categoryUri from DB, then fetches category CID, 794 * then putRecord with updated fields preserving the original categoryRef and createdAt. 795 * Category cannot be changed on edit (no reparenting). 796 * The firehose indexer updates the DB row asynchronously. 797 */ 798 app.put( 799 "/boards/:id", 800 requireAuth(ctx), 801 requirePermission(ctx, "space.atbb.permission.manageCategories"), 802 async (c) => { 803 const idParam = c.req.param("id"); 804 const id = parseBigIntParam(idParam); 805 if (id === null) { 806 return c.json({ error: "Invalid board ID" }, 400); 807 } 808 809 const { body, error: parseError } = await safeParseJsonBody(c); 810 if (parseError) return parseError; 811 812 const { name, description, sortOrder } = body; 813 814 if (typeof name !== "string" || name.trim().length === 0) { 815 return c.json({ error: "name is required and must be a non-empty string" }, 400); 816 } 817 818 let board: typeof boards.$inferSelect; 819 try { 820 const [row] = await ctx.db 821 .select() 822 .from(boards) 823 .where(and(eq(boards.id, id), eq(boards.did, ctx.config.forumDid))) 824 .limit(1); 825 826 if (!row) { 827 return c.json({ error: "Board not found" }, 404); 828 } 829 board = row; 830 } catch (error) { 831 return handleRouteError(c, error, "Failed to look up board", { 832 operation: "PUT /api/admin/boards/:id", 833 logger: ctx.logger, 834 id: idParam, 835 }); 836 } 837 838 // Fetch category CID to rebuild the categoryRef strongRef. 839 // Always fetch fresh — the category's CID can change after category edits. 840 let categoryCid: string; 841 try { 842 const categoryRkey = board.categoryUri.split("/").pop() ?? ""; 843 const [cat] = await ctx.db 844 .select({ cid: categories.cid }) 845 .from(categories) 846 .where( 847 and( 848 eq(categories.did, ctx.config.forumDid), 849 eq(categories.rkey, categoryRkey) 850 ) 851 ) 852 .limit(1); 853 854 if (!cat) { 855 return c.json({ error: "Category not found" }, 404); 856 } 857 categoryCid = cat.cid; 858 } catch (error) { 859 return handleRouteError(c, error, "Failed to look up category", { 860 operation: "PUT /api/admin/boards/:id", 861 logger: ctx.logger, 862 id: idParam, 863 }); 864 } 865 866 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/boards/:id"); 867 if (agentError) return agentError; 868 869 // putRecord is a full replacement — fall back to existing values for 870 // optional fields not provided in the request body, to avoid data loss. 871 const resolvedDescription = typeof description === "string" 872 ? description.trim() 873 : board.description; 874 const resolvedSortOrder = (Number.isInteger(sortOrder) && sortOrder >= 0) 875 ? sortOrder 876 : board.sortOrder; 877 878 try { 879 const result = await agent.com.atproto.repo.putRecord({ 880 repo: ctx.config.forumDid, 881 collection: "space.atbb.forum.board", 882 rkey: board.rkey, 883 record: { 884 $type: "space.atbb.forum.board", 885 name: name.trim(), 886 ...(resolvedDescription != null && { description: resolvedDescription }), 887 ...(resolvedSortOrder != null && { sortOrder: resolvedSortOrder }), 888 category: { category: { uri: board.categoryUri, cid: categoryCid } }, 889 createdAt: board.createdAt.toISOString(), 890 }, 891 }); 892 893 return c.json({ uri: result.data.uri, cid: result.data.cid }); 894 } catch (error) { 895 return handleRouteError(c, error, "Failed to update board", { 896 operation: "PUT /api/admin/boards/:id", 897 logger: ctx.logger, 898 id: idParam, 899 }); 900 } 901 } 902 ); 903 904 /** 905 * DELETE /api/admin/boards/:id 906 * 907 * Delete a board. Pre-flight: refuses with 409 if any posts have boardId 908 * pointing to this board. If clear, calls deleteRecord on the Forum DID's PDS. 909 * The firehose indexer removes the DB row asynchronously. 910 */ 911 app.delete( 912 "/boards/:id", 913 requireAuth(ctx), 914 requirePermission(ctx, "space.atbb.permission.manageCategories"), 915 async (c) => { 916 const idParam = c.req.param("id"); 917 const id = parseBigIntParam(idParam); 918 if (id === null) { 919 return c.json({ error: "Invalid board ID" }, 400); 920 } 921 922 let board: typeof boards.$inferSelect; 923 try { 924 const [row] = await ctx.db 925 .select() 926 .from(boards) 927 .where(and(eq(boards.id, id), eq(boards.did, ctx.config.forumDid))) 928 .limit(1); 929 930 if (!row) { 931 return c.json({ error: "Board not found" }, 404); 932 } 933 board = row; 934 } catch (error) { 935 return handleRouteError(c, error, "Failed to look up board", { 936 operation: "DELETE /api/admin/boards/:id", 937 logger: ctx.logger, 938 id: idParam, 939 }); 940 } 941 942 // Pre-flight: refuse if any posts reference this board 943 try { 944 const [postCount] = await ctx.db 945 .select({ count: count() }) 946 .from(posts) 947 .where(eq(posts.boardId, id)); 948 949 if (postCount && postCount.count > 0) { 950 return c.json( 951 { error: "Cannot delete board with posts. Remove all posts first." }, 952 409 953 ); 954 } 955 } catch (error) { 956 return handleRouteError(c, error, "Failed to check board posts", { 957 operation: "DELETE /api/admin/boards/:id", 958 logger: ctx.logger, 959 id: idParam, 960 }); 961 } 962 963 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/admin/boards/:id"); 964 if (agentError) return agentError; 965 966 try { 967 await agent.com.atproto.repo.deleteRecord({ 968 repo: ctx.config.forumDid, 969 collection: "space.atbb.forum.board", 970 rkey: board.rkey, 971 }); 972 973 return c.json({ success: true }); 974 } catch (error) { 975 return handleRouteError(c, error, "Failed to delete board", { 976 operation: "DELETE /api/admin/boards/:id", 977 logger: ctx.logger, 978 id: idParam, 979 }); 980 } 981 } 982 ); 983 984 /** 985 * GET /api/admin/themes 986 * 987 * Returns all themes for this forum — no policy filtering. 988 * Admins need to see all themes, including drafts not yet in the policy. 989 */ 990 app.get( 991 "/themes", 992 requireAuth(ctx), 993 requirePermission(ctx, "space.atbb.permission.manageThemes"), 994 async (c) => { 995 try { 996 const themeList = await ctx.db 997 .select() 998 .from(themes) 999 .where(eq(themes.did, ctx.config.forumDid)) 1000 .limit(100); 1001 1002 return c.json({ 1003 themes: themeList.map((theme) => ({ 1004 id: serializeBigInt(theme.id), 1005 uri: `at://${theme.did}/space.atbb.forum.theme/${theme.rkey}`, 1006 name: theme.name, 1007 colorScheme: theme.colorScheme, 1008 tokens: theme.tokens, 1009 cssOverrides: theme.cssOverrides ?? null, 1010 fontUrls: (theme.fontUrls as string[] | null) ?? null, 1011 createdAt: serializeDate(theme.createdAt), 1012 indexedAt: serializeDate(theme.indexedAt), 1013 })), 1014 isTruncated: themeList.length === 100, 1015 }); 1016 } catch (error) { 1017 return handleRouteError(c, error, "Failed to retrieve themes", { 1018 operation: "GET /api/admin/themes", 1019 logger: ctx.logger, 1020 }); 1021 } 1022 } 1023 ); 1024 1025 /** 1026 * POST /api/admin/themes 1027 * 1028 * Create a new theme record on Forum DID's PDS. 1029 * Writes space.atbb.forum.theme with a fresh TID rkey. 1030 * The firehose indexer creates the DB row asynchronously. 1031 */ 1032 app.post( 1033 "/themes", 1034 requireAuth(ctx), 1035 requirePermission(ctx, "space.atbb.permission.manageThemes"), 1036 async (c) => { 1037 const { body, error: parseError } = await safeParseJsonBody(c); 1038 if (parseError) return parseError; 1039 1040 const { name, colorScheme, tokens, cssOverrides, fontUrls } = body; 1041 1042 if (typeof name !== "string" || name.trim().length === 0) { 1043 return c.json({ error: "name is required and must be a non-empty string" }, 400); 1044 } 1045 if (typeof colorScheme !== "string" || (colorScheme !== "light" && colorScheme !== "dark")) { 1046 return c.json({ error: 'colorScheme is required and must be "light" or "dark"' }, 400); 1047 } 1048 if (tokens === null || tokens === undefined || typeof tokens !== "object" || Array.isArray(tokens)) { 1049 return c.json({ error: "tokens is required and must be a plain object" }, 400); 1050 } 1051 for (const [key, val] of Object.entries(tokens as Record<string, unknown>)) { 1052 if (typeof val !== "string") { 1053 return c.json({ error: `tokens["${key}"] must be a string` }, 400); 1054 } 1055 } 1056 if (cssOverrides !== undefined && typeof cssOverrides !== "string") { 1057 return c.json({ error: "cssOverrides must be a string" }, 400); 1058 } 1059 if (fontUrls !== undefined) { 1060 if (!Array.isArray(fontUrls)) { 1061 return c.json({ error: "fontUrls must be an array of strings" }, 400); 1062 } 1063 for (const url of fontUrls as unknown[]) { 1064 if (typeof url !== "string" || !url.startsWith("https://")) { 1065 return c.json({ error: "fontUrls must contain only HTTPS URLs" }, 400); 1066 } 1067 } 1068 } 1069 1070 // Sanitize cssOverrides before writing to PDS. In its own try-catch 1071 // because sanitization failure has different semantics than a PDS write failure. 1072 let sanitizedCssOverrides: string | undefined; 1073 if (typeof cssOverrides === "string") { 1074 try { 1075 const { css, warnings } = sanitizeCssOverrides(cssOverrides); 1076 if (warnings.length > 0) { 1077 ctx.logger.warn("Stripped dangerous CSS constructs from theme on create", { 1078 operation: "POST /api/admin/themes", 1079 warnings, 1080 }); 1081 } 1082 sanitizedCssOverrides = css; 1083 } catch (error) { 1084 if (isProgrammingError(error)) throw error; 1085 ctx.logger.error("CSS sanitization failed unexpectedly on create", { 1086 operation: "POST /api/admin/themes", 1087 error: error instanceof Error ? error.message : String(error), 1088 }); 1089 return c.json({ error: "Failed to process CSS overrides" }, 500); 1090 } 1091 } 1092 1093 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/admin/themes"); 1094 if (agentError) return agentError; 1095 1096 const rkey = TID.nextStr(); 1097 const now = new Date().toISOString(); 1098 1099 try { 1100 const result = await agent.com.atproto.repo.putRecord({ 1101 repo: ctx.config.forumDid, 1102 collection: "space.atbb.forum.theme", 1103 rkey, 1104 record: { 1105 $type: "space.atbb.forum.theme", 1106 name: name.trim(), 1107 colorScheme, 1108 tokens, 1109 ...(typeof sanitizedCssOverrides === "string" && { cssOverrides: sanitizedCssOverrides }), 1110 ...(Array.isArray(fontUrls) && { fontUrls }), 1111 createdAt: now, 1112 }, 1113 }); 1114 1115 return c.json({ uri: result.data.uri, cid: result.data.cid }, 201); 1116 } catch (error) { 1117 return handleRouteError(c, error, "Failed to create theme", { 1118 operation: "POST /api/admin/themes", 1119 logger: ctx.logger, 1120 }); 1121 } 1122 } 1123 ); 1124 1125 /** 1126 * PUT /api/admin/themes/:rkey 1127 * 1128 * Update an existing theme. Fetches the existing row from DB to preserve 1129 * createdAt and fall back optional fields not in the request body. 1130 * The firehose indexer updates the DB row asynchronously. 1131 */ 1132 app.put( 1133 "/themes/:rkey", 1134 requireAuth(ctx), 1135 requirePermission(ctx, "space.atbb.permission.manageThemes"), 1136 async (c) => { 1137 const themeRkey = c.req.param("rkey").trim(); 1138 1139 const { body, error: parseError } = await safeParseJsonBody(c); 1140 if (parseError) return parseError; 1141 1142 const { name, colorScheme, tokens, cssOverrides, fontUrls } = body; 1143 1144 if (typeof name !== "string" || name.trim().length === 0) { 1145 return c.json({ error: "name is required and must be a non-empty string" }, 400); 1146 } 1147 if (typeof colorScheme !== "string" || (colorScheme !== "light" && colorScheme !== "dark")) { 1148 return c.json({ error: 'colorScheme is required and must be "light" or "dark"' }, 400); 1149 } 1150 if (tokens === null || tokens === undefined || typeof tokens !== "object" || Array.isArray(tokens)) { 1151 return c.json({ error: "tokens is required and must be a plain object" }, 400); 1152 } 1153 for (const [key, val] of Object.entries(tokens as Record<string, unknown>)) { 1154 if (typeof val !== "string") { 1155 return c.json({ error: `tokens["${key}"] must be a string` }, 400); 1156 } 1157 } 1158 if (cssOverrides !== undefined && typeof cssOverrides !== "string") { 1159 return c.json({ error: "cssOverrides must be a string" }, 400); 1160 } 1161 if (fontUrls !== undefined) { 1162 if (!Array.isArray(fontUrls)) { 1163 return c.json({ error: "fontUrls must be an array of strings" }, 400); 1164 } 1165 for (const url of fontUrls as unknown[]) { 1166 if (typeof url !== "string" || !url.startsWith("https://")) { 1167 return c.json({ error: "fontUrls must contain only HTTPS URLs" }, 400); 1168 } 1169 } 1170 } 1171 1172 let theme: typeof themes.$inferSelect; 1173 try { 1174 const [row] = await ctx.db 1175 .select() 1176 .from(themes) 1177 .where(and(eq(themes.did, ctx.config.forumDid), eq(themes.rkey, themeRkey))) 1178 .limit(1); 1179 1180 if (!row) { 1181 return c.json({ error: "Theme not found" }, 404); 1182 } 1183 theme = row; 1184 } catch (error) { 1185 return handleRouteError(c, error, "Failed to look up theme", { 1186 operation: "PUT /api/admin/themes/:rkey", 1187 logger: ctx.logger, 1188 themeRkey, 1189 }); 1190 } 1191 1192 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/themes/:rkey"); 1193 if (agentError) return agentError; 1194 1195 // putRecord is a full replacement — fall back to existing values for 1196 // optional fields not provided in the request body, to avoid data loss. 1197 const rawCssOverrides = 1198 typeof cssOverrides === "string" ? cssOverrides : theme.cssOverrides; 1199 let resolvedCssOverrides: string | null | undefined = rawCssOverrides; 1200 if (rawCssOverrides != null) { 1201 try { 1202 const { css, warnings } = sanitizeCssOverrides(rawCssOverrides); 1203 if (warnings.length > 0) { 1204 ctx.logger.warn("Stripped dangerous CSS constructs from theme on update", { 1205 operation: "PUT /api/admin/themes/:rkey", 1206 themeRkey, 1207 warnings, 1208 }); 1209 } 1210 resolvedCssOverrides = css; 1211 } catch (error) { 1212 if (isProgrammingError(error)) throw error; 1213 ctx.logger.error("CSS sanitization failed unexpectedly on update", { 1214 operation: "PUT /api/admin/themes/:rkey", 1215 themeRkey, 1216 error: error instanceof Error ? error.message : String(error), 1217 }); 1218 return c.json({ error: "Failed to process CSS overrides" }, 500); 1219 } 1220 } 1221 const resolvedFontUrls = Array.isArray(fontUrls) ? fontUrls : (theme.fontUrls as string[] | null); 1222 1223 try { 1224 const result = await agent.com.atproto.repo.putRecord({ 1225 repo: ctx.config.forumDid, 1226 collection: "space.atbb.forum.theme", 1227 rkey: theme.rkey, 1228 record: { 1229 $type: "space.atbb.forum.theme", 1230 name: name.trim(), 1231 colorScheme, 1232 tokens, 1233 ...(resolvedCssOverrides != null && { cssOverrides: resolvedCssOverrides }), 1234 ...(resolvedFontUrls != null && { fontUrls: resolvedFontUrls }), 1235 createdAt: theme.createdAt.toISOString(), 1236 updatedAt: new Date().toISOString(), 1237 }, 1238 }); 1239 1240 return c.json({ uri: result.data.uri, cid: result.data.cid }); 1241 } catch (error) { 1242 return handleRouteError(c, error, "Failed to update theme", { 1243 operation: "PUT /api/admin/themes/:rkey", 1244 logger: ctx.logger, 1245 themeRkey, 1246 }); 1247 } 1248 } 1249 ); 1250 1251 /** 1252 * DELETE /api/admin/themes/:rkey 1253 * 1254 * Delete a theme. Pre-flight: refuses with 409 if the theme is set as 1255 * defaultLightTheme or defaultDarkTheme in the theme policy. 1256 * The firehose indexer removes the DB row asynchronously. 1257 */ 1258 app.delete( 1259 "/themes/:rkey", 1260 requireAuth(ctx), 1261 requirePermission(ctx, "space.atbb.permission.manageThemes"), 1262 async (c) => { 1263 const themeRkey = c.req.param("rkey").trim(); 1264 1265 let theme: typeof themes.$inferSelect; 1266 try { 1267 const [row] = await ctx.db 1268 .select() 1269 .from(themes) 1270 .where(and(eq(themes.did, ctx.config.forumDid), eq(themes.rkey, themeRkey))) 1271 .limit(1); 1272 1273 if (!row) { 1274 return c.json({ error: "Theme not found" }, 404); 1275 } 1276 theme = row; 1277 } catch (error) { 1278 return handleRouteError(c, error, "Failed to look up theme", { 1279 operation: "DELETE /api/admin/themes/:rkey", 1280 logger: ctx.logger, 1281 themeRkey, 1282 }); 1283 } 1284 1285 // Pre-flight conflict check: refuse if this theme is a policy default 1286 const themeUri = `at://${theme.did}/space.atbb.forum.theme/${theme.rkey}`; 1287 try { 1288 const [conflictingPolicy] = await ctx.db 1289 .select({ id: themePolicies.id }) 1290 .from(themePolicies) 1291 .where( 1292 and( 1293 eq(themePolicies.did, ctx.config.forumDid), 1294 or( 1295 eq(themePolicies.defaultLightThemeUri, themeUri), 1296 eq(themePolicies.defaultDarkThemeUri, themeUri) 1297 ) 1298 ) 1299 ) 1300 .limit(1); 1301 1302 if (conflictingPolicy) { 1303 return c.json( 1304 { error: "Cannot delete a theme that is currently set as a default. Update the theme policy first." }, 1305 409 1306 ); 1307 } 1308 } catch (error) { 1309 return handleRouteError(c, error, "Failed to check theme policy", { 1310 operation: "DELETE /api/admin/themes/:rkey", 1311 logger: ctx.logger, 1312 themeRkey, 1313 }); 1314 } 1315 1316 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/admin/themes/:rkey"); 1317 if (agentError) return agentError; 1318 1319 try { 1320 await agent.com.atproto.repo.deleteRecord({ 1321 repo: ctx.config.forumDid, 1322 collection: "space.atbb.forum.theme", 1323 rkey: theme.rkey, 1324 }); 1325 1326 return c.json({ success: true }); 1327 } catch (error) { 1328 return handleRouteError(c, error, "Failed to delete theme", { 1329 operation: "DELETE /api/admin/themes/:rkey", 1330 logger: ctx.logger, 1331 themeRkey, 1332 }); 1333 } 1334 } 1335 ); 1336 1337 /** 1338 * POST /api/admin/themes/:rkey/duplicate 1339 * 1340 * Clones an existing theme record with " (Copy)" appended to the name. 1341 * Uses a fresh TID as the new record key. 1342 * The firehose indexer will create the DB row asynchronously. 1343 */ 1344 app.post( 1345 "/themes/:rkey/duplicate", 1346 requireAuth(ctx), 1347 requirePermission(ctx, "space.atbb.permission.manageThemes"), 1348 async (c) => { 1349 const sourceRkey = c.req.param("rkey").trim(); 1350 1351 let source: typeof themes.$inferSelect; 1352 try { 1353 const [row] = await ctx.db 1354 .select() 1355 .from(themes) 1356 .where(and(eq(themes.did, ctx.config.forumDid), eq(themes.rkey, sourceRkey))) 1357 .limit(1); 1358 1359 if (!row) { 1360 return c.json({ error: "Theme not found" }, 404); 1361 } 1362 source = row; 1363 } catch (error) { 1364 return handleRouteError(c, error, "Failed to look up source theme", { 1365 operation: "POST /api/admin/themes/:rkey/duplicate", 1366 logger: ctx.logger, 1367 sourceRkey, 1368 }); 1369 } 1370 1371 const { agent, error: agentError } = getForumAgentOrError( 1372 ctx, 1373 c, 1374 "POST /api/admin/themes/:rkey/duplicate" 1375 ); 1376 if (agentError) return agentError; 1377 1378 const newRkey = TID.nextStr(); 1379 const newName = `${source.name} (Copy)`; 1380 const now = new Date().toISOString(); 1381 1382 // Sanitize cssOverrides from source before writing to PDS so any 1383 // pre-sanitization records don't propagate dangerous CSS via duplication. 1384 let duplicateCssOverrides: string | null = null; 1385 if (source.cssOverrides != null) { 1386 try { 1387 const { css, warnings } = sanitizeCssOverrides(source.cssOverrides); 1388 if (warnings.length > 0) { 1389 ctx.logger.warn("Stripped dangerous CSS constructs from theme on duplicate", { 1390 operation: "POST /api/admin/themes/:rkey/duplicate", 1391 sourceRkey, 1392 warnings, 1393 }); 1394 } 1395 duplicateCssOverrides = css; 1396 } catch (error) { 1397 if (isProgrammingError(error)) throw error; 1398 ctx.logger.error("CSS sanitization failed unexpectedly on duplicate", { 1399 operation: "POST /api/admin/themes/:rkey/duplicate", 1400 sourceRkey, 1401 error: error instanceof Error ? error.message : String(error), 1402 }); 1403 return c.json({ error: "Failed to process CSS overrides" }, 500); 1404 } 1405 } 1406 1407 try { 1408 const result = await agent.com.atproto.repo.putRecord({ 1409 repo: ctx.config.forumDid, 1410 collection: "space.atbb.forum.theme", 1411 rkey: newRkey, 1412 record: { 1413 $type: "space.atbb.forum.theme", 1414 name: newName, 1415 colorScheme: source.colorScheme, 1416 tokens: source.tokens, 1417 ...(duplicateCssOverrides != null && { cssOverrides: duplicateCssOverrides }), 1418 ...(source.fontUrls != null && { fontUrls: source.fontUrls }), 1419 createdAt: now, 1420 }, 1421 }); 1422 1423 return c.json({ uri: result.data.uri, rkey: newRkey, name: newName }, 201); 1424 } catch (error) { 1425 return handleRouteError(c, error, "Failed to duplicate theme", { 1426 operation: "POST /api/admin/themes/:rkey/duplicate", 1427 logger: ctx.logger, 1428 sourceRkey, 1429 newRkey, 1430 }); 1431 } 1432 } 1433 ); 1434 1435 /** 1436 * PUT /api/admin/theme-policy 1437 * 1438 * Create or update the themePolicy singleton (rkey: "self") on Forum DID's PDS. 1439 * Upsert semantics: works whether or not a policy record exists yet. 1440 * The firehose indexer creates/updates the DB row asynchronously. 1441 */ 1442 app.put( 1443 "/theme-policy", 1444 requireAuth(ctx), 1445 requirePermission(ctx, "space.atbb.permission.manageThemes"), 1446 async (c) => { 1447 const { body, error: parseError } = await safeParseJsonBody(c); 1448 if (parseError) return parseError; 1449 1450 const { availableThemes, defaultLightThemeUri, defaultDarkThemeUri, allowUserChoice } = body; 1451 1452 if (!Array.isArray(availableThemes) || availableThemes.length === 0) { 1453 return c.json({ error: "availableThemes is required and must be a non-empty array" }, 400); 1454 } 1455 for (const t of availableThemes as unknown[]) { 1456 if ( 1457 typeof t !== "object" || 1458 t === null || 1459 typeof (t as Record<string, unknown>).uri !== "string" 1460 ) { 1461 return c.json({ error: "Each availableThemes entry must have a uri string field" }, 400); 1462 } 1463 } 1464 1465 if (typeof defaultLightThemeUri !== "string" || !defaultLightThemeUri.startsWith("at://")) { 1466 return c.json({ error: "defaultLightThemeUri is required and must be an AT-URI" }, 400); 1467 } 1468 if (typeof defaultDarkThemeUri !== "string" || !defaultDarkThemeUri.startsWith("at://")) { 1469 return c.json({ error: "defaultDarkThemeUri is required and must be an AT-URI" }, 400); 1470 } 1471 1472 const typedAvailableThemes = availableThemes as Array<{ uri: string; cid?: string }>; 1473 const availableUris = typedAvailableThemes.map((t) => t.uri); 1474 if (!availableUris.includes(defaultLightThemeUri)) { 1475 return c.json({ error: "defaultLightThemeUri must be present in availableThemes" }, 400); 1476 } 1477 if (!availableUris.includes(defaultDarkThemeUri)) { 1478 return c.json({ error: "defaultDarkThemeUri must be present in availableThemes" }, 400); 1479 } 1480 1481 const resolvedAllowUserChoice = typeof allowUserChoice === "boolean" ? allowUserChoice : true; 1482 1483 // CID is optional — live refs (no cid) are valid for canonical atbb.space presets. 1484 // Pass cid through when provided; omit it when absent or empty string. 1485 const resolvedThemes = typedAvailableThemes.map((t) => ({ 1486 uri: t.uri, 1487 cid: typeof t.cid === "string" && t.cid !== "" ? t.cid : undefined, 1488 })); 1489 1490 const lightTheme = resolvedThemes.find((t) => t.uri === defaultLightThemeUri); 1491 const darkTheme = resolvedThemes.find((t) => t.uri === defaultDarkThemeUri); 1492 if (!lightTheme || !darkTheme) { 1493 // Both URIs were validated as present in availableThemes above — this is unreachable. 1494 return c.json({ error: "Internal error: theme URIs not found in resolved themes" }, 500); 1495 } 1496 1497 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/theme-policy"); 1498 if (agentError) return agentError; 1499 1500 try { 1501 const result = await agent.com.atproto.repo.putRecord({ 1502 repo: ctx.config.forumDid, 1503 collection: "space.atbb.forum.themePolicy", 1504 rkey: "self", 1505 record: { 1506 $type: "space.atbb.forum.themePolicy", 1507 availableThemes: resolvedThemes.map((t) => ({ 1508 uri: t.uri, 1509 ...(t.cid !== undefined ? { cid: t.cid } : {}), 1510 })), 1511 defaultLightTheme: { uri: lightTheme.uri, ...(lightTheme.cid !== undefined ? { cid: lightTheme.cid } : {}) }, 1512 defaultDarkTheme: { uri: darkTheme.uri, ...(darkTheme.cid !== undefined ? { cid: darkTheme.cid } : {}) }, 1513 allowUserChoice: resolvedAllowUserChoice, 1514 updatedAt: new Date().toISOString(), 1515 }, 1516 }); 1517 1518 return c.json({ uri: result.data.uri, cid: result.data.cid }); 1519 } catch (error) { 1520 return handleRouteError(c, error, "Failed to update theme policy", { 1521 operation: "PUT /api/admin/theme-policy", 1522 logger: ctx.logger, 1523 }); 1524 } 1525 } 1526 ); 1527 1528 /** 1529 * GET /api/admin/modlog 1530 * 1531 * Paginated, reverse-chronological list of mod actions. 1532 * Joins users table twice: once for the moderator handle (via createdBy), 1533 * once for the subject handle (via subjectDid, nullable for post-targeting actions). 1534 * 1535 * Uses leftJoin for both users joins so actions are never dropped when a 1536 * moderator or subject DID has no indexed users row. moderatorHandle falls 1537 * back to moderatorDid in that case. 1538 * 1539 * Requires any of: moderatePosts, banUsers, lockTopics. 1540 */ 1541 app.get( 1542 "/modlog", 1543 requireAuth(ctx), 1544 requireAnyPermission(ctx, [ 1545 "space.atbb.permission.moderatePosts", 1546 "space.atbb.permission.banUsers", 1547 "space.atbb.permission.lockTopics", 1548 ]), 1549 async (c) => { 1550 const rawLimit = c.req.query("limit"); 1551 const rawOffset = c.req.query("offset"); 1552 1553 if (rawLimit !== undefined && (!/^\d+$/.test(rawLimit))) { 1554 return c.json({ error: "limit must be a positive integer" }, 400); 1555 } 1556 if (rawOffset !== undefined && (!/^\d+$/.test(rawOffset))) { 1557 return c.json({ error: "offset must be a non-negative integer" }, 400); 1558 } 1559 1560 const limitVal = rawLimit !== undefined ? parseInt(rawLimit, 10) : 50; 1561 const offsetVal = rawOffset !== undefined ? parseInt(rawOffset, 10) : 0; 1562 1563 if (rawLimit !== undefined && limitVal < 1) { 1564 return c.json({ error: "limit must be a positive integer" }, 400); 1565 } 1566 if (rawOffset !== undefined && offsetVal < 0) { 1567 return c.json({ error: "offset must be a non-negative integer" }, 400); 1568 } 1569 1570 const clampedLimit = Math.min(limitVal, 100); 1571 1572 try { 1573 const [countResult, actions] = await Promise.all([ 1574 ctx.db 1575 .select({ total: count() }) 1576 .from(modActions) 1577 .where(eq(modActions.did, ctx.config.forumDid)), 1578 ctx.db 1579 .select() 1580 .from(modActions) 1581 .where(eq(modActions.did, ctx.config.forumDid)) 1582 .orderBy(desc(modActions.createdAt)) 1583 .limit(clampedLimit) 1584 .offset(offsetVal), 1585 ]); 1586 1587 const total = Number(countResult[0]?.total ?? 0); 1588 1589 // Resolve handles in a single batch query instead of aliased self-joins 1590 // (drizzle-orm's alias() generates invalid SQL for SQLite) 1591 const dids = new Set<string>(); 1592 for (const a of actions) { 1593 if (a.createdBy) dids.add(a.createdBy); 1594 if (a.subjectDid) dids.add(a.subjectDid); 1595 } 1596 1597 const handleMap = new Map<string, string>(); 1598 if (dids.size > 0) { 1599 const userRows = await ctx.db 1600 .select({ did: users.did, handle: users.handle }) 1601 .from(users) 1602 .where(inArray(users.did, [...dids])); 1603 for (const u of userRows) { 1604 if (u.handle) handleMap.set(u.did, u.handle); 1605 } 1606 } 1607 1608 return c.json({ 1609 actions: actions.map((a) => ({ 1610 id: a.id.toString(), 1611 action: a.action, 1612 moderatorDid: a.createdBy, 1613 moderatorHandle: handleMap.get(a.createdBy) ?? a.createdBy, 1614 subjectDid: a.subjectDid ?? null, 1615 subjectHandle: a.subjectDid ? (handleMap.get(a.subjectDid) ?? null) : null, 1616 subjectPostUri: a.subjectPostUri ?? null, 1617 reason: a.reason ?? null, 1618 createdAt: a.createdAt.toISOString(), 1619 })), 1620 total, 1621 offset: offsetVal, 1622 limit: clampedLimit, 1623 }); 1624 } catch (error) { 1625 return handleRouteError(c, error, "Failed to retrieve mod action log", { 1626 operation: "GET /api/admin/modlog", 1627 logger: ctx.logger, 1628 }); 1629 } 1630 } 1631 ); 1632 1633 return app; 1634}