mirror of
https://gitlab.com/soapbox-pub/ditto.git
synced 2025-12-06 03:19:46 +00:00
Add link-header module
This commit is contained in:
parent
b360dfaf06
commit
316e3e287f
13 changed files with 148 additions and 120 deletions
11
deno.lock
generated
11
deno.lock
generated
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
34
packages/api/pagination/link-header.test.ts
Normal file
34
packages/api/pagination/link-header.test.ts
Normal 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"',
|
||||
);
|
||||
});
|
||||
39
packages/api/pagination/link-header.ts
Normal file
39
packages/api/pagination/link-header.ts
Normal 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"`;
|
||||
}
|
||||
0
packages/api/pagination/mod.ts
Normal file
0
packages/api/pagination/mod.ts
Normal file
0
packages/api/pagination/paginate.test.ts
Normal file
0
packages/api/pagination/paginate.test.ts
Normal file
43
packages/api/pagination/paginate.ts
Normal file
43
packages/api/pagination/paginate.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<NostrEvent[]> {
|
|||
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. */
|
||||
export async function createTestDB(opts?: { conf?: DittoConf; pure?: boolean }) {
|
||||
const conf = opts?.conf ?? testConf();
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
function localRequest(c: Context): Request {
|
||||
const { conf } = c.var;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue