diff --git a/backend/internal/api/annotations.go b/backend/internal/api/annotations.go index c43fa70..59e6cf0 100644 --- a/backend/internal/api/annotations.go +++ b/backend/internal/api/annotations.go @@ -28,6 +28,7 @@ type CreateAnnotationRequest struct { Selector json.RawMessage `json:"selector,omitempty"` Title string `json:"title,omitempty"` Tags []string `json:"tags,omitempty"` + Labels []string `json:"labels,omitempty"` } type CreateAnnotationResponse struct { @@ -138,6 +139,15 @@ func (s *AnnotationService) CreateAnnotation(w http.ResponseWriter, r *http.Requ record.Facets = facets } + validSelfLabels := map[string]bool{"sexual": true, "nudity": true, "violence": true, "gore": true, "spam": true, "misleading": true} + var validLabels []string + for _, l := range req.Labels { + if validSelfLabels[l] { + validLabels = append(validLabels, l) + } + } + record.Labels = xrpc.NewSelfLabels(validLabels) + var result *xrpc.CreateRecordOutput if existing, err := s.checkDuplicateAnnotation(session.DID, req.URL, req.Text); err == nil && existing != nil { @@ -213,6 +223,12 @@ func (s *AnnotationService) CreateAnnotation(w http.ResponseWriter, r *http.Requ log.Printf("Warning: failed to index annotation in local DB: %v", err) } + for _, label := range validLabels { + if err := s.db.CreateContentLabel(session.DID, result.URI, label, session.DID); err != nil { + log.Printf("Warning: failed to create self-label %s: %v", label, err) + } + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(CreateAnnotationResponse{ URI: result.URI, @@ -262,8 +278,9 @@ func (s *AnnotationService) DeleteAnnotation(w http.ResponseWriter, r *http.Requ } type UpdateAnnotationRequest struct { - Text string `json:"text"` - Tags []string `json:"tags"` + Text string `json:"text"` + Tags []string `json:"tags"` + Labels []string `json:"labels,omitempty"` } func (s *AnnotationService) UpdateAnnotation(w http.ResponseWriter, r *http.Request) { @@ -336,6 +353,15 @@ func (s *AnnotationService) UpdateAnnotation(w http.ResponseWriter, r *http.Requ record.Tags = nil } + updateValidLabels := map[string]bool{"sexual": true, "nudity": true, "violence": true, "gore": true, "spam": true, "misleading": true} + var updateLabels []string + for _, l := range req.Labels { + if updateValidLabels[l] { + updateLabels = append(updateLabels, l) + } + } + record.Labels = xrpc.NewSelfLabels(updateLabels) + if err := record.Validate(); err != nil { return fmt.Errorf("validation failed: %w", err) } @@ -351,12 +377,24 @@ func (s *AnnotationService) UpdateAnnotation(w http.ResponseWriter, r *http.Requ }) if err != nil { + log.Printf("[UpdateAnnotation] Failed: %v", err) http.Error(w, "Failed to update record: "+err.Error(), http.StatusInternalServerError) return } s.db.UpdateAnnotation(uri, req.Text, tagsJSON, result.CID) + validSelfLabels := map[string]bool{"sexual": true, "nudity": true, "violence": true, "gore": true, "spam": true, "misleading": true} + var validLabels []string + for _, l := range req.Labels { + if validSelfLabels[l] { + validLabels = append(validLabels, l) + } + } + if err := s.db.SyncSelfLabels(session.DID, uri, validLabels); err != nil { + log.Printf("Warning: failed to sync self-labels: %v", err) + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, @@ -603,6 +641,7 @@ type CreateHighlightRequest struct { Selector json.RawMessage `json:"selector"` Color string `json:"color,omitempty"` Tags []string `json:"tags,omitempty"` + Labels []string `json:"labels,omitempty"` } func (s *AnnotationService) CreateHighlight(w http.ResponseWriter, r *http.Request) { @@ -626,6 +665,15 @@ func (s *AnnotationService) CreateHighlight(w http.ResponseWriter, r *http.Reque urlHash := db.HashURL(req.URL) record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, req.Color, req.Tags) + validSelfLabels := map[string]bool{"sexual": true, "nudity": true, "violence": true, "gore": true, "spam": true, "misleading": true} + var validLabels []string + for _, l := range req.Labels { + if validSelfLabels[l] { + validLabels = append(validLabels, l) + } + } + record.Labels = xrpc.NewSelfLabels(validLabels) + if err := record.Validate(); err != nil { http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) return @@ -691,6 +739,12 @@ func (s *AnnotationService) CreateHighlight(w http.ResponseWriter, r *http.Reque return } + for _, label := range validLabels { + if err := s.db.CreateContentLabel(session.DID, result.URI, label, session.DID); err != nil { + log.Printf("Warning: failed to create self-label %s: %v", label, err) + } + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"uri": result.URI, "cid": result.CID}) } @@ -828,8 +882,9 @@ func (s *AnnotationService) DeleteBookmark(w http.ResponseWriter, r *http.Reques } type UpdateHighlightRequest struct { - Color string `json:"color"` - Tags []string `json:"tags,omitempty"` + Color string `json:"color"` + Tags []string `json:"tags,omitempty"` + Labels []string `json:"labels,omitempty"` } func (s *AnnotationService) UpdateHighlight(w http.ResponseWriter, r *http.Request) { @@ -880,6 +935,15 @@ func (s *AnnotationService) UpdateHighlight(w http.ResponseWriter, r *http.Reque record.Tags = req.Tags } + updateValidLabels := map[string]bool{"sexual": true, "nudity": true, "violence": true, "gore": true, "spam": true, "misleading": true} + var updateLabels []string + for _, l := range req.Labels { + if updateValidLabels[l] { + updateLabels = append(updateLabels, l) + } + } + record.Labels = xrpc.NewSelfLabels(updateLabels) + if err := record.Validate(); err != nil { return fmt.Errorf("validation failed: %w", err) } @@ -906,6 +970,17 @@ func (s *AnnotationService) UpdateHighlight(w http.ResponseWriter, r *http.Reque } s.db.UpdateHighlight(uri, req.Color, tagsJSON, result.CID) + validSelfLabels := map[string]bool{"sexual": true, "nudity": true, "violence": true, "gore": true, "spam": true, "misleading": true} + var validLabels []string + for _, l := range req.Labels { + if validSelfLabels[l] { + validLabels = append(validLabels, l) + } + } + if err := s.db.SyncSelfLabels(session.DID, uri, validLabels); err != nil { + log.Printf("Warning: failed to sync self-labels: %v", err) + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "uri": result.URI, "cid": result.CID}) } @@ -914,6 +989,7 @@ type UpdateBookmarkRequest struct { Title string `json:"title"` Description string `json:"description"` Tags []string `json:"tags,omitempty"` + Labels []string `json:"labels,omitempty"` } func (s *AnnotationService) UpdateBookmark(w http.ResponseWriter, r *http.Request) { @@ -967,6 +1043,15 @@ func (s *AnnotationService) UpdateBookmark(w http.ResponseWriter, r *http.Reques record.Tags = req.Tags } + updateValidLabels := map[string]bool{"sexual": true, "nudity": true, "violence": true, "gore": true, "spam": true, "misleading": true} + var updateLabels []string + for _, l := range req.Labels { + if updateValidLabels[l] { + updateLabels = append(updateLabels, l) + } + } + record.Labels = xrpc.NewSelfLabels(updateLabels) + if err := record.Validate(); err != nil { return fmt.Errorf("validation failed: %w", err) } @@ -993,6 +1078,17 @@ func (s *AnnotationService) UpdateBookmark(w http.ResponseWriter, r *http.Reques } s.db.UpdateBookmark(uri, req.Title, req.Description, tagsJSON, result.CID) + validSelfLabels := map[string]bool{"sexual": true, "nudity": true, "violence": true, "gore": true, "spam": true, "misleading": true} + var validLabels []string + for _, l := range req.Labels { + if validSelfLabels[l] { + validLabels = append(validLabels, l) + } + } + if err := s.db.SyncSelfLabels(session.DID, uri, validLabels); err != nil { + log.Printf("Warning: failed to sync self-labels: %v", err) + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "uri": result.URI, "cid": result.CID}) } diff --git a/backend/internal/api/handler.go b/backend/internal/api/handler.go index 091b38c..f0a85cb 100644 --- a/backend/internal/api/handler.go +++ b/backend/internal/api/handler.go @@ -27,6 +27,7 @@ type Handler struct { refresher *TokenRefresher apiKeys *APIKeyHandler syncService *internal_sync.Service + moderation *ModerationHandler } func NewHandler(database *db.DB, annotationService *AnnotationService, refresher *TokenRefresher, syncService *internal_sync.Service) *Handler { @@ -36,6 +37,7 @@ func NewHandler(database *db.DB, annotationService *AnnotationService, refresher refresher: refresher, apiKeys: NewAPIKeyHandler(database, refresher), syncService: syncService, + moderation: NewModerationHandler(database, refresher), } } @@ -93,6 +95,23 @@ func (h *Handler) RegisterRoutes(r chi.Router) { r.Get("/preferences", h.GetPreferences) r.Put("/preferences", h.UpdatePreferences) + + r.Post("/moderation/block", h.moderation.BlockUser) + r.Delete("/moderation/block", h.moderation.UnblockUser) + r.Get("/moderation/blocks", h.moderation.GetBlocks) + r.Post("/moderation/mute", h.moderation.MuteUser) + r.Delete("/moderation/mute", h.moderation.UnmuteUser) + r.Get("/moderation/mutes", h.moderation.GetMutes) + r.Get("/moderation/relationship", h.moderation.GetRelationship) + r.Post("/moderation/report", h.moderation.CreateReport) + r.Get("/moderation/admin/check", h.moderation.AdminCheckAccess) + r.Get("/moderation/admin/reports", h.moderation.AdminGetReports) + r.Get("/moderation/admin/report", h.moderation.AdminGetReport) + r.Post("/moderation/admin/action", h.moderation.AdminTakeAction) + r.Post("/moderation/admin/label", h.moderation.AdminCreateLabel) + r.Delete("/moderation/admin/label", h.moderation.AdminDeleteLabel) + r.Get("/moderation/admin/labels", h.moderation.AdminGetLabels) + r.Get("/moderation/labeler", h.moderation.GetLabelerInfo) }) } @@ -423,7 +442,8 @@ func (h *Handler) GetFeed(w http.ResponseWriter, r *http.Request) { feed = filtered } - // ... + feed = h.filterFeedByModeration(feed, viewerDID) + switch feedType { case "popular": sortFeedByPopularity(feed) @@ -447,7 +467,6 @@ func (h *Handler) GetFeed(w http.ResponseWriter, r *http.Request) { } else { feed = []interface{}{} } - // ... if len(feed) > limit { feed = feed[:limit] @@ -1514,3 +1533,39 @@ func (h *Handler) getViewerDID(r *http.Request) string { } return did } + +func getItemAuthorDID(item interface{}) string { + switch v := item.(type) { + case APIAnnotation: + return v.Author.DID + case APIHighlight: + return v.Author.DID + case APIBookmark: + return v.Author.DID + case APICollectionItem: + return v.Author.DID + default: + return "" + } +} + +func (h *Handler) filterFeedByModeration(feed []interface{}, viewerDID string) []interface{} { + if viewerDID == "" { + return feed + } + + hiddenDIDs, err := h.db.GetAllHiddenDIDs(viewerDID) + if err != nil || len(hiddenDIDs) == 0 { + return feed + } + + var filtered []interface{} + for _, item := range feed { + authorDID := getItemAuthorDID(item) + if authorDID != "" && hiddenDIDs[authorDID] { + continue + } + filtered = append(filtered, item) + } + return filtered +} diff --git a/backend/internal/api/hydration.go b/backend/internal/api/hydration.go index 4db8e8a..1bdd355 100644 --- a/backend/internal/api/hydration.go +++ b/backend/internal/api/hydration.go @@ -61,6 +61,12 @@ type APIGenerator struct { Name string `json:"name"` } +type APILabel struct { + Val string `json:"val"` + Src string `json:"src"` + Scope string `json:"scope"` +} + type APIAnnotation struct { ID string `json:"id"` CID string `json:"cid"` @@ -76,37 +82,40 @@ type APIAnnotation struct { LikeCount int `json:"likeCount"` ReplyCount int `json:"replyCount"` ViewerHasLiked bool `json:"viewerHasLiked"` + Labels []APILabel `json:"labels,omitempty"` } type APIHighlight struct { - ID string `json:"id"` - Type string `json:"type"` - Motivation string `json:"motivation"` - Author Author `json:"creator"` - Target APITarget `json:"target"` - Color string `json:"color,omitempty"` - Tags []string `json:"tags,omitempty"` - CreatedAt time.Time `json:"created"` - CID string `json:"cid,omitempty"` - LikeCount int `json:"likeCount"` - ReplyCount int `json:"replyCount"` - ViewerHasLiked bool `json:"viewerHasLiked"` + ID string `json:"id"` + Type string `json:"type"` + Motivation string `json:"motivation"` + Author Author `json:"creator"` + Target APITarget `json:"target"` + Color string `json:"color,omitempty"` + Tags []string `json:"tags,omitempty"` + CreatedAt time.Time `json:"created"` + CID string `json:"cid,omitempty"` + LikeCount int `json:"likeCount"` + ReplyCount int `json:"replyCount"` + ViewerHasLiked bool `json:"viewerHasLiked"` + Labels []APILabel `json:"labels,omitempty"` } type APIBookmark struct { - ID string `json:"id"` - Type string `json:"type"` - Motivation string `json:"motivation"` - Author Author `json:"creator"` - Source string `json:"source"` - Title string `json:"title,omitempty"` - Description string `json:"description,omitempty"` - Tags []string `json:"tags,omitempty"` - CreatedAt time.Time `json:"created"` - CID string `json:"cid,omitempty"` - LikeCount int `json:"likeCount"` - ReplyCount int `json:"replyCount"` - ViewerHasLiked bool `json:"viewerHasLiked"` + ID string `json:"id"` + Type string `json:"type"` + Motivation string `json:"motivation"` + Author Author `json:"creator"` + Source string `json:"source"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Tags []string `json:"tags,omitempty"` + CreatedAt time.Time `json:"created"` + CID string `json:"cid,omitempty"` + LikeCount int `json:"likeCount"` + ReplyCount int `json:"replyCount"` + ViewerHasLiked bool `json:"viewerHasLiked"` + Labels []APILabel `json:"labels,omitempty"` } type APIReply struct { @@ -208,6 +217,12 @@ func hydrateAnnotations(database *db.DB, annotations []db.Annotation, viewerDID defer cancel() likeCounts, replyCounts, viewerLikes := fetchCounts(ctx, database, uris, viewerDID) + subscribedLabelers := getSubscribedLabelers(database, viewerDID) + authorDIDs := collectDIDs(annotations, func(a db.Annotation) string { return a.AuthorDID }) + labelerDIDs := appendUnique(subscribedLabelers, authorDIDs) + uriLabels, _ := database.GetContentLabelsForURIs(uris, labelerDIDs) + didLabels, _ := database.GetContentLabelsForDIDs(authorDIDs, labelerDIDs) + result := make([]APIAnnotation, len(annotations)) for i, a := range annotations { var body *APIBody @@ -265,6 +280,7 @@ func hydrateAnnotations(database *db.DB, annotations []db.Annotation, viewerDID }, CreatedAt: a.CreatedAt, IndexedAt: a.IndexedAt, + Labels: mergeLabels(uriLabels[a.URI], didLabels[a.AuthorDID]), } result[i].LikeCount = likeCounts[a.URI] @@ -293,6 +309,12 @@ func hydrateHighlights(database *db.DB, highlights []db.Highlight, viewerDID str defer cancel() likeCounts, replyCounts, viewerLikes := fetchCounts(ctx, database, uris, viewerDID) + subscribedLabelers := getSubscribedLabelers(database, viewerDID) + authorDIDs := collectDIDs(highlights, func(h db.Highlight) string { return h.AuthorDID }) + labelerDIDs := appendUnique(subscribedLabelers, authorDIDs) + uriLabels, _ := database.GetContentLabelsForURIs(uris, labelerDIDs) + didLabels, _ := database.GetContentLabelsForDIDs(authorDIDs, labelerDIDs) + result := make([]APIHighlight, len(highlights)) for i, h := range highlights { var selector *APISelector @@ -335,6 +357,7 @@ func hydrateHighlights(database *db.DB, highlights []db.Highlight, viewerDID str Tags: tags, CreatedAt: h.CreatedAt, CID: cid, + Labels: mergeLabels(uriLabels[h.URI], didLabels[h.AuthorDID]), } result[i].LikeCount = likeCounts[h.URI] @@ -363,6 +386,12 @@ func hydrateBookmarks(database *db.DB, bookmarks []db.Bookmark, viewerDID string defer cancel() likeCounts, replyCounts, viewerLikes := fetchCounts(ctx, database, uris, viewerDID) + subscribedLabelers := getSubscribedLabelers(database, viewerDID) + authorDIDs := collectDIDs(bookmarks, func(b db.Bookmark) string { return b.AuthorDID }) + labelerDIDs := appendUnique(subscribedLabelers, authorDIDs) + uriLabels, _ := database.GetContentLabelsForURIs(uris, labelerDIDs) + didLabels, _ := database.GetContentLabelsForDIDs(authorDIDs, labelerDIDs) + result := make([]APIBookmark, len(bookmarks)) for i, b := range bookmarks { var tags []string @@ -396,6 +425,7 @@ func hydrateBookmarks(database *db.DB, bookmarks []db.Bookmark, viewerDID string Tags: tags, CreatedAt: b.CreatedAt, CID: cid, + Labels: mergeLabels(uriLabels[b.URI], didLabels[b.AuthorDID]), } result[i].LikeCount = likeCounts[b.URI] result[i].ReplyCount = replyCounts[b.URI] @@ -762,3 +792,78 @@ func hydrateNotifications(database *db.DB, notifications []db.Notification) ([]A return result, nil } + +func mergeLabels(uriLabels []db.ContentLabel, didLabels []db.ContentLabel) []APILabel { + seen := make(map[string]bool) + var labels []APILabel + for _, l := range uriLabels { + key := l.Val + ":" + l.Src + if !seen[key] { + labels = append(labels, APILabel{Val: l.Val, Src: l.Src, Scope: "content"}) + seen[key] = true + } + } + for _, l := range didLabels { + key := l.Val + ":" + l.Src + if !seen[key] { + labels = append(labels, APILabel{Val: l.Val, Src: l.Src, Scope: "account"}) + seen[key] = true + } + } + return labels +} + +func appendUnique(base []string, extra []string) []string { + if base == nil { + return nil + } + seen := make(map[string]bool, len(base)) + for _, s := range base { + seen[s] = true + } + result := make([]string, len(base)) + copy(result, base) + for _, s := range extra { + if !seen[s] { + result = append(result, s) + seen[s] = true + } + } + return result +} + +func getSubscribedLabelers(database *db.DB, viewerDID string) []string { + serviceDID := config.Get().ServiceDID + + if viewerDID == "" { + if serviceDID != "" { + return []string{serviceDID} + } + return nil + } + + prefs, err := database.GetPreferences(viewerDID) + if err != nil || prefs == nil || prefs.SubscribedLabelers == nil { + if serviceDID != "" { + return []string{serviceDID} + } + return nil + } + + type sub struct { + DID string `json:"did"` + } + var subs []sub + if err := json.Unmarshal([]byte(*prefs.SubscribedLabelers), &subs); err != nil || len(subs) == 0 { + if serviceDID != "" { + return []string{serviceDID} + } + return nil + } + + dids := make([]string, len(subs)) + for i, s := range subs { + dids[i] = s.DID + } + return dids +} diff --git a/backend/internal/api/moderation.go b/backend/internal/api/moderation.go new file mode 100644 index 0000000..75a9797 --- /dev/null +++ b/backend/internal/api/moderation.go @@ -0,0 +1,676 @@ +package api + +import ( + "encoding/json" + "log" + "net/http" + "strconv" + + "margin.at/internal/config" + "margin.at/internal/db" +) + +type ModerationHandler struct { + db *db.DB + refresher *TokenRefresher +} + +func NewModerationHandler(database *db.DB, refresher *TokenRefresher) *ModerationHandler { + return &ModerationHandler{db: database, refresher: refresher} +} + +func (m *ModerationHandler) BlockUser(w http.ResponseWriter, r *http.Request) { + session, err := m.refresher.GetSessionWithAutoRefresh(r) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var req struct { + DID string `json:"did"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.DID == "" { + http.Error(w, "did is required", http.StatusBadRequest) + return + } + + if req.DID == session.DID { + http.Error(w, "Cannot block yourself", http.StatusBadRequest) + return + } + + if err := m.db.CreateBlock(session.DID, req.DID); err != nil { + log.Printf("Failed to create block: %v", err) + http.Error(w, "Failed to block user", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +func (m *ModerationHandler) UnblockUser(w http.ResponseWriter, r *http.Request) { + session, err := m.refresher.GetSessionWithAutoRefresh(r) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + did := r.URL.Query().Get("did") + if did == "" { + http.Error(w, "did query parameter required", http.StatusBadRequest) + return + } + + if err := m.db.DeleteBlock(session.DID, did); err != nil { + log.Printf("Failed to delete block: %v", err) + http.Error(w, "Failed to unblock user", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +func (m *ModerationHandler) GetBlocks(w http.ResponseWriter, r *http.Request) { + session, err := m.refresher.GetSessionWithAutoRefresh(r) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + blocks, err := m.db.GetBlocks(session.DID) + if err != nil { + http.Error(w, "Failed to fetch blocks", http.StatusInternalServerError) + return + } + + dids := make([]string, len(blocks)) + for i, b := range blocks { + dids[i] = b.SubjectDID + } + profiles := fetchProfilesForDIDs(m.db, dids) + + type BlockedUser struct { + DID string `json:"did"` + Author Author `json:"author"` + CreatedAt string `json:"createdAt"` + } + + items := make([]BlockedUser, len(blocks)) + for i, b := range blocks { + items[i] = BlockedUser{ + DID: b.SubjectDID, + Author: profiles[b.SubjectDID], + CreatedAt: b.CreatedAt.Format("2006-01-02T15:04:05Z"), + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"items": items}) +} + + +func (m *ModerationHandler) MuteUser(w http.ResponseWriter, r *http.Request) { + session, err := m.refresher.GetSessionWithAutoRefresh(r) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var req struct { + DID string `json:"did"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.DID == "" { + http.Error(w, "did is required", http.StatusBadRequest) + return + } + + if req.DID == session.DID { + http.Error(w, "Cannot mute yourself", http.StatusBadRequest) + return + } + + if err := m.db.CreateMute(session.DID, req.DID); err != nil { + log.Printf("Failed to create mute: %v", err) + http.Error(w, "Failed to mute user", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +func (m *ModerationHandler) UnmuteUser(w http.ResponseWriter, r *http.Request) { + session, err := m.refresher.GetSessionWithAutoRefresh(r) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + did := r.URL.Query().Get("did") + if did == "" { + http.Error(w, "did query parameter required", http.StatusBadRequest) + return + } + + if err := m.db.DeleteMute(session.DID, did); err != nil { + log.Printf("Failed to delete mute: %v", err) + http.Error(w, "Failed to unmute user", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +func (m *ModerationHandler) GetMutes(w http.ResponseWriter, r *http.Request) { + session, err := m.refresher.GetSessionWithAutoRefresh(r) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + mutes, err := m.db.GetMutes(session.DID) + if err != nil { + http.Error(w, "Failed to fetch mutes", http.StatusInternalServerError) + return + } + + dids := make([]string, len(mutes)) + for i, mu := range mutes { + dids[i] = mu.SubjectDID + } + profiles := fetchProfilesForDIDs(m.db, dids) + + type MutedUser struct { + DID string `json:"did"` + Author Author `json:"author"` + CreatedAt string `json:"createdAt"` + } + + items := make([]MutedUser, len(mutes)) + for i, mu := range mutes { + items[i] = MutedUser{ + DID: mu.SubjectDID, + Author: profiles[mu.SubjectDID], + CreatedAt: mu.CreatedAt.Format("2006-01-02T15:04:05Z"), + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"items": items}) +} + +func (m *ModerationHandler) GetRelationship(w http.ResponseWriter, r *http.Request) { + viewerDID := m.getViewerDID(r) + subjectDID := r.URL.Query().Get("did") + + if subjectDID == "" { + http.Error(w, "did query parameter required", http.StatusBadRequest) + return + } + + blocked, muted, blockedBy, err := m.db.GetViewerRelationship(viewerDID, subjectDID) + if err != nil { + http.Error(w, "Failed to get relationship", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "blocking": blocked, + "muting": muted, + "blockedBy": blockedBy, + }) +} + + +func (m *ModerationHandler) CreateReport(w http.ResponseWriter, r *http.Request) { + session, err := m.refresher.GetSessionWithAutoRefresh(r) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var req struct { + SubjectDID string `json:"subjectDid"` + SubjectURI *string `json:"subjectUri,omitempty"` + ReasonType string `json:"reasonType"` + ReasonText *string `json:"reasonText,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.SubjectDID == "" || req.ReasonType == "" { + http.Error(w, "subjectDid and reasonType are required", http.StatusBadRequest) + return + } + + validReasons := map[string]bool{ + "spam": true, + "violation": true, + "misleading": true, + "sexual": true, + "rude": true, + "other": true, + } + + if !validReasons[req.ReasonType] { + http.Error(w, "Invalid reasonType", http.StatusBadRequest) + return + } + + id, err := m.db.CreateReport(session.DID, req.SubjectDID, req.SubjectURI, req.ReasonType, req.ReasonText) + if err != nil { + log.Printf("Failed to create report: %v", err) + http.Error(w, "Failed to submit report", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"id": id, "status": "ok"}) +} + + +func (m *ModerationHandler) AdminGetReports(w http.ResponseWriter, r *http.Request) { + session, err := m.refresher.GetSessionWithAutoRefresh(r) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if !config.Get().IsAdmin(session.DID) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + status := r.URL.Query().Get("status") + limit := parseIntParam(r, "limit", 50) + offset := parseIntParam(r, "offset", 0) + + reports, err := m.db.GetReports(status, limit, offset) + if err != nil { + http.Error(w, "Failed to fetch reports", http.StatusInternalServerError) + return + } + + uniqueDIDs := make(map[string]bool) + for _, rpt := range reports { + uniqueDIDs[rpt.ReporterDID] = true + uniqueDIDs[rpt.SubjectDID] = true + } + dids := make([]string, 0, len(uniqueDIDs)) + for did := range uniqueDIDs { + dids = append(dids, did) + } + profiles := fetchProfilesForDIDs(m.db, dids) + + type HydratedReport struct { + ID int `json:"id"` + Reporter Author `json:"reporter"` + Subject Author `json:"subject"` + SubjectURI *string `json:"subjectUri,omitempty"` + ReasonType string `json:"reasonType"` + ReasonText *string `json:"reasonText,omitempty"` + Status string `json:"status"` + CreatedAt string `json:"createdAt"` + ResolvedAt *string `json:"resolvedAt,omitempty"` + ResolvedBy *string `json:"resolvedBy,omitempty"` + } + + items := make([]HydratedReport, len(reports)) + for i, rpt := range reports { + items[i] = HydratedReport{ + ID: rpt.ID, + Reporter: profiles[rpt.ReporterDID], + Subject: profiles[rpt.SubjectDID], + SubjectURI: rpt.SubjectURI, + ReasonType: rpt.ReasonType, + ReasonText: rpt.ReasonText, + Status: rpt.Status, + CreatedAt: rpt.CreatedAt.Format("2006-01-02T15:04:05Z"), + } + if rpt.ResolvedAt != nil { + resolved := rpt.ResolvedAt.Format("2006-01-02T15:04:05Z") + items[i].ResolvedAt = &resolved + } + items[i].ResolvedBy = rpt.ResolvedBy + } + + pendingCount, _ := m.db.GetReportCount("pending") + totalCount, _ := m.db.GetReportCount("") + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "items": items, + "totalItems": totalCount, + "pendingCount": pendingCount, + }) +} + +func (m *ModerationHandler) AdminTakeAction(w http.ResponseWriter, r *http.Request) { + session, err := m.refresher.GetSessionWithAutoRefresh(r) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if !config.Get().IsAdmin(session.DID) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + var req struct { + ReportID int `json:"reportId"` + Action string `json:"action"` + Comment *string `json:"comment,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + validActions := map[string]bool{ + "acknowledge": true, + "escalate": true, + "takedown": true, + "dismiss": true, + } + + if !validActions[req.Action] { + http.Error(w, "Invalid action", http.StatusBadRequest) + return + } + + report, err := m.db.GetReport(req.ReportID) + if err != nil { + http.Error(w, "Report not found", http.StatusNotFound) + return + } + + if err := m.db.CreateModerationAction(req.ReportID, session.DID, req.Action, req.Comment); err != nil { + log.Printf("Failed to create moderation action: %v", err) + http.Error(w, "Failed to take action", http.StatusInternalServerError) + return + } + + resolveStatus := "resolved" + switch req.Action { + case "dismiss": + resolveStatus = "dismissed" + case "escalate": + resolveStatus = "escalated" + case "takedown": + resolveStatus = "resolved" + if report.SubjectURI != nil && *report.SubjectURI != "" { + m.deleteContent(*report.SubjectURI) + } + case "acknowledge": + resolveStatus = "acknowledged" + } + + if err := m.db.ResolveReport(req.ReportID, session.DID, resolveStatus); err != nil { + log.Printf("Failed to resolve report: %v", err) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +func (m *ModerationHandler) AdminGetReport(w http.ResponseWriter, r *http.Request) { + session, err := m.refresher.GetSessionWithAutoRefresh(r) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if !config.Get().IsAdmin(session.DID) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + idStr := r.URL.Query().Get("id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "Invalid report ID", http.StatusBadRequest) + return + } + + report, err := m.db.GetReport(id) + if err != nil { + http.Error(w, "Report not found", http.StatusNotFound) + return + } + + actions, _ := m.db.GetReportActions(id) + + profiles := fetchProfilesForDIDs(m.db, []string{report.ReporterDID, report.SubjectDID}) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "report": report, + "reporter": profiles[report.ReporterDID], + "subject": profiles[report.SubjectDID], + "actions": actions, + }) +} + +func (m *ModerationHandler) AdminCheckAccess(w http.ResponseWriter, r *http.Request) { + session, err := m.refresher.GetSessionWithAutoRefresh(r) + if err != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]bool{"isAdmin": false}) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]bool{"isAdmin": config.Get().IsAdmin(session.DID)}) +} + +func (m *ModerationHandler) deleteContent(uri string) { + m.db.Exec("DELETE FROM annotations WHERE uri = $1", uri) + m.db.Exec("DELETE FROM highlights WHERE uri = $1", uri) + m.db.Exec("DELETE FROM bookmarks WHERE uri = $1", uri) + m.db.Exec("DELETE FROM replies WHERE uri = $1", uri) +} + + +func (m *ModerationHandler) AdminCreateLabel(w http.ResponseWriter, r *http.Request) { + session, err := m.refresher.GetSessionWithAutoRefresh(r) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if !config.Get().IsAdmin(session.DID) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + var req struct { + Src string `json:"src"` + URI string `json:"uri"` + Val string `json:"val"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.Val == "" { + http.Error(w, "val is required", http.StatusBadRequest) + return + } + + labelerDID := config.Get().ServiceDID + if labelerDID == "" { + http.Error(w, "SERVICE_DID not configured — cannot issue labels", http.StatusInternalServerError) + return + } + + targetURI := req.URI + if targetURI == "" { + targetURI = req.Src + } + if targetURI == "" { + http.Error(w, "src or uri is required", http.StatusBadRequest) + return + } + + validLabels := map[string]bool{ + "sexual": true, + "nudity": true, + "violence": true, + "gore": true, + "spam": true, + "misleading": true, + } + + if !validLabels[req.Val] { + http.Error(w, "Invalid label value. Must be one of: sexual, nudity, violence, gore, spam, misleading", http.StatusBadRequest) + return + } + + if err := m.db.CreateContentLabel(labelerDID, targetURI, req.Val, session.DID); err != nil { + log.Printf("Failed to create content label: %v", err) + http.Error(w, "Failed to create label", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +func (m *ModerationHandler) AdminDeleteLabel(w http.ResponseWriter, r *http.Request) { + session, err := m.refresher.GetSessionWithAutoRefresh(r) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if !config.Get().IsAdmin(session.DID) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + idStr := r.URL.Query().Get("id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "Invalid label ID", http.StatusBadRequest) + return + } + + if err := m.db.DeleteContentLabel(id); err != nil { + http.Error(w, "Failed to delete label", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +func (m *ModerationHandler) AdminGetLabels(w http.ResponseWriter, r *http.Request) { + session, err := m.refresher.GetSessionWithAutoRefresh(r) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if !config.Get().IsAdmin(session.DID) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + limit := parseIntParam(r, "limit", 50) + offset := parseIntParam(r, "offset", 0) + + labels, err := m.db.GetAllContentLabels(limit, offset) + if err != nil { + http.Error(w, "Failed to fetch labels", http.StatusInternalServerError) + return + } + + uniqueDIDs := make(map[string]bool) + for _, l := range labels { + uniqueDIDs[l.CreatedBy] = true + if len(l.Src) > 4 && l.Src[:4] == "did:" { + uniqueDIDs[l.Src] = true + } + } + dids := make([]string, 0, len(uniqueDIDs)) + for did := range uniqueDIDs { + dids = append(dids, did) + } + profiles := fetchProfilesForDIDs(m.db, dids) + + type HydratedLabel struct { + ID int `json:"id"` + Src string `json:"src"` + URI string `json:"uri"` + Val string `json:"val"` + CreatedBy Author `json:"createdBy"` + CreatedAt string `json:"createdAt"` + Subject *Author `json:"subject,omitempty"` + } + + items := make([]HydratedLabel, len(labels)) + for i, l := range labels { + items[i] = HydratedLabel{ + ID: l.ID, + Src: l.Src, + URI: l.URI, + Val: l.Val, + CreatedBy: profiles[l.CreatedBy], + CreatedAt: l.CreatedAt.Format("2006-01-02T15:04:05Z"), + } + if len(l.Src) > 4 && l.Src[:4] == "did:" { + subj := profiles[l.Src] + items[i].Subject = &subj + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"items": items}) +} + +func (m *ModerationHandler) getViewerDID(r *http.Request) string { + cookie, err := r.Cookie("margin_session") + if err != nil { + return "" + } + did, _, _, _, _, err := m.db.GetSession(cookie.Value) + if err != nil { + return "" + } + return did +} + +func (m *ModerationHandler) GetLabelerInfo(w http.ResponseWriter, r *http.Request) { + serviceDID := config.Get().ServiceDID + + type LabelDefinition struct { + Identifier string `json:"identifier"` + Severity string `json:"severity"` + Blurs string `json:"blurs"` + Description string `json:"description"` + } + + labels := []LabelDefinition{ + {Identifier: "sexual", Severity: "inform", Blurs: "content", Description: "Sexual content"}, + {Identifier: "nudity", Severity: "inform", Blurs: "content", Description: "Nudity"}, + {Identifier: "violence", Severity: "inform", Blurs: "content", Description: "Violence"}, + {Identifier: "gore", Severity: "alert", Blurs: "content", Description: "Graphic/gory content"}, + {Identifier: "spam", Severity: "inform", Blurs: "content", Description: "Spam or unwanted content"}, + {Identifier: "misleading", Severity: "inform", Blurs: "content", Description: "Misleading information"}, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "did": serviceDID, + "name": "Margin Moderation", + "labels": labels, + }) +} diff --git a/backend/internal/api/preferences.go b/backend/internal/api/preferences.go index 7725dcf..60be8af 100644 --- a/backend/internal/api/preferences.go +++ b/backend/internal/api/preferences.go @@ -1,18 +1,30 @@ package api import ( - "bytes" "encoding/json" "fmt" "net/http" "time" + "margin.at/internal/config" "margin.at/internal/db" "margin.at/internal/xrpc" ) +type LabelerSubscription struct { + DID string `json:"did"` +} + +type LabelPreference struct { + LabelerDID string `json:"labelerDid"` + Label string `json:"label"` + Visibility string `json:"visibility"` +} + type PreferencesResponse struct { - ExternalLinkSkippedHostnames []string `json:"externalLinkSkippedHostnames"` + ExternalLinkSkippedHostnames []string `json:"externalLinkSkippedHostnames"` + SubscribedLabelers []LabelerSubscription `json:"subscribedLabelers"` + LabelPreferences []LabelPreference `json:"labelPreferences"` } func (h *Handler) GetPreferences(w http.ResponseWriter, r *http.Request) { @@ -33,9 +45,31 @@ func (h *Handler) GetPreferences(w http.ResponseWriter, r *http.Request) { json.Unmarshal([]byte(*prefs.ExternalLinkSkippedHostnames), &hostnames) } + var labelers []LabelerSubscription + if prefs != nil && prefs.SubscribedLabelers != nil { + json.Unmarshal([]byte(*prefs.SubscribedLabelers), &labelers) + } + if labelers == nil { + labelers = []LabelerSubscription{} + serviceDID := config.Get().ServiceDID + if serviceDID != "" { + labelers = append(labelers, LabelerSubscription{DID: serviceDID}) + } + } + + var labelPrefs []LabelPreference + if prefs != nil && prefs.LabelPreferences != nil { + json.Unmarshal([]byte(*prefs.LabelPreferences), &labelPrefs) + } + if labelPrefs == nil { + labelPrefs = []LabelPreference{} + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(PreferencesResponse{ ExternalLinkSkippedHostnames: hostnames, + SubscribedLabelers: labelers, + LabelPreferences: labelPrefs, }) } @@ -52,53 +86,32 @@ func (h *Handler) UpdatePreferences(w http.ResponseWriter, r *http.Request) { return } - record := xrpc.NewPreferencesRecord(input.ExternalLinkSkippedHostnames) + var xrpcLabelers []xrpc.LabelerSubscription + for _, l := range input.SubscribedLabelers { + xrpcLabelers = append(xrpcLabelers, xrpc.LabelerSubscription{DID: l.DID}) + } + var xrpcLabelPrefs []xrpc.LabelPreference + for _, lp := range input.LabelPreferences { + xrpcLabelPrefs = append(xrpcLabelPrefs, xrpc.LabelPreference{ + LabelerDID: lp.LabelerDID, + Label: lp.Label, + Visibility: lp.Visibility, + }) + } + + record := xrpc.NewPreferencesRecord(input.ExternalLinkSkippedHostnames, xrpcLabelers, xrpcLabelPrefs) if err := record.Validate(); err != nil { http.Error(w, fmt.Sprintf("Invalid record: %v", err), http.StatusBadRequest) return } - err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, _ string) error { - url := fmt.Sprintf("%s/xrpc/com.atproto.repo.putRecord", client.PDS) - - body := map[string]interface{}{ - "repo": session.DID, - "collection": xrpc.CollectionPreferences, - "rkey": "self", - "record": record, - } - - jsonBody, err := json.Marshal(body) - if err != nil { - return err - } - - req, err := http.NewRequestWithContext(r.Context(), "POST", url, bytes.NewBuffer(jsonBody)) - if err != nil { - return err - } - req.Header.Set("Authorization", "Bearer "+client.AccessToken) - req.Header.Set("Content-Type", "application/json") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - var errResp struct { - Error string `json:"error"` - Message string `json:"message"` - } - json.NewDecoder(resp.Body).Decode(&errResp) - return fmt.Errorf("XRPC error %d: %s - %s", resp.StatusCode, errResp.Error, errResp.Message) - } - - return nil + err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { + _, err := client.PutRecord(r.Context(), did, xrpc.CollectionPreferences, "self", record) + return err }) if err != nil { + fmt.Printf("[UpdatePreferences] PDS write failed: %v\n", err) http.Error(w, fmt.Sprintf("Failed to update preferences: %v", err), http.StatusInternalServerError) return } @@ -106,12 +119,27 @@ func (h *Handler) UpdatePreferences(w http.ResponseWriter, r *http.Request) { createdAt, _ := time.Parse(time.RFC3339, record.CreatedAt) hostnamesJSON, _ := json.Marshal(input.ExternalLinkSkippedHostnames) hostnamesStr := string(hostnamesJSON) + + var subscribedLabelersPtr, labelPrefsPtr *string + if len(input.SubscribedLabelers) > 0 { + labelersJSON, _ := json.Marshal(input.SubscribedLabelers) + s := string(labelersJSON) + subscribedLabelersPtr = &s + } + if len(input.LabelPreferences) > 0 { + prefsJSON, _ := json.Marshal(input.LabelPreferences) + s := string(prefsJSON) + labelPrefsPtr = &s + } + uri := fmt.Sprintf("at://%s/%s/self", session.DID, xrpc.CollectionPreferences) err = h.db.UpsertPreferences(&db.Preferences{ URI: uri, AuthorDID: session.DID, ExternalLinkSkippedHostnames: &hostnamesStr, + SubscribedLabelers: subscribedLabelersPtr, + LabelPreferences: labelPrefsPtr, CreatedAt: createdAt, IndexedAt: time.Now(), }) diff --git a/backend/internal/api/profile.go b/backend/internal/api/profile.go index 44a8c68..45f0124 100644 --- a/backend/internal/api/profile.go +++ b/backend/internal/api/profile.go @@ -11,6 +11,7 @@ import ( "github.com/go-chi/chi/v5" + "margin.at/internal/config" "margin.at/internal/db" "margin.at/internal/xrpc" ) @@ -147,6 +148,15 @@ func (h *Handler) GetProfile(w http.ResponseWriter, r *http.Request) { Links []string `json:"links"` CreatedAt string `json:"createdAt"` IndexedAt string `json:"indexedAt"` + Labels []struct { + Val string `json:"val"` + Src string `json:"src"` + } `json:"labels,omitempty"` + Viewer *struct { + Blocking bool `json:"blocking"` + Muting bool `json:"muting"` + BlockedBy bool `json:"blockedBy"` + } `json:"viewer,omitempty"` }{ URI: profile.URI, DID: profile.AuthorDID, @@ -178,6 +188,40 @@ func (h *Handler) GetProfile(w http.ResponseWriter, r *http.Request) { resp.Links = []string{} } + viewerDID := h.getViewerDID(r) + if viewerDID != "" && viewerDID != profile.AuthorDID { + blocking, muting, blockedBy, err := h.db.GetViewerRelationship(viewerDID, profile.AuthorDID) + if err == nil { + resp.Viewer = &struct { + Blocking bool `json:"blocking"` + Muting bool `json:"muting"` + BlockedBy bool `json:"blockedBy"` + }{ + Blocking: blocking, + Muting: muting, + BlockedBy: blockedBy, + } + } + } + + subscribedLabelers := getSubscribedLabelers(h.db, viewerDID) + if subscribedLabelers == nil { + serviceDID := config.Get().ServiceDID + if serviceDID != "" { + subscribedLabelers = []string{serviceDID} + } + } + if didLabels, err := h.db.GetContentLabelsForDIDs([]string{profile.AuthorDID}, subscribedLabelers); err == nil { + if labels, ok := didLabels[profile.AuthorDID]; ok { + for _, l := range labels { + resp.Labels = append(resp.Labels, struct { + Val string `json:"val"` + Src string `json:"src"` + }{Val: l.Val, Src: l.Src}) + } + } + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 544140b..6505d5a 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -2,6 +2,7 @@ package config import ( "os" + "strings" "sync" ) @@ -9,6 +10,8 @@ type Config struct { BskyPublicAPI string PLCDirectory string BaseURL string + AdminDIDs []string + ServiceDID string } var ( @@ -18,10 +21,21 @@ var ( func Get() *Config { once.Do(func() { + adminDIDs := []string{} + if raw := os.Getenv("ADMIN_DIDS"); raw != "" { + for _, did := range strings.Split(raw, ",") { + did = strings.TrimSpace(did) + if did != "" { + adminDIDs = append(adminDIDs, did) + } + } + } instance = &Config{ BskyPublicAPI: getEnvOrDefault("BSKY_PUBLIC_API", "https://public.api.bsky.app"), PLCDirectory: getEnvOrDefault("PLC_DIRECTORY_URL", "https://plc.directory"), BaseURL: os.Getenv("BASE_URL"), + AdminDIDs: adminDIDs, + ServiceDID: os.Getenv("SERVICE_DID"), } }) return instance @@ -45,3 +59,12 @@ func (c *Config) BskyGetProfilesURL() string { func (c *Config) PLCResolveURL(did string) string { return c.PLCDirectory + "/" + did } + +func (c *Config) IsAdmin(did string) bool { + for _, adminDID := range c.AdminDIDs { + if adminDID == did { + return true + } + } + return false +} diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go index 1092cd9..27c628d 100644 --- a/backend/internal/db/db.go +++ b/backend/internal/db/db.go @@ -149,11 +149,59 @@ type Preferences struct { URI string `json:"uri"` AuthorDID string `json:"authorDid"` ExternalLinkSkippedHostnames *string `json:"externalLinkSkippedHostnames,omitempty"` + SubscribedLabelers *string `json:"subscribedLabelers,omitempty"` + LabelPreferences *string `json:"labelPreferences,omitempty"` CreatedAt time.Time `json:"createdAt"` IndexedAt time.Time `json:"indexedAt"` CID *string `json:"cid,omitempty"` } +type Block struct { + ID int `json:"id"` + ActorDID string `json:"actorDid"` + SubjectDID string `json:"subjectDid"` + CreatedAt time.Time `json:"createdAt"` +} + +type Mute struct { + ID int `json:"id"` + ActorDID string `json:"actorDid"` + SubjectDID string `json:"subjectDid"` + CreatedAt time.Time `json:"createdAt"` +} + +type ModerationReport struct { + ID int `json:"id"` + ReporterDID string `json:"reporterDid"` + SubjectDID string `json:"subjectDid"` + SubjectURI *string `json:"subjectUri,omitempty"` + ReasonType string `json:"reasonType"` + ReasonText *string `json:"reasonText,omitempty"` + Status string `json:"status"` + CreatedAt time.Time `json:"createdAt"` + ResolvedAt *time.Time `json:"resolvedAt,omitempty"` + ResolvedBy *string `json:"resolvedBy,omitempty"` +} + +type ModerationAction struct { + ID int `json:"id"` + ReportID int `json:"reportId"` + ActorDID string `json:"actorDid"` + Action string `json:"action"` + Comment *string `json:"comment,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +type ContentLabel struct { + ID int `json:"id"` + Src string `json:"src"` + URI string `json:"uri"` + Val string `json:"val"` + Neg bool `json:"neg"` + CreatedBy string `json:"createdBy"` + CreatedAt time.Time `json:"createdAt"` +} + func New(dsn string) (*DB, error) { driver := "sqlite3" if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") { @@ -388,6 +436,64 @@ func (db *DB) Migrate() error { updated_at ` + dateType + ` NOT NULL )`) + db.Exec(`CREATE TABLE IF NOT EXISTS blocks ( + id ` + autoInc + `, + actor_did TEXT NOT NULL, + subject_did TEXT NOT NULL, + created_at ` + dateType + ` NOT NULL, + UNIQUE(actor_did, subject_did) + )`) + db.Exec(`CREATE INDEX IF NOT EXISTS idx_blocks_actor ON blocks(actor_did)`) + db.Exec(`CREATE INDEX IF NOT EXISTS idx_blocks_subject ON blocks(subject_did)`) + + db.Exec(`CREATE TABLE IF NOT EXISTS mutes ( + id ` + autoInc + `, + actor_did TEXT NOT NULL, + subject_did TEXT NOT NULL, + created_at ` + dateType + ` NOT NULL, + UNIQUE(actor_did, subject_did) + )`) + db.Exec(`CREATE INDEX IF NOT EXISTS idx_mutes_actor ON mutes(actor_did)`) + db.Exec(`CREATE INDEX IF NOT EXISTS idx_mutes_subject ON mutes(subject_did)`) + + db.Exec(`CREATE TABLE IF NOT EXISTS moderation_reports ( + id ` + autoInc + `, + reporter_did TEXT NOT NULL, + subject_did TEXT NOT NULL, + subject_uri TEXT, + reason_type TEXT NOT NULL, + reason_text TEXT, + status TEXT NOT NULL DEFAULT 'pending', + created_at ` + dateType + ` NOT NULL, + resolved_at ` + dateType + `, + resolved_by TEXT + )`) + db.Exec(`CREATE INDEX IF NOT EXISTS idx_mod_reports_status ON moderation_reports(status)`) + db.Exec(`CREATE INDEX IF NOT EXISTS idx_mod_reports_subject ON moderation_reports(subject_did)`) + db.Exec(`CREATE INDEX IF NOT EXISTS idx_mod_reports_reporter ON moderation_reports(reporter_did)`) + + db.Exec(`CREATE TABLE IF NOT EXISTS moderation_actions ( + id ` + autoInc + `, + report_id INTEGER NOT NULL, + actor_did TEXT NOT NULL, + action TEXT NOT NULL, + comment TEXT, + created_at ` + dateType + ` NOT NULL + )`) + db.Exec(`CREATE INDEX IF NOT EXISTS idx_mod_actions_report ON moderation_actions(report_id)`) + + db.Exec(`CREATE TABLE IF NOT EXISTS content_labels ( + id ` + autoInc + `, + src TEXT NOT NULL, + uri TEXT NOT NULL, + val TEXT NOT NULL, + neg INTEGER NOT NULL DEFAULT 0, + created_by TEXT NOT NULL, + created_at ` + dateType + ` NOT NULL + )`) + db.Exec(`CREATE INDEX IF NOT EXISTS idx_content_labels_uri ON content_labels(uri)`) + db.Exec(`CREATE INDEX IF NOT EXISTS idx_content_labels_src ON content_labels(src)`) + db.runMigrations() return nil @@ -512,8 +618,8 @@ func (db *DB) DeleteAPIKey(id, ownerDID string) (string, error) { func (db *DB) GetPreferences(did string) (*Preferences, error) { var p Preferences - err := db.QueryRow("SELECT uri, author_did, external_link_skipped_hostnames, created_at, indexed_at, cid FROM preferences WHERE author_did = $1", did).Scan( - &p.URI, &p.AuthorDID, &p.ExternalLinkSkippedHostnames, &p.CreatedAt, &p.IndexedAt, &p.CID, + err := db.QueryRow("SELECT uri, author_did, external_link_skipped_hostnames, subscribed_labelers, label_preferences, created_at, indexed_at, cid FROM preferences WHERE author_did = $1", did).Scan( + &p.URI, &p.AuthorDID, &p.ExternalLinkSkippedHostnames, &p.SubscribedLabelers, &p.LabelPreferences, &p.CreatedAt, &p.IndexedAt, &p.CID, ) if err == sql.ErrNoRows { return nil, nil @@ -526,17 +632,63 @@ func (db *DB) GetPreferences(did string) (*Preferences, error) { func (db *DB) UpsertPreferences(p *Preferences) error { query := ` - INSERT INTO preferences (uri, author_did, external_link_skipped_hostnames, created_at, indexed_at, cid) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO preferences (uri, author_did, external_link_skipped_hostnames, subscribed_labelers, label_preferences, created_at, indexed_at, cid) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT(uri) DO UPDATE SET external_link_skipped_hostnames = EXCLUDED.external_link_skipped_hostnames, + subscribed_labelers = EXCLUDED.subscribed_labelers, + label_preferences = EXCLUDED.label_preferences, indexed_at = EXCLUDED.indexed_at, cid = EXCLUDED.cid ` - _, err := db.Exec(db.Rebind(query), p.URI, p.AuthorDID, p.ExternalLinkSkippedHostnames, p.CreatedAt, p.IndexedAt, p.CID) + _, err := db.Exec(db.Rebind(query), p.URI, p.AuthorDID, p.ExternalLinkSkippedHostnames, p.SubscribedLabelers, p.LabelPreferences, p.CreatedAt, p.IndexedAt, p.CID) + return err +} + +func (db *DB) DeleteAPIKeyByURI(uri string) error { + _, err := db.Exec("DELETE FROM api_keys WHERE uri = $1", uri) return err } +func (db *DB) DeletePreferences(uri string) error { + _, err := db.Exec("DELETE FROM preferences WHERE uri = $1", uri) + return err +} + +func (db *DB) GetAPIKeyURIs(ownerDID string) ([]string, error) { + rows, err := db.Query(db.Rebind("SELECT uri FROM api_keys WHERE owner_did = ? AND uri IS NOT NULL AND uri != ''"), ownerDID) + if err != nil { + return nil, err + } + defer rows.Close() + var uris []string + for rows.Next() { + var uri string + if err := rows.Scan(&uri); err != nil { + return nil, err + } + uris = append(uris, uri) + } + return uris, nil +} + +func (db *DB) GetPreferenceURIs(did string) ([]string, error) { + rows, err := db.Query(db.Rebind("SELECT uri FROM preferences WHERE author_did = ? AND uri IS NOT NULL AND uri != ''"), did) + if err != nil { + return nil, err + } + defer rows.Close() + var uris []string + for rows.Next() { + var uri string + if err := rows.Scan(&uri); err != nil { + return nil, err + } + uris = append(uris, uri) + } + return uris, nil +} + func (db *DB) runMigrations() { dateType := "DATETIME" if db.driver == "postgres" { @@ -572,6 +724,66 @@ func (db *DB) runMigrations() { db.Exec(`ALTER TABLE api_keys ADD COLUMN uri TEXT`) db.Exec(`ALTER TABLE api_keys ADD COLUMN cid TEXT`) db.Exec(`ALTER TABLE api_keys ADD COLUMN indexed_at ` + dateType + ` DEFAULT CURRENT_TIMESTAMP`) + + db.migrateModeration(dateType) + + db.Exec(`ALTER TABLE preferences ADD COLUMN subscribed_labelers TEXT`) + db.Exec(`ALTER TABLE preferences ADD COLUMN label_preferences TEXT`) +} + +func (db *DB) migrateModeration(dateType string) { + _, err := db.Exec(`SELECT subject_did FROM moderation_reports LIMIT 0`) + if err != nil { + db.Exec(`DROP TABLE IF EXISTS moderation_reports`) + db.Exec(`DROP TABLE IF EXISTS moderation_actions`) + + autoInc := "INTEGER PRIMARY KEY AUTOINCREMENT" + if db.driver == "postgres" { + autoInc = "SERIAL PRIMARY KEY" + } + + db.Exec(`CREATE TABLE IF NOT EXISTS moderation_reports ( + id ` + autoInc + `, + reporter_did TEXT NOT NULL, + subject_did TEXT NOT NULL, + subject_uri TEXT, + reason_type TEXT NOT NULL, + reason_text TEXT, + status TEXT NOT NULL DEFAULT 'pending', + created_at ` + dateType + ` NOT NULL, + resolved_at ` + dateType + `, + resolved_by TEXT + )`) + db.Exec(`CREATE INDEX IF NOT EXISTS idx_mod_reports_status ON moderation_reports(status)`) + db.Exec(`CREATE INDEX IF NOT EXISTS idx_mod_reports_subject ON moderation_reports(subject_did)`) + db.Exec(`CREATE INDEX IF NOT EXISTS idx_mod_reports_reporter ON moderation_reports(reporter_did)`) + + db.Exec(`CREATE TABLE IF NOT EXISTS moderation_actions ( + id ` + autoInc + `, + report_id INTEGER NOT NULL, + actor_did TEXT NOT NULL, + action TEXT NOT NULL, + comment TEXT, + created_at ` + dateType + ` NOT NULL + )`) + db.Exec(`CREATE INDEX IF NOT EXISTS idx_mod_actions_report ON moderation_actions(report_id)`) + } + + autoInc := "INTEGER PRIMARY KEY AUTOINCREMENT" + if db.driver == "postgres" { + autoInc = "SERIAL PRIMARY KEY" + } + db.Exec(`CREATE TABLE IF NOT EXISTS content_labels ( + id ` + autoInc + `, + src TEXT NOT NULL, + uri TEXT NOT NULL, + val TEXT NOT NULL, + neg INTEGER NOT NULL DEFAULT 0, + created_by TEXT NOT NULL, + created_at ` + dateType + ` NOT NULL + )`) + db.Exec(`CREATE INDEX IF NOT EXISTS idx_content_labels_uri ON content_labels(uri)`) + db.Exec(`CREATE INDEX IF NOT EXISTS idx_content_labels_src ON content_labels(src)`) } func (db *DB) Close() error { diff --git a/backend/internal/db/queries_keys.go b/backend/internal/db/queries_keys.go index fa5604c..c0c8990 100644 --- a/backend/internal/db/queries_keys.go +++ b/backend/internal/db/queries_keys.go @@ -8,6 +8,11 @@ func (db *DB) CreateAPIKey(key *APIKey) error { _, err := db.Exec(db.Rebind(` INSERT INTO api_keys (id, owner_did, name, key_hash, created_at, uri, cid) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + key_hash = EXCLUDED.key_hash, + uri = EXCLUDED.uri, + cid = EXCLUDED.cid `), key.ID, key.OwnerDID, key.Name, key.KeyHash, key.CreatedAt, key.URI, key.CID) return err } diff --git a/backend/internal/db/queries_moderation.go b/backend/internal/db/queries_moderation.go new file mode 100644 index 0000000..e073ea8 --- /dev/null +++ b/backend/internal/db/queries_moderation.go @@ -0,0 +1,430 @@ +package db + +import "time" + + +func (db *DB) CreateBlock(actorDID, subjectDID string) error { + query := `INSERT INTO blocks (actor_did, subject_did, created_at) VALUES (?, ?, ?) + ON CONFLICT(actor_did, subject_did) DO NOTHING` + _, err := db.Exec(db.Rebind(query), actorDID, subjectDID, time.Now()) + return err +} + +func (db *DB) DeleteBlock(actorDID, subjectDID string) error { + _, err := db.Exec(db.Rebind(`DELETE FROM blocks WHERE actor_did = ? AND subject_did = ?`), actorDID, subjectDID) + return err +} + +func (db *DB) GetBlocks(actorDID string) ([]Block, error) { + rows, err := db.Query(db.Rebind(`SELECT id, actor_did, subject_did, created_at FROM blocks WHERE actor_did = ? ORDER BY created_at DESC`), actorDID) + if err != nil { + return nil, err + } + defer rows.Close() + + var blocks []Block + for rows.Next() { + var b Block + if err := rows.Scan(&b.ID, &b.ActorDID, &b.SubjectDID, &b.CreatedAt); err != nil { + continue + } + blocks = append(blocks, b) + } + return blocks, nil +} + +func (db *DB) IsBlocked(actorDID, subjectDID string) (bool, error) { + var count int + err := db.QueryRow(db.Rebind(`SELECT COUNT(*) FROM blocks WHERE actor_did = ? AND subject_did = ?`), actorDID, subjectDID).Scan(&count) + return count > 0, err +} + +func (db *DB) IsBlockedEither(did1, did2 string) (bool, error) { + var count int + err := db.QueryRow(db.Rebind(`SELECT COUNT(*) FROM blocks WHERE (actor_did = ? AND subject_did = ?) OR (actor_did = ? AND subject_did = ?)`), did1, did2, did2, did1).Scan(&count) + return count > 0, err +} + +func (db *DB) GetBlockedDIDs(actorDID string) ([]string, error) { + rows, err := db.Query(db.Rebind(`SELECT subject_did FROM blocks WHERE actor_did = ?`), actorDID) + if err != nil { + return nil, err + } + defer rows.Close() + + var dids []string + for rows.Next() { + var did string + if err := rows.Scan(&did); err != nil { + continue + } + dids = append(dids, did) + } + return dids, nil +} + +func (db *DB) GetBlockedByDIDs(actorDID string) ([]string, error) { + rows, err := db.Query(db.Rebind(`SELECT actor_did FROM blocks WHERE subject_did = ?`), actorDID) + if err != nil { + return nil, err + } + defer rows.Close() + + var dids []string + for rows.Next() { + var did string + if err := rows.Scan(&did); err != nil { + continue + } + dids = append(dids, did) + } + return dids, nil +} + + +func (db *DB) CreateMute(actorDID, subjectDID string) error { + query := `INSERT INTO mutes (actor_did, subject_did, created_at) VALUES (?, ?, ?) + ON CONFLICT(actor_did, subject_did) DO NOTHING` + _, err := db.Exec(db.Rebind(query), actorDID, subjectDID, time.Now()) + return err +} + +func (db *DB) DeleteMute(actorDID, subjectDID string) error { + _, err := db.Exec(db.Rebind(`DELETE FROM mutes WHERE actor_did = ? AND subject_did = ?`), actorDID, subjectDID) + return err +} + +func (db *DB) GetMutes(actorDID string) ([]Mute, error) { + rows, err := db.Query(db.Rebind(`SELECT id, actor_did, subject_did, created_at FROM mutes WHERE actor_did = ? ORDER BY created_at DESC`), actorDID) + if err != nil { + return nil, err + } + defer rows.Close() + + var mutes []Mute + for rows.Next() { + var m Mute + if err := rows.Scan(&m.ID, &m.ActorDID, &m.SubjectDID, &m.CreatedAt); err != nil { + continue + } + mutes = append(mutes, m) + } + return mutes, nil +} + +func (db *DB) IsMuted(actorDID, subjectDID string) (bool, error) { + var count int + err := db.QueryRow(db.Rebind(`SELECT COUNT(*) FROM mutes WHERE actor_did = ? AND subject_did = ?`), actorDID, subjectDID).Scan(&count) + return count > 0, err +} + +func (db *DB) GetMutedDIDs(actorDID string) ([]string, error) { + rows, err := db.Query(db.Rebind(`SELECT subject_did FROM mutes WHERE actor_did = ?`), actorDID) + if err != nil { + return nil, err + } + defer rows.Close() + + var dids []string + for rows.Next() { + var did string + if err := rows.Scan(&did); err != nil { + continue + } + dids = append(dids, did) + } + return dids, nil +} + +func (db *DB) GetAllHiddenDIDs(actorDID string) (map[string]bool, error) { + hidden := make(map[string]bool) + if actorDID == "" { + return hidden, nil + } + + blocked, err := db.GetBlockedDIDs(actorDID) + if err != nil { + return hidden, err + } + for _, did := range blocked { + hidden[did] = true + } + + blockedBy, err := db.GetBlockedByDIDs(actorDID) + if err != nil { + return hidden, err + } + for _, did := range blockedBy { + hidden[did] = true + } + + muted, err := db.GetMutedDIDs(actorDID) + if err != nil { + return hidden, err + } + for _, did := range muted { + hidden[did] = true + } + + return hidden, nil +} + +func (db *DB) GetViewerRelationship(viewerDID, subjectDID string) (blocked bool, muted bool, blockedBy bool, err error) { + if viewerDID == "" || subjectDID == "" { + return false, false, false, nil + } + + blocked, err = db.IsBlocked(viewerDID, subjectDID) + if err != nil { + return + } + + muted, err = db.IsMuted(viewerDID, subjectDID) + if err != nil { + return + } + + blockedBy, err = db.IsBlocked(subjectDID, viewerDID) + return +} + + +func (db *DB) CreateReport(reporterDID, subjectDID string, subjectURI *string, reasonType string, reasonText *string) (int, error) { + query := `INSERT INTO moderation_reports (reporter_did, subject_did, subject_uri, reason_type, reason_text, status, created_at) + VALUES (?, ?, ?, ?, ?, 'pending', ?)` + + result, err := db.Exec(db.Rebind(query), reporterDID, subjectDID, subjectURI, reasonType, reasonText, time.Now()) + if err != nil { + return 0, err + } + + id, err := result.LastInsertId() + return int(id), err +} + +func (db *DB) GetReports(status string, limit, offset int) ([]ModerationReport, error) { + query := `SELECT id, reporter_did, subject_did, subject_uri, reason_type, reason_text, status, created_at, resolved_at, resolved_by + FROM moderation_reports` + args := []interface{}{} + + if status != "" { + query += ` WHERE status = ?` + args = append(args, status) + } + + query += ` ORDER BY created_at DESC LIMIT ? OFFSET ?` + args = append(args, limit, offset) + + rows, err := db.Query(db.Rebind(query), args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var reports []ModerationReport + for rows.Next() { + var r ModerationReport + if err := rows.Scan(&r.ID, &r.ReporterDID, &r.SubjectDID, &r.SubjectURI, &r.ReasonType, &r.ReasonText, &r.Status, &r.CreatedAt, &r.ResolvedAt, &r.ResolvedBy); err != nil { + continue + } + reports = append(reports, r) + } + return reports, nil +} + +func (db *DB) GetReport(id int) (*ModerationReport, error) { + var r ModerationReport + err := db.QueryRow(db.Rebind(`SELECT id, reporter_did, subject_did, subject_uri, reason_type, reason_text, status, created_at, resolved_at, resolved_by FROM moderation_reports WHERE id = ?`), id).Scan( + &r.ID, &r.ReporterDID, &r.SubjectDID, &r.SubjectURI, &r.ReasonType, &r.ReasonText, &r.Status, &r.CreatedAt, &r.ResolvedAt, &r.ResolvedBy, + ) + if err != nil { + return nil, err + } + return &r, nil +} + +func (db *DB) ResolveReport(id int, resolvedBy string, status string) error { + _, err := db.Exec(db.Rebind(`UPDATE moderation_reports SET status = ?, resolved_at = ?, resolved_by = ? WHERE id = ?`), status, time.Now(), resolvedBy, id) + return err +} + +func (db *DB) CreateModerationAction(reportID int, actorDID, action string, comment *string) error { + query := `INSERT INTO moderation_actions (report_id, actor_did, action, comment, created_at) VALUES (?, ?, ?, ?, ?)` + _, err := db.Exec(db.Rebind(query), reportID, actorDID, action, comment, time.Now()) + return err +} + +func (db *DB) GetReportActions(reportID int) ([]ModerationAction, error) { + rows, err := db.Query(db.Rebind(`SELECT id, report_id, actor_did, action, comment, created_at FROM moderation_actions WHERE report_id = ? ORDER BY created_at DESC`), reportID) + if err != nil { + return nil, err + } + defer rows.Close() + + var actions []ModerationAction + for rows.Next() { + var a ModerationAction + if err := rows.Scan(&a.ID, &a.ReportID, &a.ActorDID, &a.Action, &a.Comment, &a.CreatedAt); err != nil { + continue + } + actions = append(actions, a) + } + return actions, nil +} + +func (db *DB) GetReportCount(status string) (int, error) { + query := `SELECT COUNT(*) FROM moderation_reports` + args := []interface{}{} + if status != "" { + query += ` WHERE status = ?` + args = append(args, status) + } + var count int + err := db.QueryRow(db.Rebind(query), args...).Scan(&count) + return count, err +} + + +func (db *DB) CreateContentLabel(src, uri, val, createdBy string) error { + query := `INSERT INTO content_labels (src, uri, val, neg, created_by, created_at) VALUES (?, ?, ?, 0, ?, ?)` + _, err := db.Exec(db.Rebind(query), src, uri, val, createdBy, time.Now()) + return err +} + +func (db *DB) SyncSelfLabels(authorDID, uri string, labels []string) error { + _, err := db.Exec(db.Rebind(`DELETE FROM content_labels WHERE src = ? AND uri = ? AND created_by = ?`), authorDID, uri, authorDID) + if err != nil { + return err + } + for _, val := range labels { + if err := db.CreateContentLabel(authorDID, uri, val, authorDID); err != nil { + return err + } + } + return nil +} + +func (db *DB) NegateContentLabel(id int) error { + _, err := db.Exec(db.Rebind(`UPDATE content_labels SET neg = 1 WHERE id = ?`), id) + return err +} + +func (db *DB) DeleteContentLabel(id int) error { + _, err := db.Exec(db.Rebind(`DELETE FROM content_labels WHERE id = ?`), id) + return err +} + +func (db *DB) GetContentLabelsForURIs(uris []string, labelerDIDs []string) (map[string][]ContentLabel, error) { + result := make(map[string][]ContentLabel) + if len(uris) == 0 { + return result, nil + } + + placeholders := make([]string, len(uris)) + args := make([]interface{}, len(uris)) + for i, uri := range uris { + placeholders[i] = "?" + args[i] = uri + } + + query := `SELECT id, src, uri, val, neg, created_by, created_at FROM content_labels + WHERE uri IN (` + joinStrings(placeholders, ",") + `) AND neg = 0` + + if len(labelerDIDs) > 0 { + srcPlaceholders := make([]string, len(labelerDIDs)) + for i, did := range labelerDIDs { + srcPlaceholders[i] = "?" + args = append(args, did) + } + query += ` AND src IN (` + joinStrings(srcPlaceholders, ",") + `)` + } + + query += ` ORDER BY created_at DESC` + + rows, err := db.Query(db.Rebind(query), args...) + if err != nil { + return result, err + } + defer rows.Close() + + for rows.Next() { + var l ContentLabel + if err := rows.Scan(&l.ID, &l.Src, &l.URI, &l.Val, &l.Neg, &l.CreatedBy, &l.CreatedAt); err != nil { + continue + } + result[l.URI] = append(result[l.URI], l) + } + return result, nil +} + +func (db *DB) GetContentLabelsForDIDs(dids []string, labelerDIDs []string) (map[string][]ContentLabel, error) { + result := make(map[string][]ContentLabel) + if len(dids) == 0 { + return result, nil + } + + placeholders := make([]string, len(dids)) + args := make([]interface{}, len(dids)) + for i, did := range dids { + placeholders[i] = "?" + args[i] = did + } + + query := `SELECT id, src, uri, val, neg, created_by, created_at FROM content_labels + WHERE uri IN (` + joinStrings(placeholders, ",") + `) AND neg = 0` + + if len(labelerDIDs) > 0 { + srcPlaceholders := make([]string, len(labelerDIDs)) + for i, did := range labelerDIDs { + srcPlaceholders[i] = "?" + args = append(args, did) + } + query += ` AND src IN (` + joinStrings(srcPlaceholders, ",") + `)` + } + + query += ` ORDER BY created_at DESC` + + rows, err := db.Query(db.Rebind(query), args...) + if err != nil { + return result, err + } + defer rows.Close() + + for rows.Next() { + var l ContentLabel + if err := rows.Scan(&l.ID, &l.Src, &l.URI, &l.Val, &l.Neg, &l.CreatedBy, &l.CreatedAt); err != nil { + continue + } + result[l.URI] = append(result[l.URI], l) + } + return result, nil +} + +func (db *DB) GetAllContentLabels(limit, offset int) ([]ContentLabel, error) { + rows, err := db.Query(db.Rebind(`SELECT id, src, uri, val, neg, created_by, created_at FROM content_labels ORDER BY created_at DESC LIMIT ? OFFSET ?`), limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var labels []ContentLabel + for rows.Next() { + var l ContentLabel + if err := rows.Scan(&l.ID, &l.Src, &l.URI, &l.Val, &l.Neg, &l.CreatedBy, &l.CreatedAt); err != nil { + continue + } + labels = append(labels, l) + } + return labels, nil +} + +func joinStrings(strs []string, sep string) string { + result := "" + for i, s := range strs { + if i > 0 { + result += sep + } + result += s + } + return result +} diff --git a/backend/internal/firehose/ingester.go b/backend/internal/firehose/ingester.go index 73f8fd4..6e83fa8 100644 --- a/backend/internal/firehose/ingester.go +++ b/backend/internal/firehose/ingester.go @@ -27,6 +27,8 @@ const ( CollectionCollection = "at.margin.collection" CollectionCollectionItem = "at.margin.collectionItem" CollectionProfile = "at.margin.profile" + CollectionAPIKey = "at.margin.apikey" + CollectionPreferences = "at.margin.preferences" CollectionSembleCard = "network.cosmik.card" CollectionSembleCollection = "network.cosmik.collection" ) @@ -64,6 +66,8 @@ func NewIngester(database *db.DB, syncService *internal_sync.Service) *Ingester i.RegisterHandler(CollectionCollection, i.handleCollection) i.RegisterHandler(CollectionCollectionItem, i.handleCollectionItem) i.RegisterHandler(CollectionProfile, i.handleProfile) + i.RegisterHandler(CollectionAPIKey, i.handleAPIKey) + i.RegisterHandler(CollectionPreferences, i.handlePreferences) i.RegisterHandler(CollectionSembleCard, i.handleSembleCard) i.RegisterHandler(CollectionSembleCollection, i.handleSembleCollection) i.RegisterHandler(xrpc.CollectionSembleCollectionLink, i.handleSembleCollectionLink) @@ -272,6 +276,10 @@ func (i *Ingester) handleDelete(collection, uri string) { i.db.RemoveFromCollection(uri) case CollectionProfile: i.db.DeleteProfile(uri) + case CollectionAPIKey: + i.db.DeleteAPIKeyByURI(uri) + case CollectionPreferences: + i.db.DeletePreferences(uri) case CollectionSembleCard: i.db.DeleteAnnotation(uri) i.db.DeleteBookmark(uri) @@ -736,6 +744,112 @@ func (i *Ingester) handleProfile(event *FirehoseEvent) { } } +func (i *Ingester) handleAPIKey(event *FirehoseEvent) { + var record struct { + Name string `json:"name"` + KeyHash string `json:"keyHash"` + CreatedAt string `json:"createdAt"` + } + + if err := json.Unmarshal(event.Record, &record); err != nil { + return + } + + uri := fmt.Sprintf("at://%s/%s/%s", event.Repo, event.Collection, event.Rkey) + + createdAt, err := time.Parse(time.RFC3339, record.CreatedAt) + if err != nil { + createdAt = time.Now() + } + + var cidPtr *string + if event.CID != "" { + cidPtr = &event.CID + } + + apiKey := &db.APIKey{ + ID: event.Rkey, + OwnerDID: event.Repo, + Name: record.Name, + KeyHash: record.KeyHash, + CreatedAt: createdAt, + URI: uri, + CID: cidPtr, + IndexedAt: time.Now(), + } + + if err := i.db.CreateAPIKey(apiKey); err != nil { + log.Printf("Failed to index API key: %v", err) + } else { + log.Printf("Indexed API key from %s: %s", event.Repo, record.Name) + } +} + +func (i *Ingester) handlePreferences(event *FirehoseEvent) { + if event.Rkey != "self" { + return + } + + var record struct { + ExternalLinkSkippedHostnames []string `json:"externalLinkSkippedHostnames"` + SubscribedLabelers json.RawMessage `json:"subscribedLabelers"` + LabelPreferences json.RawMessage `json:"labelPreferences"` + CreatedAt string `json:"createdAt"` + } + + if err := json.Unmarshal(event.Record, &record); err != nil { + return + } + + uri := fmt.Sprintf("at://%s/%s/%s", event.Repo, event.Collection, event.Rkey) + + createdAt, err := time.Parse(time.RFC3339, record.CreatedAt) + if err != nil { + createdAt = time.Now() + } + + var cidPtr *string + if event.CID != "" { + cidPtr = &event.CID + } + + var skippedHostnamesPtr *string + if len(record.ExternalLinkSkippedHostnames) > 0 { + hostnamesBytes, _ := json.Marshal(record.ExternalLinkSkippedHostnames) + hostnamesStr := string(hostnamesBytes) + skippedHostnamesPtr = &hostnamesStr + } + + var subscribedLabelersPtr *string + if len(record.SubscribedLabelers) > 0 && string(record.SubscribedLabelers) != "null" { + s := string(record.SubscribedLabelers) + subscribedLabelersPtr = &s + } + + var labelPrefsPtr *string + if len(record.LabelPreferences) > 0 && string(record.LabelPreferences) != "null" { + s := string(record.LabelPreferences) + labelPrefsPtr = &s + } + + prefs := &db.Preferences{ + URI: uri, + AuthorDID: event.Repo, + ExternalLinkSkippedHostnames: skippedHostnamesPtr, + SubscribedLabelers: subscribedLabelersPtr, + LabelPreferences: labelPrefsPtr, + CreatedAt: createdAt, + IndexedAt: time.Now(), + CID: cidPtr, + } + + if err := i.db.UpsertPreferences(prefs); err != nil { + log.Printf("Failed to index preferences: %v", err) + } else { + log.Printf("Indexed preferences from %s", event.Repo) + } +} + func (i *Ingester) handleSembleCard(event *FirehoseEvent) { var card xrpc.SembleCard if err := json.Unmarshal(event.Record, &card); err != nil { diff --git a/backend/internal/sync/service.go b/backend/internal/sync/service.go index 9c32b95..f4ecbdd 100644 --- a/backend/internal/sync/service.go +++ b/backend/internal/sync/service.go @@ -34,6 +34,8 @@ func (s *Service) PerformSync(ctx context.Context, did string, getClient func(co xrpc.CollectionLike, xrpc.CollectionCollection, xrpc.CollectionCollectionItem, + xrpc.CollectionAPIKey, + xrpc.CollectionPreferences, xrpc.CollectionSembleCard, xrpc.CollectionSembleCollection, xrpc.CollectionSembleCollectionLink, @@ -187,7 +189,13 @@ func (s *Service) PerformSync(ctx context.Context, did string, getClient func(co } else { err = e } - case xrpc.CollectionSembleCollectionLink: + case xrpc.CollectionAPIKey: + localURIs, err = s.db.GetAPIKeyURIs(did) + localURIs = filterURIsByCollection(localURIs, xrpc.CollectionAPIKey) + case xrpc.CollectionPreferences: + localURIs, err = s.db.GetPreferenceURIs(did) + localURIs = filterURIsByCollection(localURIs, xrpc.CollectionPreferences) + case xrpc.CollectionSembleCollectionLink: items, e := s.db.GetCollectionItemsByAuthor(did) if e == nil { for _, item := range items { @@ -224,6 +232,10 @@ func (s *Service) PerformSync(ctx context.Context, did string, getClient func(co _ = s.db.DeleteCollection(uri) case xrpc.CollectionSembleCollectionLink: _ = s.db.RemoveFromCollection(uri) + case xrpc.CollectionAPIKey: + _ = s.db.DeleteAPIKeyByURI(uri) + case xrpc.CollectionPreferences: + _ = s.db.DeletePreferences(uri) } deletedCount++ } @@ -613,6 +625,66 @@ func (s *Service) upsertRecord(did, collection, uri, cid string, value json.RawM CreatedAt: createdAt, IndexedAt: time.Now(), }) + + case xrpc.CollectionAPIKey: + var record xrpc.APIKeyRecord + if err := json.Unmarshal(value, &record); err != nil { + return err + } + createdAt, _ := time.Parse(time.RFC3339, record.CreatedAt) + + parts := strings.Split(uri, "/") + rkey := parts[len(parts)-1] + + return s.db.CreateAPIKey(&db.APIKey{ + ID: rkey, + OwnerDID: did, + Name: record.Name, + KeyHash: record.KeyHash, + CreatedAt: createdAt, + URI: uri, + CID: cidPtr, + IndexedAt: time.Now(), + }) + + case xrpc.CollectionPreferences: + var record xrpc.PreferencesRecord + if err := json.Unmarshal(value, &record); err != nil { + return err + } + createdAt, _ := time.Parse(time.RFC3339, record.CreatedAt) + + var skippedHostnamesPtr *string + if len(record.ExternalLinkSkippedHostnames) > 0 { + hostnamesBytes, _ := json.Marshal(record.ExternalLinkSkippedHostnames) + hostnamesStr := string(hostnamesBytes) + skippedHostnamesPtr = &hostnamesStr + } + + var subscribedLabelersPtr *string + if len(record.SubscribedLabelers) > 0 { + labelersBytes, _ := json.Marshal(record.SubscribedLabelers) + s := string(labelersBytes) + subscribedLabelersPtr = &s + } + + var labelPrefsPtr *string + if len(record.LabelPreferences) > 0 { + prefsBytes, _ := json.Marshal(record.LabelPreferences) + s := string(prefsBytes) + labelPrefsPtr = &s + } + + return s.db.UpsertPreferences(&db.Preferences{ + URI: uri, + AuthorDID: did, + ExternalLinkSkippedHostnames: skippedHostnamesPtr, + SubscribedLabelers: subscribedLabelersPtr, + LabelPreferences: labelPrefsPtr, + CreatedAt: createdAt, + IndexedAt: time.Now(), + CID: cidPtr, + }) } return nil } diff --git a/backend/internal/xrpc/records.go b/backend/internal/xrpc/records.go index eaccd96..99e6a2d 100644 --- a/backend/internal/xrpc/records.go +++ b/backend/internal/xrpc/records.go @@ -25,6 +25,29 @@ const ( SelectorTypePosition = "TextPositionSelector" ) +type SelfLabel struct { + Val string `json:"val"` +} + +type SelfLabels struct { + Type string `json:"$type"` + Values []SelfLabel `json:"values"` +} + +func NewSelfLabels(vals []string) *SelfLabels { + if len(vals) == 0 { + return nil + } + labels := make([]SelfLabel, len(vals)) + for i, v := range vals { + labels[i] = SelfLabel{Val: v} + } + return &SelfLabels{ + Type: "com.atproto.label.defs#selfLabels", + Values: labels, + } +} + type Selector struct { Type string `json:"type"` } @@ -86,6 +109,7 @@ type AnnotationRecord struct { Facets []Facet `json:"facets,omitempty"` Generator *Generator `json:"generator,omitempty"` Rights string `json:"rights,omitempty"` + Labels *SelfLabels `json:"labels,omitempty"` CreatedAt string `json:"createdAt"` } @@ -203,6 +227,7 @@ type HighlightRecord struct { Tags []string `json:"tags,omitempty"` Generator *Generator `json:"generator,omitempty"` Rights string `json:"rights,omitempty"` + Labels *SelfLabels `json:"labels,omitempty"` CreatedAt string `json:"createdAt"` } @@ -315,6 +340,7 @@ type BookmarkRecord struct { Tags []string `json:"tags,omitempty"` Generator *Generator `json:"generator,omitempty"` Rights string `json:"rights,omitempty"` + Labels *SelfLabels `json:"labels,omitempty"` CreatedAt string `json:"createdAt"` } @@ -435,10 +461,22 @@ func (r *MarginProfileRecord) Validate() error { return nil } +type LabelerSubscription struct { + DID string `json:"did"` +} + +type LabelPreference struct { + LabelerDID string `json:"labelerDid"` + Label string `json:"label"` + Visibility string `json:"visibility"` +} + type PreferencesRecord struct { - Type string `json:"$type"` - ExternalLinkSkippedHostnames []string `json:"externalLinkSkippedHostnames,omitempty"` - CreatedAt string `json:"createdAt"` + Type string `json:"$type"` + ExternalLinkSkippedHostnames []string `json:"externalLinkSkippedHostnames,omitempty"` + SubscribedLabelers []LabelerSubscription `json:"subscribedLabelers,omitempty"` + LabelPreferences []LabelPreference `json:"labelPreferences,omitempty"` + CreatedAt string `json:"createdAt"` } func (r *PreferencesRecord) Validate() error { @@ -450,15 +488,37 @@ func (r *PreferencesRecord) Validate() error { return fmt.Errorf("hostname too long: %s", host) } } + if len(r.SubscribedLabelers) > 50 { + return fmt.Errorf("too many subscribed labelers") + } + if len(r.LabelPreferences) > 500 { + return fmt.Errorf("too many label preferences") + } return nil } -func NewPreferencesRecord(skippedHostnames []string) *PreferencesRecord { - return &PreferencesRecord{ +func NewPreferencesRecord(skippedHostnames []string, labelers interface{}, labelPrefs interface{}) *PreferencesRecord { + record := &PreferencesRecord{ Type: CollectionPreferences, ExternalLinkSkippedHostnames: skippedHostnames, CreatedAt: time.Now().UTC().Format(time.RFC3339), } + + if labelers != nil { + switch v := labelers.(type) { + case []LabelerSubscription: + record.SubscribedLabelers = v + } + } + + if labelPrefs != nil { + switch v := labelPrefs.(type) { + case []LabelPreference: + record.LabelPreferences = v + } + } + + return record } type APIKeyRecord struct { diff --git a/lexicons/at/margin/annotation.json b/lexicons/at/margin/annotation.json index 25fc085..35fe890 100644 --- a/lexicons/at/margin/annotation.json +++ b/lexicons/at/margin/annotation.json @@ -70,6 +70,11 @@ "format": "uri", "description": "License URI (e.g., https://creativecommons.org/licenses/by/4.0/)" }, + "labels": { + "type": "ref", + "ref": "com.atproto.label.defs#selfLabels", + "description": "Self-applied content labels for this annotation" + }, "createdAt": { "type": "string", "format": "datetime" diff --git a/lexicons/at/margin/bookmark.json b/lexicons/at/margin/bookmark.json index 06daa31..5ce24e3 100644 --- a/lexicons/at/margin/bookmark.json +++ b/lexicons/at/margin/bookmark.json @@ -63,6 +63,11 @@ "format": "uri", "description": "License URI (e.g., https://creativecommons.org/licenses/by/4.0/)" }, + "labels": { + "type": "ref", + "ref": "com.atproto.label.defs#selfLabels", + "description": "Self-applied content labels for this bookmark" + }, "createdAt": { "type": "string", "format": "datetime" diff --git a/lexicons/at/margin/highlight.json b/lexicons/at/margin/highlight.json index 4717541..e0ad190 100644 --- a/lexicons/at/margin/highlight.json +++ b/lexicons/at/margin/highlight.json @@ -53,6 +53,11 @@ "format": "uri", "description": "License URI (e.g., https://creativecommons.org/licenses/by/4.0/)" }, + "labels": { + "type": "ref", + "ref": "com.atproto.label.defs#selfLabels", + "description": "Self-applied content labels for this highlight" + }, "createdAt": { "type": "string", "format": "datetime" diff --git a/lexicons/at/margin/preferences.json b/lexicons/at/margin/preferences.json index 4317b55..f66bce1 100644 --- a/lexicons/at/margin/preferences.json +++ b/lexicons/at/margin/preferences.json @@ -19,6 +19,45 @@ }, "maxLength": 100 }, + "subscribedLabelers": { + "type": "array", + "description": "List of labeler services the user subscribes to for content moderation.", + "items": { + "type": "object", + "required": ["did"], + "properties": { + "did": { + "type": "string", + "description": "DID of the labeler service." + } + } + }, + "maxLength": 50 + }, + "labelPreferences": { + "type": "array", + "description": "Per-label visibility preferences for subscribed labelers.", + "items": { + "type": "object", + "required": ["labelerDid", "label", "visibility"], + "properties": { + "labelerDid": { + "type": "string", + "description": "DID of the labeler service." + }, + "label": { + "type": "string", + "description": "The label identifier (e.g. sexual, violence, spam)." + }, + "visibility": { + "type": "string", + "description": "How to handle content with this label: hide, warn, or ignore.", + "knownValues": ["hide", "warn", "ignore"] + } + } + }, + "maxLength": 500 + }, "createdAt": { "type": "string", "format": "datetime" diff --git a/web/src/App.tsx b/web/src/App.tsx index 47af91f..1943f09 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -20,6 +20,7 @@ import { UserUrlWrapper, } from "./routes/wrappers"; import About from "./views/About"; +import AdminModeration from "./views/core/AdminModeration"; export default function App() { React.useEffect(() => { @@ -178,6 +179,15 @@ export default function App() { } /> + + + + } + /> + { try { const res = await apiRequest( `/api/annotations?uri=${encodeURIComponent(uri)}`, { method: "PUT", - body: JSON.stringify({ text, tags }), + body: JSON.stringify({ text, tags, labels }), }, ); return res.ok; @@ -445,13 +450,14 @@ export async function updateHighlight( uri: string, color: string, tags?: string[], + labels?: string[], ): Promise { try { const res = await apiRequest( `/api/highlights?uri=${encodeURIComponent(uri)}`, { method: "PUT", - body: JSON.stringify({ color, tags }), + body: JSON.stringify({ color, tags, labels }), }, ); return res.ok; @@ -466,13 +472,14 @@ export async function updateBookmark( title?: string, description?: string, tags?: string[], + labels?: string[], ): Promise { try { const res = await apiRequest( `/api/bookmarks?uri=${encodeURIComponent(uri)}`, { method: "PUT", - body: JSON.stringify({ title, description, tags }), + body: JSON.stringify({ title, description, tags, labels }), }, ); return res.ok; @@ -942,9 +949,19 @@ export async function getUserTargetItems( return { annotations: [], highlights: [] }; } } -export async function getPreferences(): Promise<{ +import type { + LabelerSubscription, + LabelPreference, + LabelerInfo, +} from "../types"; + +export interface PreferencesResponse { externalLinkSkippedHostnames?: string[]; -}> { + subscribedLabelers?: LabelerSubscription[]; + labelPreferences?: LabelPreference[]; +} + +export async function getPreferences(): Promise { try { const res = await apiRequest("/api/preferences", { skipAuthRedirect: true, @@ -959,6 +976,8 @@ export async function getPreferences(): Promise<{ export async function updatePreferences(prefs: { externalLinkSkippedHostnames?: string[]; + subscribedLabelers?: LabelerSubscription[]; + labelPreferences?: LabelPreference[]; }): Promise { try { const res = await apiRequest("/api/preferences", { @@ -971,3 +990,234 @@ export async function updatePreferences(prefs: { return false; } } + +export async function getLabelerInfo(): Promise { + try { + const res = await apiRequest("/moderation/labeler", { + skipAuthRedirect: true, + }); + if (!res.ok) return null; + return await res.json(); + } catch (e) { + console.error("Failed to fetch labeler info:", e); + return null; + } +} + +import type { + ModerationRelationship, + BlockedUser, + MutedUser, + ModerationReport, + ReportReasonType, +} from "../types"; + +export async function blockUser(did: string): Promise { + try { + const res = await apiRequest("/api/moderation/block", { + method: "POST", + body: JSON.stringify({ did }), + }); + return res.ok; + } catch (e) { + console.error("Failed to block user:", e); + return false; + } +} + +export async function unblockUser(did: string): Promise { + try { + const res = await apiRequest( + `/api/moderation/block?did=${encodeURIComponent(did)}`, + { method: "DELETE" }, + ); + return res.ok; + } catch (e) { + console.error("Failed to unblock user:", e); + return false; + } +} + +export async function getBlocks(): Promise { + try { + const res = await apiRequest("/api/moderation/blocks"); + if (!res.ok) return []; + const data = await res.json(); + return data.items || []; + } catch (e) { + console.error("Failed to fetch blocks:", e); + return []; + } +} + +export async function muteUser(did: string): Promise { + try { + const res = await apiRequest("/api/moderation/mute", { + method: "POST", + body: JSON.stringify({ did }), + }); + return res.ok; + } catch (e) { + console.error("Failed to mute user:", e); + return false; + } +} + +export async function unmuteUser(did: string): Promise { + try { + const res = await apiRequest( + `/api/moderation/mute?did=${encodeURIComponent(did)}`, + { method: "DELETE" }, + ); + return res.ok; + } catch (e) { + console.error("Failed to unmute user:", e); + return false; + } +} + +export async function getMutes(): Promise { + try { + const res = await apiRequest("/api/moderation/mutes"); + if (!res.ok) return []; + const data = await res.json(); + return data.items || []; + } catch (e) { + console.error("Failed to fetch mutes:", e); + return []; + } +} + +export async function getModerationRelationship( + did: string, +): Promise { + try { + const res = await apiRequest( + `/api/moderation/relationship?did=${encodeURIComponent(did)}`, + { skipAuthRedirect: true }, + ); + if (!res.ok) return { blocking: false, muting: false, blockedBy: false }; + return await res.json(); + } catch (e) { + console.error("Failed to get moderation relationship:", e); + return { blocking: false, muting: false, blockedBy: false }; + } +} + +export async function reportUser(params: { + subjectDid: string; + subjectUri?: string; + reasonType: ReportReasonType; + reasonText?: string; +}): Promise { + try { + const res = await apiRequest("/api/moderation/report", { + method: "POST", + body: JSON.stringify(params), + }); + return res.ok; + } catch (e) { + console.error("Failed to submit report:", e); + return false; + } +} + +export async function checkAdminAccess(): Promise { + try { + const res = await apiRequest("/api/moderation/admin/check", { + skipAuthRedirect: true, + }); + if (!res.ok) return false; + const data = await res.json(); + return data.isAdmin || false; + } catch (e) { + return false; + } +} + +export async function getAdminReports( + status?: string, + limit = 50, + offset = 0, +): Promise<{ + items: ModerationReport[]; + totalItems: number; + pendingCount: number; +}> { + try { + const params = new URLSearchParams(); + if (status) params.append("status", status); + params.append("limit", limit.toString()); + params.append("offset", offset.toString()); + const res = await apiRequest( + `/api/moderation/admin/reports?${params.toString()}`, + ); + if (!res.ok) return { items: [], totalItems: 0, pendingCount: 0 }; + return await res.json(); + } catch (e) { + console.error("Failed to fetch admin reports:", e); + return { items: [], totalItems: 0, pendingCount: 0 }; + } +} + +export async function adminTakeAction(params: { + reportId: number; + action: string; + comment?: string; +}): Promise { + try { + const res = await apiRequest("/api/moderation/admin/action", { + method: "POST", + body: JSON.stringify(params), + }); + return res.ok; + } catch (e) { + console.error("Failed to take moderation action:", e); + return false; + } +} + +export async function adminCreateLabel(params: { + src: string; + uri?: string; + val: string; +}): Promise { + try { + const res = await apiRequest("/api/moderation/admin/label", { + method: "POST", + body: JSON.stringify(params), + }); + return res.ok; + } catch (e) { + console.error("Failed to create label:", e); + return false; + } +} + +export async function adminDeleteLabel(id: number): Promise { + try { + const res = await apiRequest(`/api/moderation/admin/label?id=${id}`, { + method: "DELETE", + }); + return res.ok; + } catch (e) { + console.error("Failed to delete label:", e); + return false; + } +} + +export async function adminGetLabels( + limit = 50, + offset = 0, +): Promise<{ items: any[] }> { + try { + const res = await apiRequest( + `/api/moderation/admin/labels?limit=${limit}&offset=${offset}`, + ); + if (!res.ok) return { items: [] }; + return await res.json(); + } catch (e) { + console.error("Failed to fetch labels:", e); + return { items: [] }; + } +} diff --git a/web/src/components/common/Card.tsx b/web/src/components/common/Card.tsx index d71ae48..a90aaa5 100644 --- a/web/src/components/common/Card.tsx +++ b/web/src/components/common/Card.tsx @@ -1,6 +1,8 @@ import React, { useState } from "react"; import { formatDistanceToNow } from "date-fns"; import RichText from "./RichText"; +import MoreMenu from "./MoreMenu"; +import type { MoreMenuItem } from "./MoreMenu"; import { MessageSquare, Heart, @@ -9,29 +11,106 @@ import { Trash2, Edit3, Globe, + ShieldBan, + VolumeX, + Flag, + EyeOff, + Eye, } from "lucide-react"; import ShareMenu from "../modals/ShareMenu"; import AddToCollectionModal from "../modals/AddToCollectionModal"; import ExternalLinkModal from "../modals/ExternalLinkModal"; +import ReportModal from "../modals/ReportModal"; +import EditItemModal from "../modals/EditItemModal"; import { clsx } from "clsx"; -import { likeItem, unlikeItem, deleteItem } from "../../api/client"; +import { + likeItem, + unlikeItem, + deleteItem, + blockUser, + muteUser, +} from "../../api/client"; import { $user } from "../../store/auth"; import { $preferences } from "../../store/preferences"; import { useStore } from "@nanostores/react"; -import type { AnnotationItem } from "../../types"; +import type { + AnnotationItem, + ContentLabel, + LabelVisibility, +} from "../../types"; import { Link } from "react-router-dom"; import { Avatar } from "../ui"; import CollectionIcon from "./CollectionIcon"; import ProfileHoverCard from "./ProfileHoverCard"; +const LABEL_DESCRIPTIONS: Record = { + sexual: "Sexual Content", + nudity: "Nudity", + violence: "Violence", + gore: "Graphic Content", + spam: "Spam", + misleading: "Misleading", +}; + +function getContentWarning( + labels?: ContentLabel[], + prefs?: { + labelPreferences: { + labelerDid: string; + label: string; + visibility: LabelVisibility; + }[]; + }, +): { + label: string; + description: string; + visibility: LabelVisibility; + isAccountWide: boolean; +} | null { + if (!labels || labels.length === 0) return null; + const priority = [ + "gore", + "violence", + "nudity", + "sexual", + "misleading", + "spam", + ]; + for (const p of priority) { + const match = labels.find((l) => l.val === p); + if (match) { + const pref = prefs?.labelPreferences.find( + (lp) => lp.label === p && lp.labelerDid === match.src, + ); + const visibility: LabelVisibility = pref?.visibility || "warn"; + if (visibility === "ignore") continue; + return { + label: p, + description: LABEL_DESCRIPTIONS[p] || p, + visibility, + isAccountWide: match.scope === "account", + }; + } + } + return null; +} + interface CardProps { item: AnnotationItem; onDelete?: (uri: string) => void; + onUpdate?: (item: AnnotationItem) => void; hideShare?: boolean; } -export default function Card({ item, onDelete, hideShare }: CardProps) { +export default function Card({ + item: initialItem, + onDelete, + onUpdate, + hideShare, +}: CardProps) { + const [item, setItem] = useState(initialItem); const user = useStore($user); + const preferences = useStore($preferences); const isAuthor = user && item.author?.did === user.did; const [liked, setLiked] = useState(!!item.viewer?.like); @@ -39,6 +118,17 @@ export default function Card({ item, onDelete, hideShare }: CardProps) { const [showCollectionModal, setShowCollectionModal] = useState(false); const [showExternalLinkModal, setShowExternalLinkModal] = useState(false); const [externalLinkUrl, setExternalLinkUrl] = useState(null); + const [showReportModal, setShowReportModal] = useState(false); + const [showEditModal, setShowEditModal] = useState(false); + const [contentRevealed, setContentRevealed] = useState(false); + + const contentWarning = getContentWarning(item.labels, preferences); + + if (contentWarning?.visibility === "hide") return null; + + React.useEffect(() => { + setItem(initialItem); + }, [initialItem]); React.useEffect(() => { setLiked(!!item.viewer?.like); @@ -183,7 +273,7 @@ export default function Card({ item, onDelete, hideShare }: CardProps) { const displayImage = ogData?.image; return ( -
+
{item.collection && (
{item.addedBy && item.addedBy.did !== item.author?.did ? ( @@ -221,11 +311,22 @@ export default function Card({ item, onDelete, hideShare }: CardProps) {
- +
+
+ +
+
@@ -282,7 +383,7 @@ export default function Card({ item, onDelete, hideShare }: CardProps) { })()}
- {pageUrl && !isBookmark && ( + {pageUrl && !isBookmark && !(contentWarning && !contentRevealed) && (
-
+
+ {contentWarning && !contentRevealed && ( +
+
+ + + {contentWarning.description} + +
+ +
+ )} + {contentWarning && contentRevealed && ( + + )} {isBookmark && (
)} + + {!isAuthor && user && ( + <> +
+ { + const menuItems: MoreMenuItem[] = [ + { + label: "Report", + icon: , + onClick: () => setShowReportModal(true), + variant: "danger", + }, + { + label: `Mute @${item.author?.handle || "user"}`, + icon: , + onClick: async () => { + if (item.author?.did) { + await muteUser(item.author.did); + onDelete?.(item.uri); + } + }, + }, + { + label: `Block @${item.author?.handle || "user"}`, + icon: , + onClick: async () => { + if (item.author?.did) { + await blockUser(item.author.did); + onDelete?.(item.uri); + } + }, + variant: "danger", + }, + ]; + return menuItems; + })()} + /> + + )}
setShowExternalLinkModal(false)} url={externalLinkUrl} /> + + setShowReportModal(false)} + subjectDid={item.author?.did || ""} + subjectUri={item.uri} + subjectHandle={item.author?.handle} + /> + + setShowEditModal(false)} + item={item} + type={type} + onSaved={(updated) => { + setItem(updated); + onUpdate?.(updated); + }} + />
); } diff --git a/web/src/components/common/MoreMenu.tsx b/web/src/components/common/MoreMenu.tsx new file mode 100644 index 0000000..5cecbe5 --- /dev/null +++ b/web/src/components/common/MoreMenu.tsx @@ -0,0 +1,99 @@ +import React, { useState, useRef, useEffect } from "react"; +import { MoreHorizontal } from "lucide-react"; +import { clsx } from "clsx"; + +export interface MoreMenuItem { + label: string; + icon?: React.ReactNode; + onClick: () => void; + variant?: "default" | "danger"; + disabled?: boolean; +} + +interface MoreMenuProps { + items: MoreMenuItem[]; + className?: string; +} + +export default function MoreMenu({ items, className }: MoreMenuProps) { + const [isOpen, setIsOpen] = useState(false); + const buttonRef = useRef(null); + const menuRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + + const handleClickOutside = (e: MouseEvent) => { + if ( + menuRef.current && + !menuRef.current.contains(e.target as Node) && + buttonRef.current && + !buttonRef.current.contains(e.target as Node) + ) { + setIsOpen(false); + } + }; + + const handleScroll = () => setIsOpen(false); + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") setIsOpen(false); + }; + + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("scroll", handleScroll, true); + document.addEventListener("keydown", handleEscape); + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("scroll", handleScroll, true); + document.removeEventListener("keydown", handleEscape); + }; + }, [isOpen]); + + if (items.length === 0) return null; + + return ( +
+ + + {isOpen && ( +
+ {items.map((item, i) => ( + + ))} +
+ )} +
+ ); +} diff --git a/web/src/components/feed/Composer.tsx b/web/src/components/feed/Composer.tsx index df41f66..20f125a 100644 --- a/web/src/components/feed/Composer.tsx +++ b/web/src/components/feed/Composer.tsx @@ -1,7 +1,16 @@ import React, { useState } from "react"; import { createAnnotation, createHighlight } from "../../api/client"; -import type { Selector } from "../../types"; -import { X } from "lucide-react"; +import type { Selector, ContentLabelValue } from "../../types"; +import { X, ShieldAlert } from "lucide-react"; + +const SELF_LABEL_OPTIONS: { value: ContentLabelValue; label: string }[] = [ + { value: "sexual", label: "Sexual" }, + { value: "nudity", label: "Nudity" }, + { value: "violence", label: "Violence" }, + { value: "gore", label: "Gore" }, + { value: "spam", label: "Spam" }, + { value: "misleading", label: "Misleading" }, +]; interface ComposerProps { url: string; @@ -23,6 +32,8 @@ export default function Composer({ const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [showQuoteInput, setShowQuoteInput] = useState(false); + const [selfLabels, setSelfLabels] = useState([]); + const [showLabelPicker, setShowLabelPicker] = useState(false); const highlightedText = selector?.type === "TextQuoteSelector" ? selector.exact : null; @@ -59,6 +70,7 @@ export default function Composer({ }, color: "yellow", tags: tagList, + labels: selfLabels.length > 0 ? selfLabels : undefined, }); } else { await createAnnotation({ @@ -66,6 +78,7 @@ export default function Composer({ text: text.trim(), selector: finalSelector || undefined, tags: tagList, + labels: selfLabels.length > 0 ? selfLabels : undefined, }); } @@ -172,6 +185,45 @@ export default function Composer({ disabled={loading} /> +
+ + + {showLabelPicker && ( +
+ {SELF_LABEL_OPTIONS.map((opt) => ( + + ))} +
+ )} +
+
void; + item: AnnotationItem; + type: "annotation" | "highlight" | "bookmark"; + onSaved?: (item: AnnotationItem) => void; +} + +export default function EditItemModal({ + isOpen, + onClose, + item, + type, + onSaved, +}: EditItemModalProps) { + const [text, setText] = useState(item.body?.value || ""); + const [tags, setTags] = useState(item.tags || []); + const [tagInput, setTagInput] = useState(""); + + const [color, setColor] = useState(item.color || "yellow"); + + const [title, setTitle] = useState(item.title || item.target?.title || ""); + const [description, setDescription] = useState(item.description || ""); + + const existingLabels = (item.labels || []) + .filter((l) => l.src === item.author?.did) + .map((l) => l.val as ContentLabelValue); + const [selfLabels, setSelfLabels] = + useState(existingLabels); + const [showLabelPicker, setShowLabelPicker] = useState( + existingLabels.length > 0, + ); + + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (isOpen) { + setText(item.body?.value || ""); + setTags(item.tags || []); + setTagInput(""); + setColor(item.color || "yellow"); + setTitle(item.title || item.target?.title || ""); + setDescription(item.description || ""); + const labels = (item.labels || []) + .filter((l) => l.src === item.author?.did) + .map((l) => l.val as ContentLabelValue); + setSelfLabels(labels); + setShowLabelPicker(labels.length > 0); + } + }, [isOpen, item]); + + if (!isOpen) return null; + + const addTag = () => { + const t = tagInput.trim().toLowerCase(); + if (t && !tags.includes(t)) { + setTags([...tags, t]); + } + setTagInput(""); + }; + + const removeTag = (tag: string) => { + setTags(tags.filter((t) => t !== tag)); + }; + + const toggleLabel = (val: ContentLabelValue) => { + setSelfLabels((prev) => + prev.includes(val) ? prev.filter((l) => l !== val) : [...prev, val], + ); + }; + + const handleSave = async () => { + setSaving(true); + setError(null); + let success = false; + const labels = selfLabels.length > 0 ? selfLabels : []; + + try { + if (type === "annotation") { + success = await updateAnnotation( + item.uri, + text, + tags.length > 0 ? tags : undefined, + labels, + ); + } else if (type === "highlight") { + success = await updateHighlight( + item.uri, + color, + tags.length > 0 ? tags : undefined, + labels, + ); + } else if (type === "bookmark") { + success = await updateBookmark( + item.uri, + title || undefined, + description || undefined, + tags.length > 0 ? tags : undefined, + labels, + ); + } + } catch (e) { + console.error("Edit save error:", e); + setError(e instanceof Error ? e.message : "Failed to save"); + setSaving(false); + return; + } + + setSaving(false); + if (!success) { + setError("Failed to save changes. Please try again."); + return; + } + const updated = { ...item }; + if (type === "annotation") { + updated.body = { type: "TextualBody", value: text, format: "text/plain" }; + } else if (type === "highlight") { + updated.color = color; + } else if (type === "bookmark") { + updated.title = title; + updated.description = description; + } + updated.tags = tags; + const otherLabels = (item.labels || []).filter( + (l) => l.src !== item.author?.did, + ); + const newSelfLabels = selfLabels.map((val) => ({ + val, + src: item.author?.did || "", + scope: "content" as const, + })); + updated.labels = [...otherLabels, ...newSelfLabels]; + onSaved?.(updated); + onClose(); + }; + + return ( +
+
e.stopPropagation()} + > +
+

+ Edit{" "} + {type === "annotation" + ? "Annotation" + : type === "highlight" + ? "Highlight" + : "Bookmark"} +

+ +
+ +
+ {type === "annotation" && ( +
+ +