Merge remote-tracking branch 'origin/main' into sqlite-worker
This commit is contained in:
commit
01886059ab
|
@ -1 +1 @@
|
|||
deno 1.37.1
|
||||
deno 1.38.3
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -28,8 +28,8 @@ const streamSchema = z.enum([
|
|||
type Stream = z.infer<typeof streamSchema>;
|
||||
|
||||
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') {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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';
|
||||
|
|
10
src/deps.ts
10
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';
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
25
src/sign.ts
25
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<K extends number = number>(event: EventTemplate<K>, c: AppContext): Promise<Event<K>> {
|
||||
async function signEvent<K extends number = number>(
|
||||
event: EventTemplate<K>,
|
||||
c: AppContext,
|
||||
opts: SignEventOpts = {},
|
||||
): Promise<Event<K>> {
|
||||
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<K extends number = number>(event: EventTemplate<K>, c:
|
|||
}
|
||||
|
||||
/** Sign event with NIP-46, waiting in the background for the signed event. */
|
||||
async function signNostrConnect<K extends number = number>(event: EventTemplate<K>, c: AppContext): Promise<Event<K>> {
|
||||
async function signNostrConnect<K extends number = number>(
|
||||
event: EventTemplate<K>,
|
||||
c: AppContext,
|
||||
opts: SignEventOpts = {},
|
||||
): Promise<Event<K>> {
|
||||
const pubkey = c.get('pubkey');
|
||||
|
||||
if (!pubkey) {
|
||||
|
@ -48,7 +61,9 @@ async function signNostrConnect<K extends number = number>(event: EventTemplate<
|
|||
JSON.stringify({
|
||||
id: messageId,
|
||||
method: 'sign_event',
|
||||
params: [event],
|
||||
params: [event, {
|
||||
pow: opts.pow,
|
||||
}],
|
||||
}),
|
||||
),
|
||||
tags: [['p', pubkey]],
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
},
|
||||
|
|
24
src/utils.ts
24
src/utils.ts
|
@ -107,9 +107,31 @@ function dedupeEvents<K extends number>(events: Event<K>[]): Event<K>[] {
|
|||
return [...new Map(events.map((event) => [event.id, event])).values()];
|
||||
}
|
||||
|
||||
/** Return a copy of the event with the given tags removed. */
|
||||
function stripTags<E extends EventTemplate>(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. */
|
||||
|
|
|
@ -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<string, Promise<string | null>>({ ttl: Time.hours(1), max: 5000 });
|
||||
|
||||
|
@ -19,7 +20,7 @@ async function lookup(value: string, opts: LookupOpts = {}): Promise<string | nu
|
|||
const [_, name = '_', domain] = match;
|
||||
|
||||
try {
|
||||
const res = await fetch(`https://${domain}/.well-known/nostr.json?name=${name}`, {
|
||||
const res = await fetchWorker(`https://${domain}/.well-known/nostr.json?name=${name}`, {
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
});
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { type Event, type EventTemplate, nip13 } from '@/deps.ts';
|
||||
import { type Event, type EventTemplate, nip13, type VerifiedEvent } from '@/deps.ts';
|
||||
import { decode64Schema, jsonSchema } from '@/schema.ts';
|
||||
import { signedEventSchema } from '@/schemas/nostr.ts';
|
||||
import { eventAge, findTag, nostrNow, sha256 } from '@/utils.ts';
|
||||
|
@ -32,7 +32,7 @@ function validateAuthEvent(req: Request, event: Event, opts: ParseAuthRequestOpt
|
|||
const { maxAge = Time.minutes(1), validatePayload = true, pow = 0 } = opts;
|
||||
|
||||
const schema = signedEventSchema
|
||||
.refine((event): event is Event<27235> => 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')
|
||||
|
|
|
@ -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<PreviewCard
|
|||
console.log(`Unfurling ${url}...`);
|
||||
try {
|
||||
const result = await unfurl(url, {
|
||||
fetch: (url) => fetch(url, { signal }),
|
||||
fetch: (url) => fetchWorker(url, { signal }),
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
@ -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<void>((resolve) => {
|
||||
signal.addEventListener('abort', () => resolve(), { once: true });
|
||||
});
|
||||
},
|
||||
sanitizeResources: false,
|
||||
});
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
import { Comlink } from '@/deps.ts';
|
||||
|
||||
import './handlers/abortsignal.ts';
|
||||
|
||||
import type { FetchWorker } from './fetch.worker.ts';
|
||||
|
||||
const _worker = Comlink.wrap<typeof FetchWorker>(
|
||||
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<typeof fetch>): [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<typeof fetch>): 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<ArrayBuffer | Blob | string | undefined | null> {
|
||||
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 };
|
|
@ -0,0 +1,23 @@
|
|||
import { Comlink } from '@/deps.ts';
|
||||
|
||||
import './handlers/abortsignal.ts';
|
||||
|
||||
export const FetchWorker = {
|
||||
async fetch(
|
||||
url: string,
|
||||
init: Omit<RequestInit, 'signal'>,
|
||||
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);
|
|
@ -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<AbortSignal, { aborted: boolean; port?: MessagePort }>);
|
Loading…
Reference in New Issue