···128128 }
129129}
130130131131+// loadContentFilter creates a ContentFilter from the moderation store.
132132+// Returns nil if moderation is not configured.
133133+func (h *Handler) loadContentFilter(ctx context.Context) *moderation.ContentFilter {
134134+ if h.moderationStore == nil {
135135+ return nil
136136+ }
137137+ f, err := moderation.LoadFilter(ctx, h.moderationStore)
138138+ if err != nil {
139139+ log.Warn().Err(err).Msg("failed to load content filter")
140140+ return nil
141141+ }
142142+ return f
143143+}
144144+131145// validateRKey validates and returns an rkey from a path parameter.
132146// Returns the rkey if valid, or writes an error response and returns empty string if invalid.
133147func validateRKey(w http.ResponseWriter, rkey string) string {
+25
internal/handlers/profile.go
···99 "arabica/internal/atproto"
1010 "arabica/internal/metrics"
1111 "arabica/internal/models"
1212+ "arabica/internal/moderation"
1213 "arabica/internal/web/bff"
1314 "arabica/internal/web/components"
1415 "arabica/internal/web/pages"
···420421 // For now, continue with the DID we have
421422 }
422423424424+ // Check if user is blacklisted
425425+ if cf := h.loadContentFilter(ctx); cf != nil && cf.IsBlocked(did) {
426426+ layoutData, _, _ := h.layoutDataFromRequest(r, "Profile Not Found")
427427+ w.WriteHeader(http.StatusNotFound)
428428+ if err := pages.ProfileNotFound(layoutData).Render(r.Context(), w); err != nil {
429429+ log.Error().Err(err).Msg("Failed to render profile not found page")
430430+ }
431431+ return
432432+ }
433433+423434 // Fetch profile
424435 profile, err := publicClient.GetProfile(ctx, did)
425436 if err != nil {
···532543 }
533544 }
534545546546+ // Check if user is blacklisted
547547+ cf := h.loadContentFilter(ctx)
548548+ if cf != nil && cf.IsBlocked(did) {
549549+ http.Error(w, "User not found", http.StatusNotFound)
550550+ return
551551+ }
552552+535553 // Fetch all user data from their PDS
536554 profileData, err := h.fetchUserProfileData(ctx, did, publicClient)
537555 if err != nil {
538556 log.Error().Err(err).Str("did", did).Msg("Failed to fetch user data for profile partial")
539557 http.Error(w, "Failed to load profile data", http.StatusInternalServerError)
540558 return
559559+ }
560560+561561+ // Filter moderated content from profile
562562+ if cf != nil {
563563+ profileData.Brews = moderation.FilterSlice(cf, profileData.Brews, func(b *models.Brew) (string, string) {
564564+ return atproto.BuildATURI(did, atproto.NSIDBrew, b.RKey), did
565565+ })
541566 }
542567543568 // Check if this is an Arabica user (has records or is registered in feed)
···11+package moderation
22+33+import (
44+ "context"
55+66+ "github.com/rs/zerolog/log"
77+)
88+99+// FilterSource provides the data needed to build a ContentFilter.
1010+// Both moderation.Store and feed.ModerationFilter satisfy this interface.
1111+type FilterSource interface {
1212+ ListHiddenURIs(ctx context.Context) ([]string, error)
1313+ ListBlacklistedDIDs(ctx context.Context) ([]string, error)
1414+}
1515+1616+// ContentFilter holds pre-loaded moderation state for efficient per-item checks.
1717+// Create one per request via LoadFilter, then use ShouldHide or FilterSlice.
1818+type ContentFilter struct {
1919+ hiddenURIs map[string]bool
2020+ blacklisted map[string]bool
2121+}
2222+2323+// LoadFilter bulk-loads hidden URIs and blacklisted DIDs from the source (2 queries).
2424+// Errors from the source are logged and degraded gracefully (partial filtering).
2525+// A nil source returns an empty filter that hides nothing.
2626+func LoadFilter(ctx context.Context, src FilterSource) (*ContentFilter, error) {
2727+ f := &ContentFilter{
2828+ hiddenURIs: make(map[string]bool),
2929+ blacklisted: make(map[string]bool),
3030+ }
3131+3232+ if src == nil {
3333+ return f, nil
3434+ }
3535+3636+ if uris, err := src.ListHiddenURIs(ctx); err != nil {
3737+ log.Warn().Err(err).Msg("moderation: failed to load hidden URIs for filter")
3838+ } else {
3939+ for _, uri := range uris {
4040+ f.hiddenURIs[uri] = true
4141+ }
4242+ }
4343+4444+ if dids, err := src.ListBlacklistedDIDs(ctx); err != nil {
4545+ log.Warn().Err(err).Msg("moderation: failed to load blacklisted DIDs for filter")
4646+ } else {
4747+ for _, did := range dids {
4848+ f.blacklisted[did] = true
4949+ }
5050+ }
5151+5252+ return f, nil
5353+}
5454+5555+// ShouldHide returns true if the record should be hidden, either because its
5656+// URI is in the hidden set or its author DID is blacklisted.
5757+// Empty strings are never matched.
5858+func (f *ContentFilter) ShouldHide(uri, authorDID string) bool {
5959+ if uri != "" && f.hiddenURIs[uri] {
6060+ return true
6161+ }
6262+ if authorDID != "" && f.blacklisted[authorDID] {
6363+ return true
6464+ }
6565+ return false
6666+}
6767+6868+// IsBlocked returns true if the given DID is blacklisted.
6969+func (f *ContentFilter) IsBlocked(did string) bool {
7070+ return did != "" && f.blacklisted[did]
7171+}
7272+7373+// FilterSlice removes items that should be hidden from a slice.
7474+// The getKeys function extracts the AT-URI and author DID from each item.
7575+// A nil filter returns the input unchanged.
7676+func FilterSlice[T any](f *ContentFilter, items []T, getKeys func(T) (uri string, authorDID string)) []T {
7777+ if f == nil {
7878+ return items
7979+ }
8080+8181+ result := make([]T, 0, len(items))
8282+ for _, item := range items {
8383+ uri, did := getKeys(item)
8484+ if !f.ShouldHide(uri, did) {
8585+ result = append(result, item)
8686+ }
8787+ }
8888+ return result
8989+}