import { type AppContext } from '@/app.ts'; import { Conf } from '@/config.ts'; import { type Context, Debug, type Event, EventTemplate, Filter, HTTPException, parseFormData, type TypeFest, z, } from '@/deps.ts'; import * as pipeline from '@/pipeline.ts'; import { signAdminEvent, signEvent } from '@/sign.ts'; import { nostrNow } from '@/utils.ts'; import { eventsDB } from '@/db/events.ts'; const debug = Debug('ditto:api'); /** EventTemplate with defaults. */ type EventStub = TypeFest.SetOptional, 'content' | 'created_at' | 'tags'>; /** Publish an event through the pipeline. */ async function createEvent(t: EventStub, c: AppContext): Promise> { const pubkey = c.get('pubkey'); if (!pubkey) { throw new HTTPException(401); } const event = await signEvent({ content: '', created_at: nostrNow(), tags: [], ...t, }, c); return publishEvent(event, c); } /** Filter for fetching an existing event to update. */ interface UpdateEventFilter extends Filter { kinds: [K]; limit?: 1; } /** Fetch existing event, update it, then publish the new event. */ async function updateEvent>( filter: UpdateEventFilter, fn: (prev: Event | undefined) => E, c: AppContext, ): Promise> { const [prev] = await eventsDB.getEvents([filter], { limit: 1 }); return createEvent(fn(prev), c); } /** Fetch existing event, update its tags, then publish the new event. */ function updateListEvent( filter: UpdateEventFilter, fn: (tags: string[][]) => string[][], c: AppContext, ): Promise> { return updateEvent(filter, (prev) => ({ kind: filter.kinds[0], content: prev?.content ?? '', tags: fn(prev?.tags ?? []), }), c); } /** Publish an admin event through the pipeline. */ async function createAdminEvent(t: EventStub, c: AppContext): Promise> { const event = await signAdminEvent({ content: '', created_at: nostrNow(), tags: [], ...t, }); return publishEvent(event, c); } /** Push the event through the pipeline, rethrowing any RelayError. */ async function publishEvent(event: Event, c: AppContext): Promise> { debug('EVENT', event); try { await pipeline.handleEvent(event); } catch (e) { if (e instanceof pipeline.RelayError) { throw new HTTPException(422, { res: c.json({ error: e.message }, 422), }); } } return event; } /** Parse request body to JSON, depending on the content-type of the request. */ async function parseBody(req: Request): Promise { switch (req.headers.get('content-type')?.split(';')[0]) { case 'multipart/form-data': case 'application/x-www-form-urlencoded': return parseFormData(await req.formData()); case 'application/json': return req.json(); } } /** Schema to parse pagination query params. */ const paginationSchema = z.object({ since: z.coerce.number().optional().catch(undefined), until: z.lazy(() => z.coerce.number().catch(nostrNow())), limit: z.coerce.number().catch(20).transform((value) => Math.min(Math.max(value, 0), 40)), }); /** Mastodon API pagination query params. */ type PaginationParams = z.infer; /** Build HTTP Link header for Mastodon API pagination. */ function buildLinkHeader(url: string, events: Event[]): string | undefined { if (events.length <= 1) return; const firstEvent = events[0]; const lastEvent = events[events.length - 1]; const { localDomain } = Conf; const { pathname, search } = new URL(url); const next = new URL(pathname + search, localDomain); const prev = new URL(pathname + search, localDomain); next.searchParams.set('until', String(lastEvent.created_at)); prev.searchParams.set('since', String(firstEvent.created_at)); return `<${next}>; rel="next", <${prev}>; rel="prev"`; } type Entity = { id: string }; type HeaderRecord = Record; /** Return results with pagination headers. Assumes chronological sorting of events. */ function paginated(c: AppContext, events: Event[], entities: (Entity | undefined)[], headers: HeaderRecord = {}) { const link = buildLinkHeader(c.req.url, events); 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>)[]; /** Add a basic JSON-LD context to ActivityStreams object, if it doesn't already exist. */ function maybeAddContext(object: T): T & { '@context': LDContext } { return { '@context': ['https://www.w3.org/ns/activitystreams'], ...object, }; } /** Like hono's `c.json()` except returns JSON-LD. */ function activityJson(c: Context, object: T) { const response = c.json(maybeAddContext(object)); response.headers.set('content-type', 'application/activity+json; charset=UTF-8'); return response; } /** Rewrite the URL of the request object to use the local domain. */ function localRequest(c: Context): Request { return Object.create(c.req.raw, { url: { value: Conf.local(c.req.url) }, }); } export { activityJson, createAdminEvent, createEvent, localRequest, paginated, type PaginationParams, paginationSchema, parseBody, updateEvent, updateListEvent, };