Skip to content

Commit

Permalink
Appview v2 enforce post thread root boundary (#2120)
Browse files Browse the repository at this point in the history
* enforce post thread root boundary

* test thread root boundary
  • Loading branch information
devinivy authored Feb 2, 2024
1 parent b292023 commit 374d2b8
Show file tree
Hide file tree
Showing 2 changed files with 84 additions and 10 deletions.
44 changes: 34 additions & 10 deletions packages/bsky/src/views/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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)
}
Expand All @@ -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 {
Expand All @@ -535,41 +544,52 @@ 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<string, string[]>,
state: HydrationState,
depth: number,
): (ThreadViewPost | NotFoundPost | BlockedPost)[] | undefined {
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,
),
}
})
}
Expand Down Expand Up @@ -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
}
50 changes: 50 additions & 0 deletions packages/bsky/tests/views/thread.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down

0 comments on commit 374d2b8

Please sign in to comment.