diff --git a/bun.lockb b/bun.lockb index d2a92de..97de569 100755 Binary files a/bun.lockb and b/bun.lockb differ 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, });