Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee

feat: moderation tooling (#11)

authored by

Patrick Dewey and committed by
GitHub
c29b91c3 c8aefabd

+3712 -95
+2 -10
.cells/cells.jsonl
··· 36 36 {"id":"01KGAYXR359TWZPS2AJF9ZB4S5","title":"Extract profile data fetching into reusable function","description":"Two handlers contain nearly identical ~100-line blocks for fetching user profile data:\n\n**Duplicated in:**\n1. HandleProfile (lines 1628-1733)\n2. HandleProfilePartial (lines 1875-1980)\n\nBoth do:\n- Create errgroup context\n- Fetch beans, roasters, grinders, brewers, brews in parallel\n- Parse records and build lookup maps\n- Resolve references between entities\n\n**Proposed refactoring:**\nExtract into a single helper function:\n\nfunc (h *Handler) fetchUserData(ctx context.Context, did string, publicClient *atproto.PublicClient) (*UserDataBundle, error)\n\nThis function would return a struct containing:\n- Beans (with roasters resolved)\n- Roasters\n- Grinders\n- Brewers\n- Brews (with all refs resolved)\n\nAcceptance criteria:\n- Create fetchUserData helper function\n- Refactor both handlers to use it\n- Reduce duplicated code by ~80-100 lines\n- All profile functionality works as before","status":"completed","priority":"low","created_at":"2026-01-31T21:21:42.501437644Z","updated_at":"2026-01-31T21:32:30.483213969Z","completed_at":"2026-01-31T21:32:30.468389045Z"} 37 37 {"id":"01KGB1KDNNZZ7ZDA5NCK0T4E1G","title":"Create shared entity table components for profile and manage pages","description":"## Summary\nRefactor the profile and manage page tables to use shared components, reducing duplication and improving maintainability.\n\n## Current State\n- `manage_partial.templ` has tables for beans, roasters, grinders, brewers with Edit/Delete actions\n- `profile_partial.templ` has similar tables but without actions\n- Beans on profile are split into \"Open Bags\" and \"Closed Bags\" sections\n- Significant HTML/structure duplication between the two files\n\n## Proposed Solution\nCreate shared table components with configurable props:\n\n### Shared Components to Create\n1. **BeansTable** - shared beans table component\n2. **RoastersTable** - shared roasters table component \n3. **GrindersTable** - shared grinders table component\n4. **BrewersTable** - shared brewers table component\n\n### Props Pattern\nEach component should accept props that control behavior:\n\n```go\ntype EntityTableProps struct {\n Items []*models.Entity\n ShowActions bool // Whether to render Edit/Delete column\n // Other entity-specific options as needed\n}\n```\n\n### Beans Table Special Handling\nThe beans table has unique requirements:\n- Manage page: single table with Status column, shows all beans\n- Profile page: split into Open/Closed sections, no Status column\n\nOptions to handle this:\n1. **Single component with `ShowStatus` prop** - manage uses it, profile calls it twice with filtered lists\n2. **`GroupByStatus` prop** - component internally groups and renders sections\n3. Keep beans separate if complexity doesn't warrant sharing\n\nRecommend option 1 - simpler, profile already filters beans via `filterOpenBeans`/`filterClosedBeans`.\n\n### Actions Implementation\nWhen `ShowActions: true`:\n- Render action column header\n- Render Edit button with `hx-get=\"/api/modals/{entity}/{rkey}\"`\n- Render Delete button with `hx-delete=\"/api/{entities}/{rkey}\"`\n\n### Files to Modify\n- `internal/web/components/manage_partial.templ` - use shared components\n- `internal/web/components/profile_partial.templ` - use shared components\n- `internal/web/components/entity_tables.templ` (new) - shared table components\n\n### Acceptance Criteria\n- [ ] Shared table components created in `entity_tables.templ`\n- [ ] `manage_partial.templ` refactored to use shared components\n- [ ] `profile_partial.templ` refactored to use shared components\n- [ ] Actions can be enabled/disabled via props\n- [ ] No visual regressions on manage page\n- [ ] No visual regressions on profile page\n- [ ] `templ generate` runs successfully\n- [ ] Tests pass (if any)","status":"completed","priority":"normal","assignee":"patrick","labels":["frontend","refactoring"],"created_at":"2026-01-31T22:08:29.877786981Z","updated_at":"2026-01-31T22:16:42.669635082Z","completed_at":"2026-01-31T22:16:42.657711384Z"} 38 38 {"id":"01KGB2WBMP539SFSKZCKJAYH0C","title":"Prevent line wrap between emoji and text in table headers","description":"Add whitespace-nowrap to table header cells to prevent the emoji and column name from wrapping onto separate lines.","status":"completed","priority":"normal","assignee":"patrick","labels":["frontend"],"created_at":"2026-01-31T22:30:51.286845962Z","updated_at":"2026-01-31T22:31:23.380048647Z","completed_at":"2026-01-31T22:31:23.369060496Z"} 39 - {"id":"01KGBF5Q2KJ8PZNSMJPFFF31C8","title":"Add AT Protocol explanation page","description":"Create a page explaining what the AT Protocol is that the footer link redirects to instead of atproto.com.\n\nAcceptance criteria:\n- New /atproto page explaining the AT Protocol\n- Footer AT Protocol link redirects to /atproto instead of external site\n- Page matches site design system\n- Explains what AT Protocol is and how Arabica uses it\n- Links to atproto.com for those who want more info","status":"claimed","priority":"normal","assignee":"patrick","created_at":"2026-02-01T02:05:40.819263564Z","updated_at":"2026-02-01T02:10:19.29363426Z","notes":[{"timestamp":"2026-02-01T02:10:19.279321594Z","author":"patrick","message":"Initial implementation complete:\n- Created /atproto page with AT Protocol explanation\n- Updated footer link to point to /atproto instead of external site\n- Added route and handler\n- Page covers: PDS, DIDs, Lexicons, AT-URIs, how Arabica uses ATProto\n- Includes links to atproto.com for more info\nReady for review and final polish"}]} 39 + {"id":"01KGBF5Q2KJ8PZNSMJPFFF31C8","title":"Add AT Protocol explanation page","description":"Create a page explaining what the AT Protocol is that the footer link redirects to instead of atproto.com.\n\nAcceptance criteria:\n- New /atproto page explaining the AT Protocol\n- Footer AT Protocol link redirects to /atproto instead of external site\n- Page matches site design system\n- Explains what AT Protocol is and how Arabica uses it\n- Links to atproto.com for those who want more info","status":"cancelled","priority":"normal","assignee":"patrick","created_at":"2026-02-01T02:05:40.819263564Z","updated_at":"2026-02-06T01:44:49.962642705Z","notes":[{"timestamp":"2026-02-01T02:10:19.279321594Z","author":"patrick","message":"Initial implementation complete:\n- Created /atproto page with AT Protocol explanation\n- Updated footer link to point to /atproto instead of external site\n- Added route and handler\n- Page covers: PDS, DIDs, Lexicons, AT-URIs, how Arabica uses ATProto\n- Includes links to atproto.com for more info\nReady for review and final polish"}]} 40 40 {"id":"01KGBM1YCGZRNTVCJDZFV3100R","title":"Move web/static to static directory","description":"Move web/static directory to top-level static directory and update all references across the codebase.\n\nFiles to update:\n- flake.nix (tailwindcss paths)\n- .gitignore (output.css path)\n- justfile (tailwindcss paths)\n- default.nix (tailwindcss paths)\n- .gitattributes (vendored JS paths)\n- internal/routing/routing.go (file server path)\n- deploy/Dockerfile (COPY command)\n- CLAUDE.md (documentation)\n\nAcceptance criteria:\n- web/static moved to static/\n- All references updated\n- Server still serves static files correctly\n- Tailwind CSS build still works","status":"completed","priority":"normal","assignee":"patrick","created_at":"2026-02-01T03:31:00.112467261Z","updated_at":"2026-02-01T03:32:59.754877212Z","completed_at":"2026-02-01T03:32:59.739794374Z"} 41 - {"id":"01KGDXVE566WVDG8TC9DWPSYXF","title":"Implement likes for social interactions","description":"Add a like lexicon and implement like functionality across the app.\n\n## Lexicon: social.arabica.alpha.like\n\nThe like record should:\n- Use `com.atproto.repo.strongRef` for the subject (URI + CID for immutability)\n- Support liking any arabica.social lexicon type (beans, roasters, grinders, brewers, brews, and comments once implemented)\n- Include createdAt timestamp\n- Use TID as record key\n\n## Backend Implementation\n\n1. Create lexicon file: `lexicons/social.arabica.alpha.like.json`\n2. Add NSID constant in `internal/atproto/nsid.go`\n3. Add Like model in `internal/models/models.go`\n4. Add record conversion functions in `internal/atproto/records.go`\n5. Add Store interface methods: CreateLike, DeleteLike, GetLikesForSubject, GetUserLikes\n6. Implement in AtprotoStore\n7. Update firehose indexing to track likes\n\n## Frontend Implementation\n\n1. Add like button component\n2. Show like counts on brews/beans/etc in feed\n3. Update like counts in response to firehose events (real-time updates)\n4. Toggle like state based on current user's likes\n\n## Acceptance Criteria\n\n- Users can like any arabica.social record\n- Likes are stored in the user's PDS (actor-owned data)\n- Like counts display on records in the feed\n- Real-time like count updates via firehose\n- Optimistic UI updates for likes","status":"open","priority":"high","labels":["backend","frontend","atproto"],"created_at":"2026-02-02T01:00:41.510646368Z","updated_at":"2026-02-02T01:00:41.510646368Z"} 41 + {"id":"01KGDXVE566WVDG8TC9DWPSYXF","title":"Implement likes for social interactions","description":"Add a like lexicon and implement like functionality across the app.\n\n## Lexicon: social.arabica.alpha.like\n\nThe like record should:\n- Use `com.atproto.repo.strongRef` for the subject (URI + CID for immutability)\n- Support liking any arabica.social lexicon type (beans, roasters, grinders, brewers, brews, and comments once implemented)\n- Include createdAt timestamp\n- Use TID as record key\n\n## Backend Implementation\n\n1. Create lexicon file: `lexicons/social.arabica.alpha.like.json`\n2. Add NSID constant in `internal/atproto/nsid.go`\n3. Add Like model in `internal/models/models.go`\n4. Add record conversion functions in `internal/atproto/records.go`\n5. Add Store interface methods: CreateLike, DeleteLike, GetLikesForSubject, GetUserLikes\n6. Implement in AtprotoStore\n7. Update firehose indexing to track likes\n\n## Frontend Implementation\n\n1. Add like button component\n2. Show like counts on brews/beans/etc in feed\n3. Update like counts in response to firehose events (real-time updates)\n4. Toggle like state based on current user's likes\n\n## Acceptance Criteria\n\n- Users can like any arabica.social record\n- Likes are stored in the user's PDS (actor-owned data)\n- Like counts display on records in the feed\n- Real-time like count updates via firehose\n- Optimistic UI updates for likes","status":"cancelled","priority":"high","labels":["backend","frontend","atproto"],"created_at":"2026-02-02T01:00:41.510646368Z","updated_at":"2026-02-06T01:45:10.223739659Z"} 42 42 {"id":"01KGDXVR86CB2H44DJHKN4Z9M0","title":"Implement comments for social interactions","description":"Add a comment lexicon and implement comment functionality across the app.\n\n## Lexicon: social.arabica.alpha.comment\n\nThe comment record should:\n- Use `com.atproto.repo.strongRef` for the subject (URI + CID for immutability)\n- Support commenting on any arabica.social lexicon type (beans, roasters, grinders, brewers, brews, and other comments)\n- Include text field (max 1000 chars / 300 graphemes as per AT Protocol conventions)\n- Include createdAt timestamp\n- Use TID as record key\n- NOTE: This cell implements flat comments only. Threaded/nested comments are handled in a separate cell.\n\n## Backend Implementation\n\n1. Create lexicon file: `lexicons/social.arabica.alpha.comment.json`\n2. Add NSID constant in `internal/atproto/nsid.go`\n3. Add Comment model in `internal/models/models.go`\n4. Add record conversion functions in `internal/atproto/records.go`\n5. Add Store interface methods: CreateComment, DeleteComment, GetCommentsForSubject, GetUserComments\n6. Implement in AtprotoStore\n7. Update firehose indexing to track comments\n\n## Frontend Implementation\n\n1. Add comment section component below brew detail view\n2. Add comment form (authenticated users only)\n3. Display comment count on records in feed\n4. Update comment counts in response to firehose events\n5. Show commenter profile info (avatar, handle)\n\n## Acceptance Criteria\n\n- Users can comment on any arabica.social record\n- Comments are stored in the user's PDS (actor-owned data)\n- Comment counts display on records in the feed\n- Comments visible on record detail views\n- Real-time comment count updates via firehose","status":"open","priority":"normal","labels":["backend","frontend","atproto"],"created_at":"2026-02-02T01:00:51.846427126Z","updated_at":"2026-02-02T01:00:51.846427126Z"} 43 43 {"id":"01KGDXW31PHFMBE3WTV83HPET1","title":"Implement comment threading","description":"Add support for threaded/nested comments, building on the flat comment system.\n\n## Lexicon Changes\n\nUpdate `social.arabica.alpha.comment` to add optional threading fields:\n- `parent`: Optional strongRef to parent comment (for replies)\n- `root`: Optional strongRef to root subject (maintains context when replying to comments)\n\nAlternatively, consider a separate reply field or keeping comments flat with UI-level threading.\n\n## Backend Implementation\n\n1. Update comment model to include parent/root references\n2. Add methods to fetch comment threads: GetCommentThread, GetReplies\n3. Update firehose indexing to track parent-child relationships\n4. Add depth limits for threading (prevent infinite nesting)\n\n## Frontend Implementation\n\n1. Design threaded comment UI (indentation, collapse/expand)\n2. Add 'reply' button on comments\n3. Show reply context when replying\n4. Consider max nesting depth for display (e.g., 3-4 levels)\n5. Mobile-friendly thread navigation\n\n## Design Considerations\n\n- How deep should threading go? (Recommend max 3-4 levels visible, then flatten)\n- How to handle deleted parent comments?\n- Should users be notified when someone replies to their comment?\n- Performance: lazy-load deep threads vs eager-load\n\n## Acceptance Criteria\n\n- Users can reply directly to comments\n- Thread structure is visually clear\n- Threads can be collapsed/expanded\n- Works well on mobile devices\n- Parent context shown when replying","status":"blocked","priority":"low","blocked_by":["01KGDXVR86CB2H44DJHKN4Z9M0"],"labels":["frontend","atproto"],"created_at":"2026-02-02T01:01:02.902692829Z","updated_at":"2026-02-02T01:01:06.361891137Z"} 44 44 {"id":"01KGGG8EHG9Y6VQ8PZM9W74584","title":"Refactor lexicon record types to use concrete RecordType","description":"Replace magic strings for lexicon record types with a concrete RecordType type and defined constants.\n\n## Current State\nMagic strings like \"brew\", \"bean\", \"roaster\", \"grinder\", \"brewer\" are used throughout the codebase:\n- `internal/feed/service.go` - FeedItem.RecordType field uses string type\n- `internal/firehose/index.go` - Sets RecordType to string literals\n- `internal/web/pages/feed.templ` - Switch cases on string values\n- `internal/handlers/handlers.go` - writeJSON calls with string type names\n\n## Proposed Changes\n1. Define a `RecordType` type in `internal/atproto/nsid.go` (or a new file)\n2. Create constants: `RecordTypeBrew`, `RecordTypeBean`, `RecordTypeRoaster`, `RecordTypeGrinder`, `RecordTypeBrewer`, `RecordTypeLike`\n3. Update `FeedItem.RecordType` in `feed/service.go` to use the new type\n4. Update all string literal usages to use the constants\n5. Consider adding helper methods (e.g., `RecordType.String()`, `RecordType.DisplayName()`)\n\n## Acceptance Criteria\n- No magic strings for record types remain in Go code\n- Template switch statements updated to use constants\n- Type safety enforced at compile time\n- All tests pass","status":"completed","priority":"normal","labels":["refactoring","tech-debt"],"created_at":"2026-02-03T01:00:51.120049807Z","updated_at":"2026-02-03T01:06:55.448414863Z","completed_at":"2026-02-03T01:06:55.435337779Z"} ··· 50 50 {"id":"01KGGK6V4WMC3X67E72JTE27A7","title":"Fix 'failed to convert record to feed item' warning for likes","description":"The firehose index logs a warning 'failed to convert record to feed item' when processing like records. This is expected behavior since likes are not displayed as standalone feed items - they're indexed for like counts but shouldn't appear in the feed.\n\nThe warning appears in internal/firehose/index.go:433 when recordToFeedItem returns an error for NSIDLike records (line 582-584).\n\nFix: Handle the like case silently without logging a warning, since this is expected behavior. Either:\n1. Skip like records before calling recordToFeedItem\n2. Return a special sentinel error from recordToFeedItem that indicates 'skip without warning'\n3. Check the collection type at the call site and skip likes there\n\nThis applies to both authenticated and unauthenticated users.","status":"completed","priority":"normal","labels":["bug"],"created_at":"2026-02-03T01:52:24.220349056Z","updated_at":"2026-02-03T01:54:54.698106017Z","completed_at":"2026-02-03T01:54:54.686934901Z"} 51 51 {"id":"01KGGNPG25M2AJ619P65FPMEH4","title":"Add OpenGraph metadata to brew pages","description":"Add dynamic OpenGraph metadata support for brew pages to enable rich social sharing previews.\n\n## Background\n\nCurrently, the site uses static OpenGraph tags in layout.templ:\n- og:title: \"Arabica - Coffee Brew Tracker\" (static)\n- og:description: \"Track your coffee brewing journey...\" (static) \n- og:type: \"website\" (static)\n- og:image: NOT PRESENT\n\nWhen users share brew links on social media, all previews look identical and provide no context about the specific brew.\n\n## Requirements\n\n### 1. Extend LayoutData struct\n\nAdd optional OpenGraph fields to `internal/web/components/layout.templ`:\n\n```go\ntype LayoutData struct {\n Title string\n IsAuthenticated bool\n UserDID string\n UserProfile *bff.UserProfile\n CSPNonce string\n \n // OpenGraph metadata (optional, falls back to defaults)\n OGTitle string\n OGDescription string\n OGImage string\n OGType string // \"website\", \"article\"\n OGUrl string // Canonical URL\n}\n```\n\n### 2. Update layout.templ head section\n\nModify meta tag rendering to use dynamic values with fallbacks:\n- If OGTitle set, use it; otherwise use site default\n- If OGDescription set, use it; otherwise use site default\n- If OGImage set, render og:image tag (currently missing entirely)\n- If OGUrl set, render og:url tag\n- Support og:type (default \"website\", brew pages use \"article\")\n\n### 3. Update buildLayoutData helper\n\nExtend `handlers.go` buildLayoutData() to accept optional OG parameters, or create a new helper `buildLayoutDataWithOG()`.\n\n### 4. Update HandleBrewView handler\n\nConstruct descriptive OG metadata from brew data:\n- **og:title**: \"{Bean Name} from {Origin} - Arabica\" or similar\n- **og:description**: \"{Rating}/10 - {Tasting Notes preview}\" or brew summary\n- **og:type**: \"article\"\n- **og:url**: The share URL (already computed as `shareURL`)\n- **og:image**: Static default for now (e.g., /static/og-brew-default.png)\n\nAvailable data for description construction:\n- brew.Rating, brew.TastingNotes\n- brew.Bean.Name, brew.Bean.Origin, brew.Bean.RoastLevel\n- brew.Bean.Roaster.Name\n- brew.CoffeeAmount, brew.WaterAmount, brew.Temperature\n\n### 5. Add Twitter Card support\n\nAdd Twitter-specific meta tags:\n- twitter:card = \"summary\" (or \"summary_large_image\" if we have images)\n- twitter:title, twitter:description (same as OG values)\n\n### 6. Static fallback image\n\nCreate or source a default OG image for brews:\n- Location: /static/og-brew-default.png or similar\n- Size: 1200x630px (recommended OG image size)\n- Design: Coffee-themed, includes Arabica branding\n\n## Acceptance Criteria\n\n- [ ] LayoutData extended with optional OG fields\n- [ ] layout.templ renders dynamic OG tags with fallbacks\n- [ ] Brew view pages include descriptive OG metadata\n- [ ] Twitter Card tags included\n- [ ] Static default OG image created and served\n- [ ] Existing pages (home, about, etc.) continue working with defaults\n- [ ] templ generate runs successfully\n- [ ] Manual testing: share brew URL to social platform preview tool\n\n## Implementation Notes\n\n- Follow existing patterns in handlers.go for data flow\n- Keep backwards compatible - pages not setting OG fields should use defaults\n- Consider creating a helper function to build OG description from brew data\n- The shareURL is already constructed in HandleBrewView (lines 695-701)\n\n## Future Enhancements (out of scope)\n\n- Dynamic image generation showing brew stats\n- Profile page OG metadata\n- Feed page OG metadata with recent brew preview","status":"completed","priority":"normal","assignee":"patrick","labels":["frontend","social"],"created_at":"2026-02-03T02:35:54.309356038Z","updated_at":"2026-02-03T02:53:43.170537883Z","completed_at":"2026-02-03T02:53:43.15817599Z","notes":[{"timestamp":"2026-02-03T02:53:36.100130152Z","author":"patrick","message":"Implementation complete:\n- Extended LayoutData with OG fields (OGTitle, OGDescription, OGImage, OGType, OGUrl)\n- Added helper methods ogTitle(), ogDescription(), ogType() with fallbacks\n- Updated layout.templ to render dynamic OG and Twitter Card meta tags\n- Added populateBrewOGMetadata() helper in handlers.go\n- Updated HandleBrewView to populate OG metadata from brew data\n- Added PublicURL to handler Config for absolute URLs\n- Added comprehensive tests for OG metadata generation\n- All tests pass"}]} 52 52 {"id":"01KGM9QB86N5RX50042DT1YN1N","title":"Consolidate Tailwind CSS usage","description":"Reduce Tailwind class duplication by leveraging existing CSS abstractions and adding missing utility classes.\n\n## Changes Required\n\n### 1. Add new utility classes to app.css\n- `.page-container` variants (sm, md, lg, xl) for max-width containers\n- `.avatar-sm`, `.avatar-md`, `.avatar-lg` size classes\n- `.section-box` for bg-brown-50 rounded sections\n\n### 2. Update templ components to use existing abstractions\n- WelcomeCard (shared.templ) - use `.card` instead of inline gradient\n- ProfileHeader, ProfileStat, ProfileTabs (profile.templ) - use `.card`\n- Header dropdown (header.templ) - use `.action-menu`\n- Login/CTA buttons (shared.templ) - use `.btn-primary`\n- Avatar component (shared.templ) - use new CSS classes instead of templ.KV\n\n### 3. Replace inline page containers\n- Replace `max-w-4xl mx-auto` etc. with `.page-container-*` classes across all pages\n\n## Acceptance Criteria\n- No visual changes to the site\n- Reduced class verbosity in templ files\n- Avatar component simplified\n- Consistent use of existing abstractions","status":"completed","priority":"normal","assignee":"patrick","labels":["css","refactor"],"created_at":"2026-02-04T12:23:36.966151046Z","updated_at":"2026-02-04T12:27:59.86186814Z","completed_at":"2026-02-04T12:27:59.850499942Z"} 53 - \\\\\\\ to: kxrlzxyu 98a353d2 "fix: action menu divider fix" (rebased revision) 54 - {"id":"01KGK913NFHX2MMAXHCXT5S8HM","title":"Implement moderation service core with role-based permissions","description":"Create the core moderation service with JSON-based role configuration.\n\n## Requirements\n\n### JSON Config File (config/moderators.json)\n```json\n{\n \"roles\": {\n \"admin\": {\n \"description\": \"Full platform control\",\n \"permissions\": [\"hide_record\", \"unhide_record\", \"blacklist_user\", \"unblacklist_user\", \"view_reports\", \"dismiss_report\", \"view_audit_log\"]\n },\n \"moderator\": {\n \"description\": \"Content moderation\",\n \"permissions\": [\"hide_record\", \"unhide_record\", \"view_reports\", \"dismiss_report\"]\n }\n },\n \"users\": [\n {\"did\": \"did:plc:xxx\", \"handle\": \"arabica.social\", \"role\": \"admin\", \"note\": \"Platform owner\"}\n ]\n}\n```\n\n### ModerationService (internal/moderation/service.go)\n- Load config from JSON file (path from env var or flag)\n- `IsAdmin(did string) bool`\n- `IsModerator(did string) bool` (includes admins)\n- `HasPermission(did string, permission string) bool`\n- `GetRole(did string) (Role, bool)`\n- `ListModerators() []ModeratorUser`\n\n### Models (internal/moderation/models.go)\n- Role struct with permissions\n- ModeratorUser struct\n- Config struct\n\nAcceptance criteria:\n- Config file is validated on load (unknown roles, missing required fields)\n- Service is nil-safe (returns false/empty if not configured)\n- Reload capability for config changes without restart (nice to have)\n- Unit tests for permission checking","status":"completed","priority":"high","assignee":"patrick","labels":["moderation","backend"],"created_at":"2026-02-04T02:52:13.871235963Z","updated_at":"2026-02-04T03:07:38.338966523Z","completed_at":"2026-02-04T03:07:38.323669324Z","notes":[{"timestamp":"2026-02-04T03:07:38.24039103Z","author":"patrick","message":"Implemented core moderation service:\n- Created internal/moderation/models.go with Role, Permission, ModeratorUser, and Config types\n- Created internal/moderation/service.go with role-based permission checking\n- Added 17 unit tests covering all functionality\n- Created config/moderators.json.example with example configuration\n- Updated CLAUDE.md with new env var ARABICA_MODERATORS_CONFIG and project structure"}]} 55 - {"id":"01KGK913QKZ96RKRH37VB99C7D","title":"Implement moderation storage in BoltDB","description":"Add BoltDB buckets and operations for moderation data.\n\n## Storage Requirements\n\n### Buckets\n- `moderation_hidden_records` - AT-URIs of hidden records\n- `moderation_blacklisted_users` - Blacklisted DIDs with metadata\n- `moderation_reports` - User reports on content\n- `moderation_audit_log` - All moderation actions\n\n### Models (internal/moderation/models.go additions)\n\n```go\ntype HiddenRecord struct {\n ATURI string `json:\"at_uri\"`\n HiddenAt time.Time `json:\"hidden_at\"`\n HiddenBy string `json:\"hidden_by\"` // DID of moderator\n Reason string `json:\"reason\"`\n AutoHidden bool `json:\"auto_hidden\"` // true if automod\n}\n\ntype BlacklistedUser struct {\n DID string `json:\"did\"`\n BlacklistedAt time.Time `json:\"blacklisted_at\"`\n BlacklistedBy string `json:\"blacklisted_by\"`\n Reason string `json:\"reason\"`\n}\n\ntype Report struct {\n ID string `json:\"id\"` // TID\n SubjectURI string `json:\"subject_uri\"` // AT-URI of reported content\n SubjectDID string `json:\"subject_did\"` // DID of content owner\n ReporterDID string `json:\"reporter_did\"`\n Reason string `json:\"reason\"`\n CreatedAt time.Time `json:\"created_at\"`\n Status string `json:\"status\"` // pending, dismissed, actioned\n ResolvedBy string `json:\"resolved_by\"`\n ResolvedAt *time.Time `json:\"resolved_at\"`\n}\n\ntype AuditEntry struct {\n ID string `json:\"id\"`\n Action string `json:\"action\"` // hide_record, blacklist_user, etc.\n ActorDID string `json:\"actor_did\"`\n TargetURI string `json:\"target_uri\"` // AT-URI or DID\n Reason string `json:\"reason\"`\n Timestamp time.Time `json:\"timestamp\"`\n AutoMod bool `json:\"auto_mod\"`\n}\n```\n\n### Store Interface (internal/moderation/store.go)\n```go\ntype Store interface {\n // Hidden records\n HideRecord(ctx context.Context, entry HiddenRecord) error\n UnhideRecord(ctx context.Context, atURI string) error\n IsRecordHidden(ctx context.Context, atURI string) bool\n ListHiddenRecords(ctx context.Context) ([]HiddenRecord, error)\n \n // Blacklist\n BlacklistUser(ctx context.Context, entry BlacklistedUser) error\n UnblacklistUser(ctx context.Context, did string) error\n IsBlacklisted(ctx context.Context, did string) bool\n ListBlacklistedUsers(ctx context.Context) ([]BlacklistedUser, error)\n \n // Reports\n CreateReport(ctx context.Context, report Report) error\n GetReport(ctx context.Context, id string) (*Report, error)\n ListPendingReports(ctx context.Context) ([]Report, error)\n ResolveReport(ctx context.Context, id string, status string, resolvedBy string) error\n CountReportsForURI(ctx context.Context, atURI string) (int, error)\n CountReportsForDID(ctx context.Context, did string) (int, error)\n \n // Audit\n LogAction(ctx context.Context, entry AuditEntry) error\n ListAuditLog(ctx context.Context, limit int) ([]AuditEntry, error)\n}\n```\n\nAcceptance criteria:\n- All operations are atomic\n- Audit logging happens automatically on moderation actions\n- Efficient lookups for IsRecordHidden and IsBlacklisted (called on every feed item)\n- Unit tests for store operations","status":"completed","priority":"high","assignee":"patrick","labels":["moderation","backend","database"],"created_at":"2026-02-04T02:52:13.939164045Z","updated_at":"2026-02-04T03:12:29.407349995Z","completed_at":"2026-02-04T03:12:29.395926199Z","notes":[{"timestamp":"2026-02-04T03:12:29.301269019Z","author":"patrick","message":"Implemented BoltDB storage for moderation:\n- Added 6 new buckets for moderation data in store.go\n- Created moderation_store.go with full CRUD operations:\n - Hidden records: HideRecord, UnhideRecord, IsRecordHidden, GetHiddenRecord, ListHiddenRecords\n - Blacklist: BlacklistUser, UnblacklistUser, IsBlacklisted, GetBlacklistedUser, ListBlacklistedUsers\n - Reports: CreateReport, GetReport, ListPendingReports, ResolveReport, CountReportsForURI/DID, HasReportedURI\n - Audit: LogAction, ListAuditLog\n- Added indexes for efficient report counting by URI and DID\n- Created comprehensive tests (20 test cases)"}]} 56 - {"id":"01KGK91R3NWQEPPSSK9035JFZQ","title":"Integrate moderation filtering into feed service","description":"Hook moderation service into feed aggregation to filter hidden content and blacklisted users.\n\n## Requirements\n\n### Feed Service Changes (internal/feed/service.go)\n- Accept ModerationService dependency\n- Filter out hidden records before returning feed items\n- Filter out all records from blacklisted users\n- Consider performance: batch lookups vs per-item checks\n\n### Integration Points\n```go\ntype FeedService struct {\n // existing fields...\n moderation *moderation.Service\n}\n\nfunc (f *FeedService) GetFeed(ctx context.Context) ([]FeedItem, error) {\n items := f.getRawFeed(ctx)\n return f.filterModerated(ctx, items), nil\n}\n\nfunc (f *FeedService) filterModerated(ctx context.Context, items []FeedItem) []FeedItem {\n var filtered []FeedItem\n for _, item := range items {\n if f.moderation.IsBlacklisted(ctx, item.AuthorDID) {\n continue\n }\n if f.moderation.IsRecordHidden(ctx, item.ATURI) {\n continue\n }\n filtered = append(filtered, item)\n }\n return filtered\n}\n```\n\n### Public Brew View\n- Also filter on individual brew view endpoint (/brews/{did}/{rkey})\n- Return 404 for hidden records (or 451 Unavailable For Legal Reasons?)\n- Return 404 for blacklisted users' content\n\n### Handler Updates (internal/handlers/handlers.go)\n- Inject moderation service\n- Check moderation status on brew view routes\n\nAcceptance criteria:\n- Hidden records don't appear in feed\n- Blacklisted users' content doesn't appear anywhere\n- Minimal performance impact (consider caching blacklist/hidden sets)\n- Graceful degradation if moderation service unavailable","status":"completed","priority":"high","assignee":"patrick","labels":["moderation","backend","feed"],"created_at":"2026-02-04T02:52:34.805956493Z","updated_at":"2026-02-04T12:45:54.18942141Z","completed_at":"2026-02-04T12:45:54.173769314Z"} 57 - {"id":"01KGK91R5RCE0B0PASS301ZZQT","title":"Implement user report submission system","description":"Allow authenticated users to report content with basic automod thresholds.\n\n## Requirements\n\n### Report Endpoint\n- POST /api/reports\n- Requires authentication\n- Rate limited (max 10 reports per user per hour)\n\n### Request/Response\n```go\ntype ReportRequest struct {\n SubjectURI string `json:\"subject_uri\"` // AT-URI of content\n Reason string `json:\"reason\"` // free text, max 500 chars\n}\n\ntype ReportResponse struct {\n ID string `json:\"id\"`\n Message string `json:\"message\"`\n}\n```\n\n### Automod Thresholds\nConfigurable via JSON config or constants:\n```go\nconst (\n AutoHideThreshold = 3 // reports on single record\n AutoHideUserThreshold = 5 // total reports across user's records\n NewUserDays = 7 // users \u003c 7 days old\n NewUserAutoHideThreshold = 2 // lower threshold for new users\n)\n```\n\nWhen threshold reached:\n1. Auto-hide the record (mark AutoHidden: true)\n2. Create audit log entry with AutoMod: true\n3. Keep report in pending state for moderator review\n\n### UI Component\n- Report button on brew cards in feed\n- Report button on brew detail page\n- Simple modal: textarea for reason + submit\n- Confirmation message after submission\n\n### Handler (internal/handlers/moderation.go)\n```go\nfunc (h *Handler) HandleSubmitReport(w http.ResponseWriter, r *http.Request)\n```\n\nAcceptance criteria:\n- Users can report any content (not their own)\n- Duplicate reports from same user are rejected\n- Automod triggers when thresholds reached\n- Rate limiting prevents abuse\n- Success/error feedback to user","status":"open","priority":"high","labels":["moderation","backend","frontend"],"created_at":"2026-02-04T02:52:34.872474383Z","updated_at":"2026-02-04T03:12:29.413298406Z"} 58 - {"id":"01KGK926RAW8Q3SQHFH4KKKM3K","title":"Implement admin/moderator dashboard UI","description":"Create the admin dashboard for moderators and admins to take moderation actions.\n\n## Requirements\n\n### Routes\n- GET /admin - Dashboard (moderators + admins)\n- GET /admin/reports - Pending reports view\n- GET /admin/audit - Audit log (admins only)\n- POST /admin/hide - Hide a record\n- POST /admin/unhide - Unhide a record\n- POST /admin/blacklist - Blacklist a user (admins only)\n- POST /admin/unblacklist - Unblacklist a user (admins only)\n- POST /admin/reports/{id}/resolve - Resolve a report\n\n### Access Control\n- All /admin routes require authentication\n- Check HasPermission() for each action\n- Return 403 if insufficient permissions\n- Hide UI elements user can't access\n\n### Dashboard Page (internal/web/pages/admin.templ)\n```\n┌─────────────────────────────────────────────────────┐\n│ Moderation Dashboard [Your Role] │\n├─────────────────────────────────────────────────────┤\n│ │\n│ Pending Reports (3) │\n│ ┌─────────────────────────────────────────────────┐ │\n│ │ @user reported brew by @other │ │\n│ │ \"Spam content\" [View] [Hide] [Dismiss]│ │\n│ └─────────────────────────────────────────────────┘ │\n│ │\n│ Recently Hidden (auto-hidden marked) │\n│ ┌─────────────────────────────────────────────────┐ │\n│ │ brew/abc by @user [AUTO] - 3 reports [Unhide] │ │\n│ └─────────────────────────────────────────────────┘ │\n│ │\n│ Blacklisted Users (admin only) │\n│ ┌─────────────────────────────────────────────────┐ │\n│ │ @baduser - \"repeated spam\" [Unblacklist] │ │\n│ └─────────────────────────────────────────────────┘ │\n│ │\n│ [View Audit Log] (admin only) │\n└─────────────────────────────────────────────────────┘\n```\n\n### Components\n- AdminLayout component (reuses main layout but adds admin nav)\n- ReportCard component\n- HiddenRecordRow component\n- BlacklistedUserRow component\n- AuditLogTable component\n\n### HTMX Integration\n- Actions (hide/unhide/resolve) via HTMX POST\n- Optimistic UI updates\n- Error toast on failure\n\n### Handler (internal/handlers/admin.go)\nNew handler file for admin routes, or add to existing handlers.go\n\nAcceptance criteria:\n- Only accessible to moderators/admins\n- All actions require appropriate permissions\n- Actions create audit log entries\n- Responsive design\n- Confirmation dialogs for destructive actions (blacklist)","status":"completed","priority":"high","assignee":"patrick","blocked_by":["01KGK91R3NWQEPPSSK9035JFZQ","01KGK91R5RCE0B0PASS301ZZQT"],"labels":["moderation","frontend","backend"],"created_at":"2026-02-04T02:52:49.80269115Z","updated_at":"2026-02-05T02:05:48.756110333Z","completed_at":"2026-02-05T02:05:48.744596981Z"} 59 - {"id":"01KGMAQBRS5J9N0JHBJAAZ518Q","title":"Add moderator actions to feed item more menu","description":"Add moderation actions (hide/unhide) to the existing 'more' dropdown menu on feed items for users with appropriate permissions.\n\n## Requirements\n\n### UI Changes\n- Extend the existing 'more' menu (three dots) on feed/brew cards\n- Show moderation actions only if user has permission\n- Actions to add:\n - 'Hide from feed' (if not hidden, requires hide_record permission)\n - 'Unhide' (if hidden, requires unhide_record permission)\n - Visual indicator if record is currently hidden (for mods viewing hidden content)\n\n### Backend Changes\n- Pass moderation context to feed/brew templates:\n - `IsModerator bool`\n - `CanHideRecord bool`\n - `IsRecordHidden bool` (per item)\n- Add HTMX endpoints:\n - POST /admin/hide (accepts AT-URI, returns updated menu)\n - POST /admin/unhide (accepts AT-URI, returns updated menu)\n\n### Integration Points\n- Feed page (feed.templ) - brew cards in community feed\n- Profile page (profile.templ) - brew cards on user profiles\n- Brew view page (brew_view.templ) - single brew detail view\n\n### Behavior\n- Actions trigger HTMX POST requests\n- On success: swap the menu to show updated state\n- On error: show toast notification\n- Create audit log entry for each action\n\n### Permissions Check\n- Check ModerationService.HasPermission() before rendering actions\n- Check again server-side before executing action\n- Return 403 if permission denied\n\nAcceptance criteria:\n- Moderators see hide/unhide options in more menu\n- Regular users don't see moderation options\n- Actions work via HTMX without full page reload\n- Audit log entries created for all actions\n- Hidden records show visual indicator to moderators","status":"completed","priority":"high","assignee":"patrick","labels":["moderation","frontend","backend"],"created_at":"2026-02-04T12:41:06.073520449Z","updated_at":"2026-02-04T13:06:16.292634304Z","completed_at":"2026-02-04T13:06:16.279224398Z"} 60 - +{"id":"01KGZQ1Q1CSCBNXY9AS6GQJK2B","title":"Fix more actions menu z-index on feed cards","description":"The action bar border-t divider line visually paints on top of the popup menu instead of behind it. Root cause: .action-menu has z-10 while .dropdown-menu has z-50, and .feed-card animation creates a new stacking context.\n\nFix:\n- Bump .action-menu z-index from z-10 to z-50 (matching .dropdown-menu) in static/css/app.css\n- Add isolation: isolate or relative z-index to action-bar wrapper if needed\n- Remove the two // FIX: comments in internal/web/components/action_bar.templ\n- Regenerate templ code\n- Rebuild CSS with Tailwind\n\nKey files:\n- static/css/app.css:309-311 — .action-menu definition\n- internal/web/components/action_bar.templ:92-93 — FIX comments\n- static/css/app.css:410-411 — .feed-card animation\n\nVerify on homepage feed and profile brews tab.","status":"open","priority":"normal","created_at":"2026-02-08T22:48:06.956664383Z","updated_at":"2026-02-08T22:48:06.956664383Z"}
+1 -1
.gitignore
··· 50 50 51 51 # Development files 52 52 known-dids.txt 53 - 53 + moderators.json 54 54 roles.json
+22 -1
BACKLOG.md
··· 23 23 ## Far Future Considerations 24 24 25 25 - Maybe swap from boltdb to sqlite 26 - - Use the non-cgo library 26 + - Use the non-cgo library? 27 + - Is there a compelling reason to do this? 28 + - Might be good as a sort of witness-cache type thing (record refs to avoid hitting PDS's as often?) 29 + - Probably not worth unless we keep a copy of all (or all recent) network data 30 + 31 + - The profile, manage, and brews list pages all function in a similar fashion, 32 + should one or more of them be consolidated? 33 + - Manage + brews list together probably makes sense 34 + 35 + - IMPORTANT: If this platform gains any traction, we will need some form of content moderation 36 + - Due to the nature of arabica, this will only really need to be text based (text and hyperlinks) 37 + - Malicious link scanning may be reasonable, not sure about deeper text analysis 38 + - Need to do more research into security 39 + - Need admin tooling at the app level that will allow deleting records (may not be possible), 40 + removing from appview, blacklisting users (and maybe IPs?), possibly more 41 + - Having accounts with admin rights may be an approach to this (configured with flags at startup time?) 42 + @arabica.social, @pdewey.com, maybe others? (need trusted users in other time zones probably) 43 + - Add on some piece to the TOS that mentions I reserve the right to de-list content from the platform 44 + - Continue limiting firehose posts to users who have been previously authenticated (keep a permanent record of "trusted" users) 45 + - By logging in users agree to TOS -- can create records to be displayed on the appview ("signal" records) 46 + Attestation signature from appview (or pds -- use key from pds) was source of record being created 47 + - This is a pretty important consideration going forward, lots to consider 27 48 28 49 ## Fixes 29 50
+27
CLAUDE.md
··· 42 42 43 43 All work items are tracked as cells. When starting new work, check for existing cells first. 44 44 45 + ## Workflow Rules 46 + 47 + Do NOT spend more than 2-3 minutes exploring/reading files before beginning implementation. If the task is clear, start writing code immediately. Ask clarifying questions rather than endlessly reading the codebase. When given a specific implementation task, produce code changes in the same session. 48 + 49 + ## Dependencies 50 + 51 + When implementing features, prefer standard library solutions over external dependencies. Only add a third-party dependency if the standard library genuinely cannot handle the requirement. For Go: check stdlib first (e.g., os.Stdout for TTY detection). For JS/TS: check built-in APIs before npm packages. 52 + 53 + ## Task Agents 54 + 55 + When spawning task agents, set a hard limit of 3 agents maximum. Each agent must have a clearly scoped deliverable and file output path. Do not poll agents in a loop—instead, give each agent its full instructions upfront and collect results at the end. If agents aren't producing results within 5 minutes, fall back to doing the work directly. 56 + 57 + ## Testing & Verification 58 + 59 + For Go projects: always run `go vet ./...` and `go build ./...` after making changes. For JavaScript/CSS projects: verify template field names match backend struct fields before considering a task complete. Always test form submissions to verify content-type handling (JSON vs form-encoded). 60 + 61 + ### Using Go Tooling Effectively 62 + 63 + - To see source files from a dependency, or to answer questions about a dependency, run `go mod download -json MODULE` and use the returned `Dir` path to read the files. 64 + - Use `go doc foo.Bar` or `go doc -all foo` to read documentation for packages, types, functions, etc. 65 + - Use `go run .` or `go run ./cmd/foo` instead of `go build` to run programs, to avoid leaving behind build artifacts. 66 + 45 67 ## Tech Stack 46 68 47 69 - **Language:** Go 1.21+ ··· 98 120 feed/ 99 121 service.go # Community feed aggregation 100 122 registry.go # User registration for feed 123 + moderation/ 124 + models.go # Moderation types (roles, permissions, reports) 125 + service.go # Role-based moderation service 101 126 models/ 102 127 models.go # Domain models and request types 103 128 middleware/ ··· 105 130 routing/ 106 131 routing.go # Router setup and middleware chain 107 132 lexicons/ # AT Protocol lexicon definitions (JSON) 133 + config/ # Configuration files (moderators.json.example) 108 134 static/ # CSS, JS, manifest 109 135 ``` 110 136 ··· 453 479 | `SERVER_PUBLIC_URL` | - | Public URL for reverse proxy | 454 480 | `ARABICA_DB_PATH` | ~/.local/share/arabica/arabica.db | BoltDB path (sessions, registry) | 455 481 | `ARABICA_FEED_INDEX_PATH` | ~/.local/share/arabica/feed-index.db | Firehose index BoltDB path | 482 + | `ARABICA_MODERATORS_CONFIG` | - | Path to moderators JSON config | 456 483 | `ARABICA_PROFILE_CACHE_TTL` | 1h | Profile cache duration | 457 484 | `SECURE_COOKIES` | false | Set true for HTTPS | 458 485 | `LOG_LEVEL` | info | debug/info/warn/error |
+14
cmd/server/main.go
··· 18 18 "arabica/internal/feed" 19 19 "arabica/internal/firehose" 20 20 "arabica/internal/handlers" 21 + "arabica/internal/moderation" 21 22 "arabica/internal/routing" 22 23 23 24 "github.com/rs/zerolog" ··· 184 185 adapter := firehose.NewFeedIndexAdapter(feedIndex) 185 186 feedService.SetFirehoseIndex(adapter) 186 187 188 + // Wire up moderation filtering for the feed 189 + moderationStore := store.ModerationStore() 190 + feedService.SetModerationFilter(moderationStore) 191 + 187 192 log.Info().Msg("Firehose consumer started") 188 193 189 194 // Log known DIDs from database (DIDs discovered via firehose) ··· 295 300 296 301 // Wire up the feed index for like functionality 297 302 h.SetFeedIndex(feedIndex) 303 + 304 + // Initialize moderation service and wire up to handler 305 + moderatorsConfigPath := os.Getenv("ARABICA_MODERATORS_CONFIG") 306 + moderationSvc, err := moderation.NewService(moderatorsConfigPath) 307 + if err != nil { 308 + log.Warn().Err(err).Msg("Failed to initialize moderation service, moderation disabled") 309 + } else { 310 + h.SetModeration(moderationSvc, moderationStore) 311 + } 298 312 299 313 // Setup router with middleware 300 314 handler := routing.SetupRouter(routing.Config{
+510
internal/database/boltstore/moderation_store.go
··· 1 + package boltstore 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "time" 8 + 9 + "arabica/internal/moderation" 10 + 11 + bolt "go.etcd.io/bbolt" 12 + ) 13 + 14 + // ModerationStore provides persistent storage for moderation data. 15 + type ModerationStore struct { 16 + db *bolt.DB 17 + } 18 + 19 + // HideRecord stores a hidden record entry. 20 + func (s *ModerationStore) HideRecord(ctx context.Context, entry moderation.HiddenRecord) error { 21 + return s.db.Update(func(tx *bolt.Tx) error { 22 + bucket := tx.Bucket(BucketModerationHiddenRecords) 23 + if bucket == nil { 24 + return fmt.Errorf("bucket not found: %s", BucketModerationHiddenRecords) 25 + } 26 + 27 + data, err := json.Marshal(entry) 28 + if err != nil { 29 + return fmt.Errorf("failed to marshal hidden record: %w", err) 30 + } 31 + 32 + return bucket.Put([]byte(entry.ATURI), data) 33 + }) 34 + } 35 + 36 + // UnhideRecord removes a record from the hidden list. 37 + func (s *ModerationStore) UnhideRecord(ctx context.Context, atURI string) error { 38 + return s.db.Update(func(tx *bolt.Tx) error { 39 + bucket := tx.Bucket(BucketModerationHiddenRecords) 40 + if bucket == nil { 41 + return nil 42 + } 43 + 44 + return bucket.Delete([]byte(atURI)) 45 + }) 46 + } 47 + 48 + // IsRecordHidden checks if a record is hidden. 49 + func (s *ModerationStore) IsRecordHidden(ctx context.Context, atURI string) bool { 50 + var hidden bool 51 + 52 + s.db.View(func(tx *bolt.Tx) error { 53 + bucket := tx.Bucket(BucketModerationHiddenRecords) 54 + if bucket == nil { 55 + return nil 56 + } 57 + 58 + hidden = bucket.Get([]byte(atURI)) != nil 59 + return nil 60 + }) 61 + 62 + return hidden 63 + } 64 + 65 + // GetHiddenRecord retrieves a hidden record entry by AT-URI. 66 + func (s *ModerationStore) GetHiddenRecord(ctx context.Context, atURI string) (*moderation.HiddenRecord, error) { 67 + var record *moderation.HiddenRecord 68 + 69 + err := s.db.View(func(tx *bolt.Tx) error { 70 + bucket := tx.Bucket(BucketModerationHiddenRecords) 71 + if bucket == nil { 72 + return nil 73 + } 74 + 75 + data := bucket.Get([]byte(atURI)) 76 + if data == nil { 77 + return nil 78 + } 79 + 80 + record = &moderation.HiddenRecord{} 81 + return json.Unmarshal(data, record) 82 + }) 83 + 84 + return record, err 85 + } 86 + 87 + // ListHiddenRecords returns all hidden records. 88 + func (s *ModerationStore) ListHiddenRecords(ctx context.Context) ([]moderation.HiddenRecord, error) { 89 + var records []moderation.HiddenRecord 90 + 91 + err := s.db.View(func(tx *bolt.Tx) error { 92 + bucket := tx.Bucket(BucketModerationHiddenRecords) 93 + if bucket == nil { 94 + return nil 95 + } 96 + 97 + return bucket.ForEach(func(k, v []byte) error { 98 + var record moderation.HiddenRecord 99 + if err := json.Unmarshal(v, &record); err != nil { 100 + return err 101 + } 102 + records = append(records, record) 103 + return nil 104 + }) 105 + }) 106 + 107 + return records, err 108 + } 109 + 110 + // BlacklistUser adds a user to the blacklist. 111 + func (s *ModerationStore) BlacklistUser(ctx context.Context, entry moderation.BlacklistedUser) error { 112 + return s.db.Update(func(tx *bolt.Tx) error { 113 + bucket := tx.Bucket(BucketModerationBlacklist) 114 + if bucket == nil { 115 + return fmt.Errorf("bucket not found: %s", BucketModerationBlacklist) 116 + } 117 + 118 + data, err := json.Marshal(entry) 119 + if err != nil { 120 + return fmt.Errorf("failed to marshal blacklisted user: %w", err) 121 + } 122 + 123 + return bucket.Put([]byte(entry.DID), data) 124 + }) 125 + } 126 + 127 + // UnblacklistUser removes a user from the blacklist. 128 + func (s *ModerationStore) UnblacklistUser(ctx context.Context, did string) error { 129 + return s.db.Update(func(tx *bolt.Tx) error { 130 + bucket := tx.Bucket(BucketModerationBlacklist) 131 + if bucket == nil { 132 + return nil 133 + } 134 + 135 + return bucket.Delete([]byte(did)) 136 + }) 137 + } 138 + 139 + // IsBlacklisted checks if a user is blacklisted. 140 + func (s *ModerationStore) IsBlacklisted(ctx context.Context, did string) bool { 141 + var blacklisted bool 142 + 143 + s.db.View(func(tx *bolt.Tx) error { 144 + bucket := tx.Bucket(BucketModerationBlacklist) 145 + if bucket == nil { 146 + return nil 147 + } 148 + 149 + blacklisted = bucket.Get([]byte(did)) != nil 150 + return nil 151 + }) 152 + 153 + return blacklisted 154 + } 155 + 156 + // GetBlacklistedUser retrieves a blacklisted user entry by DID. 157 + func (s *ModerationStore) GetBlacklistedUser(ctx context.Context, did string) (*moderation.BlacklistedUser, error) { 158 + var user *moderation.BlacklistedUser 159 + 160 + err := s.db.View(func(tx *bolt.Tx) error { 161 + bucket := tx.Bucket(BucketModerationBlacklist) 162 + if bucket == nil { 163 + return nil 164 + } 165 + 166 + data := bucket.Get([]byte(did)) 167 + if data == nil { 168 + return nil 169 + } 170 + 171 + user = &moderation.BlacklistedUser{} 172 + return json.Unmarshal(data, user) 173 + }) 174 + 175 + return user, err 176 + } 177 + 178 + // ListBlacklistedUsers returns all blacklisted users. 179 + func (s *ModerationStore) ListBlacklistedUsers(ctx context.Context) ([]moderation.BlacklistedUser, error) { 180 + var users []moderation.BlacklistedUser 181 + 182 + err := s.db.View(func(tx *bolt.Tx) error { 183 + bucket := tx.Bucket(BucketModerationBlacklist) 184 + if bucket == nil { 185 + return nil 186 + } 187 + 188 + return bucket.ForEach(func(k, v []byte) error { 189 + var user moderation.BlacklistedUser 190 + if err := json.Unmarshal(v, &user); err != nil { 191 + return err 192 + } 193 + users = append(users, user) 194 + return nil 195 + }) 196 + }) 197 + 198 + return users, err 199 + } 200 + 201 + // CreateReport stores a new report. 202 + func (s *ModerationStore) CreateReport(ctx context.Context, report moderation.Report) error { 203 + return s.db.Update(func(tx *bolt.Tx) error { 204 + // Store the report 205 + bucket := tx.Bucket(BucketModerationReports) 206 + if bucket == nil { 207 + return fmt.Errorf("bucket not found: %s", BucketModerationReports) 208 + } 209 + 210 + data, err := json.Marshal(report) 211 + if err != nil { 212 + return fmt.Errorf("failed to marshal report: %w", err) 213 + } 214 + 215 + if err := bucket.Put([]byte(report.ID), data); err != nil { 216 + return err 217 + } 218 + 219 + // Index by subject URI 220 + uriIndex := tx.Bucket(BucketModerationReportsByURI) 221 + if uriIndex != nil { 222 + // Store report ID in a list for this URI 223 + key := []byte(report.SubjectURI + ":" + report.ID) 224 + if err := uriIndex.Put(key, []byte(report.ID)); err != nil { 225 + return err 226 + } 227 + } 228 + 229 + // Index by subject DID 230 + didIndex := tx.Bucket(BucketModerationReportsByDID) 231 + if didIndex != nil { 232 + // Store report ID in a list for this DID 233 + key := []byte(report.SubjectDID + ":" + report.ID) 234 + if err := didIndex.Put(key, []byte(report.ID)); err != nil { 235 + return err 236 + } 237 + } 238 + 239 + return nil 240 + }) 241 + } 242 + 243 + // GetReport retrieves a report by ID. 244 + func (s *ModerationStore) GetReport(ctx context.Context, id string) (*moderation.Report, error) { 245 + var report *moderation.Report 246 + 247 + err := s.db.View(func(tx *bolt.Tx) error { 248 + bucket := tx.Bucket(BucketModerationReports) 249 + if bucket == nil { 250 + return nil 251 + } 252 + 253 + data := bucket.Get([]byte(id)) 254 + if data == nil { 255 + return nil 256 + } 257 + 258 + report = &moderation.Report{} 259 + return json.Unmarshal(data, report) 260 + }) 261 + 262 + return report, err 263 + } 264 + 265 + // ListPendingReports returns all reports with pending status. 266 + func (s *ModerationStore) ListPendingReports(ctx context.Context) ([]moderation.Report, error) { 267 + var reports []moderation.Report 268 + 269 + err := s.db.View(func(tx *bolt.Tx) error { 270 + bucket := tx.Bucket(BucketModerationReports) 271 + if bucket == nil { 272 + return nil 273 + } 274 + 275 + return bucket.ForEach(func(k, v []byte) error { 276 + var report moderation.Report 277 + if err := json.Unmarshal(v, &report); err != nil { 278 + return err 279 + } 280 + if report.Status == moderation.ReportStatusPending { 281 + reports = append(reports, report) 282 + } 283 + return nil 284 + }) 285 + }) 286 + 287 + return reports, err 288 + } 289 + 290 + // ListAllReports returns all reports regardless of status. 291 + func (s *ModerationStore) ListAllReports(ctx context.Context) ([]moderation.Report, error) { 292 + var reports []moderation.Report 293 + 294 + err := s.db.View(func(tx *bolt.Tx) error { 295 + bucket := tx.Bucket(BucketModerationReports) 296 + if bucket == nil { 297 + return nil 298 + } 299 + 300 + return bucket.ForEach(func(k, v []byte) error { 301 + var report moderation.Report 302 + if err := json.Unmarshal(v, &report); err != nil { 303 + return err 304 + } 305 + reports = append(reports, report) 306 + return nil 307 + }) 308 + }) 309 + 310 + return reports, err 311 + } 312 + 313 + // ResolveReport updates a report's status and resolution info. 314 + func (s *ModerationStore) ResolveReport(ctx context.Context, id string, status moderation.ReportStatus, resolvedBy string) error { 315 + return s.db.Update(func(tx *bolt.Tx) error { 316 + bucket := tx.Bucket(BucketModerationReports) 317 + if bucket == nil { 318 + return fmt.Errorf("bucket not found: %s", BucketModerationReports) 319 + } 320 + 321 + data := bucket.Get([]byte(id)) 322 + if data == nil { 323 + return fmt.Errorf("report not found: %s", id) 324 + } 325 + 326 + var report moderation.Report 327 + if err := json.Unmarshal(data, &report); err != nil { 328 + return err 329 + } 330 + 331 + report.Status = status 332 + report.ResolvedBy = resolvedBy 333 + now := time.Now() 334 + report.ResolvedAt = &now 335 + 336 + newData, err := json.Marshal(report) 337 + if err != nil { 338 + return err 339 + } 340 + 341 + return bucket.Put([]byte(id), newData) 342 + }) 343 + } 344 + 345 + // CountReportsForURI returns the number of reports for a given AT-URI. 346 + func (s *ModerationStore) CountReportsForURI(ctx context.Context, atURI string) (int, error) { 347 + var count int 348 + 349 + err := s.db.View(func(tx *bolt.Tx) error { 350 + bucket := tx.Bucket(BucketModerationReportsByURI) 351 + if bucket == nil { 352 + return nil 353 + } 354 + 355 + cursor := bucket.Cursor() 356 + prefix := []byte(atURI + ":") 357 + 358 + for k, _ := cursor.Seek(prefix); k != nil && hasPrefix(k, prefix); k, _ = cursor.Next() { 359 + count++ 360 + } 361 + 362 + return nil 363 + }) 364 + 365 + return count, err 366 + } 367 + 368 + // CountReportsForDID returns the number of reports for content by a given DID. 369 + func (s *ModerationStore) CountReportsForDID(ctx context.Context, did string) (int, error) { 370 + var count int 371 + 372 + err := s.db.View(func(tx *bolt.Tx) error { 373 + bucket := tx.Bucket(BucketModerationReportsByDID) 374 + if bucket == nil { 375 + return nil 376 + } 377 + 378 + cursor := bucket.Cursor() 379 + prefix := []byte(did + ":") 380 + 381 + for k, _ := cursor.Seek(prefix); k != nil && hasPrefix(k, prefix); k, _ = cursor.Next() { 382 + count++ 383 + } 384 + 385 + return nil 386 + }) 387 + 388 + return count, err 389 + } 390 + 391 + // HasReportedURI checks if a user has already reported a specific URI. 392 + func (s *ModerationStore) HasReportedURI(ctx context.Context, reporterDID, subjectURI string) (bool, error) { 393 + var found bool 394 + 395 + err := s.db.View(func(tx *bolt.Tx) error { 396 + bucket := tx.Bucket(BucketModerationReports) 397 + if bucket == nil { 398 + return nil 399 + } 400 + 401 + return bucket.ForEach(func(k, v []byte) error { 402 + var report moderation.Report 403 + if err := json.Unmarshal(v, &report); err != nil { 404 + return nil // Skip malformed entries 405 + } 406 + if report.ReporterDID == reporterDID && report.SubjectURI == subjectURI { 407 + found = true 408 + } 409 + return nil 410 + }) 411 + }) 412 + 413 + return found, err 414 + } 415 + 416 + // LogAction stores a moderation action in the audit log. 417 + func (s *ModerationStore) LogAction(ctx context.Context, entry moderation.AuditEntry) error { 418 + return s.db.Update(func(tx *bolt.Tx) error { 419 + bucket := tx.Bucket(BucketModerationAuditLog) 420 + if bucket == nil { 421 + return fmt.Errorf("bucket not found: %s", BucketModerationAuditLog) 422 + } 423 + 424 + data, err := json.Marshal(entry) 425 + if err != nil { 426 + return fmt.Errorf("failed to marshal audit entry: %w", err) 427 + } 428 + 429 + // Use timestamp-based key for chronological ordering 430 + // Format: timestamp:id for uniqueness 431 + key := fmt.Sprintf("%d:%s", entry.Timestamp.UnixNano(), entry.ID) 432 + 433 + return bucket.Put([]byte(key), data) 434 + }) 435 + } 436 + 437 + // ListAuditLog returns the most recent audit log entries. 438 + // Entries are returned in reverse chronological order (newest first). 439 + func (s *ModerationStore) ListAuditLog(ctx context.Context, limit int) ([]moderation.AuditEntry, error) { 440 + var entries []moderation.AuditEntry 441 + 442 + err := s.db.View(func(tx *bolt.Tx) error { 443 + bucket := tx.Bucket(BucketModerationAuditLog) 444 + if bucket == nil { 445 + return nil 446 + } 447 + 448 + // Collect all entries first (BoltDB cursors iterate in key order) 449 + var all []moderation.AuditEntry 450 + err := bucket.ForEach(func(k, v []byte) error { 451 + var entry moderation.AuditEntry 452 + if err := json.Unmarshal(v, &entry); err != nil { 453 + return nil // Skip malformed entries 454 + } 455 + all = append(all, entry) 456 + return nil 457 + }) 458 + if err != nil { 459 + return err 460 + } 461 + 462 + // Reverse to get newest first 463 + for i := len(all) - 1; i >= 0 && len(entries) < limit; i-- { 464 + entries = append(entries, all[i]) 465 + } 466 + 467 + return nil 468 + }) 469 + 470 + return entries, err 471 + } 472 + 473 + // CountReportsFromUserSince counts reports submitted by a user since a given time. 474 + // Used for rate limiting report submissions. 475 + func (s *ModerationStore) CountReportsFromUserSince(ctx context.Context, reporterDID string, since time.Time) (int, error) { 476 + var count int 477 + 478 + err := s.db.View(func(tx *bolt.Tx) error { 479 + bucket := tx.Bucket(BucketModerationReports) 480 + if bucket == nil { 481 + return nil 482 + } 483 + 484 + return bucket.ForEach(func(k, v []byte) error { 485 + var report moderation.Report 486 + if err := json.Unmarshal(v, &report); err != nil { 487 + return nil // Skip malformed entries 488 + } 489 + if report.ReporterDID == reporterDID && report.CreatedAt.After(since) { 490 + count++ 491 + } 492 + return nil 493 + }) 494 + }) 495 + 496 + return count, err 497 + } 498 + 499 + // hasPrefix checks if a byte slice has a given prefix. 500 + func hasPrefix(s, prefix []byte) bool { 501 + if len(s) < len(prefix) { 502 + return false 503 + } 504 + for i, b := range prefix { 505 + if s[i] != b { 506 + return false 507 + } 508 + } 509 + return true 510 + }
+453
internal/database/boltstore/moderation_store_test.go
··· 1 + package boltstore 2 + 3 + import ( 4 + "context" 5 + "path/filepath" 6 + "testing" 7 + "time" 8 + 9 + "arabica/internal/moderation" 10 + 11 + "github.com/stretchr/testify/assert" 12 + "github.com/stretchr/testify/require" 13 + ) 14 + 15 + func setupTestModerationStore(t *testing.T) *ModerationStore { 16 + tmpDir := t.TempDir() 17 + dbPath := filepath.Join(tmpDir, "test.db") 18 + 19 + store, err := Open(Options{Path: dbPath}) 20 + require.NoError(t, err) 21 + 22 + t.Cleanup(func() { 23 + store.Close() 24 + }) 25 + 26 + return store.ModerationStore() 27 + } 28 + 29 + func TestHiddenRecords(t *testing.T) { 30 + ctx := context.Background() 31 + store := setupTestModerationStore(t) 32 + 33 + t.Run("hide and check record", func(t *testing.T) { 34 + entry := moderation.HiddenRecord{ 35 + ATURI: "at://did:plc:test/app.bsky.feed.post/abc123", 36 + HiddenAt: time.Now(), 37 + HiddenBy: "did:plc:admin", 38 + Reason: "Spam content", 39 + AutoHidden: false, 40 + } 41 + 42 + err := store.HideRecord(ctx, entry) 43 + require.NoError(t, err) 44 + 45 + assert.True(t, store.IsRecordHidden(ctx, entry.ATURI)) 46 + assert.False(t, store.IsRecordHidden(ctx, "at://did:plc:other/app.bsky.feed.post/xyz")) 47 + }) 48 + 49 + t.Run("get hidden record", func(t *testing.T) { 50 + uri := "at://did:plc:test/social.arabica.alpha.brew/get123" 51 + entry := moderation.HiddenRecord{ 52 + ATURI: uri, 53 + HiddenAt: time.Now(), 54 + HiddenBy: "did:plc:mod", 55 + Reason: "Inappropriate", 56 + AutoHidden: true, 57 + } 58 + 59 + err := store.HideRecord(ctx, entry) 60 + require.NoError(t, err) 61 + 62 + retrieved, err := store.GetHiddenRecord(ctx, uri) 63 + require.NoError(t, err) 64 + require.NotNil(t, retrieved) 65 + 66 + assert.Equal(t, uri, retrieved.ATURI) 67 + assert.Equal(t, "did:plc:mod", retrieved.HiddenBy) 68 + assert.Equal(t, "Inappropriate", retrieved.Reason) 69 + assert.True(t, retrieved.AutoHidden) 70 + }) 71 + 72 + t.Run("unhide record", func(t *testing.T) { 73 + uri := "at://did:plc:test/social.arabica.alpha.brew/unhide123" 74 + entry := moderation.HiddenRecord{ 75 + ATURI: uri, 76 + HiddenAt: time.Now(), 77 + HiddenBy: "did:plc:admin", 78 + } 79 + 80 + err := store.HideRecord(ctx, entry) 81 + require.NoError(t, err) 82 + assert.True(t, store.IsRecordHidden(ctx, uri)) 83 + 84 + err = store.UnhideRecord(ctx, uri) 85 + require.NoError(t, err) 86 + assert.False(t, store.IsRecordHidden(ctx, uri)) 87 + }) 88 + 89 + t.Run("list hidden records", func(t *testing.T) { 90 + // Clear by unhiding previous test records 91 + store.UnhideRecord(ctx, "at://did:plc:test/app.bsky.feed.post/abc123") 92 + store.UnhideRecord(ctx, "at://did:plc:test/social.arabica.alpha.brew/get123") 93 + 94 + // Add fresh records 95 + for i := 0; i < 3; i++ { 96 + entry := moderation.HiddenRecord{ 97 + ATURI: "at://did:plc:list/social.arabica.alpha.brew/list" + string(rune('0'+i)), 98 + HiddenAt: time.Now(), 99 + HiddenBy: "did:plc:admin", 100 + } 101 + require.NoError(t, store.HideRecord(ctx, entry)) 102 + } 103 + 104 + records, err := store.ListHiddenRecords(ctx) 105 + require.NoError(t, err) 106 + assert.GreaterOrEqual(t, len(records), 3) 107 + }) 108 + } 109 + 110 + func TestBlacklist(t *testing.T) { 111 + ctx := context.Background() 112 + store := setupTestModerationStore(t) 113 + 114 + t.Run("blacklist and check user", func(t *testing.T) { 115 + entry := moderation.BlacklistedUser{ 116 + DID: "did:plc:baduser", 117 + BlacklistedAt: time.Now(), 118 + BlacklistedBy: "did:plc:admin", 119 + Reason: "Repeated violations", 120 + } 121 + 122 + err := store.BlacklistUser(ctx, entry) 123 + require.NoError(t, err) 124 + 125 + assert.True(t, store.IsBlacklisted(ctx, "did:plc:baduser")) 126 + assert.False(t, store.IsBlacklisted(ctx, "did:plc:gooduser")) 127 + }) 128 + 129 + t.Run("get blacklisted user", func(t *testing.T) { 130 + did := "did:plc:getblacklist" 131 + entry := moderation.BlacklistedUser{ 132 + DID: did, 133 + BlacklistedAt: time.Now(), 134 + BlacklistedBy: "did:plc:admin", 135 + Reason: "Test reason", 136 + } 137 + 138 + err := store.BlacklistUser(ctx, entry) 139 + require.NoError(t, err) 140 + 141 + retrieved, err := store.GetBlacklistedUser(ctx, did) 142 + require.NoError(t, err) 143 + require.NotNil(t, retrieved) 144 + 145 + assert.Equal(t, did, retrieved.DID) 146 + assert.Equal(t, "did:plc:admin", retrieved.BlacklistedBy) 147 + assert.Equal(t, "Test reason", retrieved.Reason) 148 + }) 149 + 150 + t.Run("unblacklist user", func(t *testing.T) { 151 + did := "did:plc:unblacklist" 152 + entry := moderation.BlacklistedUser{ 153 + DID: did, 154 + BlacklistedAt: time.Now(), 155 + BlacklistedBy: "did:plc:admin", 156 + } 157 + 158 + err := store.BlacklistUser(ctx, entry) 159 + require.NoError(t, err) 160 + assert.True(t, store.IsBlacklisted(ctx, did)) 161 + 162 + err = store.UnblacklistUser(ctx, did) 163 + require.NoError(t, err) 164 + assert.False(t, store.IsBlacklisted(ctx, did)) 165 + }) 166 + 167 + t.Run("list blacklisted users", func(t *testing.T) { 168 + users, err := store.ListBlacklistedUsers(ctx) 169 + require.NoError(t, err) 170 + assert.GreaterOrEqual(t, len(users), 1) 171 + }) 172 + } 173 + 174 + func TestReports(t *testing.T) { 175 + ctx := context.Background() 176 + store := setupTestModerationStore(t) 177 + 178 + t.Run("create and get report", func(t *testing.T) { 179 + report := moderation.Report{ 180 + ID: "report001", 181 + SubjectURI: "at://did:plc:subject/social.arabica.alpha.brew/abc", 182 + SubjectDID: "did:plc:subject", 183 + ReporterDID: "did:plc:reporter", 184 + Reason: "This is spam", 185 + CreatedAt: time.Now(), 186 + Status: moderation.ReportStatusPending, 187 + } 188 + 189 + err := store.CreateReport(ctx, report) 190 + require.NoError(t, err) 191 + 192 + retrieved, err := store.GetReport(ctx, "report001") 193 + require.NoError(t, err) 194 + require.NotNil(t, retrieved) 195 + 196 + assert.Equal(t, "report001", retrieved.ID) 197 + assert.Equal(t, "did:plc:reporter", retrieved.ReporterDID) 198 + assert.Equal(t, moderation.ReportStatusPending, retrieved.Status) 199 + }) 200 + 201 + t.Run("list pending reports", func(t *testing.T) { 202 + // Create a mix of pending and resolved reports 203 + pending := moderation.Report{ 204 + ID: "report_pending", 205 + SubjectURI: "at://did:plc:sub/social.arabica.alpha.brew/p1", 206 + SubjectDID: "did:plc:sub", 207 + ReporterDID: "did:plc:rep1", 208 + Status: moderation.ReportStatusPending, 209 + CreatedAt: time.Now(), 210 + } 211 + require.NoError(t, store.CreateReport(ctx, pending)) 212 + 213 + dismissed := moderation.Report{ 214 + ID: "report_dismissed", 215 + SubjectURI: "at://did:plc:sub/social.arabica.alpha.brew/p2", 216 + SubjectDID: "did:plc:sub", 217 + ReporterDID: "did:plc:rep2", 218 + Status: moderation.ReportStatusDismissed, 219 + CreatedAt: time.Now(), 220 + } 221 + require.NoError(t, store.CreateReport(ctx, dismissed)) 222 + 223 + reports, err := store.ListPendingReports(ctx) 224 + require.NoError(t, err) 225 + 226 + // Should only include pending reports 227 + for _, r := range reports { 228 + assert.Equal(t, moderation.ReportStatusPending, r.Status) 229 + } 230 + }) 231 + 232 + t.Run("resolve report", func(t *testing.T) { 233 + report := moderation.Report{ 234 + ID: "report_to_resolve", 235 + SubjectURI: "at://did:plc:sub/social.arabica.alpha.brew/resolve", 236 + SubjectDID: "did:plc:sub", 237 + ReporterDID: "did:plc:rep", 238 + Status: moderation.ReportStatusPending, 239 + CreatedAt: time.Now(), 240 + } 241 + require.NoError(t, store.CreateReport(ctx, report)) 242 + 243 + err := store.ResolveReport(ctx, "report_to_resolve", moderation.ReportStatusActioned, "did:plc:mod") 244 + require.NoError(t, err) 245 + 246 + retrieved, err := store.GetReport(ctx, "report_to_resolve") 247 + require.NoError(t, err) 248 + 249 + assert.Equal(t, moderation.ReportStatusActioned, retrieved.Status) 250 + assert.Equal(t, "did:plc:mod", retrieved.ResolvedBy) 251 + assert.NotNil(t, retrieved.ResolvedAt) 252 + }) 253 + 254 + t.Run("count reports for URI", func(t *testing.T) { 255 + uri := "at://did:plc:counted/social.arabica.alpha.brew/count" 256 + 257 + for i := 0; i < 3; i++ { 258 + report := moderation.Report{ 259 + ID: "count_uri_" + string(rune('0'+i)), 260 + SubjectURI: uri, 261 + SubjectDID: "did:plc:counted", 262 + ReporterDID: "did:plc:reporter" + string(rune('0'+i)), 263 + Status: moderation.ReportStatusPending, 264 + CreatedAt: time.Now(), 265 + } 266 + require.NoError(t, store.CreateReport(ctx, report)) 267 + } 268 + 269 + count, err := store.CountReportsForURI(ctx, uri) 270 + require.NoError(t, err) 271 + assert.Equal(t, 3, count) 272 + }) 273 + 274 + t.Run("count reports for DID", func(t *testing.T) { 275 + did := "did:plc:counteddid" 276 + 277 + for i := 0; i < 2; i++ { 278 + report := moderation.Report{ 279 + ID: "count_did_" + string(rune('0'+i)), 280 + SubjectURI: "at://" + did + "/social.arabica.alpha.brew/post" + string(rune('0'+i)), 281 + SubjectDID: did, 282 + ReporterDID: "did:plc:reporter", 283 + Status: moderation.ReportStatusPending, 284 + CreatedAt: time.Now(), 285 + } 286 + require.NoError(t, store.CreateReport(ctx, report)) 287 + } 288 + 289 + count, err := store.CountReportsForDID(ctx, did) 290 + require.NoError(t, err) 291 + assert.Equal(t, 2, count) 292 + }) 293 + 294 + t.Run("has reported URI", func(t *testing.T) { 295 + uri := "at://did:plc:hasreported/social.arabica.alpha.brew/check" 296 + reporter := "did:plc:checker" 297 + 298 + report := moderation.Report{ 299 + ID: "has_reported_check", 300 + SubjectURI: uri, 301 + SubjectDID: "did:plc:hasreported", 302 + ReporterDID: reporter, 303 + Status: moderation.ReportStatusPending, 304 + CreatedAt: time.Now(), 305 + } 306 + require.NoError(t, store.CreateReport(ctx, report)) 307 + 308 + has, err := store.HasReportedURI(ctx, reporter, uri) 309 + require.NoError(t, err) 310 + assert.True(t, has) 311 + 312 + has, err = store.HasReportedURI(ctx, "did:plc:other", uri) 313 + require.NoError(t, err) 314 + assert.False(t, has) 315 + }) 316 + 317 + t.Run("count reports from user since", func(t *testing.T) { 318 + reporter := "did:plc:ratelimituser" 319 + now := time.Now() 320 + 321 + // Create reports at different times 322 + for i := 0; i < 5; i++ { 323 + report := moderation.Report{ 324 + ID: "ratelimit_" + string(rune('a'+i)), 325 + SubjectURI: "at://did:plc:target/social.arabica.alpha.brew/rl" + string(rune('0'+i)), 326 + SubjectDID: "did:plc:target", 327 + ReporterDID: reporter, 328 + Status: moderation.ReportStatusPending, 329 + CreatedAt: now.Add(-time.Duration(i*30) * time.Minute), // 0, -30, -60, -90, -120 mins 330 + } 331 + require.NoError(t, store.CreateReport(ctx, report)) 332 + } 333 + 334 + // Count reports in the last hour (should be 2: 0min and -30min) 335 + oneHourAgo := now.Add(-1 * time.Hour) 336 + count, err := store.CountReportsFromUserSince(ctx, reporter, oneHourAgo) 337 + require.NoError(t, err) 338 + assert.Equal(t, 2, count) 339 + 340 + // Count reports in the last 2 hours (should be 4: 0, -30, -60, -90 mins) 341 + twoHoursAgo := now.Add(-2 * time.Hour) 342 + count, err = store.CountReportsFromUserSince(ctx, reporter, twoHoursAgo) 343 + require.NoError(t, err) 344 + assert.Equal(t, 4, count) 345 + 346 + // Count reports from a different user (should be 0) 347 + count, err = store.CountReportsFromUserSince(ctx, "did:plc:otheruser", oneHourAgo) 348 + require.NoError(t, err) 349 + assert.Equal(t, 0, count) 350 + }) 351 + } 352 + 353 + func TestAuditLog(t *testing.T) { 354 + ctx := context.Background() 355 + store := setupTestModerationStore(t) 356 + 357 + t.Run("log action", func(t *testing.T) { 358 + entry := moderation.AuditEntry{ 359 + ID: "audit001", 360 + Action: moderation.AuditActionHideRecord, 361 + ActorDID: "did:plc:mod", 362 + TargetURI: "at://did:plc:target/social.arabica.alpha.brew/abc", 363 + Reason: "Spam", 364 + Timestamp: time.Now(), 365 + AutoMod: false, 366 + } 367 + 368 + err := store.LogAction(ctx, entry) 369 + require.NoError(t, err) 370 + }) 371 + 372 + t.Run("list audit log", func(t *testing.T) { 373 + // Add several entries with different timestamps 374 + now := time.Now() 375 + for i := 0; i < 5; i++ { 376 + entry := moderation.AuditEntry{ 377 + ID: "audit_list_" + string(rune('0'+i)), 378 + Action: moderation.AuditActionHideRecord, 379 + ActorDID: "did:plc:mod", 380 + TargetURI: "at://did:plc:target/social.arabica.alpha.brew/" + string(rune('0'+i)), 381 + Timestamp: now.Add(time.Duration(i) * time.Second), 382 + } 383 + require.NoError(t, store.LogAction(ctx, entry)) 384 + } 385 + 386 + entries, err := store.ListAuditLog(ctx, 3) 387 + require.NoError(t, err) 388 + assert.Len(t, entries, 3) 389 + 390 + // Should be in reverse chronological order (newest first) 391 + for i := 1; i < len(entries); i++ { 392 + assert.True(t, entries[i-1].Timestamp.After(entries[i].Timestamp) || 393 + entries[i-1].Timestamp.Equal(entries[i].Timestamp)) 394 + } 395 + }) 396 + 397 + t.Run("automod entry", func(t *testing.T) { 398 + entry := moderation.AuditEntry{ 399 + ID: "audit_automod", 400 + Action: moderation.AuditActionHideRecord, 401 + ActorDID: "automod", 402 + TargetURI: "at://did:plc:auto/social.arabica.alpha.brew/auto", 403 + Reason: "Exceeded report threshold", 404 + Timestamp: time.Now(), 405 + AutoMod: true, 406 + } 407 + 408 + err := store.LogAction(ctx, entry) 409 + require.NoError(t, err) 410 + 411 + entries, err := store.ListAuditLog(ctx, 100) 412 + require.NoError(t, err) 413 + 414 + var found bool 415 + for _, e := range entries { 416 + if e.ID == "audit_automod" { 417 + assert.True(t, e.AutoMod) 418 + found = true 419 + break 420 + } 421 + } 422 + assert.True(t, found, "automod entry not found") 423 + }) 424 + } 425 + 426 + func TestNonExistentRecords(t *testing.T) { 427 + ctx := context.Background() 428 + store := setupTestModerationStore(t) 429 + 430 + t.Run("get nonexistent hidden record", func(t *testing.T) { 431 + record, err := store.GetHiddenRecord(ctx, "at://nonexistent") 432 + require.NoError(t, err) 433 + assert.Nil(t, record) 434 + }) 435 + 436 + t.Run("get nonexistent blacklisted user", func(t *testing.T) { 437 + user, err := store.GetBlacklistedUser(ctx, "did:plc:nonexistent") 438 + require.NoError(t, err) 439 + assert.Nil(t, user) 440 + }) 441 + 442 + t.Run("get nonexistent report", func(t *testing.T) { 443 + report, err := store.GetReport(ctx, "nonexistent") 444 + require.NoError(t, err) 445 + assert.Nil(t, report) 446 + }) 447 + 448 + t.Run("resolve nonexistent report", func(t *testing.T) { 449 + err := store.ResolveReport(ctx, "nonexistent", moderation.ReportStatusDismissed, "did:plc:mod") 450 + assert.Error(t, err) 451 + assert.Contains(t, err.Error(), "not found") 452 + }) 453 + }
+29
internal/database/boltstore/store.go
··· 22 22 23 23 // BucketFeedRegistry stores registered user DIDs for the community feed 24 24 BucketFeedRegistry = []byte("feed_registry") 25 + 26 + // BucketModerationHiddenRecords stores AT-URIs of hidden records 27 + BucketModerationHiddenRecords = []byte("moderation_hidden_records") 28 + 29 + // BucketModerationBlacklist stores blacklisted user DIDs 30 + BucketModerationBlacklist = []byte("moderation_blacklist") 31 + 32 + // BucketModerationReports stores user reports on content 33 + BucketModerationReports = []byte("moderation_reports") 34 + 35 + // BucketModerationReportsByURI indexes reports by subject AT-URI 36 + BucketModerationReportsByURI = []byte("moderation_reports_by_uri") 37 + 38 + // BucketModerationReportsByDID indexes reports by subject DID 39 + BucketModerationReportsByDID = []byte("moderation_reports_by_did") 40 + 41 + // BucketModerationAuditLog stores moderation action audit trail 42 + BucketModerationAuditLog = []byte("moderation_audit_log") 25 43 ) 26 44 27 45 // Store wraps a BoltDB database and provides access to specialized stores. ··· 87 105 BucketSessions, 88 106 BucketAuthRequests, 89 107 BucketFeedRegistry, 108 + BucketModerationHiddenRecords, 109 + BucketModerationBlacklist, 110 + BucketModerationReports, 111 + BucketModerationReportsByURI, 112 + BucketModerationReportsByDID, 113 + BucketModerationAuditLog, 90 114 } 91 115 92 116 for _, bucket := range buckets { ··· 127 151 // FeedStore returns a feed registry store backed by this database. 128 152 func (s *Store) FeedStore() *FeedStore { 129 153 return &FeedStore{db: s.db} 154 + } 155 + 156 + // ModerationStore returns a moderation store backed by this database. 157 + func (s *Store) ModerationStore() *ModerationStore { 158 + return &ModerationStore{db: s.db} 130 159 } 131 160 132 161 // Stats returns database statistics.
+89 -4
internal/feed/service.go
··· 13 13 "github.com/rs/zerolog/log" 14 14 ) 15 15 16 + // ModerationFilter provides content filtering for moderation. 17 + // This interface allows the feed service to filter hidden/blacklisted content. 18 + type ModerationFilter interface { 19 + IsRecordHidden(ctx context.Context, atURI string) bool 20 + IsBlacklisted(ctx context.Context, did string) bool 21 + } 22 + 16 23 const ( 17 24 // PublicFeedCacheTTL is the duration for which the public feed cache is valid. 18 25 // This value can be adjusted based on desired freshness vs. performance tradeoff. ··· 87 94 88 95 // Service fetches and aggregates brews from registered users 89 96 type Service struct { 90 - registry *Registry 91 - cache *publicFeedCache 92 - firehoseIndex FirehoseIndex 97 + registry *Registry 98 + cache *publicFeedCache 99 + firehoseIndex FirehoseIndex 100 + moderationFilter ModerationFilter 93 101 } 94 102 95 103 // NewService creates a new feed service ··· 106 114 log.Info().Msg("feed: firehose index configured") 107 115 } 108 116 117 + // SetModerationFilter configures the service to filter moderated content 118 + func (s *Service) SetModerationFilter(filter ModerationFilter) { 119 + s.moderationFilter = filter 120 + log.Info().Msg("feed: moderation filter configured") 121 + } 122 + 123 + // filterModeratedItems removes hidden records and content from blacklisted users 124 + func (s *Service) filterModeratedItems(ctx context.Context, items []*FeedItem) []*FeedItem { 125 + if s.moderationFilter == nil { 126 + return items 127 + } 128 + 129 + filtered := make([]*FeedItem, 0, len(items)) 130 + for _, item := range items { 131 + // Get author DID from the item 132 + authorDID := s.getAuthorDID(item) 133 + if authorDID != "" && s.moderationFilter.IsBlacklisted(ctx, authorDID) { 134 + log.Debug().Str("author", authorDID).Msg("feed: filtering blacklisted user's content") 135 + continue 136 + } 137 + 138 + // Check if the record is hidden 139 + if item.SubjectURI != "" && s.moderationFilter.IsRecordHidden(ctx, item.SubjectURI) { 140 + log.Debug().Str("uri", item.SubjectURI).Msg("feed: filtering hidden record") 141 + continue 142 + } 143 + 144 + filtered = append(filtered, item) 145 + } 146 + 147 + if len(items) != len(filtered) { 148 + log.Debug(). 149 + Int("original", len(items)). 150 + Int("filtered", len(filtered)). 151 + Msg("feed: moderation filtering applied") 152 + } 153 + 154 + return filtered 155 + } 156 + 157 + // getAuthorDID extracts the author DID from a feed item 158 + func (s *Service) getAuthorDID(item *FeedItem) string { 159 + if item.Author != nil { 160 + return item.Author.DID 161 + } 162 + // Author should always be set on feed items, but handle gracefully 163 + return "" 164 + } 165 + 109 166 // GetCachedPublicFeed returns cached feed items for unauthenticated users. 110 167 // It returns up to PublicFeedLimit items from the cache, refreshing if expired. 111 168 // The cache stores PublicFeedCacheSize items internally but only returns PublicFeedLimit. 169 + // Moderated content is filtered even from cached items to ensure hidden content 170 + // doesn't appear if it was hidden after caching. 112 171 func (s *Service) GetCachedPublicFeed(ctx context.Context) ([]*FeedItem, error) { 113 172 s.cache.mu.RLock() 114 173 cacheValid := time.Now().Before(s.cache.expiresAt) && len(s.cache.items) > 0 ··· 116 175 s.cache.mu.RUnlock() 117 176 118 177 if cacheValid { 178 + // Apply moderation filtering to cached items 179 + // This ensures recently hidden content doesn't appear 180 + items = s.filterModeratedItems(ctx, items) 181 + 119 182 // Return only the first PublicFeedLimit items from the cache 120 183 if len(items) > PublicFeedLimit { 121 184 items = items[:PublicFeedLimit] ··· 180 243 181 244 // GetRecentRecords fetches recent activity (brews and other records) from firehose index 182 245 // Returns up to `limit` items sorted by most recent first 246 + // Moderated content (hidden records, blacklisted users) is filtered out 183 247 func (s *Service) GetRecentRecords(ctx context.Context, limit int) ([]*FeedItem, error) { 184 248 if s.firehoseIndex == nil || !s.firehoseIndex.IsReady() { 185 249 log.Warn().Msg("feed: firehose index not ready") ··· 187 251 } 188 252 189 253 log.Debug().Msg("feed: using firehose index") 190 - return s.getRecentRecordsFromFirehose(ctx, limit) 254 + 255 + // Fetch more items than requested to account for filtered content 256 + // This ensures we can still return `limit` items after filtering 257 + fetchLimit := limit 258 + if s.moderationFilter != nil { 259 + fetchLimit = limit + 10 // Buffer for filtered items 260 + } 261 + 262 + items, err := s.getRecentRecordsFromFirehose(ctx, fetchLimit) 263 + if err != nil { 264 + return nil, err 265 + } 266 + 267 + // Apply moderation filtering 268 + items = s.filterModeratedItems(ctx, items) 269 + 270 + // Trim to requested limit 271 + if len(items) > limit { 272 + items = items[:limit] 273 + } 274 + 275 + return items, nil 191 276 } 192 277 193 278 // getRecentRecordsFromFirehose fetches feed items from the firehose index
+556
internal/handlers/admin.go
··· 1 + package handlers 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "net/http" 7 + "time" 8 + 9 + "arabica/internal/atproto" 10 + "arabica/internal/middleware" 11 + "arabica/internal/moderation" 12 + "arabica/internal/web/components" 13 + "arabica/internal/web/pages" 14 + 15 + "github.com/rs/zerolog/log" 16 + ) 17 + 18 + // hideRequest is the request body for hiding a record 19 + type hideRequest struct { 20 + URI string `json:"uri"` 21 + Reason string `json:"reason,omitempty"` 22 + } 23 + 24 + // HandleHideRecord handles POST /admin/hide 25 + func (h *Handler) HandleHideRecord(w http.ResponseWriter, r *http.Request) { 26 + // Check authentication 27 + userDID, err := atproto.GetAuthenticatedDID(r.Context()) 28 + if err != nil || userDID == "" { 29 + http.Error(w, "Authentication required", http.StatusUnauthorized) 30 + return 31 + } 32 + 33 + // Check permission 34 + if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionHideRecord) { 35 + http.Error(w, "Permission denied", http.StatusForbidden) 36 + return 37 + } 38 + 39 + // Parse request - support both JSON and form data 40 + var req hideRequest 41 + contentType := r.Header.Get("Content-Type") 42 + if contentType == "application/json" { 43 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 44 + http.Error(w, "Invalid request body", http.StatusBadRequest) 45 + return 46 + } 47 + } else { 48 + // Parse as form data (HTMX default) 49 + if err := r.ParseForm(); err != nil { 50 + http.Error(w, "Invalid request body", http.StatusBadRequest) 51 + return 52 + } 53 + req.URI = r.FormValue("uri") 54 + req.Reason = r.FormValue("reason") 55 + } 56 + 57 + if req.URI == "" { 58 + http.Error(w, "URI is required", http.StatusBadRequest) 59 + return 60 + } 61 + 62 + // Hide the record 63 + entry := moderation.HiddenRecord{ 64 + ATURI: req.URI, 65 + HiddenAt: time.Now(), 66 + HiddenBy: userDID, 67 + Reason: req.Reason, 68 + AutoHidden: false, 69 + } 70 + 71 + if err := h.moderationStore.HideRecord(r.Context(), entry); err != nil { 72 + log.Error().Err(err).Str("uri", req.URI).Msg("Failed to hide record") 73 + http.Error(w, "Failed to hide record", http.StatusInternalServerError) 74 + return 75 + } 76 + 77 + // Log the action 78 + auditEntry := moderation.AuditEntry{ 79 + ID: generateTID(), 80 + Action: moderation.AuditActionHideRecord, 81 + ActorDID: userDID, 82 + TargetURI: req.URI, 83 + Reason: req.Reason, 84 + Timestamp: time.Now(), 85 + AutoMod: false, 86 + } 87 + if err := h.moderationStore.LogAction(r.Context(), auditEntry); err != nil { 88 + log.Error().Err(err).Msg("Failed to log hide action") 89 + // Don't fail the request, just log the error 90 + } 91 + 92 + log.Info(). 93 + Str("uri", req.URI). 94 + Str("by", userDID). 95 + Msg("Record hidden from feed") 96 + 97 + w.Header().Set("HX-Trigger", "mod-action") 98 + w.WriteHeader(http.StatusOK) 99 + } 100 + 101 + // HandleUnhideRecord handles POST /admin/unhide 102 + func (h *Handler) HandleUnhideRecord(w http.ResponseWriter, r *http.Request) { 103 + // Check authentication 104 + userDID, err := atproto.GetAuthenticatedDID(r.Context()) 105 + if err != nil || userDID == "" { 106 + http.Error(w, "Authentication required", http.StatusUnauthorized) 107 + return 108 + } 109 + 110 + // Check permission 111 + if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionUnhideRecord) { 112 + http.Error(w, "Permission denied", http.StatusForbidden) 113 + return 114 + } 115 + 116 + // Parse request - support both JSON and form data 117 + var req hideRequest 118 + contentType := r.Header.Get("Content-Type") 119 + if contentType == "application/json" { 120 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 121 + http.Error(w, "Invalid request body", http.StatusBadRequest) 122 + return 123 + } 124 + } else { 125 + // Parse as form data (HTMX default) 126 + if err := r.ParseForm(); err != nil { 127 + http.Error(w, "Invalid request body", http.StatusBadRequest) 128 + return 129 + } 130 + req.URI = r.FormValue("uri") 131 + req.Reason = r.FormValue("reason") 132 + } 133 + 134 + if req.URI == "" { 135 + http.Error(w, "URI is required", http.StatusBadRequest) 136 + return 137 + } 138 + 139 + // Unhide the record 140 + if err := h.moderationStore.UnhideRecord(r.Context(), req.URI); err != nil { 141 + log.Error().Err(err).Str("uri", req.URI).Msg("Failed to unhide record") 142 + http.Error(w, "Failed to unhide record", http.StatusInternalServerError) 143 + return 144 + } 145 + 146 + // Log the action 147 + auditEntry := moderation.AuditEntry{ 148 + ID: generateTID(), 149 + Action: moderation.AuditActionUnhideRecord, 150 + ActorDID: userDID, 151 + TargetURI: req.URI, 152 + Reason: req.Reason, 153 + Timestamp: time.Now(), 154 + AutoMod: false, 155 + } 156 + if err := h.moderationStore.LogAction(r.Context(), auditEntry); err != nil { 157 + log.Error().Err(err).Msg("Failed to log unhide action") 158 + } 159 + 160 + log.Info(). 161 + Str("uri", req.URI). 162 + Str("by", userDID). 163 + Msg("Record unhidden") 164 + 165 + w.Header().Set("HX-Trigger", "mod-action") 166 + w.WriteHeader(http.StatusOK) 167 + } 168 + 169 + // generateTID generates a TID (timestamp-based identifier) 170 + func generateTID() string { 171 + // Simple implementation using unix nano timestamp 172 + // In production, you might want a more sophisticated TID generator 173 + return time.Now().Format("20060102150405.000000000") 174 + } 175 + 176 + // buildAdminProps builds the admin dashboard props for the given moderator. 177 + func (h *Handler) buildAdminProps(ctx context.Context, userDID string) pages.AdminProps { 178 + canHide := h.moderationService.HasPermission(userDID, moderation.PermissionHideRecord) 179 + canUnhide := h.moderationService.HasPermission(userDID, moderation.PermissionUnhideRecord) 180 + canViewLogs := h.moderationService.HasPermission(userDID, moderation.PermissionViewAuditLog) 181 + canViewReports := h.moderationService.HasPermission(userDID, moderation.PermissionViewReports) 182 + canBlock := h.moderationService.HasPermission(userDID, moderation.PermissionBlacklistUser) 183 + canUnblock := h.moderationService.HasPermission(userDID, moderation.PermissionUnblacklistUser) 184 + 185 + var hiddenRecords []moderation.HiddenRecord 186 + var auditLog []moderation.AuditEntry 187 + var enrichedReports []pages.EnrichedReport 188 + var blockedUsers []moderation.BlacklistedUser 189 + 190 + if (canHide || canUnhide) && h.moderationStore != nil { 191 + hiddenRecords, _ = h.moderationStore.ListHiddenRecords(ctx) 192 + } 193 + 194 + if canViewLogs && h.moderationStore != nil { 195 + auditLog, _ = h.moderationStore.ListAuditLog(ctx, 50) 196 + } 197 + 198 + if canViewReports && h.moderationStore != nil { 199 + reports, _ := h.moderationStore.ListPendingReports(ctx) 200 + enrichedReports = h.enrichReports(ctx, reports) 201 + } 202 + 203 + if (canBlock || canUnblock) && h.moderationStore != nil { 204 + blockedUsers, _ = h.moderationStore.ListBlacklistedUsers(ctx) 205 + } 206 + 207 + return pages.AdminProps{ 208 + HiddenRecords: hiddenRecords, 209 + AuditLog: auditLog, 210 + Reports: enrichedReports, 211 + BlockedUsers: blockedUsers, 212 + CanHide: canHide, 213 + CanUnhide: canUnhide, 214 + CanViewLogs: canViewLogs, 215 + CanViewReports: canViewReports, 216 + CanBlock: canBlock, 217 + CanUnblock: canUnblock, 218 + } 219 + } 220 + 221 + // HandleAdmin renders the moderation dashboard 222 + func (h *Handler) HandleAdmin(w http.ResponseWriter, r *http.Request) { 223 + // Check authentication 224 + userDID, err := atproto.GetAuthenticatedDID(r.Context()) 225 + if err != nil || userDID == "" { 226 + http.Redirect(w, r, "/", http.StatusSeeOther) 227 + return 228 + } 229 + 230 + // Check if user is a moderator 231 + if h.moderationService == nil || !h.moderationService.IsModerator(userDID) { 232 + http.Error(w, "Access denied", http.StatusForbidden) 233 + return 234 + } 235 + 236 + userProfile := h.getUserProfile(r.Context(), userDID) 237 + adminProps := h.buildAdminProps(r.Context(), userDID) 238 + 239 + layoutData := &components.LayoutData{ 240 + Title: "Moderation", 241 + IsAuthenticated: true, 242 + UserDID: userDID, 243 + UserProfile: userProfile, 244 + CSPNonce: middleware.CSPNonceFromContext(r.Context()), 245 + IsModerator: true, 246 + } 247 + 248 + if err := pages.Admin(layoutData, adminProps).Render(r.Context(), w); err != nil { 249 + log.Error().Err(err).Msg("Failed to render admin page") 250 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 251 + } 252 + } 253 + 254 + // HandleAdminPartial renders just the admin dashboard content (for HTMX refresh) 255 + func (h *Handler) HandleAdminPartial(w http.ResponseWriter, r *http.Request) { 256 + userDID, err := atproto.GetAuthenticatedDID(r.Context()) 257 + if err != nil || userDID == "" { 258 + http.Error(w, "Authentication required", http.StatusUnauthorized) 259 + return 260 + } 261 + 262 + if h.moderationService == nil || !h.moderationService.IsModerator(userDID) { 263 + http.Error(w, "Access denied", http.StatusForbidden) 264 + return 265 + } 266 + 267 + adminProps := h.buildAdminProps(r.Context(), userDID) 268 + 269 + if err := pages.AdminDashboardBody(adminProps).Render(r.Context(), w); err != nil { 270 + log.Error().Err(err).Msg("Failed to render admin partial") 271 + http.Error(w, "Failed to render", http.StatusInternalServerError) 272 + } 273 + } 274 + 275 + // enrichReports resolves handles and fetches post content for reports 276 + func (h *Handler) enrichReports(ctx context.Context, reports []moderation.Report) []pages.EnrichedReport { 277 + if len(reports) == 0 { 278 + return nil 279 + } 280 + 281 + publicClient := atproto.NewPublicClient() 282 + enriched := make([]pages.EnrichedReport, 0, len(reports)) 283 + 284 + for _, report := range reports { 285 + er := pages.EnrichedReport{ 286 + Report: report, 287 + } 288 + 289 + // Resolve owner handle 290 + if profile, err := publicClient.GetProfile(ctx, report.SubjectDID); err == nil { 291 + er.OwnerHandle = profile.Handle 292 + } 293 + 294 + // Resolve reporter handle 295 + if profile, err := publicClient.GetProfile(ctx, report.ReporterDID); err == nil { 296 + er.ReporterHandle = profile.Handle 297 + } 298 + 299 + // Fetch post content summary 300 + er.PostContent = h.getPostContentSummary(ctx, publicClient, report.SubjectURI) 301 + 302 + enriched = append(enriched, er) 303 + } 304 + 305 + return enriched 306 + } 307 + 308 + // getPostContentSummary fetches a summary of post content from an AT-URI 309 + func (h *Handler) getPostContentSummary(ctx context.Context, publicClient *atproto.PublicClient, atURI string) string { 310 + // Parse AT-URI to get DID, collection, and rkey 311 + components, err := atproto.ResolveATURI(atURI) 312 + if err != nil { 313 + return "" 314 + } 315 + 316 + // Fetch the record 317 + record, err := publicClient.GetRecord(ctx, components.DID, components.Collection, components.RKey) 318 + if err != nil { 319 + return "" 320 + } 321 + 322 + // Build summary based on record type 323 + var summary string 324 + 325 + // Check for brew records 326 + if method, ok := record.Value["method"].(string); ok { 327 + summary = "Brew: " + method 328 + } 329 + if tastingNotes, ok := record.Value["tastingNotes"].(string); ok && tastingNotes != "" { 330 + if summary != "" { 331 + summary += "\n" 332 + } 333 + // Truncate long tasting notes 334 + if len(tastingNotes) > 200 { 335 + summary += tastingNotes[:200] + "..." 336 + } else { 337 + summary += tastingNotes 338 + } 339 + } 340 + 341 + // Check for bean records 342 + if name, ok := record.Value["name"].(string); ok { 343 + if summary == "" { 344 + summary = "Bean: " + name 345 + } 346 + } 347 + 348 + // If no specific fields found, return a generic message 349 + if summary == "" { 350 + summary = "(Record content not available)" 351 + } 352 + 353 + return summary 354 + } 355 + 356 + // blockRequest is the request body for blocking a user 357 + type blockRequest struct { 358 + DID string `json:"did"` 359 + Reason string `json:"reason,omitempty"` 360 + } 361 + 362 + // HandleBlockUser handles POST /_mod/block 363 + func (h *Handler) HandleBlockUser(w http.ResponseWriter, r *http.Request) { 364 + // Check authentication 365 + userDID, err := atproto.GetAuthenticatedDID(r.Context()) 366 + if err != nil || userDID == "" { 367 + http.Error(w, "Authentication required", http.StatusUnauthorized) 368 + return 369 + } 370 + 371 + // Check permission 372 + if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionBlacklistUser) { 373 + http.Error(w, "Permission denied", http.StatusForbidden) 374 + return 375 + } 376 + 377 + // Parse request - support both JSON and form data 378 + var req blockRequest 379 + contentType := r.Header.Get("Content-Type") 380 + if contentType == "application/json" { 381 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 382 + http.Error(w, "Invalid request body", http.StatusBadRequest) 383 + return 384 + } 385 + } else { 386 + // Parse as form data (HTMX default) 387 + if err := r.ParseForm(); err != nil { 388 + http.Error(w, "Invalid request body", http.StatusBadRequest) 389 + return 390 + } 391 + req.DID = r.FormValue("did") 392 + req.Reason = r.FormValue("reason") 393 + } 394 + 395 + if req.DID == "" { 396 + http.Error(w, "DID is required", http.StatusBadRequest) 397 + return 398 + } 399 + 400 + // Block the user 401 + entry := moderation.BlacklistedUser{ 402 + DID: req.DID, 403 + BlacklistedAt: time.Now(), 404 + BlacklistedBy: userDID, 405 + Reason: req.Reason, 406 + } 407 + 408 + if err := h.moderationStore.BlacklistUser(r.Context(), entry); err != nil { 409 + log.Error().Err(err).Str("did", req.DID).Msg("Failed to block user") 410 + http.Error(w, "Failed to block user", http.StatusInternalServerError) 411 + return 412 + } 413 + 414 + // Log the action 415 + auditEntry := moderation.AuditEntry{ 416 + ID: generateTID(), 417 + Action: moderation.AuditActionBlacklistUser, 418 + ActorDID: userDID, 419 + TargetURI: req.DID, 420 + Reason: req.Reason, 421 + Timestamp: time.Now(), 422 + AutoMod: false, 423 + } 424 + if err := h.moderationStore.LogAction(r.Context(), auditEntry); err != nil { 425 + log.Error().Err(err).Msg("Failed to log block action") 426 + } 427 + 428 + log.Info(). 429 + Str("did", req.DID). 430 + Str("by", userDID). 431 + Msg("User blocked") 432 + 433 + w.Header().Set("HX-Trigger", "mod-action") 434 + w.WriteHeader(http.StatusOK) 435 + } 436 + 437 + // HandleUnblockUser handles POST /_mod/unblock 438 + func (h *Handler) HandleUnblockUser(w http.ResponseWriter, r *http.Request) { 439 + // Check authentication 440 + userDID, err := atproto.GetAuthenticatedDID(r.Context()) 441 + if err != nil || userDID == "" { 442 + http.Error(w, "Authentication required", http.StatusUnauthorized) 443 + return 444 + } 445 + 446 + // Check permission 447 + if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionUnblacklistUser) { 448 + http.Error(w, "Permission denied", http.StatusForbidden) 449 + return 450 + } 451 + 452 + // Parse request - support both JSON and form data 453 + var req blockRequest 454 + contentType := r.Header.Get("Content-Type") 455 + if contentType == "application/json" { 456 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 457 + http.Error(w, "Invalid request body", http.StatusBadRequest) 458 + return 459 + } 460 + } else { 461 + // Parse as form data (HTMX default) 462 + if err := r.ParseForm(); err != nil { 463 + http.Error(w, "Invalid request body", http.StatusBadRequest) 464 + return 465 + } 466 + req.DID = r.FormValue("did") 467 + } 468 + 469 + if req.DID == "" { 470 + http.Error(w, "DID is required", http.StatusBadRequest) 471 + return 472 + } 473 + 474 + // Unblock the user 475 + if err := h.moderationStore.UnblacklistUser(r.Context(), req.DID); err != nil { 476 + log.Error().Err(err).Str("did", req.DID).Msg("Failed to unblock user") 477 + http.Error(w, "Failed to unblock user", http.StatusInternalServerError) 478 + return 479 + } 480 + 481 + // Log the action 482 + auditEntry := moderation.AuditEntry{ 483 + ID: generateTID(), 484 + Action: moderation.AuditActionUnblacklistUser, 485 + ActorDID: userDID, 486 + TargetURI: req.DID, 487 + Timestamp: time.Now(), 488 + AutoMod: false, 489 + } 490 + if err := h.moderationStore.LogAction(r.Context(), auditEntry); err != nil { 491 + log.Error().Err(err).Msg("Failed to log unblock action") 492 + } 493 + 494 + log.Info(). 495 + Str("did", req.DID). 496 + Str("by", userDID). 497 + Msg("User unblocked") 498 + 499 + w.Header().Set("HX-Trigger", "mod-action") 500 + w.WriteHeader(http.StatusOK) 501 + } 502 + 503 + // HandleDismissReport handles POST /_mod/dismiss-report 504 + func (h *Handler) HandleDismissReport(w http.ResponseWriter, r *http.Request) { 505 + // Check authentication 506 + userDID, err := atproto.GetAuthenticatedDID(r.Context()) 507 + if err != nil || userDID == "" { 508 + http.Error(w, "Authentication required", http.StatusUnauthorized) 509 + return 510 + } 511 + 512 + // Check permission 513 + if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionDismissReport) { 514 + http.Error(w, "Permission denied", http.StatusForbidden) 515 + return 516 + } 517 + 518 + // Parse request 519 + if err := r.ParseForm(); err != nil { 520 + http.Error(w, "Invalid request body", http.StatusBadRequest) 521 + return 522 + } 523 + reportID := r.FormValue("id") 524 + if reportID == "" { 525 + http.Error(w, "Report ID is required", http.StatusBadRequest) 526 + return 527 + } 528 + 529 + // Dismiss the report 530 + if err := h.moderationStore.ResolveReport(r.Context(), reportID, moderation.ReportStatusDismissed, userDID); err != nil { 531 + log.Error().Err(err).Str("reportID", reportID).Msg("Failed to dismiss report") 532 + http.Error(w, "Failed to dismiss report", http.StatusInternalServerError) 533 + return 534 + } 535 + 536 + // Log the action 537 + auditEntry := moderation.AuditEntry{ 538 + ID: generateTID(), 539 + Action: moderation.AuditActionDismissReport, 540 + ActorDID: userDID, 541 + TargetURI: reportID, 542 + Timestamp: time.Now(), 543 + AutoMod: false, 544 + } 545 + if err := h.moderationStore.LogAction(r.Context(), auditEntry); err != nil { 546 + log.Error().Err(err).Msg("Failed to log dismiss action") 547 + } 548 + 549 + log.Info(). 550 + Str("reportID", reportID). 551 + Str("by", userDID). 552 + Msg("Report dismissed") 553 + 554 + w.Header().Set("HX-Trigger", "mod-action") 555 + w.WriteHeader(http.StatusOK) 556 + }
+270 -46
internal/handlers/handlers.go
··· 12 12 13 13 "arabica/internal/atproto" 14 14 "arabica/internal/database" 15 + "arabica/internal/database/boltstore" 15 16 "arabica/internal/feed" 16 17 "arabica/internal/firehose" 17 18 "arabica/internal/middleware" 18 19 "arabica/internal/models" 20 + "arabica/internal/moderation" 19 21 "arabica/internal/web/bff" 20 22 "arabica/internal/web/components" 21 23 "arabica/internal/web/pages" ··· 45 47 feedService *feed.Service 46 48 feedRegistry *feed.Registry 47 49 feedIndex *firehose.FeedIndex 50 + 51 + // Moderation dependencies (optional) 52 + moderationService *moderation.Service 53 + moderationStore *boltstore.ModerationStore 48 54 } 49 55 50 56 // NewHandler creates a new Handler with all required dependencies. ··· 70 76 // SetFeedIndex configures the handler to use the firehose feed index for like lookups 71 77 func (h *Handler) SetFeedIndex(idx *firehose.FeedIndex) { 72 78 h.feedIndex = idx 79 + } 80 + 81 + // SetModeration configures the handler with moderation service and store 82 + func (h *Handler) SetModeration(svc *moderation.Service, store *boltstore.ModerationStore) { 83 + h.moderationService = svc 84 + h.moderationStore = store 73 85 } 74 86 75 87 // validateRKey validates and returns an rkey from a path parameter. ··· 160 172 return userProfile 161 173 } 162 174 175 + // buildModerationContext creates moderation context for feed rendering 176 + // Returns empty context if moderation is not configured or user is not a moderator 177 + func (h *Handler) buildModerationContext(ctx context.Context, viewerDID string, items []*feed.FeedItem) pages.FeedModerationContext { 178 + modCtx := pages.FeedModerationContext{ 179 + HiddenURIs: make(map[string]bool), 180 + } 181 + 182 + // Check if moderation is configured and user is a moderator 183 + if h.moderationService == nil || viewerDID == "" { 184 + return modCtx 185 + } 186 + 187 + if !h.moderationService.IsModerator(viewerDID) { 188 + return modCtx 189 + } 190 + 191 + modCtx.IsModerator = true 192 + modCtx.CanHideRecord = h.moderationService.HasPermission(viewerDID, moderation.PermissionHideRecord) 193 + modCtx.CanBlockUser = h.moderationService.HasPermission(viewerDID, moderation.PermissionBlacklistUser) 194 + 195 + // Build map of hidden URIs for efficient lookup 196 + if h.moderationStore != nil { 197 + for _, item := range items { 198 + if item.SubjectURI != "" { 199 + if h.moderationStore.IsRecordHidden(ctx, item.SubjectURI) { 200 + modCtx.HiddenURIs[item.SubjectURI] = true 201 + } 202 + } 203 + } 204 + } 205 + 206 + return modCtx 207 + } 208 + 163 209 // getAtprotoStore creates a user-scoped atproto store from the request context. 164 210 // Returns the store and true if authenticated, or nil and false if not authenticated. 165 211 func (h *Handler) getAtprotoStore(r *http.Request) (database.Store, bool) { ··· 188 234 189 235 // buildLayoutData creates a LayoutData struct with common fields populated from the request 190 236 func (h *Handler) buildLayoutData(r *http.Request, title string, isAuthenticated bool, didStr string, userProfile *bff.UserProfile) *components.LayoutData { 237 + // Check if user is a moderator 238 + isModerator := false 239 + if h.moderationService != nil && didStr != "" { 240 + isModerator = h.moderationService.IsModerator(didStr) 241 + } 242 + 191 243 return &components.LayoutData{ 192 244 Title: title, 193 245 IsAuthenticated: isAuthenticated, 194 246 UserDID: didStr, 195 247 UserProfile: userProfile, 196 248 CSPNonce: middleware.CSPNonceFromContext(r.Context()), 249 + IsModerator: isModerator, 197 250 } 198 251 } 199 252 ··· 497 550 } 498 551 } 499 552 500 - if err := pages.FeedPartial(feedItems, isAuthenticated).Render(r.Context(), w); err != nil { 553 + // Build moderation context for moderators 554 + modCtx := h.buildModerationContext(r.Context(), viewerDID, feedItems) 555 + 556 + if err := pages.FeedPartialWithModeration(feedItems, isAuthenticated, modCtx).Render(r.Context(), w); err != nil { 501 557 http.Error(w, "Failed to render feed", http.StatusInternalServerError) 502 558 log.Error().Err(err).Msg("Failed to render feed partial") 503 559 } ··· 2328 2384 } 2329 2385 } 2330 2386 2331 - // HandleReport handles content report submissions 2332 - // 2333 - // TODO: Implement actual moderation system: 2334 - // - Store reports in database (BoltDB bucket or SQLite table) 2335 - // - Add admin interface to review reports 2336 - // - Implement report status workflow (pending -> reviewed -> dismissed/actioned) 2337 - // 2338 - // TODO: Reports should be rate limited more strictly by IP than other requests. 2339 - // Consider implementing a separate, stricter rate limit for this endpoint 2340 - // (e.g., 5 reports per hour per IP) to prevent abuse and report flooding. 2387 + // Automod thresholds for automatic content hiding 2388 + const ( 2389 + // AutoHideThreshold is the number of reports on a single record before auto-hiding 2390 + AutoHideThreshold = 3 2391 + // AutoHideUserThreshold is the total reports across a user's records before auto-hiding new reports 2392 + AutoHideUserThreshold = 5 2393 + // ReportRateLimitPerHour is the maximum reports a user can submit per hour 2394 + ReportRateLimitPerHour = 10 2395 + // MaxReportReasonLength is the maximum length of a report reason 2396 + MaxReportReasonLength = 500 2397 + ) 2398 + 2399 + // ReportRequest represents the JSON request for submitting a report 2400 + type ReportRequest struct { 2401 + SubjectURI string `json:"subject_uri"` 2402 + SubjectCID string `json:"subject_cid"` 2403 + Reason string `json:"reason"` 2404 + } 2405 + 2406 + // ReportResponse represents the JSON response from report submission 2407 + type ReportResponse struct { 2408 + ID string `json:"id,omitempty"` 2409 + Status string `json:"status"` 2410 + Message string `json:"message"` 2411 + } 2412 + 2413 + // HandleReport handles content report submissions. 2414 + // Requires authentication, validates input, checks rate limits and duplicates, 2415 + // persists the report, and triggers automod if thresholds are reached. 2341 2416 func (h *Handler) HandleReport(w http.ResponseWriter, r *http.Request) { 2342 - if err := r.ParseForm(); err != nil { 2343 - http.Error(w, "Invalid form data", http.StatusBadRequest) 2417 + ctx := r.Context() 2418 + 2419 + // Require authentication 2420 + reporterDID, err := atproto.GetAuthenticatedDID(ctx) 2421 + if err != nil || reporterDID == "" { 2422 + writeReportError(w, "Authentication required", http.StatusUnauthorized) 2344 2423 return 2345 2424 } 2346 2425 2347 - subjectURI := r.FormValue("subject_uri") 2348 - subjectCID := r.FormValue("subject_cid") 2349 - reason := r.FormValue("reason") 2426 + // Check if moderation store is configured 2427 + if h.moderationStore == nil { 2428 + log.Error().Msg("moderation: store not configured") 2429 + writeReportError(w, "Reports are not enabled", http.StatusServiceUnavailable) 2430 + return 2431 + } 2432 + 2433 + // Parse request (supports both JSON and form data) 2434 + var req ReportRequest 2435 + if isJSONRequest(r) { 2436 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 2437 + writeReportError(w, "Invalid JSON", http.StatusBadRequest) 2438 + return 2439 + } 2440 + } else { 2441 + if err := r.ParseForm(); err != nil { 2442 + writeReportError(w, "Invalid form data", http.StatusBadRequest) 2443 + return 2444 + } 2445 + req.SubjectURI = r.FormValue("subject_uri") 2446 + req.SubjectCID = r.FormValue("subject_cid") 2447 + req.Reason = r.FormValue("reason") 2448 + } 2350 2449 2351 - if subjectURI == "" { 2352 - http.Error(w, "subject_uri is required", http.StatusBadRequest) 2450 + // Validate subject URI 2451 + if req.SubjectURI == "" { 2452 + writeReportError(w, "subject_uri is required", http.StatusBadRequest) 2353 2453 return 2354 2454 } 2355 2455 2356 - // Validate reason 2357 - validReasons := map[string]bool{"spam": true, "inappropriate": true, "other": true} 2358 - if reason == "" || !validReasons[reason] { 2359 - reason = "other" 2456 + // Parse the subject URI to get the content owner's DID 2457 + uriComponents, err := atproto.ResolveATURI(req.SubjectURI) 2458 + if err != nil { 2459 + writeReportError(w, "Invalid subject_uri format", http.StatusBadRequest) 2460 + return 2360 2461 } 2462 + subjectDID := uriComponents.DID 2361 2463 2362 - // Get reporter info if authenticated 2363 - reporterDID := "anonymous" 2364 - if didStr, err := atproto.GetAuthenticatedDID(r.Context()); err == nil && didStr != "" { 2365 - reporterDID = didStr 2464 + // Prevent self-reporting 2465 + if subjectDID == reporterDID { 2466 + writeReportError(w, "You cannot report your own content", http.StatusBadRequest) 2467 + return 2366 2468 } 2367 2469 2368 - // Get reporter IP for rate limiting tracking 2369 - reporterIP := r.RemoteAddr 2370 - if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { 2371 - // Use first IP in chain (original client) 2372 - reporterIP = strings.Split(forwarded, ",")[0] 2470 + // Validate and sanitize reason 2471 + reason := strings.TrimSpace(req.Reason) 2472 + if reason == "" { 2473 + reason = "No reason provided" 2474 + } 2475 + if len(reason) > MaxReportReasonLength { 2476 + reason = reason[:MaxReportReasonLength] 2373 2477 } 2374 2478 2375 - // Create report record (not persisted yet - just for structured logging) 2376 - report := &models.Report{ 2377 - SubjectURI: subjectURI, 2378 - SubjectCID: subjectCID, 2379 - Reason: reason, 2479 + // Check rate limit (10 reports per hour per user) 2480 + oneHourAgo := time.Now().Add(-1 * time.Hour) 2481 + recentCount, err := h.moderationStore.CountReportsFromUserSince(ctx, reporterDID, oneHourAgo) 2482 + if err != nil { 2483 + log.Error().Err(err).Str("reporter", reporterDID).Msg("moderation: failed to check rate limit") 2484 + writeReportError(w, "Failed to process report", http.StatusInternalServerError) 2485 + return 2486 + } 2487 + if recentCount >= ReportRateLimitPerHour { 2488 + writeReportError(w, "Rate limit exceeded. Please try again later.", http.StatusTooManyRequests) 2489 + return 2490 + } 2491 + 2492 + // Check for duplicate report 2493 + alreadyReported, err := h.moderationStore.HasReportedURI(ctx, reporterDID, req.SubjectURI) 2494 + if err != nil { 2495 + log.Error().Err(err).Str("reporter", reporterDID).Msg("moderation: failed to check duplicate") 2496 + writeReportError(w, "Failed to process report", http.StatusInternalServerError) 2497 + return 2498 + } 2499 + if alreadyReported { 2500 + writeReportError(w, "You have already reported this content", http.StatusConflict) 2501 + return 2502 + } 2503 + 2504 + // Create the report 2505 + report := moderation.Report{ 2506 + ID: generateTID(), 2507 + SubjectURI: req.SubjectURI, 2508 + SubjectDID: subjectDID, 2380 2509 ReporterDID: reporterDID, 2381 - ReporterIP: reporterIP, 2510 + Reason: reason, 2382 2511 CreatedAt: time.Now(), 2383 - Status: "pending", 2512 + Status: moderation.ReportStatusPending, 2384 2513 } 2385 2514 2386 - // TODO: Persist report to database 2387 - // For now, log the report for manual review 2515 + // Persist the report 2516 + if err := h.moderationStore.CreateReport(ctx, report); err != nil { 2517 + log.Error().Err(err).Str("reporter", reporterDID).Msg("moderation: failed to create report") 2518 + writeReportError(w, "Failed to save report", http.StatusInternalServerError) 2519 + return 2520 + } 2521 + 2388 2522 log.Info(). 2523 + Str("report_id", report.ID). 2389 2524 Str("subject_uri", report.SubjectURI). 2390 - Str("subject_cid", report.SubjectCID). 2525 + Str("subject_did", report.SubjectDID). 2526 + Str("reporter_did", report.ReporterDID). 2391 2527 Str("reason", report.Reason). 2392 - Str("reporter_did", report.ReporterDID). 2393 - Str("reporter_ip", report.ReporterIP). 2394 - Time("created_at", report.CreatedAt). 2395 - Msg("Content report received") 2528 + Msg("moderation: report created") 2529 + 2530 + // Check automod thresholds and potentially auto-hide 2531 + h.checkAutomod(ctx, report) 2396 2532 2533 + // Return success 2397 2534 w.Header().Set("Content-Type", "application/json") 2398 2535 w.WriteHeader(http.StatusOK) 2399 - w.Write([]byte(`{"status": "received"}`)) 2536 + json.NewEncoder(w).Encode(ReportResponse{ 2537 + ID: report.ID, 2538 + Status: "received", 2539 + Message: "Thank you for your report. It will be reviewed by a moderator.", 2540 + }) 2541 + } 2542 + 2543 + // checkAutomod checks if automod thresholds are met and auto-hides content if needed. 2544 + func (h *Handler) checkAutomod(ctx context.Context, report moderation.Report) { 2545 + // Skip if record is already hidden 2546 + if h.moderationStore.IsRecordHidden(ctx, report.SubjectURI) { 2547 + return 2548 + } 2549 + 2550 + // Check report count for this specific URI 2551 + uriReportCount, err := h.moderationStore.CountReportsForURI(ctx, report.SubjectURI) 2552 + if err != nil { 2553 + log.Error().Err(err).Str("uri", report.SubjectURI).Msg("moderation: failed to count URI reports for automod") 2554 + return 2555 + } 2556 + 2557 + // Check total report count for content by this user 2558 + didReportCount, err := h.moderationStore.CountReportsForDID(ctx, report.SubjectDID) 2559 + if err != nil { 2560 + log.Error().Err(err).Str("did", report.SubjectDID).Msg("moderation: failed to count DID reports for automod") 2561 + return 2562 + } 2563 + 2564 + // Determine if we should auto-hide 2565 + shouldAutoHide := false 2566 + autoHideReason := "" 2567 + 2568 + if uriReportCount >= AutoHideThreshold { 2569 + shouldAutoHide = true 2570 + autoHideReason = fmt.Sprintf("Auto-hidden: %d reports on this record", uriReportCount) 2571 + } else if didReportCount >= AutoHideUserThreshold { 2572 + shouldAutoHide = true 2573 + autoHideReason = fmt.Sprintf("Auto-hidden: %d total reports against user's content", didReportCount) 2574 + } 2575 + 2576 + if shouldAutoHide { 2577 + // Auto-hide the record 2578 + hiddenRecord := moderation.HiddenRecord{ 2579 + ATURI: report.SubjectURI, 2580 + HiddenAt: time.Now(), 2581 + HiddenBy: "automod", 2582 + Reason: autoHideReason, 2583 + AutoHidden: true, 2584 + } 2585 + 2586 + if err := h.moderationStore.HideRecord(ctx, hiddenRecord); err != nil { 2587 + log.Error().Err(err).Str("uri", report.SubjectURI).Msg("moderation: automod failed to hide record") 2588 + return 2589 + } 2590 + 2591 + // Log the automod action 2592 + auditEntry := moderation.AuditEntry{ 2593 + ID: generateTID(), 2594 + Action: moderation.AuditActionHideRecord, 2595 + ActorDID: "automod", 2596 + TargetURI: report.SubjectURI, 2597 + Reason: autoHideReason, 2598 + Timestamp: time.Now(), 2599 + AutoMod: true, 2600 + } 2601 + 2602 + if err := h.moderationStore.LogAction(ctx, auditEntry); err != nil { 2603 + log.Error().Err(err).Msg("moderation: failed to log automod action") 2604 + } 2605 + 2606 + log.Warn(). 2607 + Str("uri", report.SubjectURI). 2608 + Str("did", report.SubjectDID). 2609 + Int("uri_reports", uriReportCount). 2610 + Int("did_reports", didReportCount). 2611 + Str("reason", autoHideReason). 2612 + Msg("moderation: automod triggered - record hidden") 2613 + } 2614 + } 2615 + 2616 + // writeReportError writes a JSON error response for report endpoints 2617 + func writeReportError(w http.ResponseWriter, message string, status int) { 2618 + w.Header().Set("Content-Type", "application/json") 2619 + w.WriteHeader(status) 2620 + json.NewEncoder(w).Encode(ReportResponse{ 2621 + Status: "error", 2622 + Message: message, 2623 + }) 2400 2624 }
+164
internal/moderation/models.go
··· 1 + package moderation 2 + 3 + import "time" 4 + 5 + // Permission represents a moderation action that can be performed 6 + type Permission string 7 + 8 + const ( 9 + PermissionHideRecord Permission = "hide_record" 10 + PermissionUnhideRecord Permission = "unhide_record" 11 + PermissionBlacklistUser Permission = "blacklist_user" 12 + PermissionUnblacklistUser Permission = "unblacklist_user" 13 + PermissionViewReports Permission = "view_reports" 14 + PermissionDismissReport Permission = "dismiss_report" 15 + PermissionViewAuditLog Permission = "view_audit_log" 16 + ) 17 + 18 + // AllPermissions returns all available permissions 19 + func AllPermissions() []Permission { 20 + return []Permission{ 21 + PermissionHideRecord, 22 + PermissionUnhideRecord, 23 + PermissionBlacklistUser, 24 + PermissionUnblacklistUser, 25 + PermissionViewReports, 26 + PermissionDismissReport, 27 + PermissionViewAuditLog, 28 + } 29 + } 30 + 31 + // RoleName represents the name of a moderation role 32 + type RoleName string 33 + 34 + const ( 35 + RoleAdmin RoleName = "admin" 36 + RoleModerator RoleName = "moderator" 37 + ) 38 + 39 + // Role defines a set of permissions for moderators 40 + type Role struct { 41 + Name RoleName `json:"-"` // Set from map key during loading 42 + Description string `json:"description"` 43 + Permissions []Permission `json:"permissions"` 44 + } 45 + 46 + // HasPermission checks if this role has the given permission 47 + func (r *Role) HasPermission(perm Permission) bool { 48 + for _, p := range r.Permissions { 49 + if p == perm { 50 + return true 51 + } 52 + } 53 + return false 54 + } 55 + 56 + // ModeratorUser represents a user with moderation privileges 57 + type ModeratorUser struct { 58 + DID string `json:"did"` 59 + Handle string `json:"handle,omitempty"` 60 + Role RoleName `json:"role"` 61 + Note string `json:"note,omitempty"` 62 + } 63 + 64 + // Config represents the moderation configuration loaded from JSON 65 + type Config struct { 66 + Roles map[RoleName]*Role `json:"roles"` 67 + Users []ModeratorUser `json:"users"` 68 + } 69 + 70 + // Validate checks that the config is valid 71 + func (c *Config) Validate() error { 72 + if c.Roles == nil { 73 + c.Roles = make(map[RoleName]*Role) 74 + } 75 + 76 + // Validate that all users reference valid roles 77 + for _, user := range c.Users { 78 + if _, ok := c.Roles[user.Role]; !ok { 79 + return &ConfigError{ 80 + Field: "users", 81 + Message: "user " + user.DID + " references unknown role: " + string(user.Role), 82 + } 83 + } 84 + } 85 + 86 + // Set role names from map keys 87 + for name, role := range c.Roles { 88 + role.Name = name 89 + } 90 + 91 + return nil 92 + } 93 + 94 + // ConfigError represents a configuration validation error 95 + type ConfigError struct { 96 + Field string 97 + Message string 98 + } 99 + 100 + func (e *ConfigError) Error() string { 101 + return "moderation config error in " + e.Field + ": " + e.Message 102 + } 103 + 104 + // HiddenRecord represents a record that has been hidden from the feed 105 + type HiddenRecord struct { 106 + ATURI string `json:"at_uri"` 107 + HiddenAt time.Time `json:"hidden_at"` 108 + HiddenBy string `json:"hidden_by"` // DID of moderator 109 + Reason string `json:"reason"` 110 + AutoHidden bool `json:"auto_hidden"` // true if hidden by automod 111 + } 112 + 113 + // BlacklistedUser represents a user who has been blacklisted 114 + type BlacklistedUser struct { 115 + DID string `json:"did"` 116 + BlacklistedAt time.Time `json:"blacklisted_at"` 117 + BlacklistedBy string `json:"blacklisted_by"` // DID of admin 118 + Reason string `json:"reason"` 119 + } 120 + 121 + // ReportStatus represents the status of a user report 122 + type ReportStatus string 123 + 124 + const ( 125 + ReportStatusPending ReportStatus = "pending" 126 + ReportStatusDismissed ReportStatus = "dismissed" 127 + ReportStatusActioned ReportStatus = "actioned" 128 + ) 129 + 130 + // Report represents a user report on content 131 + type Report struct { 132 + ID string `json:"id"` // TID 133 + SubjectURI string `json:"subject_uri"` // AT-URI of reported content 134 + SubjectDID string `json:"subject_did"` // DID of content owner 135 + ReporterDID string `json:"reporter_did"` 136 + Reason string `json:"reason"` 137 + CreatedAt time.Time `json:"created_at"` 138 + Status ReportStatus `json:"status"` 139 + ResolvedBy string `json:"resolved_by,omitempty"` 140 + ResolvedAt *time.Time `json:"resolved_at,omitempty"` 141 + } 142 + 143 + // AuditAction represents a type of moderation action 144 + type AuditAction string 145 + 146 + const ( 147 + AuditActionHideRecord AuditAction = "hide_record" 148 + AuditActionUnhideRecord AuditAction = "unhide_record" 149 + AuditActionBlacklistUser AuditAction = "blacklist_user" 150 + AuditActionUnblacklistUser AuditAction = "unblacklist_user" 151 + AuditActionDismissReport AuditAction = "dismiss_report" 152 + AuditActionActionReport AuditAction = "action_report" 153 + ) 154 + 155 + // AuditEntry represents a logged moderation action 156 + type AuditEntry struct { 157 + ID string `json:"id"` 158 + Action AuditAction `json:"action"` 159 + ActorDID string `json:"actor_did"` // DID of moderator/admin or "automod" 160 + TargetURI string `json:"target_uri"` // AT-URI or DID being acted upon 161 + Reason string `json:"reason"` 162 + Timestamp time.Time `json:"timestamp"` 163 + AutoMod bool `json:"auto_mod"` // true if action was automatic 164 + }
+224
internal/moderation/service.go
··· 1 + package moderation 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "os" 7 + "sync" 8 + 9 + "github.com/rs/zerolog/log" 10 + ) 11 + 12 + // Service provides moderation functionality with role-based access control 13 + type Service struct { 14 + mu sync.RWMutex 15 + config *Config 16 + configPath string 17 + 18 + // Quick lookup maps built from config 19 + userRoles map[string]*Role // DID -> Role 20 + userInfos map[string]*ModeratorUser // DID -> ModeratorUser 21 + } 22 + 23 + // NewService creates a new moderation service. 24 + // If configPath is empty, the service will be in "disabled" mode 25 + // where all permission checks return false. 26 + func NewService(configPath string) (*Service, error) { 27 + s := &Service{ 28 + configPath: configPath, 29 + userRoles: make(map[string]*Role), 30 + userInfos: make(map[string]*ModeratorUser), 31 + } 32 + 33 + if configPath == "" { 34 + log.Info().Msg("moderation: no config path provided, service disabled") 35 + return s, nil 36 + } 37 + 38 + if err := s.loadConfig(); err != nil { 39 + return nil, fmt.Errorf("failed to load moderation config: %w", err) 40 + } 41 + 42 + return s, nil 43 + } 44 + 45 + // loadConfig reads and parses the config file 46 + func (s *Service) loadConfig() error { 47 + data, err := os.ReadFile(s.configPath) 48 + if err != nil { 49 + if os.IsNotExist(err) { 50 + log.Warn().Str("path", s.configPath).Msg("moderation: config file not found, service disabled") 51 + return nil 52 + } 53 + return fmt.Errorf("failed to read config file: %w", err) 54 + } 55 + 56 + var config Config 57 + if err := json.Unmarshal(data, &config); err != nil { 58 + return fmt.Errorf("failed to parse config file: %w", err) 59 + } 60 + 61 + if err := config.Validate(); err != nil { 62 + return fmt.Errorf("invalid config: %w", err) 63 + } 64 + 65 + s.mu.Lock() 66 + defer s.mu.Unlock() 67 + 68 + s.config = &config 69 + s.rebuildLookupMaps() 70 + 71 + log.Info(). 72 + Int("roles", len(config.Roles)). 73 + Int("users", len(config.Users)). 74 + Str("path", s.configPath). 75 + Msg("moderation: config loaded") 76 + 77 + return nil 78 + } 79 + 80 + // rebuildLookupMaps rebuilds the quick lookup maps from config 81 + // Caller must hold the write lock 82 + func (s *Service) rebuildLookupMaps() { 83 + s.userRoles = make(map[string]*Role) 84 + s.userInfos = make(map[string]*ModeratorUser) 85 + 86 + if s.config == nil { 87 + return 88 + } 89 + 90 + for i := range s.config.Users { 91 + user := &s.config.Users[i] 92 + role, ok := s.config.Roles[user.Role] 93 + if ok { 94 + s.userRoles[user.DID] = role 95 + s.userInfos[user.DID] = user 96 + } 97 + } 98 + } 99 + 100 + // Reload reloads the configuration from disk 101 + func (s *Service) Reload() error { 102 + if s.configPath == "" { 103 + return nil 104 + } 105 + return s.loadConfig() 106 + } 107 + 108 + // IsEnabled returns true if the moderation service is configured and enabled 109 + func (s *Service) IsEnabled() bool { 110 + s.mu.RLock() 111 + defer s.mu.RUnlock() 112 + return s.config != nil && len(s.config.Users) > 0 113 + } 114 + 115 + // IsAdmin returns true if the given DID has the admin role 116 + func (s *Service) IsAdmin(did string) bool { 117 + s.mu.RLock() 118 + defer s.mu.RUnlock() 119 + 120 + role, ok := s.userRoles[did] 121 + if !ok { 122 + return false 123 + } 124 + return role.Name == RoleAdmin 125 + } 126 + 127 + // IsModerator returns true if the given DID has moderator privileges 128 + // This includes both moderators and admins (admins have all moderator permissions) 129 + func (s *Service) IsModerator(did string) bool { 130 + s.mu.RLock() 131 + defer s.mu.RUnlock() 132 + 133 + _, ok := s.userRoles[did] 134 + return ok 135 + } 136 + 137 + // HasPermission returns true if the given DID has the specified permission 138 + func (s *Service) HasPermission(did string, permission Permission) bool { 139 + s.mu.RLock() 140 + defer s.mu.RUnlock() 141 + 142 + role, ok := s.userRoles[did] 143 + if !ok { 144 + return false 145 + } 146 + return role.HasPermission(permission) 147 + } 148 + 149 + // GetRole returns the role for the given DID, if any 150 + func (s *Service) GetRole(did string) (*Role, bool) { 151 + s.mu.RLock() 152 + defer s.mu.RUnlock() 153 + 154 + role, ok := s.userRoles[did] 155 + if !ok { 156 + return nil, false 157 + } 158 + // Return a copy to prevent external modification 159 + roleCopy := *role 160 + return &roleCopy, true 161 + } 162 + 163 + // GetModeratorUser returns the moderator user info for the given DID, if any 164 + func (s *Service) GetModeratorUser(did string) (*ModeratorUser, bool) { 165 + s.mu.RLock() 166 + defer s.mu.RUnlock() 167 + 168 + user, ok := s.userInfos[did] 169 + if !ok { 170 + return nil, false 171 + } 172 + // Return a copy to prevent external modification 173 + userCopy := *user 174 + return &userCopy, true 175 + } 176 + 177 + // ListModerators returns all configured moderator users 178 + func (s *Service) ListModerators() []ModeratorUser { 179 + s.mu.RLock() 180 + defer s.mu.RUnlock() 181 + 182 + if s.config == nil { 183 + return nil 184 + } 185 + 186 + // Return a copy to prevent external modification 187 + result := make([]ModeratorUser, len(s.config.Users)) 188 + copy(result, s.config.Users) 189 + return result 190 + } 191 + 192 + // ListRoles returns all configured roles 193 + func (s *Service) ListRoles() map[RoleName]*Role { 194 + s.mu.RLock() 195 + defer s.mu.RUnlock() 196 + 197 + if s.config == nil { 198 + return nil 199 + } 200 + 201 + // Return a copy to prevent external modification 202 + result := make(map[RoleName]*Role) 203 + for name, role := range s.config.Roles { 204 + roleCopy := *role 205 + result[name] = &roleCopy 206 + } 207 + return result 208 + } 209 + 210 + // GetPermissionsForDID returns all permissions for the given DID 211 + func (s *Service) GetPermissionsForDID(did string) []Permission { 212 + s.mu.RLock() 213 + defer s.mu.RUnlock() 214 + 215 + role, ok := s.userRoles[did] 216 + if !ok { 217 + return nil 218 + } 219 + 220 + // Return a copy to prevent external modification 221 + result := make([]Permission, len(role.Permissions)) 222 + copy(result, role.Permissions) 223 + return result 224 + }
+356
internal/moderation/service_test.go
··· 1 + package moderation 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "testing" 7 + 8 + "github.com/stretchr/testify/assert" 9 + "github.com/stretchr/testify/require" 10 + ) 11 + 12 + func TestNewService_NoConfig(t *testing.T) { 13 + // Service should work in disabled mode with empty config path 14 + svc, err := NewService("") 15 + require.NoError(t, err) 16 + assert.NotNil(t, svc) 17 + assert.False(t, svc.IsEnabled()) 18 + assert.False(t, svc.IsAdmin("did:plc:test")) 19 + assert.False(t, svc.IsModerator("did:plc:test")) 20 + assert.False(t, svc.HasPermission("did:plc:test", PermissionHideRecord)) 21 + } 22 + 23 + func TestNewService_MissingFile(t *testing.T) { 24 + // Service should work in disabled mode when file doesn't exist 25 + svc, err := NewService("/nonexistent/path/config.json") 26 + require.NoError(t, err) 27 + assert.NotNil(t, svc) 28 + assert.False(t, svc.IsEnabled()) 29 + } 30 + 31 + func TestNewService_InvalidJSON(t *testing.T) { 32 + tmpDir := t.TempDir() 33 + configPath := filepath.Join(tmpDir, "moderators.json") 34 + 35 + err := os.WriteFile(configPath, []byte("not valid json"), 0644) 36 + require.NoError(t, err) 37 + 38 + _, err = NewService(configPath) 39 + assert.Error(t, err) 40 + assert.Contains(t, err.Error(), "failed to parse config file") 41 + } 42 + 43 + func TestNewService_InvalidRole(t *testing.T) { 44 + tmpDir := t.TempDir() 45 + configPath := filepath.Join(tmpDir, "moderators.json") 46 + 47 + config := `{ 48 + "roles": { 49 + "admin": { 50 + "description": "Admin role", 51 + "permissions": ["hide_record"] 52 + } 53 + }, 54 + "users": [ 55 + {"did": "did:plc:test", "role": "nonexistent"} 56 + ] 57 + }` 58 + 59 + err := os.WriteFile(configPath, []byte(config), 0644) 60 + require.NoError(t, err) 61 + 62 + _, err = NewService(configPath) 63 + assert.Error(t, err) 64 + assert.Contains(t, err.Error(), "unknown role") 65 + } 66 + 67 + func TestNewService_ValidConfig(t *testing.T) { 68 + tmpDir := t.TempDir() 69 + configPath := filepath.Join(tmpDir, "moderators.json") 70 + 71 + config := `{ 72 + "roles": { 73 + "admin": { 74 + "description": "Full platform control", 75 + "permissions": ["hide_record", "unhide_record", "blacklist_user", "unblacklist_user", "view_reports", "dismiss_report", "view_audit_log"] 76 + }, 77 + "moderator": { 78 + "description": "Content moderation", 79 + "permissions": ["hide_record", "unhide_record", "view_reports", "dismiss_report"] 80 + } 81 + }, 82 + "users": [ 83 + {"did": "did:plc:admin1", "handle": "admin.test", "role": "admin", "note": "Test admin"}, 84 + {"did": "did:plc:mod1", "handle": "mod.test", "role": "moderator"} 85 + ] 86 + }` 87 + 88 + err := os.WriteFile(configPath, []byte(config), 0644) 89 + require.NoError(t, err) 90 + 91 + svc, err := NewService(configPath) 92 + require.NoError(t, err) 93 + assert.True(t, svc.IsEnabled()) 94 + } 95 + 96 + func createTestService(t *testing.T) *Service { 97 + tmpDir := t.TempDir() 98 + configPath := filepath.Join(tmpDir, "moderators.json") 99 + 100 + config := `{ 101 + "roles": { 102 + "admin": { 103 + "description": "Full platform control", 104 + "permissions": ["hide_record", "unhide_record", "blacklist_user", "unblacklist_user", "view_reports", "dismiss_report", "view_audit_log"] 105 + }, 106 + "moderator": { 107 + "description": "Content moderation", 108 + "permissions": ["hide_record", "unhide_record", "view_reports", "dismiss_report"] 109 + } 110 + }, 111 + "users": [ 112 + {"did": "did:plc:admin1", "handle": "admin.test", "role": "admin", "note": "Test admin"}, 113 + {"did": "did:plc:mod1", "handle": "mod.test", "role": "moderator"} 114 + ] 115 + }` 116 + 117 + err := os.WriteFile(configPath, []byte(config), 0644) 118 + require.NoError(t, err) 119 + 120 + svc, err := NewService(configPath) 121 + require.NoError(t, err) 122 + return svc 123 + } 124 + 125 + func TestIsAdmin(t *testing.T) { 126 + svc := createTestService(t) 127 + 128 + assert.True(t, svc.IsAdmin("did:plc:admin1")) 129 + assert.False(t, svc.IsAdmin("did:plc:mod1")) 130 + assert.False(t, svc.IsAdmin("did:plc:unknown")) 131 + } 132 + 133 + func TestIsModerator(t *testing.T) { 134 + svc := createTestService(t) 135 + 136 + // Both admins and moderators should return true 137 + assert.True(t, svc.IsModerator("did:plc:admin1")) 138 + assert.True(t, svc.IsModerator("did:plc:mod1")) 139 + assert.False(t, svc.IsModerator("did:plc:unknown")) 140 + } 141 + 142 + func TestHasPermission(t *testing.T) { 143 + svc := createTestService(t) 144 + 145 + // Admin has all permissions 146 + assert.True(t, svc.HasPermission("did:plc:admin1", PermissionHideRecord)) 147 + assert.True(t, svc.HasPermission("did:plc:admin1", PermissionBlacklistUser)) 148 + assert.True(t, svc.HasPermission("did:plc:admin1", PermissionViewAuditLog)) 149 + 150 + // Moderator has limited permissions 151 + assert.True(t, svc.HasPermission("did:plc:mod1", PermissionHideRecord)) 152 + assert.True(t, svc.HasPermission("did:plc:mod1", PermissionViewReports)) 153 + assert.False(t, svc.HasPermission("did:plc:mod1", PermissionBlacklistUser)) 154 + assert.False(t, svc.HasPermission("did:plc:mod1", PermissionViewAuditLog)) 155 + 156 + // Unknown user has no permissions 157 + assert.False(t, svc.HasPermission("did:plc:unknown", PermissionHideRecord)) 158 + } 159 + 160 + func TestGetRole(t *testing.T) { 161 + svc := createTestService(t) 162 + 163 + role, ok := svc.GetRole("did:plc:admin1") 164 + assert.True(t, ok) 165 + assert.Equal(t, RoleAdmin, role.Name) 166 + assert.Equal(t, "Full platform control", role.Description) 167 + 168 + role, ok = svc.GetRole("did:plc:mod1") 169 + assert.True(t, ok) 170 + assert.Equal(t, RoleModerator, role.Name) 171 + 172 + _, ok = svc.GetRole("did:plc:unknown") 173 + assert.False(t, ok) 174 + } 175 + 176 + func TestGetModeratorUser(t *testing.T) { 177 + svc := createTestService(t) 178 + 179 + user, ok := svc.GetModeratorUser("did:plc:admin1") 180 + assert.True(t, ok) 181 + assert.Equal(t, "did:plc:admin1", user.DID) 182 + assert.Equal(t, "admin.test", user.Handle) 183 + assert.Equal(t, RoleAdmin, user.Role) 184 + assert.Equal(t, "Test admin", user.Note) 185 + 186 + user, ok = svc.GetModeratorUser("did:plc:mod1") 187 + assert.True(t, ok) 188 + assert.Equal(t, "mod.test", user.Handle) 189 + assert.Empty(t, user.Note) 190 + 191 + _, ok = svc.GetModeratorUser("did:plc:unknown") 192 + assert.False(t, ok) 193 + } 194 + 195 + func TestListModerators(t *testing.T) { 196 + svc := createTestService(t) 197 + 198 + users := svc.ListModerators() 199 + assert.Len(t, users, 2) 200 + 201 + // Verify it returns copies (mutation shouldn't affect service) 202 + users[0].Handle = "mutated" 203 + originalUser, _ := svc.GetModeratorUser("did:plc:admin1") 204 + assert.Equal(t, "admin.test", originalUser.Handle) 205 + } 206 + 207 + func TestListRoles(t *testing.T) { 208 + svc := createTestService(t) 209 + 210 + roles := svc.ListRoles() 211 + assert.Len(t, roles, 2) 212 + assert.Contains(t, roles, RoleAdmin) 213 + assert.Contains(t, roles, RoleModerator) 214 + } 215 + 216 + func TestGetPermissionsForDID(t *testing.T) { 217 + svc := createTestService(t) 218 + 219 + perms := svc.GetPermissionsForDID("did:plc:admin1") 220 + assert.Len(t, perms, 7) 221 + assert.Contains(t, perms, PermissionHideRecord) 222 + assert.Contains(t, perms, PermissionBlacklistUser) 223 + 224 + perms = svc.GetPermissionsForDID("did:plc:mod1") 225 + assert.Len(t, perms, 4) 226 + assert.Contains(t, perms, PermissionHideRecord) 227 + assert.NotContains(t, perms, PermissionBlacklistUser) 228 + 229 + perms = svc.GetPermissionsForDID("did:plc:unknown") 230 + assert.Nil(t, perms) 231 + } 232 + 233 + func TestReload(t *testing.T) { 234 + tmpDir := t.TempDir() 235 + configPath := filepath.Join(tmpDir, "moderators.json") 236 + 237 + // Start with one admin 238 + config1 := `{ 239 + "roles": { 240 + "admin": { 241 + "description": "Admin", 242 + "permissions": ["hide_record"] 243 + } 244 + }, 245 + "users": [ 246 + {"did": "did:plc:admin1", "role": "admin"} 247 + ] 248 + }` 249 + err := os.WriteFile(configPath, []byte(config1), 0644) 250 + require.NoError(t, err) 251 + 252 + svc, err := NewService(configPath) 253 + require.NoError(t, err) 254 + 255 + assert.True(t, svc.IsAdmin("did:plc:admin1")) 256 + assert.False(t, svc.IsAdmin("did:plc:admin2")) 257 + 258 + // Update config with another admin 259 + config2 := `{ 260 + "roles": { 261 + "admin": { 262 + "description": "Admin", 263 + "permissions": ["hide_record"] 264 + } 265 + }, 266 + "users": [ 267 + {"did": "did:plc:admin1", "role": "admin"}, 268 + {"did": "did:plc:admin2", "role": "admin"} 269 + ] 270 + }` 271 + err = os.WriteFile(configPath, []byte(config2), 0644) 272 + require.NoError(t, err) 273 + 274 + err = svc.Reload() 275 + require.NoError(t, err) 276 + 277 + assert.True(t, svc.IsAdmin("did:plc:admin1")) 278 + assert.True(t, svc.IsAdmin("did:plc:admin2")) 279 + } 280 + 281 + func TestRole_HasPermission(t *testing.T) { 282 + role := &Role{ 283 + Name: RoleModerator, 284 + Permissions: []Permission{PermissionHideRecord, PermissionViewReports}, 285 + } 286 + 287 + assert.True(t, role.HasPermission(PermissionHideRecord)) 288 + assert.True(t, role.HasPermission(PermissionViewReports)) 289 + assert.False(t, role.HasPermission(PermissionBlacklistUser)) 290 + } 291 + 292 + func TestConfig_Validate(t *testing.T) { 293 + t.Run("nil roles map", func(t *testing.T) { 294 + config := &Config{ 295 + Roles: nil, 296 + Users: []ModeratorUser{}, 297 + } 298 + err := config.Validate() 299 + assert.NoError(t, err) 300 + assert.NotNil(t, config.Roles) 301 + }) 302 + 303 + t.Run("user with unknown role", func(t *testing.T) { 304 + config := &Config{ 305 + Roles: map[RoleName]*Role{ 306 + RoleAdmin: {Description: "Admin"}, 307 + }, 308 + Users: []ModeratorUser{ 309 + {DID: "did:plc:test", Role: "unknown"}, 310 + }, 311 + } 312 + err := config.Validate() 313 + assert.Error(t, err) 314 + assert.Contains(t, err.Error(), "unknown role") 315 + }) 316 + 317 + t.Run("valid config sets role names", func(t *testing.T) { 318 + config := &Config{ 319 + Roles: map[RoleName]*Role{ 320 + RoleAdmin: {Description: "Admin"}, 321 + }, 322 + Users: []ModeratorUser{ 323 + {DID: "did:plc:test", Role: RoleAdmin}, 324 + }, 325 + } 326 + err := config.Validate() 327 + assert.NoError(t, err) 328 + assert.Equal(t, RoleAdmin, config.Roles[RoleAdmin].Name) 329 + }) 330 + } 331 + 332 + func TestDisabledService(t *testing.T) { 333 + // All methods should be safe to call on a disabled service 334 + svc, err := NewService("") 335 + require.NoError(t, err) 336 + 337 + assert.False(t, svc.IsEnabled()) 338 + assert.False(t, svc.IsAdmin("did:plc:any")) 339 + assert.False(t, svc.IsModerator("did:plc:any")) 340 + assert.False(t, svc.HasPermission("did:plc:any", PermissionHideRecord)) 341 + 342 + role, ok := svc.GetRole("did:plc:any") 343 + assert.Nil(t, role) 344 + assert.False(t, ok) 345 + 346 + user, ok := svc.GetModeratorUser("did:plc:any") 347 + assert.Nil(t, user) 348 + assert.False(t, ok) 349 + 350 + assert.Nil(t, svc.ListModerators()) 351 + assert.Nil(t, svc.ListRoles()) 352 + assert.Nil(t, svc.GetPermissionsForDID("did:plc:any")) 353 + 354 + // Reload should be a no-op 355 + assert.NoError(t, svc.Reload()) 356 + }
+9
internal/routing/routing.go
··· 84 84 mux.Handle("POST /api/likes/toggle", cop.Handler(http.HandlerFunc(h.HandleLikeToggle))) 85 85 mux.Handle("POST /api/report", cop.Handler(http.HandlerFunc(h.HandleReport))) 86 86 87 + // Moderation routes (obscured path) 88 + mux.HandleFunc("GET /_mod", h.HandleAdmin) 89 + mux.Handle("GET /_mod/content", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleAdminPartial))) 90 + mux.Handle("POST /_mod/hide", cop.Handler(http.HandlerFunc(h.HandleHideRecord))) 91 + mux.Handle("POST /_mod/unhide", cop.Handler(http.HandlerFunc(h.HandleUnhideRecord))) 92 + mux.Handle("POST /_mod/dismiss-report", cop.Handler(http.HandlerFunc(h.HandleDismissReport))) 93 + mux.Handle("POST /_mod/block", cop.Handler(http.HandlerFunc(h.HandleBlockUser))) 94 + mux.Handle("POST /_mod/unblock", cop.Handler(http.HandlerFunc(h.HandleUnblockUser))) 95 + 87 96 // Modal routes for entity management (return dialog HTML) 88 97 mux.HandleFunc("GET /api/modals/bean/new", h.HandleBeanModalNew) 89 98 mux.HandleFunc("GET /api/modals/bean/{id}", h.HandleBeanModalEdit)
+214 -15
internal/web/components/action_bar.templ
··· 1 1 package components 2 2 3 - import "fmt" 3 + import ( 4 + "fmt" 5 + "strings" 6 + ) 4 7 5 8 // ActionBarProps defines properties for the action bar below feed/profile cards 6 9 type ActionBarProps struct { ··· 24 27 25 28 // Auth state 26 29 IsAuthenticated bool 30 + 31 + // Moderation state 32 + IsModerator bool // User has moderator role 33 + CanHideRecord bool // User has hide_record permission 34 + CanBlockUser bool // User has blacklist_user permission 35 + IsRecordHidden bool // This record is currently hidden 36 + AuthorDID string // DID of the content author (for block action) 27 37 } 28 38 29 39 // ActionBar renders the action bar with Comments, Like, Share, and More menu ··· 42 52 </svg> 43 53 <span>0</span> 44 54 </button> 55 + <!-- Hidden indicator (visible to moderators) --> 56 + if props.IsModerator && props.IsRecordHidden { 57 + <span class="hidden-badge" title="This record is hidden from the public feed"> 58 + <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> 59 + <path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88"></path> 60 + </svg> 61 + Hidden 62 + </span> 63 + } 45 64 <!-- Like --> 46 65 if props.SubjectURI != "" && props.SubjectCID != "" { 47 66 @LikeButton(LikeButtonProps{ ··· 70 89 </svg> 71 90 </button> 72 91 <!-- Dropdown menu --> 92 + // FIX: the line shows above the popup modal in many cases 93 + // FIX: the modal should open in the other direction if it would extend past the edge of the viewport 73 94 <div 74 95 x-show="moreOpen" 75 96 x-transition:enter="transition ease-out duration-100" ··· 108 129 } 109 130 <div class="action-menu-divider"></div> 110 131 } 111 - <!-- Report (available to all users) --> 112 - <button 113 - type="button" 114 - hx-post="/api/report" 115 - hx-vals={ fmt.Sprintf(`{"subject_uri": "%s", "subject_cid": "%s", "reason": "inappropriate"}`, props.SubjectURI, props.SubjectCID) } 116 - hx-swap="none" 117 - @click="moreOpen = false; $dispatch('notify', {message: 'Report submitted'})" 118 - class="action-menu-item" 119 - > 120 - <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 121 - <path stroke-linecap="round" stroke-linejoin="round" d="M3 3v1.5M3 21v-6m0 0 2.77-.693a9 9 0 0 1 6.208.682l.108.054a9 9 0 0 0 6.086.71l3.114-.732a48.524 48.524 0 0 1-.005-10.499l-3.11.732a9 9 0 0 1-6.085-.711l-.108-.054a9 9 0 0 0-6.208-.682L3 4.5M3 15V4.5"></path> 122 - </svg> 123 - Report 124 - </button> 132 + <!-- Moderation actions (for moderators/admins) --> 133 + if props.CanHideRecord && props.SubjectURI != "" { 134 + if props.IsRecordHidden { 135 + <button 136 + type="button" 137 + hx-post="/_mod/unhide" 138 + hx-vals={ fmt.Sprintf(`{"uri": "%s"}`, props.SubjectURI) } 139 + hx-swap="none" 140 + @click="moreOpen = false; $dispatch('notify', {message: 'Record unhidden'})" 141 + class="action-menu-item" 142 + > 143 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 144 + <path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"></path> 145 + <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"></path> 146 + </svg> 147 + Unhide from feed 148 + </button> 149 + } else { 150 + <button 151 + type="button" 152 + hx-post="/_mod/hide" 153 + hx-vals={ fmt.Sprintf(`{"uri": "%s"}`, props.SubjectURI) } 154 + hx-swap="none" 155 + hx-confirm="Hide this record from the public feed?" 156 + @click="moreOpen = false; $dispatch('notify', {message: 'Record hidden from feed'})" 157 + class="action-menu-item action-menu-item-warning" 158 + > 159 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 160 + <path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88"></path> 161 + </svg> 162 + Hide from feed 163 + </button> 164 + } 165 + <div class="action-menu-divider"></div> 166 + } 167 + if props.CanBlockUser && props.AuthorDID != "" && !props.IsOwner { 168 + <button 169 + type="button" 170 + hx-post="/_mod/block" 171 + hx-vals={ fmt.Sprintf(`{"did": "%s"}`, props.AuthorDID) } 172 + hx-swap="none" 173 + hx-confirm="Block this user? All their content will be hidden from the feed." 174 + @click="moreOpen = false; $dispatch('notify', {message: 'User blocked'})" 175 + class="action-menu-item action-menu-item-danger" 176 + > 177 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 178 + <path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 0 0 5.636 5.636m12.728 12.728A9 9 0 0 1 5.636 5.636m12.728 12.728L5.636 5.636"></path> 179 + </svg> 180 + Block user 181 + </button> 182 + <div class="action-menu-divider"></div> 183 + } 184 + <!-- Report (available to authenticated users) --> 185 + if props.IsAuthenticated && !props.IsOwner { 186 + <button 187 + type="button" 188 + @click={ fmt.Sprintf("moreOpen = false; document.getElementById('report-modal-%s').showModal()", escapeForAlpine(props.SubjectURI)) } 189 + class="action-menu-item" 190 + > 191 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 192 + <path stroke-linecap="round" stroke-linejoin="round" d="M3 3v1.5M3 21v-6m0 0 2.77-.693a9 9 0 0 1 6.208.682l.108.054a9 9 0 0 0 6.086.71l3.114-.732a48.524 48.524 0 0 1-.005-10.499l-3.11.732a9 9 0 0 1-6.085-.711l-.108-.054a9 9 0 0 0-6.208-.682L3 4.5M3 15V4.5"></path> 193 + </svg> 194 + Report 195 + </button> 196 + } 125 197 </div> 126 198 </div> 199 + <!-- Report modal (only for authenticated non-owners) --> 200 + if props.IsAuthenticated && !props.IsOwner && props.SubjectURI != "" { 201 + @ReportModal(ReportModalProps{ 202 + ID: fmt.Sprintf("report-modal-%s", escapeForAlpine(props.SubjectURI)), 203 + SubjectURI: props.SubjectURI, 204 + SubjectCID: props.SubjectCID, 205 + }) 206 + } 127 207 </div> 128 208 } 129 209 ··· 145 225 <span x-show="copied" x-cloak>Copied!</span> 146 226 </button> 147 227 } 228 + 229 + // ReportModalProps defines properties for the report modal 230 + type ReportModalProps struct { 231 + ID string // Unique ID for the dialog 232 + SubjectURI string 233 + SubjectCID string 234 + } 235 + 236 + // ReportModal renders an inline report modal for the action bar 237 + templ ReportModal(props ReportModalProps) { 238 + <dialog id={ props.ID } class="modal-dialog" x-data="{ reason: '', charCount: 0, submitting: false, error: '', success: false }"> 239 + <div class="modal-content"> 240 + <h3 class="modal-title">Report Content</h3> 241 + <template x-if="!success"> 242 + <form 243 + @submit.prevent={ fmt.Sprintf(` 244 + submitting = true; 245 + error = ''; 246 + const dialog = document.getElementById('%s'); 247 + fetch('/api/report', { 248 + method: 'POST', 249 + headers: {'Content-Type': 'application/json'}, 250 + body: JSON.stringify({ 251 + subject_uri: '%s', 252 + subject_cid: '%s', 253 + reason: reason 254 + }) 255 + }) 256 + .then(r => r.json().then(data => ({ok: r.ok, data}))) 257 + .then(({ok, data}) => { 258 + submitting = false; 259 + if (ok) { 260 + success = true; 261 + setTimeout(() => dialog.close(), 2000); 262 + } else { 263 + error = data.message || 'Failed to submit report'; 264 + } 265 + }) 266 + .catch(() => { 267 + submitting = false; 268 + error = 'Network error. Please try again.'; 269 + }); 270 + `, props.ID, escapeForJS(props.SubjectURI), escapeForJS(props.SubjectCID)) } 271 + class="space-y-4" 272 + > 273 + <p class="text-sm text-brown-700"> 274 + Please describe why you're reporting this content. Reports are reviewed by moderators. 275 + </p> 276 + <div> 277 + <textarea 278 + x-model="reason" 279 + @input="charCount = reason.length" 280 + name="reason" 281 + placeholder="Describe the issue (optional)" 282 + rows="4" 283 + maxlength="500" 284 + class="w-full form-textarea" 285 + ></textarea> 286 + <div class="flex justify-between text-xs text-brown-500 mt-1"> 287 + <span>Optional, but helpful for moderators</span> 288 + <span x-text="charCount + '/500'"></span> 289 + </div> 290 + </div> 291 + <template x-if="error"> 292 + <div class="bg-red-100 border border-red-300 text-red-800 px-3 py-2 rounded-lg text-sm" x-text="error"></div> 293 + </template> 294 + <div class="flex gap-2"> 295 + <button 296 + type="submit" 297 + class="flex-1 btn-primary" 298 + x-bind:disabled="submitting" 299 + > 300 + <span x-show="!submitting">Submit Report</span> 301 + <span x-show="submitting">Submitting...</span> 302 + </button> 303 + <button 304 + type="button" 305 + @click="$el.closest('dialog').close()" 306 + class="flex-1 btn-secondary" 307 + x-bind:disabled="submitting" 308 + > 309 + Cancel 310 + </button> 311 + </div> 312 + </form> 313 + </template> 314 + <template x-if="success"> 315 + <div class="text-center py-4"> 316 + <div class="text-green-600 mb-2"> 317 + <svg class="w-12 h-12 mx-auto" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 318 + <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"></path> 319 + </svg> 320 + </div> 321 + <p class="font-medium text-brown-900">Report Submitted</p> 322 + <p class="text-sm text-brown-600 mt-1">Thank you for helping keep our community safe.</p> 323 + </div> 324 + </template> 325 + </div> 326 + </dialog> 327 + } 328 + 329 + // escapeForAlpine escapes special characters in a string for use in Alpine.js expressions. 330 + // This creates a safe ID by replacing problematic characters. 331 + func escapeForAlpine(s string) string { 332 + // Replace characters that are problematic in HTML IDs and Alpine expressions 333 + s = strings.ReplaceAll(s, ":", "_") 334 + s = strings.ReplaceAll(s, "/", "_") 335 + s = strings.ReplaceAll(s, ".", "_") 336 + return s 337 + } 338 + 339 + // escapeForJS escapes special characters in a string for use in JavaScript string literals. 340 + func escapeForJS(s string) string { 341 + s = strings.ReplaceAll(s, "\\", "\\\\") 342 + s = strings.ReplaceAll(s, "'", "\\'") 343 + s = strings.ReplaceAll(s, "\n", "\\n") 344 + s = strings.ReplaceAll(s, "\r", "\\r") 345 + return s 346 + }
+101
internal/web/components/dialog_modals.templ
··· 326 326 </dialog> 327 327 } 328 328 329 + // ReportDialogModalProps defines properties for the report dialog 330 + type ReportDialogModalProps struct { 331 + SubjectURI string 332 + SubjectCID string 333 + } 334 + 335 + // ReportDialogModal renders the report modal for submitting content reports 336 + templ ReportDialogModal(props ReportDialogModalProps) { 337 + <dialog id="report-modal" class="modal-dialog" x-data="{ reason: '', charCount: 0, submitting: false, error: '', success: false }"> 338 + <div class="modal-content"> 339 + <h3 class="modal-title">Report Content</h3> 340 + <template x-if="!success"> 341 + <form 342 + @submit.prevent=" 343 + submitting = true; 344 + error = ''; 345 + const dialog = document.getElementById('report-modal'); 346 + fetch('/api/report', { 347 + method: 'POST', 348 + headers: {'Content-Type': 'application/json'}, 349 + body: JSON.stringify({ 350 + subject_uri: $el.querySelector('[name=subject_uri]').value, 351 + subject_cid: $el.querySelector('[name=subject_cid]').value, 352 + reason: reason 353 + }) 354 + }) 355 + .then(r => r.json().then(data => ({ok: r.ok, data}))) 356 + .then(({ok, data}) => { 357 + submitting = false; 358 + if (ok) { 359 + success = true; 360 + setTimeout(() => dialog.close(), 2000); 361 + } else { 362 + error = data.message || 'Failed to submit report'; 363 + } 364 + }) 365 + .catch(() => { 366 + submitting = false; 367 + error = 'Network error. Please try again.'; 368 + }); 369 + " 370 + class="space-y-4" 371 + > 372 + <input type="hidden" name="subject_uri" value={ props.SubjectURI }/> 373 + <input type="hidden" name="subject_cid" value={ props.SubjectCID }/> 374 + <p class="text-sm text-brown-700"> 375 + Please describe why you're reporting this content. Reports are reviewed by moderators. 376 + </p> 377 + <div> 378 + <textarea 379 + x-model="reason" 380 + @input="charCount = reason.length" 381 + name="reason" 382 + placeholder="Describe the issue (optional)" 383 + rows="4" 384 + maxlength="500" 385 + class="w-full form-textarea" 386 + ></textarea> 387 + <div class="flex justify-between text-xs text-brown-500 mt-1"> 388 + <span>Optional, but helpful for moderators</span> 389 + <span x-text="charCount + '/500'"></span> 390 + </div> 391 + </div> 392 + <template x-if="error"> 393 + <div class="bg-red-100 border border-red-300 text-red-800 px-3 py-2 rounded-lg text-sm" x-text="error"></div> 394 + </template> 395 + <div class="flex gap-2"> 396 + <button 397 + type="submit" 398 + class="flex-1 btn-primary" 399 + :disabled="submitting" 400 + > 401 + <span x-show="!submitting">Submit Report</span> 402 + <span x-show="submitting">Submitting...</span> 403 + </button> 404 + <button 405 + type="button" 406 + @click="$el.closest('dialog').close()" 407 + class="flex-1 btn-secondary" 408 + :disabled="submitting" 409 + > 410 + Cancel 411 + </button> 412 + </div> 413 + </form> 414 + </template> 415 + <template x-if="success"> 416 + <div class="text-center py-4"> 417 + <div class="text-green-600 mb-2"> 418 + <svg class="w-12 h-12 mx-auto" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 419 + <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"></path> 420 + </svg> 421 + </div> 422 + <p class="font-medium text-brown-900">Report Submitted</p> 423 + <p class="text-sm text-brown-600 mt-1">Thank you for helping keep our community safe.</p> 424 + </div> 425 + </template> 426 + </div> 427 + </dialog> 428 + } 429 + 329 430 // Helper function to get string value from bean (handles nil case) 330 431 func getStringValue(entity interface{}, field string) string { 331 432 if entity == nil {
+2 -4
internal/web/components/entity_tables.templ
··· 1 1 package components 2 2 3 - import ( 4 - "arabica/internal/models" 5 - ) 3 + import "arabica/internal/models" 6 4 7 5 // BeansTableProps defines props for the shared beans table 8 6 type BeansTableProps struct { ··· 141 139 { roaster.Website } 142 140 } else { 143 141 <a href={ templ.URL(roaster.Website) } target="_blank" rel="noopener noreferrer" class="text-brown-700 hover:text-brown-900 underline"> 144 - Visit 142 + { templ.URL(roaster.Website) } 145 143 </a> 146 144 } 147 145 } else {
+34 -9
internal/web/components/header.templ
··· 2 2 3 3 import "arabica/internal/web/bff" 4 4 5 + // HeaderProps contains all properties for the header component 6 + type HeaderProps struct { 7 + IsAuthenticated bool 8 + UserProfile *bff.UserProfile 9 + UserDID string 10 + IsModerator bool // Show admin link in dropdown 11 + } 12 + 5 13 templ Header(isAuthenticated bool, userProfile *bff.UserProfile, userDID string) { 14 + @HeaderWithProps(HeaderProps{ 15 + IsAuthenticated: isAuthenticated, 16 + UserProfile: userProfile, 17 + UserDID: userDID, 18 + }) 19 + } 20 + 21 + templ HeaderWithProps(props HeaderProps) { 6 22 <nav class="sticky top-0 z-50 bg-gradient-to-br from-brown-800 to-brown-900 text-white shadow-xl border-b-2 border-brown-600" style="view-transition-name: header-nav;"> 7 23 <div class="container mx-auto px-4 py-4"> 8 24 <div class="flex items-center justify-between"> ··· 13 29 </a> 14 30 <!-- Navigation links --> 15 31 <div class="flex items-center gap-4"> 16 - if isAuthenticated { 32 + if props.IsAuthenticated { 17 33 <!-- User profile dropdown --> 18 34 <div x-data="{ open: false }" class="relative"> 19 35 <button @click="open = !open" @click.outside="open = false" class="flex items-center gap-2 hover:opacity-80 transition focus:outline-none"> 20 36 @Avatar(AvatarProps{ 21 - AvatarURL: getHeaderAvatarURL(userProfile), 22 - DisplayName: getHeaderDisplayName(userProfile), 37 + AvatarURL: getHeaderAvatarURL(props.UserProfile), 38 + DisplayName: getHeaderDisplayName(props.UserProfile), 23 39 Size: "sm", 24 40 }) 25 41 <svg class="w-4 h-4 transition-transform" :class="{ 'rotate-180': open }" fill="none" stroke="currentColor" viewBox="0 0 24 24"> ··· 28 44 </button> 29 45 <!-- Dropdown menu --> 30 46 <div x-show="open" x-cloak x-transition:enter="transition ease-out duration-100" x-transition:enter-start="transform opacity-0 scale-95" x-transition:enter-end="transform opacity-100 scale-100" x-transition:leave="transition ease-in duration-75" x-transition:leave-start="transform opacity-100 scale-100" x-transition:leave-end="transform opacity-0 scale-95" class="dropdown-menu"> 31 - if userProfile != nil && userProfile.Handle != "" { 47 + if props.UserProfile != nil && props.UserProfile.Handle != "" { 32 48 <div class="dropdown-header"> 33 49 <p class="text-sm font-medium text-brown-900 truncate"> 34 - if userProfile.DisplayName != "" { 35 - { userProfile.DisplayName } 50 + if props.UserProfile.DisplayName != "" { 51 + { props.UserProfile.DisplayName } 36 52 } else { 37 - { userProfile.Handle } 53 + { props.UserProfile.Handle } 38 54 } 39 55 </p> 40 - <p class="text-xs text-brown-500 truncate">{ "@" + userProfile.Handle }</p> 56 + <p class="text-xs text-brown-500 truncate">{ "@" + props.UserProfile.Handle }</p> 41 57 </div> 42 58 } 43 - <a href={ templ.SafeURL("/profile/" + getProfileIdentifier(userProfile, userDID)) } class="dropdown-item"> 59 + <a href={ templ.SafeURL("/profile/" + getProfileIdentifier(props.UserProfile, props.UserDID)) } class="dropdown-item"> 44 60 View Profile 45 61 </a> 46 62 <a href="/brews" class="dropdown-item"> ··· 52 68 <a href="#" class="dropdown-item-disabled"> 53 69 Settings (coming soon) 54 70 </a> 71 + if props.IsModerator { 72 + <div class="dropdown-divider"></div> 73 + <a href="/_mod" class="dropdown-item dropdown-item-mod"> 74 + <svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 75 + <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z"></path> 76 + </svg> 77 + Moderation 78 + </a> 79 + } 55 80 <div class="dropdown-divider"> 56 81 <form action="/logout" method="POST" @submit="if(window.ArabicaCache)window.ArabicaCache.invalidateCache()"> 57 82 <button type="submit" class="w-full text-left px-4 py-2 text-sm text-brown-700 hover:bg-brown-50 transition-colors">
+7 -1
internal/web/components/layout.templ
··· 9 9 UserDID string 10 10 UserProfile *bff.UserProfile 11 11 CSPNonce string 12 + IsModerator bool // User has moderation permissions 12 13 13 14 // OpenGraph metadata (optional, uses defaults if empty) 14 15 OGTitle string // Falls back to Title + " - Arabica" ··· 131 132 data-user-did={ data.UserDID } 132 133 } 133 134 > 134 - @Header(data.IsAuthenticated, data.UserProfile, data.UserDID) 135 + @HeaderWithProps(HeaderProps{ 136 + IsAuthenticated: data.IsAuthenticated, 137 + UserProfile: data.UserProfile, 138 + UserDID: data.UserDID, 139 + IsModerator: data.IsModerator, 140 + }) 135 141 <main class="flex-grow container mx-auto px-4 py-8" data-transition> 136 142 @content 137 143 </main>
+588
internal/web/pages/admin.templ
··· 1 + package pages 2 + 3 + import ( 4 + "arabica/internal/moderation" 5 + "arabica/internal/web/components" 6 + "fmt" 7 + ) 8 + 9 + // EnrichedReport wraps a report with resolved profile info 10 + type EnrichedReport struct { 11 + Report moderation.Report 12 + OwnerHandle string 13 + ReporterHandle string 14 + PostContent string // Summary of the reported content 15 + } 16 + 17 + type AdminProps struct { 18 + HiddenRecords []moderation.HiddenRecord 19 + AuditLog []moderation.AuditEntry 20 + Reports []EnrichedReport 21 + BlockedUsers []moderation.BlacklistedUser 22 + CanHide bool 23 + CanUnhide bool 24 + CanViewLogs bool 25 + CanViewReports bool 26 + CanBlock bool 27 + CanUnblock bool 28 + } 29 + 30 + templ Admin(layout *components.LayoutData, props AdminProps) { 31 + @components.Layout(layout, AdminContent(props)) 32 + } 33 + 34 + templ AdminContent(props AdminProps) { 35 + <div class="page-container-lg"> 36 + <div class="mb-8"> 37 + <h1 class="page-title flex items-center gap-2"> 38 + <svg class="w-8 h-8 text-amber-600" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 39 + <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z"></path> 40 + </svg> 41 + Moderation Dashboard 42 + </h1> 43 + <p class="text-brown-600 mt-2">Manage hidden content, review reports, and view moderation activity.</p> 44 + </div> 45 + @AdminDashboardBody(props) 46 + </div> 47 + } 48 + 49 + // AdminDashboardBody renders the dynamic tabs and panels. This is the HTMX 50 + // swap target so that the static page header doesn't flicker on refresh. 51 + templ AdminDashboardBody(props AdminProps) { 52 + <div 53 + id="mod-dashboard" 54 + hx-get="/_mod/content" 55 + hx-trigger="mod-action from:body" 56 + hx-swap="outerHTML" 57 + x-data="{ activeTab: sessionStorage.getItem('mod-tab') || 'hidden' }" 58 + x-effect="sessionStorage.setItem('mod-tab', activeTab)" 59 + class="space-y-6" 60 + > 61 + <div class="border-b border-brown-200"> 62 + <nav class="-mb-px flex space-x-8"> 63 + if props.CanHide || props.CanUnhide { 64 + <button 65 + type="button" 66 + @click="activeTab = 'hidden'" 67 + :class="activeTab === 'hidden' ? 'border-amber-500 text-amber-600' : 'border-transparent text-brown-500 hover:text-brown-700 hover:border-brown-300'" 68 + class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors" 69 + > 70 + Hidden Records 71 + if len(props.HiddenRecords) > 0 { 72 + <span class="ml-2 bg-brown-100 text-brown-700 py-0.5 px-2 rounded-full text-xs"> 73 + { fmt.Sprintf("%d", len(props.HiddenRecords)) } 74 + </span> 75 + } 76 + </button> 77 + } 78 + if props.CanBlock || props.CanUnblock { 79 + <button 80 + type="button" 81 + @click="activeTab = 'blocked'" 82 + :class="activeTab === 'blocked' ? 'border-amber-500 text-amber-600' : 'border-transparent text-brown-500 hover:text-brown-700 hover:border-brown-300'" 83 + class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors" 84 + > 85 + Blocked Users 86 + if len(props.BlockedUsers) > 0 { 87 + <span class="ml-2 bg-red-100 text-red-700 py-0.5 px-2 rounded-full text-xs"> 88 + { fmt.Sprintf("%d", len(props.BlockedUsers)) } 89 + </span> 90 + } 91 + </button> 92 + } 93 + if props.CanViewReports { 94 + <button 95 + type="button" 96 + @click="activeTab = 'reports'" 97 + :class="activeTab === 'reports' ? 'border-amber-500 text-amber-600' : 'border-transparent text-brown-500 hover:text-brown-700 hover:border-brown-300'" 98 + class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors" 99 + > 100 + Reports 101 + if len(props.Reports) > 0 { 102 + <span class="ml-2 bg-red-100 text-red-700 py-0.5 px-2 rounded-full text-xs"> 103 + { fmt.Sprintf("%d", len(props.Reports)) } 104 + </span> 105 + } 106 + </button> 107 + } 108 + if props.CanViewLogs { 109 + <button 110 + type="button" 111 + @click="activeTab = 'activity'" 112 + :class="activeTab === 'activity' ? 'border-amber-500 text-amber-600' : 'border-transparent text-brown-500 hover:text-brown-700 hover:border-brown-300'" 113 + class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors" 114 + > 115 + Activity Log 116 + </button> 117 + } 118 + </nav> 119 + </div> 120 + 121 + <!-- Hidden Records Tab --> 122 + if props.CanHide || props.CanUnhide { 123 + <div x-show="activeTab === 'hidden'" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"> 124 + <div class="card card-inner"> 125 + <h2 class="section-title">Hidden Records</h2> 126 + if len(props.HiddenRecords) == 0 { 127 + <div class="bg-brown-50 rounded-lg p-4 text-center text-brown-600"> 128 + <p>No records are currently hidden.</p> 129 + </div> 130 + } else { 131 + <div class="space-y-3"> 132 + for _, record := range props.HiddenRecords { 133 + @HiddenRecordCard(record, props.CanUnhide) 134 + } 135 + </div> 136 + } 137 + </div> 138 + </div> 139 + } 140 + 141 + <!-- Blocked Users Tab --> 142 + if props.CanBlock || props.CanUnblock { 143 + <div x-show="activeTab === 'blocked'" x-cloak x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"> 144 + <div class="card card-inner"> 145 + <h2 class="section-title">Blocked Users</h2> 146 + if len(props.BlockedUsers) == 0 { 147 + <div class="bg-brown-50 rounded-lg p-4 text-center text-brown-600"> 148 + <p>No users are currently blocked.</p> 149 + </div> 150 + } else { 151 + <div class="space-y-3"> 152 + for _, user := range props.BlockedUsers { 153 + @BlockedUserCard(user, props.CanUnblock) 154 + } 155 + </div> 156 + } 157 + </div> 158 + </div> 159 + } 160 + 161 + <!-- Reports Tab --> 162 + if props.CanViewReports { 163 + <div x-show="activeTab === 'reports'" x-cloak x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"> 164 + <div class="card card-inner"> 165 + <h2 class="section-title">Pending Reports</h2> 166 + if len(props.Reports) == 0 { 167 + <div class="bg-brown-50 rounded-lg p-4 text-center text-brown-600"> 168 + <p>No pending reports to review.</p> 169 + </div> 170 + } else { 171 + <div class="space-y-4"> 172 + for _, report := range props.Reports { 173 + @ReportCard(report, props.CanHide, props.CanBlock) 174 + } 175 + </div> 176 + } 177 + </div> 178 + </div> 179 + } 180 + 181 + <!-- Activity Log Tab --> 182 + if props.CanViewLogs { 183 + <div x-show="activeTab === 'activity'" x-cloak x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"> 184 + <div class="card card-inner"> 185 + <h2 class="section-title">Recent Activity</h2> 186 + if len(props.AuditLog) == 0 { 187 + <div class="bg-brown-50 rounded-lg p-4 text-center text-brown-600"> 188 + <p>No moderation activity recorded yet.</p> 189 + </div> 190 + } else { 191 + <div class="space-y-3"> 192 + for _, entry := range props.AuditLog { 193 + @AuditLogCard(entry) 194 + } 195 + </div> 196 + } 197 + </div> 198 + </div> 199 + } 200 + </div> 201 + } 202 + 203 + templ HiddenRecordCard(record moderation.HiddenRecord, canUnhide bool) { 204 + <div class="bg-brown-50 border border-brown-200 rounded-lg p-4"> 205 + <div class="flex flex-col gap-3"> 206 + <!-- URI with copy button --> 207 + <div> 208 + <span class="text-xs font-medium text-brown-500 uppercase tracking-wide">Record URI</span> 209 + <div 210 + x-data="{ copied: false, copyText() { navigator.clipboard.writeText(this.$refs.uri.textContent.trim()).then(() => { this.copied = true; setTimeout(() => this.copied = false, 2000) }) } }" 211 + class="mt-1 flex items-start gap-2" 212 + > 213 + <code x-ref="uri" class="text-sm bg-brown-100 px-2 py-1 rounded break-all flex-1 font-mono">{ record.ATURI }</code> 214 + <button 215 + type="button" 216 + x-on:click="copyText()" 217 + class="flex-shrink-0 w-8 h-8 flex items-center justify-center text-brown-500 hover:text-brown-700 hover:bg-brown-200 rounded transition-colors" 218 + x-bind:title="copied ? 'Copied!' : 'Copy to clipboard'" 219 + > 220 + <svg x-show="!copied" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> 221 + <path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path> 222 + </svg> 223 + <svg x-show="copied" x-cloak class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> 224 + <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"></path> 225 + </svg> 226 + </button> 227 + </div> 228 + </div> 229 + 230 + <!-- Meta info row --> 231 + <div class="flex flex-wrap gap-x-6 gap-y-2 text-sm"> 232 + <div> 233 + <span class="text-brown-500">Hidden:</span> 234 + <span class="text-brown-700 ml-1">{ record.HiddenAt.Format("Jan 2, 2006 15:04") }</span> 235 + </div> 236 + <div> 237 + <span class="text-brown-500">By:</span> 238 + <code class="text-brown-700 ml-1 text-xs">{ record.HiddenBy }</code> 239 + if record.AutoHidden { 240 + <span class="ml-1 text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded">auto</span> 241 + } 242 + </div> 243 + if record.Reason != "" { 244 + <div> 245 + <span class="text-brown-500">Reason:</span> 246 + <span class="text-brown-700 ml-1">{ record.Reason }</span> 247 + </div> 248 + } 249 + </div> 250 + 251 + <!-- Actions --> 252 + if canUnhide { 253 + <div class="pt-2 border-t border-brown-200"> 254 + <button 255 + class="text-sm text-amber-600 hover:text-amber-800 font-medium" 256 + hx-post="/_mod/unhide" 257 + hx-vals={ fmt.Sprintf(`{"uri": "%s"}`, record.ATURI) } 258 + hx-swap="none" 259 + hx-confirm="Are you sure you want to unhide this record?" 260 + > 261 + Unhide Record 262 + </button> 263 + </div> 264 + } 265 + </div> 266 + </div> 267 + } 268 + 269 + templ BlockedUserCard(user moderation.BlacklistedUser, canUnblock bool) { 270 + <div class="bg-brown-50 border border-brown-200 rounded-lg p-4"> 271 + <div class="flex flex-col gap-3"> 272 + <!-- DID with copy button --> 273 + <div> 274 + <span class="text-xs font-medium text-brown-500 uppercase tracking-wide">User DID</span> 275 + <div 276 + x-data="{ copied: false, copyText() { navigator.clipboard.writeText(this.$refs.did.textContent.trim()).then(() => { this.copied = true; setTimeout(() => this.copied = false, 2000) }) } }" 277 + class="mt-1 flex items-start gap-2" 278 + > 279 + <code x-ref="did" class="text-sm bg-brown-100 px-2 py-1 rounded break-all flex-1 font-mono">{ user.DID }</code> 280 + <button 281 + type="button" 282 + x-on:click="copyText()" 283 + class="flex-shrink-0 w-8 h-8 flex items-center justify-center text-brown-500 hover:text-brown-700 hover:bg-brown-200 rounded transition-colors" 284 + x-bind:title="copied ? 'Copied!' : 'Copy to clipboard'" 285 + > 286 + <svg x-show="!copied" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> 287 + <path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path> 288 + </svg> 289 + <svg x-show="copied" x-cloak class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> 290 + <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"></path> 291 + </svg> 292 + </button> 293 + </div> 294 + </div> 295 + 296 + <!-- Meta info row --> 297 + <div class="flex flex-wrap gap-x-6 gap-y-2 text-sm"> 298 + <div> 299 + <span class="text-brown-500">Blocked:</span> 300 + <span class="text-brown-700 ml-1">{ user.BlacklistedAt.Format("Jan 2, 2006 15:04") }</span> 301 + </div> 302 + <div> 303 + <span class="text-brown-500">By:</span> 304 + <code class="text-brown-700 ml-1 text-xs">{ user.BlacklistedBy }</code> 305 + </div> 306 + if user.Reason != "" { 307 + <div> 308 + <span class="text-brown-500">Reason:</span> 309 + <span class="text-brown-700 ml-1">{ user.Reason }</span> 310 + </div> 311 + } 312 + </div> 313 + 314 + <!-- Actions --> 315 + if canUnblock { 316 + <div class="pt-2 border-t border-brown-200"> 317 + <button 318 + class="text-sm text-amber-600 hover:text-amber-800 font-medium" 319 + hx-post="/_mod/unblock" 320 + hx-vals={ fmt.Sprintf(`{"did": "%s"}`, user.DID) } 321 + hx-swap="none" 322 + hx-confirm="Are you sure you want to unblock this user? Their content will reappear in the feed." 323 + > 324 + Unblock User 325 + </button> 326 + </div> 327 + } 328 + </div> 329 + </div> 330 + } 331 + 332 + templ ReportCard(report EnrichedReport, canHide bool, canBlock bool) { 333 + <div class="bg-brown-50 border border-brown-200 rounded-lg p-4"> 334 + <div class="flex flex-col gap-4"> 335 + <!-- Header with status badge and time --> 336 + <div class="flex items-center justify-between"> 337 + @ReportStatusBadge(report.Report.Status) 338 + <span class="text-sm text-brown-500">{ report.Report.CreatedAt.Format("Jan 2, 2006 15:04") }</span> 339 + </div> 340 + 341 + <!-- AT-URI with copy button --> 342 + <div> 343 + <span class="text-xs font-medium text-brown-500 uppercase tracking-wide">Record URI</span> 344 + <div 345 + x-data="{ copied: false, copyText() { navigator.clipboard.writeText(this.$refs.uri.textContent.trim()).then(() => { this.copied = true; setTimeout(() => this.copied = false, 2000) }) } }" 346 + class="mt-1 flex items-start gap-2" 347 + > 348 + <code x-ref="uri" class="text-sm bg-brown-100 px-2 py-1 rounded break-all flex-1 font-mono">{ report.Report.SubjectURI }</code> 349 + <button 350 + type="button" 351 + x-on:click="copyText()" 352 + class="flex-shrink-0 w-8 h-8 flex items-center justify-center text-brown-500 hover:text-brown-700 hover:bg-brown-200 rounded transition-colors" 353 + x-bind:title="copied ? 'Copied!' : 'Copy to clipboard'" 354 + > 355 + <svg x-show="!copied" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> 356 + <path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path> 357 + </svg> 358 + <svg x-show="copied" x-cloak class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> 359 + <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"></path> 360 + </svg> 361 + </button> 362 + </div> 363 + </div> 364 + 365 + <!-- Owner info --> 366 + <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> 367 + <div> 368 + <span class="text-xs font-medium text-brown-500 uppercase tracking-wide">Content Owner</span> 369 + <div class="mt-1"> 370 + if report.OwnerHandle != "" { 371 + <a href={ templ.SafeURL("/profile/" + report.OwnerHandle) } class="text-amber-600 hover:text-amber-700 font-medium"> 372 + { "@" + report.OwnerHandle } 373 + </a> 374 + } else { 375 + <code class="text-sm text-brown-700">{ report.Report.SubjectDID }</code> 376 + } 377 + <div 378 + x-data="{ copied: false, copyText() { navigator.clipboard.writeText(this.$refs.did.textContent.trim()).then(() => { this.copied = true; setTimeout(() => this.copied = false, 2000) }) } }" 379 + class="flex items-center gap-1 mt-1" 380 + > 381 + <code x-ref="did" class="text-xs text-brown-500 break-all">{ report.Report.SubjectDID }</code> 382 + <button 383 + type="button" 384 + x-on:click="copyText()" 385 + class="flex-shrink-0 w-5 h-5 flex items-center justify-center text-brown-400 hover:text-brown-600 rounded transition-colors" 386 + x-bind:title="copied ? 'Copied!' : 'Copy DID'" 387 + > 388 + <svg x-show="!copied" class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> 389 + <path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path> 390 + </svg> 391 + <svg x-show="copied" x-cloak class="w-3 h-3 text-green-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> 392 + <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"></path> 393 + </svg> 394 + </button> 395 + </div> 396 + </div> 397 + </div> 398 + <div> 399 + <span class="text-xs font-medium text-brown-500 uppercase tracking-wide">Reported By</span> 400 + <div class="mt-1"> 401 + if report.ReporterHandle != "" { 402 + <a href={ templ.SafeURL("/profile/" + report.ReporterHandle) } class="text-amber-600 hover:text-amber-700 font-medium"> 403 + { "@" + report.ReporterHandle } 404 + </a> 405 + } else { 406 + <code class="text-sm text-brown-700">{ report.Report.ReporterDID }</code> 407 + } 408 + <div 409 + x-data="{ copied: false, copyText() { navigator.clipboard.writeText(this.$refs.did.textContent.trim()).then(() => { this.copied = true; setTimeout(() => this.copied = false, 2000) }) } }" 410 + class="flex items-center gap-1 mt-1" 411 + > 412 + <code x-ref="did" class="text-xs text-brown-500 break-all">{ report.Report.ReporterDID }</code> 413 + <button 414 + type="button" 415 + x-on:click="copyText()" 416 + class="flex-shrink-0 w-5 h-5 flex items-center justify-center text-brown-400 hover:text-brown-600 rounded transition-colors" 417 + x-bind:title="copied ? 'Copied!' : 'Copy DID'" 418 + > 419 + <svg x-show="!copied" class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> 420 + <path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path> 421 + </svg> 422 + <svg x-show="copied" x-cloak class="w-3 h-3 text-green-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> 423 + <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"></path> 424 + </svg> 425 + </button> 426 + </div> 427 + </div> 428 + </div> 429 + </div> 430 + 431 + <!-- Post content preview --> 432 + if report.PostContent != "" { 433 + <div> 434 + <span class="text-xs font-medium text-brown-500 uppercase tracking-wide">Content Preview</span> 435 + <div class="mt-1 bg-brown-100 rounded-lg p-3"> 436 + <p class="text-sm text-brown-700 whitespace-pre-wrap">{ report.PostContent }</p> 437 + </div> 438 + </div> 439 + } 440 + 441 + <!-- Report reason --> 442 + if report.Report.Reason != "" { 443 + <div> 444 + <span class="text-xs font-medium text-brown-500 uppercase tracking-wide">Report Reason</span> 445 + <p class="mt-1 text-sm text-brown-700">{ report.Report.Reason }</p> 446 + </div> 447 + } 448 + 449 + <!-- Actions --> 450 + if report.Report.Status == moderation.ReportStatusPending { 451 + <div class="pt-3 border-t border-brown-200 flex flex-wrap gap-3"> 452 + if canHide { 453 + <button 454 + class="text-sm bg-amber-100 text-amber-700 hover:bg-amber-200 px-3 py-1.5 rounded font-medium transition-colors" 455 + hx-post="/_mod/hide" 456 + hx-vals={ fmt.Sprintf(`{"uri": "%s", "reason": "Reported by user"}`, report.Report.SubjectURI) } 457 + hx-swap="none" 458 + hx-confirm="Hide this record from the feed?" 459 + > 460 + Hide Record 461 + </button> 462 + } 463 + if canBlock { 464 + <button 465 + class="text-sm bg-red-100 text-red-700 hover:bg-red-200 px-3 py-1.5 rounded font-medium transition-colors" 466 + hx-post="/_mod/block" 467 + hx-vals={ fmt.Sprintf(`{"did": "%s", "reason": "Reported by user"}`, report.Report.SubjectDID) } 468 + hx-swap="none" 469 + hx-confirm={ fmt.Sprintf("Block user %s? All their content will be hidden from the feed.", report.Report.SubjectDID) } 470 + > 471 + Block User 472 + </button> 473 + } 474 + <button 475 + class="text-sm text-brown-600 hover:text-brown-800 px-3 py-1.5 rounded font-medium transition-colors" 476 + hx-post="/_mod/dismiss-report" 477 + hx-vals={ fmt.Sprintf(`{"id": "%s"}`, report.Report.ID) } 478 + hx-swap="none" 479 + hx-confirm="Dismiss this report?" 480 + > 481 + Dismiss 482 + </button> 483 + </div> 484 + } 485 + </div> 486 + </div> 487 + } 488 + 489 + templ ReportStatusBadge(status moderation.ReportStatus) { 490 + switch status { 491 + case moderation.ReportStatusPending: 492 + <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800"> 493 + Pending 494 + </span> 495 + case moderation.ReportStatusDismissed: 496 + <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800"> 497 + Dismissed 498 + </span> 499 + case moderation.ReportStatusActioned: 500 + <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800"> 501 + Actioned 502 + </span> 503 + default: 504 + <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-brown-100 text-brown-800"> 505 + { string(status) } 506 + </span> 507 + } 508 + } 509 + 510 + templ AuditLogCard(entry moderation.AuditEntry) { 511 + <div class="bg-brown-50 border border-brown-200 rounded-lg p-4"> 512 + <div class="flex flex-col gap-3"> 513 + <!-- Action badge and time --> 514 + <div class="flex items-center justify-between"> 515 + @AuditActionBadge(entry.Action) 516 + <span class="text-sm text-brown-500">{ entry.Timestamp.Format("Jan 2, 2006 15:04") }</span> 517 + </div> 518 + 519 + <!-- Target URI with copy button --> 520 + if entry.TargetURI != "" { 521 + <div> 522 + <span class="text-xs font-medium text-brown-500 uppercase tracking-wide">Target</span> 523 + <div 524 + x-data="{ copied: false, copyText() { navigator.clipboard.writeText(this.$refs.uri.textContent.trim()).then(() => { this.copied = true; setTimeout(() => this.copied = false, 2000) }) } }" 525 + class="mt-1 flex items-start gap-2" 526 + > 527 + <code x-ref="uri" class="text-sm bg-brown-100 px-2 py-1 rounded break-all flex-1 font-mono">{ entry.TargetURI }</code> 528 + <button 529 + type="button" 530 + x-on:click="copyText()" 531 + class="flex-shrink-0 w-8 h-8 flex items-center justify-center text-brown-500 hover:text-brown-700 hover:bg-brown-200 rounded transition-colors" 532 + x-bind:title="copied ? 'Copied!' : 'Copy to clipboard'" 533 + > 534 + <svg x-show="!copied" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> 535 + <path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path> 536 + </svg> 537 + <svg x-show="copied" x-cloak class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> 538 + <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"></path> 539 + </svg> 540 + </button> 541 + </div> 542 + </div> 543 + } 544 + 545 + <!-- Actor info --> 546 + <div class="flex flex-wrap gap-x-6 gap-y-2 text-sm"> 547 + <div> 548 + <span class="text-brown-500">Actor:</span> 549 + <code class="text-brown-700 ml-1 text-xs">{ entry.ActorDID }</code> 550 + if entry.AutoMod { 551 + <span class="ml-1 text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded">auto</span> 552 + } 553 + </div> 554 + if entry.Reason != "" { 555 + <div> 556 + <span class="text-brown-500">Reason:</span> 557 + <span class="text-brown-700 ml-1">{ entry.Reason }</span> 558 + </div> 559 + } 560 + </div> 561 + </div> 562 + </div> 563 + } 564 + 565 + templ AuditActionBadge(action moderation.AuditAction) { 566 + switch action { 567 + case moderation.AuditActionHideRecord: 568 + <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800"> 569 + Hide Record 570 + </span> 571 + case moderation.AuditActionUnhideRecord: 572 + <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800"> 573 + Unhide Record 574 + </span> 575 + case moderation.AuditActionBlacklistUser: 576 + <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800"> 577 + Block User 578 + </span> 579 + case moderation.AuditActionUnblacklistUser: 580 + <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800"> 581 + Unblock User 582 + </span> 583 + default: 584 + <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-brown-100 text-brown-800"> 585 + { string(action) } 586 + </span> 587 + } 588 + }
+25 -2
internal/web/pages/feed.templ
··· 8 8 "fmt" 9 9 ) 10 10 11 + // FeedModerationContext holds moderation state for rendering feed items 12 + type FeedModerationContext struct { 13 + IsModerator bool // User has moderator role 14 + CanHideRecord bool // User has hide_record permission 15 + CanBlockUser bool // User has blacklist_user permission 16 + HiddenURIs map[string]bool // URIs that are currently hidden 17 + } 18 + 11 19 // FeedPartial renders the feed items (for HTMX loading) 12 20 templ FeedPartial(items []*feed.FeedItem, isAuthenticated bool) { 21 + @FeedPartialWithModeration(items, isAuthenticated, FeedModerationContext{}) 22 + } 23 + 24 + // FeedPartialWithModeration renders feed items with moderation context 25 + templ FeedPartialWithModeration(items []*feed.FeedItem, isAuthenticated bool, modCtx FeedModerationContext) { 13 26 <div class="space-y-4"> 14 27 if len(items) > 0 { 15 28 for _, item := range items { 16 - @FeedCard(item, isAuthenticated) 29 + @FeedCardWithModeration(item, isAuthenticated, modCtx) 17 30 } 18 31 } else { 19 32 <div class="bg-brown-100 rounded-lg p-6 text-center text-brown-700 border border-brown-200"> ··· 24 37 </div> 25 38 } 26 39 27 - // FeedCard renders a single feed item card 40 + // FeedCard renders a single feed item card (without moderation context) 28 41 templ FeedCard(item *feed.FeedItem, isAuthenticated bool) { 42 + @FeedCardWithModeration(item, isAuthenticated, FeedModerationContext{}) 43 + } 44 + 45 + // FeedCardWithModeration renders a single feed item card with moderation context 46 + templ FeedCardWithModeration(item *feed.FeedItem, isAuthenticated bool, modCtx FeedModerationContext) { 29 47 <div class="feed-card"> 30 48 <!-- Author row --> 31 49 <div class="flex items-center gap-3 mb-3"> ··· 77 95 EditURL: getEditURL(item), 78 96 DeleteURL: getDeleteURL(item), 79 97 IsAuthenticated: isAuthenticated, 98 + IsModerator: modCtx.IsModerator, 99 + CanHideRecord: modCtx.CanHideRecord, 100 + CanBlockUser: modCtx.CanBlockUser, 101 + IsRecordHidden: modCtx.HiddenURIs[item.SubjectURI], 102 + AuthorDID: item.Author.DID, 80 103 }) 81 104 } 82 105 </div>
+1 -1
internal/web/pages/terms.templ
··· 137 137 </section> 138 138 </div> 139 139 <div class="mt-12 text-center"> 140 - <a href="/" class="btn-primary px-8 py-3"> 140 + <a href="/" class="btn-primary px-8 py-3 font-semibold shadow-lg hover:shadow-xl"> 141 141 Back to Home 142 142 </a> 143 143 </div>
+1 -1
justfile
··· 2 2 default: style templ-generate run 3 3 4 4 run: 5 - @LOG_LEVEL=debug LOG_FORMAT=console go run cmd/server/main.go -known-dids known-dids.txt 5 + @LOG_LEVEL=debug LOG_FORMAT=console ARABICA_MODERATORS_CONFIG=roles.json go run cmd/server/main.go -known-dids known-dids.txt 6 6 7 7 templ-watch: 8 8 @templ generate --watch --proxy="http://localhost:18080" --cmd="go run ./cmd/server -known-dids known-dids.txt"
+13
static/css/app.css
··· 323 323 @apply block px-4 py-2 text-sm text-brown-400 cursor-not-allowed; 324 324 } 325 325 326 + .dropdown-item-mod { 327 + @apply text-amber-700 hover:bg-amber-50; 328 + } 329 + 326 330 .dropdown-header { 327 331 @apply px-4 py-2 border-b border-brown-100; 328 332 } ··· 339 343 @apply text-red-600 hover:bg-red-50; 340 344 } 341 345 346 + .action-menu-item-warning { 347 + @apply text-amber-600 hover:bg-amber-50; 348 + } 349 + 342 350 .action-menu-divider { 343 351 @apply border-t border-brown-200 my-1; 352 + } 353 + 354 + /* Hidden record indicator badge */ 355 + .hidden-badge { 356 + @apply inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-amber-100 text-amber-700 rounded; 344 357 } 345 358 } 346 359