Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 672 lines 19 kB view raw
1package api 2 3import ( 4 "encoding/json" 5 "net/http" 6 "strconv" 7 8 "margin.at/internal/config" 9 "margin.at/internal/db" 10 "margin.at/internal/logger" 11) 12 13type ModerationHandler struct { 14 db *db.DB 15 refresher *TokenRefresher 16} 17 18func NewModerationHandler(database *db.DB, refresher *TokenRefresher) *ModerationHandler { 19 return &ModerationHandler{db: database, refresher: refresher} 20} 21 22func (m *ModerationHandler) BlockUser(w http.ResponseWriter, r *http.Request) { 23 session, err := m.refresher.GetSessionWithAutoRefresh(r) 24 if err != nil { 25 http.Error(w, "Unauthorized", http.StatusUnauthorized) 26 return 27 } 28 29 var req struct { 30 DID string `json:"did"` 31 } 32 if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.DID == "" { 33 http.Error(w, "did is required", http.StatusBadRequest) 34 return 35 } 36 37 if req.DID == session.DID { 38 http.Error(w, "Cannot block yourself", http.StatusBadRequest) 39 return 40 } 41 42 if err := m.db.CreateBlock(session.DID, req.DID); err != nil { 43 logger.Error("Failed to create block: %v", err) 44 http.Error(w, "Failed to block user", http.StatusInternalServerError) 45 return 46 } 47 48 w.Header().Set("Content-Type", "application/json") 49 json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) 50} 51 52func (m *ModerationHandler) UnblockUser(w http.ResponseWriter, r *http.Request) { 53 session, err := m.refresher.GetSessionWithAutoRefresh(r) 54 if err != nil { 55 http.Error(w, "Unauthorized", http.StatusUnauthorized) 56 return 57 } 58 59 did := r.URL.Query().Get("did") 60 if did == "" { 61 http.Error(w, "did query parameter required", http.StatusBadRequest) 62 return 63 } 64 65 if err := m.db.DeleteBlock(session.DID, did); err != nil { 66 logger.Error("Failed to delete block: %v", err) 67 http.Error(w, "Failed to unblock user", http.StatusInternalServerError) 68 return 69 } 70 71 w.Header().Set("Content-Type", "application/json") 72 json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) 73} 74 75func (m *ModerationHandler) GetBlocks(w http.ResponseWriter, r *http.Request) { 76 session, err := m.refresher.GetSessionWithAutoRefresh(r) 77 if err != nil { 78 http.Error(w, "Unauthorized", http.StatusUnauthorized) 79 return 80 } 81 82 blocks, err := m.db.GetBlocks(session.DID) 83 if err != nil { 84 http.Error(w, "Failed to fetch blocks", http.StatusInternalServerError) 85 return 86 } 87 88 dids := make([]string, len(blocks)) 89 for i, b := range blocks { 90 dids[i] = b.SubjectDID 91 } 92 profiles := fetchProfilesForDIDs(m.db, dids) 93 94 type BlockedUser struct { 95 DID string `json:"did"` 96 Author Author `json:"author"` 97 CreatedAt string `json:"createdAt"` 98 } 99 100 items := make([]BlockedUser, len(blocks)) 101 for i, b := range blocks { 102 items[i] = BlockedUser{ 103 DID: b.SubjectDID, 104 Author: profiles[b.SubjectDID], 105 CreatedAt: b.CreatedAt.Format("2006-01-02T15:04:05Z"), 106 } 107 } 108 109 w.Header().Set("Content-Type", "application/json") 110 json.NewEncoder(w).Encode(map[string]interface{}{"items": items}) 111} 112 113func (m *ModerationHandler) MuteUser(w http.ResponseWriter, r *http.Request) { 114 session, err := m.refresher.GetSessionWithAutoRefresh(r) 115 if err != nil { 116 http.Error(w, "Unauthorized", http.StatusUnauthorized) 117 return 118 } 119 120 var req struct { 121 DID string `json:"did"` 122 } 123 if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.DID == "" { 124 http.Error(w, "did is required", http.StatusBadRequest) 125 return 126 } 127 128 if req.DID == session.DID { 129 http.Error(w, "Cannot mute yourself", http.StatusBadRequest) 130 return 131 } 132 133 if err := m.db.CreateMute(session.DID, req.DID); err != nil { 134 logger.Error("Failed to create mute: %v", err) 135 http.Error(w, "Failed to mute user", http.StatusInternalServerError) 136 return 137 } 138 139 w.Header().Set("Content-Type", "application/json") 140 json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) 141} 142 143func (m *ModerationHandler) UnmuteUser(w http.ResponseWriter, r *http.Request) { 144 session, err := m.refresher.GetSessionWithAutoRefresh(r) 145 if err != nil { 146 http.Error(w, "Unauthorized", http.StatusUnauthorized) 147 return 148 } 149 150 did := r.URL.Query().Get("did") 151 if did == "" { 152 http.Error(w, "did query parameter required", http.StatusBadRequest) 153 return 154 } 155 156 if err := m.db.DeleteMute(session.DID, did); err != nil { 157 logger.Error("Failed to delete mute: %v", err) 158 http.Error(w, "Failed to unmute user", http.StatusInternalServerError) 159 return 160 } 161 162 w.Header().Set("Content-Type", "application/json") 163 json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) 164} 165 166func (m *ModerationHandler) GetMutes(w http.ResponseWriter, r *http.Request) { 167 session, err := m.refresher.GetSessionWithAutoRefresh(r) 168 if err != nil { 169 http.Error(w, "Unauthorized", http.StatusUnauthorized) 170 return 171 } 172 173 mutes, err := m.db.GetMutes(session.DID) 174 if err != nil { 175 http.Error(w, "Failed to fetch mutes", http.StatusInternalServerError) 176 return 177 } 178 179 dids := make([]string, len(mutes)) 180 for i, mu := range mutes { 181 dids[i] = mu.SubjectDID 182 } 183 profiles := fetchProfilesForDIDs(m.db, dids) 184 185 type MutedUser struct { 186 DID string `json:"did"` 187 Author Author `json:"author"` 188 CreatedAt string `json:"createdAt"` 189 } 190 191 items := make([]MutedUser, len(mutes)) 192 for i, mu := range mutes { 193 items[i] = MutedUser{ 194 DID: mu.SubjectDID, 195 Author: profiles[mu.SubjectDID], 196 CreatedAt: mu.CreatedAt.Format("2006-01-02T15:04:05Z"), 197 } 198 } 199 200 w.Header().Set("Content-Type", "application/json") 201 json.NewEncoder(w).Encode(map[string]interface{}{"items": items}) 202} 203 204func (m *ModerationHandler) GetRelationship(w http.ResponseWriter, r *http.Request) { 205 viewerDID := m.getViewerDID(r) 206 subjectDID := r.URL.Query().Get("did") 207 208 if subjectDID == "" { 209 http.Error(w, "did query parameter required", http.StatusBadRequest) 210 return 211 } 212 213 blocked, muted, blockedBy, err := m.db.GetViewerRelationship(viewerDID, subjectDID) 214 if err != nil { 215 http.Error(w, "Failed to get relationship", http.StatusInternalServerError) 216 return 217 } 218 219 w.Header().Set("Content-Type", "application/json") 220 json.NewEncoder(w).Encode(map[string]interface{}{ 221 "blocking": blocked, 222 "muting": muted, 223 "blockedBy": blockedBy, 224 }) 225} 226 227func (m *ModerationHandler) CreateReport(w http.ResponseWriter, r *http.Request) { 228 session, err := m.refresher.GetSessionWithAutoRefresh(r) 229 if err != nil { 230 http.Error(w, "Unauthorized", http.StatusUnauthorized) 231 return 232 } 233 234 var req struct { 235 SubjectDID string `json:"subjectDid"` 236 SubjectURI *string `json:"subjectUri,omitempty"` 237 ReasonType string `json:"reasonType"` 238 ReasonText *string `json:"reasonText,omitempty"` 239 } 240 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 241 http.Error(w, "Invalid request body", http.StatusBadRequest) 242 return 243 } 244 245 if req.SubjectDID == "" || req.ReasonType == "" { 246 http.Error(w, "subjectDid and reasonType are required", http.StatusBadRequest) 247 return 248 } 249 250 validReasons := map[string]bool{ 251 "spam": true, 252 "violation": true, 253 "misleading": true, 254 "sexual": true, 255 "rude": true, 256 "other": true, 257 } 258 259 if !validReasons[req.ReasonType] { 260 http.Error(w, "Invalid reasonType", http.StatusBadRequest) 261 return 262 } 263 264 id, err := m.db.CreateReport(session.DID, req.SubjectDID, req.SubjectURI, req.ReasonType, req.ReasonText) 265 if err != nil { 266 logger.Error("Failed to create report: %v", err) 267 http.Error(w, "Failed to submit report", http.StatusInternalServerError) 268 return 269 } 270 271 w.Header().Set("Content-Type", "application/json") 272 json.NewEncoder(w).Encode(map[string]interface{}{"id": id, "status": "ok"}) 273} 274 275func (m *ModerationHandler) AdminGetReports(w http.ResponseWriter, r *http.Request) { 276 session, err := m.refresher.GetSessionWithAutoRefresh(r) 277 if err != nil { 278 http.Error(w, "Unauthorized", http.StatusUnauthorized) 279 return 280 } 281 282 if !config.Get().IsAdmin(session.DID) { 283 http.Error(w, "Forbidden", http.StatusForbidden) 284 return 285 } 286 287 status := r.URL.Query().Get("status") 288 limit := parseIntParam(r, "limit", 50) 289 offset := parseIntParam(r, "offset", 0) 290 291 reports, err := m.db.GetReports(status, limit, offset) 292 if err != nil { 293 http.Error(w, "Failed to fetch reports", http.StatusInternalServerError) 294 return 295 } 296 297 uniqueDIDs := make(map[string]bool) 298 for _, rpt := range reports { 299 uniqueDIDs[rpt.ReporterDID] = true 300 uniqueDIDs[rpt.SubjectDID] = true 301 } 302 dids := make([]string, 0, len(uniqueDIDs)) 303 for did := range uniqueDIDs { 304 dids = append(dids, did) 305 } 306 profiles := fetchProfilesForDIDs(m.db, dids) 307 308 type HydratedReport struct { 309 ID int `json:"id"` 310 Reporter Author `json:"reporter"` 311 Subject Author `json:"subject"` 312 SubjectURI *string `json:"subjectUri,omitempty"` 313 ReasonType string `json:"reasonType"` 314 ReasonText *string `json:"reasonText,omitempty"` 315 Status string `json:"status"` 316 CreatedAt string `json:"createdAt"` 317 ResolvedAt *string `json:"resolvedAt,omitempty"` 318 ResolvedBy *string `json:"resolvedBy,omitempty"` 319 } 320 321 items := make([]HydratedReport, len(reports)) 322 for i, rpt := range reports { 323 items[i] = HydratedReport{ 324 ID: rpt.ID, 325 Reporter: profiles[rpt.ReporterDID], 326 Subject: profiles[rpt.SubjectDID], 327 SubjectURI: rpt.SubjectURI, 328 ReasonType: rpt.ReasonType, 329 ReasonText: rpt.ReasonText, 330 Status: rpt.Status, 331 CreatedAt: rpt.CreatedAt.Format("2006-01-02T15:04:05Z"), 332 } 333 if rpt.ResolvedAt != nil { 334 resolved := rpt.ResolvedAt.Format("2006-01-02T15:04:05Z") 335 items[i].ResolvedAt = &resolved 336 } 337 items[i].ResolvedBy = rpt.ResolvedBy 338 } 339 340 pendingCount, _ := m.db.GetReportCount("pending") 341 totalCount, _ := m.db.GetReportCount("") 342 343 w.Header().Set("Content-Type", "application/json") 344 json.NewEncoder(w).Encode(map[string]interface{}{ 345 "items": items, 346 "totalItems": totalCount, 347 "pendingCount": pendingCount, 348 }) 349} 350 351func (m *ModerationHandler) AdminTakeAction(w http.ResponseWriter, r *http.Request) { 352 session, err := m.refresher.GetSessionWithAutoRefresh(r) 353 if err != nil { 354 http.Error(w, "Unauthorized", http.StatusUnauthorized) 355 return 356 } 357 358 if !config.Get().IsAdmin(session.DID) { 359 http.Error(w, "Forbidden", http.StatusForbidden) 360 return 361 } 362 363 var req struct { 364 ReportID int `json:"reportId"` 365 Action string `json:"action"` 366 Comment *string `json:"comment,omitempty"` 367 } 368 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 369 http.Error(w, "Invalid request body", http.StatusBadRequest) 370 return 371 } 372 373 validActions := map[string]bool{ 374 "acknowledge": true, 375 "escalate": true, 376 "takedown": true, 377 "dismiss": true, 378 } 379 380 if !validActions[req.Action] { 381 http.Error(w, "Invalid action", http.StatusBadRequest) 382 return 383 } 384 385 report, err := m.db.GetReport(req.ReportID) 386 if err != nil { 387 http.Error(w, "Report not found", http.StatusNotFound) 388 return 389 } 390 391 if err := m.db.CreateModerationAction(req.ReportID, session.DID, req.Action, req.Comment); err != nil { 392 logger.Error("Failed to create moderation action: %v", err) 393 http.Error(w, "Failed to take action", http.StatusInternalServerError) 394 return 395 } 396 397 resolveStatus := "resolved" 398 switch req.Action { 399 case "dismiss": 400 resolveStatus = "dismissed" 401 case "escalate": 402 resolveStatus = "escalated" 403 case "takedown": 404 resolveStatus = "resolved" 405 if report.SubjectURI != nil && *report.SubjectURI != "" { 406 m.deleteContent(*report.SubjectURI) 407 } 408 case "acknowledge": 409 resolveStatus = "acknowledged" 410 } 411 412 if err := m.db.ResolveReport(req.ReportID, session.DID, resolveStatus); err != nil { 413 logger.Error("Failed to resolve report: %v", err) 414 } 415 416 w.Header().Set("Content-Type", "application/json") 417 json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) 418} 419 420func (m *ModerationHandler) AdminGetReport(w http.ResponseWriter, r *http.Request) { 421 session, err := m.refresher.GetSessionWithAutoRefresh(r) 422 if err != nil { 423 http.Error(w, "Unauthorized", http.StatusUnauthorized) 424 return 425 } 426 427 if !config.Get().IsAdmin(session.DID) { 428 http.Error(w, "Forbidden", http.StatusForbidden) 429 return 430 } 431 432 idStr := r.URL.Query().Get("id") 433 id, err := strconv.Atoi(idStr) 434 if err != nil { 435 http.Error(w, "Invalid report ID", http.StatusBadRequest) 436 return 437 } 438 439 report, err := m.db.GetReport(id) 440 if err != nil { 441 http.Error(w, "Report not found", http.StatusNotFound) 442 return 443 } 444 445 actions, _ := m.db.GetReportActions(id) 446 447 profiles := fetchProfilesForDIDs(m.db, []string{report.ReporterDID, report.SubjectDID}) 448 449 w.Header().Set("Content-Type", "application/json") 450 json.NewEncoder(w).Encode(map[string]interface{}{ 451 "report": report, 452 "reporter": profiles[report.ReporterDID], 453 "subject": profiles[report.SubjectDID], 454 "actions": actions, 455 }) 456} 457 458func (m *ModerationHandler) AdminCheckAccess(w http.ResponseWriter, r *http.Request) { 459 session, err := m.refresher.GetSessionWithAutoRefresh(r) 460 if err != nil { 461 w.Header().Set("Content-Type", "application/json") 462 json.NewEncoder(w).Encode(map[string]bool{"isAdmin": false}) 463 return 464 } 465 466 w.Header().Set("Content-Type", "application/json") 467 json.NewEncoder(w).Encode(map[string]bool{"isAdmin": config.Get().IsAdmin(session.DID)}) 468} 469 470func (m *ModerationHandler) deleteContent(uri string) { 471 m.db.Exec("DELETE FROM annotations WHERE uri = $1", uri) 472 m.db.Exec("DELETE FROM highlights WHERE uri = $1", uri) 473 m.db.Exec("DELETE FROM bookmarks WHERE uri = $1", uri) 474 m.db.Exec("DELETE FROM replies WHERE uri = $1", uri) 475} 476 477func (m *ModerationHandler) AdminCreateLabel(w http.ResponseWriter, r *http.Request) { 478 session, err := m.refresher.GetSessionWithAutoRefresh(r) 479 if err != nil { 480 http.Error(w, "Unauthorized", http.StatusUnauthorized) 481 return 482 } 483 484 if !config.Get().IsAdmin(session.DID) { 485 http.Error(w, "Forbidden", http.StatusForbidden) 486 return 487 } 488 489 var req struct { 490 Src string `json:"src"` 491 URI string `json:"uri"` 492 Val string `json:"val"` 493 } 494 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 495 http.Error(w, "Invalid request body", http.StatusBadRequest) 496 return 497 } 498 499 if req.Val == "" { 500 http.Error(w, "val is required", http.StatusBadRequest) 501 return 502 } 503 504 labelerDID := config.Get().ServiceDID 505 if labelerDID == "" { 506 http.Error(w, "SERVICE_DID not configured — cannot issue labels", http.StatusInternalServerError) 507 return 508 } 509 510 targetURI := req.URI 511 if targetURI == "" { 512 targetURI = req.Src 513 } 514 if targetURI == "" { 515 http.Error(w, "src or uri is required", http.StatusBadRequest) 516 return 517 } 518 519 validLabels := map[string]bool{ 520 "sexual": true, 521 "nudity": true, 522 "violence": true, 523 "gore": true, 524 "spam": true, 525 "misleading": true, 526 } 527 528 if !validLabels[req.Val] { 529 http.Error(w, "Invalid label value. Must be one of: sexual, nudity, violence, gore, spam, misleading", http.StatusBadRequest) 530 return 531 } 532 533 if err := m.db.CreateContentLabel(labelerDID, targetURI, req.Val, session.DID); err != nil { 534 logger.Error("Failed to create content label: %v", err) 535 http.Error(w, "Failed to create label", http.StatusInternalServerError) 536 return 537 } 538 539 w.Header().Set("Content-Type", "application/json") 540 json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) 541} 542 543func (m *ModerationHandler) AdminDeleteLabel(w http.ResponseWriter, r *http.Request) { 544 session, err := m.refresher.GetSessionWithAutoRefresh(r) 545 if err != nil { 546 http.Error(w, "Unauthorized", http.StatusUnauthorized) 547 return 548 } 549 550 if !config.Get().IsAdmin(session.DID) { 551 http.Error(w, "Forbidden", http.StatusForbidden) 552 return 553 } 554 555 idStr := r.URL.Query().Get("id") 556 id, err := strconv.Atoi(idStr) 557 if err != nil { 558 http.Error(w, "Invalid label ID", http.StatusBadRequest) 559 return 560 } 561 562 if err := m.db.DeleteContentLabel(id); err != nil { 563 http.Error(w, "Failed to delete label", http.StatusInternalServerError) 564 return 565 } 566 567 w.Header().Set("Content-Type", "application/json") 568 json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) 569} 570 571func (m *ModerationHandler) AdminGetLabels(w http.ResponseWriter, r *http.Request) { 572 session, err := m.refresher.GetSessionWithAutoRefresh(r) 573 if err != nil { 574 http.Error(w, "Unauthorized", http.StatusUnauthorized) 575 return 576 } 577 578 if !config.Get().IsAdmin(session.DID) { 579 http.Error(w, "Forbidden", http.StatusForbidden) 580 return 581 } 582 583 limit := parseIntParam(r, "limit", 50) 584 offset := parseIntParam(r, "offset", 0) 585 586 labels, err := m.db.GetAllContentLabels(limit, offset) 587 if err != nil { 588 http.Error(w, "Failed to fetch labels", http.StatusInternalServerError) 589 return 590 } 591 592 uniqueDIDs := make(map[string]bool) 593 for _, l := range labels { 594 uniqueDIDs[l.CreatedBy] = true 595 if len(l.Src) > 4 && l.Src[:4] == "did:" { 596 uniqueDIDs[l.Src] = true 597 } 598 } 599 dids := make([]string, 0, len(uniqueDIDs)) 600 for did := range uniqueDIDs { 601 dids = append(dids, did) 602 } 603 profiles := fetchProfilesForDIDs(m.db, dids) 604 605 type HydratedLabel struct { 606 ID int `json:"id"` 607 Src string `json:"src"` 608 URI string `json:"uri"` 609 Val string `json:"val"` 610 CreatedBy Author `json:"createdBy"` 611 CreatedAt string `json:"createdAt"` 612 Subject *Author `json:"subject,omitempty"` 613 } 614 615 items := make([]HydratedLabel, len(labels)) 616 for i, l := range labels { 617 items[i] = HydratedLabel{ 618 ID: l.ID, 619 Src: l.Src, 620 URI: l.URI, 621 Val: l.Val, 622 CreatedBy: profiles[l.CreatedBy], 623 CreatedAt: l.CreatedAt.Format("2006-01-02T15:04:05Z"), 624 } 625 if len(l.Src) > 4 && l.Src[:4] == "did:" { 626 subj := profiles[l.Src] 627 items[i].Subject = &subj 628 } 629 } 630 631 w.Header().Set("Content-Type", "application/json") 632 json.NewEncoder(w).Encode(map[string]interface{}{"items": items}) 633} 634 635func (m *ModerationHandler) getViewerDID(r *http.Request) string { 636 cookie, err := r.Cookie("margin_session") 637 if err != nil { 638 return "" 639 } 640 did, _, _, _, _, err := m.db.GetSession(cookie.Value) 641 if err != nil { 642 return "" 643 } 644 return did 645} 646 647func (m *ModerationHandler) GetLabelerInfo(w http.ResponseWriter, r *http.Request) { 648 serviceDID := config.Get().ServiceDID 649 650 type LabelDefinition struct { 651 Identifier string `json:"identifier"` 652 Severity string `json:"severity"` 653 Blurs string `json:"blurs"` 654 Description string `json:"description"` 655 } 656 657 labels := []LabelDefinition{ 658 {Identifier: "sexual", Severity: "inform", Blurs: "content", Description: "Sexual content"}, 659 {Identifier: "nudity", Severity: "inform", Blurs: "content", Description: "Nudity"}, 660 {Identifier: "violence", Severity: "inform", Blurs: "content", Description: "Violence"}, 661 {Identifier: "gore", Severity: "alert", Blurs: "content", Description: "Graphic/gory content"}, 662 {Identifier: "spam", Severity: "inform", Blurs: "content", Description: "Spam or unwanted content"}, 663 {Identifier: "misleading", Severity: "inform", Blurs: "content", Description: "Misleading information"}, 664 } 665 666 w.Header().Set("Content-Type", "application/json") 667 json.NewEncoder(w).Encode(map[string]interface{}{ 668 "did": serviceDID, 669 "name": "Margin Moderation", 670 "labels": labels, 671 }) 672}