basic nip86 implmentation
This commit is contained in:
parent
22a4fe069f
commit
df94aed0d0
6 changed files with 370 additions and 56 deletions
271
src/main.ts
271
src/main.ts
|
@ -1,99 +1,270 @@
|
|||
import type { ServerWebSocket } from "bun";
|
||||
|
||||
interface Event {
|
||||
kind: number;
|
||||
tags: string[][];
|
||||
content: string;
|
||||
created_at: number;
|
||||
pubkey: string;
|
||||
id: string;
|
||||
sig: string;
|
||||
}
|
||||
import {
|
||||
allowPubkey,
|
||||
banPubkey,
|
||||
getAllAllowedPubkeys,
|
||||
isPubkeyAllowed,
|
||||
validateAuthEvent,
|
||||
} from "./utils.ts";
|
||||
import { getGitCommitHash } from "./utils.ts" with { type: "macro" };
|
||||
import { nip98 } from "nostr-tools";
|
||||
|
||||
type Nip42ProxySocketData = {
|
||||
authenticated: boolean;
|
||||
authToken: string;
|
||||
remoteWs: WebSocket;
|
||||
remoteWs?: WebSocket;
|
||||
};
|
||||
|
||||
async function validateAuthEvent(event: Event, challenge: string): Promise<boolean> {
|
||||
if (event.kind !== 22242) return false;
|
||||
const last30Seconds = Math.floor(Date.now() / 1000) - 30;
|
||||
if (event.created_at < last30Seconds) return false;
|
||||
const challengeTag = event.tags.find(tag => tag[0] === 'challenge')?.[1];
|
||||
if (challengeTag !== challenge) return false;
|
||||
return await isPubkeyAllowed(event);
|
||||
const sendMessage = (
|
||||
ws: ServerWebSocket<Nip42ProxySocketData>,
|
||||
message: any[],
|
||||
) => ws.send(JSON.stringify(message), true);
|
||||
const sendAuth = (ws: ServerWebSocket<Nip42ProxySocketData>) =>
|
||||
sendMessage(ws, [
|
||||
"AUTH",
|
||||
ws.data.authToken,
|
||||
"This is an authenticated relay.",
|
||||
]);
|
||||
|
||||
export interface RelayConfig {
|
||||
adminPubkey?: string;
|
||||
relay: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
banner?: string;
|
||||
contact?: string;
|
||||
policy?: string;
|
||||
allowUnauthedPublish: boolean;
|
||||
}
|
||||
|
||||
async function isPubkeyAllowed(event: Event): Promise<boolean> {
|
||||
const file = Bun.file("./allowed-pubkeys.json");
|
||||
if (!await file.exists()) return true;
|
||||
const allowedPubkeys = JSON.parse(await file.text());
|
||||
return allowedPubkeys.includes(event.pubkey);
|
||||
}
|
||||
export function main({
|
||||
relay,
|
||||
adminPubkey,
|
||||
name,
|
||||
description,
|
||||
icon,
|
||||
banner,
|
||||
contact,
|
||||
policy,
|
||||
allowUnauthedPublish,
|
||||
}: RelayConfig) {
|
||||
function relayInfo() {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
name,
|
||||
description,
|
||||
banner,
|
||||
pubkey: adminPubkey,
|
||||
contact,
|
||||
software: "https://git.arx-ccn.com/Arx/nip42-proxy.git",
|
||||
supported_nips: [1, 2, 4, 9, 11, 22, 28, 40, 42, 70, 77, 86],
|
||||
version: `nip42-proxy - ${getGitCommitHash()}`,
|
||||
posting_policy: policy,
|
||||
icon,
|
||||
limitation: {
|
||||
auth_required: true,
|
||||
restricted_writes: true,
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "application/json; charset=utf-8" } },
|
||||
);
|
||||
}
|
||||
|
||||
const sendMessage = (ws: ServerWebSocket<Nip42ProxySocketData>, message: any[]) => ws.send(JSON.stringify(message), true);
|
||||
const sendAuth = (ws: ServerWebSocket<Nip42ProxySocketData>) => sendMessage(ws, ["AUTH", ws.data.authToken, "This is an authenticated relay."]);
|
||||
async function relayRPC(
|
||||
config: RelayConfig,
|
||||
token: string,
|
||||
url: string,
|
||||
method: string,
|
||||
params: any[],
|
||||
) {
|
||||
if (method === "supportedmethods")
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
result: [
|
||||
"supportedmethods",
|
||||
"banpubkey",
|
||||
"allowpubkey",
|
||||
"listallowedpubkeys",
|
||||
],
|
||||
}),
|
||||
);
|
||||
const valid = await nip98.validateToken(token, url, "POST");
|
||||
if (!token || !valid)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "You are not authorized",
|
||||
}),
|
||||
);
|
||||
const event = await nip98.unpackEventFromToken(token);
|
||||
if (adminPubkey && event.pubkey !== adminPubkey)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "You are not authorized",
|
||||
}),
|
||||
);
|
||||
else if (!(await isPubkeyAllowed(event)))
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "You are not authorized",
|
||||
}),
|
||||
);
|
||||
|
||||
if (method === "allowpubkey") {
|
||||
const [pubkey] = params;
|
||||
if (!pubkey)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: `Missing pubkey parameter`,
|
||||
}),
|
||||
);
|
||||
await allowPubkey(pubkey);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
result: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (method === "listallowedpubkeys") {
|
||||
const resp = await getAllAllowedPubkeys();
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
result: resp.map((k: string) => ({ pubkey: k })),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (method === "banpubkey") {
|
||||
const [pubkey] = params;
|
||||
if (!pubkey)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: `Missing pubkey parameter`,
|
||||
}),
|
||||
);
|
||||
await banPubkey(pubkey);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
result: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: `Unsupported method ${method}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function main(mainRelayUrl: string, allowUnauthedPublish: boolean) {
|
||||
const server = Bun.serve<Nip42ProxySocketData, {}>({
|
||||
fetch(req, server) {
|
||||
const upgrade = server.upgrade(req, {
|
||||
async fetch(req, server) {
|
||||
const upgraded = server.upgrade(req, {
|
||||
data: {
|
||||
authenticated: false,
|
||||
authToken: Bun.randomUUIDv7()
|
||||
}
|
||||
authToken: Bun.randomUUIDv7(),
|
||||
},
|
||||
});
|
||||
if (!upgrade) console.log(`http request`)
|
||||
return new Response("this is a proxy. Use http to access it");
|
||||
if (upgraded) return undefined as any;
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname === "/") {
|
||||
switch (req.headers.get("content-type")) {
|
||||
case "application/nostr+json":
|
||||
return relayInfo();
|
||||
case "application/nostr+json+rpc":
|
||||
const token = req.headers.get("Authorization") || "";
|
||||
let data = await req.json();
|
||||
if (!data)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Please send a valid nostr rpc request",
|
||||
}),
|
||||
);
|
||||
let { method, params } = await req.json();
|
||||
params ||= [];
|
||||
return relayRPC(adminPubkey, token, req.url, method, params);
|
||||
break;
|
||||
default:
|
||||
return new Response("Nip42 Proxy");
|
||||
}
|
||||
}
|
||||
if (url.pathname === "/api/health")
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
activeRequests: server.pendingRequests,
|
||||
activeWebsockets: server.pendingWebSockets,
|
||||
}),
|
||||
);
|
||||
},
|
||||
websocket: {
|
||||
async message(ws, msg) {
|
||||
const [command, ...data] = JSON.parse(msg);
|
||||
const [command, ...data] = JSON.parse(msg as string);
|
||||
if (!ws.data.authenticated) {
|
||||
if (command === "REQ") {
|
||||
const [name, ...filters] = data;
|
||||
sendMessage(ws, ["CLOSED", name, 'auth-required: you must authenticate first']);
|
||||
sendMessage(ws, [
|
||||
"CLOSED",
|
||||
name,
|
||||
"auth-required: you must authenticate first",
|
||||
]);
|
||||
}
|
||||
if (command === "EVENT") {
|
||||
const [event] = data;
|
||||
if (allowUnauthedPublish && await isPubkeyAllowed(event)) {
|
||||
if (allowUnauthedPublish && (await isPubkeyAllowed(event))) {
|
||||
ws.data.remoteWs.send(msg);
|
||||
return;
|
||||
}
|
||||
sendMessage(ws, ["OK", event.id, false, 'auth-required: you must authenticate first']);
|
||||
sendMessage(ws, [
|
||||
"OK",
|
||||
event.id,
|
||||
false,
|
||||
"auth-required: you must authenticate first",
|
||||
]);
|
||||
}
|
||||
if (command === "AUTH") {
|
||||
const [event] = data;
|
||||
const valid = await validateAuthEvent(event, ws.data.authToken);
|
||||
if (!valid) return sendMessage(ws, ['NOTICE', "Invalid auth event"]);
|
||||
sendMessage(ws, ['OK', event.id, true, "successully authenticated. welcome"]);
|
||||
if (!valid)
|
||||
return sendMessage(ws, ["NOTICE", "Invalid auth event"]);
|
||||
sendMessage(ws, [
|
||||
"OK",
|
||||
event.id,
|
||||
true,
|
||||
"successully authenticated. welcome",
|
||||
]);
|
||||
ws.data.authenticated = true;
|
||||
return;
|
||||
}
|
||||
return sendAuth(ws);
|
||||
} else if (ws.data.authenticated && command === "AUTH") {
|
||||
const [event] = data;
|
||||
return sendMessage(ws, ['OK', event.id, true, 'you were already authenticated'])
|
||||
return sendMessage(ws, [
|
||||
"OK",
|
||||
event.id,
|
||||
true,
|
||||
"you were already authenticated",
|
||||
]);
|
||||
}
|
||||
ws.data.remoteWs.send(msg);
|
||||
},
|
||||
open(ws) {
|
||||
const remoteWs = new WebSocket(mainRelayUrl);
|
||||
remoteWs.addEventListener("message", data => ws.send(data.data, true));
|
||||
const remoteWs = new WebSocket(relay);
|
||||
remoteWs.addEventListener("message", (data) =>
|
||||
ws.send(
|
||||
(data as MessageEvent).data as string | ArrayBufferLike,
|
||||
true,
|
||||
),
|
||||
);
|
||||
remoteWs.addEventListener("open", () => {
|
||||
ws.data.remoteWs = remoteWs;
|
||||
sendAuth(ws);
|
||||
})
|
||||
remoteWs.addEventListener("error", console.error)
|
||||
});
|
||||
remoteWs.addEventListener("error", console.error);
|
||||
},
|
||||
perMessageDeflate: true,
|
||||
perMessageDeflateCompressionLevel: 9
|
||||
perMessageDeflateCompressionLevel: 9,
|
||||
close(ws) {
|
||||
ws.data.remoteWs?.close();
|
||||
}
|
||||
}
|
||||
})
|
||||
console.log('Server listening @', server.url.host)
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log("Server listening @", server.url.host);
|
||||
}
|
||||
|
||||
|
|
67
src/utils.ts
Normal file
67
src/utils.ts
Normal file
|
@ -0,0 +1,67 @@
|
|||
import type { Event } from "nostr-tools";
|
||||
|
||||
export function getGitCommitHash() {
|
||||
try {
|
||||
const { stdout } = Bun.spawnSync({
|
||||
cmd: ["git", "rev-parse", "HEAD"],
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const out = stdout?.toString()?.trim();
|
||||
return out?.split("\n")[0] || "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateAuthEvent(
|
||||
event: Event,
|
||||
challenge: string,
|
||||
): Promise<boolean> {
|
||||
if (event.kind !== 22242) return false;
|
||||
const last30Seconds = Math.floor(Date.now() / 1000) - 30;
|
||||
if (event.created_at < last30Seconds) return false;
|
||||
const challengeTag = event.tags.find((tag) => tag[0] === "challenge")?.[1];
|
||||
if (challengeTag !== challenge) return false;
|
||||
return await isPubkeyAllowed(event);
|
||||
}
|
||||
|
||||
export async function isPubkeyAllowed(event: Event): Promise<boolean> {
|
||||
const file = Bun.file("./allowed-pubkeys.json");
|
||||
if (!(await file.exists())) return true;
|
||||
try {
|
||||
const allowedPubkeys = JSON.parse(await file.text());
|
||||
return (
|
||||
Array.isArray(allowedPubkeys) && allowedPubkeys.includes(event.pubkey)
|
||||
);
|
||||
} catch {
|
||||
// If the file is malformed, default to deny
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllAllowedPubkeys() {
|
||||
const file = Bun.file("./allowed-pubkeys.json");
|
||||
try {
|
||||
if (await file.exists()) return JSON.parse(await file.text());
|
||||
} catch {}
|
||||
return [] as string[];
|
||||
}
|
||||
|
||||
export async function saveAllowedPubkeys(pubkeys: string[]): Promise<number> {
|
||||
const file = Bun.file("./allowed-pubkeys.json");
|
||||
const unique = Array.from(new Set(pubkeys));
|
||||
return await file.write(JSON.stringify(unique));
|
||||
}
|
||||
|
||||
export async function allowPubkey(pubkey: string): Promise<number> {
|
||||
const pubkeys = await getAllAllowedPubkeys();
|
||||
if (!pubkeys.includes(pubkey)) pubkeys.push(pubkey);
|
||||
return await saveAllowedPubkeys(pubkeys);
|
||||
}
|
||||
|
||||
export async function banPubkey(pubkey: string): Promise<number> {
|
||||
const pubkeys = await getAllAllowedPubkeys();
|
||||
const filtered = pubkeys.filter((p: string) => p !== pubkey);
|
||||
return await saveAllowedPubkeys(filtered);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue