diff --git a/packages/bsky/src/api/app/bsky/actor/getProfile.ts b/packages/bsky/src/api/app/bsky/actor/getProfile.ts index f58ecc7dbee..fa2226fb0ba 100644 --- a/packages/bsky/src/api/app/bsky/actor/getProfile.ts +++ b/packages/bsky/src/api/app/bsky/actor/getProfile.ts @@ -16,14 +16,11 @@ export default function (server: Server, ctx: AppContext) { server.app.bsky.actor.getProfile({ auth: ctx.authVerifier.optionalStandardOrRole, handler: async ({ auth, params, req }) => { - const { viewer, canViewTakedowns } = ctx.authVerifier.parseCreds(auth) + const { viewer, includeTakedowns } = ctx.authVerifier.parseCreds(auth) const labelers = ctx.reqLabelers(req) - const hydrateCtx = { labelers, viewer } + const hydrateCtx = { labelers, viewer, includeTakedowns } - const result = await getProfile( - { ...params, hydrateCtx, canViewTakedowns }, - ctx, - ) + const result = await getProfile({ ...params, hydrateCtx }, ctx) const repoRev = await ctx.hydrator.actor.getRepoRevSafe(viewer) @@ -57,11 +54,10 @@ const hydration = async (input: { skeleton: SkeletonState }) => { const { ctx, params, skeleton } = input - return ctx.hydrator.hydrateProfilesDetailed( - [skeleton.did], - params.hydrateCtx, - true, - ) + return ctx.hydrator.hydrateProfilesDetailed([skeleton.did], { + ...params.hydrateCtx, + includeTakedowns: true, + }) } const presentation = (input: { @@ -75,7 +71,7 @@ const presentation = (input: { if (!profile) { throw new InvalidRequestError('Profile not found') } else if ( - !params.canViewTakedowns && + !params.hydrateCtx.includeTakedowns && ctx.views.actorIsTakendown(skeleton.did, hydration) ) { throw new InvalidRequestError( @@ -93,7 +89,6 @@ type Context = { type Params = QueryParams & { hydrateCtx: HydrateCtx - canViewTakedowns: boolean } type SkeletonState = { did: string } diff --git a/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts b/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts index 451fcedcb5e..ba216eb6bbc 100644 --- a/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts +++ b/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts @@ -66,11 +66,7 @@ const hydration = async (input: { skeleton: Skeleton }) => { const { ctx, params, skeleton } = input - return ctx.hydrator.hydrateProfilesDetailed( - skeleton.dids, - params.hydrateCtx, - true, - ) + return ctx.hydrator.hydrateProfilesDetailed(skeleton.dids, params.hydrateCtx) } const noBlocksOrMutes = (input: { diff --git a/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts b/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts index eb686b80910..87661bc1ee4 100644 --- a/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts @@ -28,14 +28,11 @@ export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getAuthorFeed({ auth: ctx.authVerifier.optionalStandardOrRole, handler: async ({ params, auth, req }) => { - const { viewer, canViewTakedowns } = ctx.authVerifier.parseCreds(auth) + const { viewer, includeTakedowns } = ctx.authVerifier.parseCreds(auth) const labelers = ctx.reqLabelers(req) - const hydrateCtx = { labelers, viewer } + const hydrateCtx = { labelers, viewer, includeTakedowns } - const result = await getAuthorFeed( - { ...params, hydrateCtx, includeTakedowns: canViewTakedowns }, - ctx, - ) + const result = await getAuthorFeed({ ...params, hydrateCtx }, ctx) const repoRev = await ctx.hydrator.actor.getRepoRevSafe(viewer) @@ -69,7 +66,7 @@ export const skeleton = async (inputs: { } const actors = await ctx.hydrator.actor.getActors( [did], - params.includeTakedowns, + params.hydrateCtx.includeTakedowns, ) const actor = actors.get(did) if (!actor) { @@ -103,11 +100,7 @@ const hydration = async (inputs: { }): Promise => { const { ctx, params, skeleton } = inputs const [feedPostState, profileViewerState] = await Promise.all([ - ctx.hydrator.hydrateFeedItems( - skeleton.items, - params.hydrateCtx, - params.includeTakedowns, - ), + ctx.hydrator.hydrateFeedItems(skeleton.items, params.hydrateCtx), ctx.hydrator.hydrateProfileViewers([skeleton.actor.did], params.hydrateCtx), ]) return mergeStates(feedPostState, profileViewerState) @@ -163,7 +156,6 @@ type Context = { type Params = QueryParams & { hydrateCtx: HydrateCtx - includeTakedowns: boolean } type Skeleton = { diff --git a/packages/bsky/src/api/app/bsky/graph/getFollowers.ts b/packages/bsky/src/api/app/bsky/graph/getFollowers.ts index 42e31f7288f..a7fe741e18d 100644 --- a/packages/bsky/src/api/app/bsky/graph/getFollowers.ts +++ b/packages/bsky/src/api/app/bsky/graph/getFollowers.ts @@ -29,14 +29,11 @@ export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getFollowers({ auth: ctx.authVerifier.optionalStandardOrRole, handler: async ({ params, auth, req }) => { - const { viewer, canViewTakedowns } = ctx.authVerifier.parseCreds(auth) + const { viewer, includeTakedowns } = ctx.authVerifier.parseCreds(auth) const labelers = ctx.reqLabelers(req) - const hydrateCtx = { labelers, viewer } + const hydrateCtx = { labelers, viewer, includeTakedowns } - const result = await getFollowers( - { ...params, hydrateCtx, canViewTakedowns }, - ctx, - ) + const result = await getFollowers({ ...params, hydrateCtx }, ctx) return { encoding: 'application/json', @@ -85,7 +82,6 @@ const hydration = async ( const profileState = await ctx.hydrator.hydrateProfiles( dids, params.hydrateCtx, - params.canViewTakedowns, ) return mergeStates(followState, profileState) } @@ -112,13 +108,16 @@ const presentation = ( ctx.views.actorIsTakendown(did, hydration) const subject = ctx.views.profile(subjectDid, hydration) - if (!subject || (!params.canViewTakedowns && isTakendown(subjectDid))) { + if ( + !subject || + (!params.hydrateCtx.includeTakedowns && isTakendown(subjectDid)) + ) { throw new InvalidRequestError(`Actor not found: ${params.actor}`) } const followers = mapDefined(followUris, (followUri) => { const followerDid = didFromUri(followUri) - if (!params.canViewTakedowns && isTakendown(followerDid)) { + if (!params.hydrateCtx.includeTakedowns && isTakendown(followerDid)) { return } return ctx.views.profile(didFromUri(followUri), hydration) @@ -134,7 +133,6 @@ type Context = { type Params = QueryParams & { hydrateCtx: HydrateCtx - canViewTakedowns: boolean } type SkeletonState = { diff --git a/packages/bsky/src/api/app/bsky/graph/getFollows.ts b/packages/bsky/src/api/app/bsky/graph/getFollows.ts index cfe8e220cc7..d5a6ba233f9 100644 --- a/packages/bsky/src/api/app/bsky/graph/getFollows.ts +++ b/packages/bsky/src/api/app/bsky/graph/getFollows.ts @@ -23,15 +23,12 @@ export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getFollows({ auth: ctx.authVerifier.optionalStandardOrRole, handler: async ({ params, auth, req }) => { - const { viewer, canViewTakedowns } = ctx.authVerifier.parseCreds(auth) + const { viewer, includeTakedowns } = ctx.authVerifier.parseCreds(auth) const labelers = ctx.reqLabelers(req) - const hydrateCtx = { labelers, viewer } + const hydrateCtx = { labelers, viewer, includeTakedowns } // @TODO ensure canViewTakedowns gets threaded through and applied properly - const result = await getFollows( - { ...params, hydrateCtx, canViewTakedowns }, - ctx, - ) + const result = await getFollows({ ...params, hydrateCtx }, ctx) return { encoding: 'application/json', @@ -80,7 +77,6 @@ const hydration = async ( const profileState = await ctx.hydrator.hydrateProfiles( dids, params.hydrateCtx, - params.canViewTakedowns, ) return mergeStates(followState, profileState) } @@ -109,14 +105,17 @@ const presentation = ( ctx.views.actorIsTakendown(did, hydration) const subject = ctx.views.profile(subjectDid, hydration) - if (!subject || (!params.canViewTakedowns && isTakendown(subjectDid))) { + if ( + !subject || + (!params.hydrateCtx.includeTakedowns && isTakendown(subjectDid)) + ) { throw new InvalidRequestError(`Actor not found: ${params.actor}`) } const follows = mapDefined(followUris, (followUri) => { const followDid = hydration.follows?.get(followUri)?.record.subject if (!followDid) return - if (!params.canViewTakedowns && isTakendown(followDid)) { + if (!params.hydrateCtx.includeTakedowns && isTakendown(followDid)) { return } return ctx.views.profile(followDid, hydration) @@ -132,7 +131,6 @@ type Context = { type Params = QueryParams & { hydrateCtx: HydrateCtx - canViewTakedowns: boolean } type SkeletonState = { diff --git a/packages/bsky/src/api/com/atproto/admin/getAccountInfos.ts b/packages/bsky/src/api/com/atproto/admin/getAccountInfos.ts index 971839d5afd..c10b55d1048 100644 --- a/packages/bsky/src/api/com/atproto/admin/getAccountInfos.ts +++ b/packages/bsky/src/api/com/atproto/admin/getAccountInfos.ts @@ -8,16 +8,16 @@ export default function (server: Server, ctx: AppContext) { auth: ctx.authVerifier.optionalStandardOrRole, handler: async ({ params, auth }) => { const { dids } = params - const { canViewTakedowns } = ctx.authVerifier.parseCreds(auth) + const { includeTakedowns } = ctx.authVerifier.parseCreds(auth) const actors = await ctx.hydrator.actor.getActors(dids, true) const infos = mapDefined(dids, (did) => { const info = actors.get(did) if (!info) return - if (info.takedownRef && !canViewTakedowns) return + if (info.takedownRef && !includeTakedowns) return const profileRecord = - !info.profileTakedownRef || canViewTakedowns + !info.profileTakedownRef || includeTakedowns ? info.profile : undefined return { diff --git a/packages/bsky/src/auth-verifier.ts b/packages/bsky/src/auth-verifier.ts index 2936b1cd28b..44bde797932 100644 --- a/packages/bsky/src/auth-verifier.ts +++ b/packages/bsky/src/auth-verifier.ts @@ -258,7 +258,7 @@ export class AuthVerifier { ) { const viewer = creds.credentials.type === 'standard' ? creds.credentials.iss : null - const canViewTakedowns = + const includeTakedowns = (creds.credentials.type === 'role' && creds.credentials.admin) || creds.credentials.type === 'mod_service' || (creds.credentials.type === 'standard' && @@ -269,7 +269,7 @@ export class AuthVerifier { return { viewer, - canViewTakedowns, + includeTakedowns, canPerformTakedown, } } diff --git a/packages/bsky/src/hydration/hydrator.ts b/packages/bsky/src/hydration/hydrator.ts index de64a333ace..d72c6477bad 100644 --- a/packages/bsky/src/hydration/hydrator.ts +++ b/packages/bsky/src/hydration/hydrator.ts @@ -50,6 +50,7 @@ import { ParsedLabelers } from '../util' export type HydrateCtx = { labelers: ParsedLabelers viewer: string | null + includeTakedowns?: boolean } export type HydrationState = { @@ -133,16 +134,15 @@ export class Hydrator { async hydrateProfiles( dids: string[], ctx: HydrateCtx, - includeTakedowns = false, ): Promise { const [actors, labels, profileViewersState] = await Promise.all([ - this.actor.getActors(dids, includeTakedowns), - this.label.getLabelsForSubjects( - labelSubjectsForDid(dids), - ctx.labelers.dids, - ), + this.actor.getActors(dids, ctx.includeTakedowns), + this.label.getLabelsForSubjects(labelSubjectsForDid(dids), ctx.labelers), this.hydrateProfileViewers(dids, ctx), ]) + if (!ctx.includeTakedowns) { + actionTakedownLabels(dids, actors, labels) + } return mergeStates(profileViewersState ?? {}, { actors, labels, @@ -157,9 +157,8 @@ export class Hydrator { async hydrateProfilesBasic( dids: string[], ctx: HydrateCtx, - includeTakedowns = false, ): Promise { - return this.hydrateProfiles(dids, ctx, includeTakedowns) + return this.hydrateProfiles(dids, ctx) } // app.bsky.actor.defs#profileViewDetailed @@ -169,10 +168,9 @@ export class Hydrator { async hydrateProfilesDetailed( dids: string[], ctx: HydrateCtx, - includeTakedowns = false, ): Promise { const [state, profileAggs] = await Promise.all([ - this.hydrateProfiles(dids, ctx, includeTakedowns), + this.hydrateProfiles(dids, ctx), this.actor.getProfileAggregates(dids), ]) return { @@ -189,6 +187,7 @@ export class Hydrator { await this.hydrateListsBasic(uris, ctx), await this.hydrateProfilesBasic(uris.map(didFromUri), ctx), ]) + return mergeStates(listsState, profilesState) } @@ -203,6 +202,11 @@ export class Hydrator { ctx.viewer ? this.graph.getListViewerStates(uris, ctx.viewer) : undefined, this.label.getLabelsForSubjects(uris, ctx.labelers), ]) + + if (!ctx.includeTakedowns) { + actionTakedownLabels(uris, lists, labels) + } + return { lists, listViewers, labels, ctx } } @@ -241,13 +245,12 @@ export class Hydrator { async hydratePosts( refs: ItemRef[], ctx: HydrateCtx, - includeTakedowns = false, state: HydrationState = {}, ): Promise { const uris = refs.map((ref) => ref.uri) const postsLayer0 = await this.feed.getPosts( uris, - includeTakedowns, + ctx.includeTakedowns, state.posts, ) // first level embeds plus thread roots we haven't fetched yet @@ -257,7 +260,7 @@ export class Hydrator { const postUrisLayer1 = urisLayer1ByCollection.get(ids.AppBskyFeedPost) ?? [] const postsLayer1 = await this.feed.getPosts( [...postUrisLayer1, ...additionalRootUris], - includeTakedowns, + ctx.includeTakedowns, ) // second level embeds, ignoring any additional root uris we mixed-in to the previous layer const urisLayer2 = nestedRecordUrisFromPosts(postsLayer1, postUrisLayer1) @@ -270,7 +273,7 @@ export class Hydrator { } } const [postsLayer2, threadgates] = await Promise.all([ - this.feed.getPosts(postUrisLayer2, includeTakedowns), + this.feed.getPosts(postUrisLayer2, ctx.includeTakedowns), this.feed.getThreadgatesForPosts([...threadRootUris.values()]), ]) // collect list/feedgen embeds, lists in threadgates, post record hydration @@ -302,13 +305,16 @@ export class Hydrator { ] = await Promise.all([ this.feed.getPostAggregates(refs), ctx.viewer ? this.feed.getPostViewerStates(refs, ctx.viewer) : undefined, - this.label.getLabelsForSubjects(allPostUris, ctx.labelers.dids), + this.label.getLabelsForSubjects(allPostUris, ctx.labelers), this.hydratePostBlocks(posts), - this.hydrateProfiles(allPostUris.map(didFromUri), ctx, includeTakedowns), + this.hydrateProfiles(allPostUris.map(didFromUri), ctx), this.hydrateLists([...nestedListUris, ...gateListUris], ctx), this.hydrateFeedGens(nestedFeedGenUris, ctx), this.hydrateLabelers(nestedLabelerDids, ctx), ]) + if (!ctx.includeTakedowns) { + actionTakedownLabels(allPostUris, posts, labels) + } // combine all hydration state return mergeManyStates( profileState, @@ -381,14 +387,13 @@ export class Hydrator { async hydrateFeedItems( items: FeedItem[], ctx: HydrateCtx, - includeTakedowns = false, ): Promise { const postUris = items.map((item) => item.post.uri) const repostUris = mapDefined(items, (item) => item.repost?.uri) const [posts, reposts, repostProfileState] = await Promise.all([ - this.feed.getPosts(postUris, includeTakedowns), - this.feed.getReposts(repostUris, includeTakedowns), - this.hydrateProfiles(repostUris.map(didFromUri), ctx, includeTakedowns), + this.feed.getPosts(postUris, ctx.includeTakedowns), + this.feed.getReposts(repostUris, ctx.includeTakedowns), + this.hydrateProfiles(repostUris.map(didFromUri), ctx), ]) const postAndReplyRefs: ItemRef[] = [] posts.forEach((post, uri) => { @@ -398,12 +403,7 @@ export class Hydrator { postAndReplyRefs.push(post.record.reply.root, post.record.reply.parent) } }) - const postState = await this.hydratePosts( - postAndReplyRefs, - ctx, - includeTakedowns, - { posts }, - ) + const postState = await this.hydratePosts(postAndReplyRefs, ctx, { posts }) return mergeManyStates(postState, repostProfileState, { reposts, ctx, @@ -437,7 +437,7 @@ export class Hydrator { ): Promise { const [feedgens, feedgenAggs, feedgenViewers, profileState, labels] = await Promise.all([ - this.feed.getFeedGens(uris), + this.feed.getFeedGens(uris, ctx.includeTakedowns), this.feed.getFeedGenAggregates(uris.map((uri) => ({ uri }))), ctx.viewer ? this.feed.getFeedGenViewerStates(uris, ctx.viewer) @@ -445,6 +445,9 @@ export class Hydrator { this.hydrateProfiles(uris.map(didFromUri), ctx), this.label.getLabelsForSubjects(uris, ctx.labelers), ]) + if (!ctx.includeTakedowns) { + actionTakedownLabels(uris, feedgens, labels) + } return mergeStates(profileState, { feedgens, feedgenAggs, @@ -498,9 +501,10 @@ export class Hydrator { this.feed.getLikes(likeUris), // reason: like this.feed.getReposts(repostUris), // reason: repost this.graph.getFollows(followUris), // reason: follow - this.label.getLabelsForSubjects(uris, ctx.labelers.dids), + this.label.getLabelsForSubjects(uris, ctx.labelers), this.hydrateProfiles(uris.map(didFromUri), ctx), ]) + actionTakedownLabels(postUris, posts, labels) return mergeStates(profileState, { posts, likes, @@ -782,3 +786,15 @@ const mergeManyStates = (...states: HydrationState[]) => { const mergeManyMaps = (...maps: HydrationMap[]) => { return maps.reduce(mergeMaps, undefined as HydrationMap | undefined) } + +const actionTakedownLabels = ( + keys: string[], + hydrationMap: HydrationMap, + labels: Labels, +) => { + for (const key of keys) { + if (labels.get(key)?.isTakendown) { + hydrationMap.set(key, null) + } + } +} diff --git a/packages/bsky/src/hydration/label.ts b/packages/bsky/src/hydration/label.ts index 9b47e0454ae..f32b35a5496 100644 --- a/packages/bsky/src/hydration/label.ts +++ b/packages/bsky/src/hydration/label.ts @@ -10,10 +10,16 @@ import { } from './util' import { AtUri } from '@atproto/syntax' import { ids } from '../lexicon/lexicons' +import { ParsedLabelers } from '../util' export type { Label } from '../lexicon/types/com/atproto/label/defs' -export type Labels = HydrationMap +export type SubjectLabels = { + isTakendown: boolean + labels: Label[] +} + +export type Labels = HydrationMap export type LabelerAgg = { likes: number @@ -35,22 +41,36 @@ export class LabelHydrator { async getLabelsForSubjects( subjects: string[], - issuers: string[], + labelers: ParsedLabelers, ): Promise { - if (!subjects.length || !issuers.length) return new HydrationMap() - const res = await this.dataplane.getLabels({ subjects, issuers }) + if (!subjects.length || !labelers.dids.length) + return new HydrationMap() + const res = await this.dataplane.getLabels({ + subjects, + issuers: labelers.dids, + }) return res.labels.reduce((acc, cur) => { const parsed = parseJsonBytes(cur) as Label | undefined if (!parsed || parsed.neg) return acc const { sig: _, ...label } = parsed - const entry = acc.get(label.uri) - if (entry) { - entry.push(label) - } else { - acc.set(label.uri, [label]) + let entry = acc.get(label.uri) + if (!entry) { + entry = { + isTakendown: false, + labels: [], + } + acc.set(label.uri, entry) + } + entry.labels.push(label) + if ( + TAKEDOWN_LABELS.includes(label.val) && + !label.neg && + labelers.redact.has(label.src) + ) { + entry.isTakendown = true } return acc - }, new HydrationMap()) + }, new HydrationMap()) } async getLabelers( @@ -98,3 +118,5 @@ export class LabelHydrator { const labelerDidToUri = (did: string): string => { return AtUri.make(did, ids.AppBskyLabelerService, 'self').toString() } + +const TAKEDOWN_LABELS = ['!takedown', '!suspend'] diff --git a/packages/bsky/src/views/index.ts b/packages/bsky/src/views/index.ts index 29a5238d383..424fc810aea 100644 --- a/packages/bsky/src/views/index.ts +++ b/packages/bsky/src/views/index.ts @@ -61,7 +61,9 @@ export class Views { // ------------ actorIsTakendown(did: string, state: HydrationState): boolean { - return !!state.actors?.get(did)?.takedownRef + if (state.actors?.get(did)?.takedownRef) return true + if (state.labels?.get(did)?.isTakendown) return true + return false } viewerBlockExists(did: string, state: HydrationState): boolean { @@ -134,8 +136,8 @@ export class Views { 'self', ).toString() const labels = [ - ...(state.labels?.get(did) ?? []), - ...(state.labels?.get(profileUri) ?? []), + ...(state.labels?.get(did)?.labels ?? []), + ...(state.labels?.get(profileUri)?.labels ?? []), ...this.selfLabels({ uri: profileUri, cid: actor.profileCid?.toString(), @@ -223,7 +225,7 @@ export class Views { return undefined } const listViewer = state.listViewers?.get(uri) - const labels = state.labels?.get(uri) ?? [] + const labels = state.labels?.get(uri)?.labels ?? [] const creator = new AtUri(uri).hostname return { uri, @@ -279,7 +281,7 @@ export class Views { const uri = AtUri.make(did, ids.AppBskyLabelerService, 'self').toString() const labels = [ - ...(state.labels?.get(uri) ?? []), + ...(state.labels?.get(uri)?.labels ?? []), ...this.selfLabels({ uri, cid: labeler.cid.toString(), @@ -349,7 +351,7 @@ export class Views { if (!creator) return const viewer = state.feedgenViewers?.get(uri) const aggs = state.feedgenAggs?.get(uri) - const labels = state.labels?.get(uri) ?? [] + const labels = state.labels?.get(uri)?.labels ?? [] return { uri, @@ -406,7 +408,7 @@ export class Views { parsedUri.rkey, ).toString() const labels = [ - ...(state.labels?.get(uri) ?? []), + ...(state.labels?.get(uri)?.labels ?? []), ...this.selfLabels({ uri, cid: post.cid, @@ -884,7 +886,7 @@ export class Views { recordInfo = state.follows?.get(notif.uri) } if (!recordInfo) return - const labels = state.labels?.get(notif.uri) ?? [] + const labels = state.labels?.get(notif.uri)?.labels ?? [] const selfLabels = this.selfLabels({ uri: notif.uri, cid: recordInfo.cid, diff --git a/packages/bsky/tests/label-hydration.test.ts b/packages/bsky/tests/label-hydration.test.ts index b6927dd8afe..ec1fcb92c07 100644 --- a/packages/bsky/tests/label-hydration.test.ts +++ b/packages/bsky/tests/label-hydration.test.ts @@ -85,8 +85,11 @@ describe('label hydration', () => { expect(res.data.labels?.length).toBe(1) expect(res.data.labels?.[0].src).toBe(labelerDid) expect(res.data.labels?.[0].val).toBe('misleading') + expect(res.headers['atproto-content-labelers']).toEqual( - `${labelerDid};redact`, + network.bsky.ctx.cfg.labelsFromIssuerDids + .map((did) => `${did};redact`) + .join(','), ) }) @@ -149,7 +152,7 @@ describe('label hydration', () => { val: opts.val, cts: new Date().toISOString(), neg: false, - src: opts.src ?? 'did:example:labeler', + src: opts.src ?? labelerDid, }) .execute() } diff --git a/packages/bsky/tests/views/takedown-labels.test.ts b/packages/bsky/tests/views/takedown-labels.test.ts new file mode 100644 index 00000000000..b7118c75a34 --- /dev/null +++ b/packages/bsky/tests/views/takedown-labels.test.ts @@ -0,0 +1,134 @@ +import AtpAgent from '@atproto/api' +import { TestNetwork, SeedClient, basicSeed, RecordRef } from '@atproto/dev-env' + +describe('bsky takedown labels', () => { + let network: TestNetwork + let agent: AtpAgent + let sc: SeedClient + + let takendownSubjects: string[] + + let aliceListRef: RecordRef + let carolListRef: RecordRef + let aliceGenRef: RecordRef + let bobGenRef: RecordRef + let carolGenRef: RecordRef + + beforeAll(async () => { + network = await TestNetwork.create({ + dbPostgresSchema: 'bsky_views_takedown_labels', + }) + agent = network.bsky.getClient() + sc = network.getSeedClient() + await basicSeed(sc) + + aliceListRef = await sc.createList(sc.dids.alice, 'alice list', 'mod') + carolListRef = await sc.createList(sc.dids.carol, 'carol list', 'mod') + aliceGenRef = await sc.createFeedGen( + sc.dids.alice, + 'did:web:example.com', + 'alice generator', + ) + bobGenRef = await sc.createFeedGen( + sc.dids.bob, + 'did:web:example.com', + 'bob generator', + ) + carolGenRef = await sc.createFeedGen( + sc.dids.carol, + 'did:web:example.com', + 'carol generator', + ) + + await network.processAll() + + takendownSubjects = [ + sc.posts[sc.dids.alice][0].ref.uriStr, + sc.dids.carol, + aliceListRef.uriStr, + aliceGenRef.uriStr, + ] + const src = network.ozone.ctx.cfg.service.did + const cts = new Date().toISOString() + const labels = takendownSubjects.map((uri) => ({ + src, + uri, + cid: '', + val: '!takedown', + neg: false, + cts, + })) + + await network.bsky.db.db.insertInto('label').values(labels).execute() + }) + + afterAll(async () => { + await network.close() + }) + + it('takesdown profiles', async () => { + const attempt = agent.api.app.bsky.actor.getProfile({ + actor: sc.dids.carol, + }) + await expect(attempt).rejects.toThrow('Account has been suspended') + const res = await agent.api.app.bsky.actor.getProfiles({ + actors: [sc.dids.alice, sc.dids.bob, sc.dids.carol], + }) + expect(res.data.profiles.length).toBe(2) + expect(res.data.profiles.some((p) => p.did === sc.dids.carol)).toBe(false) + }) + + it('takesdown posts', async () => { + const uris = [ + sc.posts[sc.dids.alice][0].ref.uriStr, + sc.posts[sc.dids.alice][1].ref.uriStr, + sc.posts[sc.dids.bob][0].ref.uriStr, + sc.posts[sc.dids.carol][0].ref.uriStr, + sc.posts[sc.dids.dan][1].ref.uriStr, + sc.replies[sc.dids.alice][0].ref.uriStr, + ] + const res = await agent.api.app.bsky.feed.getPosts({ uris }) + + expect(res.data.posts.length).toBe(4) + expect(res.data.posts.some((p) => p.author.did === sc.dids.carol)).toBe( + false, + ) + expect( + res.data.posts.some( + (p) => p.uri === sc.posts[sc.dids.alice][0].ref.uriStr, + ), + ).toBe(false) + }) + + it('takesdown lists', async () => { + // record takedown + const attempt1 = agent.api.app.bsky.graph.getList({ + list: aliceListRef.uriStr, + }) + await expect(attempt1).rejects.toThrow('List not found') + + // actor takedown + const attempt2 = agent.api.app.bsky.graph.getList({ + list: carolListRef.uriStr, + }) + await expect(attempt2).rejects.toThrow('List not found') + }) + + it('takesdown feed generators', async () => { + const res = await agent.api.app.bsky.feed.getFeedGenerators({ + feeds: [aliceGenRef.uriStr, bobGenRef.uriStr, carolGenRef.uriStr], + }) + expect(res.data.feeds.length).toBe(1) + expect(res.data.feeds.at(0)?.uri).toEqual(bobGenRef.uriStr) + }) + + it('only applies if the relevant labeler is configured', async () => { + const res = await agent.api.app.bsky.actor.getProfile( + { + actor: sc.dids.carol, + }, + { headers: { 'atproto-accept-labelers': 'did:web:example.com' } }, + ) + expect(res.data.did).toEqual(sc.dids.carol) + }) +}) diff --git a/packages/dev-env/src/network.ts b/packages/dev-env/src/network.ts index fc1ed67c19d..d7da4df79ab 100644 --- a/packages/dev-env/src/network.ts +++ b/packages/dev-env/src/network.ts @@ -53,6 +53,7 @@ export class TestNetwork extends TestNetworkNoAppView { dbPostgresUrl, redisHost, modServiceDid: ozoneDid, + labelsFromIssuerDids: [ozoneDid, 'did:example:labeler'], // this did is also used as the labeler in seeds ...params.bsky, }) diff --git a/packages/dev-env/src/seed/client.ts b/packages/dev-env/src/seed/client.ts index 1e5a9ae10d4..f4c2283543d 100644 --- a/packages/dev-env/src/seed/client.ts +++ b/packages/dev-env/src/seed/client.ts @@ -81,6 +81,10 @@ export class SeedClient< string, Record }> > + feedgens: Record< + string, + Record }> + > dids: Record constructor(public network: Network, public agent: AtpAgent) { @@ -93,6 +97,7 @@ export class SeedClient< this.replies = {} this.reposts = {} this.lists = {} + this.feedgens = {} this.dids = {} } @@ -396,6 +401,25 @@ export class SeedClient< return ref } + async createFeedGen(by: string, feedDid: string, name: string) { + const res = await this.agent.api.app.bsky.feed.generator.create( + { repo: by }, + { + did: feedDid, + displayName: name, + createdAt: new Date().toISOString(), + }, + this.getHeaders(by), + ) + this.feedgens[by] ??= {} + const ref = new RecordRef(res.uri, res.cid) + this.feedgens[by][ref.uriStr] = { + ref: ref, + items: {}, + } + return ref + } + async addToList(by: string, subject: string, list: RecordRef) { const res = await this.agent.api.app.bsky.graph.listitem.create( { repo: by },