From e2ce9d0dab4e819a19744e2890e333853bf9a935 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 17 Feb 2025 23:03:59 -0600 Subject: [PATCH] Add paginationMiddleware, getting warmer --- packages/api/deno.json | 2 +- packages/api/middleware/mod.ts | 1 + .../api/middleware/paginationMiddleware.ts | 80 +++++++++++++++++++ packages/api/pagination/mod.ts | 3 + packages/api/pagination/schema.test.ts | 23 ++++++ .../pagination/schema.ts} | 0 packages/api/routes/timelinesRoute.ts | 34 ++++---- .../ditto/middleware/paginationMiddleware.ts | 48 ----------- 8 files changed, 126 insertions(+), 65 deletions(-) create mode 100644 packages/api/middleware/paginationMiddleware.ts create mode 100644 packages/api/pagination/schema.test.ts rename packages/{ditto/schemas/pagination.ts => api/pagination/schema.ts} (100%) delete mode 100644 packages/ditto/middleware/paginationMiddleware.ts diff --git a/packages/api/deno.json b/packages/api/deno.json index ccd831bc..43e04033 100644 --- a/packages/api/deno.json +++ b/packages/api/deno.json @@ -4,7 +4,7 @@ "exports": { ".": "./mod.ts", "./middleware": "./middleware/mod.ts", - "./pagination": "./pagination.ts", + "./pagination": "./pagination/mod.ts", "./routes": "./routes/mod.ts", "./schema": "./schema.ts", "./views": "./views/mod.ts" diff --git a/packages/api/middleware/mod.ts b/packages/api/middleware/mod.ts index da56cab9..5ad9efff 100644 --- a/packages/api/middleware/mod.ts +++ b/packages/api/middleware/mod.ts @@ -1,2 +1,3 @@ +export { paginationMiddleware } from './paginationMiddleware.ts'; export { requireVar } from './requireVar.ts'; export { userMiddleware } from './userMiddleware.ts'; diff --git a/packages/api/middleware/paginationMiddleware.ts b/packages/api/middleware/paginationMiddleware.ts new file mode 100644 index 00000000..87567289 --- /dev/null +++ b/packages/api/middleware/paginationMiddleware.ts @@ -0,0 +1,80 @@ +import { paginated, paginatedList, paginationSchema } from '@ditto/api/pagination'; + +import type { NostrEvent } from '@nostrify/nostrify'; +import type { DittoMiddleware } from '../DittoMiddleware.ts'; + +interface Pagination { + since?: number; + until?: number; + limit: number; +} + +interface ListPagination { + limit: number; + offset: number; +} + +type HeaderRecord = Record; +type PaginateFn = (events: NostrEvent[], body: object | unknown[], headers?: HeaderRecord) => Response; +type ListPaginateFn = (params: ListPagination, body: object | unknown[], headers?: HeaderRecord) => Response; + +/** 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', +): DittoMiddleware<{ pagination: ListPagination; paginate: ListPaginateFn }>; +export function paginationMiddleware( + type?: string, +): DittoMiddleware<{ pagination?: Pagination | ListPagination; paginate: PaginateFn | ListPaginateFn }> { + return async (c, next) => { + const { relay } = c.var; + + const pagination = paginationSchema.parse(c.req.query()); + + const { + max_id: maxId, + min_id: minId, + since, + until, + } = pagination; + + if ((maxId && !until) || (minId && !since)) { + const ids: string[] = []; + + if (maxId) ids.push(maxId); + if (minId) ids.push(minId); + + if (ids.length) { + const events = await relay.query( + [{ ids, limit: ids.length }], + { signal: c.req.raw.signal }, + ); + + for (const event of events) { + if (!until && maxId === event.id) pagination.until = event.created_at; + if (!since && minId === event.id) pagination.since = event.created_at; + } + } + } + + if (type === 'list') { + c.set('pagination', { + limit: pagination.limit, + offset: pagination.offset, + }); + const fn: ListPaginateFn = (params, body, headers) => paginatedList(c, params, body, headers); + c.set('paginate', fn); + } else { + c.set('pagination', { + since: pagination.since, + until: pagination.until, + limit: pagination.limit, + }); + const fn: PaginateFn = (events, body, headers) => paginated(c, events, body, headers); + c.set('paginate', fn); + } + + await next(); + }; +} diff --git a/packages/api/pagination/mod.ts b/packages/api/pagination/mod.ts index e69de29b..18998a36 100644 --- a/packages/api/pagination/mod.ts +++ b/packages/api/pagination/mod.ts @@ -0,0 +1,3 @@ +export { buildLinkHeader, buildListLinkHeader } from './link-header.ts'; +export { paginated, paginatedList } from './paginate.ts'; +export { paginationSchema } from './schema.ts'; diff --git a/packages/api/pagination/schema.test.ts b/packages/api/pagination/schema.test.ts new file mode 100644 index 00000000..94be9091 --- /dev/null +++ b/packages/api/pagination/schema.test.ts @@ -0,0 +1,23 @@ +import { assertEquals } from '@std/assert'; + +import { paginationSchema } from './schema.ts'; + +Deno.test('paginationSchema', () => { + const pagination = paginationSchema.parse({ + limit: '10', + offset: '20', + max_id: '1', + min_id: '2', + since: '3', + until: '4', + }); + + assertEquals(pagination, { + limit: 10, + offset: 20, + max_id: '1', + min_id: '2', + since: 3, + until: 4, + }); +}); diff --git a/packages/ditto/schemas/pagination.ts b/packages/api/pagination/schema.ts similarity index 100% rename from packages/ditto/schemas/pagination.ts rename to packages/api/pagination/schema.ts diff --git a/packages/api/routes/timelinesRoute.ts b/packages/api/routes/timelinesRoute.ts index 31bbaa35..629d5ffa 100644 --- a/packages/api/routes/timelinesRoute.ts +++ b/packages/api/routes/timelinesRoute.ts @@ -1,18 +1,21 @@ import { DittoRoute } from '@ditto/api'; -import { userMiddleware } from '@ditto/api/middleware'; +import { paginationMiddleware, userMiddleware } from '@ditto/api/middleware'; import { booleanParamSchema, languageSchema } from '@ditto/api/schema'; +import { getTagSet } from '@ditto/utils/tags'; import { z } from 'zod'; +import type { Context } from '@hono/hono'; import type { NostrFilter } from '@nostrify/nostrify'; +import type { DittoEnv } from '../DittoEnv.ts'; -const route = new DittoRoute().use(userMiddleware({ privileged: false, required: true })); +const route = new DittoRoute().use(paginationMiddleware()); const homeQuerySchema = z.object({ exclude_replies: booleanParamSchema.optional(), only_media: booleanParamSchema.optional(), }); -route.get('/home', async (c) => { +route.get('/home', userMiddleware({ privileged: false, required: true }), async (c) => { const { user, pagination } = c.var; const pubkey = await user?.signer.getPublicKey()!; @@ -50,7 +53,7 @@ const publicQuerySchema = z.object({ language: languageSchema.optional(), }); -const publicTimelineController: AppController = (c) => { +route.get('/public', (c) => { const { conf, pagination } = c.var; const result = publicQuerySchema.safeParse(c.req.query()); @@ -79,30 +82,29 @@ const publicTimelineController: AppController = (c) => { } return renderStatuses(c, [filter]); -}; +}); -const hashtagTimelineController: AppController = (c) => { +route.get('/tag/:hashtag', (c) => { const { pagination } = c.var; - const hashtag = c.req.param('hashtag')!.toLowerCase(); + const hashtag = c.req.param('hashtag').toLowerCase(); + return renderStatuses(c, [{ kinds: [1, 20], '#t': [hashtag], ...pagination }]); -}; +}); -const suggestedTimelineController: AppController = async (c) => { - const { conf } = c.var; - const store = c.get('store'); - const params = c.get('pagination'); +route.get('/suggested', async (c) => { + const { conf, relay, pagination } = c.var; - const [follows] = await store.query( + const [follows] = await relay.query( [{ kinds: [3], authors: [conf.pubkey], limit: 1 }], ); const authors = [...getTagSet(follows?.tags ?? [], 'p')]; - return renderStatuses(c, [{ authors, kinds: [1, 20], ...params }]); -}; + return renderStatuses(c, [{ authors, kinds: [1, 20], ...pagination }]); +}); /** Render statuses for timelines. */ -async function renderStatuses(c: AppContext, filters: NostrFilter[]) { +async function renderStatuses(c: Context, filters: NostrFilter[]) { const { conf, store, user, signal } = c.var; const opts = { signal, timeout: conf.db.timeouts.timelines }; diff --git a/packages/ditto/middleware/paginationMiddleware.ts b/packages/ditto/middleware/paginationMiddleware.ts deleted file mode 100644 index 498a2d76..00000000 --- a/packages/ditto/middleware/paginationMiddleware.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { AppMiddleware } from '@/app.ts'; -import { paginationSchema } from '@/schemas/pagination.ts'; - -/** Fixes compatibility with Mastodon apps by that don't use `Link` headers. */ -export const paginationMiddleware: AppMiddleware = async (c, next) => { - const { store } = c.var; - - const pagination = paginationSchema.parse(c.req.query()); - - const { - max_id: maxId, - min_id: minId, - since, - until, - } = pagination; - - if ((maxId && !until) || (minId && !since)) { - const ids: string[] = []; - - if (maxId) ids.push(maxId); - if (minId) ids.push(minId); - - if (ids.length) { - const events = await store.query( - [{ ids, limit: ids.length }], - { signal: c.req.raw.signal }, - ); - - for (const event of events) { - if (!until && maxId === event.id) pagination.until = event.created_at; - if (!since && minId === event.id) pagination.since = event.created_at; - } - } - } - - c.set('pagination', { - since: pagination.since, - until: pagination.until, - limit: pagination.limit, - }); - - c.set('listPagination', { - limit: pagination.limit, - offset: pagination.offset, - }); - - await next(); -};