diff --git a/deno.lock b/deno.lock index b46ce6da..2a2a3da1 100644 --- a/deno.lock +++ b/deno.lock @@ -99,6 +99,7 @@ "npm:@scure/base@^1.1.6": "1.1.6", "npm:@scure/bip32@^1.4.0": "1.4.0", "npm:@scure/bip39@^1.3.0": "1.3.0", + "npm:@types/http-link-header@*": "1.0.7", "npm:@types/node@*": "22.5.4", "npm:blurhash@2.0.5": "2.0.5", "npm:comlink-async-generator@*": "0.0.1", @@ -109,6 +110,7 @@ "npm:fast-stable-stringify@1": "1.0.0", "npm:formdata-helper@0.3": "0.3.0", "npm:hono-rate-limiter@0.3": "0.3.0_hono@4.2.5", + "npm:http-link-header@*": "1.1.3", "npm:iso-639-1@^3.1.5": "3.1.5", "npm:isomorphic-dompurify@^2.16.0": "2.16.0", "npm:kysely-postgres-js@2.0.0": "2.0.0_kysely@0.27.3_postgres@3.4.4", @@ -994,6 +996,12 @@ "@types/trusted-types" ] }, + "@types/http-link-header@1.0.7": { + "integrity": "sha512-snm5oLckop0K3cTDAiBnZDy6ncx9DJ3mCRDvs42C884MbVYPP74Tiq2hFsSDRTyjK6RyDYDIulPiW23ge+g5Lw==", + "dependencies": [ + "@types/node" + ] + }, "@types/node@22.5.4": { "integrity": "sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==", "dependencies": [ @@ -1255,6 +1263,9 @@ "entities" ] }, + "http-link-header@1.1.3": { + "integrity": "sha512-3cZ0SRL8fb9MUlU3mKM61FcQvPfXx2dBrZW3Vbg5CXa8jFlK8OaEpePenLe1oEXQduhz8b0QjsqfS59QP4AJDQ==" + }, "http-proxy-agent@7.0.2": { "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dependencies": [ diff --git a/packages/api/DittoApp.test.ts b/packages/api/DittoApp.test.ts index 9bab8eab..83da5bca 100644 --- a/packages/api/DittoApp.test.ts +++ b/packages/api/DittoApp.test.ts @@ -9,9 +9,9 @@ import { DittoRoute } from './DittoRoute.ts'; Deno.test('DittoApp', async () => { await using db = DittoDB.create('memory://'); const conf = new DittoConf(new Map()); - const store = new MockRelay(); + const relay = new MockRelay(); - const app = new DittoApp({ conf, db, store }); + const app = new DittoApp({ conf, db, relay }); const hono = new Hono(); const route = new DittoRoute(); diff --git a/packages/api/DittoEnv.ts b/packages/api/DittoEnv.ts index fc35bb58..761bc3f8 100644 --- a/packages/api/DittoEnv.ts +++ b/packages/api/DittoEnv.ts @@ -1,24 +1,20 @@ import type { DittoConf } from '@ditto/conf'; import type { DittoDatabase } from '@ditto/db'; import type { Env } from '@hono/hono'; -import type { NostrSigner, NRelay, NStore } from '@nostrify/nostrify'; +import type { NRelay } from '@nostrify/nostrify'; export interface DittoEnv extends Env { Variables: { /** Ditto site configuration. */ conf: DittoConf; - /** Main database. */ - store: NRelay; - /** Database object. */ + /** Relay store. */ + relay: NRelay; + /** + * Database object. + * @deprecated Store data as Nostr events instead. + */ db: DittoDatabase; /** Abort signal for the request. */ signal: AbortSignal; - /** The current user */ - user?: { - /** The user's signer. */ - signer: NostrSigner; - /** The user's store. */ - store: NStore; - }; }; } diff --git a/packages/api/deno.json b/packages/api/deno.json index 9dc56068..ccd831bc 100644 --- a/packages/api/deno.json +++ b/packages/api/deno.json @@ -4,6 +4,7 @@ "exports": { ".": "./mod.ts", "./middleware": "./middleware/mod.ts", + "./pagination": "./pagination.ts", "./routes": "./routes/mod.ts", "./schema": "./schema.ts", "./views": "./views/mod.ts" diff --git a/packages/api/middleware/userMiddleware.ts b/packages/api/middleware/userMiddleware.ts index f8ff997e..584acf9b 100644 --- a/packages/api/middleware/userMiddleware.ts +++ b/packages/api/middleware/userMiddleware.ts @@ -8,16 +8,10 @@ import type { DittoMiddleware } from '../DittoMiddleware.ts'; interface User { signer: NostrSigner; - store: NStore; -} - -interface UserMiddlewareOpts { - /** Returns a 401 response if no user can be determined. */ - required?: boolean; - /** Whether the user must prove themselves with a NIP-98 auth challenge. */ - privileged: boolean; + relay: NStore; } +// @ts-ignore The types are right. export function userMiddleware(opts: { privileged: false; required: true }): DittoMiddleware<{ user: User }>; export function userMiddleware(opts: { privileged: true; required?: boolean }): DittoMiddleware<{ user: User }>; export function userMiddleware(opts: { privileged: false; required?: boolean }): DittoMiddleware<{ user?: User }>; @@ -28,7 +22,7 @@ export function userMiddleware(opts: { privileged: boolean; required?: boolean } const { privileged, required = false } = opts; return async (c, next) => { - const { conf, db, store } = c.var; + const { conf, db, relay } = c.var; const header = c.req.header('authorization'); const match = header?.match(BEARER_REGEX); @@ -55,7 +49,7 @@ export function userMiddleware(opts: { privileged: boolean; required?: boolean } userPubkey, signer: new NSecSigner(nep46Seckey), relays: nip46_relays, - relay: store, + relay, }); } catch { throw new HTTPException(401); @@ -82,9 +76,9 @@ export function userMiddleware(opts: { privileged: boolean; required?: boolean } } if (signer) { - const user: User = { signer, store }; + const user: User = { signer, relay }; c.set('user', user); - } else if (required) { + } else if (privileged || required) { throw new HTTPException(401); } diff --git a/packages/api/pagination/link-header.test.ts b/packages/api/pagination/link-header.test.ts new file mode 100644 index 00000000..db41eaa0 --- /dev/null +++ b/packages/api/pagination/link-header.test.ts @@ -0,0 +1,34 @@ +import { genEvent } from '@nostrify/nostrify/test'; +import { assertEquals } from '@std/assert'; + +import { buildLinkHeader, buildListLinkHeader } from './link-header.ts'; + +Deno.test('buildLinkHeader', () => { + const url = 'https://ditto.test/api/v1/events'; + + const events = [ + genEvent({ created_at: 1 }), + genEvent({ created_at: 2 }), + genEvent({ created_at: 3 }), + ]; + + const link = buildLinkHeader(url, events); + + assertEquals( + link?.toString(), + '; rel="next", ; rel="prev"', + ); +}); + +Deno.test('buildListLinkHeader', () => { + const url = 'https://ditto.test/api/v1/tags'; + + const params = { offset: 0, limit: 3 }; + + const link = buildListLinkHeader(url, params); + + assertEquals( + link?.toString(), + '; rel="next", ; rel="prev"', + ); +}); diff --git a/packages/api/pagination/link-header.ts b/packages/api/pagination/link-header.ts new file mode 100644 index 00000000..648b4aab --- /dev/null +++ b/packages/api/pagination/link-header.ts @@ -0,0 +1,39 @@ +import type { NostrEvent } from '@nostrify/nostrify'; + +/** Build HTTP Link header for Mastodon API pagination. */ +export function buildLinkHeader(url: string, events: NostrEvent[]): string | undefined { + if (events.length <= 1) return; + + const firstEvent = events[0]; + const lastEvent = events[events.length - 1]; + + const { pathname, search } = new URL(url); + + const next = new URL(pathname + search, url); + const prev = new URL(pathname + search, url); + + next.searchParams.set('until', String(lastEvent.created_at)); + prev.searchParams.set('since', String(firstEvent.created_at)); + + return `<${next}>; rel="next", <${prev}>; rel="prev"`; +} + +/** Build HTTP Link header for paginating Nostr lists. */ +export function buildListLinkHeader( + url: string, + params: { offset: number; limit: number }, +): string | undefined { + const { pathname, search } = new URL(url); + const { offset, limit } = params; + + const next = new URL(pathname + search, url); + const prev = new URL(pathname + search, url); + + 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"`; +} diff --git a/packages/api/pagination/mod.ts b/packages/api/pagination/mod.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/api/pagination/paginate.test.ts b/packages/api/pagination/paginate.test.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/api/pagination/paginate.ts b/packages/api/pagination/paginate.ts new file mode 100644 index 00000000..2da2e478 --- /dev/null +++ b/packages/api/pagination/paginate.ts @@ -0,0 +1,43 @@ +import { buildLinkHeader, buildListLinkHeader } from './link-header.ts'; + +import type { Context } from '@hono/hono'; +import type { NostrEvent } from '@nostrify/nostrify'; + +type HeaderRecord = Record; + +/** Return results with pagination headers. Assumes chronological sorting of events. */ +export function paginated( + c: Context, + events: NostrEvent[], + body: object | unknown[], + headers: HeaderRecord = {}, +): Response { + const link = buildLinkHeader(c.req.url, events); + + if (link) { + headers.link = link; + } + + // Filter out undefined entities. + const results = Array.isArray(body) ? body.filter(Boolean) : body; + return c.json(results, 200, headers); +} + +/** paginate a list of tags. */ +export function paginatedList( + c: Context, + params: { offset: number; limit: number }, + body: object | unknown[], + headers: HeaderRecord = {}, +): Response { + const link = buildListLinkHeader(c.req.url, params); + const hasMore = Array.isArray(body) ? body.length > 0 : true; + + if (link) { + headers.link = hasMore ? link : link.split(', ').find((link) => link.endsWith('; rel="prev"'))!; + } + + // Filter out undefined entities. + const results = Array.isArray(body) ? body.filter(Boolean) : body; + return c.json(results, 200, headers); +} diff --git a/packages/api/routes/timelinesRoute.ts b/packages/api/routes/timelinesRoute.ts index 3147109a..31bbaa35 100644 --- a/packages/api/routes/timelinesRoute.ts +++ b/packages/api/routes/timelinesRoute.ts @@ -1,21 +1,21 @@ import { DittoRoute } from '@ditto/api'; -import { requireVar, userMiddleware } from '@ditto/api/middleware'; +import { userMiddleware } from '@ditto/api/middleware'; import { booleanParamSchema, languageSchema } from '@ditto/api/schema'; import { z } from 'zod'; import type { NostrFilter } from '@nostrify/nostrify'; -const route = new DittoRoute(); +const route = new DittoRoute().use(userMiddleware({ privileged: false, required: true })); const homeQuerySchema = z.object({ exclude_replies: booleanParamSchema.optional(), only_media: booleanParamSchema.optional(), }); -route.get('/home', requireVar('user'), async (c) => { +route.get('/home', async (c) => { const { user, pagination } = c.var; - const pubkey = await user.signer.getPublicKey()!; + const pubkey = await user?.signer.getPublicKey()!; const result = homeQuerySchema.safeParse(c.req.query()); if (!result.success) { diff --git a/packages/ditto/test.ts b/packages/ditto/test.ts index de101185..5d0b714c 100644 --- a/packages/ditto/test.ts +++ b/packages/ditto/test.ts @@ -3,10 +3,9 @@ import { DittoDB } from '@ditto/db'; import ISO6391, { LanguageCode } from 'iso-639-1'; import lande from 'lande'; import { NostrEvent } from '@nostrify/nostrify'; -import { finalizeEvent, generateSecretKey, nip19 } from 'nostr-tools'; +import { generateSecretKey, nip19 } from 'nostr-tools'; import { EventsDB } from '@/storages/EventsDB.ts'; -import { purifyEvent } from '../utils/purify.ts'; import { sql } from 'kysely'; /** Import an event fixture by name in tests. */ @@ -21,19 +20,6 @@ export async function jsonlEvents(path: string): Promise { return data.split('\n').map((line) => JSON.parse(line)); } -/** Generate an event for use in tests. */ -export function genEvent(t: Partial = {}, sk: Uint8Array = generateSecretKey()): NostrEvent { - const event = finalizeEvent({ - kind: 255, - created_at: 0, - content: '', - tags: [], - ...t, - }, sk); - - return purifyEvent(event); -} - /** Create a database for testing. It uses `DATABASE_URL`, or creates an in-memory database by default. */ export async function createTestDB(opts?: { conf?: DittoConf; pure?: boolean }) { const conf = opts?.conf ?? testConf(); diff --git a/packages/utils/api.ts b/packages/utils/api.ts index 62677d66..5f24c004 100644 --- a/packages/utils/api.ts +++ b/packages/utils/api.ts @@ -210,82 +210,6 @@ async function parseBody(req: Request): Promise { } } -/** Build HTTP Link header for Mastodon API pagination. */ -function buildLinkHeader(origin: string, url: string, events: NostrEvent[]): string | undefined { - if (events.length <= 1) return; - const firstEvent = events[0]; - const lastEvent = events[events.length - 1]; - - const { pathname, search } = new URL(url); - const next = new URL(pathname + search, origin); - const prev = new URL(pathname + search, origin); - - next.searchParams.set('until', String(lastEvent.created_at)); - prev.searchParams.set('since', String(firstEvent.created_at)); - - return `<${next}>; rel="next", <${prev}>; rel="prev"`; -} - -type HeaderRecord = Record; - -/** Return results with pagination headers. Assumes chronological sorting of events. */ -function paginated(c: AppContext, events: NostrEvent[], body: object | unknown[], headers: HeaderRecord = {}) { - const { conf } = c.var; - const { origin } = conf.url; - - const link = buildLinkHeader(origin, c.req.url, events); - - if (link) { - headers.link = link; - } - - // Filter out undefined entities. - const results = Array.isArray(body) ? body.filter(Boolean) : body; - return c.json(results, 200, headers); -} - -/** Build HTTP Link header for paginating Nostr lists. */ -function buildListLinkHeader( - origin: string, - url: string, - params: { offset: number; limit: number }, -): string | undefined { - 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 }, - body: object | unknown[], - headers: HeaderRecord = {}, -) { - const { conf } = c.var; - const { origin } = conf.url; - - const link = buildListLinkHeader(origin, c.req.url, params); - const hasMore = Array.isArray(body) ? body.length > 0 : true; - - if (link) { - headers.link = hasMore ? link : link.split(', ').find((link) => link.endsWith('; rel="prev"'))!; - } - - // Filter out undefined entities. - const results = Array.isArray(body) ? body.filter(Boolean) : body; - return c.json(results, 200, headers); -} - /** Rewrite the URL of the request object to use the local domain. */ function localRequest(c: Context): Request { const { conf } = c.var;