2023-05-13 19:45:13 +00:00
|
|
|
import { getAuthor } from '@/client.ts';
|
2023-06-10 22:15:08 +00:00
|
|
|
import { Conf } from '@/config.ts';
|
2023-07-27 15:36:19 +00:00
|
|
|
import { type Context, nip19, parseFormData, z } from '@/deps.ts';
|
2023-04-29 20:49:22 +00:00
|
|
|
import { type Event } from '@/event.ts';
|
2023-05-13 19:45:13 +00:00
|
|
|
import { lookupNip05Cached } from '@/nip05.ts';
|
2023-03-18 22:47:34 +00:00
|
|
|
|
|
|
|
/** Get the current time in Nostr format. */
|
|
|
|
const nostrNow = () => Math.floor(new Date().getTime() / 1000);
|
2023-07-08 20:04:50 +00:00
|
|
|
/** Convenience function to convert Nostr dates into native Date objects. */
|
|
|
|
const nostrDate = (seconds: number) => new Date(seconds * 1000);
|
2023-03-18 22:47:34 +00:00
|
|
|
|
|
|
|
/** Pass to sort() to sort events by date. */
|
|
|
|
const eventDateComparator = (a: Event, b: Event) => b.created_at - a.created_at;
|
|
|
|
|
2023-04-29 21:23:23 +00:00
|
|
|
/** Get pubkey from bech32 string, if applicable. */
|
|
|
|
function bech32ToPubkey(bech32: string): string | undefined {
|
|
|
|
try {
|
|
|
|
const decoded = nip19.decode(bech32);
|
|
|
|
|
|
|
|
switch (decoded.type) {
|
|
|
|
case 'nprofile':
|
|
|
|
return decoded.data.pubkey;
|
|
|
|
case 'npub':
|
|
|
|
return decoded.data;
|
|
|
|
}
|
|
|
|
} catch (_) {
|
|
|
|
//
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-04-29 23:59:40 +00:00
|
|
|
interface Nip05 {
|
|
|
|
/** Localpart of the nip05, eg `alex` in `alex@alexgleason.me`. */
|
|
|
|
local: string | undefined;
|
|
|
|
/** Domain of the nip05, eg `alexgleason.me` in `alex@alexgleason.me`. */
|
|
|
|
domain: string;
|
|
|
|
/** Value with underscore removed, eg `_@fiatjaf.com` becomes `fiatjaf.com`, but `alex@alexgleason.me` stays the same. */
|
|
|
|
handle: string;
|
|
|
|
/** The localpart, if available and not `_`. Otherwise the domain. */
|
|
|
|
nickname: string;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Parse a NIP-05 identifier and return an object with metadata about it.
|
|
|
|
* Throws if the value is not a valid NIP-05 identifier.
|
|
|
|
*/
|
|
|
|
function parseNip05(value: string): Nip05 {
|
|
|
|
const match = value.match(/^(?:([\w.+-]+)@)?([\w.-]+)$/i);
|
|
|
|
if (!match) throw new Error(`nip05: failed to parse ${value}`);
|
|
|
|
|
|
|
|
const [_, local, domain] = match;
|
|
|
|
return {
|
|
|
|
local,
|
|
|
|
domain,
|
|
|
|
handle: local === '_' ? domain : value,
|
|
|
|
nickname: (local && local !== '_') ? local : domain,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2023-05-13 19:45:13 +00:00
|
|
|
/** Resolve a bech32 or NIP-05 identifier to an account. */
|
|
|
|
async function lookupAccount(value: string): Promise<Event<0> | undefined> {
|
|
|
|
console.log(`Looking up ${value}`);
|
|
|
|
|
|
|
|
const pubkey = bech32ToPubkey(value) || await lookupNip05Cached(value);
|
|
|
|
|
|
|
|
if (pubkey) {
|
|
|
|
return getAuthor(pubkey);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-04-30 19:13:27 +00:00
|
|
|
/** Parse request body to JSON, depending on the content-type of the request. */
|
|
|
|
async function parseBody(req: Request): Promise<unknown> {
|
|
|
|
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();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-06-10 00:22:38 +00:00
|
|
|
const paginationSchema = z.object({
|
|
|
|
since: z.coerce.number().optional().catch(undefined),
|
2023-07-08 20:21:28 +00:00
|
|
|
until: z.lazy(() => z.coerce.number().catch(nostrNow())),
|
2023-07-25 22:44:38 +00:00
|
|
|
limit: z.coerce.number().catch(20).transform((value) => Math.min(Math.max(value, 0), 40)),
|
2023-06-10 00:22:38 +00:00
|
|
|
});
|
|
|
|
|
2023-07-07 20:07:20 +00:00
|
|
|
type PaginationParams = z.infer<typeof paginationSchema>;
|
|
|
|
|
2023-06-10 00:22:38 +00:00
|
|
|
function buildLinkHeader(url: string, events: Event[]): string | undefined {
|
|
|
|
if (!events.length) return;
|
|
|
|
const firstEvent = events[0];
|
|
|
|
const lastEvent = events[events.length - 1];
|
|
|
|
|
|
|
|
const { pathname, search } = new URL(url);
|
2023-06-10 22:15:08 +00:00
|
|
|
const next = new URL(pathname + search, Conf.localDomain);
|
|
|
|
const prev = new URL(pathname + search, Conf.localDomain);
|
2023-06-10 00:22:38 +00:00
|
|
|
|
|
|
|
next.searchParams.set('until', String(lastEvent.created_at));
|
|
|
|
prev.searchParams.set('since', String(firstEvent.created_at));
|
|
|
|
|
|
|
|
return `<${next}>; rel="next", <${prev}>; rel="prev"`;
|
|
|
|
}
|
|
|
|
|
2023-07-08 23:41:11 +00:00
|
|
|
/** Return the event's age in milliseconds. */
|
|
|
|
function eventAge(event: Event): number {
|
|
|
|
return new Date().getTime() - nostrDate(event.created_at).getTime();
|
|
|
|
}
|
|
|
|
|
|
|
|
function findTag(tags: string[][], name: string): string[] | undefined {
|
|
|
|
return tags.find((tag) => tag[0] === name);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get sha256 hash (hex) of some text.
|
|
|
|
* https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#converting_a_digest_to_a_hex_string
|
|
|
|
*/
|
|
|
|
async function sha256(message: string): Promise<string> {
|
|
|
|
const msgUint8 = new TextEncoder().encode(message);
|
|
|
|
const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8);
|
|
|
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
|
|
const hashHex = hashArray
|
|
|
|
.map((b) => b.toString(16).padStart(2, '0'))
|
|
|
|
.join('');
|
|
|
|
return hashHex;
|
|
|
|
}
|
|
|
|
|
2023-07-27 15:36:19 +00:00
|
|
|
/** JSON-LD context. */
|
|
|
|
type LDContext = (string | Record<string, string | Record<string, string>>)[];
|
|
|
|
|
|
|
|
/** Add a basic JSON-LD context to ActivityStreams object, if it doesn't already exist. */
|
|
|
|
function maybeAddContext<T>(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<T, P extends string>(c: Context<any, P>, object: T) {
|
|
|
|
const response = c.json(maybeAddContext(object));
|
|
|
|
response.headers.set('content-type', 'application/activity+json; charset=UTF-8');
|
|
|
|
return response;
|
|
|
|
}
|
|
|
|
|
2023-06-10 00:22:38 +00:00
|
|
|
export {
|
2023-07-27 15:36:19 +00:00
|
|
|
activityJson,
|
2023-06-10 00:22:38 +00:00
|
|
|
bech32ToPubkey,
|
|
|
|
buildLinkHeader,
|
2023-07-08 23:41:11 +00:00
|
|
|
eventAge,
|
2023-06-10 00:22:38 +00:00
|
|
|
eventDateComparator,
|
2023-07-08 23:41:11 +00:00
|
|
|
findTag,
|
2023-06-10 00:22:38 +00:00
|
|
|
lookupAccount,
|
|
|
|
type Nip05,
|
2023-07-08 20:04:50 +00:00
|
|
|
nostrDate,
|
2023-06-10 00:22:38 +00:00
|
|
|
nostrNow,
|
2023-07-07 20:07:20 +00:00
|
|
|
type PaginationParams,
|
2023-06-10 00:22:38 +00:00
|
|
|
paginationSchema,
|
|
|
|
parseBody,
|
|
|
|
parseNip05,
|
2023-07-08 23:41:11 +00:00
|
|
|
sha256,
|
2023-06-10 00:22:38 +00:00
|
|
|
};
|
2023-07-09 01:01:10 +00:00
|
|
|
|
|
|
|
export { Time } from './utils/time.ts';
|