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: permission middleware, simplify handlers

authored by

Patrick Dewey and committed by tangled.org 9a37b454 d3359f00

+991 -138
+4 -3
cmd/server/main.go
··· 397 397 398 398 // Setup router with middleware 399 399 handler := routing.SetupRouter(routing.Config{ 400 - Handlers: h, 401 - OAuthManager: oauthManager, 402 - Logger: log.Logger, 400 + Handlers: h, 401 + OAuthManager: oauthManager, 402 + Logger: log.Logger, 403 + ModerationService: moderationSvc, 403 404 }) 404 405 405 406 // Start internal metrics server on localhost only (not publicly accessible)
+483
docs/moderation-improvements.md
··· 1 + # Moderation Improvements: Rules as Config, Labels, Permission Middleware 2 + 3 + Exploration of three Osprey-inspired ideas adapted for Arabica's single-binary 4 + Go architecture. No new infrastructure required — these build on the existing 5 + SQLite + JSON config system. 6 + 7 + ## 1. Rules as Config 8 + 9 + ### Problem 10 + 11 + Automod thresholds are hardcoded constants in `internal/handlers/report.go`: 12 + 13 + ```go 14 + const ( 15 + AutoHideThreshold = 3 // reports on single record 16 + AutoHideUserThreshold = 5 // total reports on user's content 17 + ReportRateLimitPerHour = 10 18 + MaxReportReasonLength = 500 19 + ) 20 + ``` 21 + 22 + Changing these requires a code change and redeploy. There's also no way to 23 + express more nuanced rules like "auto-hide from new users at a lower threshold" 24 + or "auto-blacklist after N auto-hides." 25 + 26 + ### Proposal 27 + 28 + Add an `automod` section to the existing moderators JSON config: 29 + 30 + ```json 31 + { 32 + "roles": { ... }, 33 + "users": [ ... ], 34 + "automod": { 35 + "rules": [ 36 + { 37 + "name": "high_report_uri", 38 + "description": "Auto-hide records with 3+ reports", 39 + "trigger": "report_created", 40 + "conditions": { 41 + "reports_on_uri": { "gte": 3 } 42 + }, 43 + "action": "hide_record" 44 + }, 45 + { 46 + "name": "high_report_user", 47 + "description": "Auto-hide when user has 5+ reported records", 48 + "trigger": "report_created", 49 + "conditions": { 50 + "reports_on_user": { "gte": 5 } 51 + }, 52 + "action": "hide_record" 53 + }, 54 + { 55 + "name": "repeat_offender", 56 + "description": "Blacklist users auto-hidden 3+ times", 57 + "trigger": "record_auto_hidden", 58 + "conditions": { 59 + "auto_hides_on_user": { "gte": 3 } 60 + }, 61 + "action": "blacklist_user" 62 + } 63 + ], 64 + "rate_limit_per_hour": 10, 65 + "max_reason_length": 500 66 + } 67 + } 68 + ``` 69 + 70 + ### Condition Types 71 + 72 + Each condition maps to an existing store query or a simple new one: 73 + 74 + | Condition | Store Method | Exists? | 75 + |-----------|-------------|---------| 76 + | `reports_on_uri` | `CountReportsForURI()` | Yes | 77 + | `reports_on_user` | `CountReportsForDID()` / `CountReportsForDIDSince()` | Yes | 78 + | `auto_hides_on_user` | Count hidden records where `subject_did = ?` | New query | 79 + | `has_label` | Label lookup (see section 2) | New | 80 + | `account_age_days` | Would need PDS profile data | Deferred | 81 + 82 + ### Trigger Types 83 + 84 + | Trigger | When Evaluated | 85 + |---------|---------------| 86 + | `report_created` | After `CreateReport()` succeeds (replaces current `checkAutomod`) | 87 + | `record_auto_hidden` | After an auto-hide action completes (enables chained rules) | 88 + 89 + ### Action Types 90 + 91 + | Action | Implementation | 92 + |--------|---------------| 93 + | `hide_record` | Existing `HideRecord()` with `AutoHidden: true` | 94 + | `blacklist_user` | Existing `BlacklistUser()` with `BlacklistedBy: "automod"` | 95 + | `add_label` | New — adds a label to user/record (see section 2) | 96 + | `remove_label` | New — removes a label | 97 + 98 + ### Go Types 99 + 100 + ```go 101 + // In internal/moderation/models.go 102 + 103 + type AutomodConfig struct { 104 + Rules []AutomodRule `json:"rules"` 105 + RateLimitPerHour int `json:"rate_limit_per_hour"` 106 + MaxReasonLength int `json:"max_reason_length"` 107 + } 108 + 109 + type AutomodRule struct { 110 + Name string `json:"name"` 111 + Description string `json:"description"` 112 + Trigger AutomodTrigger `json:"trigger"` 113 + Conditions []AutomodCondition `json:"conditions"` 114 + Action AutomodAction `json:"action"` 115 + } 116 + 117 + type AutomodTrigger string 118 + 119 + const ( 120 + TriggerReportCreated AutomodTrigger = "report_created" 121 + TriggerRecordAutoHidden AutomodTrigger = "record_auto_hidden" 122 + ) 123 + 124 + type AutomodCondition struct { 125 + Type string `json:"type"` // "reports_on_uri", "reports_on_user", etc. 126 + Operator string `json:"operator"` // "gte", "eq", "lte" 127 + Threshold int `json:"threshold"` 128 + Label string `json:"label,omitempty"` // For has_label condition 129 + } 130 + 131 + type AutomodAction struct { 132 + Type string `json:"type"` // "hide_record", "blacklist_user", "add_label" 133 + Label string `json:"label,omitempty"` // For add_label/remove_label actions 134 + } 135 + ``` 136 + 137 + ### Evaluation Engine 138 + 139 + Replace `checkAutomod()` with a rule evaluator: 140 + 141 + ```go 142 + // In internal/moderation/automod.go 143 + 144 + type RuleEvaluator struct { 145 + rules []AutomodRule 146 + store Store 147 + } 148 + 149 + func (e *RuleEvaluator) Evaluate(ctx context.Context, trigger AutomodTrigger, event AutomodEvent) []AutomodResult { 150 + var results []AutomodResult 151 + for _, rule := range e.rules { 152 + if rule.Trigger != trigger { 153 + continue 154 + } 155 + if e.allConditionsMet(ctx, rule.Conditions, event) { 156 + results = append(results, AutomodResult{ 157 + Rule: rule, 158 + Action: rule.Action, 159 + }) 160 + } 161 + } 162 + return results 163 + } 164 + ``` 165 + 166 + The handler calls the evaluator instead of checking hardcoded thresholds. 167 + Results chain — if a `hide_record` action fires, it triggers 168 + `record_auto_hidden` rules in the same pass. 169 + 170 + ### Migration Path 171 + 172 + 1. Add `AutomodConfig` to `Config` struct with defaults matching current 173 + constants 174 + 2. Move `checkAutomod()` logic into `RuleEvaluator` 175 + 3. If no `automod` section in config, use built-in defaults (backward 176 + compatible) 177 + 4. Delete the hardcoded constants 178 + 179 + --- 180 + 181 + ## 2. Labels 182 + 183 + ### Problem 184 + 185 + Automod is stateless — it only counts reports at decision time. There's no way 186 + to say "this user was warned" or "this account is new and should have stricter 187 + thresholds." Moderators also can't tag users with notes that affect future 188 + automod behavior. 189 + 190 + ### Proposal 191 + 192 + Add a lightweight label system for entities (users and records). Labels are 193 + key-value tags with optional TTL, stored in SQLite alongside the existing 194 + moderation tables. 195 + 196 + ### Schema 197 + 198 + ```sql 199 + CREATE TABLE moderation_labels ( 200 + id TEXT PRIMARY KEY, -- TID 201 + entity_type TEXT NOT NULL, -- 'user' or 'record' 202 + entity_id TEXT NOT NULL, -- DID or AT-URI 203 + label TEXT NOT NULL, -- e.g. 'warned', 'trusted', 'new_account' 204 + value TEXT DEFAULT '', -- optional value 205 + created_at TEXT NOT NULL, -- RFC3339Nano 206 + created_by TEXT NOT NULL, -- DID or 'automod' or 'system' 207 + expires_at TEXT, -- RFC3339Nano, NULL = permanent 208 + UNIQUE(entity_type, entity_id, label) 209 + ); 210 + 211 + CREATE INDEX idx_labels_entity ON moderation_labels(entity_type, entity_id); 212 + CREATE INDEX idx_labels_expires ON moderation_labels(expires_at) WHERE expires_at IS NOT NULL; 213 + ``` 214 + 215 + ### Store Interface Additions 216 + 217 + ```go 218 + // Added to moderation.Store interface 219 + 220 + // Labels 221 + AddLabel(ctx context.Context, label Label) error 222 + RemoveLabel(ctx context.Context, entityType, entityID, labelName string) error 223 + HasLabel(ctx context.Context, entityType, entityID, labelName string) (bool, error) 224 + GetLabel(ctx context.Context, entityType, entityID, labelName string) (*Label, error) 225 + ListLabels(ctx context.Context, entityType, entityID string) ([]Label, error) 226 + CleanExpiredLabels(ctx context.Context) (int, error) // Periodic cleanup 227 + ``` 228 + 229 + ### Label Model 230 + 231 + ```go 232 + type Label struct { 233 + ID string `json:"id"` 234 + EntityType string `json:"entity_type"` // "user" or "record" 235 + EntityID string `json:"entity_id"` // DID or AT-URI 236 + Name string `json:"label"` 237 + Value string `json:"value,omitempty"` 238 + CreatedAt time.Time `json:"created_at"` 239 + CreatedBy string `json:"created_by"` 240 + ExpiresAt *time.Time `json:"expires_at,omitempty"` 241 + } 242 + 243 + func (l *Label) IsExpired() bool { 244 + return l.ExpiresAt != nil && time.Now().After(*l.ExpiresAt) 245 + } 246 + ``` 247 + 248 + ### Predefined Labels 249 + 250 + These would be documented but not hardcoded — any string works as a label name: 251 + 252 + | Label | Entity | Meaning | Typical TTL | 253 + |-------|--------|---------|-------------| 254 + | `warned` | user | Moderator issued a warning | 30 days | 255 + | `trusted` | user | Exempt from automod | permanent | 256 + | `under_review` | user | Flagged for manual review | 7 days | 257 + | `rate_limited` | user | Temporarily restricted | 24 hours | 258 + | `spam` | record | Identified as spam | permanent | 259 + 260 + ### Integration with Automod Rules 261 + 262 + Labels become a condition type in automod rules: 263 + 264 + ```json 265 + { 266 + "name": "strict_threshold_warned_users", 267 + "trigger": "report_created", 268 + "conditions": { 269 + "reports_on_uri": { "gte": 2 }, 270 + "has_label": { "entity": "subject_user", "label": "warned" } 271 + }, 272 + "action": "hide_record" 273 + } 274 + ``` 275 + 276 + And an action type: 277 + 278 + ```json 279 + { 280 + "name": "warn_on_first_autohide", 281 + "trigger": "record_auto_hidden", 282 + "conditions": { 283 + "auto_hides_on_user": { "gte": 1 }, 284 + "not_has_label": { "entity": "subject_user", "label": "warned" } 285 + }, 286 + "action": { "type": "add_label", "label": "warned", "expires": "30d" } 287 + } 288 + ``` 289 + 290 + ### UI Integration 291 + 292 + - Admin dashboard shows labels on users/records 293 + - Moderators with `manage_labels` permission can add/remove labels manually 294 + - Label badges appear on reported content for context 295 + 296 + ### Cleanup 297 + 298 + A goroutine runs `CleanExpiredLabels()` periodically (e.g., every hour). This 299 + is a single DELETE query: 300 + 301 + ```sql 302 + DELETE FROM moderation_labels WHERE expires_at IS NOT NULL AND expires_at < ? 303 + ``` 304 + 305 + --- 306 + 307 + ## 3. Permission Middleware 308 + 309 + ### Problem 310 + 311 + Every moderation handler repeats the same auth + permission check boilerplate: 312 + 313 + ```go 314 + userDID, err := atproto.GetAuthenticatedDID(r.Context()) 315 + if err != nil || userDID == "" { 316 + http.Error(w, "Authentication required", http.StatusUnauthorized) 317 + return 318 + } 319 + if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionXXX) { 320 + log.Warn().Str("did", userDID).Msg("Denied: insufficient permissions") 321 + http.Error(w, "Permission denied", http.StatusForbidden) 322 + return 323 + } 324 + ``` 325 + 326 + This is ~8 lines repeated in 8 handlers. Changes to auth behavior require 327 + touching every handler. 328 + 329 + ### Proposal 330 + 331 + A `RequirePermission` middleware that wraps handlers with permission checks: 332 + 333 + ```go 334 + // In internal/middleware/moderation.go 335 + 336 + func RequirePermission( 337 + modService *moderation.Service, 338 + perm moderation.Permission, 339 + next http.Handler, 340 + ) http.Handler { 341 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 342 + userDID := atproto.GetAuthenticatedDIDFromContext(r.Context()) 343 + if userDID == "" { 344 + http.Error(w, "Authentication required", http.StatusUnauthorized) 345 + return 346 + } 347 + 348 + if modService == nil || !modService.HasPermission(userDID, perm) { 349 + log.Warn(). 350 + Str("did", userDID). 351 + Str("permission", string(perm)). 352 + Str("path", r.URL.Path). 353 + Msg("permission denied") 354 + http.Error(w, "Permission denied", http.StatusForbidden) 355 + return 356 + } 357 + 358 + next.ServeHTTP(w, r) 359 + }) 360 + } 361 + 362 + func RequireModerator( 363 + modService *moderation.Service, 364 + next http.Handler, 365 + ) http.Handler { 366 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 367 + userDID := atproto.GetAuthenticatedDIDFromContext(r.Context()) 368 + if userDID == "" { 369 + http.Error(w, "Authentication required", http.StatusUnauthorized) 370 + return 371 + } 372 + 373 + if modService == nil || !modService.IsModerator(userDID) { 374 + http.Error(w, "Access denied", http.StatusForbidden) 375 + return 376 + } 377 + 378 + next.ServeHTTP(w, r) 379 + }) 380 + } 381 + ``` 382 + 383 + ### Routing Changes 384 + 385 + Before: 386 + ```go 387 + mux.Handle("POST /_mod/hide", cop.Handler(http.HandlerFunc(h.HandleHideRecord))) 388 + mux.Handle("POST /_mod/unhide", cop.Handler(http.HandlerFunc(h.HandleUnhideRecord))) 389 + mux.Handle("POST /_mod/block", cop.Handler(http.HandlerFunc(h.HandleBlockUser))) 390 + // ... each handler checks permissions internally 391 + ``` 392 + 393 + After: 394 + ```go 395 + modPerm := middleware.RequirePermission // alias for readability 396 + 397 + mux.Handle("POST /_mod/hide", 398 + cop.Handler(modPerm(modSvc, moderation.PermissionHideRecord, 399 + http.HandlerFunc(h.HandleHideRecord)))) 400 + 401 + mux.Handle("POST /_mod/unhide", 402 + cop.Handler(modPerm(modSvc, moderation.PermissionUnhideRecord, 403 + http.HandlerFunc(h.HandleUnhideRecord)))) 404 + 405 + mux.Handle("POST /_mod/block", 406 + cop.Handler(modPerm(modSvc, moderation.PermissionBlacklistUser, 407 + http.HandlerFunc(h.HandleBlockUser)))) 408 + ``` 409 + 410 + ### What Handlers Lose 411 + 412 + Each handler drops the first ~8 lines of boilerplate. They still need the 413 + authenticated DID for audit logging, but can get it from context (it's already 414 + validated by the middleware): 415 + 416 + ```go 417 + func (h *Handler) HandleHideRecord(w http.ResponseWriter, r *http.Request) { 418 + // DID is guaranteed valid by middleware 419 + userDID := atproto.GetAuthenticatedDIDFromContext(r.Context()) 420 + 421 + // Straight to business logic 422 + if err := r.ParseForm(); err != nil { 423 + http.Error(w, "Invalid request body", http.StatusBadRequest) 424 + return 425 + } 426 + // ... 427 + } 428 + ``` 429 + 430 + ### Edge Case: Admin Dashboard 431 + 432 + `HandleAdmin` uses `IsModerator()` (not a specific permission) and then 433 + conditionally loads data based on individual permissions. This stays as-is — the 434 + middleware handles the gate, the handler still calls `HasPermission()` for 435 + conditional data loading: 436 + 437 + ```go 438 + mux.HandleFunc("GET /_mod", 439 + middleware.RequireModerator(modSvc, http.HandlerFunc(h.HandleAdmin))) 440 + 441 + // Inside HandleAdmin, permission checks remain for conditional data: 442 + canHide := h.moderationService.HasPermission(userDID, moderation.PermissionHideRecord) 443 + ``` 444 + 445 + --- 446 + 447 + ## Implementation Order 448 + 449 + These three features have natural dependencies: 450 + 451 + ``` 452 + Phase 1: Permission Middleware 453 + └─ Standalone refactor, no new features 454 + └─ Reduces boilerplate, makes Phase 2/3 cleaner 455 + └─ ~1 new file, ~8 handler edits, routing changes 456 + 457 + Phase 2: Labels 458 + └─ New table, store methods, model types 459 + └─ Admin UI for viewing/managing labels 460 + └─ ~3 new files, store interface additions 461 + 462 + Phase 3: Rules as Config 463 + └─ Depends on Labels for `has_label` condition 464 + └─ Config schema additions, rule evaluator 465 + └─ Replace checkAutomod() with evaluator 466 + └─ ~2 new files, config changes, handler changes 467 + ``` 468 + 469 + Each phase is independently useful and shippable. Phase 1 is pure cleanup. 470 + Phase 2 gives moderators new capabilities. Phase 3 makes automod flexible. 471 + 472 + ## What This Doesn't Do 473 + 474 + - **No new infrastructure** — everything stays in SQLite + JSON config 475 + - **No DSL** — rules are JSON, not a custom language 476 + - **No real-time streaming** — evaluation happens synchronously in handlers 477 + - **No investigation UI** — the existing admin dashboard gets labels, not a 478 + query engine 479 + - **No ML/AI integration** — rules are deterministic threshold checks 480 + 481 + These are deliberate constraints. If the moderation needs outgrow this, that's 482 + the point where Osprey (or a similar system) becomes worth the infrastructure 483 + cost.
+214
docs/osprey-evaluation.md
··· 1 + # Osprey Rules Engine Evaluation 2 + 3 + Evaluation of [Osprey](https://github.com/roostorg/osprey) (by ROOST / 4 + internet.dev) as a potential replacement or complement to Arabica's moderation 5 + system. 6 + 7 + ## What Is Osprey? 8 + 9 + Osprey is a **real-time safety rules engine** for processing event streams and 10 + making automated decisions about user behavior. Originally built at Discord, 11 + open-sourced through ROOST (Robust Open Online Safety Tools). Tagline: "Automate 12 + the obvious and investigate the ambiguous." 13 + 14 + Adopted by **Bluesky, Discord, and Matrix.org**. Apache 2.0 licensed, reached 15 + v1.0.1 (March 2026), actively maintained. 16 + 17 + ### Core Concepts 18 + 19 + **SML (Some Madeup Language)** — A Python-subset DSL for writing rules: 20 + 21 + ```python 22 + # Models: extract features from event JSON 23 + UserId: Entity[str] = EntityJson(type='User', path='$.user.userId') 24 + PostText: str = JsonData(path='$.text') 25 + 26 + # Rules: boolean conditions 27 + SpamLinkRule = Rule( 28 + when_all=[ 29 + PostCount == 1, 30 + EmbedLink != None, 31 + ListLength(list=MentionIds) >= 1, 32 + ], 33 + description='First post with link embed', 34 + ) 35 + 36 + # Effects: actions when rules match 37 + WhenRules( 38 + rules_any=[SpamLinkRule], 39 + then=[ 40 + DeclareVerdict(verdict='reject'), 41 + LabelAdd(entity=UserId, label='likely_spammer'), 42 + ], 43 + ) 44 + ``` 45 + 46 + **Labels** — Stateful tags on entities (users, IPs, etc.) that persist across 47 + evaluations. Support expiry (`expires_after=TimeDelta(days=7)`). Enable stateful 48 + rules like "if this user was flagged as a spammer last week, auto-reject." 49 + 50 + **Entities** — Special features (UserID, IP, etc.) that can carry labels and 51 + have effects applied to them. 52 + 53 + **File Organization** — Rules compose across files via `Import()` and 54 + conditional `Require(require_if=EventType == 'userPost')`. 55 + 56 + ### Architecture 57 + 58 + Osprey is a **multi-service system**, not an embeddable library: 59 + 60 + | Component | Language | Purpose | 61 + |-----------|----------|---------| 62 + | Worker | Python | Core rules engine, consumes Kafka events | 63 + | Coordinator | Rust | Distributed deployment coordination (optional) | 64 + | UI | TypeScript/React | Investigation dashboard, querying, labeling | 65 + | UI API | Python/Flask | Backend for the UI, queries Druid | 66 + | RPC | gRPC/Protobuf | Inter-service communication | 67 + 68 + **Infrastructure requirements:** 69 + - Kafka (KRaft mode) — event I/O 70 + - PostgreSQL — labels, execution results 71 + - Apache Druid — OLAP for UI queries 72 + - MinIO — object storage for Druid 73 + - Google Bigtable (optional) — labels at scale 74 + 75 + **Data flow:** 76 + 1. Events arrive on Kafka input topic 77 + 2. Worker evaluates SML rules against events 78 + 3. Rules produce verdicts and effects (hide, label, reject) 79 + 4. Results dispatched to output sinks (Kafka, Labels, stdout) 80 + 5. Execution results flow to Druid for UI querying 81 + 82 + ### Plugin System 83 + 84 + Python `pluggy`-based. Plugins can register: 85 + - **UDFs** — Custom functions for SML rules 86 + - **Output Sinks** — Custom result destinations 87 + - **AST Validators** — Custom rule validation 88 + 89 + ## Current Arabica Moderation System 90 + 91 + For comparison, here's what Arabica has today (~2,100 lines across 3 layers): 92 + 93 + ### Capabilities 94 + 95 + | Feature | Implementation | 96 + |---------|---------------| 97 + | Role-based access | JSON config with admin/moderator roles, 8 granular permissions | 98 + | Record hiding | Manual + automod (3 reports → auto-hide) | 99 + | User blacklisting | Manual ban/unban by moderators | 100 + | Reports | User submission with rate limiting (10/hr), duplicate detection | 101 + | Automod | Threshold-based: 3 reports/URI or 5 reports/user → auto-hide | 102 + | Audit log | All actions logged including automod flag | 103 + | Admin dashboard | HTMX-powered with stats, reports, hidden records, blacklist | 104 + | Feed filtering | Batch-loads hidden URIs for efficient filtering | 105 + 106 + ### Code Organization 107 + 108 + ``` 109 + internal/moderation/ 110 + models.go # Roles, permissions, data types 111 + service.go # Thread-safe permission checks 112 + store.go # 16-method store interface 113 + internal/database/sqlitestore/ 114 + moderation.go # SQLite implementation 115 + internal/handlers/ 116 + admin.go # Dashboard + mod actions (662 lines) 117 + report.go # Report submission + automod (260 lines) 118 + ``` 119 + 120 + ### What Works Well 121 + 122 + - Optional design — gracefully degrades without config 123 + - Thread-safe service with RWMutex 124 + - Comprehensive audit trail 125 + - CSRF protection on all mutations 126 + - Efficient batch feed filtering 127 + - Flexible role/permission model 128 + 129 + ### Pain Points 130 + 131 + - Automod thresholds are hardcoded constants 132 + - Permission checks are manual boilerplate in every handler 133 + - Report enrichment makes unbatched PDS calls 134 + - Config changes require server restart 135 + - No rule composition or conditional logic beyond fixed thresholds 136 + 137 + ## Fit Assessment 138 + 139 + ### Where Osprey Shines vs Arabica's Needs 140 + 141 + **Osprey's strengths:** 142 + - Sophisticated rule composition (AND/OR, conditional loading, labels) 143 + - Stateful rules across evaluations (labels with TTL) 144 + - Investigation UI for T&S teams 145 + - Built for high-throughput event streams 146 + - Plugin system for custom detection logic 147 + 148 + **What Arabica could use:** 149 + - More flexible automod rules (not just hardcoded thresholds) 150 + - Rule composition (e.g., "new user + link + mention → suspicious") 151 + - Stateful tracking (e.g., "user had 3 reports dismissed this month") 152 + - Easier rule iteration without code deploys 153 + 154 + ### Why It Doesn't Fit Today 155 + 156 + | Concern | Detail | 157 + |---------|--------| 158 + | **Massive infrastructure overhead** | Kafka + Postgres + Druid + MinIO is orders of magnitude more infra than Arabica's SQLite-based stack. Arabica runs as a single Go binary. | 159 + | **No Go SDK** | Osprey is Python-native. No Go client library exists. Integration would require Kafka as middleware or raw gRPC proto compilation. | 160 + | **Scale mismatch** | Osprey was built for Discord-scale (millions of events/sec). Arabica is a small community app. The operational complexity is not justified. | 161 + | **Python dependency** | Arabica is a pure Go project. Adding a Python rules engine (plus its infra) contradicts the project's preference for stdlib solutions and minimal dependencies. | 162 + | **Overlapping concerns** | Osprey would replace ~2,100 lines of straightforward Go code with a multi-service deployment. The current system is well-structured and maintainable. | 163 + 164 + ### What Could Be Borrowed (Ideas, Not Code) 165 + 166 + Even though deploying Osprey doesn't make sense, some of its concepts are worth 167 + adopting in Arabica's existing moderation code: 168 + 169 + 1. **Configurable thresholds** — Move automod constants (3 reports/URI, 5 170 + reports/user) into the moderators JSON config so they're tunable without 171 + deploys. 172 + 173 + 2. **Labels / stateful tags** — Add a lightweight label system for users (e.g., 174 + `new_account`, `warned`, `trusted`). Labels could influence automod behavior: 175 + a `warned` user might have a lower auto-hide threshold. 176 + 177 + 3. **Rule composition** — Express automod rules as config rather than code: 178 + ```json 179 + { 180 + "automod_rules": [ 181 + { 182 + "name": "high_report_volume", 183 + "conditions": {"reports_on_uri": {"gte": 3}}, 184 + "action": "hide_record" 185 + }, 186 + { 187 + "name": "repeated_offender", 188 + "conditions": {"reports_on_user": {"gte": 5}, "user_label": "warned"}, 189 + "action": "blacklist_user" 190 + } 191 + ] 192 + } 193 + ``` 194 + 195 + 4. **Permission middleware** — Replace per-handler permission boilerplate with 196 + middleware that checks permissions based on route patterns. 197 + 198 + 5. **TTL-based labels** — Osprey's label expiry is useful for temporary states 199 + like "under review" or "rate-limited for 24h." 200 + 201 + ## Recommendation 202 + 203 + **Don't integrate Osprey.** The infrastructure and language mismatch is too 204 + large, and Arabica's moderation needs are well-served by its current 205 + ~2,100-line Go implementation. 206 + 207 + **Do consider** extracting the best ideas (configurable thresholds, labels, 208 + rule composition as config) into the existing system. This would address the 209 + current pain points (hardcoded thresholds, no stateful tracking) without the 210 + operational burden of a multi-service Python deployment. 211 + 212 + If Arabica ever grows to need a dedicated T&S team with investigation tooling, 213 + Osprey becomes worth revisiting — but that's a fundamentally different scale 214 + than today.
+7
internal/atproto/oauth.go
··· 189 189 contextKeySessionData contextKey = "sessionData" 190 190 ) 191 191 192 + // ContextWithAuthDID returns a context with the given DID set as the 193 + // authenticated user. This is useful for testing middleware and handlers that 194 + // call GetAuthenticatedDID. 195 + func ContextWithAuthDID(ctx context.Context, did string) context.Context { 196 + return context.WithValue(ctx, contextKeyUserDID, did) 197 + } 198 + 192 199 // GetAuthenticatedDID retrieves the authenticated user's DID from the request context 193 200 func GetAuthenticatedDID(ctx context.Context) (string, error) { 194 201 did, ok := ctx.Value(contextKeyUserDID).(string)
+15 -104
internal/handlers/admin.go
··· 26 26 } 27 27 28 28 // HandleHideRecord handles POST /admin/hide 29 + // Auth and permission checks are handled by RequirePermission middleware. 29 30 func (h *Handler) HandleHideRecord(w http.ResponseWriter, r *http.Request) { 30 - // Check authentication 31 - userDID, err := atproto.GetAuthenticatedDID(r.Context()) 32 - if err != nil || userDID == "" { 33 - http.Error(w, "Authentication required", http.StatusUnauthorized) 34 - return 35 - } 36 - 37 - // Check permission 38 - if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionHideRecord) { 39 - log.Warn().Str("did", userDID).Str("endpoint", "/_mod/hide").Msg("Denied: insufficient permissions") 40 - http.Error(w, "Permission denied", http.StatusForbidden) 41 - return 42 - } 31 + userDID, _ := atproto.GetAuthenticatedDID(r.Context()) 43 32 44 - // Parse form data only (JSON is rejected to prevent CSRF bypass) 45 33 if err := r.ParseForm(); err != nil { 46 34 http.Error(w, "Invalid request body", http.StatusBadRequest) 47 35 return ··· 95 83 } 96 84 97 85 // HandleUnhideRecord handles POST /admin/unhide 86 + // Auth and permission checks are handled by RequirePermission middleware. 98 87 func (h *Handler) HandleUnhideRecord(w http.ResponseWriter, r *http.Request) { 99 - // Check authentication 100 - userDID, err := atproto.GetAuthenticatedDID(r.Context()) 101 - if err != nil || userDID == "" { 102 - http.Error(w, "Authentication required", http.StatusUnauthorized) 103 - return 104 - } 105 - 106 - // Check permission 107 - if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionUnhideRecord) { 108 - log.Warn().Str("did", userDID).Str("endpoint", "/_mod/unhide").Msg("Denied: insufficient permissions") 109 - http.Error(w, "Permission denied", http.StatusForbidden) 110 - return 111 - } 88 + userDID, _ := atproto.GetAuthenticatedDID(r.Context()) 112 89 113 - // Parse form data only (JSON is rejected to prevent CSRF bypass) 114 90 if err := r.ParseForm(); err != nil { 115 91 http.Error(w, "Invalid request body", http.StatusBadRequest) 116 92 return ··· 257 233 } 258 234 259 235 // HandleAdminPartial renders just the admin dashboard content (for HTMX refresh) 236 + // Auth and moderator checks are handled by RequireModerator middleware. 260 237 func (h *Handler) HandleAdminPartial(w http.ResponseWriter, r *http.Request) { 261 - userDID, err := atproto.GetAuthenticatedDID(r.Context()) 262 - if err != nil || userDID == "" { 263 - http.Error(w, "Authentication required", http.StatusUnauthorized) 264 - return 265 - } 266 - 267 - if h.moderationService == nil || !h.moderationService.IsModerator(userDID) { 268 - log.Warn().Str("did", userDID).Str("endpoint", "/_mod/content").Msg("Denied: not a moderator") 269 - http.Error(w, "Access denied", http.StatusForbidden) 270 - return 271 - } 272 - 238 + userDID, _ := atproto.GetAuthenticatedDID(r.Context()) 273 239 adminProps := h.buildAdminProps(r.Context(), userDID) 274 240 275 241 if err := pages.AdminDashboardBody(adminProps).Render(r.Context(), w); err != nil { ··· 366 332 } 367 333 368 334 // HandleBlockUser handles POST /_mod/block 335 + // Auth and permission checks are handled by RequirePermission middleware. 369 336 func (h *Handler) HandleBlockUser(w http.ResponseWriter, r *http.Request) { 370 - // Check authentication 371 - userDID, err := atproto.GetAuthenticatedDID(r.Context()) 372 - if err != nil || userDID == "" { 373 - http.Error(w, "Authentication required", http.StatusUnauthorized) 374 - return 375 - } 337 + userDID, _ := atproto.GetAuthenticatedDID(r.Context()) 376 338 377 - // Check permission 378 - if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionBlacklistUser) { 379 - log.Warn().Str("did", userDID).Str("endpoint", "/_mod/block").Msg("Denied: insufficient permissions") 380 - http.Error(w, "Permission denied", http.StatusForbidden) 381 - return 382 - } 383 - 384 - // Parse form data only (JSON is rejected to prevent CSRF bypass) 385 339 if err := r.ParseForm(); err != nil { 386 340 http.Error(w, "Invalid request body", http.StatusBadRequest) 387 341 return ··· 433 387 } 434 388 435 389 // HandleUnblockUser handles POST /_mod/unblock 390 + // Auth and permission checks are handled by RequirePermission middleware. 436 391 func (h *Handler) HandleUnblockUser(w http.ResponseWriter, r *http.Request) { 437 - // Check authentication 438 - userDID, err := atproto.GetAuthenticatedDID(r.Context()) 439 - if err != nil || userDID == "" { 440 - http.Error(w, "Authentication required", http.StatusUnauthorized) 441 - return 442 - } 443 - 444 - // Check permission 445 - if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionUnblacklistUser) { 446 - log.Warn().Str("did", userDID).Str("endpoint", "/_mod/unblock").Msg("Denied: insufficient permissions") 447 - http.Error(w, "Permission denied", http.StatusForbidden) 448 - return 449 - } 392 + userDID, _ := atproto.GetAuthenticatedDID(r.Context()) 450 393 451 - // Parse form data only (JSON is rejected to prevent CSRF bypass) 452 394 if err := r.ParseForm(); err != nil { 453 395 http.Error(w, "Invalid request body", http.StatusBadRequest) 454 396 return ··· 492 434 493 435 // HandleResetAutoHide handles POST /_mod/reset-autohide 494 436 // Resets the per-user auto-hide report counter so that only future reports count toward the threshold. 437 + // Auth and permission checks are handled by RequirePermission middleware. 495 438 func (h *Handler) HandleResetAutoHide(w http.ResponseWriter, r *http.Request) { 496 - userDID, err := atproto.GetAuthenticatedDID(r.Context()) 497 - if err != nil || userDID == "" { 498 - http.Error(w, "Authentication required", http.StatusUnauthorized) 499 - return 500 - } 501 - 502 - if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionResetAutoHide) { 503 - log.Warn().Str("did", userDID).Str("endpoint", "/_mod/reset-autohide").Msg("Denied: insufficient permissions") 504 - http.Error(w, "Permission denied", http.StatusForbidden) 505 - return 506 - } 439 + userDID, _ := atproto.GetAuthenticatedDID(r.Context()) 507 440 508 441 if err := r.ParseForm(); err != nil { 509 442 http.Error(w, "Invalid request body", http.StatusBadRequest) ··· 545 478 } 546 479 547 480 // HandleDismissReport handles POST /_mod/dismiss-report 481 + // Auth and permission checks are handled by RequirePermission middleware. 548 482 func (h *Handler) HandleDismissReport(w http.ResponseWriter, r *http.Request) { 549 - // Check authentication 550 - userDID, err := atproto.GetAuthenticatedDID(r.Context()) 551 - if err != nil || userDID == "" { 552 - http.Error(w, "Authentication required", http.StatusUnauthorized) 553 - return 554 - } 555 - 556 - // Check permission 557 - if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionDismissReport) { 558 - log.Warn().Str("did", userDID).Str("endpoint", "/_mod/dismiss-report").Msg("Denied: insufficient permissions") 559 - http.Error(w, "Permission denied", http.StatusForbidden) 560 - return 561 - } 483 + userDID, _ := atproto.GetAuthenticatedDID(r.Context()) 562 484 563 - // Parse request 564 485 if err := r.ParseForm(); err != nil { 565 486 http.Error(w, "Invalid request body", http.StatusBadRequest) 566 487 return ··· 641 562 } 642 563 643 564 // HandleAdminStats renders the stats partial for HTMX refresh. 565 + // Auth and admin checks are handled by RequireAdmin middleware. 644 566 func (h *Handler) HandleAdminStats(w http.ResponseWriter, r *http.Request) { 645 - userDID, err := atproto.GetAuthenticatedDID(r.Context()) 646 - if err != nil || userDID == "" { 647 - http.Error(w, "Authentication required", http.StatusUnauthorized) 648 - return 649 - } 650 - 651 - if h.moderationService == nil || !h.moderationService.IsAdmin(userDID) { 652 - http.Error(w, "Access denied", http.StatusForbidden) 653 - return 654 - } 655 - 656 567 stats := h.collectAdminStats(r.Context()) 657 568 658 569 if err := pages.AdminStatsContent(stats).Render(r.Context(), w); err != nil {
+4 -18
internal/handlers/join.go
··· 97 97 } 98 98 99 99 // HandleCreateInvite creates a PDS invite code and emails it to the requester. 100 + // Auth and admin checks are handled by RequireAdmin middleware. 100 101 func (h *Handler) HandleCreateInvite(w http.ResponseWriter, r *http.Request) { 101 - userDID, err := atproto.GetAuthenticatedDID(r.Context()) 102 - if err != nil || userDID == "" { 103 - http.Error(w, "Authentication required", http.StatusUnauthorized) 104 - return 105 - } 106 - if h.moderationService == nil || !h.moderationService.IsAdmin(userDID) { 107 - http.Error(w, "Access denied", http.StatusForbidden) 108 - return 109 - } 102 + userDID, _ := atproto.GetAuthenticatedDID(r.Context()) 110 103 111 104 if err := r.ParseForm(); err != nil { 112 105 http.Error(w, "Invalid request", http.StatusBadRequest) ··· 207 200 } 208 201 209 202 // HandleDismissJoinRequest removes a join request without sending an invite. 203 + // Auth and admin checks are handled by RequireAdmin middleware. 210 204 func (h *Handler) HandleDismissJoinRequest(w http.ResponseWriter, r *http.Request) { 211 - userDID, err := atproto.GetAuthenticatedDID(r.Context()) 212 - if err != nil || userDID == "" { 213 - http.Error(w, "Authentication required", http.StatusUnauthorized) 214 - return 215 - } 216 - if h.moderationService == nil || !h.moderationService.IsAdmin(userDID) { 217 - http.Error(w, "Access denied", http.StatusForbidden) 218 - return 219 - } 205 + userDID, _ := atproto.GetAuthenticatedDID(r.Context()) 220 206 221 207 if err := r.ParseForm(); err != nil { 222 208 http.Error(w, "Invalid request", http.StatusBadRequest)
+76
internal/middleware/moderation.go
··· 1 + package middleware 2 + 3 + import ( 4 + "net/http" 5 + 6 + "arabica/internal/atproto" 7 + "arabica/internal/moderation" 8 + 9 + "github.com/rs/zerolog/log" 10 + ) 11 + 12 + // RequirePermission returns middleware that checks the authenticated user has 13 + // the given permission. Returns 401 if unauthenticated, 403 if not permitted. 14 + func RequirePermission(svc *moderation.Service, perm moderation.Permission, next http.Handler) http.Handler { 15 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 + userDID, err := atproto.GetAuthenticatedDID(r.Context()) 17 + if err != nil || userDID == "" { 18 + http.Error(w, "Authentication required", http.StatusUnauthorized) 19 + return 20 + } 21 + 22 + if svc == nil || !svc.HasPermission(userDID, perm) { 23 + log.Warn(). 24 + Str("did", userDID). 25 + Str("permission", string(perm)). 26 + Str("path", r.URL.Path). 27 + Msg("Denied: insufficient permissions") 28 + http.Error(w, "Permission denied", http.StatusForbidden) 29 + return 30 + } 31 + 32 + next.ServeHTTP(w, r) 33 + }) 34 + } 35 + 36 + // RequireModerator returns middleware that checks the authenticated user is a 37 + // moderator (any role). Returns 401 if unauthenticated, 403 if not a moderator. 38 + func RequireModerator(svc *moderation.Service, next http.Handler) http.Handler { 39 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 40 + userDID, err := atproto.GetAuthenticatedDID(r.Context()) 41 + if err != nil || userDID == "" { 42 + http.Error(w, "Authentication required", http.StatusUnauthorized) 43 + return 44 + } 45 + 46 + if svc == nil || !svc.IsModerator(userDID) { 47 + log.Warn(). 48 + Str("did", userDID). 49 + Str("path", r.URL.Path). 50 + Msg("Denied: not a moderator") 51 + http.Error(w, "Access denied", http.StatusForbidden) 52 + return 53 + } 54 + 55 + next.ServeHTTP(w, r) 56 + }) 57 + } 58 + 59 + // RequireAdmin returns middleware that checks the authenticated user is an admin. 60 + // Returns 401 if unauthenticated, 403 if not an admin. 61 + func RequireAdmin(svc *moderation.Service, next http.Handler) http.Handler { 62 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 63 + userDID, err := atproto.GetAuthenticatedDID(r.Context()) 64 + if err != nil || userDID == "" { 65 + http.Error(w, "Authentication required", http.StatusUnauthorized) 66 + return 67 + } 68 + 69 + if svc == nil || !svc.IsAdmin(userDID) { 70 + http.Error(w, "Access denied", http.StatusForbidden) 71 + return 72 + } 73 + 74 + next.ServeHTTP(w, r) 75 + }) 76 + }
+161
internal/middleware/moderation_test.go
··· 1 + package middleware 2 + 3 + import ( 4 + "net/http" 5 + "net/http/httptest" 6 + "os" 7 + "path/filepath" 8 + "testing" 9 + 10 + "arabica/internal/atproto" 11 + "arabica/internal/moderation" 12 + 13 + "github.com/stretchr/testify/assert" 14 + ) 15 + 16 + func authenticatedRequest(did string) *http.Request { 17 + req := httptest.NewRequest(http.MethodPost, "/", nil) 18 + ctx := atproto.ContextWithAuthDID(req.Context(), did) 19 + return req.WithContext(ctx) 20 + } 21 + 22 + func unauthenticatedRequest() *http.Request { 23 + return httptest.NewRequest(http.MethodPost, "/", nil) 24 + } 25 + 26 + // setupService creates a moderation service from a temp config file with an 27 + // admin (did:plc:admin) and a moderator (did:plc:mod) who can only hide records. 28 + func setupService(t *testing.T) *moderation.Service { 29 + t.Helper() 30 + config := `{ 31 + "roles": { 32 + "admin": { 33 + "description": "Full access", 34 + "permissions": ["hide_record", "unhide_record", "blacklist_user", "unblacklist_user", "view_reports", "dismiss_report", "view_audit_log", "reset_autohide"] 35 + }, 36 + "moderator": { 37 + "description": "Limited", 38 + "permissions": ["hide_record", "view_reports"] 39 + } 40 + }, 41 + "users": [ 42 + {"did": "did:plc:admin", "handle": "admin", "role": "admin"}, 43 + {"did": "did:plc:mod", "handle": "mod", "role": "moderator"} 44 + ] 45 + }` 46 + path := filepath.Join(t.TempDir(), "mod.json") 47 + err := os.WriteFile(path, []byte(config), 0644) 48 + assert.NoError(t, err) 49 + 50 + svc, err := moderation.NewService(path) 51 + assert.NoError(t, err) 52 + return svc 53 + } 54 + 55 + var okHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 56 + w.WriteHeader(http.StatusOK) 57 + }) 58 + 59 + func TestRequirePermission(t *testing.T) { 60 + svc := setupService(t) 61 + 62 + t.Run("unauthenticated returns 401", func(t *testing.T) { 63 + rec := httptest.NewRecorder() 64 + h := RequirePermission(svc, moderation.PermissionHideRecord, okHandler) 65 + h.ServeHTTP(rec, unauthenticatedRequest()) 66 + assert.Equal(t, http.StatusUnauthorized, rec.Code) 67 + }) 68 + 69 + t.Run("no permission returns 403", func(t *testing.T) { 70 + rec := httptest.NewRecorder() 71 + // mod doesn't have blacklist_user 72 + h := RequirePermission(svc, moderation.PermissionBlacklistUser, okHandler) 73 + h.ServeHTTP(rec, authenticatedRequest("did:plc:mod")) 74 + assert.Equal(t, http.StatusForbidden, rec.Code) 75 + }) 76 + 77 + t.Run("unknown user returns 403", func(t *testing.T) { 78 + rec := httptest.NewRecorder() 79 + h := RequirePermission(svc, moderation.PermissionHideRecord, okHandler) 80 + h.ServeHTTP(rec, authenticatedRequest("did:plc:nobody")) 81 + assert.Equal(t, http.StatusForbidden, rec.Code) 82 + }) 83 + 84 + t.Run("permitted user passes through", func(t *testing.T) { 85 + rec := httptest.NewRecorder() 86 + h := RequirePermission(svc, moderation.PermissionHideRecord, okHandler) 87 + h.ServeHTTP(rec, authenticatedRequest("did:plc:mod")) 88 + assert.Equal(t, http.StatusOK, rec.Code) 89 + }) 90 + 91 + t.Run("admin has all permissions", func(t *testing.T) { 92 + rec := httptest.NewRecorder() 93 + h := RequirePermission(svc, moderation.PermissionBlacklistUser, okHandler) 94 + h.ServeHTTP(rec, authenticatedRequest("did:plc:admin")) 95 + assert.Equal(t, http.StatusOK, rec.Code) 96 + }) 97 + 98 + t.Run("nil service returns 403", func(t *testing.T) { 99 + rec := httptest.NewRecorder() 100 + h := RequirePermission(nil, moderation.PermissionHideRecord, okHandler) 101 + h.ServeHTTP(rec, authenticatedRequest("did:plc:admin")) 102 + assert.Equal(t, http.StatusForbidden, rec.Code) 103 + }) 104 + } 105 + 106 + func TestRequireModerator(t *testing.T) { 107 + svc := setupService(t) 108 + 109 + t.Run("unauthenticated returns 401", func(t *testing.T) { 110 + rec := httptest.NewRecorder() 111 + h := RequireModerator(svc, okHandler) 112 + h.ServeHTTP(rec, unauthenticatedRequest()) 113 + assert.Equal(t, http.StatusUnauthorized, rec.Code) 114 + }) 115 + 116 + t.Run("non-moderator returns 403", func(t *testing.T) { 117 + rec := httptest.NewRecorder() 118 + h := RequireModerator(svc, okHandler) 119 + h.ServeHTTP(rec, authenticatedRequest("did:plc:nobody")) 120 + assert.Equal(t, http.StatusForbidden, rec.Code) 121 + }) 122 + 123 + t.Run("moderator passes through", func(t *testing.T) { 124 + rec := httptest.NewRecorder() 125 + h := RequireModerator(svc, okHandler) 126 + h.ServeHTTP(rec, authenticatedRequest("did:plc:mod")) 127 + assert.Equal(t, http.StatusOK, rec.Code) 128 + }) 129 + 130 + t.Run("admin passes through", func(t *testing.T) { 131 + rec := httptest.NewRecorder() 132 + h := RequireModerator(svc, okHandler) 133 + h.ServeHTTP(rec, authenticatedRequest("did:plc:admin")) 134 + assert.Equal(t, http.StatusOK, rec.Code) 135 + }) 136 + } 137 + 138 + func TestRequireAdmin(t *testing.T) { 139 + svc := setupService(t) 140 + 141 + t.Run("unauthenticated returns 401", func(t *testing.T) { 142 + rec := httptest.NewRecorder() 143 + h := RequireAdmin(svc, okHandler) 144 + h.ServeHTTP(rec, unauthenticatedRequest()) 145 + assert.Equal(t, http.StatusUnauthorized, rec.Code) 146 + }) 147 + 148 + t.Run("moderator returns 403", func(t *testing.T) { 149 + rec := httptest.NewRecorder() 150 + h := RequireAdmin(svc, okHandler) 151 + h.ServeHTTP(rec, authenticatedRequest("did:plc:mod")) 152 + assert.Equal(t, http.StatusForbidden, rec.Code) 153 + }) 154 + 155 + t.Run("admin passes through", func(t *testing.T) { 156 + rec := httptest.NewRecorder() 157 + h := RequireAdmin(svc, okHandler) 158 + h.ServeHTTP(rec, authenticatedRequest("did:plc:admin")) 159 + assert.Equal(t, http.StatusOK, rec.Code) 160 + }) 161 + }
+27 -13
internal/routing/routing.go
··· 7 7 "arabica/internal/atproto" 8 8 "arabica/internal/handlers" 9 9 "arabica/internal/middleware" 10 + "arabica/internal/moderation" 10 11 11 12 "github.com/rs/zerolog" 12 13 "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ··· 16 17 17 18 // Config holds the configuration needed for setting up routes 18 19 type Config struct { 19 - Handlers *handlers.Handler 20 - OAuthManager *atproto.OAuthManager 21 - Logger zerolog.Logger 20 + Handlers *handlers.Handler 21 + OAuthManager *atproto.OAuthManager 22 + Logger zerolog.Logger 23 + ModerationService *moderation.Service 22 24 } 23 25 24 26 // SetupRouter creates and configures the HTTP router with all routes and middleware ··· 149 151 mux.HandleFunc("GET /profile/{actor}", h.HandleProfile) 150 152 151 153 // Moderation routes 154 + // HandleAdmin keeps its own auth check (redirects to / instead of 401) 155 + modSvc := cfg.ModerationService 152 156 mux.HandleFunc("GET /_mod", h.HandleAdmin) 153 - mux.Handle("GET /_mod/content", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleAdminPartial))) 154 - mux.Handle("POST /_mod/hide", cop.Handler(http.HandlerFunc(h.HandleHideRecord))) 155 - mux.Handle("POST /_mod/unhide", cop.Handler(http.HandlerFunc(h.HandleUnhideRecord))) 156 - mux.Handle("POST /_mod/dismiss-report", cop.Handler(http.HandlerFunc(h.HandleDismissReport))) 157 - mux.Handle("POST /_mod/reset-autohide", cop.Handler(http.HandlerFunc(h.HandleResetAutoHide))) 158 - mux.Handle("POST /_mod/block", cop.Handler(http.HandlerFunc(h.HandleBlockUser))) 159 - mux.Handle("POST /_mod/unblock", cop.Handler(http.HandlerFunc(h.HandleUnblockUser))) 160 - mux.Handle("POST /_mod/invite", cop.Handler(http.HandlerFunc(h.HandleCreateInvite))) 161 - mux.Handle("POST /_mod/dismiss-join", cop.Handler(http.HandlerFunc(h.HandleDismissJoinRequest))) 162 - mux.Handle("GET /_mod/stats", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleAdminStats))) 157 + mux.Handle("GET /_mod/content", middleware.RequireModerator(modSvc, 158 + middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleAdminPartial)))) 159 + mux.Handle("POST /_mod/hide", cop.Handler( 160 + middleware.RequirePermission(modSvc, moderation.PermissionHideRecord, http.HandlerFunc(h.HandleHideRecord)))) 161 + mux.Handle("POST /_mod/unhide", cop.Handler( 162 + middleware.RequirePermission(modSvc, moderation.PermissionUnhideRecord, http.HandlerFunc(h.HandleUnhideRecord)))) 163 + mux.Handle("POST /_mod/dismiss-report", cop.Handler( 164 + middleware.RequirePermission(modSvc, moderation.PermissionDismissReport, http.HandlerFunc(h.HandleDismissReport)))) 165 + mux.Handle("POST /_mod/reset-autohide", cop.Handler( 166 + middleware.RequirePermission(modSvc, moderation.PermissionResetAutoHide, http.HandlerFunc(h.HandleResetAutoHide)))) 167 + mux.Handle("POST /_mod/block", cop.Handler( 168 + middleware.RequirePermission(modSvc, moderation.PermissionBlacklistUser, http.HandlerFunc(h.HandleBlockUser)))) 169 + mux.Handle("POST /_mod/unblock", cop.Handler( 170 + middleware.RequirePermission(modSvc, moderation.PermissionUnblacklistUser, http.HandlerFunc(h.HandleUnblockUser)))) 171 + mux.Handle("POST /_mod/invite", cop.Handler( 172 + middleware.RequireAdmin(modSvc, http.HandlerFunc(h.HandleCreateInvite)))) 173 + mux.Handle("POST /_mod/dismiss-join", cop.Handler( 174 + middleware.RequireAdmin(modSvc, http.HandlerFunc(h.HandleDismissJoinRequest)))) 175 + mux.Handle("GET /_mod/stats", middleware.RequireAdmin(modSvc, 176 + middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleAdminStats)))) 163 177 164 178 // Static files (must come after specific routes) 165 179 fs := http.FileServer(http.Dir("static"))