Barazo AppView backend barazo.forum
at main 1459 lines 47 kB view raw
1import { eq, and, desc, sql } from 'drizzle-orm' 2import { requireCommunityDid } from '../middleware/community-resolver.js' 3import type { FastifyPluginCallback } from 'fastify' 4import { 5 notFound, 6 forbidden, 7 badRequest, 8 conflict, 9 errorResponseSchema, 10} from '../lib/api-errors.js' 11import { 12 lockTopicSchema, 13 pinTopicSchema, 14 modDeleteSchema, 15 banUserSchema, 16 moderationLogQuerySchema, 17 createReportSchema, 18 reportQuerySchema, 19 resolveReportSchema, 20 reportedUsersQuerySchema, 21 moderationThresholdsSchema, 22 appealReportSchema, 23 myReportsQuerySchema, 24} from '../validation/moderation.js' 25import { topics } from '../db/schema/topics.js' 26import { replies } from '../db/schema/replies.js' 27import { users } from '../db/schema/users.js' 28import { moderationActions } from '../db/schema/moderation-actions.js' 29import { reports } from '../db/schema/reports.js' 30import { communitySettings } from '../db/schema/community-settings.js' 31import { notifications } from '../db/schema/notifications.js' 32import { communityFilters } from '../db/schema/community-filters.js' 33import { createRequireModerator } from '../auth/require-moderator.js' 34import { checkBanPropagation } from '../services/ban-propagation.js' 35import { createNotificationService } from '../services/notification.js' 36 37// --------------------------------------------------------------------------- 38// OpenAPI JSON Schema definitions 39// --------------------------------------------------------------------------- 40 41const moderationActionJsonSchema = { 42 type: 'object' as const, 43 properties: { 44 id: { type: 'number' as const }, 45 action: { type: 'string' as const }, 46 targetUri: { type: ['string', 'null'] as const }, 47 targetDid: { type: ['string', 'null'] as const }, 48 moderatorDid: { type: 'string' as const }, 49 reason: { type: ['string', 'null'] as const }, 50 createdAt: { type: 'string' as const, format: 'date-time' as const }, 51 }, 52} 53 54const reportJsonSchema = { 55 type: 'object' as const, 56 properties: { 57 id: { type: 'number' as const }, 58 reporterDid: { type: 'string' as const }, 59 targetUri: { type: 'string' as const }, 60 targetDid: { type: 'string' as const }, 61 reasonType: { type: 'string' as const }, 62 description: { type: ['string', 'null'] as const }, 63 status: { type: 'string' as const }, 64 resolutionType: { type: ['string', 'null'] as const }, 65 resolvedBy: { type: ['string', 'null'] as const }, 66 resolvedAt: { type: ['string', 'null'] as const }, 67 appealReason: { type: ['string', 'null'] as const }, 68 appealedAt: { type: ['string', 'null'] as const }, 69 appealStatus: { type: 'string' as const, enum: ['none', 'pending', 'rejected'] }, 70 createdAt: { type: 'string' as const, format: 'date-time' as const }, 71 }, 72} 73 74// --------------------------------------------------------------------------- 75// Helpers 76// --------------------------------------------------------------------------- 77 78function serializeAction(row: typeof moderationActions.$inferSelect) { 79 return { 80 id: row.id, 81 action: row.action, 82 targetUri: row.targetUri, 83 targetDid: row.targetDid, 84 moderatorDid: row.moderatorDid, 85 reason: row.reason, 86 createdAt: row.createdAt.toISOString(), 87 } 88} 89 90function serializeReport(row: typeof reports.$inferSelect) { 91 return { 92 id: row.id, 93 reporterDid: row.reporterDid, 94 targetUri: row.targetUri, 95 targetDid: row.targetDid, 96 reasonType: row.reasonType, 97 description: row.description, 98 status: row.status, 99 resolutionType: row.resolutionType, 100 resolvedBy: row.resolvedBy, 101 resolvedAt: row.resolvedAt?.toISOString() ?? null, 102 appealReason: row.appealReason ?? null, 103 appealedAt: row.appealedAt?.toISOString() ?? null, 104 appealStatus: row.appealStatus, 105 createdAt: row.createdAt.toISOString(), 106 } 107} 108 109function encodeCursor(createdAt: string, id: number): string { 110 return Buffer.from(JSON.stringify({ createdAt, id })).toString('base64') 111} 112 113function decodeCursor(cursor: string): { createdAt: string; id: number } | null { 114 try { 115 const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8')) as Record< 116 string, 117 unknown 118 > 119 if (typeof decoded.createdAt === 'string' && typeof decoded.id === 'number') { 120 return { createdAt: decoded.createdAt, id: decoded.id } 121 } 122 return null 123 } catch { 124 return null 125 } 126} 127 128/** 129 * Extract DID from an AT URI. 130 * Format: at://did:plc:xxx/collection/rkey -> did:plc:xxx 131 */ 132function extractDidFromUri(uri: string): string | undefined { 133 const match = /^at:\/\/(did:[^/]+)\//.exec(uri) 134 return match?.[1] 135} 136 137// --------------------------------------------------------------------------- 138// Moderation routes plugin 139// --------------------------------------------------------------------------- 140 141export function moderationRoutes(): FastifyPluginCallback { 142 return (app, _opts, done) => { 143 const { db, env, authMiddleware } = app 144 const requireModerator = createRequireModerator(db, authMiddleware, app.log) 145 const requireAdmin = app.requireAdmin 146 const notificationService = createNotificationService(db, app.log) 147 148 // ------------------------------------------------------------------- 149 // POST /api/moderation/lock/:id (moderator+) 150 // ------------------------------------------------------------------- 151 152 app.post( 153 '/api/moderation/lock/:id', 154 { 155 preHandler: [requireModerator], 156 schema: { 157 tags: ['Moderation'], 158 summary: 'Lock or unlock a topic', 159 security: [{ bearerAuth: [] }], 160 params: { 161 type: 'object', 162 required: ['id'], 163 properties: { id: { type: 'string' } }, 164 }, 165 body: { 166 type: 'object', 167 properties: { 168 reason: { type: 'string', maxLength: 500 }, 169 }, 170 }, 171 response: { 172 200: { 173 type: 'object', 174 properties: { 175 uri: { type: 'string' }, 176 isLocked: { type: 'boolean' }, 177 }, 178 }, 179 401: errorResponseSchema, 180 403: errorResponseSchema, 181 404: errorResponseSchema, 182 }, 183 }, 184 }, 185 async (request, reply) => { 186 const communityDid = requireCommunityDid(request) 187 const user = request.user 188 if (!user) { 189 return reply.status(401).send({ error: 'Authentication required' }) 190 } 191 192 const { id } = request.params as { id: string } 193 const decodedUri = decodeURIComponent(id) 194 const parsed = lockTopicSchema.safeParse(request.body) 195 196 const topicRows = await db 197 .select() 198 .from(topics) 199 .where(and(eq(topics.uri, decodedUri), eq(topics.communityDid, communityDid))) 200 201 const topic = topicRows[0] 202 if (!topic) { 203 throw notFound('Topic not found') 204 } 205 206 const newLocked = !topic.isLocked 207 const action = newLocked ? 'lock' : 'unlock' 208 209 await db.transaction(async (tx) => { 210 await tx.update(topics).set({ isLocked: newLocked }).where(eq(topics.uri, decodedUri)) 211 212 await tx.insert(moderationActions).values({ 213 action, 214 targetUri: decodedUri, 215 moderatorDid: user.did, 216 communityDid, 217 reason: parsed.success ? parsed.data.reason : undefined, 218 }) 219 }) 220 221 app.log.info({ action, topicUri: decodedUri, moderatorDid: user.did }, `Topic ${action}ed`) 222 223 // Fire-and-forget: notify topic author of lock/unlock 224 notificationService 225 .notifyOnModAction({ 226 targetUri: decodedUri, 227 moderatorDid: user.did, 228 targetDid: topic.authorDid, 229 communityDid, 230 }) 231 .catch((err: unknown) => { 232 app.log.error({ err, topicUri: decodedUri }, 'Mod action notification failed') 233 }) 234 235 return reply.status(200).send({ 236 uri: decodedUri, 237 isLocked: newLocked, 238 }) 239 } 240 ) 241 242 // ------------------------------------------------------------------- 243 // POST /api/moderation/pin/:id (moderator+) 244 // ------------------------------------------------------------------- 245 246 app.post( 247 '/api/moderation/pin/:id', 248 { 249 preHandler: [requireModerator], 250 schema: { 251 tags: ['Moderation'], 252 summary: 'Pin or unpin a topic', 253 security: [{ bearerAuth: [] }], 254 params: { 255 type: 'object', 256 required: ['id'], 257 properties: { id: { type: 'string' } }, 258 }, 259 body: { 260 type: 'object', 261 properties: { 262 reason: { type: 'string', maxLength: 500 }, 263 scope: { type: 'string', enum: ['category', 'forum'] }, 264 }, 265 }, 266 response: { 267 200: { 268 type: 'object', 269 properties: { 270 uri: { type: 'string' }, 271 isPinned: { type: 'boolean' }, 272 pinnedScope: { type: 'string', nullable: true }, 273 pinnedAt: { type: 'string', nullable: true }, 274 }, 275 }, 276 401: errorResponseSchema, 277 403: errorResponseSchema, 278 404: errorResponseSchema, 279 }, 280 }, 281 }, 282 async (request, reply) => { 283 const communityDid = requireCommunityDid(request) 284 const user = request.user 285 if (!user) { 286 return reply.status(401).send({ error: 'Authentication required' }) 287 } 288 289 const { id } = request.params as { id: string } 290 const decodedUri = decodeURIComponent(id) 291 const parsed = pinTopicSchema.safeParse(request.body) 292 const scope = parsed.success ? parsed.data.scope : 'category' 293 294 const topicRows = await db 295 .select() 296 .from(topics) 297 .where(and(eq(topics.uri, decodedUri), eq(topics.communityDid, communityDid))) 298 299 const topic = topicRows[0] 300 if (!topic) { 301 throw notFound('Topic not found') 302 } 303 304 const newPinned = !topic.isPinned 305 const action = newPinned ? 'pin' : 'unpin' 306 307 // Forum-wide pins require admin role 308 if (newPinned && scope === 'forum') { 309 const userRows = await db.select().from(users).where(eq(users.did, user.did)) 310 const userRecord = userRows[0] 311 if (!userRecord || userRecord.role !== 'admin') { 312 throw forbidden('Forum-wide pins require admin privileges') 313 } 314 } 315 316 const pinnedAt = newPinned ? new Date() : null 317 const pinnedScope = newPinned ? scope : null 318 319 await db.transaction(async (tx) => { 320 await tx 321 .update(topics) 322 .set({ isPinned: newPinned, pinnedAt, pinnedScope }) 323 .where(eq(topics.uri, decodedUri)) 324 325 await tx.insert(moderationActions).values({ 326 action, 327 targetUri: decodedUri, 328 moderatorDid: user.did, 329 communityDid, 330 reason: parsed.success ? parsed.data.reason : undefined, 331 }) 332 }) 333 334 app.log.info( 335 { action, topicUri: decodedUri, moderatorDid: user.did, pinnedScope }, 336 `Topic ${action}ned` 337 ) 338 339 // Fire-and-forget: notify topic author of pin/unpin 340 notificationService 341 .notifyOnModAction({ 342 targetUri: decodedUri, 343 moderatorDid: user.did, 344 targetDid: topic.authorDid, 345 communityDid, 346 }) 347 .catch((err: unknown) => { 348 app.log.error({ err, topicUri: decodedUri }, 'Mod action notification failed') 349 }) 350 351 return reply.status(200).send({ 352 uri: decodedUri, 353 isPinned: newPinned, 354 pinnedScope, 355 pinnedAt: pinnedAt?.toISOString() ?? null, 356 }) 357 } 358 ) 359 360 // ------------------------------------------------------------------- 361 // POST /api/moderation/delete/:id (moderator+) 362 // ------------------------------------------------------------------- 363 364 app.post( 365 '/api/moderation/delete/:id', 366 { 367 preHandler: [requireModerator], 368 schema: { 369 tags: ['Moderation'], 370 summary: 'Mod-delete content (marks as deleted in index, does NOT delete from PDS)', 371 security: [{ bearerAuth: [] }], 372 params: { 373 type: 'object', 374 required: ['id'], 375 properties: { id: { type: 'string' } }, 376 }, 377 body: { 378 type: 'object', 379 required: ['reason'], 380 properties: { 381 reason: { type: 'string', minLength: 1, maxLength: 500 }, 382 }, 383 }, 384 response: { 385 200: { 386 type: 'object', 387 properties: { 388 uri: { type: 'string' }, 389 isModDeleted: { type: 'boolean' }, 390 }, 391 }, 392 400: errorResponseSchema, 393 401: errorResponseSchema, 394 403: errorResponseSchema, 395 404: errorResponseSchema, 396 409: errorResponseSchema, 397 }, 398 }, 399 }, 400 async (request, reply) => { 401 const communityDid = requireCommunityDid(request) 402 const user = request.user 403 if (!user) { 404 return reply.status(401).send({ error: 'Authentication required' }) 405 } 406 407 const { id } = request.params as { id: string } 408 const decodedUri = decodeURIComponent(id) 409 const parsed = modDeleteSchema.safeParse(request.body) 410 if (!parsed.success) { 411 throw badRequest('Reason is required for mod-delete') 412 } 413 414 // Check if this is a topic or reply 415 const topicRows = await db 416 .select() 417 .from(topics) 418 .where(and(eq(topics.uri, decodedUri), eq(topics.communityDid, communityDid))) 419 420 const topic = topicRows[0] 421 422 if (topic) { 423 if (topic.isModDeleted) { 424 throw conflict('Content already mod-deleted') 425 } 426 427 await db.transaction(async (tx) => { 428 await tx.update(topics).set({ isModDeleted: true }).where(eq(topics.uri, decodedUri)) 429 430 await tx.insert(moderationActions).values({ 431 action: 'delete', 432 targetUri: decodedUri, 433 targetDid: topic.authorDid, 434 moderatorDid: user.did, 435 communityDid, 436 reason: parsed.data.reason, 437 }) 438 }) 439 440 app.log.info( 441 { action: 'delete', topicUri: decodedUri, moderatorDid: user.did }, 442 'Topic mod-deleted' 443 ) 444 445 // Fire-and-forget: notify topic author of deletion 446 notificationService 447 .notifyOnModAction({ 448 targetUri: decodedUri, 449 moderatorDid: user.did, 450 targetDid: topic.authorDid, 451 communityDid, 452 }) 453 .catch((err: unknown) => { 454 app.log.error({ err, topicUri: decodedUri }, 'Mod action notification failed') 455 }) 456 457 return reply.status(200).send({ 458 uri: decodedUri, 459 isModDeleted: true, 460 }) 461 } 462 463 // Not a topic -- check replies 464 const replyRows = await db 465 .select() 466 .from(replies) 467 .where(and(eq(replies.uri, decodedUri), eq(replies.communityDid, communityDid))) 468 469 const replyRow = replyRows[0] 470 if (!replyRow) { 471 throw notFound('Content not found') 472 } 473 474 if (replyRow.isModDeleted) { 475 throw conflict('Content already mod-deleted') 476 } 477 478 await db.transaction(async (tx) => { 479 await tx.update(replies).set({ isModDeleted: true }).where(eq(replies.uri, decodedUri)) 480 481 // Decrement reply count on parent topic 482 await tx 483 .update(topics) 484 .set({ replyCount: sql`GREATEST(${topics.replyCount} - 1, 0)` }) 485 .where(eq(topics.uri, replyRow.rootUri)) 486 487 await tx.insert(moderationActions).values({ 488 action: 'delete', 489 targetUri: decodedUri, 490 targetDid: replyRow.authorDid, 491 moderatorDid: user.did, 492 communityDid, 493 reason: parsed.data.reason, 494 }) 495 }) 496 497 app.log.info( 498 { action: 'delete', replyUri: decodedUri, moderatorDid: user.did }, 499 'Reply mod-deleted' 500 ) 501 502 // Fire-and-forget: notify reply author of deletion 503 notificationService 504 .notifyOnModAction({ 505 targetUri: decodedUri, 506 moderatorDid: user.did, 507 targetDid: replyRow.authorDid, 508 communityDid, 509 }) 510 .catch((err: unknown) => { 511 app.log.error({ err, replyUri: decodedUri }, 'Mod action notification failed') 512 }) 513 514 return reply.status(200).send({ 515 uri: decodedUri, 516 isModDeleted: true, 517 }) 518 } 519 ) 520 521 // ------------------------------------------------------------------- 522 // POST /api/moderation/ban (admin only) 523 // ------------------------------------------------------------------- 524 525 app.post( 526 '/api/moderation/ban', 527 { 528 preHandler: [requireAdmin], 529 schema: { 530 tags: ['Moderation'], 531 summary: 'Ban or unban a user by DID', 532 security: [{ bearerAuth: [] }], 533 body: { 534 type: 'object', 535 required: ['did', 'reason'], 536 properties: { 537 did: { type: 'string', minLength: 1 }, 538 reason: { type: 'string', minLength: 1, maxLength: 500 }, 539 }, 540 }, 541 response: { 542 200: { 543 type: 'object', 544 properties: { 545 did: { type: 'string' }, 546 isBanned: { type: 'boolean' }, 547 }, 548 }, 549 400: errorResponseSchema, 550 401: errorResponseSchema, 551 403: errorResponseSchema, 552 404: errorResponseSchema, 553 }, 554 }, 555 }, 556 async (request, reply) => { 557 const communityDid = requireCommunityDid(request) 558 const admin = request.user 559 if (!admin) { 560 return reply.status(401).send({ error: 'Authentication required' }) 561 } 562 563 const parsed = banUserSchema.safeParse(request.body) 564 if (!parsed.success) { 565 throw badRequest('DID and reason are required') 566 } 567 568 const { did: targetDid, reason } = parsed.data 569 570 // Prevent self-ban 571 if (targetDid === admin.did) { 572 throw badRequest('Cannot ban yourself') 573 } 574 575 // Check user exists 576 const userRows = await db.select().from(users).where(eq(users.did, targetDid)) 577 578 const targetUser = userRows[0] 579 if (!targetUser) { 580 throw notFound('User not found') 581 } 582 583 // Prevent banning other admins 584 if (targetUser.role === 'admin') { 585 throw forbidden('Cannot ban an admin') 586 } 587 588 const newBanned = !targetUser.isBanned 589 const action = newBanned ? 'ban' : 'unban' 590 591 await db.transaction(async (tx) => { 592 await tx.update(users).set({ isBanned: newBanned }).where(eq(users.did, targetDid)) 593 594 await tx.insert(moderationActions).values({ 595 action, 596 targetDid, 597 moderatorDid: admin.did, 598 communityDid, 599 reason, 600 }) 601 }) 602 603 app.log.info({ action, targetDid, adminDid: admin.did }, `User ${action}ned`) 604 605 // In multi mode, check ban propagation across communities 606 if (env.COMMUNITY_MODE === 'multi' && action === 'ban') { 607 try { 608 const result = await checkBanPropagation(db, app.cache, app.log, targetDid) 609 if (result.propagated) { 610 app.log.info( 611 { targetDid, banCount: result.banCount }, 612 'Ban propagation triggered global account filter' 613 ) 614 } 615 } catch (err) { 616 app.log.warn({ err, targetDid }, 'Ban propagation check failed (non-critical)') 617 } 618 } 619 620 // Fire-and-forget: notify banned/unbanned user 621 // Use targetDid as the targetUri since bans are user-level, not content-level 622 notificationService 623 .notifyOnModAction({ 624 targetUri: `at://${targetDid}`, 625 moderatorDid: admin.did, 626 targetDid, 627 communityDid, 628 }) 629 .catch((err: unknown) => { 630 app.log.error({ err, targetDid }, 'Mod action notification failed') 631 }) 632 633 return reply.status(200).send({ 634 did: targetDid, 635 isBanned: newBanned, 636 }) 637 } 638 ) 639 640 // ------------------------------------------------------------------- 641 // GET /api/moderation/log (moderator+) 642 // ------------------------------------------------------------------- 643 644 app.get( 645 '/api/moderation/log', 646 { 647 preHandler: [requireModerator], 648 schema: { 649 tags: ['Moderation'], 650 summary: 'Get moderation action log (paginated)', 651 security: [{ bearerAuth: [] }], 652 querystring: { 653 type: 'object', 654 properties: { 655 cursor: { type: 'string' }, 656 limit: { type: 'string' }, 657 action: { 658 type: 'string', 659 enum: ['lock', 'unlock', 'pin', 'unpin', 'delete', 'ban', 'unban'], 660 }, 661 }, 662 }, 663 response: { 664 200: { 665 type: 'object', 666 properties: { 667 actions: { type: 'array', items: moderationActionJsonSchema }, 668 cursor: { type: ['string', 'null'] }, 669 }, 670 }, 671 400: errorResponseSchema, 672 }, 673 }, 674 }, 675 async (request, reply) => { 676 const communityDid = requireCommunityDid(request) 677 const parsed = moderationLogQuerySchema.safeParse(request.query) 678 if (!parsed.success) { 679 throw badRequest('Invalid query parameters') 680 } 681 682 const { cursor, limit, action } = parsed.data 683 const conditions = [eq(moderationActions.communityDid, communityDid)] 684 685 if (action) { 686 conditions.push(eq(moderationActions.action, action)) 687 } 688 689 if (cursor) { 690 const decoded = decodeCursor(cursor) 691 if (decoded) { 692 conditions.push( 693 sql`(${moderationActions.createdAt}, ${moderationActions.id}) < (${decoded.createdAt}::timestamptz, ${decoded.id})` 694 ) 695 } 696 } 697 698 const whereClause = and(...conditions) 699 const fetchLimit = limit + 1 700 701 const rows = await db 702 .select() 703 .from(moderationActions) 704 .where(whereClause) 705 .orderBy(desc(moderationActions.createdAt)) 706 .limit(fetchLimit) 707 708 const hasMore = rows.length > limit 709 const resultRows = hasMore ? rows.slice(0, limit) : rows 710 711 let nextCursor: string | null = null 712 if (hasMore) { 713 const lastRow = resultRows[resultRows.length - 1] 714 if (lastRow) { 715 nextCursor = encodeCursor(lastRow.createdAt.toISOString(), lastRow.id) 716 } 717 } 718 719 return reply.status(200).send({ 720 actions: resultRows.map(serializeAction), 721 cursor: nextCursor, 722 }) 723 } 724 ) 725 726 // ------------------------------------------------------------------- 727 // POST /api/moderation/report (authenticated user) 728 // ------------------------------------------------------------------- 729 730 app.post( 731 '/api/moderation/report', 732 { 733 preHandler: [authMiddleware.requireAuth], 734 schema: { 735 tags: ['Moderation'], 736 summary: 'Report content for moderator review', 737 security: [{ bearerAuth: [] }], 738 body: { 739 type: 'object', 740 required: ['targetUri', 'reasonType'], 741 properties: { 742 targetUri: { type: 'string', minLength: 1 }, 743 reasonType: { 744 type: 'string', 745 enum: ['spam', 'sexual', 'harassment', 'violation', 'misleading', 'other'], 746 }, 747 description: { type: 'string', maxLength: 1000 }, 748 }, 749 }, 750 response: { 751 201: reportJsonSchema, 752 400: errorResponseSchema, 753 401: errorResponseSchema, 754 404: errorResponseSchema, 755 409: errorResponseSchema, 756 }, 757 }, 758 }, 759 async (request, reply) => { 760 const communityDid = requireCommunityDid(request) 761 const user = request.user 762 if (!user) { 763 return reply.status(401).send({ error: 'Authentication required' }) 764 } 765 766 const parsed = createReportSchema.safeParse(request.body) 767 if (!parsed.success) { 768 throw badRequest('Invalid report data') 769 } 770 771 const { targetUri, reasonType, description } = parsed.data 772 773 // Extract target DID from URI 774 const targetDid = extractDidFromUri(targetUri) 775 if (!targetDid) { 776 throw badRequest('Invalid target URI format') 777 } 778 779 // Cannot report own content 780 if (targetDid === user.did) { 781 throw badRequest('Cannot report your own content') 782 } 783 784 // Verify target content exists (topic or reply) 785 const topicRows = await db 786 .select({ uri: topics.uri }) 787 .from(topics) 788 .where(and(eq(topics.uri, targetUri), eq(topics.communityDid, communityDid))) 789 790 let contentExists = topicRows.length > 0 791 792 if (!contentExists) { 793 const replyRows = await db 794 .select({ uri: replies.uri }) 795 .from(replies) 796 .where(and(eq(replies.uri, targetUri), eq(replies.communityDid, communityDid))) 797 contentExists = replyRows.length > 0 798 } 799 800 if (!contentExists) { 801 throw notFound('Content not found') 802 } 803 804 // Check for duplicate report 805 const existingReports = await db 806 .select({ id: reports.id }) 807 .from(reports) 808 .where( 809 and( 810 eq(reports.reporterDid, user.did), 811 eq(reports.targetUri, targetUri), 812 eq(reports.communityDid, communityDid) 813 ) 814 ) 815 816 if (existingReports.length > 0) { 817 throw conflict('You have already reported this content') 818 } 819 820 const inserted = await db 821 .insert(reports) 822 .values({ 823 reporterDid: user.did, 824 targetUri, 825 targetDid, 826 reasonType, 827 description, 828 communityDid, 829 }) 830 .returning() 831 832 const report = inserted[0] 833 if (!report) { 834 throw badRequest('Failed to create report') 835 } 836 837 app.log.info( 838 { reportId: report.id, reporterDid: user.did, targetUri, reasonType }, 839 'Content reported' 840 ) 841 842 // In multi mode, notify the community admin about the report 843 if (env.COMMUNITY_MODE === 'multi') { 844 try { 845 const filterRows = await db 846 .select({ adminDid: communityFilters.adminDid }) 847 .from(communityFilters) 848 .where(eq(communityFilters.communityDid, communityDid)) 849 850 const adminDid = filterRows[0]?.adminDid 851 if (adminDid) { 852 await db.insert(notifications).values({ 853 recipientDid: adminDid, 854 type: 'global_report', 855 subjectUri: targetUri, 856 actorDid: user.did, 857 communityDid, 858 }) 859 } 860 } catch (err) { 861 app.log.warn( 862 { err, communityDid }, 863 'Failed to send global report notification (non-critical)' 864 ) 865 } 866 } 867 868 return reply.status(201).send(serializeReport(report)) 869 } 870 ) 871 872 // ------------------------------------------------------------------- 873 // GET /api/moderation/reports (moderator+) 874 // ------------------------------------------------------------------- 875 876 app.get( 877 '/api/moderation/reports', 878 { 879 preHandler: [requireModerator], 880 schema: { 881 tags: ['Moderation'], 882 summary: 'List content reports (paginated)', 883 security: [{ bearerAuth: [] }], 884 querystring: { 885 type: 'object', 886 properties: { 887 status: { type: 'string', enum: ['pending', 'resolved'] }, 888 cursor: { type: 'string' }, 889 limit: { type: 'string' }, 890 }, 891 }, 892 response: { 893 200: { 894 type: 'object', 895 properties: { 896 reports: { type: 'array', items: reportJsonSchema }, 897 cursor: { type: ['string', 'null'] }, 898 }, 899 }, 900 400: errorResponseSchema, 901 }, 902 }, 903 }, 904 async (request, reply) => { 905 const communityDid = requireCommunityDid(request) 906 const parsed = reportQuerySchema.safeParse(request.query) 907 if (!parsed.success) { 908 throw badRequest('Invalid query parameters') 909 } 910 911 const { status, cursor, limit } = parsed.data 912 const conditions = [eq(reports.communityDid, communityDid)] 913 914 if (status) { 915 conditions.push(eq(reports.status, status)) 916 } 917 918 if (cursor) { 919 const decoded = decodeCursor(cursor) 920 if (decoded) { 921 conditions.push( 922 sql`(${reports.createdAt}, ${reports.id}) < (${decoded.createdAt}::timestamptz, ${decoded.id})` 923 ) 924 } 925 } 926 927 const whereClause = and(...conditions) 928 const fetchLimit = limit + 1 929 930 const rows = await db 931 .select() 932 .from(reports) 933 .where(whereClause) 934 .orderBy(desc(reports.createdAt)) 935 .limit(fetchLimit) 936 937 const hasMore = rows.length > limit 938 const resultRows = hasMore ? rows.slice(0, limit) : rows 939 940 let nextCursor: string | null = null 941 if (hasMore) { 942 const lastRow = resultRows[resultRows.length - 1] 943 if (lastRow) { 944 nextCursor = encodeCursor(lastRow.createdAt.toISOString(), lastRow.id) 945 } 946 } 947 948 return reply.status(200).send({ 949 reports: resultRows.map(serializeReport), 950 cursor: nextCursor, 951 }) 952 } 953 ) 954 955 // ------------------------------------------------------------------- 956 // PUT /api/moderation/reports/:id (moderator+) 957 // ------------------------------------------------------------------- 958 959 app.put( 960 '/api/moderation/reports/:id', 961 { 962 preHandler: [requireModerator], 963 schema: { 964 tags: ['Moderation'], 965 summary: 'Resolve a content report', 966 security: [{ bearerAuth: [] }], 967 params: { 968 type: 'object', 969 required: ['id'], 970 properties: { id: { type: 'string' } }, 971 }, 972 body: { 973 type: 'object', 974 required: ['resolutionType'], 975 properties: { 976 resolutionType: { 977 type: 'string', 978 enum: ['dismissed', 'warned', 'labeled', 'removed', 'banned'], 979 }, 980 }, 981 }, 982 response: { 983 200: reportJsonSchema, 984 400: errorResponseSchema, 985 401: errorResponseSchema, 986 404: errorResponseSchema, 987 409: errorResponseSchema, 988 }, 989 }, 990 }, 991 async (request, reply) => { 992 const communityDid = requireCommunityDid(request) 993 const user = request.user 994 if (!user) { 995 return reply.status(401).send({ error: 'Authentication required' }) 996 } 997 998 const { id } = request.params as { id: string } 999 const reportId = Number(id) 1000 if (Number.isNaN(reportId)) { 1001 throw badRequest('Invalid report ID') 1002 } 1003 1004 const parsed = resolveReportSchema.safeParse(request.body) 1005 if (!parsed.success) { 1006 throw badRequest('Invalid resolution data') 1007 } 1008 1009 const existing = await db 1010 .select() 1011 .from(reports) 1012 .where(and(eq(reports.id, reportId), eq(reports.communityDid, communityDid))) 1013 1014 const report = existing[0] 1015 if (!report) { 1016 throw notFound('Report not found') 1017 } 1018 1019 if (report.status === 'resolved') { 1020 throw conflict('Report already resolved') 1021 } 1022 1023 const updated = await db 1024 .update(reports) 1025 .set({ 1026 status: 'resolved', 1027 resolutionType: parsed.data.resolutionType, 1028 resolvedBy: user.did, 1029 resolvedAt: new Date(), 1030 }) 1031 .where(eq(reports.id, reportId)) 1032 .returning() 1033 1034 const resolvedReport = updated[0] 1035 if (!resolvedReport) { 1036 throw notFound('Report not found after update') 1037 } 1038 1039 app.log.info( 1040 { 1041 reportId, 1042 resolutionType: parsed.data.resolutionType, 1043 resolvedBy: user.did, 1044 }, 1045 'Report resolved' 1046 ) 1047 1048 return reply.status(200).send(serializeReport(resolvedReport)) 1049 } 1050 ) 1051 1052 // ------------------------------------------------------------------- 1053 // GET /api/admin/reports/users (admin only) 1054 // ------------------------------------------------------------------- 1055 1056 app.get( 1057 '/api/admin/reports/users', 1058 { 1059 preHandler: [requireAdmin], 1060 schema: { 1061 tags: ['Admin'], 1062 summary: 'Most-reported users in this community', 1063 security: [{ bearerAuth: [] }], 1064 querystring: { 1065 type: 'object', 1066 properties: { 1067 limit: { type: 'string' }, 1068 }, 1069 }, 1070 response: { 1071 200: { 1072 type: 'object', 1073 properties: { 1074 users: { 1075 type: 'array', 1076 items: { 1077 type: 'object', 1078 properties: { 1079 did: { type: 'string' }, 1080 reportCount: { type: 'number' }, 1081 }, 1082 }, 1083 }, 1084 }, 1085 }, 1086 }, 1087 }, 1088 }, 1089 async (request, reply) => { 1090 const communityDid = requireCommunityDid(request) 1091 const parsed = reportedUsersQuerySchema.safeParse(request.query) 1092 const limit = parsed.success ? parsed.data.limit : 25 1093 1094 const rows = await db 1095 .select({ 1096 did: reports.targetDid, 1097 reportCount: sql<number>`count(*)::int`, 1098 }) 1099 .from(reports) 1100 .where(eq(reports.communityDid, communityDid)) 1101 .groupBy(reports.targetDid) 1102 .orderBy(sql`count(*) DESC`) 1103 .limit(limit) 1104 1105 return reply.status(200).send({ 1106 users: rows.map((r) => ({ did: r.did, reportCount: r.reportCount })), 1107 }) 1108 } 1109 ) 1110 1111 // ------------------------------------------------------------------- 1112 // GET /api/admin/moderation/thresholds (admin only) 1113 // ------------------------------------------------------------------- 1114 1115 app.get( 1116 '/api/admin/moderation/thresholds', 1117 { 1118 preHandler: [requireAdmin], 1119 schema: { 1120 tags: ['Admin'], 1121 summary: 'Get moderation thresholds for this community', 1122 security: [{ bearerAuth: [] }], 1123 response: { 1124 200: { 1125 type: 'object', 1126 properties: { 1127 autoBlockReportCount: { type: 'number' }, 1128 warnThreshold: { type: 'number' }, 1129 firstPostQueueCount: { type: 'number' }, 1130 newAccountDays: { type: 'number' }, 1131 newAccountWriteRatePerMin: { type: 'number' }, 1132 establishedWriteRatePerMin: { type: 'number' }, 1133 linkHoldEnabled: { type: 'boolean' }, 1134 topicCreationDelayEnabled: { type: 'boolean' }, 1135 burstPostCount: { type: 'number' }, 1136 burstWindowMinutes: { type: 'number' }, 1137 trustedPostThreshold: { type: 'number' }, 1138 }, 1139 }, 1140 }, 1141 }, 1142 }, 1143 async (request, reply) => { 1144 const communityDid = requireCommunityDid(request) 1145 const settingsRows = await db 1146 .select({ moderationThresholds: communitySettings.moderationThresholds }) 1147 .from(communitySettings) 1148 .where(eq(communitySettings.communityDid, communityDid)) 1149 1150 const settings = settingsRows[0] 1151 const t = settings?.moderationThresholds 1152 1153 return reply.status(200).send({ 1154 autoBlockReportCount: t?.autoBlockReportCount ?? 5, 1155 warnThreshold: t?.warnThreshold ?? 3, 1156 firstPostQueueCount: t?.firstPostQueueCount ?? 0, 1157 newAccountDays: t?.newAccountDays ?? 7, 1158 newAccountWriteRatePerMin: t?.newAccountWriteRatePerMin ?? 3, 1159 establishedWriteRatePerMin: t?.establishedWriteRatePerMin ?? 10, 1160 linkHoldEnabled: t?.linkHoldEnabled ?? false, 1161 topicCreationDelayEnabled: t?.topicCreationDelayEnabled ?? false, 1162 burstPostCount: t?.burstPostCount ?? 5, 1163 burstWindowMinutes: t?.burstWindowMinutes ?? 10, 1164 trustedPostThreshold: t?.trustedPostThreshold ?? 10, 1165 }) 1166 } 1167 ) 1168 1169 // ------------------------------------------------------------------- 1170 // PUT /api/admin/moderation/thresholds (admin only) 1171 // ------------------------------------------------------------------- 1172 1173 app.put( 1174 '/api/admin/moderation/thresholds', 1175 { 1176 preHandler: [requireAdmin], 1177 schema: { 1178 tags: ['Admin'], 1179 summary: 'Update moderation thresholds', 1180 security: [{ bearerAuth: [] }], 1181 body: { 1182 type: 'object', 1183 properties: { 1184 autoBlockReportCount: { type: 'number', minimum: 1, maximum: 100 }, 1185 warnThreshold: { type: 'number', minimum: 1, maximum: 50 }, 1186 firstPostQueueCount: { type: 'number', minimum: 0, maximum: 50 }, 1187 newAccountDays: { type: 'number', minimum: 0, maximum: 90 }, 1188 newAccountWriteRatePerMin: { type: 'number', minimum: 1, maximum: 30 }, 1189 establishedWriteRatePerMin: { type: 'number', minimum: 1, maximum: 100 }, 1190 linkHoldEnabled: { type: 'boolean' }, 1191 topicCreationDelayEnabled: { type: 'boolean' }, 1192 burstPostCount: { type: 'number', minimum: 2, maximum: 50 }, 1193 burstWindowMinutes: { type: 'number', minimum: 1, maximum: 60 }, 1194 trustedPostThreshold: { type: 'number', minimum: 1, maximum: 100 }, 1195 }, 1196 }, 1197 response: { 1198 200: { 1199 type: 'object', 1200 properties: { 1201 autoBlockReportCount: { type: 'number' }, 1202 warnThreshold: { type: 'number' }, 1203 firstPostQueueCount: { type: 'number' }, 1204 newAccountDays: { type: 'number' }, 1205 newAccountWriteRatePerMin: { type: 'number' }, 1206 establishedWriteRatePerMin: { type: 'number' }, 1207 linkHoldEnabled: { type: 'boolean' }, 1208 topicCreationDelayEnabled: { type: 'boolean' }, 1209 burstPostCount: { type: 'number' }, 1210 burstWindowMinutes: { type: 'number' }, 1211 trustedPostThreshold: { type: 'number' }, 1212 }, 1213 }, 1214 400: errorResponseSchema, 1215 }, 1216 }, 1217 }, 1218 async (request, reply) => { 1219 const communityDid = requireCommunityDid(request) 1220 const parsed = moderationThresholdsSchema.safeParse(request.body) 1221 if (!parsed.success) { 1222 throw badRequest('Invalid threshold values') 1223 } 1224 1225 // Load existing thresholds, merge with partial update 1226 const existingRows = await db 1227 .select({ moderationThresholds: communitySettings.moderationThresholds }) 1228 .from(communitySettings) 1229 .where(eq(communitySettings.communityDid, communityDid)) 1230 1231 const existing = existingRows[0]?.moderationThresholds ?? { 1232 autoBlockReportCount: 5, 1233 warnThreshold: 3, 1234 firstPostQueueCount: 0, 1235 newAccountDays: 7, 1236 newAccountWriteRatePerMin: 3, 1237 establishedWriteRatePerMin: 10, 1238 linkHoldEnabled: false, 1239 topicCreationDelayEnabled: false, 1240 burstPostCount: 5, 1241 burstWindowMinutes: 10, 1242 trustedPostThreshold: 10, 1243 } 1244 1245 // Filter out undefined values from the partial update 1246 const definedUpdates: Record<string, unknown> = {} 1247 for (const [key, value] of Object.entries(parsed.data)) { 1248 if (value !== undefined) { 1249 definedUpdates[key] = value 1250 } 1251 } 1252 const merged = { ...existing, ...definedUpdates } as typeof existing 1253 1254 await db 1255 .update(communitySettings) 1256 .set({ moderationThresholds: merged }) 1257 .where(eq(communitySettings.communityDid, communityDid)) 1258 1259 // Invalidate cached anti-spam settings 1260 try { 1261 await app.cache.del(`antispam:settings:${communityDid}`) 1262 } catch { 1263 // Non-critical 1264 } 1265 1266 return reply.status(200).send(merged) 1267 } 1268 ) 1269 1270 // ------------------------------------------------------------------- 1271 // GET /api/moderation/my-reports (authenticated user) 1272 // ------------------------------------------------------------------- 1273 1274 app.get( 1275 '/api/moderation/my-reports', 1276 { 1277 preHandler: [authMiddleware.requireAuth], 1278 schema: { 1279 tags: ['Moderation'], 1280 summary: 'List reports submitted by the authenticated user (paginated)', 1281 security: [{ bearerAuth: [] }], 1282 querystring: { 1283 type: 'object', 1284 properties: { 1285 cursor: { type: 'string' }, 1286 limit: { type: 'string' }, 1287 }, 1288 }, 1289 response: { 1290 200: { 1291 type: 'object', 1292 properties: { 1293 reports: { type: 'array', items: reportJsonSchema }, 1294 cursor: { type: ['string', 'null'] }, 1295 }, 1296 }, 1297 400: errorResponseSchema, 1298 401: errorResponseSchema, 1299 }, 1300 }, 1301 }, 1302 async (request, reply) => { 1303 const communityDid = requireCommunityDid(request) 1304 const user = request.user 1305 if (!user) { 1306 return reply.status(401).send({ error: 'Authentication required' }) 1307 } 1308 1309 const parsed = myReportsQuerySchema.safeParse(request.query) 1310 if (!parsed.success) { 1311 throw badRequest('Invalid query parameters') 1312 } 1313 1314 const { cursor, limit } = parsed.data 1315 const conditions = [ 1316 eq(reports.reporterDid, user.did), 1317 eq(reports.communityDid, communityDid), 1318 ] 1319 1320 if (cursor) { 1321 const decoded = decodeCursor(cursor) 1322 if (decoded) { 1323 conditions.push( 1324 sql`(${reports.createdAt}, ${reports.id}) < (${decoded.createdAt}::timestamptz, ${decoded.id})` 1325 ) 1326 } 1327 } 1328 1329 const whereClause = and(...conditions) 1330 const fetchLimit = limit + 1 1331 1332 const rows = await db 1333 .select() 1334 .from(reports) 1335 .where(whereClause) 1336 .orderBy(desc(reports.createdAt)) 1337 .limit(fetchLimit) 1338 1339 const hasMore = rows.length > limit 1340 const resultRows = hasMore ? rows.slice(0, limit) : rows 1341 1342 let nextCursor: string | null = null 1343 if (hasMore) { 1344 const lastRow = resultRows[resultRows.length - 1] 1345 if (lastRow) { 1346 nextCursor = encodeCursor(lastRow.createdAt.toISOString(), lastRow.id) 1347 } 1348 } 1349 1350 return reply.status(200).send({ 1351 reports: resultRows.map(serializeReport), 1352 cursor: nextCursor, 1353 }) 1354 } 1355 ) 1356 1357 // ------------------------------------------------------------------- 1358 // POST /api/moderation/reports/:id/appeal (authenticated user) 1359 // ------------------------------------------------------------------- 1360 1361 app.post( 1362 '/api/moderation/reports/:id/appeal', 1363 { 1364 preHandler: [authMiddleware.requireAuth], 1365 schema: { 1366 tags: ['Moderation'], 1367 summary: 'Appeal a dismissed report', 1368 security: [{ bearerAuth: [] }], 1369 params: { 1370 type: 'object', 1371 required: ['id'], 1372 properties: { id: { type: 'string' } }, 1373 }, 1374 body: { 1375 type: 'object', 1376 required: ['reason'], 1377 properties: { 1378 reason: { type: 'string', minLength: 1, maxLength: 1000 }, 1379 }, 1380 }, 1381 response: { 1382 200: reportJsonSchema, 1383 400: errorResponseSchema, 1384 401: errorResponseSchema, 1385 403: errorResponseSchema, 1386 404: errorResponseSchema, 1387 409: errorResponseSchema, 1388 }, 1389 }, 1390 }, 1391 async (request, reply) => { 1392 const communityDid = requireCommunityDid(request) 1393 const user = request.user 1394 if (!user) { 1395 return reply.status(401).send({ error: 'Authentication required' }) 1396 } 1397 1398 const { id } = request.params as { id: string } 1399 const reportId = Number(id) 1400 if (Number.isNaN(reportId)) { 1401 throw badRequest('Invalid report ID') 1402 } 1403 1404 const parsed = appealReportSchema.safeParse(request.body) 1405 if (!parsed.success) { 1406 throw badRequest('Invalid appeal data') 1407 } 1408 1409 const existing = await db 1410 .select() 1411 .from(reports) 1412 .where(and(eq(reports.id, reportId), eq(reports.communityDid, communityDid))) 1413 1414 const report = existing[0] 1415 if (!report) { 1416 throw notFound('Report not found') 1417 } 1418 1419 if (report.reporterDid !== user.did) { 1420 throw forbidden('You can only appeal your own reports') 1421 } 1422 1423 if (report.status !== 'resolved') { 1424 throw badRequest('Can only appeal resolved reports') 1425 } 1426 1427 if (report.resolutionType !== 'dismissed') { 1428 throw badRequest('Can only appeal dismissed reports') 1429 } 1430 1431 if (report.appealStatus !== 'none') { 1432 throw conflict('Report has already been appealed') 1433 } 1434 1435 const updated = await db 1436 .update(reports) 1437 .set({ 1438 appealReason: parsed.data.reason, 1439 appealedAt: new Date(), 1440 appealStatus: 'pending', 1441 status: 'pending', 1442 }) 1443 .where(eq(reports.id, reportId)) 1444 .returning() 1445 1446 const appealedReport = updated[0] 1447 if (!appealedReport) { 1448 throw notFound('Report not found after update') 1449 } 1450 1451 app.log.info({ reportId, reporterDid: user.did }, 'Report appealed') 1452 1453 return reply.status(200).send(serializeReport(appealedReport)) 1454 } 1455 ) 1456 1457 done() 1458 } 1459}