diff --git a/internal/feed/service.go b/internal/feed/service.go index 8d73418..051f758 100644 --- a/internal/feed/service.go +++ b/internal/feed/service.go @@ -10,6 +10,7 @@ import ( "arabica/internal/lexicons" "arabica/internal/metrics" "arabica/internal/models" + "arabica/internal/moderation" "github.com/rs/zerolog/log" ) @@ -175,36 +176,15 @@ func (s *Service) filterModeratedItems(ctx context.Context, items []*FeedItem) [ return items } - // Load moderation sets in bulk (2 queries instead of 2*len(items)) - blacklistedDIDs := make(map[string]bool) - if dids, err := s.moderationFilter.ListBlacklistedDIDs(ctx); err == nil { - for _, did := range dids { - blacklistedDIDs[did] = true - } - } - - hiddenURIs := make(map[string]bool) - if uris, err := s.moderationFilter.ListHiddenURIs(ctx); err == nil { - for _, uri := range uris { - hiddenURIs[uri] = true - } + f, err := moderation.LoadFilter(ctx, s.moderationFilter) + if err != nil { + log.Warn().Err(err).Msg("feed: failed to load moderation filter") + return items } - filtered := make([]*FeedItem, 0, len(items)) - for _, item := range items { - authorDID := s.getAuthorDID(item) - if authorDID != "" && blacklistedDIDs[authorDID] { - log.Debug().Str("author", authorDID).Msg("feed: filtering blacklisted user's content") - continue - } - - if item.SubjectURI != "" && hiddenURIs[item.SubjectURI] { - log.Debug().Str("uri", item.SubjectURI).Msg("feed: filtering hidden record") - continue - } - - filtered = append(filtered, item) - } + filtered := moderation.FilterSlice(f, items, func(item *FeedItem) (string, string) { + return item.SubjectURI, s.getAuthorDID(item) + }) if len(items) != len(filtered) { log.Debug(). diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index a667450..7dd1511 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -128,6 +128,20 @@ func (h *Handler) invalidateFeedCache() { } } +// loadContentFilter creates a ContentFilter from the moderation store. +// Returns nil if moderation is not configured. +func (h *Handler) loadContentFilter(ctx context.Context) *moderation.ContentFilter { + if h.moderationStore == nil { + return nil + } + f, err := moderation.LoadFilter(ctx, h.moderationStore) + if err != nil { + log.Warn().Err(err).Msg("failed to load content filter") + return nil + } + return f +} + // validateRKey validates and returns an rkey from a path parameter. // Returns the rkey if valid, or writes an error response and returns empty string if invalid. func validateRKey(w http.ResponseWriter, rkey string) string { diff --git a/internal/handlers/profile.go b/internal/handlers/profile.go index aa3d520..3c82eb1 100644 --- a/internal/handlers/profile.go +++ b/internal/handlers/profile.go @@ -9,6 +9,7 @@ import ( "arabica/internal/atproto" "arabica/internal/metrics" "arabica/internal/models" + "arabica/internal/moderation" "arabica/internal/web/bff" "arabica/internal/web/components" "arabica/internal/web/pages" @@ -420,6 +421,16 @@ func (h *Handler) HandleProfile(w http.ResponseWriter, r *http.Request) { // For now, continue with the DID we have } + // Check if user is blacklisted + if cf := h.loadContentFilter(ctx); cf != nil && cf.IsBlocked(did) { + layoutData, _, _ := h.layoutDataFromRequest(r, "Profile Not Found") + w.WriteHeader(http.StatusNotFound) + if err := pages.ProfileNotFound(layoutData).Render(r.Context(), w); err != nil { + log.Error().Err(err).Msg("Failed to render profile not found page") + } + return + } + // Fetch profile profile, err := publicClient.GetProfile(ctx, did) if err != nil { @@ -532,6 +543,13 @@ func (h *Handler) HandleProfilePartial(w http.ResponseWriter, r *http.Request) { } } + // Check if user is blacklisted + cf := h.loadContentFilter(ctx) + if cf != nil && cf.IsBlocked(did) { + http.Error(w, "User not found", http.StatusNotFound) + return + } + // Fetch all user data from their PDS profileData, err := h.fetchUserProfileData(ctx, did, publicClient) if err != nil { @@ -540,6 +558,13 @@ func (h *Handler) HandleProfilePartial(w http.ResponseWriter, r *http.Request) { return } + // Filter moderated content from profile + if cf != nil { + profileData.Brews = moderation.FilterSlice(cf, profileData.Brews, func(b *models.Brew) (string, string) { + return atproto.BuildATURI(did, atproto.NSIDBrew, b.RKey), did + }) + } + // Check if this is an Arabica user (has records or is registered in feed) isArabicaUser := h.feedRegistry.IsRegistered(did) || len(profileData.Brews) > 0 || len(profileData.Beans) > 0 || diff --git a/internal/handlers/recipe.go b/internal/handlers/recipe.go index 66a4aee..4445e45 100644 --- a/internal/handlers/recipe.go +++ b/internal/handlers/recipe.go @@ -11,6 +11,7 @@ import ( "arabica/internal/atproto" "arabica/internal/matching" "arabica/internal/models" + "arabica/internal/moderation" "arabica/internal/web/components" "arabica/internal/web/pages" @@ -639,6 +640,16 @@ func (h *Handler) listAllRecipesFromIndex(ctx context.Context) ([]*models.Recipe recipes = append(recipes, recipe) } + // Filter moderated content (hidden records + blacklisted users) + if cf := h.loadContentFilter(ctx); cf != nil { + recipes = moderation.FilterSlice(cf, recipes, func(r *models.Recipe) (string, string) { + if r.AuthorDID != "" && r.RKey != "" { + return atproto.BuildATURI(r.AuthorDID, atproto.NSIDRecipe, r.RKey), r.AuthorDID + } + return "", r.AuthorDID + }) + } + return recipes, nil } diff --git a/internal/moderation/filter.go b/internal/moderation/filter.go new file mode 100644 index 0000000..0d5f931 --- /dev/null +++ b/internal/moderation/filter.go @@ -0,0 +1,89 @@ +package moderation + +import ( + "context" + + "github.com/rs/zerolog/log" +) + +// FilterSource provides the data needed to build a ContentFilter. +// Both moderation.Store and feed.ModerationFilter satisfy this interface. +type FilterSource interface { + ListHiddenURIs(ctx context.Context) ([]string, error) + ListBlacklistedDIDs(ctx context.Context) ([]string, error) +} + +// ContentFilter holds pre-loaded moderation state for efficient per-item checks. +// Create one per request via LoadFilter, then use ShouldHide or FilterSlice. +type ContentFilter struct { + hiddenURIs map[string]bool + blacklisted map[string]bool +} + +// LoadFilter bulk-loads hidden URIs and blacklisted DIDs from the source (2 queries). +// Errors from the source are logged and degraded gracefully (partial filtering). +// A nil source returns an empty filter that hides nothing. +func LoadFilter(ctx context.Context, src FilterSource) (*ContentFilter, error) { + f := &ContentFilter{ + hiddenURIs: make(map[string]bool), + blacklisted: make(map[string]bool), + } + + if src == nil { + return f, nil + } + + if uris, err := src.ListHiddenURIs(ctx); err != nil { + log.Warn().Err(err).Msg("moderation: failed to load hidden URIs for filter") + } else { + for _, uri := range uris { + f.hiddenURIs[uri] = true + } + } + + if dids, err := src.ListBlacklistedDIDs(ctx); err != nil { + log.Warn().Err(err).Msg("moderation: failed to load blacklisted DIDs for filter") + } else { + for _, did := range dids { + f.blacklisted[did] = true + } + } + + return f, nil +} + +// ShouldHide returns true if the record should be hidden, either because its +// URI is in the hidden set or its author DID is blacklisted. +// Empty strings are never matched. +func (f *ContentFilter) ShouldHide(uri, authorDID string) bool { + if uri != "" && f.hiddenURIs[uri] { + return true + } + if authorDID != "" && f.blacklisted[authorDID] { + return true + } + return false +} + +// IsBlocked returns true if the given DID is blacklisted. +func (f *ContentFilter) IsBlocked(did string) bool { + return did != "" && f.blacklisted[did] +} + +// FilterSlice removes items that should be hidden from a slice. +// The getKeys function extracts the AT-URI and author DID from each item. +// A nil filter returns the input unchanged. +func FilterSlice[T any](f *ContentFilter, items []T, getKeys func(T) (uri string, authorDID string)) []T { + if f == nil { + return items + } + + result := make([]T, 0, len(items)) + for _, item := range items { + uri, did := getKeys(item) + if !f.ShouldHide(uri, did) { + result = append(result, item) + } + } + return result +} diff --git a/internal/moderation/filter_test.go b/internal/moderation/filter_test.go new file mode 100644 index 0000000..8ea5ee0 --- /dev/null +++ b/internal/moderation/filter_test.go @@ -0,0 +1,150 @@ +package moderation + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockFilterSource implements FilterSource for testing +type mockFilterSource struct { + hiddenURIs []string + blacklistedDIDs []string + hiddenErr error + blacklistErr error +} + +func (m *mockFilterSource) ListHiddenURIs(ctx context.Context) ([]string, error) { + return m.hiddenURIs, m.hiddenErr +} + +func (m *mockFilterSource) ListBlacklistedDIDs(ctx context.Context) ([]string, error) { + return m.blacklistedDIDs, m.blacklistErr +} + +func TestLoadFilter(t *testing.T) { + ctx := context.Background() + src := &mockFilterSource{ + hiddenURIs: []string{"at://did:plc:a/col/1", "at://did:plc:b/col/2"}, + blacklistedDIDs: []string{"did:plc:bad"}, + } + + f, err := LoadFilter(ctx, src) + require.NoError(t, err) + assert.NotNil(t, f) +} + +func TestLoadFilter_NilSource(t *testing.T) { + f, err := LoadFilter(context.Background(), nil) + require.NoError(t, err) + assert.NotNil(t, f) + // Empty filter should hide nothing + assert.False(t, f.ShouldHide("at://anything", "did:plc:anyone")) +} + +func TestShouldHide_HiddenURI(t *testing.T) { + ctx := context.Background() + f, _ := LoadFilter(ctx, &mockFilterSource{ + hiddenURIs: []string{"at://did:plc:a/col/1"}, + }) + + assert.True(t, f.ShouldHide("at://did:plc:a/col/1", "")) + assert.False(t, f.ShouldHide("at://did:plc:a/col/2", "")) +} + +func TestShouldHide_BlacklistedAuthor(t *testing.T) { + ctx := context.Background() + f, _ := LoadFilter(ctx, &mockFilterSource{ + blacklistedDIDs: []string{"did:plc:bad"}, + }) + + assert.True(t, f.ShouldHide("", "did:plc:bad")) + assert.False(t, f.ShouldHide("", "did:plc:good")) +} + +func TestShouldHide_BothEmpty(t *testing.T) { + ctx := context.Background() + f, _ := LoadFilter(ctx, &mockFilterSource{}) + + assert.False(t, f.ShouldHide("at://anything", "did:plc:anyone")) +} + +func TestIsBlocked(t *testing.T) { + ctx := context.Background() + f, _ := LoadFilter(ctx, &mockFilterSource{ + blacklistedDIDs: []string{"did:plc:bad"}, + }) + + assert.True(t, f.IsBlocked("did:plc:bad")) + assert.False(t, f.IsBlocked("did:plc:good")) +} + +func TestFilterSlice(t *testing.T) { + type item struct { + uri string + authorDID string + name string + } + + ctx := context.Background() + f, _ := LoadFilter(ctx, &mockFilterSource{ + hiddenURIs: []string{"at://did:plc:a/col/hidden"}, + blacklistedDIDs: []string{"did:plc:bad"}, + }) + + items := []*item{ + {uri: "at://did:plc:a/col/ok", authorDID: "did:plc:good", name: "visible"}, + {uri: "at://did:plc:a/col/hidden", authorDID: "did:plc:good", name: "hidden-record"}, + {uri: "at://did:plc:b/col/ok", authorDID: "did:plc:bad", name: "blocked-author"}, + {uri: "at://did:plc:c/col/ok", authorDID: "did:plc:nice", name: "also-visible"}, + } + + result := FilterSlice(f, items, func(i *item) (string, string) { + return i.uri, i.authorDID + }) + + assert.Len(t, result, 2) + assert.Equal(t, "visible", result[0].name) + assert.Equal(t, "also-visible", result[1].name) +} + +func TestFilterSlice_NilFilter(t *testing.T) { + type item struct{ name string } + items := []*item{{name: "a"}, {name: "b"}} + + result := FilterSlice[*item](nil, items, func(i *item) (string, string) { + return "", "" + }) + + assert.Len(t, result, 2) +} + +func TestLoadFilter_SourceErrors(t *testing.T) { + ctx := context.Background() + + t.Run("hidden URIs error returns partial filter", func(t *testing.T) { + f, err := LoadFilter(ctx, &mockFilterSource{ + hiddenErr: assert.AnError, + blacklistedDIDs: []string{"did:plc:bad"}, + }) + require.NoError(t, err) + // Blacklist still works + assert.True(t, f.IsBlocked("did:plc:bad")) + // Hidden URIs degraded gracefully + assert.False(t, f.ShouldHide("at://anything", "")) + }) + + t.Run("blacklist error returns partial filter", func(t *testing.T) { + f, err := LoadFilter(ctx, &mockFilterSource{ + hiddenURIs: []string{"at://did:plc:a/col/1"}, + blacklistErr: assert.AnError, + }) + require.NoError(t, err) + // Hidden URIs still work + assert.True(t, f.ShouldHide("at://did:plc:a/col/1", "")) + // Blacklist degraded gracefully + assert.False(t, f.IsBlocked("did:plc:bad")) + }) +}