From 88ef8087a584968e9a893ad2937d61d483268c39 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 15 Mar 2025 17:15:37 -0500 Subject: [PATCH] Let paginationMiddleware be configurable, add pagination to reactions handler --- packages/ditto/controllers/api/statuses.ts | 2 +- packages/ditto/controllers/api/suggestions.ts | 4 +- packages/ditto/controllers/api/trends.ts | 2 +- packages/ditto/routes/pleromaStatusesRoute.ts | 155 +++++++++--------- packages/ditto/views.ts | 2 +- .../middleware/paginationMiddleware.ts | 17 +- packages/mastoapi/pagination/schema.test.ts | 2 +- packages/mastoapi/pagination/schema.ts | 35 ++-- 8 files changed, 122 insertions(+), 97 deletions(-) diff --git a/packages/ditto/controllers/api/statuses.ts b/packages/ditto/controllers/api/statuses.ts index 116ad2de..e80441b6 100644 --- a/packages/ditto/controllers/api/statuses.ts +++ b/packages/ditto/controllers/api/statuses.ts @@ -633,7 +633,7 @@ const zappedByController: AppController = async (c) => { const { db, relay } = c.var; const id = c.req.param('id'); - const { offset, limit } = paginationSchema.parse(c.req.query()); + const { offset, limit } = paginationSchema().parse(c.req.query()); const zaps = await db.kysely.selectFrom('event_zaps') .selectAll() diff --git a/packages/ditto/controllers/api/suggestions.ts b/packages/ditto/controllers/api/suggestions.ts index cb6a8206..e288ad7b 100644 --- a/packages/ditto/controllers/api/suggestions.ts +++ b/packages/ditto/controllers/api/suggestions.ts @@ -9,7 +9,7 @@ import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; export const suggestionsV1Controller: AppController = async (c) => { const { signal } = c.var; - const { offset, limit } = paginationSchema.parse(c.req.query()); + const { offset, limit } = paginationSchema().parse(c.req.query()); const suggestions = await renderV2Suggestions(c, { offset, limit }, signal); const accounts = suggestions.map(({ account }) => account); return paginatedList(c, { offset, limit }, accounts); @@ -17,7 +17,7 @@ export const suggestionsV1Controller: AppController = async (c) => { export const suggestionsV2Controller: AppController = async (c) => { const { signal } = c.var; - const { offset, limit } = paginationSchema.parse(c.req.query()); + const { offset, limit } = paginationSchema().parse(c.req.query()); const suggestions = await renderV2Suggestions(c, { offset, limit }, signal); return paginatedList(c, { offset, limit }, suggestions); }; diff --git a/packages/ditto/controllers/api/trends.ts b/packages/ditto/controllers/api/trends.ts index 4e5810ed..ca59bd34 100644 --- a/packages/ditto/controllers/api/trends.ts +++ b/packages/ditto/controllers/api/trends.ts @@ -124,7 +124,7 @@ async function getTrendingLinks(conf: DittoConf, relay: NStore): Promise { const { conf, relay } = c.var; - const { limit, offset, until } = paginationSchema.parse(c.req.query()); + const { limit, offset, until } = paginationSchema().parse(c.req.query()); const [label] = await relay.query([{ kinds: [1985], diff --git a/packages/ditto/routes/pleromaStatusesRoute.ts b/packages/ditto/routes/pleromaStatusesRoute.ts index 7aa08a68..5d7fad38 100644 --- a/packages/ditto/routes/pleromaStatusesRoute.ts +++ b/packages/ditto/routes/pleromaStatusesRoute.ts @@ -1,4 +1,4 @@ -import { userMiddleware } from '@ditto/mastoapi/middleware'; +import { paginationMiddleware, userMiddleware } from '@ditto/mastoapi/middleware'; import { DittoRoute } from '@ditto/mastoapi/router'; import { DittoEvent } from '@/interfaces/DittoEvent.ts'; @@ -109,92 +109,97 @@ route.delete('/:id{[0-9a-f]{64}}/reactions/:emoji', userMiddleware(), async (c) * Get an object of emoji to account mappings with accounts that reacted to the post. * https://docs.pleroma.social/backend/development/API/pleroma_api/#get-apiv1pleromastatusesidreactions */ -route.get('/:id{[0-9a-f]{64}}/reactions/:emoji?', userMiddleware({ required: false }), async (c) => { - const { relay, user } = c.var; +route.get( + '/:id{[0-9a-f]{64}}/reactions/:emoji?', + paginationMiddleware({ limit: 100 }), + userMiddleware({ required: false }), + async (c) => { + const { relay, user, pagination, paginate } = c.var; - const params = c.req.param(); - const result = params.emoji ? parseEmojiParam(params.emoji) : undefined; - const pubkey = await user?.signer.getPublicKey(); + const params = c.req.param(); + const result = params.emoji ? parseEmojiParam(params.emoji) : undefined; + const pubkey = await user?.signer.getPublicKey(); - const events = await relay.query([{ kinds: [7], '#e': [params.id], limit: 100 }]) - .then((events) => - events.filter((event) => { - if (!result) return true; + const events = await relay.query([{ kinds: [7], '#e': [params.id], ...pagination }]) + .then((events) => + events.filter((event) => { + if (!result) return true; - switch (result.type) { - case 'native': - return event.content === result.native; - case 'custom': - return event.content === `:${result.shortcode}:`; - } - }) - ) - .then((events) => hydrateEvents({ ...c.var, events })); + switch (result.type) { + case 'native': + return event.content === result.native; + case 'custom': + return event.content === `:${result.shortcode}:`; + } + }) + ) + .then((events) => hydrateEvents({ ...c.var, events })); - /** Events grouped by emoji key. */ - const byEmojiKey = events.reduce((acc, event) => { - const result = parseEmojiInput(event.content); + /** Events grouped by emoji key. */ + const byEmojiKey = events.reduce((acc, event) => { + const result = parseEmojiInput(event.content); - if (!result || result.type === 'basic') { - return acc; - } - - let url: URL | undefined; - - if (result.type === 'custom') { - const tag = event.tags.find(([name, value]) => name === 'emoji' && value === result.shortcode); - try { - url = new URL(tag![2]); - } catch { + if (!result || result.type === 'basic') { return acc; } - } - let key: string; - switch (result.type) { - case 'native': - key = result.native; - break; - case 'custom': - key = `${result.shortcode}:${url}`; - break; - } + let url: URL | undefined; - acc[key] = acc[key] || []; - acc[key].push(event); - - return acc; - }, {} as Record); - - const results = await Promise.all( - Object.entries(byEmojiKey).map(async ([key, events]) => { - let name: string = key; - let url: string | undefined; - - // Custom emojis: `:` - try { - const [shortcode, ...rest] = key.split(':'); - - url = new URL(rest.join(':')).toString(); - name = shortcode; - } catch { - // fallthrough + if (result.type === 'custom') { + const tag = event.tags.find(([name, value]) => name === 'emoji' && value === result.shortcode); + try { + url = new URL(tag![2]); + } catch { + return acc; + } } - return { - name, - count: events.length, - me: pubkey && events.some((event) => event.pubkey === pubkey), - accounts: await Promise.all( - events.map((event) => event.author ? renderAccount(event.author) : accountFromPubkey(event.pubkey)), - ), - url, - }; - }), - ); + let key: string; + switch (result.type) { + case 'native': + key = result.native; + break; + case 'custom': + key = `${result.shortcode}:${url}`; + break; + } - return c.json(results); -}); + acc[key] = acc[key] || []; + acc[key].push(event); + + return acc; + }, {} as Record); + + const results = await Promise.all( + Object.entries(byEmojiKey).map(async ([key, events]) => { + let name: string = key; + let url: string | undefined; + + // Custom emojis: `:` + try { + const [shortcode, ...rest] = key.split(':'); + + url = new URL(rest.join(':')).toString(); + name = shortcode; + } catch { + // fallthrough + } + + return { + name, + count: events.length, + me: pubkey && events.some((event) => event.pubkey === pubkey), + accounts: await Promise.all( + events.map((event) => event.author ? renderAccount(event.author) : accountFromPubkey(event.pubkey)), + ), + url, + }; + }), + ); + + return paginate(events, results); + }, +); /** Determine if the input is a native or custom emoji, returning a structured object or throwing an error. */ function parseEmojiParam(input: string): diff --git a/packages/ditto/views.ts b/packages/ditto/views.ts index 79379e6c..030516db 100644 --- a/packages/ditto/views.ts +++ b/packages/ditto/views.ts @@ -41,7 +41,7 @@ async function renderEventAccounts(c: AppContext, filters: NostrFilter[], opts?: } async function renderAccounts(c: AppContext, pubkeys: string[]) { - const { offset, limit } = paginationSchema.parse(c.req.query()); + const { offset, limit } = paginationSchema().parse(c.req.query()); const authors = pubkeys.reverse().slice(offset, offset + limit); const { relay, signal } = c.var; diff --git a/packages/mastoapi/middleware/paginationMiddleware.ts b/packages/mastoapi/middleware/paginationMiddleware.ts index 28a7f1a1..01b222f3 100644 --- a/packages/mastoapi/middleware/paginationMiddleware.ts +++ b/packages/mastoapi/middleware/paginationMiddleware.ts @@ -1,5 +1,5 @@ import { paginated, paginatedList } from '../pagination/paginate.ts'; -import { paginationSchema } from '../pagination/schema.ts'; +import { paginationSchema, type PaginationSchemaOpts } from '../pagination/schema.ts'; import type { DittoMiddleware } from '@ditto/mastoapi/router'; import type { NostrEvent } from '@nostrify/nostrify'; @@ -19,19 +19,26 @@ type HeaderRecord = Record; type PaginateFn = (events: NostrEvent[], body: object | unknown[], headers?: HeaderRecord) => Response; type ListPaginateFn = (params: ListPagination, body: object | unknown[], headers?: HeaderRecord) => Response; +interface PaginationMiddlewareOpts extends PaginationSchemaOpts { + type?: string; +} + /** Fixes compatibility with Mastodon apps by that don't use `Link` headers. */ // @ts-ignore Types are right. export function paginationMiddleware(): DittoMiddleware<{ pagination: Pagination; paginate: PaginateFn }>; export function paginationMiddleware( - type: 'list', + opts: PaginationMiddlewareOpts & { type: 'list' }, ): DittoMiddleware<{ pagination: ListPagination; paginate: ListPaginateFn }>; export function paginationMiddleware( - type?: string, + opts?: PaginationMiddlewareOpts, +): DittoMiddleware<{ pagination: Pagination; paginate: PaginateFn }>; +export function paginationMiddleware( + opts: PaginationMiddlewareOpts = {}, ): DittoMiddleware<{ pagination?: Pagination | ListPagination; paginate: PaginateFn | ListPaginateFn }> { return async (c, next) => { const { relay } = c.var; - const pagination = paginationSchema.parse(c.req.query()); + const pagination = paginationSchema(opts).parse(c.req.query()); const { max_id: maxId, @@ -59,7 +66,7 @@ export function paginationMiddleware( } } - if (type === 'list') { + if (opts.type === 'list') { c.set('pagination', { limit: pagination.limit, offset: pagination.offset, diff --git a/packages/mastoapi/pagination/schema.test.ts b/packages/mastoapi/pagination/schema.test.ts index 94be9091..b17eca16 100644 --- a/packages/mastoapi/pagination/schema.test.ts +++ b/packages/mastoapi/pagination/schema.test.ts @@ -3,7 +3,7 @@ import { assertEquals } from '@std/assert'; import { paginationSchema } from './schema.ts'; Deno.test('paginationSchema', () => { - const pagination = paginationSchema.parse({ + const pagination = paginationSchema().parse({ limit: '10', offset: '20', max_id: '1', diff --git a/packages/mastoapi/pagination/schema.ts b/packages/mastoapi/pagination/schema.ts index 5647246d..e6b35ce4 100644 --- a/packages/mastoapi/pagination/schema.ts +++ b/packages/mastoapi/pagination/schema.ts @@ -9,15 +9,28 @@ export interface Pagination { offset: number; } +export interface PaginationSchemaOpts { + limit?: number; + max?: number; +} + /** Schema to parse pagination query params. */ -export const paginationSchema: z.ZodType = z.object({ - max_id: z.string().transform((val) => { - if (!val.includes('-')) return val; - return val.split('-')[1]; - }).optional().catch(undefined), - min_id: z.string().optional().catch(undefined), - since: z.coerce.number().nonnegative().optional().catch(undefined), - until: z.coerce.number().nonnegative().optional().catch(undefined), - limit: z.coerce.number().catch(20).transform((value) => Math.min(Math.max(value, 0), 40)), - offset: z.coerce.number().nonnegative().catch(0), -}) as z.ZodType; +export function paginationSchema(opts: PaginationSchemaOpts = {}): z.ZodType { + let { limit = 20, max = 40 } = opts; + + if (limit > max) { + max = limit; + } + + return z.object({ + max_id: z.string().transform((val) => { + if (!val.includes('-')) return val; + return val.split('-')[1]; + }).optional().catch(undefined), + min_id: z.string().optional().catch(undefined), + since: z.coerce.number().nonnegative().optional().catch(undefined), + until: z.coerce.number().nonnegative().optional().catch(undefined), + limit: z.coerce.number().catch(limit).transform((value) => Math.min(Math.max(value, 0), max)), + offset: z.coerce.number().nonnegative().catch(0), + }) as z.ZodType; +}