From 374d2b8f711a13336e9283291213fcd320d18926 Mon Sep 17 00:00:00 2001 From: devin ivy Date: Fri, 2 Feb 2024 16:47:18 -0500 Subject: [PATCH] Appview v2 enforce post thread root boundary (#2120) * enforce post thread root boundary * test thread root boundary --- packages/bsky/src/views/index.ts | 44 ++++++++++++++++----- packages/bsky/tests/views/thread.test.ts | 50 ++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 10 deletions(-) diff --git a/packages/bsky/src/views/index.ts b/packages/bsky/src/views/index.ts index c9e80beb117..08c58134383 100644 --- a/packages/bsky/src/views/index.ts +++ b/packages/bsky/src/views/index.ts @@ -46,7 +46,7 @@ import { isRecordWithMedia, } from './types' import { Label } from '../hydration/label' -import { FeedItem, Repost } from '../hydration/feed' +import { FeedItem, Post, Repost } from '../hydration/feed' import { RecordInfo } from '../hydration/util' import { Notification } from '../proto/bsky_pb' @@ -494,7 +494,8 @@ export class Views { ): ThreadViewPost | NotFoundPost | BlockedPost { const { anchor, uris } = skele const post = this.post(anchor, state) - if (!post) return this.notFoundPost(anchor) + const postInfo = state.posts?.get(anchor) + if (!postInfo || !post) return this.notFoundPost(anchor) if (this.viewerBlockExists(post.author.did, state)) { return this.blockedPost(anchor, post.author.did, state) } @@ -509,22 +510,30 @@ export class Views { childrenByParentUri[parentUri] ??= [] childrenByParentUri[parentUri].push(uri) }) - const violatesThreadGate = state.posts?.get(anchor)?.violatesThreadGate + const rootUri = getRootUri(anchor, postInfo) + const violatesThreadGate = postInfo.violatesThreadGate return { $type: 'app.bsky.feed.defs#threadViewPost', post, parent: !violatesThreadGate - ? this.threadParent(anchor, state, opts.height) + ? this.threadParent(anchor, rootUri, state, opts.height) : undefined, replies: !violatesThreadGate - ? this.threadReplies(anchor, childrenByParentUri, state, opts.depth) + ? this.threadReplies( + anchor, + rootUri, + childrenByParentUri, + state, + opts.depth, + ) : undefined, } } threadParent( childUri: string, + rootUri: string, state: HydrationState, height: number, ): ThreadViewPost | NotFoundPost | BlockedPost | undefined { @@ -535,19 +544,22 @@ export class Views { return this.blockedPost(parentUri, creatorFromUri(parentUri), state) } const post = this.post(parentUri, state) - if (!post) return this.notFoundPost(parentUri) + const postInfo = state.posts?.get(parentUri) + if (!postInfo || !post) return this.notFoundPost(parentUri) + if (rootUri !== getRootUri(parentUri, postInfo)) return // outside thread boundary if (this.viewerBlockExists(post.author.did, state)) { return this.blockedPost(parentUri, post.author.did, state) } return { $type: 'app.bsky.feed.defs#threadViewPost', post, - parent: this.threadParent(parentUri, state, height - 1), + parent: this.threadParent(parentUri, rootUri, state, height - 1), } } threadReplies( parentUri: string, + rootUri: string, childrenByParentUri: Record, state: HydrationState, depth: number, @@ -555,21 +567,29 @@ export class Views { if (depth < 1) return undefined const childrenUris = childrenByParentUri[parentUri] ?? [] return mapDefined(childrenUris, (uri) => { - if (state.posts?.get(uri)?.violatesThreadGate) { + const postInfo = state.posts?.get(uri) + if (postInfo?.violatesThreadGate) { return undefined } if (state.postBlocks?.get(uri)?.reply) { return undefined } const post = this.post(uri, state) - if (!post) return this.notFoundPost(parentUri) + if (!postInfo || !post) return this.notFoundPost(parentUri) + if (rootUri !== getRootUri(uri, postInfo)) return // outside thread boundary if (this.viewerBlockExists(post.author.did, state)) { return this.blockedPost(parentUri, post.author.did, state) } return { $type: 'app.bsky.feed.defs#threadViewPost', post, - replies: this.threadReplies(uri, childrenByParentUri, state, depth - 1), + replies: this.threadReplies( + uri, + rootUri, + childrenByParentUri, + state, + depth - 1, + ), } }) } @@ -831,3 +851,7 @@ const postToGateUri = (uri: string) => { } return aturi.toString() } + +const getRootUri = (uri: string, post: Post): string => { + return post.record.reply?.root.uri ?? uri +} diff --git a/packages/bsky/tests/views/thread.test.ts b/packages/bsky/tests/views/thread.test.ts index 292322e914c..c3496f7cf50 100644 --- a/packages/bsky/tests/views/thread.test.ts +++ b/packages/bsky/tests/views/thread.test.ts @@ -136,6 +136,56 @@ describe('pds thread views', () => { expect(forSnapshot(thread3.data.thread)).toMatchSnapshot() }) + it('omits parents and replies w/ different root than anchor post.', async () => { + const badRoot = sc.posts[alice][0] + const goodRoot = await sc.post(alice, 'good root') + const goodReply1 = await sc.reply( + alice, + goodRoot.ref, + goodRoot.ref, + 'good reply 1', + ) + const goodReply2 = await sc.reply( + alice, + goodRoot.ref, + goodReply1.ref, + 'good reply 2', + ) + const badReply = await sc.reply( + alice, + badRoot.ref, + goodReply1.ref, + 'bad reply', + ) + await network.processAll() + // good reply doesn't have replies w/ different root + const { data: goodReply1Thread } = + await agent.api.app.bsky.feed.getPostThread( + { uri: goodReply1.ref.uriStr }, + { headers: await network.serviceHeaders(alice) }, + ) + assert(isThreadViewPost(goodReply1Thread.thread)) + assert(isThreadViewPost(goodReply1Thread.thread.parent)) + expect(goodReply1Thread.thread.parent.post.uri).toEqual(goodRoot.ref.uriStr) + expect( + goodReply1Thread.thread.replies?.map((r) => { + assert(isThreadViewPost(r)) + return r.post.uri + }), + ).toEqual([ + goodReply2.ref.uriStr, // does not contain badReply + ]) + expect(goodReply1Thread.thread.parent.replies).toBeUndefined() + // bad reply doesn't have a parent, which would have a different root + const { data: badReplyThread } = + await agent.api.app.bsky.feed.getPostThread( + { uri: badReply.ref.uriStr }, + { headers: await network.serviceHeaders(alice) }, + ) + assert(isThreadViewPost(badReplyThread.thread)) + expect(badReplyThread.thread.parent).toBeUndefined() // is not goodReply1 + }) + it('reflects self-labels', async () => { const { data: thread } = await agent.api.app.bsky.feed.getPostThread( { uri: sc.posts[alice][0].ref.uriStr },