Skip to content

Commit 1002120

Browse files
authored
Search filtering by tag (#4312)
1 parent 756ab5d commit 1002120

File tree

9 files changed

+401
-12
lines changed

9 files changed

+401
-12
lines changed

packages/bsky/src/api/app/bsky/feed/searchPosts.ts

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { AtpAgent } from '@atproto/api'
22
import { mapDefined } from '@atproto/common'
3+
import { ServerConfig } from '../../../../config'
34
import { AppContext } from '../../../../context'
45
import { DataPlaneClient } from '../../../../data-plane'
6+
import {
7+
PostSearchQuery,
8+
parsePostSearchQuery,
9+
} from '../../../../data-plane/server/util'
510
import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator'
611
import { parseString } from '../../../../hydration/util'
712
import { Server } from '../../../../lexicon'
@@ -21,16 +26,20 @@ export default function (server: Server, ctx: AppContext) {
2126
const searchPosts = createPipeline(
2227
skeleton,
2328
hydration,
24-
noBlocks,
29+
noBlocksOrTagged,
2530
presentation,
2631
)
2732
server.app.bsky.feed.searchPosts({
2833
auth: ctx.authVerifier.standardOptional,
2934
handler: async ({ auth, params, req }) => {
30-
const viewer = auth.credentials.iss
35+
const { viewer, isModService } = ctx.authVerifier.parseCreds(auth)
36+
3137
const labelers = ctx.reqLabelers(req)
3238
const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer })
33-
const results = await searchPosts({ ...params, hydrateCtx }, ctx)
39+
const results = await searchPosts(
40+
{ ...params, hydrateCtx, isModService },
41+
ctx,
42+
)
3443
return {
3544
encoding: 'application/json',
3645
body: results,
@@ -42,6 +51,9 @@ export default function (server: Server, ctx: AppContext) {
4251

4352
const skeleton = async (inputs: SkeletonFnInput<Context, Params>) => {
4453
const { ctx, params } = inputs
54+
const parsedQuery = parsePostSearchQuery(params.q, {
55+
author: params.author,
56+
})
4557

4658
if (ctx.searchAgent) {
4759
// @NOTE cursors won't change on appview swap
@@ -64,6 +76,7 @@ const skeleton = async (inputs: SkeletonFnInput<Context, Params>) => {
6476
return {
6577
posts: res.posts.map(({ uri }) => uri),
6678
cursor: parseString(res.cursor),
79+
parsedQuery,
6780
}
6881
}
6982

@@ -75,6 +88,7 @@ const skeleton = async (inputs: SkeletonFnInput<Context, Params>) => {
7588
return {
7689
posts: res.uris,
7790
cursor: parseString(res.cursor),
91+
parsedQuery,
7892
}
7993
}
8094

@@ -88,11 +102,30 @@ const hydration = async (
88102
)
89103
}
90104

91-
const noBlocks = (inputs: RulesFnInput<Context, Params, Skeleton>) => {
92-
const { ctx, skeleton, hydration } = inputs
105+
const noBlocksOrTagged = (inputs: RulesFnInput<Context, Params, Skeleton>) => {
106+
const { ctx, params, skeleton, hydration } = inputs
107+
const { parsedQuery } = skeleton
108+
93109
skeleton.posts = skeleton.posts.filter((uri) => {
110+
const post = hydration.posts?.get(uri)
111+
if (!post) return
112+
94113
const creator = creatorFromUri(uri)
95-
return !ctx.views.viewerBlockExists(creator, hydration)
114+
const isCuratedSearch = params.sort === 'top'
115+
const isPostByViewer = creator === params.hydrateCtx.viewer
116+
117+
// Cases to always show.
118+
if (isPostByViewer) return true
119+
if (params.isModService) return true
120+
121+
// Cases to never show.
122+
if (ctx.views.viewerBlockExists(creator, hydration)) return false
123+
124+
// Cases to conditionally show based on tagging.
125+
const tagged = [...ctx.cfg.searchTagsHide].some((t) => post.tags.has(t))
126+
if (isCuratedSearch && tagged) return false
127+
if (!parsedQuery.author && tagged) return false
128+
return true
96129
})
97130
return skeleton
98131
}
@@ -101,9 +134,12 @@ const presentation = (
101134
inputs: PresentationFnInput<Context, Params, Skeleton>,
102135
) => {
103136
const { ctx, skeleton, hydration } = inputs
104-
const posts = mapDefined(skeleton.posts, (uri) =>
105-
ctx.views.post(uri, hydration),
106-
)
137+
const posts = mapDefined(skeleton.posts, (uri) => {
138+
const post = hydration.posts?.get(uri)
139+
if (!post) return
140+
141+
return ctx.views.post(uri, hydration)
142+
})
107143
return {
108144
posts,
109145
cursor: skeleton.cursor,
@@ -112,16 +148,21 @@ const presentation = (
112148
}
113149

114150
type Context = {
151+
cfg: ServerConfig
115152
dataplane: DataPlaneClient
116153
hydrator: Hydrator
117154
views: Views
118155
searchAgent?: AtpAgent
119156
}
120157

121-
type Params = QueryParams & { hydrateCtx: HydrateCtx }
158+
type Params = QueryParams & {
159+
hydrateCtx: HydrateCtx
160+
isModService: boolean
161+
}
122162

123163
type Skeleton = {
124164
posts: string[]
125165
hitsTotal?: number
126166
cursor?: string
167+
parsedQuery: PostSearchQuery
127168
}

packages/bsky/src/auth-verifier.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,12 +388,17 @@ export class AuthVerifier {
388388
const canPerformTakedown =
389389
(creds.credentials.type === 'role' && creds.credentials.admin) ||
390390
creds.credentials.type === 'mod_service'
391+
const isModService =
392+
creds.credentials.type === 'mod_service' ||
393+
(creds.credentials.type === 'standard' &&
394+
this.isModService(creds.credentials.iss))
391395

392396
return {
393397
viewer,
394398
includeTakedowns: includeTakedownsAnd3pBlocks,
395399
include3pBlocks: includeTakedownsAnd3pBlocks,
396400
canPerformTakedown,
401+
isModService,
397402
}
398403
}
399404
}

packages/bsky/src/config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export interface ServerConfigValues {
4242
courierHttpVersion?: '1.1' | '2'
4343
courierIgnoreBadTls?: boolean
4444
searchUrl?: string
45+
searchTagsHide: Set<string>
4546
suggestionsUrl?: string
4647
suggestionsApiKey?: string
4748
topicsUrl?: string
@@ -133,6 +134,7 @@ export class ServerConfig {
133134
process.env.BSKY_SEARCH_URL ||
134135
process.env.BSKY_SEARCH_ENDPOINT ||
135136
undefined
137+
const searchTagsHide = new Set(envList(process.env.BSKY_SEARCH_TAGS_HIDE))
136138
const suggestionsUrl = process.env.BSKY_SUGGESTIONS_URL || undefined
137139
const suggestionsApiKey = process.env.BSKY_SUGGESTIONS_API_KEY || undefined
138140
const topicsUrl = process.env.BSKY_TOPICS_URL || undefined
@@ -296,6 +298,7 @@ export class ServerConfig {
296298
dataplaneHttpVersion,
297299
dataplaneIgnoreBadTls,
298300
searchUrl,
301+
searchTagsHide,
299302
suggestionsUrl,
300303
suggestionsApiKey,
301304
topicsUrl,
@@ -440,6 +443,10 @@ export class ServerConfig {
440443
return this.cfg.searchUrl
441444
}
442445

446+
get searchTagsHide() {
447+
return this.cfg.searchTagsHide
448+
}
449+
443450
get suggestionsUrl() {
444451
return this.cfg.suggestionsUrl
445452
}
@@ -539,6 +546,7 @@ export class ServerConfig {
539546
get threadTagsHide() {
540547
return this.cfg.threadTagsHide
541548
}
549+
542550
get threadTagsBumpDown() {
543551
return this.cfg.threadTagsBumpDown
544552
}

packages/bsky/src/data-plane/server/routes/search.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { ServiceImpl } from '@connectrpc/connect'
22
import { Service } from '../../../proto/bsky_connect'
33
import { Database } from '../db'
44
import { IndexedAtDidKeyset, TimeCidKeyset, paginate } from '../db/pagination'
5+
import { parsePostSearchQuery } from '../util'
56

67
export default (db: Database): Partial<ServiceImpl<typeof Service>> => ({
78
// @TODO actor search endpoints still fall back to search service
@@ -35,12 +36,28 @@ export default (db: Database): Partial<ServiceImpl<typeof Service>> => ({
3536
// @TODO post search endpoint still falls back to search service
3637
async searchPosts(req) {
3738
const { term, limit, cursor } = req
39+
const { q, author } = parsePostSearchQuery(term)
40+
41+
let authorDid = author
42+
if (author && !author?.startsWith('did:')) {
43+
const res = await db.db
44+
.selectFrom('actor')
45+
.where('handle', '=', author)
46+
.selectAll()
47+
.executeTakeFirst()
48+
authorDid = res?.did
49+
}
50+
3851
const { ref } = db.db.dynamic
3952
let builder = db.db
4053
.selectFrom('post')
41-
.where('post.text', 'like', `%${term}%`)
54+
.where('post.text', 'like', `%${q}%`)
4255
.selectAll()
4356

57+
if (authorDid) {
58+
builder = builder.where('post.creator', '=', authorDid)
59+
}
60+
4461
const keyset = new TimeCidKeyset(ref('post.sortAt'), ref('post.cid'))
4562
builder = paginate(builder, {
4663
limit,

packages/bsky/src/data-plane/server/util.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,3 +153,54 @@ export const violatesThreadGate = async (
153153

154154
return true
155155
}
156+
157+
// @NOTE: This type is not complete with all supported options.
158+
// Only the ones that we needed to apply custom logic on are currently present.
159+
export type PostSearchQuery = {
160+
q: string
161+
author: string | undefined
162+
}
163+
164+
export const parsePostSearchQuery = (
165+
qParam: string,
166+
params?: {
167+
author?: string
168+
},
169+
): PostSearchQuery => {
170+
// Accept individual params, but give preference to options embedded in `q`.
171+
let author = params?.author
172+
173+
const parts: string[] = []
174+
let curr = ''
175+
let quoted = false
176+
for (const c of qParam) {
177+
if (c === ' ' && !quoted) {
178+
curr.trim() && parts.push(curr)
179+
curr = ''
180+
continue
181+
}
182+
183+
if (c === '"') {
184+
quoted = !quoted
185+
}
186+
curr += c
187+
}
188+
curr.trim() && parts.push(curr)
189+
190+
const qParts: string[] = []
191+
for (const p of parts) {
192+
const tokens = p.split(':')
193+
if (tokens[0] === 'did') {
194+
author = p
195+
} else if (tokens[0] === 'author' || tokens[0] === 'from') {
196+
author = tokens[1]
197+
} else {
198+
qParts.push(p)
199+
}
200+
}
201+
202+
return {
203+
q: qParts.join(' '),
204+
author,
205+
}
206+
}

packages/bsky/tests/utils.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import {
2+
PostSearchQuery,
3+
parsePostSearchQuery,
4+
} from '../src/data-plane/server/util'
5+
6+
describe('parsePostSearchQuery', () => {
7+
type TestCase = {
8+
input: string
9+
output: PostSearchQuery
10+
}
11+
12+
const tests: TestCase[] = [
13+
{
14+
input: `bluesky `,
15+
output: { q: `bluesky`, author: undefined },
16+
},
17+
{
18+
input: ` bluesky from:esb.lol`,
19+
output: { q: `bluesky`, author: `esb.lol` },
20+
},
21+
{
22+
input: `bluesky "from:esb.lol"`,
23+
output: { q: `bluesky "from:esb.lol"`, author: undefined },
24+
},
25+
{
26+
input: `bluesky mentions:@esb.lol `,
27+
output: { q: `bluesky mentions:@esb.lol`, author: undefined },
28+
},
29+
{
30+
input: `bluesky lang:"en"`,
31+
output: { q: `bluesky lang:"en"`, author: undefined },
32+
},
33+
{
34+
input: `bluesky "literal" "from:invalid" did:test:123 `,
35+
output: {
36+
q: `bluesky "literal" "from:invalid"`,
37+
author: `did:test:123`,
38+
},
39+
},
40+
]
41+
42+
it.each(tests)(`'$input' -> '$output'`, ({ input, output }) => {
43+
expect(parsePostSearchQuery(input)).toEqual(output)
44+
})
45+
})

0 commit comments

Comments
 (0)