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:
parent
71f38cca62
commit
2701786de8
7 changed files with 107 additions and 130 deletions
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
|
@ -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"
|
||||
|
|
|
@ -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<Response> | 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<Response> => {
|
|||
// 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<Response> => {
|
|||
// 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<Response> => {
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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) => {
|
||||
|
|
39
src/index.ts
39
src/index.ts
|
@ -5,26 +5,13 @@ import { fetchObject } from "./request";
|
|||
|
||||
const server = Bun.serve({
|
||||
port: 3000,
|
||||
fetch(req): Response | Promise<Response> {
|
||||
fetch(req: Request): Response | Promise<Response> {
|
||||
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} ...`);
|
113
src/request.ts
113
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<Response>
|
||||
{
|
||||
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,
|
||||
});
|
||||
|
||||
|
|
Loading…
Reference in a new issue