From badde52e38e018c192c5039ec10de911890d6150 Mon Sep 17 00:00:00 2001 From: daviirodrig <30713947+daviirodrig@users.noreply.github.com> Date: Mon, 21 Apr 2025 23:32:48 -0300 Subject: [PATCH 1/2] search endpoint --- services/appview/src/db.ts | 11 ++- services/appview/src/index.ts | 15 ++-- .../appview/src/routes/actor/searchActor.ts | 77 +++++++++++++++++++ 3 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 services/appview/src/routes/actor/searchActor.ts diff --git a/services/appview/src/db.ts b/services/appview/src/db.ts index 07df3b5..0834a11 100644 --- a/services/appview/src/db.ts +++ b/services/appview/src/db.ts @@ -1,6 +1,6 @@ -import mongoose, { Schema, Document, Model, Connection } from 'mongoose' -import { env } from './env.js' +import mongoose, { Connection, Document, Model, Schema } from 'mongoose' import { pino } from 'pino' +import { env } from './env.js' export interface LikeDocument extends Document { uri: string @@ -118,6 +118,13 @@ export const profileSchema = new Schema({ cid: { type: String, required: true }, }) +// Add text index for profile search +profileSchema.index({ + displayName: 'text', + authorHandle: 'text', + description: 'text', +}) + export interface AudioDocument extends Document { uri: string sound: string diff --git a/services/appview/src/index.ts b/services/appview/src/index.ts index de31026..edcc063 100644 --- a/services/appview/src/index.ts +++ b/services/appview/src/index.ts @@ -12,16 +12,17 @@ import { createBidirectionalResolver, createIdResolver, } from './id-resolver.js' +import { takedownFilterMiddleware } from './middleware/takedown-filter.js' import { createGetProfileRouter } from './routes/actor/getProfile.js' +import { createSearchActorRouter } from './routes/actor/searchActor.js' +import { createTakedownRouter } from './routes/admin/takedowns.js' import { createGetAuthorFeedRouter } from './routes/feed/getAuthorFeed.js' import { createGetPostsRouter } from './routes/feed/getPosts.js' import { createGetPostThreadRouter } from './routes/feed/getPostThread.js' import { createGetFollowersRouter } from './routes/graph/getFollowers.js' import { createGetFollowsRouter } from './routes/graph/getFollows.js' -import wellKnownRouter from './well-known.js' import { TakedownService } from './services/takedown.js' -import { createTakedownRouter } from './routes/admin/takedowns.js' -import { takedownFilterMiddleware } from './middleware/takedown-filter.js' +import wellKnownRouter from './well-known.js' export type AppContext = { db: Database @@ -49,7 +50,7 @@ export class Server { // Get service DID from environment const serviceDid = env.SERVICE_DID - + // Create takedown service const takedownService = new TakedownService(db) @@ -78,7 +79,7 @@ export class Server { // Apply takedown filter middleware to all routes app.use('*', takedownFilterMiddleware) - + // TODO: Remove this after getAuthorFeedRouter is properly implemented on frontend const feedRouter = createFeedRouter(ctx) app.route('/', feedRouter) @@ -89,13 +90,15 @@ export class Server { const getFollowersRouter = createGetFollowersRouter(ctx) const getFollowsRouter = createGetFollowsRouter(ctx) const getAuthorFeedRouter = createGetAuthorFeedRouter(ctx) - + const searchActorRouter = createSearchActorRouter(ctx) + app.route('/', getPostsRouter) app.route('/', getPostThreadRouter) app.route('/', getProfileRouter) app.route('/', getFollowersRouter) app.route('/', getFollowsRouter) app.route('/', getAuthorFeedRouter) + app.route('/', searchActorRouter) // Create and configure the takedown router const takedownRouter = createTakedownRouter({ takedownService }) diff --git a/services/appview/src/routes/actor/searchActor.ts b/services/appview/src/routes/actor/searchActor.ts new file mode 100644 index 0000000..a49bc47 --- /dev/null +++ b/services/appview/src/routes/actor/searchActor.ts @@ -0,0 +1,77 @@ +import { Hono } from 'hono' +import { AppContext } from '../../index.js' +import type { Label } from '../../lexicon/types/com/atproto/label/defs.js' +import type * as SoSprkActorDefs from '../../lexicon/types/so/sprk/actor/defs.js' +import type * as SoSprkActorSearch from '../../lexicon/types/so/sprk/actor/searchActors.js' + +export const createSearchActorRouter = (ctx: AppContext) => { + const router = new Hono() + + router.get('/xrpc/so.sprk.actor.searchActors', async (c) => { + const q = c.req.query('q')?.trim() + let limit = parseInt(c.req.query('limit') ?? '25') + if (isNaN(limit)) limit = 25 + if (limit < 1 || limit > 100) { + return c.json({ error: 'Limit must be between 1 and 100' }, 400) + } + + let skip = 0 + const cursorParam = c.req.query('cursor') + if (cursorParam) { + skip = parseInt(cursorParam) + if (isNaN(skip) || skip < 0) { + return c.json({ error: 'Invalid cursor' }, 400) + } + } + + const filter: any = {} + const sort: any = {} + + if (q) { + filter.$text = { $search: q } + sort.score = { $meta: 'textScore' } + } else { + sort.createdAt = -1 + } + + const profiles = await ctx.db.models.Profile.find(filter) + .sort(sort) + .skip(skip) + .limit(limit) + .lean() + + const actors: SoSprkActorDefs.ProfileView[] = await Promise.all( + profiles.map(async (p) => { + const avatar = p.avatar + ? `https://media.sprk.so/avatar/tiny/${p.authorDid}/${(p.avatar as any).ref.$link}/webp` + : undefined + const labels = Array.isArray(p.labels) + ? (p.labels as Label[]) + : undefined + const handle = await ctx.resolver.resolveDidToHandle(p.authorDid) + return { + $type: 'so.sprk.actor.defs#profileView', + did: p.authorDid, + handle: handle, + displayName: p.displayName, + description: p.description, + avatar, + indexedAt: p.indexedAt, + createdAt: p.createdAt, + labels, + } satisfies SoSprkActorDefs.ProfileView + }), + ) + + const nextCursor = + profiles.length === limit ? String(skip + limit) : undefined + const result: SoSprkActorSearch.OutputSchema = { actors } + if (nextCursor) { + result.cursor = nextCursor + } + + return c.json(result) + }) + + return router +} -- 2.51.2 From b2c484d2ee5455239b6e567d114143eed11b8bcc Mon Sep 17 00:00:00 2001 From: daviirodrig <30713947+daviirodrig@users.noreply.github.com> Date: Mon, 21 Apr 2025 23:39:09 -0300 Subject: [PATCH 2/2] use regex on search --- services/appview/src/routes/actor/searchActor.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/services/appview/src/routes/actor/searchActor.ts b/services/appview/src/routes/actor/searchActor.ts index a49bc47..b94b5be 100644 --- a/services/appview/src/routes/actor/searchActor.ts +++ b/services/appview/src/routes/actor/searchActor.ts @@ -4,6 +4,11 @@ import type { Label } from '../../lexicon/types/com/atproto/label/defs.js' import type * as SoSprkActorDefs from '../../lexicon/types/so/sprk/actor/defs.js' import type * as SoSprkActorSearch from '../../lexicon/types/so/sprk/actor/searchActors.js' +// Helper to escape user input for safe RegExp usage +function escapeRegExp(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + export const createSearchActorRouter = (ctx: AppContext) => { const router = new Hono() @@ -28,8 +33,15 @@ export const createSearchActorRouter = (ctx: AppContext) => { const sort: any = {} if (q) { - filter.$text = { $search: q } - sort.score = { $meta: 'textScore' } + const escaped = escapeRegExp(q) + const regex = new RegExp(escaped, 'i') + filter.$or = [ + { displayName: regex }, + { description: regex }, + { handle: regex }, + ] + // fall back to sorting by createdAt + sort.createdAt = -1 } else { sort.createdAt = -1 } -- 2.51.2