diff --git a/appview/ADMIN.md b/appview/ADMIN.md new file mode 100644 index 0000000..448843e --- /dev/null +++ b/appview/ADMIN.md @@ -0,0 +1,235 @@ +# Effem AppView — Admin, Audit, and PII Policy + +This document owns the operational contract for the moderation surface: +who has admin, how admin actions are audited, and how we handle the +personally-identifying free-text users attach to reports. + +Paired code: `appview/handlers/admin.go`, `appview/httpmw/auth.go`, +`migrations/0003_admin_reports_audit.sql`, +`migrations/0004_content_takedown.sql`. + +--- + +## 1. Granting admin + +There are two independent mechanisms. Use either or both. + +### 1a. Admin tokens (service-account-style) + +Set `EFFEM_AUTH_ADMIN_TOKENS=token=subject,other-token=subject2`. Each +token grants `admin` + `read` scopes. Subject should be a DID; `*` is +allowed for machine principals (Prometheus scraper, CI, etc.). + +Rotate quarterly, or immediately if a token leaks: + +```sh +# Railway dashboard → effem-appview → Variables +EFFEM_AUTH_ADMIN_TOKENS= +# Redeploy. The old token stops working on the next boot. +``` + +### 1b. Admin DIDs (human moderators) + +Set `EFFEM_ADMIN_DIDS=did:plc:alice,did:plc:bob`. Any authenticated +principal whose subject DID appears in the list is promoted to `admin` +scope on every request, regardless of which token they present. + +Use this for human moderators so they can use their normal user auth +without a separate admin-token rotation schedule. Token rotation for the +user still happens through whatever issuance mechanism (OAuth, static +read-token map) the user falls under. + +**Policy**: prefer admin DIDs for humans, admin tokens for machines. + +### Listing current admins + +```sh +railway variables get EFFEM_AUTH_ADMIN_TOKENS +railway variables get EFFEM_ADMIN_DIDS +``` + +There is no endpoint that lists admins — by design. Admin membership +lives in config and is reviewed by reading the env vars directly. + +--- + +## 2. Admin endpoints + +All gated by `RequireScope("admin")`. All POSTs are capped at 64 KB. + +| Route | Method | Purpose | +|---|---|---| +| `/xrpc/xyz.effem.admin.listReports` | GET | Paginate reports, filter by `status`. Cursor is the id of the last row on the previous page. | +| `/xrpc/xyz.effem.admin.resolveReport` | POST | Body `{report_id, status, resolution_note}`. Transitions a report to `resolved` or `dismissed`. | +| `/xrpc/xyz.effem.admin.getReportedContent` | GET | Query `?subject_uri=…`. Fetches the reported comment/recommendation/list so the admin can decide without a DB shell. Returns the raw content including the `removed` flag. | +| `/xrpc/xyz.effem.admin.removeContent` | POST | Body `{at_uri, reason}`. Soft-removes a comment or recommendation (sets `removed=true`). The row stays in the DB for audit. | + +### Examples + +```sh +# List pending reports +curl -H "Authorization: Bearer $ADMIN_TOKEN" \ + "https:///xrpc/xyz.effem.admin.listReports?status=pending&limit=50" + +# Resolve a report +curl -X POST \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"report_id": 123, "status": "resolved", "resolution_note": "contacted user, edited"}' \ + "https:///xrpc/xyz.effem.admin.resolveReport" + +# Take down a comment +curl -X POST \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"at_uri": "at://did:plc:abc/xyz.effem.feed.comment/3l5xyz", "reason": "abuse"}' \ + "https:///xrpc/xyz.effem.admin.removeContent" +``` + +--- + +## 3. Audit log + +Every successful admin action writes a row to `admin_audit`: + +| Column | Notes | +|---|---| +| `admin_did` | Subject of the admin principal at action time. | +| `action` | Short stable token. Current values: `resolve_report`, `remove_content`. | +| `target` | AT URI or report id — whatever identifies the thing acted on. | +| `details` | JSONB with action-specific context. **Never includes raw report `reason` text.** | +| `created_at` | Timestamp of the action. | + +Two indexes: `(admin_did, created_at DESC)` for per-admin history, +`(action, created_at DESC)` for "who resolved reports today." + +### Investigations + +```sql +-- Recent actions by one admin +SELECT created_at, action, target, details +FROM admin_audit +WHERE admin_did = 'did:plc:alice' +ORDER BY created_at DESC +LIMIT 100; + +-- Everything that happened on a single at:// URI +SELECT created_at, admin_did, action, details +FROM admin_audit +WHERE target = 'at://did:plc:abc/xyz.effem.feed.comment/3l5xyz' +ORDER BY created_at; +``` + +### Retention + +`admin_audit` is kept indefinitely. It is small (one row per admin action) +and the accountability trail outweighs the storage cost. + +--- + +## 4. PII retention — reports + +`reports.reason` is free-text user input. It may contain personal names, +accusations, URLs to the reporter's evidence, or anything else the user +typed. Treat it as sensitive. + +### 4a. 90-day archival + +Resolved and dismissed reports older than 90 days are moved to +`reports_archive` with `reason` nulled. Subject URI, reporter DID, and +resolution metadata are preserved for audit. + +**Not yet implemented.** Run this as a scheduled job once operational +volume warrants it. A migration plus a Railway cron runs the archive step +nightly; skeleton SQL: + +```sql +-- One-time: create the archive table (skeleton for future migration 00XX). +CREATE TABLE IF NOT EXISTS reports_archive ( + id BIGINT PRIMARY KEY, + did TEXT NOT NULL, + rkey TEXT NOT NULL, + subject_uri TEXT NOT NULL, + reason_type TEXT NOT NULL, + status TEXT NOT NULL, + resolution_note TEXT, + resolved_by TEXT, + resolved_at TIMESTAMPTZ, + created_at TEXT NOT NULL, + archived_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Nightly: move + scrub. +INSERT INTO reports_archive (id, did, rkey, subject_uri, reason_type, status, + resolution_note, resolved_by, resolved_at, created_at) +SELECT id, did, rkey, subject_uri, reason_type, status, + resolution_note, resolved_by, resolved_at, created_at +FROM reports +WHERE status IN ('resolved', 'dismissed') + AND resolved_at < NOW() - INTERVAL '90 days' +ON CONFLICT (id) DO NOTHING; + +DELETE FROM reports +WHERE status IN ('resolved', 'dismissed') + AND resolved_at < NOW() - INTERVAL '90 days'; +``` + +Pending reports never expire until acted on — resolve or dismiss them to +start the archival clock. + +### 4b. Account deletion cascade + +When a user deletes their account, delete: + +1. Reports they filed (`reports WHERE did = `). +2. Reports filed against them (`reports WHERE subject_uri LIKE 'at:///%'`). +3. Their own content: comments, recommendations, bookmarks, blocks, + lists, episode states, subscriptions, profile. + +Today the firehose indexer handles the content side automatically — a +tombstone event via `com.atproto.sync.subscribeRepos` ends up in +`Indexer.DeleteRecord`. **Reports are not currently cascaded.** Add the +cascade when account-deletion handling lands. Until then, file a manual +SQL cleanup as part of any support ticket: + +```sql +DELETE FROM reports WHERE did = :did; +DELETE FROM reports WHERE subject_uri LIKE 'at://' || :did || '/%'; +``` + +### 4c. Log scrubbing + +The `reason` text must never reach the INFO-level application log. The +indexer stores it in the row; the admin handler returns it in responses +to admin callers only. Anywhere else (warnings, metrics, audit details), +log only `report_id` and `reason_type`. + +Static check: grep `reason` in code and confirm every usage is either in +`admin.go`'s response construction or `indexer/report.go`'s upsert. + +### 4d. Stats staleness after takedown + +`RemoveContent` flips `removed=true` but does not immediately re-run +`refreshPodcastStats` / `refreshEpisodeStats`. The materialized counts +stay slightly inflated until the next firehose event on the same feed / +episode triggers a refresh. The stats-refresh queries already exclude +removed rows (`WHERE removed = false`), so the drift self-heals within +minutes on active feeds. + +If this ever becomes visible to users, either: +- wire the stats refresh directly into `RemoveContent`, or +- run a periodic job that refreshes stats for feeds touched in the last N minutes. + +--- + +## 5. Policy summary + +- Admin DIDs for humans, admin tokens for machines. +- Every admin mutation writes to `admin_audit` on success. Failures are + logged; they never block the mutation. +- Audit details never carry raw report `reason` text. +- Reports retain free-text for 90 days after resolution, then scrub. +- Account deletion cascades content today (via firehose tombstones) and + requires a manual SQL cleanup for reports until automated. +- Takedowns are soft. Hard deletes are handled by support tickets with a + paired audit entry, never by a standing endpoint. diff --git a/appview/config.go b/appview/config.go index a0a3f4b..51d576e 100644 --- a/appview/config.go +++ b/appview/config.go @@ -17,10 +17,16 @@ type Config struct { AuthRequired bool AuthReadTokens map[string]string AuthAdminTokens map[string]string - CORSOrigins []string - RateLimitEnabled bool - RateLimitRPS float64 - RateLimitBurst int + // AdminDIDs is the set of DIDs whose authenticated principal gets + // promoted to the admin scope regardless of which token they present. + // Use it to grant admin to human moderators without issuing a dedicated + // admin-tokens mapping; dedicated service tokens still go through + // AuthAdminTokens. + AdminDIDs []string + CORSOrigins []string + RateLimitEnabled bool + RateLimitRPS float64 + RateLimitBurst int } func (c Config) Validate() error { @@ -59,6 +65,11 @@ func (c Config) Validate() error { if c.RateLimitEnabled && c.RateLimitBurst <= 0 { return fmt.Errorf("rate limit burst must be positive when rate limiting is enabled") } + for _, did := range c.AdminDIDs { + if !strings.HasPrefix(did, "did:") { + return fmt.Errorf("admin DID %q must start with \"did:\"", did) + } + } return nil } diff --git a/appview/database/migrations/0003_admin_reports_audit.sql b/appview/database/migrations/0003_admin_reports_audit.sql new file mode 100644 index 0000000..e58ef40 --- /dev/null +++ b/appview/database/migrations/0003_admin_reports_audit.sql @@ -0,0 +1,22 @@ +-- Admin moderation surface: report resolution fields + audit log. +-- Resolution columns are nullable because every existing report row is still +-- pending. A subsequent admin action flips status and stamps the resolver. + +ALTER TABLE reports + ADD COLUMN resolution_note TEXT, + ADD COLUMN resolved_by TEXT, + ADD COLUMN resolved_at TIMESTAMPTZ; + +CREATE TABLE IF NOT EXISTS admin_audit ( + id BIGSERIAL PRIMARY KEY, + admin_did TEXT NOT NULL, + action TEXT NOT NULL, + target TEXT, + details JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Admin-first listing: "what did admin X do recently". +CREATE INDEX idx_admin_audit_admin_did_created ON admin_audit (admin_did, created_at DESC); +-- Action-first listing: "who resolved reports in the last 24h". +CREATE INDEX idx_admin_audit_action_created ON admin_audit (action, created_at DESC); diff --git a/appview/database/migrations/0004_content_takedown.sql b/appview/database/migrations/0004_content_takedown.sql new file mode 100644 index 0000000..d8423e8 --- /dev/null +++ b/appview/database/migrations/0004_content_takedown.sql @@ -0,0 +1,11 @@ +-- Soft takedown surface for comments and recommendations. Lists, bookmarks, +-- and episode states are user-scoped and moderated indirectly via account +-- deletion or block; only public-visible user-authored content gets a +-- takedown flag. +-- +-- NOT NULL with a DEFAULT is metadata-only on Postgres 11+ (no table rewrite). +-- Existing rows inherit removed = FALSE and the column is added almost +-- instantly regardless of table size. + +ALTER TABLE comments ADD COLUMN removed BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE recommendations ADD COLUMN removed BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/appview/database/models.go b/appview/database/models.go index 4e100aa..3879007 100644 --- a/appview/database/models.go +++ b/appview/database/models.go @@ -40,23 +40,30 @@ type Comment struct { ReplyParent string `gorm:"size:1024" json:"reply_parent"` ReplyParentCID string `gorm:"column:reply_parent_cid;size:256" json:"reply_parent_cid"` Facets []byte `gorm:"type:jsonb" json:"facets"` - CreatedAt string `gorm:"size:64;not null" json:"created_at"` - IndexedAt time.Time `gorm:"autoCreateTime" json:"-"` + // Removed is set to true when an admin takes the content down. Reads + // filter it out; the row stays for audit. Indexer struct-updates skip + // this field because its zero value is false, so a firehose re-index of + // the same record cannot resurrect a taken-down comment. + Removed bool `gorm:"not null;default:false" json:"removed"` + CreatedAt string `gorm:"size:64;not null" json:"created_at"` + IndexedAt time.Time `gorm:"autoCreateTime" json:"-"` } func (Comment) TableName() string { return "comments" } type Recommendation struct { - ID uint `gorm:"primaryKey" json:"-"` - DID string `gorm:"column:did;size:255;not null;index:idx_recommendations_did_rkey,unique;index:idx_recommendations_did" json:"did"` - Rkey string `gorm:"size:512;not null;index:idx_recommendations_did_rkey,unique" json:"rkey"` - FeedID int64 `gorm:"not null;index:idx_recommendations_episode" json:"feed_id"` - EpisodeID int64 `gorm:"not null;index:idx_recommendations_episode" json:"episode_id"` - EpisodeGuid string `gorm:"size:512" json:"episode_guid"` - PodcastGuid string `gorm:"size:512" json:"podcast_guid"` - Text string `gorm:"type:text" json:"text"` - CreatedAt string `gorm:"size:64;not null" json:"created_at"` - IndexedAt time.Time `gorm:"autoCreateTime" json:"-"` + ID uint `gorm:"primaryKey" json:"-"` + DID string `gorm:"column:did;size:255;not null;index:idx_recommendations_did_rkey,unique;index:idx_recommendations_did" json:"did"` + Rkey string `gorm:"size:512;not null;index:idx_recommendations_did_rkey,unique" json:"rkey"` + FeedID int64 `gorm:"not null;index:idx_recommendations_episode" json:"feed_id"` + EpisodeID int64 `gorm:"not null;index:idx_recommendations_episode" json:"episode_id"` + EpisodeGuid string `gorm:"size:512" json:"episode_guid"` + PodcastGuid string `gorm:"size:512" json:"podcast_guid"` + Text string `gorm:"type:text" json:"text"` + // See Comment.Removed for semantics. + Removed bool `gorm:"not null;default:false" json:"removed"` + CreatedAt string `gorm:"size:64;not null" json:"created_at"` + IndexedAt time.Time `gorm:"autoCreateTime" json:"-"` } func (Recommendation) TableName() string { return "recommendations" } @@ -164,15 +171,34 @@ type Block struct { func (Block) TableName() string { return "blocks" } type Report struct { - ID uint `gorm:"primaryKey" json:"-"` - DID string `gorm:"column:did;size:255;not null;index:idx_reports_did_rkey,unique;index:idx_reports_did" json:"did"` - Rkey string `gorm:"size:512;not null;index:idx_reports_did_rkey,unique" json:"rkey"` - SubjectURI string `gorm:"size:1024;not null" json:"subject_uri"` - ReasonType string `gorm:"size:64;not null" json:"reason_type"` - Reason string `gorm:"type:text" json:"reason"` - Status string `gorm:"size:64;not null;default:pending;index:idx_reports_status" json:"status"` - CreatedAt string `gorm:"size:64;not null" json:"created_at"` - IndexedAt time.Time `gorm:"autoCreateTime" json:"-"` + ID uint `gorm:"primaryKey" json:"-"` + DID string `gorm:"column:did;size:255;not null;index:idx_reports_did_rkey,unique;index:idx_reports_did" json:"did"` + Rkey string `gorm:"size:512;not null;index:idx_reports_did_rkey,unique" json:"rkey"` + SubjectURI string `gorm:"size:1024;not null" json:"subject_uri"` + ReasonType string `gorm:"size:64;not null" json:"reason_type"` + Reason string `gorm:"type:text" json:"reason"` + Status string `gorm:"size:64;not null;default:pending;index:idx_reports_status" json:"status"` + // Set by ResolveReport when an admin acts on the report. Nil until then. + ResolutionNote string `gorm:"type:text" json:"resolution_note,omitempty"` + ResolvedBy string `gorm:"type:text" json:"resolved_by,omitempty"` + ResolvedAt *time.Time `gorm:"column:resolved_at" json:"resolved_at,omitempty"` + CreatedAt string `gorm:"size:64;not null" json:"created_at"` + IndexedAt time.Time `gorm:"autoCreateTime" json:"-"` } func (Report) TableName() string { return "reports" } + +// AdminAudit records every mutation performed through the admin surface so +// moderation actions are retrievable later. Details is a raw JSONB blob with +// action-specific context (status, reason, etc.) — keep it small enough that +// a full scan of the table stays cheap during investigations. +type AdminAudit struct { + ID uint `gorm:"primaryKey" json:"id"` + AdminDID string `gorm:"column:admin_did;size:255;not null;index:idx_admin_audit_admin_did_created,priority:1" json:"admin_did"` + Action string `gorm:"size:128;not null;index:idx_admin_audit_action_created,priority:1" json:"action"` + Target string `gorm:"size:1024" json:"target,omitempty"` + Details []byte `gorm:"type:jsonb" json:"details,omitempty"` + CreatedAt time.Time `gorm:"autoCreateTime;index:idx_admin_audit_admin_did_created,priority:2,sort:desc;index:idx_admin_audit_action_created,priority:2,sort:desc" json:"created_at"` +} + +func (AdminAudit) TableName() string { return "admin_audit" } diff --git a/appview/handlers/admin.go b/appview/handlers/admin.go new file mode 100644 index 0000000..360cc35 --- /dev/null +++ b/appview/handlers/admin.go @@ -0,0 +1,299 @@ +package handlers + +import ( + "net/http" + "strconv" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/labstack/echo/v4" + "tangled.org/sparrowtek.com/effem-AppView/appview/database" +) + +// Admin-moderatable collections. GetReportedContent supports reading any of +// these; RemoveContent only supports content types where takedown is +// meaningful (comments and recommendations today). +const ( + collectionComment = "xyz.effem.feed.comment" + collectionRecommendation = "xyz.effem.feed.recommendation" + collectionPodcastList = "xyz.effem.feed.list" +) + +// Admin audit action names. Shared between handlers so dashboards and +// queries against admin_audit do not drift. +const ( + auditActionResolveReport = "resolve_report" + auditActionRemoveContent = "remove_content" +) + +// reportItem is the wire shape of a report row. Reason is included for +// admins; it contains user-supplied free-text and must not leak to +// non-admin callers. +type reportItem struct { + ID uint `json:"id"` + DID string `json:"did"` + Rkey string `json:"rkey"` + SubjectURI string `json:"subject_uri"` + ReasonType string `json:"reason_type"` + Reason string `json:"reason,omitempty"` + Status string `json:"status"` + ResolutionNote string `json:"resolution_note,omitempty"` + ResolvedBy string `json:"resolved_by,omitempty"` + ResolvedAt *time.Time `json:"resolved_at,omitempty"` + CreatedAt string `json:"created_at"` +} + +func toReportItem(row database.Report) reportItem { + return reportItem{ + ID: row.ID, + DID: row.DID, + Rkey: row.Rkey, + SubjectURI: row.SubjectURI, + ReasonType: row.ReasonType, + Reason: row.Reason, + Status: row.Status, + ResolutionNote: row.ResolutionNote, + ResolvedBy: row.ResolvedBy, + ResolvedAt: row.ResolvedAt, + CreatedAt: row.CreatedAt, + } +} + +// ListReports returns a page of reports ordered newest-first. Filter with +// ?status=pending (or resolved/dismissed). Cursor is the id of the oldest +// report on the previous page. +func (h *Handlers) ListReports(c echo.Context) error { + status := c.QueryParam("status") + switch status { + case "", "pending", "resolved", "dismissed": + // allowed + default: + return writeError(c, http.StatusBadRequest, "InvalidRequest", + "status must be pending, resolved, or dismissed") + } + + limit := parseLimit(c.QueryParam("limit"), 50, 200) + cursorRaw := c.QueryParam("cursor") + + q := h.db.WithContext(c.Request().Context()). + Model(&database.Report{}). + Order("id DESC"). + Limit(limit + 1) + if status != "" { + q = q.Where("status = ?", status) + } + if cursorRaw != "" { + cur, err := strconv.ParseUint(cursorRaw, 10, 64) + if err != nil { + return writeError(c, http.StatusBadRequest, "InvalidRequest", "cursor must be a positive integer") + } + q = q.Where("id < ?", cur) + } + + var rows []database.Report + if err := q.Find(&rows).Error; err != nil { + return h.internalError(c, "ListReports.find", err) + } + + nextCursor := "" + if len(rows) > limit { + nextCursor = strconv.FormatUint(uint64(rows[limit-1].ID), 10) + rows = rows[:limit] + } + + items := make([]reportItem, 0, len(rows)) + for _, r := range rows { + items = append(items, toReportItem(r)) + } + + return c.JSON(http.StatusOK, map[string]any{ + "reports": items, + "cursor": nextCursor, + }) +} + +type resolveReportRequest struct { + ReportID uint `json:"report_id"` + Status string `json:"status"` + ResolutionNote string `json:"resolution_note,omitempty"` +} + +// ResolveReport marks a report as resolved or dismissed. Only pending +// reports can be transitioned; re-resolving is a no-op that still writes a +// fresh audit row so reversals are visible. +func (h *Handlers) ResolveReport(c echo.Context) error { + var req resolveReportRequest + if err := c.Bind(&req); err != nil { + return writeError(c, http.StatusBadRequest, "InvalidRequest", "invalid JSON body") + } + if req.ReportID == 0 { + return writeError(c, http.StatusBadRequest, "InvalidRequest", "report_id is required") + } + if req.Status != "resolved" && req.Status != "dismissed" { + return writeError(c, http.StatusBadRequest, "InvalidRequest", "status must be resolved or dismissed") + } + + adminDID := requestingDID(c) + ctx := c.Request().Context() + now := time.Now().UTC() + + // Use a map so GORM writes all three columns unconditionally — a struct + // Updates with an empty resolution_note would skip it as a zero value. + res := h.db.WithContext(ctx). + Model(&database.Report{}). + Where("id = ?", req.ReportID). + Updates(map[string]any{ + "status": req.Status, + "resolution_note": req.ResolutionNote, + "resolved_by": adminDID, + "resolved_at": now, + }) + if res.Error != nil { + return h.internalError(c, "ResolveReport.update", res.Error) + } + if res.RowsAffected == 0 { + return writeError(c, http.StatusNotFound, "NotFound", "report not found") + } + + h.writeAudit(ctx, adminDID, auditActionResolveReport, + strconv.FormatUint(uint64(req.ReportID), 10), + map[string]any{ + "status": req.Status, + // Note is audited here for accountability. Reason text from the + // original report is never copied into admin_audit — that keeps + // user-supplied free-text out of a table we want to reason + // broadly about. + "note": req.ResolutionNote, + }, + ) + + return c.JSON(http.StatusOK, map[string]any{"ok": true}) +} + +// GetReportedContent fetches the underlying record for a report's +// subject_uri so the admin can decide without switching to a DB client. +// Returns the raw row (including removed flag) so an admin sees takedowns. +func (h *Handlers) GetReportedContent(c echo.Context) error { + uriRaw := c.QueryParam("subject_uri") + if uriRaw == "" { + return writeError(c, http.StatusBadRequest, "InvalidRequest", "subject_uri is required") + } + uri, err := syntax.ParseATURI(uriRaw) + if err != nil { + return writeError(c, http.StatusBadRequest, "InvalidRequest", "invalid AT URI") + } + + did := uri.Authority().String() + rkey := uri.RecordKey().String() + collection := uri.Collection().String() + if did == "" || rkey == "" { + return writeError(c, http.StatusBadRequest, "InvalidRequest", "subject_uri must reference a specific record") + } + + ctx := c.Request().Context() + db := h.db.WithContext(ctx) + + switch collection { + case collectionComment: + var row database.Comment + if err := db.Where("did = ? AND rkey = ?", did, rkey).First(&row).Error; err != nil { + return writeError(c, http.StatusNotFound, "NotFound", "comment not found") + } + return c.JSON(http.StatusOK, map[string]any{ + "kind": "comment", + "removed": row.Removed, + "content": toCommentResponse(row), + }) + case collectionRecommendation: + var row database.Recommendation + if err := db.Where("did = ? AND rkey = ?", did, rkey).First(&row).Error; err != nil { + return writeError(c, http.StatusNotFound, "NotFound", "recommendation not found") + } + return c.JSON(http.StatusOK, map[string]any{ + "kind": "recommendation", + "removed": row.Removed, + "content": row, + }) + case collectionPodcastList: + var row database.PodcastList + if err := db.Where("did = ? AND rkey = ?", did, rkey).First(&row).Error; err != nil { + return writeError(c, http.StatusNotFound, "NotFound", "list not found") + } + return c.JSON(http.StatusOK, map[string]any{ + "kind": "list", + "content": toListResponse(row), + }) + default: + return writeError(c, http.StatusBadRequest, "InvalidRequest", "unsupported collection") + } +} + +type removeContentRequest struct { + ATURI string `json:"at_uri"` + Reason string `json:"reason,omitempty"` +} + +// RemoveContent soft-deletes a comment or recommendation by setting +// removed=true. The row stays in the database for audit; reads filter it +// out. Podcast lists and other content types are not takeable-down via this +// endpoint — tombstone them at the source instead. +func (h *Handlers) RemoveContent(c echo.Context) error { + var req removeContentRequest + if err := c.Bind(&req); err != nil { + return writeError(c, http.StatusBadRequest, "InvalidRequest", "invalid JSON body") + } + if req.ATURI == "" { + return writeError(c, http.StatusBadRequest, "InvalidRequest", "at_uri is required") + } + uri, err := syntax.ParseATURI(req.ATURI) + if err != nil { + return writeError(c, http.StatusBadRequest, "InvalidRequest", "invalid AT URI") + } + + did := uri.Authority().String() + rkey := uri.RecordKey().String() + collection := uri.Collection().String() + if did == "" || rkey == "" { + return writeError(c, http.StatusBadRequest, "InvalidRequest", "at_uri must reference a specific record") + } + + adminDID := requestingDID(c) + ctx := c.Request().Context() + db := h.db.WithContext(ctx) + + var rowsAffected int64 + switch collection { + case collectionComment: + res := db.Model(&database.Comment{}). + Where("did = ? AND rkey = ?", did, rkey). + Update("removed", true) + if res.Error != nil { + return h.internalError(c, "RemoveContent.comment", res.Error) + } + rowsAffected = res.RowsAffected + case collectionRecommendation: + res := db.Model(&database.Recommendation{}). + Where("did = ? AND rkey = ?", did, rkey). + Update("removed", true) + if res.Error != nil { + return h.internalError(c, "RemoveContent.recommendation", res.Error) + } + rowsAffected = res.RowsAffected + default: + return writeError(c, http.StatusBadRequest, "InvalidRequest", + "takedown is only supported for comment and recommendation collections") + } + + if rowsAffected == 0 { + return writeError(c, http.StatusNotFound, "NotFound", "record not found") + } + + h.writeAudit(ctx, adminDID, auditActionRemoveContent, req.ATURI, + map[string]any{ + "reason": req.Reason, + "collection": collection, + }, + ) + + return c.JSON(http.StatusOK, map[string]any{"ok": true}) +} diff --git a/appview/handlers/comment.go b/appview/handlers/comment.go index 39119e5..227f98d 100644 --- a/appview/handlers/comment.go +++ b/appview/handlers/comment.go @@ -69,6 +69,7 @@ func (h *Handlers) GetComments(c echo.Context) error { query := h.db.WithContext(c.Request().Context()). Where("feed_id = ? AND episode_id = ?", feedID, episodeID). + Where("removed = ?", false). Order("rkey DESC"). Limit(limit + 1) if cursor != "" { @@ -108,7 +109,7 @@ func (h *Handlers) GetCommentThread(c echo.Context) error { reqDID := requestingDID(c) ctx := c.Request().Context() - rootQuery := h.db.WithContext(ctx).Where("at_uri = ?", uri) + rootQuery := h.db.WithContext(ctx).Where("at_uri = ?", uri).Where("removed = ?", false) rootQuery = h.excludeBlockedDIDs(rootQuery, reqDID, "did") var root database.Comment @@ -125,6 +126,7 @@ func (h *Handlers) GetCommentThread(c echo.Context) error { // create when two replies share a timestamp. repliesQuery := h.db.WithContext(ctx). Where("reply_root = ?", root.ATURI). + Where("removed = ?", false). Order("rkey ASC"). Limit(limit + 1) if cursor != "" { @@ -150,6 +152,7 @@ func (h *Handlers) GetCommentThread(c echo.Context) error { if err := h.db.WithContext(ctx). Model(&database.Comment{}). Where("reply_root = ?", root.ATURI). + Where("removed = ?", false). Count(&replyCount).Error; err != nil { return h.internalError(c, "GetCommentThread.count", err) } diff --git a/appview/handlers/handlers.go b/appview/handlers/handlers.go index 578e99a..a45cf7f 100644 --- a/appview/handlers/handlers.go +++ b/appview/handlers/handlers.go @@ -1,15 +1,17 @@ package handlers import ( + "context" "encoding/json" "log/slog" "net/http" "strconv" - "tangled.org/sparrowtek.com/effem-AppView/appview/httpmw" - "tangled.org/sparrowtek.com/effem-AppView/appview/podcastindex" "github.com/labstack/echo/v4" "gorm.io/gorm" + "tangled.org/sparrowtek.com/effem-AppView/appview/database" + "tangled.org/sparrowtek.com/effem-AppView/appview/httpmw" + "tangled.org/sparrowtek.com/effem-AppView/appview/podcastindex" ) type Handlers struct { @@ -115,3 +117,34 @@ func (h *Handlers) excludeBlockedDIDs(q *gorm.DB, requesterDID, didColumn string q = q.Where(didColumn+" NOT IN (SELECT did FROM blocks WHERE subject_did = ?)", requesterDID) return q } + +// writeAudit records an admin action in the admin_audit table. Details is +// marshaled to JSON; a nil details argument stores SQL NULL. Audit write +// failures are logged but do not fail the surrounding admin call — the +// moderation action itself has already succeeded and we don't want a stuck +// audit path to block takedowns during an incident. +func (h *Handlers) writeAudit(ctx context.Context, adminDID, action, target string, details any) { + var raw []byte + if details != nil { + b, err := json.Marshal(details) + if err != nil { + h.logger.Warn("audit details marshal failed", "action", action, "err", err) + } else { + raw = b + } + } + row := database.AdminAudit{ + AdminDID: adminDID, + Action: action, + Target: target, + Details: raw, + } + if err := h.db.WithContext(ctx).Create(&row).Error; err != nil { + h.logger.Warn("audit write failed", + "action", action, + "target", target, + "admin_did", adminDID, + "err", err, + ) + } +} diff --git a/appview/handlers/recommendation.go b/appview/handlers/recommendation.go index 6058824..a7da0be 100644 --- a/appview/handlers/recommendation.go +++ b/appview/handlers/recommendation.go @@ -14,7 +14,11 @@ func (h *Handlers) GetRecommendations(c echo.Context) error { limit := parseLimit(c.QueryParam("limit"), 50, 100) cursor := c.QueryParam("cursor") - q := h.db.WithContext(c.Request().Context()).Model(&database.Recommendation{}).Order("rkey DESC").Limit(limit + 1) + q := h.db.WithContext(c.Request().Context()). + Model(&database.Recommendation{}). + Where("removed = ?", false). + Order("rkey DESC"). + Limit(limit + 1) if feedID > 0 { q = q.Where("feed_id = ?", feedID) } @@ -82,7 +86,9 @@ func (h *Handlers) GetPopular(c echo.Context) error { limit := parseLimit(c.QueryParam("limit"), 20, 100) period := c.QueryParam("period") - q := h.db.WithContext(c.Request().Context()).Model(&database.Recommendation{}) + q := h.db.WithContext(c.Request().Context()). + Model(&database.Recommendation{}). + Where("removed = ?", false) if period != "" { now := time.Now().UTC() var cutoff time.Time diff --git a/appview/httpmw/auth.go b/appview/httpmw/auth.go index 4df3c6e..c1bd1a0 100644 --- a/appview/httpmw/auth.go +++ b/appview/httpmw/auth.go @@ -46,8 +46,12 @@ func newPrincipal(subject string, scopes ...string) Principal { type TokenAuthorizer struct { readTokens map[string]string adminTokens map[string]string + adminDIDs map[string]struct{} } +// NewTokenAuthorizer builds an authenticator with the given read-token and +// admin-token maps. Call WithAdminDIDs to promote specific principal DIDs to +// the admin scope regardless of which token they present. func NewTokenAuthorizer(readTokens, adminTokens map[string]string) *TokenAuthorizer { readCopy := make(map[string]string, len(readTokens)) for token, subject := range readTokens { @@ -57,7 +61,27 @@ func NewTokenAuthorizer(readTokens, adminTokens map[string]string) *TokenAuthori for token, subject := range adminTokens { adminCopy[token] = subject } - return &TokenAuthorizer{readTokens: readCopy, adminTokens: adminCopy} + return &TokenAuthorizer{ + readTokens: readCopy, + adminTokens: adminCopy, + adminDIDs: map[string]struct{}{}, + } +} + +// WithAdminDIDs layers a DID-based admin grant on top of the token mapping. +// Any authenticated principal whose subject appears in dids is upgraded to +// the admin scope. Subsequent calls overwrite the previous set; pass nil or +// an empty slice to clear it. +func (a *TokenAuthorizer) WithAdminDIDs(dids []string) *TokenAuthorizer { + set := make(map[string]struct{}, len(dids)) + for _, d := range dids { + if d == "" { + continue + } + set[d] = struct{}{} + } + a.adminDIDs = set + return a } func (a *TokenAuthorizer) Authenticate(token string) (Principal, bool) { @@ -68,6 +92,12 @@ func (a *TokenAuthorizer) Authenticate(token string) (Principal, bool) { return newPrincipal(subject, scopeAdmin, scopeRead), true } if subject, ok := a.readTokens[token]; ok { + // Promote to admin if the authenticated subject is in the admin DID + // allowlist. Keep the read scope so admin-free endpoints still see + // a principal with read access. + if _, isAdmin := a.adminDIDs[subject]; isAdmin { + return newPrincipal(subject, scopeAdmin, scopeRead), true + } return newPrincipal(subject, scopeRead), true } return Principal{}, false diff --git a/appview/httpmw/auth_test.go b/appview/httpmw/auth_test.go index 7a7ac4a..7e3893b 100644 --- a/appview/httpmw/auth_test.go +++ b/appview/httpmw/auth_test.go @@ -50,6 +50,63 @@ func TestAuthenticationAndScope(t *testing.T) { }) } +func TestAdminDIDPromotionUpgradesReadTokenToAdmin(t *testing.T) { + e := echo.New() + authorizer := NewTokenAuthorizer( + map[string]string{ + "alice-token": "did:plc:alice", + "bob-token": "did:plc:bob", + }, + nil, + ).WithAdminDIDs([]string{"did:plc:alice"}) + + e.Use(Authentication(authorizer, true)) + e.GET("/xrpc/admin-only", func(c echo.Context) error { + return c.NoContent(http.StatusOK) + }, RequireScope("admin")) + + t.Run("promoted alice passes admin scope", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/xrpc/admin-only", nil) + req.Header.Set(echo.HeaderAuthorization, "Bearer alice-token") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 for promoted admin, got %d", rec.Code) + } + }) + + t.Run("unpromoted bob rejected at admin scope", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/xrpc/admin-only", nil) + req.Header.Set(echo.HeaderAuthorization, "Bearer bob-token") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403 for non-admin, got %d", rec.Code) + } + }) +} + +func TestAdminDIDPromotionDoesNotDropReadScope(t *testing.T) { + e := echo.New() + authorizer := NewTokenAuthorizer( + map[string]string{"alice-token": "did:plc:alice"}, + nil, + ).WithAdminDIDs([]string{"did:plc:alice"}) + + e.Use(Authentication(authorizer, true)) + e.GET("/xrpc/read-only", func(c echo.Context) error { + return c.NoContent(http.StatusOK) + }, RequireScope("read")) + + req := httptest.NewRequest(http.MethodGet, "/xrpc/read-only", nil) + req.Header.Set(echo.HeaderAuthorization, "Bearer alice-token") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("admin-promoted principal must keep read scope, got %d", rec.Code) + } +} + func TestRequireQueryDID(t *testing.T) { e := echo.New() authorizer := NewTokenAuthorizer( diff --git a/appview/indexer/stats.go b/appview/indexer/stats.go index ea49a14..4c28db2 100644 --- a/appview/indexer/stats.go +++ b/appview/indexer/stats.go @@ -21,12 +21,18 @@ func (idx *Indexer) refreshPodcastStats(ctx context.Context, feedID int64) error } var commentCount int64 - if err := db.Model(&database.Comment{}).Where("feed_id = ?", feedID).Count(&commentCount).Error; err != nil { + if err := db.Model(&database.Comment{}). + Where("feed_id = ?", feedID). + Where("removed = ?", false). + Count(&commentCount).Error; err != nil { return err } var recommendationCount int64 - if err := db.Model(&database.Recommendation{}).Where("feed_id = ?", feedID).Count(&recommendationCount).Error; err != nil { + if err := db.Model(&database.Recommendation{}). + Where("feed_id = ?", feedID). + Where("removed = ?", false). + Count(&recommendationCount).Error; err != nil { return err } @@ -52,12 +58,18 @@ func (idx *Indexer) refreshEpisodeStats(ctx context.Context, feedID, episodeID i db := idx.db.WithContext(ctx) var commentCount int64 - if err := db.Model(&database.Comment{}).Where("feed_id = ? AND episode_id = ?", feedID, episodeID).Count(&commentCount).Error; err != nil { + if err := db.Model(&database.Comment{}). + Where("feed_id = ? AND episode_id = ?", feedID, episodeID). + Where("removed = ?", false). + Count(&commentCount).Error; err != nil { return err } var recommendationCount int64 - if err := db.Model(&database.Recommendation{}).Where("feed_id = ? AND episode_id = ?", feedID, episodeID).Count(&recommendationCount).Error; err != nil { + if err := db.Model(&database.Recommendation{}). + Where("feed_id = ? AND episode_id = ?", feedID, episodeID). + Where("removed = ?", false). + Count(&recommendationCount).Error; err != nil { return err } diff --git a/appview/server.go b/appview/server.go index 174578b..ac7ad64 100644 --- a/appview/server.go +++ b/appview/server.go @@ -65,7 +65,8 @@ func NewServer(cfg Config) (*Server, error) { piClient := podcastindex.NewClient(cfg.PIKey, cfg.PISecret) cachedPI := podcastindex.NewCachedClient(piClient, db) - authz := httpmw.NewTokenAuthorizer(cfg.AuthReadTokens, cfg.AuthAdminTokens) + authz := httpmw.NewTokenAuthorizer(cfg.AuthReadTokens, cfg.AuthAdminTokens). + WithAdminDIDs(cfg.AdminDIDs) rateLimiter, err := httpmw.NewPrincipalRateLimiter(httpmw.RateLimiterConfig{ Enabled: cfg.RateLimitEnabled, RPS: cfg.RateLimitRPS, @@ -91,7 +92,7 @@ func NewServer(cfg Config) (*Server, error) { e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ AllowOrigins: cfg.CORSOrigins, - AllowMethods: []string{http.MethodGet, http.MethodOptions}, + AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodOptions}, AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization}, })) e.Use(middleware.Recover()) @@ -182,6 +183,19 @@ func (srv *Server) registerRoutes() { xrpc.GET("/xyz.effem.podcast.getCategories", h.GetCategories) xrpc.GET("/xyz.effem.podcast.getRecentEpisodes", h.GetRecentEpisodes) xrpc.GET("/xyz.effem.podcast.getStats", h.GetStats) + + // Admin moderation surface. Nested group so RequireScope("admin") and + // the tight BodyLimit apply to every admin route. Echo stacks middleware + // from parent groups, so these routes are also gated by RequireScope("read") + // — redundant but harmless (admin implies read). + admin := xrpc.Group("", + httpmw.RequireScope("admin"), + middleware.BodyLimit("64K"), + ) + admin.GET("/xyz.effem.admin.listReports", h.ListReports) + admin.POST("/xyz.effem.admin.resolveReport", h.ResolveReport) + admin.GET("/xyz.effem.admin.getReportedContent", h.GetReportedContent) + admin.POST("/xyz.effem.admin.removeContent", h.RemoveContent) } func (srv *Server) RunAPI(ctx context.Context) error { diff --git a/cmd/effem-appview/main.go b/cmd/effem-appview/main.go index 45f6ea0..75db3f7 100644 --- a/cmd/effem-appview/main.go +++ b/cmd/effem-appview/main.go @@ -79,6 +79,11 @@ func main() { EnvVars: []string{"EFFEM_AUTH_ADMIN_TOKENS"}, Usage: "Comma-separated token=did pairs with admin scope", }, + &cli.StringFlag{ + Name: "admin-dids", + EnvVars: []string{"EFFEM_ADMIN_DIDS"}, + Usage: "Comma-separated DIDs that receive admin scope on authentication", + }, &cli.StringFlag{ Name: "cors-allowed-origins", Value: "http://localhost:3000", @@ -138,6 +143,7 @@ func run(cctx *cli.Context) error { AuthRequired: cctx.Bool("auth-required"), AuthReadTokens: readTokens, AuthAdminTokens: adminTokens, + AdminDIDs: appview.ParseCommaList(cctx.String("admin-dids")), CORSOrigins: appview.ParseCommaList(cctx.String("cors-allowed-origins")), RateLimitEnabled: cctx.Bool("rate-limit-enabled"), RateLimitRPS: cctx.Float64("rate-limit-rps"),