diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a01c7cbd1b..1f4dd9e106 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -458,6 +458,7 @@ The command palette is a first-class navigation surface. When you add a new user | `org` | `/org/:org` | `org` | | `~username` | `/~:username` | `username` | | `~username-orgs` | `/~:username/orgs` | `username` | +| `collections` | `/collections` | — | | `collections-new` | `/collections/new` | — | | `collections-identifier-rkey` | `/collections/:identifier/:rkey` | `identifier` (handle or DID), `rkey` | diff --git a/app/components/Collection/ItemList.stories.ts b/app/components/Collection/ItemList.stories.ts index 1569dc6e1b..9c88ddcf42 100644 --- a/app/components/Collection/ItemList.stories.ts +++ b/app/components/Collection/ItemList.stories.ts @@ -12,7 +12,7 @@ const meta = { export default meta type Story = StoryObj -/** Packages with notes, a package missing from the registry, and a nested collection. */ +/** Packages with notes, a package missing from the registry, a nested collection, and a hidden one. */ export const Default: Story = {} /** Packages shown as cards once their metadata has loaded. */ @@ -20,17 +20,15 @@ export const WithPackageCards: Story = { args: { packages: mockPackageCards }, } -/** An included collection that can't be shown. */ -export const UnavailableCollection: Story = { +/** Every reason an included collection can't be shown. */ +export const UnavailableCollections: Story = { args: { - items: [ - { - kind: 'collection', - subjectRef: 'at://did:plc:x/dev.npmx.collection/3m5k5baifas2z', - collection: null, - unavailableReason: 'unresolvable', - }, - ], + items: (['unresolvable', 'hidden'] as const).map(reason => ({ + kind: 'collection' as const, + subjectRef: `at://did:plc:x/dev.npmx.collection/${reason}`, + collection: null, + unavailableReason: reason, + })), }, } diff --git a/app/components/Collection/ItemList.vue b/app/components/Collection/ItemList.vue index eb59e397cf..9e2de503ac 100644 --- a/app/components/Collection/ItemList.vue +++ b/app/components/Collection/ItemList.vue @@ -1,6 +1,6 @@ + + {{ + $t( + 'collections.in_collections', + { count: compactNumberFormatter.format(collection.includedInCount) }, + collection.includedInCount, + ) + }} + diff --git a/app/pages/collections/index.vue b/app/pages/collections/index.vue new file mode 100644 index 0000000000..58dbdb0b82 --- /dev/null +++ b/app/pages/collections/index.vue @@ -0,0 +1,82 @@ + + + diff --git a/app/storybook/mocks/handlers/collections.ts b/app/storybook/mocks/handlers/collections.ts index 9af3de28d1..3638eb8f11 100644 --- a/app/storybook/mocks/handlers/collections.ts +++ b/app/storybook/mocks/handlers/collections.ts @@ -42,6 +42,7 @@ export const mockCollection: CollectionView = { ...mockCollectionSummary, likes: { total: 12, viewerHas: false }, follows: { total: 5, viewerHas: false }, + includedInCount: 2, items: [ { kind: 'package', @@ -77,6 +78,12 @@ export const mockCollection: CollectionView = { itemCount: 2, }, }, + { + kind: 'collection', + subjectRef: 'at://did:plc:spammer/dev.npmx.collection/3m5k5baifas2c', + collection: null, + unavailableReason: 'hidden', + }, ], } diff --git a/docs/content/2.guide/1.features.md b/docs/content/2.guide/1.features.md index 020a5a3764..491236608e 100644 --- a/docs/content/2.guide/1.features.md +++ b/docs/content/2.guide/1.features.md @@ -109,6 +109,7 @@ Collections are curated, ordered lists of npm packages, with an optional note on - **Include other collections** - Add a collection's link to another collection to show it as a linked entry with its author and size - **Duplicate** - Start a new collection from a copy of any collection - **Like and follow** - Like a collection to recommend it, or follow it to keep up with it. Your own collections count as followed +- **Find related collections** - Package pages and collection pages show how many collections include them ## Explore users and organizations diff --git a/docs/content/2.guide/3.url-structure.md b/docs/content/2.guide/3.url-structure.md index 3269730721..ae0d3e6f0a 100644 --- a/docs/content/2.guide/3.url-structure.md +++ b/docs/content/2.guide/3.url-structure.md @@ -40,19 +40,20 @@ npmx.dev also supports shorter, cleaner URLs: npmx.dev keeps package exploration views in routes or query parameters so you can copy and share the exact state you are looking at. -| View | Example | -| ------------------- | --------------------------------- | -| Search results | `/search?q=vue` | -| Package comparison | `/compare?packages=react,preact` | -| Tools | `/tools` | -| A collection | `/collections//` | -| Dependency stats | `/tools/deps-stats` | -| Source file or line | `/package-code/vue/v/3.5.27/...` | -| Generated docs | `/package-docs/ufo/v/1.6.3` | -| Version diff | `/diff/vue/v/3.5.26...3.5.27` | -| Changelog | `/package-changelog/vue/v/3.5.27` | -| Timeline | `/package-timeline/vue/v/3.5.27` | -| Stats | `/package-stats/vue/v/3.5.27` | +| View | Example | +| -------------------------- | --------------------------------- | +| Search results | `/search?q=vue` | +| Package comparison | `/compare?packages=react,preact` | +| Tools | `/tools` | +| A collection | `/collections//` | +| Collections with a package | `/collections?package=vue` | +| Dependency stats | `/tools/deps-stats` | +| Source file or line | `/package-code/vue/v/3.5.27/...` | +| Generated docs | `/package-docs/ufo/v/1.6.3` | +| Version diff | `/diff/vue/v/3.5.26...3.5.27` | +| Changelog | `/package-changelog/vue/v/3.5.27` | +| Timeline | `/package-timeline/vue/v/3.5.27` | +| Stats | `/package-stats/vue/v/3.5.27` | ## Understand URL limitations diff --git a/docs/content/4.integrations/2.collections.md b/docs/content/4.integrations/2.collections.md index b98250996f..c2664165c1 100644 --- a/docs/content/4.integrations/2.collections.md +++ b/docs/content/4.integrations/2.collections.md @@ -7,7 +7,7 @@ navigation: ## Collections live in your account -A collection is an ordered list of npm packages with notes, stored as a `dev.npmx.collection` record in your own atproto repository. npmx.dev is one way to make and view them, not the only one. Write a record with that shape from any app, script or PDS, and it shows up on your npmx.dev profile. +A collection is an ordered list of npm packages with notes, stored as a `dev.npmx.collection` record in your own atproto repository. npmx.dev is one way to make and view them, not the only one. Write a record with that shape from any app, script or PDS, and it shows up on npmx.dev: on your profile and on the pages of the packages it contains. Collections are public, like everything else in an atproto repository. @@ -122,4 +122,6 @@ To edit a collection, use `com.atproto.repo.putRecord` with the same record key, ## When it shows up - **Your profile** reads straight from your PDS, so it updates right away. **The collection page** does too for changes made on npmx.dev; changes from other apps can take up to 5 minutes. -- **Like counts** come from [Constellation](https://constellation.microcosm.blue/), usually within seconds. +- **Package pages** ("in N collections") and like counts come from [Constellation](https://constellation.microcosm.blue/), usually within seconds. + +Collections from accounts that Bluesky's moderation service has labeled as spam or hidden don't appear on npmx.dev. diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 5da0e6d2fb..3b9debdd56 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -482,11 +482,15 @@ "delete_title": "Delete this collection?", "delete_body": "This can't be undone.", "delete_error": "Couldn't delete the collection. Please try again.", + "in_collections": "In {count} collection | In {count} collections", + "containing_package_title": "Collections with {name}", + "including_title": "Collections that include this one", "includes": "Includes", "package_unresolved": "Not found on the npm registry", "unknown_item": "Unrecognized item", "unavailable": { - "unresolvable": "This collection couldn't be loaded." + "unresolvable": "This collection couldn't be loaded.", + "hidden": "This collection is hidden." }, "not_found": "Collection Not Found", "not_found_message": "This collection doesn't exist, was deleted, or is hidden.", diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index acd6106674..b621ba4d29 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -388,11 +388,15 @@ "delete_title": "Supprimer cette collection ?", "delete_body": "Cette action est irréversible.", "delete_error": "Impossible de supprimer la collection. Veuillez réessayer.", + "in_collections": "Dans {count} collection | Dans {count} collections", + "containing_package_title": "Collections avec {name}", + "including_title": "Collections qui incluent celle-ci", "includes": "Inclut", "package_unresolved": "Introuvable sur le registre npm", "unknown_item": "Élément non reconnu", "unavailable": { - "unresolvable": "Impossible de charger cette collection." + "unresolvable": "Impossible de charger cette collection.", + "hidden": "Cette collection est masquée." }, "not_found": "Collection introuvable", "not_found_message": "Cette collection n'existe pas, a été supprimée ou est masquée.", diff --git a/i18n/schema.json b/i18n/schema.json index d45b167a3e..b47acf5b22 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -1450,6 +1450,15 @@ "delete_error": { "type": "string" }, + "in_collections": { + "type": "string" + }, + "containing_package_title": { + "type": "string" + }, + "including_title": { + "type": "string" + }, "includes": { "type": "string" }, @@ -1464,6 +1473,9 @@ "properties": { "unresolvable": { "type": "string" + }, + "hidden": { + "type": "string" } }, "additionalProperties": false diff --git a/server/api/social/collections/containing.get.ts b/server/api/social/collections/containing.get.ts new file mode 100644 index 0000000000..e873140aa2 --- /dev/null +++ b/server/api/social/collections/containing.get.ts @@ -0,0 +1,23 @@ +/** + * GET /api/social/collections/containing?subject=&cursor=&limit= + * + * Collections whose items include `subject`, a package URL or a collection AT-URI. + * `limit=0` returns only the total. + */ +export default defineEventHandler(async event => { + const subject = getQuery(event).subject + if (typeof subject !== 'string' || !parseSubjectRef(subject)) { + throw createError({ status: 400, message: 'Invalid subject' }) + } + + try { + const utils = new CollectionsUtils() + if (getQuery(event).limit === '0') { + return { total: await utils.countCollectionsContaining(subject), items: [] } + } + const { cursor, limit } = getPaginationQuery(event) + return await utils.getCollectionsContaining(subject, limit, cursor) + } catch (error: unknown) { + handleApiError(error, { statusCode: 502, message: 'Failed to get containing collections' }) + } +}) diff --git a/server/utils/atproto/utils/collections.ts b/server/utils/atproto/utils/collections.ts index 178f91b95f..ff05ab6fa2 100644 --- a/server/utils/atproto/utils/collections.ts +++ b/server/utils/atproto/utils/collections.ts @@ -14,9 +14,11 @@ import { parseSubjectRef } from '#shared/utils/collections' import { mapWithConcurrency } from '#shared/utils/async' import { CACHE_MAX_AGE_ONE_MINUTE, COLLECTION_SUBJECT_REF } from '#shared/utils/constants' import type { Backlink, Constellation } from '#shared/utils/constellation' +import { AccountModerationUtils } from './moderation' const HEADERS = { 'User-Agent': 'npmx' } const COLLECTION_NSID = dev.npmx.collection.$nsid +const ITEMS_PATH = 'items[].subjectRef' // Records that point at a collection and are counted once per account. const REACTIONS = { @@ -31,6 +33,8 @@ const CACHE_REACTION_TOTAL_KEY = (reaction: Reaction, uri: string) => `${CACHE_PREFIX}${uri}:${reaction}:total` const CACHE_VIEWER_REACTION_KEY = (reaction: Reaction, uri: string, did: string) => `${CACHE_PREFIX}${uri}:${reaction}:${did}` +const CACHE_CONTAINING_COUNT_KEY = (subjectRef: string) => + `${CACHE_PREFIX}${subjectRef}:containing:total` const CACHE_MAX_AGE = CACHE_MAX_AGE_ONE_MINUTE * 5 // The owner's PDS is whatever their DID doc says, so it may be slow or hostile. @@ -60,6 +64,7 @@ export type CollectionsDeps = { cursor?: string, ) => Promise<{ cursor?: string; records: { rkey: string; subject: string }[] }> packageExists: (packageName: string) => Promise + moderation: Pick } type IndexedCollection = Omit @@ -153,6 +158,7 @@ function defaultDeps(): CollectionsDeps { } }, packageExists: name => fetchNpmPackageExists(name), + moderation: new AccountModerationUtils(), } } @@ -209,10 +215,11 @@ export class CollectionsUtils { viewerDid?: string, ): Promise { const miniDoc = await this.deps.resolveIdentity(identifier) + if (await this.deps.moderation.isHidden(miniDoc.did)) return null const stored = await this.getStoredRecord(miniDoc, rkey) if (!stored) return null - const [items, likes, follows] = await Promise.all([ + const [items, likes, follows, includedInCount] = await Promise.all([ mapWithConcurrency( stored.value.items, item => this.resolveItem(item.subjectRef, item.note), @@ -220,8 +227,9 @@ export class CollectionsUtils { ), this.getReaction('like', stored.uri, viewerDid), this.getReaction('follow', stored.uri, viewerDid), + this.countCollectionsContaining(stored.uri), ]) - return { ...toSummary(miniDoc, rkey, stored), items, likes, follows } + return { ...toSummary(miniDoc, rkey, stored), items, likes, follows, includedInCount } } async getReaction(reaction: Reaction, uri: string, viewerDid?: string): Promise { @@ -317,6 +325,7 @@ export class CollectionsUtils { ({ kind: 'collection', subjectRef, note, collection: null, unavailableReason }) as const try { const miniDoc = await this.deps.resolveIdentity(subject.did) + if (await this.deps.moderation.isHidden(miniDoc.did)) return unavailable('hidden') const stored = await this.getStoredRecord(miniDoc, subject.rkey) if (!stored) return unavailable('unresolvable') return { @@ -336,6 +345,7 @@ export class CollectionsUtils { cursor?: string, ): Promise> { const miniDoc = await this.deps.resolveIdentity(identifier) + if (await this.deps.moderation.isHidden(miniDoc.did)) return { items: [] } const { cursor: nextCursor, records } = await this.deps.listRecords(miniDoc, limit, cursor) return { cursor: nextCursor, @@ -361,6 +371,7 @@ export class CollectionsUtils { cursor?: string, ): Promise> { const miniDoc = await this.deps.resolveIdentity(identifier) + if (await this.deps.moderation.isHidden(miniDoc.did)) return { items: [] } const { cursor: nextCursor, records } = await this.deps.listFollows(miniDoc, limit, cursor) const subjects = new Map() for (const record of records) { @@ -377,21 +388,76 @@ export class CollectionsUtils { } /** - * Turns entries into summaries via `build`, dropping any entry whose author no longer resolves - * (deleted, deactivated) or that `build` rejects. + * Collections whose items reference `subjectRef` (a package URL or a collection AT-URI). + * `total` is Constellation's raw count, so it may include hidden or invalid collections. + */ + async getCollectionsContaining( + subjectRef: string, + limit: number, + cursor?: string, + ): Promise> { + const { data } = await this.deps.constellation.getBackLinks( + subjectRef, + COLLECTION_NSID, + ITEMS_PATH, + limit, + cursor, + false, + [], + 0, + ) + return { + cursor: data.cursor, + total: data.total, + items: await this.hydrateBacklinks(data.records), + } + } + + countCollectionsContaining(subjectRef: string): Promise { + return this.cached(CACHE_CONTAINING_COUNT_KEY(subjectRef), async () => { + const { data } = await this.deps.constellation.getBackLinks( + subjectRef, + COLLECTION_NSID, + ITEMS_PATH, + 1, + undefined, + false, + [], + 0, + ) + return data.total + }) + } + + private hydrateBacklinks(backlinks: Backlink[]): Promise { + const unique = new Map( + backlinks.map(link => [COLLECTION_SUBJECT_REF(link.did, link.rkey), link]), + ).values() + return this.hydrate([...unique], async (miniDoc, link) => { + const stored = await this.getStoredRecord(miniDoc, link.rkey) + return stored && toSummary(miniDoc, link.rkey, stored) + }) + } + + /** + * Turns entries into summaries via `build`, dropping hidden authors and any entry whose author + * no longer resolves (deleted, deactivated) or that `build` rejects. */ async hydrate( entries: T[], build: (miniDoc: MiniDoc, entry: T) => Promise | CollectionSummary, ): Promise { + const hidden = await this.deps.moderation.getHiddenDids(entries.map(entry => entry.did)) const summaries = await Promise.all( - entries.map(async entry => { - try { - return await build(await this.deps.resolveIdentity(entry.did), entry) - } catch { - return null - } - }), + entries + .filter(entry => !hidden.has(entry.did)) + .map(async entry => { + try { + return await build(await this.deps.resolveIdentity(entry.did), entry) + } catch { + return null + } + }), ) return summaries.filter(summary => summary !== null) } diff --git a/server/utils/atproto/utils/moderation.ts b/server/utils/atproto/utils/moderation.ts new file mode 100644 index 0000000000..7bacb0c22e --- /dev/null +++ b/server/utils/atproto/utils/moderation.ts @@ -0,0 +1,93 @@ +import { Client } from '@atproto/lex' +import * as app from '#shared/types/lexicons/app' +import { BLUESKY_API, CACHE_MAX_AGE_ONE_HOUR } from '#shared/utils/constants' + +// Bluesky's own moderation service. We don't run a labeler, so we borrow its account labels. +export const BLUESKY_MODERATION_DID = 'did:plc:ar7c4by46qjdydhdevvrndac' +const HIDING_ACCOUNT_LABELS: ReadonlySet = new Set(['!hide', '!takedown', 'spam']) + +const CACHE_KEY = (did: string) => `atproto-moderation:${did}:hidden` +// getProfiles accepts at most 25 actors per call +const PROFILES_BATCH_SIZE = 25 +const LABELS_TIMEOUT_MS = 3_000 + +type Label = { src: string; val: string; neg?: boolean; exp?: string } +export type FetchProfileLabels = ( + dids: string[], +) => Promise> + +const defaultFetchProfileLabels: FetchProfileLabels = async dids => { + const client = new Client({ service: BLUESKY_API }) + const { profiles } = await client.call( + app.bsky.actor.getProfiles, + { actors: dids as `did:${string}:${string}`[] }, + // Collection reads wait on this; past the timeout we fail open + { signal: AbortSignal.timeout(LABELS_TIMEOUT_MS) }, + ) + return profiles +} + +export function isHiddenByLabels(labels: Label[] | undefined, now = Date.now()): boolean { + return (labels ?? []).some( + label => + label.src === BLUESKY_MODERATION_DID && + !label.neg && + HIDING_ACCOUNT_LABELS.has(label.val) && + (!label.exp || Date.parse(label.exp) > now), + ) +} + +/** + * Decides which accounts' collections to hide, based on Bluesky moderation labels. + * Fails open: if the AppView is unreachable we show content rather than hide everything. + */ +export class AccountModerationUtils { + private readonly fetchProfileLabels: FetchProfileLabels + private readonly cache: CacheAdapter + + constructor(deps?: { fetchProfileLabels?: FetchProfileLabels; cache?: CacheAdapter }) { + this.fetchProfileLabels = deps?.fetchProfileLabels ?? defaultFetchProfileLabels + this.cache = deps?.cache ?? getCacheAdapter('generic') + } + + async getHiddenDids(dids: string[]): Promise> { + const unique = [...new Set(dids)] + const hidden = new Set() + const uncached: string[] = [] + + await Promise.all( + unique.map(async did => { + // Wrapped: RedisCacheAdapter treats a bare `false` as a miss + const cached = await this.cache.get<{ hidden: boolean }>(CACHE_KEY(did)) + if (!cached) uncached.push(did) + else if (cached.hidden) hidden.add(did) + }), + ) + + for (let i = 0; i < uncached.length; i += PROFILES_BATCH_SIZE) { + const batch = uncached.slice(i, i + PROFILES_BATCH_SIZE) + let profiles: Awaited> + try { + profiles = await this.fetchProfileLabels(batch) + } catch (error) { + console.warn('[moderation] Failed to fetch account labels', error) + continue + } + const labelsByDid = new Map(profiles.map(profile => [profile.did, profile.labels])) + await Promise.all( + batch.map(async did => { + // An account missing from the AppView isn't evidence of anything; don't hide it. + const isHidden = isHiddenByLabels(labelsByDid.get(did)) + if (isHidden) hidden.add(did) + await this.cache.set(CACHE_KEY(did), { hidden: isHidden }, CACHE_MAX_AGE_ONE_HOUR) + }), + ) + } + + return hidden + } + + async isHidden(did: string): Promise { + return (await this.getHiddenDids([did])).has(did) + } +} diff --git a/shared/types/social.ts b/shared/types/social.ts index 9dcade25f4..b37ccd1ccd 100644 --- a/shared/types/social.ts +++ b/shared/types/social.ts @@ -26,7 +26,7 @@ export type LikesLeaderboardEntry = { homepageLogoHeight: number | null } -/** A collection as listed (profile, browse), without resolved items */ +/** A collection as listed (profile, package page, browse), without resolved items */ export type CollectionSummary = { uri: string cid: string @@ -40,7 +40,7 @@ export type CollectionSummary = { } /** Why an included collection could not be shown */ -export type CollectionUnavailableReason = 'unresolvable' +export type CollectionUnavailableReason = 'unresolvable' | 'hidden' export type CollectionItemView = | { @@ -81,6 +81,8 @@ export type CollectionView = CollectionSummary & { items: CollectionItemView[] likes: ReactionState follows: ReactionState + // How many other collections include this one + includedInCount: number } /** diff --git a/shared/utils/constellation.ts b/shared/utils/constellation.ts index 01396e4222..e2a48bed57 100644 --- a/shared/utils/constellation.ts +++ b/shared/utils/constellation.ts @@ -73,7 +73,7 @@ export class Constellation { // constellation API endpoint, and type casting the response. const source = encodeURIComponent(`${collection}:${recordPath}`) let urlToCall = `https://${CONSTELLATION_HOST}/xrpc/blue.microcosm.links.getBacklinks?subject=${encodeURIComponent(subject)}&source=${source}&limit=${limit}` - if (cursor) urlToCall += `&cursor=${cursor}` + if (cursor) urlToCall += `&cursor=${encodeURIComponent(cursor)}` if (reverse) urlToCall += '&reverse=true' filterByDids.forEach(did => (urlToCall += `&did=${did}`)) @@ -98,7 +98,7 @@ export class Constellation { ttl: number | undefined = undefined, ) { let urlToCall = `https://${CONSTELLATION_HOST}/links/distinct-dids?target=${encodeURIComponent(target)}&collection=${collection}&path=${recordPath}&limit=${limit}` - if (cursor) urlToCall += `&cursor=${cursor}` + if (cursor) urlToCall += `&cursor=${encodeURIComponent(cursor)}` return await this.cachedFetch(urlToCall, { headers: HEADERS }, ttl) } diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index 7659ebcfa1..983633414c 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -370,6 +370,7 @@ import CollectionItemList from '~/components/Collection/ItemList.vue' import CollectionLikeButton from '~/components/Collection/LikeButton.vue' import CollectionFollowButton from '~/components/Collection/FollowButton.vue' import CollectionYourData from '~/components/Collection/YourData.vue' +import PackageCollectionsCount from '~/components/Package/CollectionsCount.vue' import type { CollectionItemView, CollectionSummary } from '#shared/types/social' import SizeIncrease from '~/components/Package/SizeIncrease.vue' import SizeDecrease from '~/components/Package/SizeDecrease.vue' @@ -1312,9 +1313,9 @@ describe('component accessibility audits', () => { }, { kind: 'collection', - subjectRef: 'at://did:plc:gone/dev.npmx.collection/3m5k5baifas2c', + subjectRef: 'at://did:plc:mallory/dev.npmx.collection/3m5k5baifas2c', collection: null, - unavailableReason: 'unresolvable', + unavailableReason: 'hidden', }, { kind: 'unknown', subjectRef: 'https://example.com/whatever' }, ] @@ -1443,6 +1444,18 @@ describe('component accessibility audits', () => { const results = await runAxe(component) expect(results.violations).toEqual([]) }) + + it('PackageCollectionsCount should have no accessibility violations', async () => { + registerEndpoint('/api/social/collections/containing', () => ({ total: 3, items: [] })) + const component = await mountSuspended(PackageCollectionsCount, { + props: { packageName: 'zod' }, + }) + await vi.waitFor(() => { + expect(component.text()).toContain('3') + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) }) describe('PackageLikeCard', () => { diff --git a/test/unit/server/utils/collections.spec.ts b/test/unit/server/utils/collections.spec.ts index 0a85e2d4d7..79c007d7fa 100644 --- a/test/unit/server/utils/collections.spec.ts +++ b/test/unit/server/utils/collections.spec.ts @@ -1,10 +1,12 @@ -import { describe, expect, it, vi } from 'vite-plus/test' +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import type { CacheAdapter } from '#server/utils/cache/shared' import type { MiniDoc } from '#shared/types/social' import type { Backlink } from '#shared/utils/constellation' import { COLLECTION_SUBJECT_REF, PACKAGE_SUBJECT_REF } from '#shared/utils/constants' const { CollectionsUtils } = await import('#server/utils/atproto/utils/collections') +const { AccountModerationUtils, BLUESKY_MODERATION_DID, isHiddenByLabels } = + await import('#server/utils/atproto/utils/moderation') type Deps = NonNullable[0]> @@ -67,6 +69,7 @@ function setup(records: RecordMap, overrides: Partial = {}) { listRecords: async () => ({ records: [] }), listFollows: async () => ({ records: [] }), packageExists: async name => name !== 'does-not-exist', + moderation: { getHiddenDids: async () => new Set(), isHidden: async () => false }, ...overrides, } return { utils: new CollectionsUtils(deps), constellation, fetchRecord } @@ -108,6 +111,19 @@ describe('CollectionsUtils.getCollection', () => { expect(await utils.getCollection(ALICE, RKEY_B)).toBeNull() }) + it('returns null when the author is hidden', async () => { + const { utils } = setup( + { [key(MALLORY, RKEY_A)]: record('x', []) }, + { + moderation: { + getHiddenDids: async () => new Set([MALLORY]), + isHidden: async did => did === MALLORY, + }, + }, + ) + expect(await utils.getCollection(MALLORY, RKEY_A)).toBeNull() + }) + it("names records by the owner's DID, not the URI their PDS reports", async () => { const { utils } = setup( {}, @@ -150,19 +166,32 @@ describe('CollectionsUtils.getCollection', () => { expect(fetchRecord).toHaveBeenCalledTimes(2) }) - it('marks unresolvable nested collections', async () => { - const { utils } = setup({ - [key(ALICE, RKEY_A)]: record('A', [COLLECTION_SUBJECT_REF(BOB, RKEY_C)]), - }) + it('marks hidden and unresolvable nested collections', async () => { + const { utils } = setup( + { + [key(ALICE, RKEY_A)]: record('A', [ + COLLECTION_SUBJECT_REF(MALLORY, RKEY_B), + COLLECTION_SUBJECT_REF(BOB, RKEY_C), + ]), + [key(MALLORY, RKEY_B)]: record('spam', []), + }, + { + moderation: { + getHiddenDids: async dids => new Set(dids.filter(did => did === MALLORY)), + isHidden: async did => did === MALLORY, + }, + }, + ) const view = await utils.getCollection(ALICE, RKEY_A) expect(view?.items).toEqual([ + expect.objectContaining({ collection: null, unavailableReason: 'hidden' }), expect.objectContaining({ collection: null, unavailableReason: 'unresolvable' }), ]) }) - it('aggregates like and follow stats', async () => { + it('aggregates like, follow and included-in stats', async () => { const { utils, constellation } = setup({ [key(ALICE, RKEY_A)]: record('A', []) }) constellation.getLinksDistinctDids.mockImplementation(async (...args: unknown[]) => ({ data: { @@ -191,6 +220,7 @@ describe('CollectionsUtils.getCollection', () => { expect(view).toMatchObject({ likes: { total: 7, viewerHas: true }, follows: { total: 2, viewerHas: false }, + includedInCount: 3, }) expect(constellation.getLinksDistinctDids).toHaveBeenCalledWith( COLLECTION_SUBJECT_REF(ALICE, RKEY_A), @@ -294,6 +324,53 @@ describe('CollectionsUtils.listFollowedCollections', () => { }) }) +describe('CollectionsUtils.getCollectionsContaining', () => { + it('hydrates backlinks, dedupes, and drops hidden or missing collections', async () => { + const { utils, constellation } = setup( + { + [key(ALICE, RKEY_A)]: record('Alice', [PACKAGE_SUBJECT_REF('zod')]), + [key(MALLORY, RKEY_A)]: record('Spam', [PACKAGE_SUBJECT_REF('zod')]), + }, + { + moderation: { + getHiddenDids: async dids => new Set(dids.filter(did => did === MALLORY)), + isHidden: async did => did === MALLORY, + }, + }, + ) + constellation.getBackLinks.mockResolvedValue({ + data: { + total: 4, + cursor: 'next', + records: [ + { did: ALICE, collection: 'dev.npmx.collection', rkey: RKEY_A }, + { did: ALICE, collection: 'dev.npmx.collection', rkey: RKEY_A }, + { did: MALLORY, collection: 'dev.npmx.collection', rkey: RKEY_A }, + { did: BOB, collection: 'dev.npmx.collection', rkey: RKEY_B }, + ], + }, + isStale: false, + cachedAt: null, + }) + + const result = await utils.getCollectionsContaining(PACKAGE_SUBJECT_REF('zod'), 10) + + expect(result.total).toBe(4) + expect(result.cursor).toBe('next') + expect(result.items.map(item => item.name)).toEqual(['Alice']) + expect(constellation.getBackLinks).toHaveBeenCalledWith( + PACKAGE_SUBJECT_REF('zod'), + 'dev.npmx.collection', + 'items[].subjectRef', + 10, + undefined, + false, + [], + 0, + ) + }) +}) + describe('CollectionsUtils.listUserCollections', () => { it('returns summaries with the record key', async () => { const { utils } = setup( @@ -317,4 +394,64 @@ describe('CollectionsUtils.listUserCollections', () => { items: [expect.objectContaining({ rkey: RKEY_A, name: 'A', itemCount: 1, did: ALICE })], }) }) + + it('returns nothing for hidden accounts', async () => { + const listRecords = vi.fn() + const { utils } = setup( + {}, + { + listRecords, + moderation: { getHiddenDids: async () => new Set([MALLORY]), isHidden: async () => true }, + }, + ) + expect(await utils.listUserCollections(MALLORY, 20)).toEqual({ items: [] }) + expect(listRecords).not.toHaveBeenCalled() + }) +}) + +const label = (val: string, extra: Record = {}) => ({ + src: BLUESKY_MODERATION_DID, + val, + ...extra, +}) + +describe('AccountModerationUtils', () => { + it('hides only on Bluesky moderation labels that hide accounts', () => { + expect(isHiddenByLabels([label('spam')])).toBe(true) + expect(isHiddenByLabels([label('!hide')])).toBe(true) + expect(isHiddenByLabels([label('spam', { neg: true })])).toBe(false) + expect(isHiddenByLabels([label('spam', { exp: '2000-01-01T00:00:00Z' })])).toBe(false) + expect(isHiddenByLabels([{ src: 'did:plc:someone-else', val: 'spam' }])).toBe(false) + expect(isHiddenByLabels([label('porn')])).toBe(false) + expect(isHiddenByLabels(undefined)).toBe(false) + }) + + it('batches lookups, caches results, and fails open', async () => { + const fetchProfileLabels = vi.fn(async (dids: string[]) => + dids.map(did => ({ did, labels: did === MALLORY ? [label('spam')] : [] })), + ) + const utils = new AccountModerationUtils({ fetchProfileLabels, cache: memoryCache() }) + + expect(await utils.getHiddenDids([ALICE, MALLORY, ALICE])).toEqual(new Set([MALLORY])) + expect(await utils.isHidden(MALLORY)).toBe(true) + expect(fetchProfileLabels).toHaveBeenCalledTimes(1) + expect(fetchProfileLabels).toHaveBeenCalledWith([ALICE, MALLORY]) + + const failing = new AccountModerationUtils({ + fetchProfileLabels: async () => Promise.reject(new Error('down')), + cache: memoryCache(), + }) + expect(await failing.getHiddenDids([MALLORY])).toEqual(new Set()) + }) + + it('splits large lookups into batches of 25', async () => { + const fetchProfileLabels = vi.fn(async (dids: string[]) => dids.map(did => ({ did }))) + const utils = new AccountModerationUtils({ fetchProfileLabels, cache: memoryCache() }) + await utils.getHiddenDids(Array.from({ length: 30 }, (_, i) => `did:plc:${i}`)) + expect(fetchProfileLabels.mock.calls.map(([dids]) => dids.length)).toEqual([25, 5]) + }) +}) + +beforeEach(() => { + vi.restoreAllMocks() })