diff --git a/.tool-versions b/.tool-versions index bc89cc40..04e52aef 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -deno 1.37.1 +deno 1.38.3 diff --git a/src/app.ts b/src/app.ts index 4cf6afee..776de8de 100644 --- a/src/app.ts +++ b/src/app.ts @@ -115,7 +115,7 @@ app.post('/oauth/revoke', emptyObjectController); app.post('/oauth/authorize', oauthAuthorizeController); app.get('/oauth/authorize', oauthController); -app.post('/api/v1/accounts', requireProof(), createAccountController); +app.post('/api/v1/accounts', requireProof({ pow: 20 }), createAccountController); app.get('/api/v1/accounts/verify_credentials', requirePubkey, verifyCredentialsController); app.patch( '/api/v1/accounts/update_credentials', diff --git a/src/controllers/api/streaming.ts b/src/controllers/api/streaming.ts index 6a9e535b..d7aa6773 100644 --- a/src/controllers/api/streaming.ts +++ b/src/controllers/api/streaming.ts @@ -28,8 +28,8 @@ const streamSchema = z.enum([ type Stream = z.infer; const streamingController: AppController = (c) => { - const upgrade = c.req.headers.get('upgrade'); - const token = c.req.headers.get('sec-websocket-protocol'); + const upgrade = c.req.header('upgrade'); + const token = c.req.header('sec-websocket-protocol'); const stream = streamSchema.optional().catch(undefined).parse(c.req.query('stream')); if (upgrade?.toLowerCase() !== 'websocket') { diff --git a/src/controllers/nostr/relay.ts b/src/controllers/nostr/relay.ts index 3a5a9aa5..9f5cd596 100644 --- a/src/controllers/nostr/relay.ts +++ b/src/controllers/nostr/relay.ts @@ -117,7 +117,7 @@ function prepareFilters(filters: ClientREQ[2][]): Filter[] { } const relayController: AppController = (c) => { - const upgrade = c.req.headers.get('upgrade'); + const upgrade = c.req.header('upgrade'); if (upgrade?.toLowerCase() !== 'websocket') { return c.text('Please use a Nostr client to connect.', 400); diff --git a/src/deps-test.ts b/src/deps-test.ts index 1448854f..3e6da88e 100644 --- a/src/deps-test.ts +++ b/src/deps-test.ts @@ -1 +1 @@ -export { assert, assertEquals, assertThrows } from 'https://deno.land/std@0.198.0/assert/mod.ts'; +export { assert, assertEquals, assertRejects, assertThrows } from 'https://deno.land/std@0.198.0/assert/mod.ts'; diff --git a/src/deps.ts b/src/deps.ts index 9c460f69..a02ad20e 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -6,8 +6,8 @@ export { Hono, HTTPException, type MiddlewareHandler, -} from 'https://deno.land/x/hono@v3.7.5/mod.ts'; -export { cors, logger, serveStatic } from 'https://deno.land/x/hono@v3.7.5/middleware.ts'; +} from 'https://deno.land/x/hono@v3.10.1/mod.ts'; +export { cors, logger, serveStatic } from 'https://deno.land/x/hono@v3.10.1/middleware.ts'; export { z } from 'https://deno.land/x/zod@v3.21.4/mod.ts'; export { Author, RelayPool } from 'https://dev.jspm.io/nostr-relaypool@0.6.28'; export { @@ -25,8 +25,9 @@ export { nip19, nip21, type UnsignedEvent, + type VerifiedEvent, verifySignature, -} from 'npm:nostr-tools@^1.14.0'; +} from 'npm:nostr-tools@^1.17.0'; export { findReplyTag } from 'https://gitlab.com/soapbox-pub/mostr/-/raw/c67064aee5ade5e01597c6d23e22e53c628ef0e2/src/nostr/tags.ts'; export { parseFormData } from 'npm:formdata-helper@^0.3.0'; // @deno-types="npm:@types/lodash@4.14.194" @@ -73,7 +74,8 @@ export { S3Client } from 'https://deno.land/x/s3_lite_client@0.6.1/mod.ts'; export { default as IpfsHash } from 'npm:ipfs-only-hash@^4.0.0'; export { default as uuid62 } from 'npm:uuid62@^1.0.2'; export { Machina } from 'https://gitlab.com/soapbox-pub/nostr-machina/-/raw/08a157d39f2741c9a3a4364cb97db36e71d8c03a/mod.ts'; -export * as Sentry from 'npm:@sentry/node@^7.73.0'; +export * as Sentry from 'https://deno.land/x/sentry@7.78.0/index.js'; export { sentry as sentryMiddleware } from 'npm:@hono/sentry@^1.0.0'; +export * as Comlink from 'npm:comlink@^4.4.1'; export type * as TypeFest from 'npm:type-fest@^4.3.0'; diff --git a/src/middleware/auth19.ts b/src/middleware/auth19.ts index fec79ad0..19344fbd 100644 --- a/src/middleware/auth19.ts +++ b/src/middleware/auth19.ts @@ -6,7 +6,7 @@ const BEARER_REGEX = new RegExp(`^Bearer (${nip19.BECH32_REGEX.source})$`); /** NIP-19 auth middleware. */ const auth19: AppMiddleware = async (c, next) => { - const authHeader = c.req.headers.get('authorization'); + const authHeader = c.req.header('authorization'); const match = authHeader?.match(BEARER_REGEX); if (match) { diff --git a/src/middleware/auth98.ts b/src/middleware/auth98.ts index 8eb73b52..0520010e 100644 --- a/src/middleware/auth98.ts +++ b/src/middleware/auth98.ts @@ -91,7 +91,7 @@ function withProof( async function obtainProof(c: AppContext, opts?: ParseAuthRequestOpts) { const req = localRequest(c); const reqEvent = await buildAuthEventTemplate(req, opts); - const resEvent = await signEvent(reqEvent, c); + const resEvent = await signEvent(reqEvent, c, opts); const result = await validateAuthEvent(req, resEvent, opts); if (result.success) { diff --git a/src/sign.ts b/src/sign.ts index 14c192f9..0662668d 100644 --- a/src/sign.ts +++ b/src/sign.ts @@ -8,22 +8,31 @@ import { Sub } from '@/subs.ts'; import { eventMatchesTemplate, Time } from '@/utils.ts'; import { createAdminEvent } from '@/utils/web.ts'; +interface SignEventOpts { + /** Target proof-of-work difficulty for the signed event. */ + pow?: number; +} + /** * Sign Nostr event using the app context. * * - If a secret key is provided, it will be used to sign the event. * - If `X-Nostr-Sign` is passed, it will use NIP-46 to sign the event. */ -async function signEvent(event: EventTemplate, c: AppContext): Promise> { +async function signEvent( + event: EventTemplate, + c: AppContext, + opts: SignEventOpts = {}, +): Promise> { const seckey = c.get('seckey'); - const header = c.req.headers.get('x-nostr-sign'); + const header = c.req.header('x-nostr-sign'); if (seckey) { return finishEvent(event, seckey); } if (header) { - return await signNostrConnect(event, c); + return await signNostrConnect(event, c, opts); } throw new HTTPException(400, { @@ -32,7 +41,11 @@ async function signEvent(event: EventTemplate, c: } /** Sign event with NIP-46, waiting in the background for the signed event. */ -async function signNostrConnect(event: EventTemplate, c: AppContext): Promise> { +async function signNostrConnect( + event: EventTemplate, + c: AppContext, + opts: SignEventOpts = {}, +): Promise> { const pubkey = c.get('pubkey'); if (!pubkey) { @@ -48,7 +61,9 @@ async function signNostrConnect(event: EventTemplate< JSON.stringify({ id: messageId, method: 'sign_event', - params: [event], + params: [event, { + pow: opts.pow, + }], }), ), tags: [['p', pubkey]], diff --git a/src/uploaders/ipfs.ts b/src/uploaders/ipfs.ts index e6a33cda..74382053 100644 --- a/src/uploaders/ipfs.ts +++ b/src/uploaders/ipfs.ts @@ -1,5 +1,6 @@ import { Conf } from '@/config.ts'; import { z } from '@/deps.ts'; +import { fetchWorker } from '@/workers/fetch.ts'; import type { Uploader } from './types.ts'; @@ -22,7 +23,7 @@ const ipfsUploader: Uploader = { const formData = new FormData(); formData.append('file', file); - const response = await fetch(url, { + const response = await fetchWorker(url, { method: 'POST', body: formData, }); @@ -41,7 +42,7 @@ const ipfsUploader: Uploader = { url.search = query.toString(); - await fetch(url, { + await fetchWorker(url, { method: 'POST', }); }, diff --git a/src/utils.ts b/src/utils.ts index 11ef0ea5..1f10cd3b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -107,9 +107,31 @@ function dedupeEvents(events: Event[]): Event[] { return [...new Map(events.map((event) => [event.id, event])).values()]; } +/** Return a copy of the event with the given tags removed. */ +function stripTags(event: E, tags: string[] = []): E { + if (!tags.length) return event; + return { + ...event, + tags: event.tags.filter(([name]) => !tags.includes(name)), + }; +} + /** Ensure the template and event match on their shared keys. */ function eventMatchesTemplate(event: Event, template: EventTemplate): boolean { - return getEventHash(event) === getEventHash({ pubkey: event.pubkey, ...template }); + const whitelist = ['nonce']; + + event = stripTags(event, whitelist); + template = stripTags(template, whitelist); + + if (template.created_at > event.created_at) { + return false; + } + + return getEventHash(event) === getEventHash({ + pubkey: event.pubkey, + ...template, + created_at: event.created_at, + }); } /** Test whether the value is a Nostr ID. */ diff --git a/src/utils/nip05.ts b/src/utils/nip05.ts index 2205ded6..b655175f 100644 --- a/src/utils/nip05.ts +++ b/src/utils/nip05.ts @@ -1,5 +1,6 @@ import { TTLCache, z } from '@/deps.ts'; import { Time } from '@/utils/time.ts'; +import { fetchWorker } from '@/workers/fetch.ts'; const nip05Cache = new TTLCache>({ ttl: Time.hours(1), max: 5000 }); @@ -19,7 +20,7 @@ async function lookup(value: string, opts: LookupOpts = {}): Promise => event.kind === 27235, 'Event must be kind 27235') + .refine((event): event is VerifiedEvent<27235> => event.kind === 27235, 'Event must be kind 27235') .refine((event) => eventAge(event) < maxAge, 'Event expired') .refine((event) => tagValue(event, 'method') === req.method, 'Event method does not match HTTP request method') .refine((event) => tagValue(event, 'u') === req.url, 'Event URL does not match request URL') diff --git a/src/utils/unfurl.ts b/src/utils/unfurl.ts index 80977cf9..45bdfc9e 100644 --- a/src/utils/unfurl.ts +++ b/src/utils/unfurl.ts @@ -1,5 +1,6 @@ import { TTLCache, unfurl } from '@/deps.ts'; import { Time } from '@/utils/time.ts'; +import { fetchWorker } from '@/workers/fetch.ts'; interface PreviewCard { url: string; @@ -22,7 +23,7 @@ async function unfurlCard(url: string, signal: AbortSignal): Promise fetch(url, { signal }), + fetch: (url) => fetchWorker(url, { signal }), }); return { diff --git a/src/workers/fetch.test.ts b/src/workers/fetch.test.ts new file mode 100644 index 00000000..c985ee62 --- /dev/null +++ b/src/workers/fetch.test.ts @@ -0,0 +1,35 @@ +import { assert, assertRejects } from '@/deps-test.ts'; + +import { fetchWorker } from './fetch.ts'; + +await sleep(2000); + +Deno.test({ + name: 'fetchWorker', + async fn() { + const response = await fetchWorker('https://example.com'); + const text = await response.text(); + assert(text.includes('Example Domain')); + }, + sanitizeResources: false, +}); + +Deno.test({ + name: 'fetchWorker with AbortSignal', + async fn() { + const controller = new AbortController(); + const signal = controller.signal; + + setTimeout(() => controller.abort(), 100); + assertRejects(() => fetchWorker('http://httpbin.org/delay/10', { signal })); + + await new Promise((resolve) => { + signal.addEventListener('abort', () => resolve(), { once: true }); + }); + }, + sanitizeResources: false, +}); + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/workers/fetch.ts b/src/workers/fetch.ts new file mode 100644 index 00000000..3246e78e --- /dev/null +++ b/src/workers/fetch.ts @@ -0,0 +1,71 @@ +import { Comlink } from '@/deps.ts'; + +import './handlers/abortsignal.ts'; + +import type { FetchWorker } from './fetch.worker.ts'; + +const _worker = Comlink.wrap( + new Worker( + new URL('./fetch.worker.ts', import.meta.url), + { type: 'module' }, + ), +); + +/** + * Fetch implementation with a Web Worker. + * Calling this performs the fetch in a separate CPU thread so it doesn't block the main thread. + */ +const fetchWorker: typeof fetch = async (...args) => { + const [url, init] = serializeFetchArgs(args); + const { body, signal, ...rest } = init; + const result = await _worker.fetch(url, { ...rest, body: await prepareBodyForWorker(body) }, signal); + return new Response(...result); +}; + +/** Take arguments to `fetch`, and turn them into something we can send over Comlink. */ +function serializeFetchArgs(args: Parameters): [string, RequestInit] { + const request = normalizeRequest(args); + const init = requestToInit(request); + return [request.url, init]; +} + +/** Get a `Request` object from arguments to `fetch`. */ +function normalizeRequest(args: Parameters): Request { + return new Request(...args); +} + +/** Get the body as a type we can transfer over Web Workers. */ +async function prepareBodyForWorker( + body: BodyInit | undefined | null, +): Promise { + if (!body || typeof body === 'string' || body instanceof ArrayBuffer || body instanceof Blob) { + return body; + } else { + const response = new Response(body); + return await response.arrayBuffer(); + } +} + +/** + * Convert a `Request` object into its serialized `RequestInit` format. + * `RequestInit` is a subset of `Request`, just lacking helper methods like `json()`, + * making it easier to serialize (exceptions: `body` and `signal`). + */ +function requestToInit(request: Request): RequestInit { + return { + method: request.method, + headers: [...request.headers.entries()], + body: request.body, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + mode: request.mode, + credentials: request.credentials, + cache: request.cache, + redirect: request.redirect, + integrity: request.integrity, + keepalive: request.keepalive, + signal: request.signal, + }; +} + +export { fetchWorker }; diff --git a/src/workers/fetch.worker.ts b/src/workers/fetch.worker.ts new file mode 100644 index 00000000..2988a2e7 --- /dev/null +++ b/src/workers/fetch.worker.ts @@ -0,0 +1,23 @@ +import { Comlink } from '@/deps.ts'; + +import './handlers/abortsignal.ts'; + +export const FetchWorker = { + async fetch( + url: string, + init: Omit, + signal: AbortSignal | null | undefined, + ): Promise<[BodyInit, ResponseInit]> { + const response = await fetch(url, { ...init, signal }); + return [ + await response.arrayBuffer(), + { + status: response.status, + statusText: response.statusText, + headers: [...response.headers.entries()], + }, + ]; + }, +}; + +Comlink.expose(FetchWorker); diff --git a/src/workers/handlers/abortsignal.ts b/src/workers/handlers/abortsignal.ts new file mode 100644 index 00000000..c4c6a3e9 --- /dev/null +++ b/src/workers/handlers/abortsignal.ts @@ -0,0 +1,46 @@ +import { Comlink } from '@/deps.ts'; + +const signalFinalizers = new FinalizationRegistry((port: MessagePort) => { + port.postMessage(null); + port.close(); +}); + +Comlink.transferHandlers.set('abortsignal', { + canHandle(value) { + return value instanceof AbortSignal || value?.constructor?.name === 'AbortSignal'; + }, + serialize(signal) { + if (signal.aborted) { + return [{ aborted: true }]; + } + + const { port1, port2 } = new MessageChannel(); + signal.addEventListener( + 'abort', + () => port1.postMessage({ reason: signal.reason }), + { once: true }, + ); + + signalFinalizers?.register(signal, port1); + + return [{ aborted: false, port: port2 }, [port2]]; + }, + deserialize({ aborted, port }) { + if (aborted || !port) { + return AbortSignal.abort(); + } + + const ctrl = new AbortController(); + + port.addEventListener('message', (ev) => { + if (ev.data && 'reason' in ev.data) { + ctrl.abort(ev.data.reason); + } + port.close(); + }, { once: true }); + + port.start(); + + return ctrl.signal; + }, +} as Comlink.TransferHandler);