You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

243 lines
8.2 KiB

import {
READ_ONLY_RELAY_URLS,
relayFilterIncludesSocialKindBlockedKind
} from '@/constants'
import { relayAllowsPublishKind } from '@/lib/relay-publish-filter'
import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr'
import { getViewerRelayStackNostrLandAggrEligible } from '@/lib/nostr-land-relay-eligibility'
import {
relayFiltersUseCapitalLetterTagKeys,
relayUrlsStripExtendedTagReqBlocked
} from '@/lib/relay-extended-tag-req-blocks'
import { isRelayBlockedByUser } from '@/lib/relay-blocked'
import { isSocialKindBlockedRelayUrl } from '@/lib/social-kind-blocked-relays'
import { isLocalNetworkUrl, normalizeHttpRelayUrl, normalizeRelayUrlByScheme } from '@/lib/url'
import type { TSubRequestFilter } from '@/types'
export type FeedRelayOperation = 'read' | 'write' | 'publish-picker' | 'favorites-feed'
export type FeedRelayDropReason =
| 'invalid'
| 'duplicate'
| 'user-blocked'
| 'read-only-for-write'
| 'profile-index-for-write'
| 'social-kind-blocked'
| 'extended-tag-blocked'
| 'third-party-local'
| 'over-cap'
| 'favorites-feed-aggr'
export type FeedRelayLayerSource =
| 'explicit'
| 'viewer-read'
| 'viewer-write'
| 'author-read'
| 'author-write'
| 'favorites'
| 'fast-read'
| 'fast-write'
| 'read-only'
| 'search'
| 'cache'
| 'http-index'
| 'relay-hint'
| 'seen-on'
| 'fallback'
export type FeedRelayLayer = {
source: FeedRelayLayerSource | string
urls: readonly string[]
/**
* True when the layer is explicitly selected by the viewer for this surface
* (for example a single-relay feed). Explicit single-relay reads can opt out
* of local/social stripping that would otherwise hide the selected relay.
*/
explicit?: boolean
}
export type FeedRelayDrop = {
url: string
normalizedUrl: string
source: FeedRelayLayerSource | string
reason: FeedRelayDropReason
}
export type FeedRelayPolicyContext = {
operation: FeedRelayOperation
blockedRelays?: readonly string[]
filters?: readonly TSubRequestFilter[]
eventKind?: number
maxRelays?: number
/**
* Default: for `operation === 'read'`, prepend {@link AGGR_NOSTR_LAND_WSS} only when the viewer has a
* `nostr.land` host in their relay stack (see {@link getViewerRelayStackNostrLandAggrEligible}) or this
* flag is set true. `favorites-feed` never prepends. Use `nostrLandAggr: 'always'|'never'` to override.
*/
nostrLandAggr?: 'default' | 'always' | 'never'
/**
* Per-call override for read-surface aggr eligibility. When omitted, uses the global synced flag from
* {@link syncViewerRelayStackNostrLandAggrEligible}.
*/
nostrLandAggrEligible?: boolean
applySocialKindBlockedFilter?: boolean
applyExtendedTagBlockedFilter?: boolean
preserveSingleExplicitRelay?: boolean
socialKindBlockedExemptRelays?: readonly string[]
allowThirdPartyLocalRelays?: boolean
}
export type FeedRelayPolicyResult = {
urls: string[]
dropped: FeedRelayDrop[]
}
function canonicalRelayUrl(url: string | undefined | null, layerSource?: FeedRelayLayerSource | string): string {
return normalizedRelayUrl(url ?? '', layerSource).toLowerCase()
}
function normalizedRelayUrl(url: string, layerSource?: FeedRelayLayerSource | string): string {
if (layerSource === 'http-index') return normalizeHttpRelayUrl(url) || url.trim()
return normalizeRelayUrlByScheme(url) || url.trim()
}
function normalizedSet(urls: readonly string[] | undefined): Set<string> {
return new Set((urls ?? []).map((u) => canonicalRelayUrl(u)).filter(Boolean))
}
function shouldApplySocialFilter(ctx: FeedRelayPolicyContext): boolean {
if (ctx.applySocialKindBlockedFilter !== undefined) return ctx.applySocialKindBlockedFilter
if (ctx.eventKind !== undefined) return relayFilterIncludesSocialKindBlockedKind({ kinds: [ctx.eventKind], limit: 1 })
return (ctx.filters ?? []).some((filter) => relayFilterIncludesSocialKindBlockedKind(filter))
}
function shouldApplyExtendedTagFilter(ctx: FeedRelayPolicyContext): boolean {
if (ctx.applyExtendedTagBlockedFilter !== undefined) return ctx.applyExtendedTagBlockedFilter
return (ctx.filters ?? []).some((filter) => relayFiltersUseCapitalLetterTagKeys([filter]))
}
function nostrLandAggrEligibleEffective(ctx: FeedRelayPolicyContext): boolean {
if (ctx.nostrLandAggrEligible !== undefined) return ctx.nostrLandAggrEligible
return getViewerRelayStackNostrLandAggrEligible()
}
function shouldEnsureAggr(ctx: FeedRelayPolicyContext): boolean {
if (ctx.nostrLandAggr === 'always') return true
if (ctx.nostrLandAggr === 'never') return false
if (ctx.operation === 'favorites-feed') return false
if (ctx.operation === 'read') return nostrLandAggrEligibleEffective(ctx)
return false
}
function isReadOnlyRelay(norm: string): boolean {
return normalizedSet(READ_ONLY_RELAY_URLS).has(norm)
}
function isSocialKindBlockedRelay(norm: string): boolean {
return isSocialKindBlockedRelayUrl(norm)
}
function isExtendedTagBlockedRelay(norm: string): boolean {
const stripped = relayUrlsStripExtendedTagReqBlocked([norm])
return stripped.length === 0
}
function addDrop(
dropped: FeedRelayDrop[],
url: string,
source: FeedRelayLayerSource | string,
reason: FeedRelayDropReason
) {
dropped.push({
url,
normalizedUrl: normalizedRelayUrl(url),
source,
reason
})
}
export function applyFeedRelayPolicy(
inputLayers: readonly FeedRelayLayer[],
context: FeedRelayPolicyContext
): FeedRelayPolicyResult {
const socialExempt = normalizedSet(context.socialKindBlockedExemptRelays)
const socialFilter = shouldApplySocialFilter(context)
const extendedFilter = shouldApplyExtendedTagFilter(context)
const max = context.maxRelays ?? Number.POSITIVE_INFINITY
const layers: FeedRelayLayer[] = shouldEnsureAggr(context)
? [{ source: 'read-only', urls: [AGGR_NOSTR_LAND_WSS] }, ...inputLayers]
: [...inputLayers]
const seen = new Set<string>()
const dropped: FeedRelayDrop[] = []
const urls: string[] = []
for (const layer of layers) {
for (const raw of layer.urls) {
const normalized = normalizedRelayUrl(raw, layer.source)
const key = canonicalRelayUrl(normalized, layer.source)
if (!normalized || !key) {
addDrop(dropped, raw, layer.source, 'invalid')
continue
}
if (seen.has(key)) {
addDrop(dropped, normalized, layer.source, 'duplicate')
continue
}
if (isRelayBlockedByUser(normalized, context.blockedRelays)) {
addDrop(dropped, normalized, layer.source, 'user-blocked')
continue
}
if (context.operation === 'favorites-feed' && key === canonicalRelayUrl(AGGR_NOSTR_LAND_WSS)) {
addDrop(dropped, normalized, layer.source, 'favorites-feed-aggr')
continue
}
if (
(context.operation === 'write' || context.operation === 'publish-picker') &&
isReadOnlyRelay(key)
) {
addDrop(dropped, normalized, layer.source, 'read-only-for-write')
continue
}
if (
(context.operation === 'write' || context.operation === 'publish-picker') &&
context.eventKind !== undefined &&
!relayAllowsPublishKind(normalized, context.eventKind)
) {
addDrop(dropped, normalized, layer.source, 'profile-index-for-write')
continue
}
if (
socialFilter &&
isSocialKindBlockedRelay(key) &&
!socialExempt.has(key) &&
!(context.preserveSingleExplicitRelay && layer.explicit && layer.urls.length === 1)
) {
addDrop(dropped, normalized, layer.source, 'social-kind-blocked')
continue
}
if (extendedFilter && isExtendedTagBlockedRelay(normalized)) {
addDrop(dropped, normalized, layer.source, 'extended-tag-blocked')
continue
}
if (!context.allowThirdPartyLocalRelays && isLocalNetworkUrl(normalized) && !layer.explicit) {
addDrop(dropped, normalized, layer.source, 'third-party-local')
continue
}
if (urls.length >= max) {
addDrop(dropped, normalized, layer.source, 'over-cap')
continue
}
seen.add(key)
urls.push(normalized)
}
}
return { urls, dropped }
}
export function feedRelayPolicyUrls(
layers: readonly FeedRelayLayer[],
context: FeedRelayPolicyContext
): string[] {
return applyFeedRelayPolicy(layers, context).urls
}