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

Configure Feed

Select the types of activity you want to include in your feed.

feat: mod labels (internal labels, not atproto network labels)

+894 -2
+18
cmd/server/main.go
··· 376 376 h.SetModeration(moderationSvc, moderationStore) 377 377 } 378 378 379 + // Periodic cleanup of expired moderation labels 380 + go func() { 381 + ticker := time.NewTicker(1 * time.Hour) 382 + defer ticker.Stop() 383 + for { 384 + select { 385 + case <-ticker.C: 386 + if n, err := moderationStore.CleanExpiredLabels(ctx); err != nil { 387 + log.Error().Err(err).Msg("Failed to clean expired labels") 388 + } else if n > 0 { 389 + log.Info().Int("count", n).Msg("Cleaned expired moderation labels") 390 + } 391 + case <-ctx.Done(): 392 + return 393 + } 394 + } 395 + }() 396 + 379 397 // Initialize join request handling 380 398 smtpPort := 587 381 399 if portStr := os.Getenv("SMTP_PORT"); portStr != "" {
+145 -1
docs/moderation-improvements.md
··· 302 302 DELETE FROM moderation_labels WHERE expires_at IS NOT NULL AND expires_at < ? 303 303 ``` 304 304 305 + ### Relationship to AT Protocol Labels 306 + 307 + AT Protocol has its own label system (`com.atproto.label`) designed for 308 + **federated, decentralized moderation**. It's worth understanding how it differs 309 + from the internal labels proposed above, whether Arabica should use both, and 310 + when each applies. 311 + 312 + #### How atproto labels work 313 + 314 + atproto labels are cryptographically signed metadata tags produced by independent 315 + **labeler services** — third-party identities (with their own DID and signing 316 + key) that publish labels about accounts or records across the network. Key 317 + properties: 318 + 319 + - **Signed**: Each label is CBOR-encoded and signed with the labeler's 320 + `#atproto_label` key. Clients verify authenticity without trusting the 321 + transport. 322 + - **User-choosable**: Users subscribe to labelers they trust (up to 20). Each 323 + user independently decides how to handle each label value: hide, warn, or 324 + ignore. This is fundamentally different from moderator-imposed decisions. 325 + - **Distributed**: Labels are broadcast via 326 + `com.atproto.label.subscribeLabels` (WebSocket stream) and queryable via 327 + `com.atproto.label.queryLabels`. AppViews hydrate labels into API responses 328 + based on the client's `atproto-accept-labelers` header. 329 + - **Graduated actions**: Label values define `severity` (inform/alert/none) and 330 + `blurs` (content/media/none). The `!hide` and `!warn` system labels are 331 + non-overridable; content labels like `porn`, `graphic-media` let users choose. 332 + - **Negatable and expirable**: A label is retracted by publishing a negation 333 + (`neg: true`) with the same src/uri/val. Labels can also carry `exp` for 334 + automatic expiry. 335 + - **Self-labels**: Record authors can self-apply global label values (e.g., 336 + `porn`, `nudity`, `graphic-media`) directly in their records via 337 + `com.atproto.label.defs#selfLabels`. 338 + 339 + The indigo SDK already provides the Go types (`LabelDefs_Label`, 340 + `LabelDefs_SelfLabels`) and signing/verification utilities. 341 + 342 + #### How internal labels differ 343 + 344 + The labels proposed in this doc are **app-internal operational state** — they 345 + exist only inside Arabica's SQLite database, are not signed or distributed, and 346 + are invisible to the broader AT Protocol network. They serve a fundamentally 347 + different purpose: 348 + 349 + | Aspect | Internal Labels | AT Protocol Labels | 350 + |--------|----------------|--------------------| 351 + | **Purpose** | Automod state, moderator notes | Network-wide content classification | 352 + | **Audience** | Arabica's automod engine + moderators | Any atproto client, user-selectable | 353 + | **Storage** | SQLite row | Signed CBOR object, distributed via WebSocket | 354 + | **Who creates** | Automod rules or Arabica moderators | Independent labeler services | 355 + | **Who consumes** | Arabica's rule evaluator | End users via their chosen client | 356 + | **Visibility** | Admin dashboard only | Public (any subscriber can see) | 357 + | **User choice** | None — moderator decisions are authoritative | Users choose labelers and per-label behavior | 358 + | **Examples** | `warned`, `trusted`, `under_review`, `rate_limited` | `porn`, `graphic-media`, `!hide`, `spam` | 359 + 360 + These are complementary, not competing. Internal labels are private moderator 361 + bookkeeping. atproto labels are public content classification signals. 362 + 363 + #### Should Arabica use both? 364 + 365 + **Yes** — but for different things, and not at the same time. 366 + 367 + **Phase 1: Internal labels (this proposal)** 368 + 369 + Internal labels are the right tool for what's described in this doc: giving 370 + automod memory and letting moderators annotate users. A `warned` label that 371 + expires in 30 days, or a `trusted` label that exempts someone from automod — 372 + these are operational concerns that don't belong on the public network. They're 373 + the equivalent of internal moderation notes, not public content ratings. 374 + 375 + Ship internal labels first. They have zero infrastructure requirements, integrate 376 + directly with the automod rule evaluator, and solve the immediate problem of 377 + stateless automod. 378 + 379 + **Phase 2: Arabica as an atproto labeler (future)** 380 + 381 + Later, Arabica could operate as a labeler service — publishing labels that other 382 + atproto clients can subscribe to. This would be valuable for: 383 + 384 + - **Content warnings on brew records**: Labeling brews that contain 385 + controversial ingredients or methods (e.g., if the community ever needs 386 + content classification beyond coffee) 387 + - **Quality signals**: A `verified-roaster` or `featured` label that other 388 + clients could consume to highlight trusted content 389 + - **Spam classification**: Publishing `spam` labels on records Arabica's automod 390 + has flagged, so other apps indexing `social.arabica.alpha.*` collections can 391 + benefit from Arabica's moderation work 392 + - **Cross-app moderation**: If other apps build on the arabica lexicons, they 393 + could subscribe to Arabica's labeler to get moderation decisions without 394 + running their own moderation 395 + 396 + This would require: 397 + 1. A signing key (`#atproto_label`) in Arabica's DID document 398 + 2. An `app.bsky.labeler.service` declaration record 399 + 3. `subscribeLabels` and `queryLabels` endpoints 400 + 4. A mapping from internal moderation actions → published atproto labels 401 + 402 + #### When an action should become a public label 403 + 404 + Not every internal label should be published externally. A rough heuristic: 405 + 406 + | Internal action | Publish as atproto label? | Why / why not | 407 + |-----------------|--------------------------|---------------| 408 + | `hide_record` | Yes → `!hide` | Other apps indexing arabica records should respect this | 409 + | `blacklist_user` | Maybe → custom `blocked` | Depends on whether other apps need to know | 410 + | `warned` | No | Private moderator state, not relevant to other clients | 411 + | `trusted` | No | Internal automod bypass, meaningless externally | 412 + | `spam` | Yes → `spam` | Useful for any app consuming arabica records | 413 + | `under_review` | No | Temporary internal state | 414 + 415 + The bridge between the two systems would be straightforward: when an internal 416 + moderation action fires that warrants a public label, also sign and publish an 417 + atproto label. The internal label drives automod behavior; the atproto label 418 + communicates the decision to the network. 419 + 420 + #### What this means for the current proposal 421 + 422 + No changes needed to the internal labels design. The schema, store interface, and 423 + automod integration all remain as specified above. The only future consideration 424 + is adding an optional `publish_label` action type to the automod rules config 425 + (Phase 3), which would sign and emit an atproto label alongside the internal 426 + action: 427 + 428 + ```json 429 + { 430 + "name": "publish_spam_label", 431 + "trigger": "record_auto_hidden", 432 + "conditions": { 433 + "has_label": { "entity": "subject_record", "label": "spam" } 434 + }, 435 + "action": { "type": "publish_label", "val": "spam" } 436 + } 437 + ``` 438 + 439 + This is purely additive and doesn't affect the Phase 2/3 work described in this 440 + doc. 441 + 305 442 --- 306 443 307 444 ## 3. Permission Middleware ··· 477 614 - **No investigation UI** — the existing admin dashboard gets labels, not a 478 615 query engine 479 616 - **No ML/AI integration** — rules are deterministic threshold checks 617 + - **No atproto labeler service** — internal labels are private operational 618 + state, not published to the network. Becoming an atproto labeler is a natural 619 + future extension (see "Relationship to AT Protocol Labels" in section 2) but 620 + is out of scope for this work. 480 621 481 622 These are deliberate constraints. If the moderation needs outgrow this, that's 482 623 the point where Osprey (or a similar system) becomes worth the infrastructure 483 - cost. 624 + cost. The atproto labeler path is a lighter lift than Osprey — it reuses the 625 + existing moderation decisions and just adds a signed publication layer — so it's 626 + a reasonable intermediate step if cross-app moderation becomes needed before 627 + full-scale infrastructure is justified.
+126
internal/database/sqlitestore/moderation.go
··· 414 414 t, _ := time.Parse(time.RFC3339Nano, resetAtStr) 415 415 return t, nil 416 416 } 417 + 418 + // ========== Labels ========== 419 + 420 + func (s *ModerationStore) AddLabel(ctx context.Context, label moderation.Label) error { 421 + var expiresAt *string 422 + if label.ExpiresAt != nil { 423 + v := label.ExpiresAt.Format(time.RFC3339Nano) 424 + expiresAt = &v 425 + } 426 + _, err := s.db.ExecContext(ctx, ` 427 + INSERT INTO moderation_labels (id, entity_type, entity_id, label, value, created_at, created_by, expires_at) 428 + VALUES (?, ?, ?, ?, ?, ?, ?, ?) 429 + ON CONFLICT(entity_type, entity_id, label) DO UPDATE SET 430 + id = excluded.id, 431 + value = excluded.value, 432 + created_at = excluded.created_at, 433 + created_by = excluded.created_by, 434 + expires_at = excluded.expires_at 435 + `, label.ID, label.EntityType, label.EntityID, label.Name, label.Value, 436 + label.CreatedAt.Format(time.RFC3339Nano), label.CreatedBy, expiresAt) 437 + if err != nil { 438 + return fmt.Errorf("add label: %w", err) 439 + } 440 + return nil 441 + } 442 + 443 + func (s *ModerationStore) RemoveLabel(ctx context.Context, entityType, entityID, labelName string) error { 444 + _, err := s.db.ExecContext(ctx, ` 445 + DELETE FROM moderation_labels WHERE entity_type = ? AND entity_id = ? AND label = ? 446 + `, entityType, entityID, labelName) 447 + return err 448 + } 449 + 450 + func (s *ModerationStore) HasLabel(ctx context.Context, entityType, entityID, labelName string) (bool, error) { 451 + var exists int 452 + err := s.db.QueryRowContext(ctx, ` 453 + SELECT 1 FROM moderation_labels 454 + WHERE entity_type = ? AND entity_id = ? AND label = ? 455 + AND (expires_at IS NULL OR expires_at > ?) 456 + `, entityType, entityID, labelName, time.Now().Format(time.RFC3339Nano)).Scan(&exists) 457 + if err == sql.ErrNoRows { 458 + return false, nil 459 + } 460 + return exists == 1, err 461 + } 462 + 463 + func (s *ModerationStore) GetLabel(ctx context.Context, entityType, entityID, labelName string) (*moderation.Label, error) { 464 + var l moderation.Label 465 + var createdAtStr string 466 + var expiresAtStr sql.NullString 467 + err := s.db.QueryRowContext(ctx, ` 468 + SELECT id, entity_type, entity_id, label, value, created_at, created_by, expires_at 469 + FROM moderation_labels WHERE entity_type = ? AND entity_id = ? AND label = ? 470 + `, entityType, entityID, labelName).Scan( 471 + &l.ID, &l.EntityType, &l.EntityID, &l.Name, &l.Value, 472 + &createdAtStr, &l.CreatedBy, &expiresAtStr) 473 + if err == sql.ErrNoRows { 474 + return nil, nil 475 + } 476 + if err != nil { 477 + return nil, err 478 + } 479 + l.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAtStr) 480 + if expiresAtStr.Valid { 481 + t, _ := time.Parse(time.RFC3339Nano, expiresAtStr.String) 482 + l.ExpiresAt = &t 483 + } 484 + return &l, nil 485 + } 486 + 487 + func (s *ModerationStore) ListLabels(ctx context.Context, entityType, entityID string) ([]moderation.Label, error) { 488 + rows, err := s.db.QueryContext(ctx, ` 489 + SELECT id, entity_type, entity_id, label, value, created_at, created_by, expires_at 490 + FROM moderation_labels WHERE entity_type = ? AND entity_id = ? 491 + ORDER BY created_at DESC 492 + `, entityType, entityID) 493 + if err != nil { 494 + return nil, err 495 + } 496 + defer rows.Close() 497 + return scanLabels(rows) 498 + } 499 + 500 + func (s *ModerationStore) ListAllLabels(ctx context.Context) ([]moderation.Label, error) { 501 + rows, err := s.db.QueryContext(ctx, ` 502 + SELECT id, entity_type, entity_id, label, value, created_at, created_by, expires_at 503 + FROM moderation_labels ORDER BY created_at DESC 504 + `) 505 + if err != nil { 506 + return nil, err 507 + } 508 + defer rows.Close() 509 + return scanLabels(rows) 510 + } 511 + 512 + func scanLabels(rows *sql.Rows) ([]moderation.Label, error) { 513 + var labels []moderation.Label 514 + for rows.Next() { 515 + var l moderation.Label 516 + var createdAtStr string 517 + var expiresAtStr sql.NullString 518 + if err := rows.Scan( 519 + &l.ID, &l.EntityType, &l.EntityID, &l.Name, &l.Value, 520 + &createdAtStr, &l.CreatedBy, &expiresAtStr); err != nil { 521 + continue 522 + } 523 + l.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAtStr) 524 + if expiresAtStr.Valid { 525 + t, _ := time.Parse(time.RFC3339Nano, expiresAtStr.String) 526 + l.ExpiresAt = &t 527 + } 528 + labels = append(labels, l) 529 + } 530 + return labels, rows.Err() 531 + } 532 + 533 + func (s *ModerationStore) CleanExpiredLabels(ctx context.Context) (int, error) { 534 + res, err := s.db.ExecContext(ctx, ` 535 + DELETE FROM moderation_labels WHERE expires_at IS NOT NULL AND expires_at < ? 536 + `, time.Now().Format(time.RFC3339Nano)) 537 + if err != nil { 538 + return 0, err 539 + } 540 + n, _ := res.RowsAffected() 541 + return int(n), nil 542 + }
+217
internal/database/sqlitestore/moderation_test.go
··· 1 + package sqlitestore 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "testing" 7 + "time" 8 + 9 + "arabica/internal/moderation" 10 + 11 + "github.com/stretchr/testify/assert" 12 + _ "modernc.org/sqlite" 13 + ) 14 + 15 + func setupTestDB(t *testing.T) *ModerationStore { 16 + t.Helper() 17 + db, err := sql.Open("sqlite", ":memory:") 18 + assert.NoError(t, err) 19 + t.Cleanup(func() { db.Close() }) 20 + 21 + _, err = db.Exec(` 22 + CREATE TABLE moderation_labels ( 23 + id TEXT PRIMARY KEY, 24 + entity_type TEXT NOT NULL, 25 + entity_id TEXT NOT NULL, 26 + label TEXT NOT NULL, 27 + value TEXT NOT NULL DEFAULT '', 28 + created_at TEXT NOT NULL, 29 + created_by TEXT NOT NULL, 30 + expires_at TEXT, 31 + UNIQUE(entity_type, entity_id, label) 32 + ); 33 + CREATE INDEX idx_modlabels_entity ON moderation_labels(entity_type, entity_id); 34 + CREATE INDEX idx_modlabels_expires ON moderation_labels(expires_at) WHERE expires_at IS NOT NULL; 35 + `) 36 + assert.NoError(t, err) 37 + return NewModerationStore(db) 38 + } 39 + 40 + func TestAddAndGetLabel(t *testing.T) { 41 + store := setupTestDB(t) 42 + ctx := context.Background() 43 + 44 + label := moderation.Label{ 45 + ID: "tid001", 46 + EntityType: "user", 47 + EntityID: "did:plc:test123", 48 + Name: "warned", 49 + Value: "", 50 + CreatedAt: time.Now(), 51 + CreatedBy: "did:plc:moderator", 52 + } 53 + 54 + assert.NoError(t, store.AddLabel(ctx, label)) 55 + 56 + got, err := store.GetLabel(ctx, "user", "did:plc:test123", "warned") 57 + assert.NoError(t, err) 58 + assert.NotNil(t, got) 59 + assert.Equal(t, "warned", got.Name) 60 + assert.Equal(t, "user", got.EntityType) 61 + assert.Equal(t, "did:plc:test123", got.EntityID) 62 + assert.Equal(t, "did:plc:moderator", got.CreatedBy) 63 + } 64 + 65 + func TestHasLabel(t *testing.T) { 66 + store := setupTestDB(t) 67 + ctx := context.Background() 68 + 69 + // No label yet 70 + has, err := store.HasLabel(ctx, "user", "did:plc:test", "warned") 71 + assert.NoError(t, err) 72 + assert.False(t, has) 73 + 74 + // Add label 75 + assert.NoError(t, store.AddLabel(ctx, moderation.Label{ 76 + ID: "tid001", 77 + EntityType: "user", 78 + EntityID: "did:plc:test", 79 + Name: "warned", 80 + CreatedAt: time.Now(), 81 + CreatedBy: "automod", 82 + })) 83 + 84 + has, err = store.HasLabel(ctx, "user", "did:plc:test", "warned") 85 + assert.NoError(t, err) 86 + assert.True(t, has) 87 + } 88 + 89 + func TestHasLabel_Expired(t *testing.T) { 90 + store := setupTestDB(t) 91 + ctx := context.Background() 92 + 93 + past := time.Now().Add(-1 * time.Hour) 94 + assert.NoError(t, store.AddLabel(ctx, moderation.Label{ 95 + ID: "tid001", 96 + EntityType: "user", 97 + EntityID: "did:plc:test", 98 + Name: "rate_limited", 99 + CreatedAt: time.Now().Add(-2 * time.Hour), 100 + CreatedBy: "automod", 101 + ExpiresAt: &past, 102 + })) 103 + 104 + // HasLabel should return false for expired labels 105 + has, err := store.HasLabel(ctx, "user", "did:plc:test", "rate_limited") 106 + assert.NoError(t, err) 107 + assert.False(t, has) 108 + } 109 + 110 + func TestRemoveLabel(t *testing.T) { 111 + store := setupTestDB(t) 112 + ctx := context.Background() 113 + 114 + assert.NoError(t, store.AddLabel(ctx, moderation.Label{ 115 + ID: "tid001", 116 + EntityType: "user", 117 + EntityID: "did:plc:test", 118 + Name: "warned", 119 + CreatedAt: time.Now(), 120 + CreatedBy: "did:plc:mod", 121 + })) 122 + 123 + assert.NoError(t, store.RemoveLabel(ctx, "user", "did:plc:test", "warned")) 124 + 125 + got, err := store.GetLabel(ctx, "user", "did:plc:test", "warned") 126 + assert.NoError(t, err) 127 + assert.Nil(t, got) 128 + } 129 + 130 + func TestListLabels(t *testing.T) { 131 + store := setupTestDB(t) 132 + ctx := context.Background() 133 + 134 + assert.NoError(t, store.AddLabel(ctx, moderation.Label{ 135 + ID: "tid001", EntityType: "user", EntityID: "did:plc:test", 136 + Name: "warned", CreatedAt: time.Now(), CreatedBy: "mod", 137 + })) 138 + assert.NoError(t, store.AddLabel(ctx, moderation.Label{ 139 + ID: "tid002", EntityType: "user", EntityID: "did:plc:test", 140 + Name: "trusted", CreatedAt: time.Now(), CreatedBy: "mod", 141 + })) 142 + assert.NoError(t, store.AddLabel(ctx, moderation.Label{ 143 + ID: "tid003", EntityType: "user", EntityID: "did:plc:other", 144 + Name: "spam", CreatedAt: time.Now(), CreatedBy: "mod", 145 + })) 146 + 147 + labels, err := store.ListLabels(ctx, "user", "did:plc:test") 148 + assert.NoError(t, err) 149 + assert.Len(t, labels, 2) 150 + 151 + all, err := store.ListAllLabels(ctx) 152 + assert.NoError(t, err) 153 + assert.Len(t, all, 3) 154 + } 155 + 156 + func TestCleanExpiredLabels(t *testing.T) { 157 + store := setupTestDB(t) 158 + ctx := context.Background() 159 + 160 + past := time.Now().Add(-1 * time.Hour) 161 + future := time.Now().Add(24 * time.Hour) 162 + 163 + // Expired label 164 + assert.NoError(t, store.AddLabel(ctx, moderation.Label{ 165 + ID: "tid001", EntityType: "user", EntityID: "did:plc:a", 166 + Name: "rate_limited", CreatedAt: time.Now(), CreatedBy: "automod", 167 + ExpiresAt: &past, 168 + })) 169 + // Active label with future expiry 170 + assert.NoError(t, store.AddLabel(ctx, moderation.Label{ 171 + ID: "tid002", EntityType: "user", EntityID: "did:plc:b", 172 + Name: "warned", CreatedAt: time.Now(), CreatedBy: "mod", 173 + ExpiresAt: &future, 174 + })) 175 + // Permanent label 176 + assert.NoError(t, store.AddLabel(ctx, moderation.Label{ 177 + ID: "tid003", EntityType: "user", EntityID: "did:plc:c", 178 + Name: "trusted", CreatedAt: time.Now(), CreatedBy: "mod", 179 + })) 180 + 181 + cleaned, err := store.CleanExpiredLabels(ctx) 182 + assert.NoError(t, err) 183 + assert.Equal(t, 1, cleaned) 184 + 185 + // Only 2 labels should remain 186 + all, err := store.ListAllLabels(ctx) 187 + assert.NoError(t, err) 188 + assert.Len(t, all, 2) 189 + } 190 + 191 + func TestAddLabel_Upsert(t *testing.T) { 192 + store := setupTestDB(t) 193 + ctx := context.Background() 194 + 195 + // Add label 196 + assert.NoError(t, store.AddLabel(ctx, moderation.Label{ 197 + ID: "tid001", EntityType: "user", EntityID: "did:plc:test", 198 + Name: "warned", Value: "first", CreatedAt: time.Now(), CreatedBy: "mod1", 199 + })) 200 + 201 + // Upsert same label with new value 202 + assert.NoError(t, store.AddLabel(ctx, moderation.Label{ 203 + ID: "tid002", EntityType: "user", EntityID: "did:plc:test", 204 + Name: "warned", Value: "second", CreatedAt: time.Now(), CreatedBy: "mod2", 205 + })) 206 + 207 + got, err := store.GetLabel(ctx, "user", "did:plc:test", "warned") 208 + assert.NoError(t, err) 209 + assert.NotNil(t, got) 210 + assert.Equal(t, "second", got.Value) 211 + assert.Equal(t, "mod2", got.CreatedBy) 212 + 213 + // Should still be only one label 214 + labels, err := store.ListLabels(ctx, "user", "did:plc:test") 215 + assert.NoError(t, err) 216 + assert.Len(t, labels, 1) 217 + }
+14
internal/firehose/index.go
··· 212 212 reset_at TEXT NOT NULL 213 213 ); 214 214 215 + CREATE TABLE IF NOT EXISTS moderation_labels ( 216 + id TEXT PRIMARY KEY, 217 + entity_type TEXT NOT NULL, 218 + entity_id TEXT NOT NULL, 219 + label TEXT NOT NULL, 220 + value TEXT NOT NULL DEFAULT '', 221 + created_at TEXT NOT NULL, 222 + created_by TEXT NOT NULL, 223 + expires_at TEXT, 224 + UNIQUE(entity_type, entity_id, label) 225 + ); 226 + CREATE INDEX IF NOT EXISTS idx_modlabels_entity ON moderation_labels(entity_type, entity_id); 227 + CREATE INDEX IF NOT EXISTS idx_modlabels_expires ON moderation_labels(expires_at) WHERE expires_at IS NOT NULL; 228 + 215 229 CREATE TABLE IF NOT EXISTS user_settings ( 216 230 did TEXT PRIMARY KEY, 217 231 profile_stats_visibility TEXT NOT NULL DEFAULT '{}'
+136
internal/handlers/admin.go
··· 144 144 canBlock := h.moderationService.HasPermission(userDID, moderation.PermissionBlacklistUser) 145 145 canUnblock := h.moderationService.HasPermission(userDID, moderation.PermissionUnblacklistUser) 146 146 canResetAutoHide := h.moderationService.HasPermission(userDID, moderation.PermissionResetAutoHide) 147 + canManageLabels := h.moderationService.HasPermission(userDID, moderation.PermissionManageLabels) 147 148 148 149 var hiddenRecords []moderation.HiddenRecord 149 150 var auditLog []moderation.AuditEntry ··· 167 168 blockedUsers, _ = h.moderationStore.ListBlacklistedUsers(ctx) 168 169 } 169 170 171 + var labels []moderation.Label 172 + if canManageLabels && h.moderationStore != nil { 173 + labels, _ = h.moderationStore.ListAllLabels(ctx) 174 + } 175 + 170 176 isAdmin := h.moderationService.IsAdmin(userDID) 171 177 172 178 var joinRequests []*boltstore.JoinRequest ··· 185 191 AuditLog: auditLog, 186 192 Reports: enrichedReports, 187 193 BlockedUsers: blockedUsers, 194 + Labels: labels, 188 195 JoinRequests: joinRequests, 189 196 Stats: stats, 190 197 CanHide: canHide, ··· 194 201 CanBlock: canBlock, 195 202 CanUnblock: canUnblock, 196 203 CanResetAutoHide: canResetAutoHide, 204 + CanManageLabels: canManageLabels, 197 205 IsAdmin: isAdmin, 198 206 } 199 207 } ··· 516 524 Str("reportID", reportID). 517 525 Str("by", userDID). 518 526 Msg("Report dismissed") 527 + 528 + w.Header().Set("HX-Trigger", "mod-action") 529 + w.WriteHeader(http.StatusOK) 530 + } 531 + 532 + // HandleAddLabel handles POST /_mod/label/add 533 + // Auth and permission checks are handled by RequirePermission middleware. 534 + func (h *Handler) HandleAddLabel(w http.ResponseWriter, r *http.Request) { 535 + userDID, _ := atproto.GetAuthenticatedDID(r.Context()) 536 + 537 + if err := r.ParseForm(); err != nil { 538 + http.Error(w, "Invalid request body", http.StatusBadRequest) 539 + return 540 + } 541 + 542 + entityType := r.FormValue("entity_type") 543 + entityID := r.FormValue("entity_id") 544 + labelName := r.FormValue("label") 545 + 546 + if entityType == "" || entityID == "" || labelName == "" { 547 + http.Error(w, "entity_type, entity_id, and label are required", http.StatusBadRequest) 548 + return 549 + } 550 + if entityType != "user" && entityType != "record" { 551 + http.Error(w, "entity_type must be 'user' or 'record'", http.StatusBadRequest) 552 + return 553 + } 554 + 555 + label := moderation.Label{ 556 + ID: generateTID(), 557 + EntityType: entityType, 558 + EntityID: entityID, 559 + Name: labelName, 560 + Value: r.FormValue("value"), 561 + CreatedAt: time.Now(), 562 + CreatedBy: userDID, 563 + } 564 + 565 + // Parse optional TTL 566 + if ttl := r.FormValue("expires"); ttl != "" { 567 + if d, err := time.ParseDuration(ttl); err == nil { 568 + exp := time.Now().Add(d) 569 + label.ExpiresAt = &exp 570 + } 571 + } 572 + 573 + if err := h.moderationStore.AddLabel(r.Context(), label); err != nil { 574 + log.Error().Err(err).Str("label", labelName).Msg("Failed to add label") 575 + http.Error(w, "Failed to add label", http.StatusInternalServerError) 576 + return 577 + } 578 + 579 + // Log the action 580 + auditEntry := moderation.AuditEntry{ 581 + ID: generateTID(), 582 + Action: moderation.AuditActionAddLabel, 583 + ActorDID: userDID, 584 + TargetURI: entityID, 585 + Reason: labelName, 586 + Details: map[string]string{ 587 + "entity_type": entityType, 588 + "label": labelName, 589 + }, 590 + Timestamp: time.Now(), 591 + } 592 + if err := h.moderationStore.LogAction(r.Context(), auditEntry); err != nil { 593 + log.Error().Err(err).Msg("Failed to log add-label action") 594 + } 595 + 596 + log.Info(). 597 + Str("entity_type", entityType). 598 + Str("entity_id", entityID). 599 + Str("label", labelName). 600 + Str("by", userDID). 601 + Msg("Label added") 602 + 603 + w.Header().Set("HX-Trigger", "mod-action") 604 + w.WriteHeader(http.StatusOK) 605 + } 606 + 607 + // HandleRemoveLabel handles POST /_mod/label/remove 608 + // Auth and permission checks are handled by RequirePermission middleware. 609 + func (h *Handler) HandleRemoveLabel(w http.ResponseWriter, r *http.Request) { 610 + userDID, _ := atproto.GetAuthenticatedDID(r.Context()) 611 + 612 + if err := r.ParseForm(); err != nil { 613 + http.Error(w, "Invalid request body", http.StatusBadRequest) 614 + return 615 + } 616 + 617 + entityType := r.FormValue("entity_type") 618 + entityID := r.FormValue("entity_id") 619 + labelName := r.FormValue("label") 620 + 621 + if entityType == "" || entityID == "" || labelName == "" { 622 + http.Error(w, "entity_type, entity_id, and label are required", http.StatusBadRequest) 623 + return 624 + } 625 + 626 + if err := h.moderationStore.RemoveLabel(r.Context(), entityType, entityID, labelName); err != nil { 627 + log.Error().Err(err).Str("label", labelName).Msg("Failed to remove label") 628 + http.Error(w, "Failed to remove label", http.StatusInternalServerError) 629 + return 630 + } 631 + 632 + // Log the action 633 + auditEntry := moderation.AuditEntry{ 634 + ID: generateTID(), 635 + Action: moderation.AuditActionRemoveLabel, 636 + ActorDID: userDID, 637 + TargetURI: entityID, 638 + Reason: labelName, 639 + Details: map[string]string{ 640 + "entity_type": entityType, 641 + "label": labelName, 642 + }, 643 + Timestamp: time.Now(), 644 + } 645 + if err := h.moderationStore.LogAction(r.Context(), auditEntry); err != nil { 646 + log.Error().Err(err).Msg("Failed to log remove-label action") 647 + } 648 + 649 + log.Info(). 650 + Str("entity_type", entityType). 651 + Str("entity_id", entityID). 652 + Str("label", labelName). 653 + Str("by", userDID). 654 + Msg("Label removed") 519 655 520 656 w.Header().Set("HX-Trigger", "mod-action") 521 657 w.WriteHeader(http.StatusOK)
+22
internal/moderation/models.go
··· 16 16 PermissionDismissReport Permission = "dismiss_report" 17 17 PermissionViewAuditLog Permission = "view_audit_log" 18 18 PermissionResetAutoHide Permission = "reset_autohide" 19 + PermissionManageLabels Permission = "manage_labels" 19 20 ) 20 21 21 22 // AllPermissions returns all available permissions ··· 29 30 PermissionDismissReport, 30 31 PermissionViewAuditLog, 31 32 PermissionResetAutoHide, 33 + PermissionManageLabels, 32 34 } 33 35 } 34 36 ··· 152 154 AuditActionResetAutoHide AuditAction = "reset_autohide" 153 155 AuditActionDismissJoinRequest AuditAction = "dismiss_join_request" 154 156 AuditActionCreateInvite AuditAction = "create_invite" 157 + AuditActionAddLabel AuditAction = "add_label" 158 + AuditActionRemoveLabel AuditAction = "remove_label" 155 159 ) 156 160 157 161 // AuditEntry represents a logged moderation action ··· 165 169 Timestamp time.Time `json:"timestamp"` 166 170 AutoMod bool `json:"auto_mod"` // true if action was automatic 167 171 } 172 + 173 + // Label represents a moderation label attached to a user or record. 174 + // Labels are internal operational state used by automod rules and moderators. 175 + type Label struct { 176 + ID string `json:"id"` 177 + EntityType string `json:"entity_type"` // "user" or "record" 178 + EntityID string `json:"entity_id"` // DID or AT-URI 179 + Name string `json:"label"` 180 + Value string `json:"value,omitempty"` 181 + CreatedAt time.Time `json:"created_at"` 182 + CreatedBy string `json:"created_by"` // DID or "automod" or "system" 183 + ExpiresAt *time.Time `json:"expires_at,omitempty"` 184 + } 185 + 186 + // IsExpired returns true if the label has passed its expiration time. 187 + func (l *Label) IsExpired() bool { 188 + return l.ExpiresAt != nil && time.Now().After(*l.ExpiresAt) 189 + }
+9
internal/moderation/store.go
··· 43 43 // Auto-hide resets 44 44 SetAutoHideReset(ctx context.Context, did string, resetAt time.Time) error 45 45 GetAutoHideReset(ctx context.Context, did string) (time.Time, error) 46 + 47 + // Labels 48 + AddLabel(ctx context.Context, label Label) error 49 + RemoveLabel(ctx context.Context, entityType, entityID, labelName string) error 50 + HasLabel(ctx context.Context, entityType, entityID, labelName string) (bool, error) 51 + GetLabel(ctx context.Context, entityType, entityID, labelName string) (*Label, error) 52 + ListLabels(ctx context.Context, entityType, entityID string) ([]Label, error) 53 + ListAllLabels(ctx context.Context) ([]Label, error) 54 + CleanExpiredLabels(ctx context.Context) (int, error) 46 55 }
+4
internal/routing/routing.go
··· 169 169 middleware.RequirePermission(modSvc, moderation.PermissionBlacklistUser, http.HandlerFunc(h.HandleBlockUser)))) 170 170 mux.Handle("POST /_mod/unblock", cop.Handler( 171 171 middleware.RequirePermission(modSvc, moderation.PermissionUnblacklistUser, http.HandlerFunc(h.HandleUnblockUser)))) 172 + mux.Handle("POST /_mod/label/add", cop.Handler( 173 + middleware.RequirePermission(modSvc, moderation.PermissionManageLabels, http.HandlerFunc(h.HandleAddLabel)))) 174 + mux.Handle("POST /_mod/label/remove", cop.Handler( 175 + middleware.RequirePermission(modSvc, moderation.PermissionManageLabels, http.HandlerFunc(h.HandleRemoveLabel)))) 172 176 mux.Handle("POST /_mod/invite", cop.Handler( 173 177 middleware.RequireAdmin(modSvc, http.HandlerFunc(h.HandleCreateInvite)))) 174 178 mux.Handle("POST /_mod/dismiss-join", cop.Handler(
+203 -1
internal/web/pages/admin.templ
··· 33 33 AuditLog []moderation.AuditEntry 34 34 Reports []EnrichedReport 35 35 BlockedUsers []moderation.BlacklistedUser 36 + Labels []moderation.Label 36 37 JoinRequests []*boltstore.JoinRequest 37 38 Stats AdminStats 38 39 CanHide bool ··· 42 43 CanBlock bool 43 44 CanUnblock bool 44 45 CanResetAutoHide bool 46 + CanManageLabels bool 45 47 IsAdmin bool 46 48 } 47 49 ··· 132 134 Activity Log 133 135 </button> 134 136 } 137 + if props.CanManageLabels { 138 + <button 139 + type="button" 140 + @click="activeTab = 'labels'" 141 + :class="activeTab === 'labels' ? 'bg-brown-300 text-brown-900 border-brown-400' : 'bg-brown-100 text-brown-600 border-brown-200 hover:bg-brown-200'" 142 + class="px-3 py-1.5 rounded-lg border font-medium text-sm transition-colors" 143 + > 144 + Labels 145 + if len(props.Labels) > 0 { 146 + <span class="ml-1.5 bg-purple-100 text-purple-700 py-0.5 px-1.5 rounded-full text-xs"> 147 + { fmt.Sprintf("%d", len(props.Labels)) } 148 + </span> 149 + } 150 + </button> 151 + } 135 152 if props.IsAdmin { 136 153 <button 137 154 type="button" ··· 231 248 } 232 249 </div> 233 250 </div> 251 + } 252 + <!-- Labels Tab --> 253 + if props.CanManageLabels { 254 + <div x-show="activeTab === 'labels'" x-cloak x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"> 255 + <div class="card card-inner"> 256 + <div class="flex items-center justify-between mb-4"> 257 + <h2 class="section-title mb-0">Labels</h2> 258 + <button 259 + type="button" 260 + x-data 261 + @click="$dispatch('open-add-label')" 262 + class="text-sm bg-purple-100 text-purple-700 hover:bg-purple-200 px-3 py-1.5 rounded font-medium transition-colors" 263 + > 264 + Add Label 265 + </button> 266 + </div> 267 + if len(props.Labels) == 0 { 268 + <div class="bg-brown-50 rounded-lg p-4 text-center text-brown-600"> 269 + <p>No labels have been applied yet.</p> 270 + </div> 271 + } else { 272 + <div class="space-y-3"> 273 + for _, label := range props.Labels { 274 + @LabelCard(label) 275 + } 276 + </div> 277 + } 278 + </div> 279 + <!-- Add Label Form --> 280 + <div 281 + x-data="{ open: false }" 282 + x-on:open-add-label.window="open = true" 283 + x-show="open" 284 + x-cloak 285 + x-transition 286 + class="card card-inner mt-4" 287 + > 288 + <h3 class="section-title">Add Label</h3> 289 + <form 290 + hx-post="/_mod/label/add" 291 + hx-swap="none" 292 + class="space-y-4" 293 + x-ref="labelForm" 294 + @htmx:after-request.window="if ($event.detail.successful) { open = false; $refs.labelForm.reset() }" 295 + > 296 + <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> 297 + <div> 298 + <label class="block text-sm font-medium text-brown-700 mb-1">Entity Type</label> 299 + <select name="entity_type" required class="w-full px-3 py-2 border border-brown-300 rounded-lg bg-white text-brown-900 text-sm focus:ring-2 focus:ring-amber-500 focus:border-amber-500"> 300 + <option value="user">User (DID)</option> 301 + <option value="record">Record (AT-URI)</option> 302 + </select> 303 + </div> 304 + <div> 305 + <label class="block text-sm font-medium text-brown-700 mb-1">Entity ID</label> 306 + <input type="text" name="entity_id" required placeholder="did:plc:... or at://..." class="w-full px-3 py-2 border border-brown-300 rounded-lg bg-white text-brown-900 text-sm focus:ring-2 focus:ring-amber-500 focus:border-amber-500"/> 307 + </div> 308 + </div> 309 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4"> 310 + <div> 311 + <label class="block text-sm font-medium text-brown-700 mb-1">Label</label> 312 + <input type="text" name="label" required placeholder="e.g. warned, trusted, spam" class="w-full px-3 py-2 border border-brown-300 rounded-lg bg-white text-brown-900 text-sm focus:ring-2 focus:ring-amber-500 focus:border-amber-500"/> 313 + </div> 314 + <div> 315 + <label class="block text-sm font-medium text-brown-700 mb-1">Value (optional)</label> 316 + <input type="text" name="value" placeholder="Optional value" class="w-full px-3 py-2 border border-brown-300 rounded-lg bg-white text-brown-900 text-sm focus:ring-2 focus:ring-amber-500 focus:border-amber-500"/> 317 + </div> 318 + <div> 319 + <label class="block text-sm font-medium text-brown-700 mb-1">Expires (optional)</label> 320 + <select name="expires" class="w-full px-3 py-2 border border-brown-300 rounded-lg bg-white text-brown-900 text-sm focus:ring-2 focus:ring-amber-500 focus:border-amber-500"> 321 + <option value="">Never</option> 322 + <option value="24h">24 hours</option> 323 + <option value="168h">7 days</option> 324 + <option value="720h">30 days</option> 325 + <option value="2160h">90 days</option> 326 + </select> 327 + </div> 328 + </div> 329 + <div class="flex gap-3"> 330 + <button type="submit" class="text-sm bg-purple-100 text-purple-700 hover:bg-purple-200 px-4 py-2 rounded font-medium transition-colors"> 331 + Add Label 332 + </button> 333 + <button type="button" @click="open = false" class="text-sm text-brown-600 hover:text-brown-800 px-4 py-2 rounded font-medium transition-colors"> 334 + Cancel 335 + </button> 336 + </div> 337 + </form> 338 + </div> 339 + </div> 340 + } 234 341 <!-- Join Requests Tab --> 235 342 if props.IsAdmin { 236 343 <div x-show="activeTab === 'join'" x-cloak x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"> ··· 250 357 </div> 251 358 </div> 252 359 } 253 - } 254 360 <!-- Stats Tab (admin only) --> 255 361 if props.IsAdmin { 256 362 <div x-show="activeTab === 'stats'" x-cloak x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"> ··· 681 787 <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800"> 682 788 Send Invite 683 789 </span> 790 + case moderation.AuditActionAddLabel: 791 + <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800"> 792 + Add Label 793 + </span> 794 + case moderation.AuditActionRemoveLabel: 795 + <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800"> 796 + Remove Label 797 + </span> 684 798 default: 685 799 <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-brown-100 text-brown-800"> 686 800 { string(action) } ··· 730 844 </div> 731 845 </div> 732 846 </div> 847 + } 848 + 849 + templ LabelCard(label moderation.Label) { 850 + <div class="bg-brown-50 border border-brown-200 rounded-lg p-4"> 851 + <div class="flex flex-col gap-3"> 852 + <!-- Label name badge and entity type --> 853 + <div class="flex items-center justify-between"> 854 + <div class="flex items-center gap-2"> 855 + @LabelBadge(label.Name) 856 + <span class="text-xs bg-brown-100 text-brown-600 px-1.5 py-0.5 rounded">{ label.EntityType }</span> 857 + if label.Value != "" { 858 + <span class="text-xs text-brown-500">= { label.Value }</span> 859 + } 860 + </div> 861 + <time class="text-sm text-brown-500" datetime={ bff.FormatISO(label.CreatedAt) }>{ label.CreatedAt.Format("Jan 2, 2006 15:04") }</time> 862 + </div> 863 + <!-- Entity ID --> 864 + <div> 865 + <span class="text-xs font-medium text-brown-500 uppercase tracking-wide"> 866 + if label.EntityType == "user" { 867 + User DID 868 + } else { 869 + Record URI 870 + } 871 + </span> 872 + <code class="mt-1 block text-sm bg-brown-100 px-2 py-1 rounded break-all font-mono">{ label.EntityID }</code> 873 + </div> 874 + <!-- Meta --> 875 + <div class="flex flex-wrap gap-x-6 gap-y-2 text-sm"> 876 + <div> 877 + <span class="text-brown-500">By:</span> 878 + <code class="text-brown-700 ml-1 text-xs">{ label.CreatedBy }</code> 879 + </div> 880 + if label.ExpiresAt != nil { 881 + <div> 882 + <span class="text-brown-500">Expires:</span> 883 + <span class="text-brown-700 ml-1">{ label.ExpiresAt.Format("Jan 2, 2006 15:04") }</span> 884 + </div> 885 + } else { 886 + <div> 887 + <span class="text-brown-500">Expires:</span> 888 + <span class="text-brown-700 ml-1">Never</span> 889 + </div> 890 + } 891 + </div> 892 + <!-- Actions --> 893 + <div class="pt-2 border-t border-brown-200"> 894 + <button 895 + class="text-sm text-red-600 hover:text-red-800 font-medium" 896 + hx-post="/_mod/label/remove" 897 + hx-vals={ fmt.Sprintf(`{"entity_type": "%s", "entity_id": "%s", "label": "%s"}`, label.EntityType, label.EntityID, label.Name) } 898 + hx-swap="none" 899 + hx-confirm={ fmt.Sprintf("Remove label '%s' from %s?", label.Name, label.EntityID) } 900 + > 901 + Remove Label 902 + </button> 903 + </div> 904 + </div> 905 + </div> 906 + } 907 + 908 + templ LabelBadge(name string) { 909 + switch name { 910 + case "warned": 911 + <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800"> 912 + { name } 913 + </span> 914 + case "trusted": 915 + <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800"> 916 + { name } 917 + </span> 918 + case "under_review": 919 + <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800"> 920 + { name } 921 + </span> 922 + case "rate_limited": 923 + <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-orange-100 text-orange-800"> 924 + { name } 925 + </span> 926 + case "spam": 927 + <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800"> 928 + { name } 929 + </span> 930 + default: 931 + <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800"> 932 + { name } 933 + </span> 934 + } 733 935 } 734 936 735 937 templ AdminStatsContent(stats AdminStats) {