diff --git a/src/utils/api.ts b/src/utils/api.ts index dceede7..599163d 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -138,8 +138,8 @@ async function parseBody(req: Request): Promise { /** Schema to parse pagination query params. */ const paginationSchema = z.object({ - since: z.coerce.number().optional().catch(undefined), - until: z.coerce.number().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)), }); @@ -179,6 +179,47 @@ function paginated(c: AppContext, events: NostrEvent[], entities: (Entity | unde return c.json(results, 200, headers); } +/** Query params for paginating a list. */ +const listPaginationSchema = z.object({ + offset: z.coerce.number().nonnegative().catch(0), + limit: z.coerce.number().catch(20).transform((value) => Math.min(Math.max(value, 0), 40)), +}); + +/** Build HTTP Link header for paginating Nostr lists. */ +function buildListLinkHeader(url: string, params: { offset: number; limit: number }): string | undefined { + const { origin } = Conf.url; + const { pathname, search } = new URL(url); + const { offset, limit } = params; + const next = new URL(pathname + search, origin); + const prev = new URL(pathname + search, origin); + + next.searchParams.set('offset', String(offset + limit)); + prev.searchParams.set('offset', String(Math.max(offset - limit, 0))); + + next.searchParams.set('limit', String(limit)); + prev.searchParams.set('limit', String(limit)); + + return `<${next}>; rel="next", <${prev}>; rel="prev"`; +} + +/** paginate a list of tags. */ +function paginatedList( + c: AppContext, + params: { offset: number; limit: number }, + entities: (Entity | undefined)[], + headers: HeaderRecord = {}, +) { + const link = buildListLinkHeader(c.req.url, params); + + if (link) { + headers.link = link; + } + + // Filter out undefined entities. + const results = entities.filter((entity): entity is Entity => Boolean(entity)); + return c.json(results, 200, headers); +} + /** JSON-LD context. */ type LDContext = (string | Record>)[]; @@ -209,8 +250,10 @@ export { createAdminEvent, createEvent, type EventStub, + listPaginationSchema, localRequest, paginated, + paginatedList, type PaginationParams, paginationSchema, parseBody, diff --git a/src/views.ts b/src/views.ts index 5b6d8c3..b49f13e 100644 --- a/src/views.ts +++ b/src/views.ts @@ -4,7 +4,7 @@ import { AppContext } from '@/app.ts'; import { Storages } from '@/storages.ts'; import { renderAccount } from '@/views/mastodon/accounts.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; -import { paginated, paginationSchema } from '@/utils/api.ts'; +import { listPaginationSchema, paginated, paginatedList, paginationSchema } from '@/utils/api.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; import { accountFromPubkey } from '@/views/mastodon/accounts.ts'; @@ -42,12 +42,14 @@ async function renderEventAccounts(c: AppContext, filters: NostrFilter[], opts?: return paginated(c, events, accounts); } -async function renderAccounts(c: AppContext, authors: string[], signal = AbortSignal.timeout(1000)) { - const { since, until, limit } = paginationSchema.parse(c.req.query()); +async function renderAccounts(c: AppContext, pubkeys: string[]) { + const { offset, limit } = listPaginationSchema.parse(c.req.query()); + const authors = pubkeys.slice(offset, offset + limit); const store = await Storages.db(); + const signal = c.req.raw.signal; - const events = await store.query([{ kinds: [0], authors, since, until, limit }], { signal }) + const events = await store.query([{ kinds: [0], authors }], { signal }) .then((events) => hydrateEvents({ events, store, signal })); const accounts = await Promise.all( @@ -61,7 +63,7 @@ async function renderAccounts(c: AppContext, authors: string[], signal = AbortSi }), ); - return paginated(c, events, accounts); + return paginatedList(c, { offset, limit }, accounts); } /** Render statuses by event IDs. */