Merge remote-tracking branch 'origin/main' into sqlite-worker

This commit is contained in:
Alex Gleason 2023-11-29 15:11:21 -06:00
commit 01886059ab
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7
18 changed files with 241 additions and 24 deletions

View File

@ -1 +1 @@
deno 1.37.1 deno 1.38.3

View File

@ -115,7 +115,7 @@ app.post('/oauth/revoke', emptyObjectController);
app.post('/oauth/authorize', oauthAuthorizeController); app.post('/oauth/authorize', oauthAuthorizeController);
app.get('/oauth/authorize', oauthController); 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.get('/api/v1/accounts/verify_credentials', requirePubkey, verifyCredentialsController);
app.patch( app.patch(
'/api/v1/accounts/update_credentials', '/api/v1/accounts/update_credentials',

View File

@ -28,8 +28,8 @@ const streamSchema = z.enum([
type Stream = z.infer<typeof streamSchema>; type Stream = z.infer<typeof streamSchema>;
const streamingController: AppController = (c) => { const streamingController: AppController = (c) => {
const upgrade = c.req.headers.get('upgrade'); const upgrade = c.req.header('upgrade');
const token = c.req.headers.get('sec-websocket-protocol'); const token = c.req.header('sec-websocket-protocol');
const stream = streamSchema.optional().catch(undefined).parse(c.req.query('stream')); const stream = streamSchema.optional().catch(undefined).parse(c.req.query('stream'));
if (upgrade?.toLowerCase() !== 'websocket') { if (upgrade?.toLowerCase() !== 'websocket') {

View File

@ -117,7 +117,7 @@ function prepareFilters(filters: ClientREQ[2][]): Filter[] {
} }
const relayController: AppController = (c) => { const relayController: AppController = (c) => {
const upgrade = c.req.headers.get('upgrade'); const upgrade = c.req.header('upgrade');
if (upgrade?.toLowerCase() !== 'websocket') { if (upgrade?.toLowerCase() !== 'websocket') {
return c.text('Please use a Nostr client to connect.', 400); return c.text('Please use a Nostr client to connect.', 400);

View File

@ -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';

View File

@ -6,8 +6,8 @@ export {
Hono, Hono,
HTTPException, HTTPException,
type MiddlewareHandler, type MiddlewareHandler,
} from 'https://deno.land/x/hono@v3.7.5/mod.ts'; } from 'https://deno.land/x/hono@v3.10.1/mod.ts';
export { cors, logger, serveStatic } from 'https://deno.land/x/hono@v3.7.5/middleware.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 { 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 { Author, RelayPool } from 'https://dev.jspm.io/nostr-relaypool@0.6.28';
export { export {
@ -25,8 +25,9 @@ export {
nip19, nip19,
nip21, nip21,
type UnsignedEvent, type UnsignedEvent,
type VerifiedEvent,
verifySignature, 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 { findReplyTag } from 'https://gitlab.com/soapbox-pub/mostr/-/raw/c67064aee5ade5e01597c6d23e22e53c628ef0e2/src/nostr/tags.ts';
export { parseFormData } from 'npm:formdata-helper@^0.3.0'; export { parseFormData } from 'npm:formdata-helper@^0.3.0';
// @deno-types="npm:@types/lodash@4.14.194" // @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 IpfsHash } from 'npm:ipfs-only-hash@^4.0.0';
export { default as uuid62 } from 'npm:uuid62@^1.0.2'; 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 { 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 { 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'; export type * as TypeFest from 'npm:type-fest@^4.3.0';

View File

@ -6,7 +6,7 @@ const BEARER_REGEX = new RegExp(`^Bearer (${nip19.BECH32_REGEX.source})$`);
/** NIP-19 auth middleware. */ /** NIP-19 auth middleware. */
const auth19: AppMiddleware = async (c, next) => { 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); const match = authHeader?.match(BEARER_REGEX);
if (match) { if (match) {

View File

@ -91,7 +91,7 @@ function withProof(
async function obtainProof(c: AppContext, opts?: ParseAuthRequestOpts) { async function obtainProof(c: AppContext, opts?: ParseAuthRequestOpts) {
const req = localRequest(c); const req = localRequest(c);
const reqEvent = await buildAuthEventTemplate(req, opts); 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); const result = await validateAuthEvent(req, resEvent, opts);
if (result.success) { if (result.success) {

View File

@ -8,22 +8,31 @@ import { Sub } from '@/subs.ts';
import { eventMatchesTemplate, Time } from '@/utils.ts'; import { eventMatchesTemplate, Time } from '@/utils.ts';
import { createAdminEvent } from '@/utils/web.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. * Sign Nostr event using the app context.
* *
* - If a secret key is provided, it will be used to sign the event. * - 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. * - 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 seckey = c.get('seckey');
const header = c.req.headers.get('x-nostr-sign'); const header = c.req.header('x-nostr-sign');
if (seckey) { if (seckey) {
return finishEvent(event, seckey); return finishEvent(event, seckey);
} }
if (header) { if (header) {
return await signNostrConnect(event, c); return await signNostrConnect(event, c, opts);
} }
throw new HTTPException(400, { 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. */ /** 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'); const pubkey = c.get('pubkey');
if (!pubkey) { if (!pubkey) {
@ -48,7 +61,9 @@ async function signNostrConnect<K extends number = number>(event: EventTemplate<
JSON.stringify({ JSON.stringify({
id: messageId, id: messageId,
method: 'sign_event', method: 'sign_event',
params: [event], params: [event, {
pow: opts.pow,
}],
}), }),
), ),
tags: [['p', pubkey]], tags: [['p', pubkey]],

View File

@ -1,5 +1,6 @@
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { z } from '@/deps.ts'; import { z } from '@/deps.ts';
import { fetchWorker } from '@/workers/fetch.ts';
import type { Uploader } from './types.ts'; import type { Uploader } from './types.ts';
@ -22,7 +23,7 @@ const ipfsUploader: Uploader = {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
const response = await fetch(url, { const response = await fetchWorker(url, {
method: 'POST', method: 'POST',
body: formData, body: formData,
}); });
@ -41,7 +42,7 @@ const ipfsUploader: Uploader = {
url.search = query.toString(); url.search = query.toString();
await fetch(url, { await fetchWorker(url, {
method: 'POST', method: 'POST',
}); });
}, },

View File

@ -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 [...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. */ /** Ensure the template and event match on their shared keys. */
function eventMatchesTemplate(event: Event, template: EventTemplate): boolean { 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. */ /** Test whether the value is a Nostr ID. */

View File

@ -1,5 +1,6 @@
import { TTLCache, z } from '@/deps.ts'; import { TTLCache, z } from '@/deps.ts';
import { Time } from '@/utils/time.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 }); 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; const [_, name = '_', domain] = match;
try { 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), signal: AbortSignal.timeout(timeout),
}); });

View File

@ -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 { decode64Schema, jsonSchema } from '@/schema.ts';
import { signedEventSchema } from '@/schemas/nostr.ts'; import { signedEventSchema } from '@/schemas/nostr.ts';
import { eventAge, findTag, nostrNow, sha256 } from '@/utils.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 { maxAge = Time.minutes(1), validatePayload = true, pow = 0 } = opts;
const schema = signedEventSchema 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) => eventAge(event) < maxAge, 'Event expired')
.refine((event) => tagValue(event, 'method') === req.method, 'Event method does not match HTTP request method') .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') .refine((event) => tagValue(event, 'u') === req.url, 'Event URL does not match request URL')

View File

@ -1,5 +1,6 @@
import { TTLCache, unfurl } from '@/deps.ts'; import { TTLCache, unfurl } from '@/deps.ts';
import { Time } from '@/utils/time.ts'; import { Time } from '@/utils/time.ts';
import { fetchWorker } from '@/workers/fetch.ts';
interface PreviewCard { interface PreviewCard {
url: string; url: string;
@ -22,7 +23,7 @@ async function unfurlCard(url: string, signal: AbortSignal): Promise<PreviewCard
console.log(`Unfurling ${url}...`); console.log(`Unfurling ${url}...`);
try { try {
const result = await unfurl(url, { const result = await unfurl(url, {
fetch: (url) => fetch(url, { signal }), fetch: (url) => fetchWorker(url, { signal }),
}); });
return { return {

35
src/workers/fetch.test.ts Normal file
View File

@ -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));
}

71
src/workers/fetch.ts Normal file
View File

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

View File

@ -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);

View File

@ -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 }>);