Add link-header module

This commit is contained in:
Alex Gleason 2025-02-17 22:15:32 -06:00
parent b360dfaf06
commit 316e3e287f
No known key found for this signature in database
GPG key ID: 7211D1F99744FBB7
13 changed files with 148 additions and 120 deletions

11
deno.lock generated
View file

@ -99,6 +99,7 @@
"npm:@scure/base@^1.1.6": "1.1.6", "npm:@scure/base@^1.1.6": "1.1.6",
"npm:@scure/bip32@^1.4.0": "1.4.0", "npm:@scure/bip32@^1.4.0": "1.4.0",
"npm:@scure/bip39@^1.3.0": "1.3.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:@types/node@*": "22.5.4",
"npm:blurhash@2.0.5": "2.0.5", "npm:blurhash@2.0.5": "2.0.5",
"npm:comlink-async-generator@*": "0.0.1", "npm:comlink-async-generator@*": "0.0.1",
@ -109,6 +110,7 @@
"npm:fast-stable-stringify@1": "1.0.0", "npm:fast-stable-stringify@1": "1.0.0",
"npm:formdata-helper@0.3": "0.3.0", "npm:formdata-helper@0.3": "0.3.0",
"npm:hono-rate-limiter@0.3": "0.3.0_hono@4.2.5", "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:iso-639-1@^3.1.5": "3.1.5",
"npm:isomorphic-dompurify@^2.16.0": "2.16.0", "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", "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/trusted-types"
] ]
}, },
"@types/http-link-header@1.0.7": {
"integrity": "sha512-snm5oLckop0K3cTDAiBnZDy6ncx9DJ3mCRDvs42C884MbVYPP74Tiq2hFsSDRTyjK6RyDYDIulPiW23ge+g5Lw==",
"dependencies": [
"@types/node"
]
},
"@types/node@22.5.4": { "@types/node@22.5.4": {
"integrity": "sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==", "integrity": "sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==",
"dependencies": [ "dependencies": [
@ -1255,6 +1263,9 @@
"entities" "entities"
] ]
}, },
"http-link-header@1.1.3": {
"integrity": "sha512-3cZ0SRL8fb9MUlU3mKM61FcQvPfXx2dBrZW3Vbg5CXa8jFlK8OaEpePenLe1oEXQduhz8b0QjsqfS59QP4AJDQ=="
},
"http-proxy-agent@7.0.2": { "http-proxy-agent@7.0.2": {
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"dependencies": [ "dependencies": [

View file

@ -9,9 +9,9 @@ import { DittoRoute } from './DittoRoute.ts';
Deno.test('DittoApp', async () => { Deno.test('DittoApp', async () => {
await using db = DittoDB.create('memory://'); await using db = DittoDB.create('memory://');
const conf = new DittoConf(new Map()); 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 hono = new Hono();
const route = new DittoRoute(); const route = new DittoRoute();

View file

@ -1,24 +1,20 @@
import type { DittoConf } from '@ditto/conf'; import type { DittoConf } from '@ditto/conf';
import type { DittoDatabase } from '@ditto/db'; import type { DittoDatabase } from '@ditto/db';
import type { Env } from '@hono/hono'; 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 { export interface DittoEnv extends Env {
Variables: { Variables: {
/** Ditto site configuration. */ /** Ditto site configuration. */
conf: DittoConf; conf: DittoConf;
/** Main database. */ /** Relay store. */
store: NRelay; relay: NRelay;
/** Database object. */ /**
* Database object.
* @deprecated Store data as Nostr events instead.
*/
db: DittoDatabase; db: DittoDatabase;
/** Abort signal for the request. */ /** Abort signal for the request. */
signal: AbortSignal; signal: AbortSignal;
/** The current user */
user?: {
/** The user's signer. */
signer: NostrSigner;
/** The user's store. */
store: NStore;
};
}; };
} }

View file

@ -4,6 +4,7 @@
"exports": { "exports": {
".": "./mod.ts", ".": "./mod.ts",
"./middleware": "./middleware/mod.ts", "./middleware": "./middleware/mod.ts",
"./pagination": "./pagination.ts",
"./routes": "./routes/mod.ts", "./routes": "./routes/mod.ts",
"./schema": "./schema.ts", "./schema": "./schema.ts",
"./views": "./views/mod.ts" "./views": "./views/mod.ts"

View file

@ -8,16 +8,10 @@ import type { DittoMiddleware } from '../DittoMiddleware.ts';
interface User { interface User {
signer: NostrSigner; signer: NostrSigner;
store: NStore; relay: 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;
} }
// @ts-ignore The types are right.
export function userMiddleware(opts: { privileged: false; required: true }): DittoMiddleware<{ user: User }>; 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: true; required?: boolean }): DittoMiddleware<{ user: User }>;
export function userMiddleware(opts: { privileged: false; 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; const { privileged, required = false } = opts;
return async (c, next) => { return async (c, next) => {
const { conf, db, store } = c.var; const { conf, db, relay } = c.var;
const header = c.req.header('authorization'); const header = c.req.header('authorization');
const match = header?.match(BEARER_REGEX); const match = header?.match(BEARER_REGEX);
@ -55,7 +49,7 @@ export function userMiddleware(opts: { privileged: boolean; required?: boolean }
userPubkey, userPubkey,
signer: new NSecSigner(nep46Seckey), signer: new NSecSigner(nep46Seckey),
relays: nip46_relays, relays: nip46_relays,
relay: store, relay,
}); });
} catch { } catch {
throw new HTTPException(401); throw new HTTPException(401);
@ -82,9 +76,9 @@ export function userMiddleware(opts: { privileged: boolean; required?: boolean }
} }
if (signer) { if (signer) {
const user: User = { signer, store }; const user: User = { signer, relay };
c.set('user', user); c.set('user', user);
} else if (required) { } else if (privileged || required) {
throw new HTTPException(401); throw new HTTPException(401);
} }

View file

@ -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(),
'<https://ditto.test/api/v1/events?until=3>; rel="next", <https://ditto.test/api/v1/events?since=1>; 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(),
'<https://ditto.test/api/v1/tags?offset=3&limit=3>; rel="next", <https://ditto.test/api/v1/tags?offset=0&limit=3>; rel="prev"',
);
});

View file

@ -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"`;
}

View file

View file

View file

@ -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<string, string | string[]>;
/** 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);
}

View file

@ -1,21 +1,21 @@
import { DittoRoute } from '@ditto/api'; 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 { booleanParamSchema, languageSchema } from '@ditto/api/schema';
import { z } from 'zod'; import { z } from 'zod';
import type { NostrFilter } from '@nostrify/nostrify'; import type { NostrFilter } from '@nostrify/nostrify';
const route = new DittoRoute(); const route = new DittoRoute().use(userMiddleware({ privileged: false, required: true }));
const homeQuerySchema = z.object({ const homeQuerySchema = z.object({
exclude_replies: booleanParamSchema.optional(), exclude_replies: booleanParamSchema.optional(),
only_media: booleanParamSchema.optional(), only_media: booleanParamSchema.optional(),
}); });
route.get('/home', requireVar('user'), async (c) => { route.get('/home', async (c) => {
const { user, pagination } = c.var; const { user, pagination } = c.var;
const pubkey = await user.signer.getPublicKey()!; const pubkey = await user?.signer.getPublicKey()!;
const result = homeQuerySchema.safeParse(c.req.query()); const result = homeQuerySchema.safeParse(c.req.query());
if (!result.success) { if (!result.success) {

View file

@ -3,10 +3,9 @@ import { DittoDB } from '@ditto/db';
import ISO6391, { LanguageCode } from 'iso-639-1'; import ISO6391, { LanguageCode } from 'iso-639-1';
import lande from 'lande'; import lande from 'lande';
import { NostrEvent } from '@nostrify/nostrify'; 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 { EventsDB } from '@/storages/EventsDB.ts';
import { purifyEvent } from '../utils/purify.ts';
import { sql } from 'kysely'; import { sql } from 'kysely';
/** Import an event fixture by name in tests. */ /** Import an event fixture by name in tests. */
@ -21,19 +20,6 @@ export async function jsonlEvents(path: string): Promise<NostrEvent[]> {
return data.split('\n').map((line) => JSON.parse(line)); return data.split('\n').map((line) => JSON.parse(line));
} }
/** Generate an event for use in tests. */
export function genEvent(t: Partial<NostrEvent> = {}, 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. */ /** 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 }) { export async function createTestDB(opts?: { conf?: DittoConf; pure?: boolean }) {
const conf = opts?.conf ?? testConf(); const conf = opts?.conf ?? testConf();

View file

@ -210,82 +210,6 @@ async function parseBody(req: Request): Promise<unknown> {
} }
} }
/** 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<string, string | string[]>;
/** 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. */ /** Rewrite the URL of the request object to use the local domain. */
function localRequest(c: Context): Request { function localRequest(c: Context): Request {
const { conf } = c.var; const { conf } = c.var;