diff --git a/nrapp/redux/eventsSlice.ts b/nrapp/redux/eventsSlice.ts index 6cbbfb3..5d3e661 100644 --- a/nrapp/redux/eventsSlice.ts +++ b/nrapp/redux/eventsSlice.ts @@ -1,5 +1,5 @@ import { ID_SEPARATOR } from "@/constants"; -import { Event } from "@/typesTEMPORARY"; +import { Event } from "@/../nrcommon/mod"; import { createEntityAdapter, createSlice, diff --git a/nrapp/typesTEMPORARY.ts b/nrapp/typesTEMPORARY.ts deleted file mode 100644 index 1aa91b2..0000000 --- a/nrapp/typesTEMPORARY.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { z } from "zod"; - -export const eventSchema = z - .object({ - id: z.string().length(32), - pubkey: z.string().length(32), - kind: z.number(), - created_at: z.number(), - tags: z.string().array().array(), - content: z.string(), - sig: z.string(), - }) - .strict(); - -export type Event = z.infer; - -export const profileEventSchema = eventSchema.extend({ - kind: z.literal(0), -}); - -export type ProfileEvent = z.infer; diff --git a/nrcommon/deno.json b/nrcommon/deno.json new file mode 100644 index 0000000..87b8b26 --- /dev/null +++ b/nrcommon/deno.json @@ -0,0 +1,8 @@ +{ + "name": "@trustroots/nrcommon", + "version": "0.0.1", + "exports": "./mod.ts", + "imports": { + "zod": "npm:zod@^3.23.8" + } +} \ No newline at end of file diff --git a/nrcommon/deps.ts b/nrcommon/deps.ts new file mode 100644 index 0000000..5c214d7 --- /dev/null +++ b/nrcommon/deps.ts @@ -0,0 +1,2 @@ +import { z } from "zod"; +export { z }; diff --git a/nrcommon/mod.ts b/nrcommon/mod.ts new file mode 100644 index 0000000..1618176 --- /dev/null +++ b/nrcommon/mod.ts @@ -0,0 +1,73 @@ +import { z } from "./deps.ts" + +import { version as PACKAGE_VERSION } from "./deno.json" with { type: "json" }; +export const CONTENT_MINIMUM_LENGTH = 3; +export const CONTENT_MAXIMUM_LENGTH = 300; + +function isHex(s: string) { + return s.split("").every((c) => "0123456789abcdef".split("").includes(c)); +} + +function isPlusCode(code: string) { + const re = + /(^|\s)([23456789C][23456789CFGHJMPQRV][23456789CFGHJMPQRVWX]{6}\+[23456789CFGHJMPQRVWX]{2,7})(\s|$)/i; + return re.test(code); +} + +export const eventSchema = z + .object({ + id: z.string().length(32), + pubkey: z.string().length(32), + kind: z.number(), + created_at: z.number(), + tags: z.string().array().array(), + content: z.string(), + sig: z.string(), + }) + .strict(); + +export type Event = z.infer; + +function hasOpenLocationCode(tags: string[][]): boolean { + const namespaces = tags + .filter((tag) => tag[0] === "L") + .map((tag) => tag.slice(1)) + .flat(); + const hasOpenLocationCodeNamespace = namespaces.includes("open-location-code"); + if (!hasOpenLocationCodeNamespace) return false; + + const plusCodeTags = tags.filter( + (tag) => tag.length > 3 && tag[0] === "l" && tag[2] === "open-location-code" + ); + if (plusCodeTags.length === 0) return false; + + const plusCodes = plusCodeTags.map((plusCodeTag) => plusCodeTag[1]); + const validPlusCodes = plusCodes.every(isPlusCode); + + if (!validPlusCodes) return false; + + return true; +} + +function hasVersion(tags: string[][]): boolean { + const versionTags = tags.filter((tag) => tag[0] === "kind30398_version"); + if (versionTags.length !== 1) return false; + const versionTag = versionTags[0]; + if (versionTag.length !== 2) return false; + const version = versionTag[1]; + if (version !== PACKAGE_VERSION) return false + return true +} + +export const kind30398EventSchema = eventSchema.extend({ + kind: z.literal(30398), + tags: z + .string() + .array() + .array() + .refine(hasOpenLocationCode, { message: "no valid open-location-code label" }) + .refine(hasVersion, { message: "no valid kind30398_version" }), + content: z.string().max(CONTENT_MAXIMUM_LENGTH, `content is above max length of ${CONTENT_MAXIMUM_LENGTH}`).min(CONTENT_MINIMUM_LENGTH, `content is below min length of ${CONTENT_MINIMUM_LENGTH}`) +}); + +export type Kind30398Event = z.infer; \ No newline at end of file diff --git a/nrserver/repost.ts b/nrserver/repost.ts deleted file mode 100644 index 48ee824..0000000 --- a/nrserver/repost.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { nostrify } from "../deps.ts"; -const { NPool, NRelay1, NSecSigner } = nostrify; -type Tags = string[][]; - -import { - DEFAULT_RELAYS, - DEV_RELAYS, - MAP_NOTE_KIND, - MAP_NOTE_REPOST_KIND, - SUBSCRIPTIONS_MAX_AGE_IN_MINUTES, -} from "../common/constants.ts"; -import { DEV_PUBKEY } from "../common/constants.ts"; -import { validateEvent } from "./validate.ts"; -import { newQueue } from "../deps.ts"; -import { nostrTools } from "../deps.ts"; -import { log } from "../log.ts"; -import { async } from "../deps.ts"; -import { DELAY_AFTER_PROCESSING_EVENT_MS } from "../common/constants.ts"; - -async function getRelayPool(isDev: true | undefined) { - const relays = isDev ? DEV_RELAYS : DEFAULT_RELAYS; - - // should be chosen according to outbox model - // https://nostrify.dev/relay/outbox - const pool = new NPool({ - open(url) { - return new NRelay1(url); - }, - async reqRouter(filter: nostrify.NostrFilter[]) { - const map = new Map(); - relays.map((relay) => { - map.set(relay, filter); - }); - return map; - }, - async eventRouter(_event) { - return relays; - }, - }); - - return pool; -} - -async function publishEvent( - relayPool: nostrify.NPool, - event: nostrify.NostrEvent -) { - log.debug("#aSmTVL Publishing event"); - await relayPool.event(event); - log.info("#p26tur Event published.", event); -} - -/** - * Take a nostr event that was signed by a user and generate the repost event. - */ -async function generateRepostedEvent( - originalEvent: nostrify.NostrEvent, - privateKey: Uint8Array -) { - const derivedTags = deriveTags(originalEvent); - const derivedContent = deriveContent(originalEvent); - const dTag = ["d", `${originalEvent.pubkey}:${originalEvent.id}`]; - const eTag = ["e", originalEvent.id]; - const pTag = ["p", originalEvent.pubkey]; - const originalCreatedAtTag = [ - "original_created_at", - `${originalEvent.created_at}`, - ]; - - const signer = new NSecSigner(privateKey); - const eventTemplate = { - kind: MAP_NOTE_REPOST_KIND, - created_at: Math.floor(Date.now() / 1000), - tags: [eTag, pTag, dTag, originalCreatedAtTag, ...derivedTags], - content: derivedContent, - }; - const signedEvent = await signer.signEvent(eventTemplate); - return signedEvent; -} - -function deriveTags(event: nostrify.NostrEvent): Tags { - return event.tags; -} - -function deriveContent(event: nostrify.NostrEvent): string { - return event.content; -} - -/** - * Create the filters to listen for events that we want to repost - */ -function createFilter( - isDev: true | undefined, - maxAgeMinutes: number | undefined -): nostrify.NostrFilter[] { - const maxAgeSeconds = - typeof maxAgeMinutes === "undefined" ? 60 * 60 : maxAgeMinutes * 60; - - const baseFilter: nostrify.NostrFilter = { - kinds: [MAP_NOTE_KIND], - since: Math.floor(Date.now() / 1e3) - maxAgeSeconds, - }; - - if (isDev) { - return [{ ...baseFilter, authors: [DEV_PUBKEY] }]; - } - - return [baseFilter]; -} - -function processEventFactoryFactory( - relayPool: nostrify.NPool, - privateKey: Uint8Array -) { - return function processEventFactory(event: nostrify.NostrEvent) { - return async function () { - log.debug(`#C1NJbQ Got event`, event); - - const isEventValid = await validateEvent(relayPool, event); - if (!isEventValid) { - log.info(`#u0Prc5 Discarding invalid event ${event.id}`); - return; - } - const repostedEvent = await generateRepostedEvent(event, privateKey); - publishEvent(relayPool, repostedEvent); - - await async.delay(DELAY_AFTER_PROCESSING_EVENT_MS); - }; - }; -} - -export async function repost( - privateKey: Uint8Array, - isDev: true | undefined, - maxAgeMinutes: number | undefined -) { - log.debug(`#BmseJH Startup`); - - const relayPool = await getRelayPool(isDev); - - let lastReceivedMessageTimestamp = 0; - let controller: AbortController; - let signal: AbortSignal; - - async function _subscribe() { - console.log( - `(Re)starting subscriptions, last message received at ${lastReceivedMessageTimestamp} (${new Date( - lastReceivedMessageTimestamp * 1000 - ).toLocaleString()})` - ); - if (lastReceivedMessageTimestamp) - maxAgeMinutes = - (Math.floor(Date.now() / 1000) - lastReceivedMessageTimestamp) / 60 + 1; - - const filter = createFilter(isDev, maxAgeMinutes); - - if (controller) controller.abort(); - controller = new AbortController(); - signal = controller.signal; - const subscription = relayPool.req(filter, { signal }); - - const queue = newQueue(3); - const processEventFactory = processEventFactoryFactory( - relayPool, - privateKey - ); - - try { - for await (const msg of subscription) { - console.log("got msg", msg); - - if (msg[0] === "EVENT") { - const event = msg[2]; - lastReceivedMessageTimestamp = event.created_at; - queue.add(processEventFactory(event)); - } else if (msg[0] === "EOSE") { - if (isDev) { - globalThis.setTimeout(() => { - controller.abort(); - }, 10e3); - } - } - } - } catch (e) { - console.log("got error"); - console.log(e.reason); - console.log(e, typeof e); - } - } - - _subscribe(); - setInterval(_subscribe, SUBSCRIPTIONS_MAX_AGE_IN_MINUTES * 60 * 1000); -} diff --git a/nrserver/validate.ts b/nrserver/validate.ts deleted file mode 100644 index 89ed132..0000000 --- a/nrserver/validate.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { - HITCHMAPS_AUTHOR_PUBLIC_KEY, - WAIT_FOR_KIND_ZERO_TIMEOUT_SECONDS, -} from "../common/constants.ts"; -import { MINIMUM_TRUSTROOTS_USERNAME_LENGTH } from "../common/constants.ts"; -import { MAP_NOTE_KIND } from "../common/constants.ts"; -import { nostrify, nostrTools } from "../deps.ts"; -import { log } from "../log.ts"; -import { Profile } from "../types.ts"; - -async function getKindZeroEvent(relayPool: nostrify.NPool, pubKey: string) { - { - const filter = [ - { - authors: [pubKey], - kinds: [0], - }, - ]; - - const controller = new AbortController(); - const signal = controller.signal; - globalThis.setTimeout( - () => controller.abort(), - WAIT_FOR_KIND_ZERO_TIMEOUT_SECONDS * 1000 - ); - - const kindZeroEvents = await relayPool.query(filter, { signal }); - if (kindZeroEvents.length > 0) return kindZeroEvents[0]; - return; - } -} - -function getProfileFromEvent(event: nostrTools.Event): Profile | undefined { - log.debug("#GHg51j kindZeroEvent", event); - try { - const profile = JSON.parse(event.content); - - const { trustrootsUsername } = profile; - - if ( - typeof trustrootsUsername !== "string" || - trustrootsUsername.length < MINIMUM_TRUSTROOTS_USERNAME_LENGTH - ) { - return; - } - - return profile; - } catch { - return; - } -} - -async function getNip5PubKey( - trustrootsUsername: string -): Promise { - try { - const nip5Response = await fetch( - `https://www.trustroots.org/.well-known/nostr.json?name=${trustrootsUsername}` - ); - const nip5Json = (await nip5Response.json()) as { - names: { - [username: string]: string; - }; - }; - - const nip5PubKey = nip5Json.names[trustrootsUsername]; - - return nip5PubKey; - } catch (e: unknown) { - console.warn(`Could not get nip5 key for ${trustrootsUsername}`, e); - return; - } -} - -/** - * Does this event meet our requirements for automated validation? - * - * Check things like, is the event signed by the pubkey which is linked to the - * correct trustroots profile. - */ -export async function validateEvent( - relayPool: nostrify.NPool, - event: nostrify.NostrEvent -) { - if (event.kind !== MAP_NOTE_KIND) { - return false; - } - - // Automatically validate all hitchmap notes without checking for kind zero - if (event.pubkey === HITCHMAPS_AUTHOR_PUBLIC_KEY) { - return true; - } - - const kindZeroEvent = await getKindZeroEvent(relayPool, event.pubkey); - - if (typeof kindZeroEvent === "undefined") { - log.debug("#Kmf59M Skipping event with no kind zero event", { event }); - return false; - } - - const profile = getProfileFromEvent(kindZeroEvent); - - if (typeof profile === "undefined") { - log.debug("#pd4X7C Skipping event with invalid profile", { event }); - return false; - } - - const { trustrootsUsername } = profile; - - log.debug(`#yUtER5 Checking username ${trustrootsUsername}`); - - const nip5PubKey = await getNip5PubKey(trustrootsUsername); - - if (typeof nip5PubKey !== "string") { - log.debug("#b0gWmE Failed to get string nip5 pubkey", { event }); - return false; - } - - if (event.pubkey !== nip5PubKey) { - log.debug("#dtKr5H Event failed nip5 validation", { event }); - return false; - } - - log.debug("#lpglLu Event passed validation", event); - return true; -}