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
This commit is contained in:
Gordon Pedersen 2023-09-27 09:49:32 +10:00
parent 71f38cca62
commit 2701786de8
7 changed files with 107 additions and 130 deletions

BIN
bun.lockb

Binary file not shown.

View file

@ -7,7 +7,8 @@
"ngrok": "ngrok tunnel --label edge=edghts_2VNJvaPttrFlAPWxrGyVKu0s3ad http://localhost:3000" "ngrok": "ngrok tunnel --label edge=edghts_2VNJvaPttrFlAPWxrGyVKu0s3ad http://localhost:3000"
}, },
"devDependencies": { "devDependencies": {
"@types/node-forge": "^1.3.5" "@types/node-forge": "^1.3.5",
"bun-types": "^1.0.3"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5.0.0" "typescript": "^5.0.0"

View file

@ -2,21 +2,32 @@ import { ACCOUNT, ACTOR, HOSTNAME, PUBLIC_KEY } from "./env"
import * as db from "./db" import * as db from "./db"
import { reqIsActivityPub, send, verify } from "./request" import { reqIsActivityPub, send, verify } from "./request"
import outbox from "./outbox" import outbox from "./outbox"
import inbox from "./inbox"
export default (req: Request): Response | Promise<Response> | undefined => { export default (req: Request): Response | Promise<Response> | undefined => {
const url = new URL(req.url) const url = new URL(req.url)
let match let match
if(req.method === "GET" && url.pathname === "/test") return new Response("", { status: 204 }) 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(/^\/([^\/]+)\/?$/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 == "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 == "POST" && (match = url.pathname.match(/^\/([^\/]+)\/inbox\/?$/i))) return postInbox(req, match[1])
else if(req.method == "GET" && (match = url.pathname.match(/^\/([^\/]+)\/following\/?$/i))) return getFollowing(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(/^\/([^\/]+)\/?$/i))) return getActor(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(/^\/([^\/]+)\/posts\/([^\/]+)\/?$/i))) return getPost(req, match[1], match[2]) // 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\/([^\/]+)\/activity\/?$/i))) return getActivity(req, match[1], match[2]) // 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 return undefined
} }
@ -41,8 +52,6 @@ const postOutbox = async (req:Request, account:string):Promise<Response> => {
// ensure that the verified actor matches the actor in the request body // ensure that the verified actor matches the actor in the request body
if (ACTOR !== body.actor) return new Response("", { status: 401 }) if (ACTOR !== body.actor) return new Response("", { status: 401 })
// console.log(body)
return await outbox(body) return await outbox(body)
} }
@ -66,46 +75,17 @@ const postInbox = async (req:Request, account:string):Promise<Response> => {
// ensure that the verified actor matches the actor in the request body // ensure that the verified actor matches the actor in the request body
if (from !== body.actor) return new Response("", { status: 401 }) 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! // TODO: add support for more types! we want replies, likes, boosts, etc!
switch (body.type) { // switch (body.type) {
case "Follow": await follow(body); // case "Follow": await follow(body);
case "Undo": await undo(body); // case "Undo": await undo(body);
case "Accept": await accept(body); // case "Accept": await accept(body);
case "Reject": await reject(body); // case "Reject": await reject(body);
} // }
return new Response("", { status: 204 }) // 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
}
} }
const getOutbox = async (req:Request, account:string):Promise<Response> => { const getOutbox = async (req:Request, account:string):Promise<Response> => {

View file

@ -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 NODE_ENV = Bun.env.NODE_ENV || "development"
export const PORT = Bun.env.PORT || "3000" 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 // in development, generate a key pair to make it easier to get started
const keypair = const keypair =

View file

@ -25,6 +25,8 @@ export default async function inbox(activity:any) {
case "Reject": reject(activity); break; case "Reject": reject(activity); break;
case "Undo": undo(activity); break; case "Undo": undo(activity); break;
} }
return new Response("", { status: 204 })
} }
const follow = async (activity:any, id:string) => { const follow = async (activity:any, id:string) => {

View file

@ -5,26 +5,13 @@ import { fetchObject } from "./request";
const server = Bun.serve({ const server = Bun.serve({
port: 3000, port: 3000,
fetch(req): Response | Promise<Response> { fetch(req: Request): Response | Promise<Response> {
const url = new URL(req.url) const url = new URL(req.url)
console.log(`${new Date().toISOString()} ${req.method} ${req.url}`) console.log(`${new Date().toISOString()} ${req.method} ${req.url}`)
if(req.method === "GET" && url.pathname === "/.well-known/webfinger") { if(req.method === "GET" && url.pathname === "/.well-known/webfinger") {
const resource = url.searchParams.get("resource") return webfinger(req, 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" }})
} }
else if(req.method === "GET" && url.pathname === "/fetch") { else if(req.method === "GET" && url.pathname === "/fetch") {
const object_url = url.searchParams.get('url') 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 }) 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} ...`); console.log(`Listening on http://localhost:${server.port} ...`);

View file

@ -1,60 +1,69 @@
import forge from "node-forge" // import crypto from "node:crypto" import forge from "node-forge" // import crypto from "node:crypto"
import { ACTOR, PRIVATE_KEY } from "./env"; 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) { export function reqIsActivityPub(req:Request) {
const contentType = req.headers.get("Accept") 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; profile="https://www.w3.org/ns/activitystreams"')
|| contentType?.includes('application/ld+json') || contentType?.includes('application/ld+json')
return activityPub
} }
/** Fetches and returns an actor at a URL. */ // this function adds / modifies the appropriate headers for signing a request, then calls fetch
async function fetchActor(url:string) { export function signedFetch(url: string | URL | Request, init?: FetchRequestInit): Promise<Response>
return (await fetchObject(ACTOR, url)).json() {
} const urlObj = typeof url === 'string' ? new URL(url)
: url instanceof Request ? new URL(url.url)
: url
/** Fetches and returns an object at a URL. */ if(!init) init = {}
// export async function fetchObject(url:string) { const headers:any = init.headers || {}
// const res = await fetch(url, { const method = init.method || (url instanceof Request ? url.method : 'GET')
// 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
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 d = new Date();
const key = forge.pki.privateKeyFromPem(PRIVATE_KEY) const key = forge.pki.privateKeyFromPem(PRIVATE_KEY)
const data = [ const dataObj:any = { }
`(request-target): get ${path}`, dataObj['(request-target)'] = `${method.toLowerCase()} ${path}`
`host: ${url.hostname}`, dataObj.host = urlObj.hostname
`date: ${d.toUTCString()}` dataObj.date = d.toUTCString()
].join("\n") 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 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, { headers.host = urlObj.hostname
method: "GET", headers.date = d.toUTCString()
headers: { if(digest) headers.digest = `SHA-256=${digest}`
host: url.hostname, headers.signature = Object.entries(signatureObj).map(([key,value]) => `${key}="${value}"`).join(',')
date: d.toUTCString(), headers.accept = 'application/ld+json; profile="http://www.w3.org/ns/activitystreams"'
"content-type": "application/json", if(body) headers["Content-Type"] = 'application/ld+json; profile="http://www.w3.org/ns/activitystreams"'
signature: `keyId="${sender}#main-key",headers="(request-target) host date",signature="${signature}"`,
accept: "application/json", 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) { if (res.status < 200 || 299 < res.status) {
throw new Error(res.statusText + ": " + (await res.text())); 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) { export async function send(sender:string, recipient:string, message:any) {
console.log(`Sending to ${recipient}`, message) console.log(`Sending to ${recipient}`, message)
const url = new URL(recipient)
// TODO: revisit fetch actor to use webfinger to get the inbox maybe? // TODO: revisit fetch actor to use webfinger to get the inbox maybe?
const actor = await fetchActor(recipient) const actor = await fetchActor(recipient)
const path = actor.inbox.replace("https://" + url.hostname, "")
const body = JSON.stringify(message) 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 res = await signedFetch(actor.inbox, {
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, {
method: "POST", 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, body,
}); });