1# sensitive content moderation 2 3## overview 4 5plyr.fm allows artists to upload cover art and use their Bluesky avatars. some of this content may be inappropriate for general audiences (nudity, graphic imagery, etc.). rather than blocking uploads, we blur sensitive images by default and let users opt-in to view them. 6 7this follows our core moderation philosophy: **information, not enforcement**. we flag content and let users decide what they want to see. 8 9## current implementation 10 11### database schema 12 13```sql 14-- tracks flagged images 15CREATE TABLE sensitive_images ( 16 id SERIAL PRIMARY KEY, 17 image_id VARCHAR, -- R2 image ID (for uploaded images) 18 url TEXT, -- full URL (for external images like Bluesky avatars) 19 reason VARCHAR, -- why flagged: 'nudity', 'violence', etc. 20 flagged_at TIMESTAMPTZ, 21 flagged_by VARCHAR -- admin identifier 22); 23 24-- user preference 25ALTER TABLE user_preferences ADD COLUMN show_sensitive_artwork BOOLEAN DEFAULT false; 26``` 27 28images can be flagged by either: 29- `image_id` - for images uploaded to R2 (track artwork, album covers) 30- `url` - for external images (Bluesky avatars synced from PDS) 31 32### frontend architecture 33 34``` 35┌─────────────────────────────────────────────────────────────────┐ 36│ page load │ 37└─────────────────────────────────────────────────────────────────┘ 38 39 ┌─────────────────────┼─────────────────────┐ 40 │ │ │ 41 ▼ ▼ ▼ 42┌───────────────┐ ┌───────────────┐ ┌───────────────┐ 43│ +layout.server│ │ +layout.ts │ │ +layout.svelte│ 44│ fetch flagged │ │ pass through │ │ init client │ 45│ images (SSR) │ │ to pages │ │ moderation │ 46└───────────────┘ └───────────────┘ └───────────────┘ 47 │ │ 48 ▼ ▼ 49┌───────────────┐ ┌───────────────┐ 50│ meta tags use │ │ SensitiveImage│ 51│ SSR data │ │ component uses│ 52│ (link preview)│ │ client store │ 53└───────────────┘ └───────────────┘ 54``` 55 56two-pronged approach: 571. **SSR** - `+layout.server.ts` fetches flagged images for meta tag filtering (link previews) 582. **client** - moderation store fetches same data for runtime blur effect 59 60### SensitiveImage component 61 62wraps any image that might need blurring: 63 64```svelte 65<SensitiveImage src={imageUrl}> 66 <img src={imageUrl} alt="..." /> 67</SensitiveImage> 68``` 69 70the component: 71- checks if `src` matches any flagged image 72- applies CSS blur filter if flagged 73- shows tooltip on hover: "sensitive - enable in portal" 74- respects user's `show_sensitive_artwork` preference 75 76### matching logic 77 78```typescript 79function checkImageSensitive(url: string, data: SensitiveImagesData): boolean { 80 // exact URL match (for external images) 81 if (data.urls.includes(url)) return true; 82 83 // R2 image ID extraction and match 84 const r2Match = url.match(/r2\.dev\/([^/.]+)\./); 85 if (r2Match && data.image_ids.includes(r2Match[1])) return true; 86 87 const cdnMatch = url.match(/\/images\/([^/.]+)\./); 88 if (cdnMatch && data.image_ids.includes(cdnMatch[1])) return true; 89 90 return false; 91} 92``` 93 94### API endpoint 95 96``` 97GET /moderation/sensitive-images 98 99Response: 100{ 101 "image_ids": ["abc123", "def456"], 102 "urls": ["https://cdn.bsky.app/..."] 103} 104``` 105 106returns arrays for SSR compatibility (Sets don't serialize to JSON). 107 108## user experience 109 110### default behavior 111 112- all images matching `sensitive_images` table are blurred 113- tooltip on hover explains how to enable 114- link previews (og:image) exclude sensitive images entirely 115 116### opt-in flow 117 1181. user navigates to portal → "your data" 1192. toggles "sensitive artwork" to enabled 1203. `show_sensitive_artwork` preference saved to database 1214. all sensitive images immediately unblur 122 123## current limitations 124 125### manual flagging only 126 127the `sensitive_images` table is currently populated manually by admins. this is "whack-a-mole" moderation - we flag images as we discover them. 128 129**future improvements needed:** 130 1311. **perceptual hashing** - hash images at upload time, detect re-uploads of flagged content 1322. **AI detection** - integrate NSFW detection API (AWS Rekognition, Google Vision, etc.) 1333. **user reporting** - let users flag inappropriate content 1344. **artist self-labeling** - let artists mark their own content as sensitive 135 136### no ATProto labels yet 137 138unlike copyright moderation, sensitive content flags don't emit ATProto labels. this is intentional for now - we're still figuring out the right taxonomy for content labels vs. copyright labels. 139 140future work might include: 141- `content-warning` label type 142- integration with Bluesky's existing content label system 143- respecting labels from other ATProto services 144 145## moderation workflow 146 147### current process 148 1491. admin discovers inappropriate image (user report, browsing, etc.) 1502. admin identifies the image source: 151 - R2 upload: extract `image_id` from URL 152 - external: copy full URL 1533. admin inserts row into `sensitive_images` via SQL or Neon console 1544. image is immediately blurred for all users 155 156### example: flagging an R2 image 157 158```sql 159-- image URL: https://pub-xxx.r2.dev/images/abc123.jpg 160INSERT INTO sensitive_images (image_id, reason, flagged_by) 161VALUES ('abc123', 'nudity', 'admin'); 162``` 163 164### example: flagging a Bluesky avatar 165 166```sql 167-- avatar URL from artist profile 168INSERT INTO sensitive_images (url, reason, flagged_by) 169VALUES ( 170 'https://cdn.bsky.app/img/avatar/plain/did:plc:xxx/bafkrei...@jpeg', 171 'nudity', 172 'admin' 173); 174``` 175 176## related documentation 177 178- [overview.md](./overview.md) - moderation philosophy and architecture 179- [copyright-detection.md](./copyright-detection.md) - automated copyright scanning 180- [atproto-labeler.md](./atproto-labeler.md) - ATProto label emission