diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..135925e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,30 @@ +name: test + +on: + push: + branches: ["main"] + pull_request: + branches: ["*"] + +permissions: + contents: read + +jobs: + test: + timeout-minutes: 1 + runs-on: ubuntu-latest + strategy: + matrix: + deno-version: [1.41.0] + + steps: + - name: Setup repo + uses: actions/checkout@v3 + with: + submodules: 'true' + + - name: Setup Deno + uses: denoland/setup-deno@v1 + + - name: Run tests + run: deno task test diff --git a/.gitignore b/.gitignore index e43b0f9..bbd6f46 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,14 @@ +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# npm dependencies +node_modules/ .DS_Store +deploy/default.ts +*cov_profile* +coverage +test.sqlite diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..5183db8 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "nostr.ts"] + path = nostr.ts + url = https://github.com/BlowaterNostr/nostr.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index ea8a007..ce5cf33 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,4 @@ { "deno.enable": true, - "deno.lint": false + "editor.indentSize": "tabSize" } diff --git a/README.md b/README.md new file mode 100644 index 0000000..1aa16a6 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# Relayed + +## Local Development + +To begin, install Deno by following the instructions at https://deno.land/manual/getting_started/installation. + +Next, create a file named `deploy/default.ts`: + +```bash +cp deploy/default.example.ts deploy/defalut.ts +``` + +After that, launch the project with the command: + +```bash +deno task start +``` + +Finally, open your browser and go to `http://localhost:8000/api` to access the GraphQL playground. + +### Use GraphQL Playground + +To begin, click the `Re-fetch GraphQL schema` button to retrieve the schema. + +In the Headers section, include `{"password":"123456"}` for identity verification. + +You can now utilize the GraphQL Playground to communicate with the server. + +### Client Connection + +Relay url is `ws://localhost:8000`. + +### Database + +If you are using MacOS, the directory might be `~/Library/Caches/deno/location_data`. diff --git a/deno.json b/deno.json index 3fecf5f..caaac4c 100644 --- a/deno.json +++ b/deno.json @@ -1,5 +1,44 @@ { + "tasks": { + "run": "deno run --allow-net --allow-env --unstable deploy/default.ts", + "test": "deno test --allow-net --unstable --allow-read --allow-write --coverage test.ts" + }, + "lint": { + "rules": { + "tags": [ + "fresh", + "recommended" + ], + "exclude": ["require-await", "require-yield", "no-unused-vars"] + } + }, + "exclude": [ + "**/_fresh/*" + ], + "imports": { + "$fresh/": "https://deno.land/x/fresh@1.6.7/", + "preact": "https://esm.sh/preact@10.19.6", + "preact/": "https://esm.sh/preact@10.19.6/", + "@preact/signals": "https://esm.sh/*@preact/signals@1.2.2", + "@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1", + "tailwindcss": "npm:tailwindcss@3.4.1", + "tailwindcss/": "npm:/tailwindcss@3.4.1/", + "tailwindcss/plugin": "npm:/tailwindcss@3.4.1/plugin.js", + "$std/": "https://deno.land/std@0.216.0/" + }, + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "preact", + "lib": [ + "deno.unstable" + ], + "noImplicitAny": false + }, + "nodeModulesDir": true, "fmt": { - "indentWidth": 4 - } + "indentWidth": 4, + "lineWidth": 110, + "exclude": ["cov_profile", "coverage"] + }, + "lock": false } diff --git a/deno.lock b/deno.lock deleted file mode 100644 index 2819e45..0000000 --- a/deno.lock +++ /dev/null @@ -1,11 +0,0 @@ -{ - "version": "3", - "remote": { - "https://deno.land/std@0.176.0/encoding/hex.ts": "50f8c95b52eae24395d3dfcb5ec1ced37c5fe7610ef6fffdcc8b0fdc38e3b32f", - "https://raw.githubusercontent.com/BlowaterNostr/nostr.ts/main/ende.ts": "f41dbfd0f31db12757dab2f4f51ece0c0bd6cb8ba3057d0a154b7f388d85e827", - "https://raw.githubusercontent.com/BlowaterNostr/nostr.ts/main/key.ts": "b9e8a1e5a446168ad93372adc299101d2be2760bd4a18c2853a8a267aa5f3f54", - "https://raw.githubusercontent.com/BlowaterNostr/nostr.ts/main/nostr.ts": "1e8ba1b6d54cd21ac01e5a2fd1623a248c2a76c1c6194388a9809e1863c1c762", - "https://raw.githubusercontent.com/BlowaterNostr/nostr.ts/main/scure.js": "fbc4be16918272bd167fff1184a7f5bbd1a676bad2a73130bb530d78df893a99", - "https://raw.githubusercontent.com/BlowaterNostr/nostr.ts/main/vendor/secp256k1.js": "69e32f6c686cc651ff2e6d4d22ed6e9b6f86b38311b405b47b2abdf3cb98eb4d" - } -} diff --git a/deploy/example.ts b/deploy/example.ts new file mode 100644 index 0000000..58560e1 --- /dev/null +++ b/deploy/example.ts @@ -0,0 +1,9 @@ +import { run } from "../main.tsx"; + +run({ + port: 8080, + default_policy: { + allowed_kinds: "all", // or none, + }, + password: Deno.env.get("relayed_pw"), +}); diff --git a/graphql-schema.ts b/graphql-schema.ts new file mode 100644 index 0000000..c817d2f --- /dev/null +++ b/graphql-schema.ts @@ -0,0 +1,43 @@ +import { gql } from "https://deno.land/x/graphql_tag@0.1.2/mod.ts"; + +export const typeDefs = gql` + type Query { + events(pubkey: String, offset: Int, limit: Int): Events + event(id: String): Event + policies: [Policy] + } + + type Mutation { + add_block(kind: Int, pubkey: String, ): Policy! + remove_block(kind: Int, pubkey: String, ): Policy! + add_allow(kind: Int, pubkey: String, ): Policy! + remove_allow(kind: Int, pubkey: String, ): Policy! + set_policy(kind: Int, read: Boolean, write: Boolean): Policy! + } + + type Events { + count: Int! + data: [Event] + } + type Event { + id: String! + content: String! + pubkey: PublicKey! + kind: Int! + created_at: Int! + sig: String! + tags: [String!]! + } + type PublicKey { + hex: String! + bech32: String! + events: [Event!]! + } + type Policy { + kind: Int! + read: Boolean! + write: Boolean! + allow: [String!]! + block: [String!]! + } +`; diff --git a/main.ts b/main.ts deleted file mode 100644 index bdec482..0000000 --- a/main.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { - _RelayResponse_Event, - ClientRequest_Message, - NostrEvent, - NostrFilters, -} from "https://raw.githubusercontent.com/BlowaterNostr/nostr.ts/main/nostr.ts"; - -Deno.serve({ - port: 8080, -}, (req) => { - if (req.headers.get("upgrade") != "websocket") { - return new Response(null, { status: 501 }); - } - - const { socket, response } = Deno.upgradeWebSocket(req); - - const events = new Map(); - - socket.onopen = (e) => { - console.log("a client connected!", e); - }; - - socket.addEventListener( - "message", - onMessage({ - socket, - events, - requested_subscriptions: new Map(), - }), - ); - - return response; -}); - -function onMessage(deps: { - socket: WebSocket; - events: Map; - requested_subscriptions: Map; -}) { - const { events, socket, requested_subscriptions } = deps; - - return (event: MessageEvent) => { - console.log(event.data); - const nostr_ws_msg = JSON.parse(event.data) as ClientRequest_Message; - const cmd = nostr_ws_msg[0]; - if (cmd == "EVENT") { - const id = nostr_ws_msg[1].id; - if (events.has(id)) { - return; - } - const event = nostr_ws_msg[1]; - events.set(id, event); - for ( - const matched of matchEventWithSubscriptions( - event, - requested_subscriptions, - ) - ) { - socket.send(JSON.stringify(respond(matched.sub_id, event))); - } - } else if (cmd == "REQ") { - const sub_id = nostr_ws_msg[1]; - const filter = nostr_ws_msg[2]; - requested_subscriptions.set(sub_id, filter); - } else if (cmd == "CLOSE") { - } else { - console.log("not implemented", event.data); - } - }; -} - -function respond(sub_id: string, event: NostrEvent): _RelayResponse_Event { - return ["EVENT", sub_id, event]; -} - -function* matchEventWithSubscriptions( - event: NostrEvent, - subscriptions: Map, -) { - for (const [sub_id, filter] of subscriptions) { - if (isMatched(event, filter)) { - yield { - sub_id, - }; - } - } -} - -function isMatched(event: NostrEvent, filter: NostrFilters) { - return filter.kinds?.includes(event.kind) || - filter.authors?.includes(event.pubkey) || - filter.ids?.includes(event.id) || - filter["#p"]?.includes(event.pubkey) || - filter["#e"]?.includes(event.id); - // filter.since - // filter.until -} diff --git a/main.tsx b/main.tsx new file mode 100644 index 0000000..31066c6 --- /dev/null +++ b/main.tsx @@ -0,0 +1,263 @@ +import { typeDefs } from "./graphql-schema.ts"; +import { SubscriptionMap, ws_handler } from "./ws.ts"; +import Error404 from "./routes/_404.tsx"; +import { render } from "https://esm.sh/preact-render-to-string@6.4.1"; +import { Mutation, RootResolver } from "./resolvers/root.ts"; +import * as gql from "https://esm.sh/graphql@16.8.1"; +import { parseJSON } from "./nostr.ts/_helper.ts"; +import { PublicKey } from "./nostr.ts/key.ts"; +import { NostrEvent, NostrFilter, NostrKind, verifyEvent } from "./nostr.ts/nostr.ts"; +import { Policy, PolicyResolver } from "./resolvers/policy.ts"; +import { func_ResolvePolicyByKind } from "./resolvers/policy.ts"; +import { func_GetEventsByKinds, func_WriteEvent } from "./resolvers/event.ts"; +import { WriteEvent } from "./resolvers/event.ts"; +import { func_GetEventsByIDs } from "./resolvers/event.ts"; +import { GetEventsByIDs } from "./resolvers/event.ts"; +import { GetEventsByKinds } from "./resolvers/event.ts"; + +const schema = gql.buildSchema(gql.print(typeDefs)); + +export type DefaultPolicy = { + allowed_kinds: "all" | "none" | NostrKind[]; +}; + +export type Relay = { + server: Deno.HttpServer; + url: string; + shutdown: () => Promise; + set_policy: (args: { + kind: NostrKind; + read?: boolean | undefined; + write?: boolean | undefined; + }) => Promise; + get_policy: (kind: NostrKind) => Promise; + default_policy: DefaultPolicy; +}; + +export async function run(args: { + port: number; + admin?: PublicKey; + password?: string; + default_policy: DefaultPolicy; + kv?: Deno.Kv; +}): Promise { + const connections = new Map(); + let { password } = args; + if (password == undefined) { + password = Deno.env.get("relayed_pw"); + if (!password) { + return new Error("password is not set"); + } + } + if (args.kv == undefined) { + args.kv = await Deno.openKv(); + } + + const { port, default_policy } = args; + + let resolve_hostname; + const hostname = new Promise((resolve) => { + resolve_hostname = resolve; + }); + + const server = Deno.serve( + { + port, + onListen: ({ hostname, port }) => { + console.log(`☁ Started on http://${hostname}:${port}`); + resolve_hostname(hostname); + }, + }, + root_handler({ + ...args, + password, + connections, + resolvePolicyByKind: PolicyResolver(default_policy, args.kv), + write_event: WriteEvent(args.kv), + get_events_by_IDs: GetEventsByIDs(args.kv), + get_events_by_kinds: GetEventsByKinds(args.kv), + kv: args.kv, + }), + ); + const resolvePolicyByKind = PolicyResolver(args.default_policy, args.kv); + const mutation_resolver = Mutation({ ...args, resolvePolicyByKind, kv: args.kv }); + return { + server, + url: `ws://${await hostname}:${port}`, + shutdown: async () => { + await server.shutdown(); + args.kv?.close(); + }, + set_policy: mutation_resolver.set_policy, + get_policy: (kind: NostrKind) => { + return resolvePolicyByKind(kind); + }, + default_policy: args.default_policy, + }; +} + +export type EventReadWriter = { + write_event: func_WriteEvent; + get_events_by_IDs: func_GetEventsByIDs; + get_events_by_kinds: func_GetEventsByKinds; +}; + +const root_handler = ( + args: { + password: string; + connections: Map; + default_policy: DefaultPolicy; + resolvePolicyByKind: func_ResolvePolicyByKind; + kv: Deno.Kv; + } & EventReadWriter, +) => +async (req: Request, info: Deno.ServeHandlerInfo) => { + console.log(info.remoteAddr); + + const { pathname } = new URL(req.url); + if (pathname == "/api") { + return graphql_handler(args)(req); + } + if (pathname == "/") { + return ws_handler(args)(req, info); + } + const resp = new Response(render(Error404()), { status: 404 }); + resp.headers.set("content-type", "html"); + return resp; +}; + +const graphql_handler = + (args: { password: string; kv: Deno.Kv; resolvePolicyByKind: func_ResolvePolicyByKind }) => + async (req: Request) => { + const { password, kv, resolvePolicyByKind } = args; + if (req.method == "POST") { + const query = await req.json(); + const nip42 = req.headers.get("nip42"); + console.log("nip42 header", nip42); + + const pw = req.headers.get("password"); + if (pw != password) { + return new Response(`{"errors":"incorrect password"}`); + } + + if (nip42) { + const auth_event = parseJSON(nip42); + if (auth_event instanceof Error) { + return new Response(`{errors:["no auth"]}`); + } + const ok = await verifyEvent(auth_event); + if (!ok) { + return new Response(`{"errors":["no auth"]}`); + } + } + const result = await gql.graphql({ + schema: schema, + source: query.query, + variableValues: query.variables, + rootValue: RootResolver(args), + }); + console.log(result); + return new Response(JSON.stringify(result)); + } else if (req.method == "GET") { + const res = new Response(graphiql); + res.headers.set("content-type", "html"); + return res; + } else { + return new Response(undefined, { status: 405 }); + } + }; + +export type RelayInformation = { + name?: string; + description?: string; + pubkey?: string; + contact?: string; + supported_nips?: number[]; + software?: string; + version?: string; + icon?: string; +}; + +// export const kv = await Deno.openKv("./test-kv"); + +const graphiql = ` + + + + + GraphiQL + + + + + + + + + + + + + + +
Loading...
+ + +`; diff --git a/makefile b/makefile new file mode 100644 index 0000000..9d091b0 --- /dev/null +++ b/makefile @@ -0,0 +1,8 @@ +run: fmt + deno task run + +fmt: + deno fmt + +test: fmt + deno task test diff --git a/nostr.ts b/nostr.ts new file mode 160000 index 0000000..de204f8 --- /dev/null +++ b/nostr.ts @@ -0,0 +1 @@ +Subproject commit de204f8c207bcb217cfdf262965adba913f26cd3 diff --git a/resolvers/event.ts b/resolvers/event.ts new file mode 100644 index 0000000..b0d82bf --- /dev/null +++ b/resolvers/event.ts @@ -0,0 +1,112 @@ +import { PublicKey } from "../nostr.ts/key.ts"; +import { NostrEvent, NostrKind } from "../nostr.ts/nostr.ts"; +import { pubkey_resolver } from "./pubkey.ts"; + +export type Actor = { + type: "admin"; +} | { + type: "user"; + npub: string; +}; + +export const EventResolverFactory = (kv: Deno.Kv) => + class EventResolver { + id: string; + sig: string; + kind: NostrKind; + content: string; + + static Resolve(event: NostrEvent) { + // const policy = kv.get(["policy", event.kind]) + return new EventResolver(event); + } + + static async ByID(id: string) { + const entry = await kv.get(["event", id]); + if (entry.value) { + return new EventResolver(entry.value); + } + return null; + } + + private constructor(public event: NostrEvent) { + this.id = this.event.id; + this.sig = this.event.sig; + this.kind = this.event.kind; + this.content = this.event.content; + } + + pubkey = () => { + return pubkey_resolver( + PublicKey.FromHex(this.event.pubkey) as PublicKey, + ); + }; + }; + +type Pagination = { + offset?: number | undefined; + limit?: number | undefined; +}; + +// export async function EventsResolver( +// args: { pubkey: string | undefined } & Pagination, +// ) { +// const limit = args.limit || 10; +// const list = kv.list({ prefix: ["event"] }); +// const res = [] as EventResolver[]; +// for await (const entry of list) { +// if (args.pubkey == undefined || args.pubkey == entry.value.pubkey) { +// res.push(EventResolver.Resolve(entry.value)); +// if (res.length >= limit) { +// break; +// } +// } +// } +// return { +// count: res.length, +// data: async function x() { +// return res; +// }, +// }; +// } + +export type func_GetEventsByIDs = (ids: string[]) => Promise; +export const GetEventsByIDs = (kv: Deno.Kv): func_GetEventsByIDs => async (ids: string[]) => { + const entries = await kv.getMany(ids.map((id) => ["event", id])); + return fromEntries(entries); +}; + +export type func_GetEventsByKinds = (kinds: NostrKind[]) => AsyncIterable; +export const GetEventsByKinds = (kv: Deno.Kv): func_GetEventsByKinds => + async function* x(kinds: NostrKind[]) { + for (const kind of kinds) { + const list = kv.list({ prefix: ["event", kind] }); + for await (const entry of list) { + console.log(entry); + if (entry.value) { + yield entry.value; + } + } + } + }; + +function fromEntries(entries: Deno.KvEntryMaybe[]) { + const events: T[] = []; + for (const entry of entries) { + if (entry.value) { + events.push(entry.value); + } + } + return events; +} + +export type func_WriteEvent = (event: NostrEvent) => Promise; +export const WriteEvent = (kv: Deno.Kv): func_WriteEvent => async (event: NostrEvent) => { + const result = await kv.atomic() + .set(["event", event.id], event) + .set(["event", event.kind, event.id], event) + .set(["event", event.pubkey, event.id], event) + .commit(); + + return result.ok; +}; diff --git a/resolvers/policy.ts b/resolvers/policy.ts new file mode 100644 index 0000000..2e4e6c0 --- /dev/null +++ b/resolvers/policy.ts @@ -0,0 +1,70 @@ +import { DefaultPolicy } from "../main.tsx"; +import { NostrEvent, NostrKind } from "../nostr.ts/nostr.ts"; + +export const Policies = (kv: Deno.Kv) => + async function () { + const list = kv.list({ prefix: ["policy"] }); + const res = [] as NostrEvent[]; + for await (const entry of list) { + res.push(entry.value); + } + return res; + }; + +export type func_ResolvePolicyByKind = (kind: NostrKind) => Promise; +export const PolicyResolver = (default_policy: DefaultPolicy, kv: Deno.Kv): func_ResolvePolicyByKind => + async function (kind: NostrKind): Promise { + const entry = await kv.get(["policy", kind]); + if (entry.value == null) { + let allow_this_kind: boolean; + if (default_policy.allowed_kinds == "all") { + allow_this_kind = true; + } else if (default_policy.allowed_kinds == "none") { + allow_this_kind = false; + } else if (default_policy.allowed_kinds.includes(kind)) { + allow_this_kind = true; + } else { + allow_this_kind = false; + } + return { + kind: kind, + read: allow_this_kind, + write: allow_this_kind, + allow: new Set(), + block: new Set(), + }; + } + const policy = entry.value; + + const allow = new Set(); + for (const item of policy.allow) { + if (typeof item == "string") { + allow.add(item); + } + } + policy.allow = allow; + + const block = new Set(); + for (const item of policy.block) { + if (typeof item == "string") { + block.add(item); + } + } + policy.block = block; + policy.kind = kind; + if (policy.read == null) { + policy.read = true; + } + if (policy.write == null) { + policy.write = true; + } + return policy; + }; + +export type Policy = { + kind: NostrKind; + read: boolean; + write: boolean; + allow: Set; + block: Set; +}; diff --git a/resolvers/pubkey.ts b/resolvers/pubkey.ts new file mode 100644 index 0000000..5e73119 --- /dev/null +++ b/resolvers/pubkey.ts @@ -0,0 +1,8 @@ +import { PublicKey } from "../nostr.ts/key.ts"; + +export const pubkey_resolver = async (pubkey: PublicKey) => { + return { + ...pubkey, + bech32: pubkey.bech32, + }; +}; diff --git a/resolvers/root.ts b/resolvers/root.ts new file mode 100644 index 0000000..0c2d9e8 --- /dev/null +++ b/resolvers/root.ts @@ -0,0 +1,63 @@ +import { NostrKind } from "../nostr.ts/nostr.ts"; +import { Policies } from "./policy.ts"; +import { func_ResolvePolicyByKind } from "./policy.ts"; + +export const Mutation = (args: { + resolvePolicyByKind: func_ResolvePolicyByKind; + kv: Deno.Kv; +}) => { + const { resolvePolicyByKind, kv } = args; + return { + add_block: async (args: { kind: number; pubkey: string }) => { + const policy = await resolvePolicyByKind(args.kind); + policy.block.add(args.pubkey); + await kv.set(["policy", args.kind], policy); + return policy; + }, + remove_block: async (args: { kind: number; pubkey: string }) => { + const policy = await resolvePolicyByKind(args.kind); + policy.block.delete(args.pubkey); + await kv.set(["policy", args.kind], policy); + return policy; + }, + add_allow: async (args: { kind: number; pubkey: string }) => { + const policy = await resolvePolicyByKind(args.kind); + policy.allow.add(args.pubkey); + await kv.set(["policy", args.kind], policy); + return policy; + }, + remove_allow: async (args: { kind: number; pubkey: string }) => { + const policy = await resolvePolicyByKind(args.kind); + policy.allow.delete(args.pubkey); + await kv.set(["policy", args.kind], policy); + return policy; + }, + set_policy: async ( + args: { + kind: NostrKind; + read?: boolean; + write?: boolean; + }, + ) => { + const policy = await resolvePolicyByKind(args.kind); + if (args.read != undefined) { + policy.read = args.read; + } + if (args.write != undefined) { + policy.write = args.write; + } + await kv.set(["policy", args.kind], policy); + return policy; + }, + }; +}; + +export function RootResolver(args: { + resolvePolicyByKind: func_ResolvePolicyByKind; + kv: Deno.Kv; +}) { + return { + policies: Policies(args.kv), + ...Mutation(args), + }; +} diff --git a/routes/_404.tsx b/routes/_404.tsx new file mode 100644 index 0000000..12ca4b7 --- /dev/null +++ b/routes/_404.tsx @@ -0,0 +1,17 @@ +export default function Error404() { + return ( + <> + + 404 - Page not found + +
+
+

404 - Page not found

+

+ The page you were looking for doesn't exist. +

+
+
+ + ); +} diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..c1c4b24 --- /dev/null +++ b/tailwind.config.ts @@ -0,0 +1,7 @@ +import { type Config } from "tailwindcss"; + +export default { + content: [ + "{routes,islands,components}/**/*.{ts,tsx}", + ], +} satisfies Config; diff --git a/test.ts b/test.ts new file mode 100644 index 0000000..462c643 --- /dev/null +++ b/test.ts @@ -0,0 +1,107 @@ +// deno-lint-ignore-file no-empty +import { Relay, run } from "./main.tsx"; +import { RelayRejectedEvent, SingleRelayConnection, SubscriptionStream } from "./nostr.ts/relay-single.ts"; +import { prepareNormalNostrEvent } from "./nostr.ts/event.ts"; +import { InMemoryAccountContext, NostrKind } from "./nostr.ts/nostr.ts"; +import { assertEquals } from "https://deno.land/std@0.202.0/assert/assert_equals.ts"; +import { fail } from "https://deno.land/std@0.202.0/assert/fail.ts"; +import { NostrEvent } from "./nostr.ts/nostr.ts"; + +Deno.test("main", async () => { + try { + await Deno.remove("test.sqlite"); + } catch (e) {} + const relay = await run({ + password: "123", + port: 8080, + default_policy: { + allowed_kinds: "none", + }, + kv: await Deno.openKv("test.sqlite"), + }) as Relay; + + const policy = await relay.get_policy(NostrKind.CONTACTS); + assertEquals(policy, { + allow: new Set(), + block: new Set(), + kind: NostrKind.CONTACTS, + read: false, + write: false, + }); + + // relay logic + const ctx = InMemoryAccountContext.Generate(); + const client = SingleRelayConnection.New(relay.url, { log: false }); + + { + // because default policy allows no kinds + const err = await client.sendEvent(await randomEvent(ctx)); + assertEquals(err instanceof RelayRejectedEvent, true); + } + { + await relay.set_policy({ + kind: NostrKind.TEXT_NOTE, + read: false, + write: true, + }); + const event_sent = await randomEvent(ctx); + const err = await client.sendEvent(event_sent); + if (err instanceof Error) fail(err.message); + + const event_got = await client.getEvent(event_sent.id); + assertEquals(event_got, undefined); + + await relay.set_policy({ kind: NostrKind.TEXT_NOTE, read: true }); + const event_got_2 = await client.getEvent(event_sent.id); + assertEquals(event_got_2, event_sent); + } + { + await relay.set_policy({ + kind: NostrKind.CONTACTS, + read: true, + write: true, + }); + const event_1 = await randomEvent(ctx, NostrKind.CONTACTS, "1"); + const event_2 = await randomEvent(ctx, NostrKind.CONTACTS, "2"); + const event_3 = await randomEvent(ctx, NostrKind.CONTACTS, "3"); + + const err_1 = await client.sendEvent(event_1); + if (err_1 instanceof Error) fail(err_1.message); + + const err_2 = await client.sendEvent(event_2); + if (err_2 instanceof Error) fail(err_2.message); + + const err_3 = await client.sendEvent(event_3); + if (err_3 instanceof Error) fail(err_3.message); + + const stream = await client.newSub("get kind 3", { + kinds: [NostrKind.CONTACTS], + }) as SubscriptionStream; + + const events: NostrEvent[] = []; + + for await (const msg of stream.chan) { + if (msg.type == "EVENT") { + events.push(msg.event); + } else if (msg.type == "EOSE") { + await stream.chan.close(); + } + } + + assertEquals(events.length, 3); + // todo: assert content + } + + await client.close(); + await relay.shutdown(); +}); + +Deno.test("graphql", async () => {}); + +async function randomEvent(ctx: InMemoryAccountContext, kind?: NostrKind, content?: string) { + const event = await prepareNormalNostrEvent(ctx, { + kind: kind || NostrKind.TEXT_NOTE, + content: content || "", + }); + return event; +} diff --git a/ws.ts b/ws.ts new file mode 100644 index 0000000..4bda5b7 --- /dev/null +++ b/ws.ts @@ -0,0 +1,299 @@ +// deno-lint-ignore-file +import { func_ResolvePolicyByKind } from "./resolvers/policy.ts"; +import { PublicKey } from "./nostr.ts/key.ts"; +import { + _RelayResponse_EOSE, + _RelayResponse_Event, + _RelayResponse_Notice, + _RelayResponse_OK, + ClientRequest_Event, + ClientRequest_Message, + ClientRequest_REQ, + NostrEvent, + NostrFilter, + verifyEvent, +} from "./nostr.ts/nostr.ts"; +import { DefaultPolicy, EventReadWriter } from "./main.tsx"; +import { func_GetEventsByIDs, func_GetEventsByKinds, func_WriteEvent } from "./resolvers/event.ts"; + +export const ws_handler = ( + args: { + connections: Map; + default_policy: DefaultPolicy; + resolvePolicyByKind: func_ResolvePolicyByKind; + } & EventReadWriter, +) => +(req: Request, info: Deno.ServeHandlerInfo) => { + const { connections } = args; + + if (req.headers.get("upgrade") != "websocket") { + return new Response(null, { status: 501 }); + } + + const { socket, response } = Deno.upgradeWebSocket(req); + + socket.onopen = ((socket: WebSocket) => (e) => { + console.log("a client connected!", info.remoteAddr); + connections.set(socket, new Map()); + })(socket); + + socket.onclose = ((socket: WebSocket) => (e) => { + console.log("client disconnected", info.remoteAddr); + connections.delete(socket); + })(socket); + + socket.onmessage = onMessage({ + ...args, + this_socket: socket, + connections, + }); + + return response; +}; + +export type SubscriptionMap = Map; + +function onMessage( + deps: { + this_socket: WebSocket; + connections: Map; + default_policy: DefaultPolicy; + resolvePolicyByKind: func_ResolvePolicyByKind; + } & EventReadWriter, +) { + const { this_socket, connections } = deps; + + return async (event: MessageEvent) => { + console.log("on message", event.data); + const nostr_ws_msg = JSON.parse(event.data) as ClientRequest_Message; + const cmd = nostr_ws_msg[0]; + if (cmd == "EVENT") { + await handle_cmd_event({ + ...deps, + this_socket, + connections, + nostr_ws_msg, + }); + } else if (cmd == "REQ") { + return handle_cmd_req(nostr_ws_msg, { ...deps, this_socket }); + } else if (cmd == "CLOSE") { + } else { + console.log("not implemented", event.data); + } + }; +} + +async function handle_cmd_event(args: { + this_socket: WebSocket; + connections: Map; + nostr_ws_msg: ClientRequest_Event; + resolvePolicyByKind: func_ResolvePolicyByKind; + write_event: func_WriteEvent; +}) { + const { this_socket, connections, nostr_ws_msg, resolvePolicyByKind, write_event } = args; + const event = nostr_ws_msg[1]; + const ok = await verifyEvent(event); + if (!ok) { + return send( + this_socket, + JSON.stringify(respond_ok(event, false, "invalid event")), + ); + } + + const policy = await resolvePolicyByKind(event.kind); + { // check if allowed to write + const author = PublicKey.FromHex(event.pubkey) as PublicKey; + if (policy.write) { + if (policy.block.has(author.bech32())) { + return send( + this_socket, + JSON.stringify( + respond_ok( + event, + false, + "this pubkey is blocked", + ), + ), + ); + } + } else { + if (!policy.allow.has(author.bech32())) { + return send( + this_socket, + JSON.stringify( + respond_ok( + event, + false, + "this kind is blocked", + ), + ), + ); + } + } + } + + const _ok = await write_event(event); + if (_ok) { + send(this_socket, JSON.stringify(respond_ok(event, true, ""))); + } else { + send(this_socket, JSON.stringify(respond_ok(event, false, ""))); + } + for ( + const matched of matchEventWithSubscriptions( + event, + connections, + ) + ) { + console.log(policy); + if (policy.read === false) { + return; + } + send( + matched.socket, + JSON.stringify(respond_event(matched.sub_id, event)), + ); + } +} + +async function handle_cmd_req( + nostr_ws_msg: ClientRequest_REQ, + args: { + this_socket: WebSocket; + connections: Map; + resolvePolicyByKind: func_ResolvePolicyByKind; + } & EventReadWriter, +) { + const { this_socket, resolvePolicyByKind, get_events_by_IDs } = args; + const sub_id = nostr_ws_msg[1]; + const filters = nostr_ws_msg.slice(2) as NostrFilter[]; + + args.connections.get(this_socket)?.set(sub_id, filters); + + // query this filter + for (const filter of filters) { + const event_candidates = await handle_filter({ ...args, filter }); + for (const event of event_candidates.values()) { + send(this_socket, JSON.stringify(respond_event(sub_id, event))); + } + } + + return send(this_socket, JSON.stringify(respond_eose(sub_id))); +} + +async function handle_filter(args: { + filter: NostrFilter; + get_events_by_IDs: func_GetEventsByIDs; + get_events_by_kinds: func_GetEventsByKinds; + resolvePolicyByKind: func_ResolvePolicyByKind; +}) { + const event_candidates = new Map(); + const { filter, get_events_by_IDs, resolvePolicyByKind, get_events_by_kinds } = args; + if (filter.ids) { + const events = await get_events_by_IDs(filter.ids); + for (const event of events) { + const policy = await resolvePolicyByKind(event.kind); + if (policy.read == false) { + continue; + } + event_candidates.set(event.id, event); + } + } + if (filter.kinds) { + if (event_candidates.size > 0) { + const keys = Array.from(event_candidates.keys()); + for (const key of keys) { + const event = event_candidates.get(key) as NostrEvent; + if (filter.kinds.includes(event.kind)) { + continue; + } + event_candidates.delete(key); + } + } else { + const events = args.get_events_by_kinds(filter.kinds); + for await (const event of events) { + console.log(event); + event_candidates.set(event.id, event); + } + } + } + return event_candidates; +} + +function respond_event( + sub_id: string, + event: NostrEvent, +): _RelayResponse_Event { + return ["EVENT", sub_id, event]; +} + +function respond_ok( + event: NostrEvent, + ok: boolean, + message: string, +): _RelayResponse_OK { + return ["OK", event.id, ok, message]; +} + +function respond_eose(sub_id: string): _RelayResponse_EOSE { + return ["EOSE", sub_id]; +} + +function respond_notice(message: string): _RelayResponse_Notice { + return ["NOTICE", message]; +} + +function* matchEventWithSubscriptions( + event: NostrEvent, + connections: Map, +) { + for (const [socket, subscriptions] of connections) { + console.log(subscriptions); + for (const [sub_id, filters] of subscriptions) { + console.log(sub_id, filters); + if (isMatched(event, filters)) { + yield { + socket, + sub_id, + event, + }; + } + } + } +} + +export function isMatched(event: NostrEvent, filters: NostrFilter[]) { + for (const filter of filters) { + const kinds = filter.kinds || []; + const authors = filter.authors || []; + const ids = filter.ids || []; + const ps = filter["#p"] || []; + const es = filter["#e"] || []; + + const match_kind = kinds.length == 0 ? true : kinds.includes(event.kind); + const match_author = authors.length == 0 ? true : authors.includes(event.pubkey); + const match_id = ids.length == 0 ? true : ids.includes(event.id); + const match_p_tag = ps.length == 0 ? true : ps.includes(event.pubkey); + const match_e_tag = es.length == 0 ? true : es.includes(event.id); + const res = ( + match_kind && + match_author && + match_id && + match_p_tag && + match_e_tag + ) || + (kinds.length == 0 && authors.length == 0 && ids.length == 0 && + ps.length == 0 && es.length == 0); + // filter.since + // filter.until + + if (res) { + return res; + } + } +} + +function send(socket: WebSocket, data: string) { + if (socket.readyState == socket.OPEN) { + socket.send(data); + } +}