Merge remote-tracking branch 'origin/main' into zaps

This commit is contained in:
Alex Gleason 2024-01-22 12:00:55 -06:00
commit 0a1b6cdf4f
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7
9 changed files with 104 additions and 98 deletions

View File

@ -5,7 +5,7 @@ import { booleanParamSchema } from '@/schema.ts';
import { nostrIdSchema } from '@/schemas/nostr.ts'; import { nostrIdSchema } from '@/schemas/nostr.ts';
import { searchStore } from '@/storages.ts'; import { searchStore } from '@/storages.ts';
import { dedupeEvents } from '@/utils.ts'; import { dedupeEvents } from '@/utils.ts';
import { lookupNip05Cached } from '@/utils/nip05.ts'; import { nip05Cache } from '@/utils/nip05.ts';
import { renderAccount } from '@/views/mastodon/accounts.ts'; import { renderAccount } from '@/views/mastodon/accounts.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts';
@ -95,13 +95,13 @@ function typeToKinds(type: SearchQuery['type']): number[] {
/** Resolve a searched value into an event, if applicable. */ /** Resolve a searched value into an event, if applicable. */
async function lookupEvent(query: SearchQuery, signal: AbortSignal): Promise<Event | undefined> { async function lookupEvent(query: SearchQuery, signal: AbortSignal): Promise<Event | undefined> {
const filters = await getLookupFilters(query); const filters = await getLookupFilters(query, signal);
const [event] = await searchStore.filter(filters, { limit: 1, signal }); const [event] = await searchStore.filter(filters, { limit: 1, signal });
return event; return event;
} }
/** Get filters to lookup the input value. */ /** Get filters to lookup the input value. */
async function getLookupFilters({ q, type, resolve }: SearchQuery): Promise<DittoFilter[]> { async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: AbortSignal): Promise<DittoFilter[]> {
const filters: DittoFilter[] = []; const filters: DittoFilter[] = [];
const accounts = !type || type === 'accounts'; const accounts = !type || type === 'accounts';
@ -139,9 +139,13 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery): Promise<Ditt
if (accounts) filters.push({ kinds: [0], authors: [q] }); if (accounts) filters.push({ kinds: [0], authors: [q] });
if (statuses) filters.push({ kinds: [1], ids: [q] }); if (statuses) filters.push({ kinds: [1], ids: [q] });
} else if (accounts && ACCT_REGEX.test(q)) { } else if (accounts && ACCT_REGEX.test(q)) {
const pubkey = await lookupNip05Cached(q); try {
if (pubkey) { const { pubkey } = await nip05Cache.fetch(q, { signal });
filters.push({ kinds: [0], authors: [pubkey], relations: ['author_stats'] }); if (pubkey) {
filters.push({ kinds: [0], authors: [pubkey], relations: ['author_stats'] });
}
} catch (_e) {
// do nothing
} }
} }

View File

@ -86,6 +86,9 @@ export { EventEmitter } from 'npm:tseep@^1.1.3';
export { default as stringifyStable } from 'npm:fast-stable-stringify@^1.0.0'; export { default as stringifyStable } from 'npm:fast-stable-stringify@^1.0.0';
// @deno-types="npm:@types/debug@^4.1.12" // @deno-types="npm:@types/debug@^4.1.12"
export { default as Debug } from 'npm:debug@^4.3.4'; export { default as Debug } from 'npm:debug@^4.3.4';
export { bech32 } from 'npm:@scure/base@^1.1.1'; export {
type MapCache,
NIP05,
} from 'https://gitlab.com/soapbox-pub/nlib/-/raw/46be9e985950547574b1735d0ae52a6a7217d056/mod.ts';
export type * as TypeFest from 'npm:type-fest@^4.3.0'; export type * as TypeFest from 'npm:type-fest@^4.3.0';

View File

@ -1,6 +1,6 @@
import { type Event, type EventTemplate, getEventHash, nip19, z } from '@/deps.ts'; import { type Event, type EventTemplate, getEventHash, nip19, z } from '@/deps.ts';
import { getAuthor } from '@/queries.ts'; import { getAuthor } from '@/queries.ts';
import { lookupNip05Cached } from '@/utils/nip05.ts'; import { nip05Cache } from '@/utils/nip05.ts';
import { nostrIdSchema } from '@/schemas/nostr.ts'; import { nostrIdSchema } from '@/schemas/nostr.ts';
/** Get the current time in Nostr format. */ /** Get the current time in Nostr format. */
@ -56,10 +56,11 @@ function parseNip05(value: string): Nip05 {
} }
/** Resolve a bech32 or NIP-05 identifier to an account. */ /** Resolve a bech32 or NIP-05 identifier to an account. */
async function lookupAccount(value: string): Promise<Event<0> | undefined> { async function lookupAccount(value: string, signal = AbortSignal.timeout(3000)): Promise<Event<0> | undefined> {
console.log(`Looking up ${value}`); console.log(`Looking up ${value}`);
const pubkey = bech32ToPubkey(value) || await lookupNip05Cached(value); const pubkey = bech32ToPubkey(value) ||
await nip05Cache.fetch(value, { signal }).then(({ pubkey }) => pubkey).catch(() => undefined);
if (pubkey) { if (pubkey) {
return getAuthor(pubkey); return getAuthor(pubkey);

37
src/utils/SimpleLRU.ts Normal file
View File

@ -0,0 +1,37 @@
// deno-lint-ignore-file ban-types
import { LRUCache, type MapCache } from '@/deps.ts';
type FetchFn<K extends {}, V extends {}, O extends {}> = (key: K, opts: O) => Promise<V>;
interface FetchFnOpts {
signal?: AbortSignal | null;
}
export class SimpleLRU<
K extends {},
V extends {},
O extends {} = FetchFnOpts,
> implements MapCache<K, V, O> {
protected cache: LRUCache<K, V, void>;
constructor(fetchFn: FetchFn<K, V, { signal: AbortSignal }>, opts: LRUCache.Options<K, V, void>) {
this.cache = new LRUCache({
fetchMethod: (key, _staleValue, { signal }) => fetchFn(key, { signal: signal as AbortSignal }),
...opts,
});
}
async fetch(key: K, opts?: O): Promise<V> {
const result = await this.cache.fetch(key, opts);
if (result === undefined) {
throw new Error('SimpleLRU: fetch failed');
}
return result;
}
put(key: K, value: V): Promise<void> {
this.cache.set(key, value);
return Promise.resolve();
}
}

View File

@ -1,73 +1,22 @@
import { Debug, TTLCache, z } from '@/deps.ts'; import { Debug, NIP05, nip19 } from '@/deps.ts';
import { SimpleLRU } from '@/utils/SimpleLRU.ts';
import { Time } from '@/utils/time.ts'; import { Time } from '@/utils/time.ts';
import { fetchWorker } from '@/workers/fetch.ts';
const debug = Debug('ditto:nip05'); const debug = Debug('ditto:nip05');
const nip05Cache = new TTLCache<string, Promise<string | null>>({ ttl: Time.hours(1), max: 5000 }); const nip05Cache = new SimpleLRU<string, nip19.ProfilePointer>(
async (key, { signal }) => {
const NIP05_REGEX = /^(?:([\w.+-]+)@)?([\w.-]+)$/; debug(`Lookup ${key}`);
try {
interface LookupOpts { const result = await NIP05.lookup(key, { fetch, signal });
signal?: AbortSignal; debug(`Found: ${key} is ${result.pubkey}`);
} return result;
} catch (e) {
/** Get pubkey from NIP-05. */ debug(`Not found: ${key}`);
async function lookup(value: string, opts: LookupOpts = {}): Promise<string | null> { throw e;
const { signal = AbortSignal.timeout(2000) } = opts;
const match = value.match(NIP05_REGEX);
if (!match) return null;
const [_, name = '_', domain] = match;
try {
const res = await fetchWorker(`https://${domain}/.well-known/nostr.json?name=${name}`, {
signal,
});
const { names } = nostrJsonSchema.parse(await res.json());
return names[name] || null;
} catch (e) {
debug(e);
return null;
}
}
/** nostr.json schema. */
const nostrJsonSchema = z.object({
names: z.record(z.string(), z.string()),
relays: z.record(z.string(), z.array(z.string())).optional().catch(undefined),
});
/**
* Lookup the NIP-05 and serve from cache first.
* To prevent race conditions we put the promise in the cache instead of the result.
*/
function lookupNip05Cached(value: string): Promise<string | null> {
const cached = nip05Cache.get(value);
if (cached !== undefined) return cached;
debug(`Lookup ${value}`);
const result = lookup(value);
nip05Cache.set(value, result);
result.then((result) => {
if (result) {
debug(`Found: ${value} is ${result}`);
} else {
debug(`Not found: ${value} is ${result}`);
} }
}); },
{ max: 5000, ttl: Time.hours(1) },
);
return result; export { nip05Cache };
}
/** Verify the NIP-05 matches the pubkey, with cache. */
async function verifyNip05Cached(value: string, pubkey: string): Promise<boolean> {
const result = await lookupNip05Cached(value);
return result === pubkey;
}
export { lookupNip05Cached, verifyNip05Cached };

View File

@ -4,7 +4,7 @@ import { lodash, nip19, type UnsignedEvent } from '@/deps.ts';
import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
import { type DittoEvent } from '@/storages/types.ts'; import { type DittoEvent } from '@/storages/types.ts';
import { getLnurl } from '@/utils/lnurl.ts'; import { getLnurl } from '@/utils/lnurl.ts';
import { verifyNip05Cached } from '@/utils/nip05.ts'; import { nip05Cache } from '@/utils/nip05.ts';
import { Nip05, nostrDate, nostrNow, parseNip05 } from '@/utils.ts'; import { Nip05, nostrDate, nostrNow, parseNip05 } from '@/utils.ts';
import { renderEmojis } from '@/views/mastodon/emojis.ts'; import { renderEmojis } from '@/views/mastodon/emojis.ts';
@ -92,9 +92,19 @@ function accountFromPubkey(pubkey: string, opts: ToAccountOpts = {}) {
return renderAccount(event, opts); return renderAccount(event, opts);
} }
async function parseAndVerifyNip05(nip05: string | undefined, pubkey: string): Promise<Nip05 | undefined> { async function parseAndVerifyNip05(
if (nip05 && await verifyNip05Cached(nip05, pubkey)) { nip05: string | undefined,
return parseNip05(nip05); pubkey: string,
signal = AbortSignal.timeout(3000),
): Promise<Nip05 | undefined> {
if (!nip05) return;
try {
const result = await nip05Cache.fetch(nip05, { signal });
if (result.pubkey === pubkey) {
return parseNip05(nip05);
}
} catch (_e) {
// do nothing
} }
} }

View File

@ -1,15 +1,13 @@
import { assert, assertRejects } from '@/deps-test.ts'; import { assertEquals, assertRejects } from '@/deps-test.ts';
import { fetchWorker } from './fetch.ts'; import { fetchWorker } from './fetch.ts';
await sleep(2000);
Deno.test({ Deno.test({
name: 'fetchWorker', name: 'fetchWorker',
async fn() { async fn() {
const response = await fetchWorker('https://example.com'); const response = await fetchWorker('http://httpbin.org/get');
const text = await response.text(); const json = await response.json();
assert(text.includes('Example Domain')); assertEquals(json.headers.Host, 'httpbin.org');
}, },
sanitizeResources: false, sanitizeResources: false,
}); });
@ -29,7 +27,3 @@ Deno.test({
}, },
sanitizeResources: false, sanitizeResources: false,
}); });
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@ -4,21 +4,27 @@ import './handlers/abortsignal.ts';
import type { FetchWorker } from './fetch.worker.ts'; import type { FetchWorker } from './fetch.worker.ts';
const _worker = Comlink.wrap<typeof FetchWorker>( const worker = new Worker(new URL('./fetch.worker.ts', import.meta.url), { type: 'module' });
new Worker( const client = Comlink.wrap<typeof FetchWorker>(worker);
new URL('./fetch.worker.ts', import.meta.url),
{ type: 'module' }, // Wait for the worker to be ready before we start using it.
), const ready = new Promise<void>((resolve) => {
); const handleEvent = () => {
self.removeEventListener('message', handleEvent);
resolve();
};
worker.addEventListener('message', handleEvent);
});
/** /**
* Fetch implementation with a Web Worker. * Fetch implementation with a Web Worker.
* Calling this performs the fetch in a separate CPU thread so it doesn't block the main thread. * 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 fetchWorker: typeof fetch = async (...args) => {
await ready;
const [url, init] = serializeFetchArgs(args); const [url, init] = serializeFetchArgs(args);
const { body, signal, ...rest } = init; const { body, signal, ...rest } = init;
const result = await _worker.fetch(url, { ...rest, body: await prepareBodyForWorker(body) }, signal); const result = await client.fetch(url, { ...rest, body: await prepareBodyForWorker(body) }, signal);
return new Response(...result); return new Response(...result);
}; };

View File

@ -24,3 +24,5 @@ export const FetchWorker = {
}; };
Comlink.expose(FetchWorker); Comlink.expose(FetchWorker);
self.postMessage('ready');