Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
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}