From 2701786de828778e25918df4e2e361979b67f880 Mon Sep 17 00:00:00 2001 From: Gordon Pedersen Date: Wed, 27 Sep 2023 09:49:32 +1000 Subject: [PATCH] Some refactoring and removing the need to specify user This is going to be single user anyway, so why use https://death.id.au/death.au when I can just be https://death.id.au --- bun.lockb | Bin 7532 -> 6098 bytes package.json | 3 +- src/activitypub.ts | 76 +++++++++++------------------- src/env.ts | 4 +- src/inbox.ts | 2 + src/index.ts | 39 ++++++++++------ src/request.ts | 113 ++++++++++++++++++++------------------------- 7 files changed, 107 insertions(+), 130 deletions(-) diff --git a/bun.lockb b/bun.lockb index d2a92dedf1ebc73dde45985a4aaa3e096345caa9..97de569746978c028abd9852a04dca55dff7060c 100755 GIT binary patch delta 1391 zcmcIkYfMu~6rP#((%#lfD=n4+zLkIpmjbR@)K+0#-4q|hM^Ri%4FwSp111VYMdJ@d z@sYEM{8)EER`iDCw@nRDjc z^O#pzche53De;3XJEuIpBdxe$Om4apd*oC{+r6b9hI)SA|Bv_jm-esOE~bg?aB%$2 z<+cbJSz@f-hf@n;CZZLwAJL9@6)_sItaMXRYGpAi=Mg@HsNnj>>eQN0ML7#sZ>}n> z2(eIA$>va1b$E&)Vs9Gi%XrR>I2!R8u3PDX!iIIvuhq4FhGd@au6tE-yCLS~q{owA zFYWjKartoCnx>=+bG;nSnEc=oX1)l#!q4|Wjqr;B2HnCZC8G;JLXVg&*Wzpj+Khr- zVkSQgH3mQD(1qVT_}Ac6`>l^K88~hLfY4I0Y{?}exc>> zdIPkWDj;kQK)zXl9&>;jK$e4GmKCUw16+bGWSz*yS_0ezrIsLUuqbdFnG9}g5aO%~ zY_|ru6?&1~v?{rUYa3VI7h^ka{r&ENyDxghiyMxQeZQUW`b&N9R30*EaBuX{#cjdq zQ(?)ofe8mcbv&(m6KLhNq3rTE%RhbGt_5M(HjI%_AVmqnwowW+DLE4<7`-d$8<7DX z6|c`V#H6^uC^^3!Q=@R30S3%LCXdM!LmcwQfz>6YE4_M)F3 z9YgLwe!Q$;U-82EiFPC;SuSXF{bboxSrkq!uB<8v!=TH1t{~22fRpY7=t+o&HunTj K6U*RUV)K7j8Y{5? delta 2405 zcmcgudrXs86#u@jEq&11mdc}mGTG<^r4K={h`^9344jgw9mtqG3W7`+?G$8&W-$gO zxau7lipF3R7RW*oHjzxHU=|-}IwR4Eo5m#~FQ-n7;J}^J_ER^PWq)it$?dtnd(Z2; z=iYOANiz~ixl=FqhfNnq>CHFv~kGJsj&!j(*u2@mC!8r^7(*2?L4cIRD2HP!#!;r%d|S zVjxO4b6n(j>4mUsK`pbV<@b$l9bOw4?l*bsMDgUQ?>p92G?X=K)5LCXcXxPH*@lMm zdWC0(_6XO=WG4+ z<>$8acdf2{m{xXrAoWUX24DB|z^V|FUtnUuz`>ongX~X&AC0LOeEGOa-j~qoJ=Sj~ zqavTmk!x3)6@qn_8q>GzPs)-FqzvfxwnR>)+3hKr4;4#(naWww+F&;A%jjs_TwSrO zQ+Rvrs=hlOR(benQm#$v2Z7MklynNx2T%POaqs7NCuql{Bg{dc8%I)mESCLO>r?26 zmG^>Y1$6hRnuP65rP(p>1o_sRVtc8>$F_E*E#b{!6w%k3j`u81J-hA&!!pt!GSG4| zE?T~db;e#X^vIoN@yy;+NW?NZ~Hf`Y4t%_;ql`|5~Wjwww{plm!#nD0A z=VzLIh6R0{Nq0tXt6OFTH7b~nc1dI%JPE(bb{<99>T8N$ys-b{TFl=%##dEn3UyS{ zY5-U+@eLv&uO>RNf_D5?viqpR7R@72A;h2qOaJv+yGeYLAt%MBMON2)KcpbZC z@2RIw!a1hU?h^5tPJCs7E_4{4y>VDMoGU|?~;A^@EOy9Ry+b`7lgjuM1s@aiR^ zAkaQE6_Sq2D27{bS`JZ1RfajH7)_!ZbcZL1?hvC(bc62D70RM3yUY9oFS0|XLQAAo z7T}GYhV2#tI#Z{fuZCi+BIjkwO149>UBzp7#4b;vi^vQQWz5SyLR^8d zEtzer49)Juc{@cc-Xz^4;Vq-7B&*b9qU8zo)xK$HvvOJ zEI*q+DUjtR^$!7tn`1e=eob}W(l`8VE_0Xt$&kmAK7CIz1(&Mw%Xfqx^$GIgPe$UXf$ohCurISF+ng@8P% l_41{4#O9^?gFT%Z5)G?6NQYOT@?~wfCTes3X3{3E`yFfMIGz9i diff --git a/package.json b/package.json index 32c717e..45375fc 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "ngrok": "ngrok tunnel --label edge=edghts_2VNJvaPttrFlAPWxrGyVKu0s3ad http://localhost:3000" }, "devDependencies": { - "@types/node-forge": "^1.3.5" + "@types/node-forge": "^1.3.5", + "bun-types": "^1.0.3" }, "peerDependencies": { "typescript": "^5.0.0" diff --git a/src/activitypub.ts b/src/activitypub.ts index 9ff2da3..f86877c 100644 --- a/src/activitypub.ts +++ b/src/activitypub.ts @@ -2,21 +2,32 @@ import { ACCOUNT, ACTOR, HOSTNAME, PUBLIC_KEY } from "./env" import * as db from "./db" import { reqIsActivityPub, send, verify } from "./request" import outbox from "./outbox" +import inbox from "./inbox" export default (req: Request): Response | Promise | undefined => { const url = new URL(req.url) let match if(req.method === "GET" && url.pathname === "/test") return new Response("", { status: 204 }) - else if(req.method == "POST" && (match = url.pathname.match(/^\/([^\/]+)\/inbox\/?$/i))) return postInbox(req, match[1]) - else if(req.method == "POST" && (match = url.pathname.match(/^\/([^\/]+)\/outbox\/?$/i))) return postOutbox(req, match[1]) - else if(req.method == "GET" && (match = url.pathname.match(/^\/([^\/]+)\/outbox\/?$/i))) return getOutbox(req, match[1]) - else if(req.method == "GET" && (match = url.pathname.match(/^\/([^\/]+)\/followers\/?$/i))) return getFollowers(req, match[1]) - else if(req.method == "GET" && (match = url.pathname.match(/^\/([^\/]+)\/following\/?$/i))) return getFollowing(req, match[1]) - else if(req.method == "GET" && (match = url.pathname.match(/^\/([^\/]+)\/?$/i))) return getActor(req, match[1]) - else if(req.method == "GET" && (match = url.pathname.match(/^\/([^\/]+)\/posts\/([^\/]+)\/?$/i))) return getPost(req, match[1], match[2]) - else if(req.method == "GET" && (match = url.pathname.match(/^\/([^\/]+)\/posts\/([^\/]+)\/activity\/?$/i))) return getActivity(req, match[1], match[2]) - + + // else if(req.method == "GET" && (match = url.pathname.match(/^\/([^\/]+)\/?$/i))) return getActor(req, match[1]) + // else if(req.method == "GET" && (match = url.pathname.match(/^\/([^\/]+)\/outbox\/?$/i))) return getOutbox(req, match[1]) + // else if(req.method == "POST" && (match = url.pathname.match(/^\/([^\/]+)\/inbox\/?$/i))) return postInbox(req, match[1]) + // else if(req.method == "POST" && (match = url.pathname.match(/^\/([^\/]+)\/outbox\/?$/i))) return postOutbox(req, match[1]) + // else if(req.method == "GET" && (match = url.pathname.match(/^\/([^\/]+)\/followers\/?$/i))) return getFollowers(req, match[1]) + // else if(req.method == "GET" && (match = url.pathname.match(/^\/([^\/]+)\/following\/?$/i))) return getFollowing(req, match[1]) + // else if(req.method == "GET" && (match = url.pathname.match(/^\/([^\/]+)\/posts\/([^\/]+)\/?$/i))) return getPost(req, match[1], match[2]) + // else if(req.method == "GET" && (match = url.pathname.match(/^\/([^\/]+)\/posts\/([^\/]+)\/activity\/?$/i))) return getActivity(req, match[1], match[2]) + + else if(req.method == "GET" && (match = url.pathname.match(/^\/?$/i))) return getActor(req, ACCOUNT) + else if(req.method == "GET" && (match = url.pathname.match(/^\/outbox\/?$/i))) return getOutbox(req, ACCOUNT) + else if(req.method == "POST" && (match = url.pathname.match(/^\/inbox\/?$/i))) return postInbox(req, ACCOUNT) + else if(req.method == "POST" && (match = url.pathname.match(/^\/outbox\/?$/i))) return postOutbox(req, ACCOUNT) + else if(req.method == "GET" && (match = url.pathname.match(/^\/followers\/?$/i))) return getFollowers(req, ACCOUNT) + else if(req.method == "GET" && (match = url.pathname.match(/^\/following\/?$/i))) return getFollowing(req, ACCOUNT) + else if(req.method == "GET" && (match = url.pathname.match(/^\/posts\/([^\/]+)\/?$/i))) return getPost(req, ACCOUNT, match[1]) + else if(req.method == "GET" && (match = url.pathname.match(/^\/posts\/([^\/]+)\/activity\/?$/i))) return getActivity(req, ACCOUNT, match[1]) + return undefined } @@ -41,8 +52,6 @@ const postOutbox = async (req:Request, account:string):Promise => { // ensure that the verified actor matches the actor in the request body if (ACTOR !== body.actor) return new Response("", { status: 401 }) - // console.log(body) - return await outbox(body) } @@ -66,46 +75,17 @@ const postInbox = async (req:Request, account:string):Promise => { // ensure that the verified actor matches the actor in the request body if (from !== body.actor) return new Response("", { status: 401 }) - // console.log(body) + return await inbox(body) // TODO: add support for more types! we want replies, likes, boosts, etc! - switch (body.type) { - case "Follow": await follow(body); - case "Undo": await undo(body); - case "Accept": await accept(body); - case "Reject": await reject(body); - } + // switch (body.type) { + // case "Follow": await follow(body); + // case "Undo": await undo(body); + // case "Accept": await accept(body); + // case "Reject": await reject(body); + // } - return new Response("", { status: 204 }) -} - -const follow = async (body:any) => { - await send(ACTOR, body.actor, { - "@context": "https://www.w3.org/ns/activitystreams", - id: `https://${HOSTNAME}/${crypto.randomUUID()}`, // TODO: de-randomise this? - type: "Accept", - actor: ACTOR, - object: body, - }); - await db.createFollower(body.actor, body.id); -} - -const undo = async (body:any) => { - switch (body.object.type) { - case "Follow": await db.deleteFollower(body.actor); break - } -} - -const accept = async (body:any) => { - switch (body.object.type) { - case "Follow": await db.acceptFollowing(body.actor); break - } -} - -const reject = async (body:any) => { - switch (body.object.type) { - case "Follow": await db.deleteFollowing(body.actor); break - } + // return new Response("", { status: 204 }) } const getOutbox = async (req:Request, account:string):Promise => { diff --git a/src/env.ts b/src/env.ts index 89a83e0..97e9200 100644 --- a/src/env.ts +++ b/src/env.ts @@ -13,9 +13,9 @@ export const HOSTNAME = /*(Bun.env.PROJECT_DOMAIN && `${Bun.env.PROJECT_DOMAIN}. export const NODE_ENV = Bun.env.NODE_ENV || "development" export const PORT = Bun.env.PORT || "3000" -export const BASE_URL = (HOSTNAME === "localhost" ? "http://" : "https://") + HOSTNAME + "/" +export const BASE_URL = (HOSTNAME === "localhost" ? "http://" : "https://") + HOSTNAME -export const ACTOR = BASE_URL + ACCOUNT +export const ACTOR = BASE_URL //+ '/' + ACCOUNT // in development, generate a key pair to make it easier to get started const keypair = diff --git a/src/inbox.ts b/src/inbox.ts index dad33f7..57f8e6c 100644 --- a/src/inbox.ts +++ b/src/inbox.ts @@ -25,6 +25,8 @@ export default async function inbox(activity:any) { case "Reject": reject(activity); break; case "Undo": undo(activity); break; } + + return new Response("", { status: 204 }) } const follow = async (activity:any, id:string) => { diff --git a/src/index.ts b/src/index.ts index 64d3969..d2fb3f9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,26 +5,13 @@ import { fetchObject } from "./request"; const server = Bun.serve({ port: 3000, - fetch(req): Response | Promise { + fetch(req: Request): Response | Promise { const url = new URL(req.url) console.log(`${new Date().toISOString()} ${req.method} ${req.url}`) if(req.method === "GET" && url.pathname === "/.well-known/webfinger") { - const resource = url.searchParams.get("resource") - - if(resource !== `acct:${ACCOUNT}@${HOSTNAME}`) return new Response("", { status: 404 }) - - return Response.json({ - subject: `acct:${ACCOUNT}@${HOSTNAME}`, - links: [ - { - rel: "self", - type: "application/activity+json", - href: ACTOR, - }, - ], - }, { headers: { "content-type": "application/activity+json" }}) + return webfinger(req, url.searchParams.get("resource")) } else if(req.method === "GET" && url.pathname === "/fetch") { const object_url = url.searchParams.get('url') @@ -36,5 +23,27 @@ const server = Bun.serve({ return admin(req) || activitypub(req) || new Response("How did we get here?", { status: 404 }) }, }); + +const webfinger = async (req: Request, resource: string | null) => { + + if(resource !== `acct:${ACCOUNT}@${HOSTNAME}`) return new Response("", { status: 404 }) + + return Response.json({ + subject: `acct:${ACCOUNT}@${HOSTNAME}`, + aliases: [ACTOR], + links: [ + { + "rel": "http://webfinger.net/rel/profile-page", + "type": "text/html", + "href": ACTOR + }, + { + rel: "self", + type: "application/activity+json", + href: ACTOR, + }, + ], + }, { headers: { "content-type": "application/activity+json" }}) +} console.log(`Listening on http://localhost:${server.port} ...`); \ No newline at end of file diff --git a/src/request.ts b/src/request.ts index c648c5a..55e32b1 100644 --- a/src/request.ts +++ b/src/request.ts @@ -1,60 +1,69 @@ import forge from "node-forge" // import crypto from "node:crypto" import { ACTOR, PRIVATE_KEY } from "./env"; +export const activityMimeTypes:string[] = ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'] + export function reqIsActivityPub(req:Request) { const contentType = req.headers.get("Accept") - return contentType?.includes('application/activity+json') + const activityPub = contentType?.includes('application/activity+json') || contentType?.includes('application/ld+json; profile="https://www.w3.org/ns/activitystreams"') || contentType?.includes('application/ld+json') + return activityPub } -/** Fetches and returns an actor at a URL. */ -async function fetchActor(url:string) { - return (await fetchObject(ACTOR, url)).json() -} +// this function adds / modifies the appropriate headers for signing a request, then calls fetch +export function signedFetch(url: string | URL | Request, init?: FetchRequestInit): Promise +{ + const urlObj = typeof url === 'string' ? new URL(url) + : url instanceof Request ? new URL(url.url) + : url -/** Fetches and returns an object at a URL. */ -// export async function fetchObject(url:string) { -// const res = await fetch(url, { -// headers: { accept: "application/activity+json" }, -// }); - -// if (res.status < 200 || 299 < res.status) { -// throw new Error(`Received ${res.status} fetching object.`); -// } - -// return res.json(); -// } - -/** Fetches and returns an object at a URL. */ -export async function fetchObject(sender:string, object_url:string) { - console.log(`fetch ${object_url}`) - const url = new URL(object_url) - const path = url.pathname + if(!init) init = {} + const headers:any = init.headers || {} + const method = init.method || (url instanceof Request ? url.method : 'GET') + const path = urlObj.pathname + urlObj.search + urlObj.hash + const body = init.body ? (typeof init.body === 'string' ? init.body : JSON.stringify(init?.body)) : null + const digest = body ? new Bun.CryptoHasher("sha256").update(body).digest("base64") : null + const d = new Date(); const key = forge.pki.privateKeyFromPem(PRIVATE_KEY) - const data = [ - `(request-target): get ${path}`, - `host: ${url.hostname}`, - `date: ${d.toUTCString()}` - ].join("\n") - + const dataObj:any = { } + dataObj['(request-target)'] = `${method.toLowerCase()} ${path}` + dataObj.host = urlObj.hostname + dataObj.date = d.toUTCString() + if(digest) dataObj.digest = `SHA-256=${digest}` + const data = Object.entries(dataObj).map(([key, value]) => `${key}: ${value}`).join('\n') const signature = forge.util.encode64(key.sign(forge.md.sha256.create().update(data))) + const signatureObj = { + keyId: `${ACTOR}#main-key`, + headers: Object.keys(dataObj).join(' '), + signature: signature + } - const res = await fetch(object_url, { - method: "GET", - headers: { - host: url.hostname, - date: d.toUTCString(), - "content-type": "application/json", - signature: `keyId="${sender}#main-key",headers="(request-target) host date",signature="${signature}"`, - accept: "application/json", - } - }); + headers.host = urlObj.hostname + headers.date = d.toUTCString() + if(digest) headers.digest = `SHA-256=${digest}` + headers.signature = Object.entries(signatureObj).map(([key,value]) => `${key}="${value}"`).join(',') + headers.accept = 'application/ld+json; profile="http://www.w3.org/ns/activitystreams"' + if(body) headers["Content-Type"] = 'application/ld+json; profile="http://www.w3.org/ns/activitystreams"' + + return fetch(url, {...init, headers }) +} + +/** Fetches and returns an actor at a URL. */ +async function fetchActor(url:string) { + return await (await fetchObject(ACTOR, url)).json() +} + +/** Fetches and returns an object at a URL. */ +export async function fetchObject(sender:string, object_url:string) { + console.log(`fetch ${object_url}`) + + const res = await fetch(object_url); if (res.status < 200 || 299 < res.status) { throw new Error(res.statusText + ": " + (await res.text())); @@ -70,37 +79,13 @@ export async function fetchObject(sender:string, object_url:string) { */ export async function send(sender:string, recipient:string, message:any) { console.log(`Sending to ${recipient}`, message) - const url = new URL(recipient) // TODO: revisit fetch actor to use webfinger to get the inbox maybe? const actor = await fetchActor(recipient) - const path = actor.inbox.replace("https://" + url.hostname, "") const body = JSON.stringify(message) - const digest = new Bun.CryptoHasher("sha256").update(body).digest("base64") - const d = new Date(); - const key = forge.pki.privateKeyFromPem(PRIVATE_KEY) - - const data = [ - `(request-target): post ${path}`, - `host: ${url.hostname}`, - `date: ${d.toUTCString()}`, - `digest: SHA-256=${digest}`, - ].join("\n") - - - const signature = forge.util.encode64(key.sign(forge.md.sha256.create().update(data))) - - const res = await fetch(actor.inbox, { + const res = await signedFetch(actor.inbox, { method: "POST", - headers: { - host: url.hostname, - date: d.toUTCString(), - digest: `SHA-256=${digest}`, - "content-type": "application/json", - signature: `keyId="${sender}#main-key",headers="(request-target) host date digest",signature="${signature}"`, - accept: "application/json", - }, body, });