commit 6f3e2f65ade4c47229dc4eaa185f791e9e7d067a Author: Gordon Pedersen Date: Sat Sep 16 10:28:06 2023 +1000 Initial commit Code based on (but not identical to) https://github.com/jakelazaroff/activitypub-starter-kit Instead of using node/express, it's using Bun/bun's inbuilt server Right now it can follow/unfollow (be followed/unfollowed) and create notes which get sent to followers No UI yet Stores posts as markdown with YAML frontmatter Stores activities for creating those posts as json Stores following/followers in json files diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f81d56e --- /dev/null +++ b/.gitignore @@ -0,0 +1,169 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +\*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +\*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +\*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +\*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.cache +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +.cache/ + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp +.cache + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.\* diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..d9e6a0a --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,53 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "bun", + "request": "launch", + "name": "Debug Bun", + + // The path to a JavaScript or TypeScript file to run. + "program": "src/index.ts", + + // The arguments to pass to the program, if any. + "args": [], + + // The working directory of the program. + "cwd": "${workspaceFolder}", + + // The environment variables to pass to the program. + "env": {}, + + // If the environment variables should not be inherited from the parent process. + "strictEnv": false, + + // If the program should be run in watch mode. + // This is equivalent to passing `--watch` to the `bun` executable. + // You can also set this to "hot" to enable hot reloading using `--hot`. + "watchMode": false, + + // If the debugger should stop on the first line of the program. + "stopOnEntry": false, + + // If the debugger should be disabled. (for example, breakpoints will not be hit) + "noDebug": false, + + // The path to the `bun` executable, defaults to your `PATH` environment variable. + "runtime": "bun", + + // The arguments to pass to the `bun` executable, if any. + // Unlike `args`, these are passed to the executable itself, not the program. + "runtimeArgs": ["--watch"], + }, + { + "type": "bun", + "request": "attach", + "name": "Attach to Bun", + + // The URL of the WebSocket inspector to attach to. + // This value can be retreived by using `bun --inspect`. + "url": "ws://localhost:6499/", + } + ] + } + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..bd2575f --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# bun-activitypub + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.0.0. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/_content/_data/followers.json b/_content/_data/followers.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/_content/_data/followers.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/_content/_data/following.json b/_content/_data/following.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/_content/_data/following.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/_content/posts/.gitkeep b/_content/posts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..d2a92de Binary files /dev/null and b/bun.lockb differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..b28de7a --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "bun-activitypub", + "module": "index.ts", + "type": "module", + "scripts": { + "start": "bun run --watch src/index.ts" + }, + "devDependencies": { + "@types/node-forge": "^1.3.5", + "@types/yaml": "^1.9.7", + "bun-types": "latest" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "gray-matter": "^4.0.3", + "node-forge": "^1.3.1", + "yaml": "^2.3.2" + } +} \ No newline at end of file diff --git a/src/activitypub.ts b/src/activitypub.ts new file mode 100644 index 0000000..79af233 --- /dev/null +++ b/src/activitypub.ts @@ -0,0 +1,187 @@ +import { ACCOUNT, ACTOR, HOSTNAME, PUBLIC_KEY } from "./env" +import * as db from "./db" +import { reqIsActivityPub, send, verify } from "./request" + +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 == "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]) + + return undefined +} + +const postInbox = async (req:Request, account:string):Promise => { + console.log("PostInbox", account) + if (ACCOUNT !== account) return new Response("", { status: 404 }) + + const bodyText = await req.text() + + /** If the request successfully verifies against the public key, `from` is the actor who sent it. */ + let from = ""; + try { + // verify the signed HTTP request + from = await verify(req, bodyText); + } catch (err) { + console.error(err); + return new Response("", { status: 401 }) + } + + const body = JSON.parse(bodyText) + + // ensure that the verified actor matches the actor in the request body + if (from !== body.actor) return new Response("", { status: 401 }) + + console.log(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); + } + + 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 getOutbox = async (req:Request, account:string):Promise => { + console.log("GetOutbox", account) + if (ACCOUNT !== account) return new Response("", { status: 404 }) + + const posts = await db.listActivities() + + return Response.json({ + "@context": "https://www.w3.org/ns/activitystreams", + id: `${ACTOR}/outbox`, + type: "OrderedCollection", + totalItems: posts.length, + orderedItems: posts.map((post) => ({ + ...post, + actor: ACTOR + })).sort( (a,b) => new Date(b.published).getTime() - new Date(a.published).getTime()) + }, { headers: { "Content-Type": "application/activity+json"} }) +} + +const getFollowers = async (req:Request, account:String):Promise => { + console.log("GetFollowers", account) + if (ACCOUNT !== account) return new Response("", { status: 404 }) + + const url = new URL(req.url) + const page = url.searchParams.get("page") + const followers = await db.listFollowers() + + if(!page) return Response.json({ + "@context": "https://www.w3.org/ns/activitystreams", + id: `${ACTOR}/followers`, + type: "OrderedCollection", + totalItems: followers.length, + first: `${ACTOR}/followers?page=1`, + }) + else return Response.json({ + "@context": "https://www.w3.org/ns/activitystreams", + id: `${ACTOR}/followers?page=${page}`, + type: "OrderedCollectionPage", + partOf: `${ACTOR}/followers`, + totalItems: followers.length, + orderedItems: followers.map(follower => follower.actor) + }) +} + +const getFollowing = async (req:Request, account:String):Promise => { + console.log("GetFollowing", account) + if (ACCOUNT !== account) return new Response("", { status: 404 }) + + const url = new URL(req.url) + const page = url.searchParams.get("page") + const following = await db.listFollowing() + + if(!page) return Response.json({ + "@context": "https://www.w3.org/ns/activitystreams", + id: `${ACTOR}/following`, + type: "OrderedCollection", + totalItems: following.length, + first: `${ACTOR}/following?page=1`, + }) + else return Response.json({ + "@context": "https://www.w3.org/ns/activitystreams", + id: `${ACTOR}/following?page=${page}`, + type: "OrderedCollectionPage", + partOf: `${ACTOR}/following`, + totalItems: following.length, + orderedItems: following.map(follow => follow.actor) + }) +} + +const getActor = async (req:Request, account:string):Promise => { + console.log("GetActor", account) + if (ACCOUNT !== account) return new Response("", { status: 404 }) + + if(reqIsActivityPub(req)) return Response.json({ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + ], + id: ACTOR, + type: "Person", + preferredUsername: ACCOUNT, + url: ACTOR, + manuallyApprovesFollowers: false, + discoverable: true, + published: "2023-09-14T00:00:00Z", + inbox: `${ACTOR}/inbox`, + outbox: `${ACTOR}/outbox`, + followers: `${ACTOR}/followers`, + following: `${ACTOR}/following`, + publicKey: { + id: `${ACTOR}#main-key`, + owner: ACTOR, + publicKeyPem: PUBLIC_KEY, + }, + }, { headers: { "Content-Type": "application/activity+json"}}) + else return Response.json(await db.listPosts()) +} + +const getPost = async (req:Request, account:string, id:string):Promise => { + console.log("GetPost", account, id) + if (ACCOUNT !== account) return new Response("", { status: 404 }) + + if(reqIsActivityPub(req)) return Response.json((await db.getActivity(id)).object, { headers: { "Content-Type": "application/activity+json"}}) + else return Response.json(await db.getPost(id)) +} + +const getActivity = async (req:Request, account:string, id:string):Promise => { + console.log("GetActivity", account, id) + if (ACCOUNT !== account) return new Response("", { status: 404 }) + + return Response.json((await db.getActivity(id)), { headers: { "Content-Type": "application/activity+json"}}) +} \ No newline at end of file diff --git a/src/admin.ts b/src/admin.ts new file mode 100644 index 0000000..10bd4dd --- /dev/null +++ b/src/admin.ts @@ -0,0 +1,115 @@ +import { createFollowing, deleteFollowing, doActivity, getFollowing, listFollowers } from "./db" +import { ACTOR, ADMIN_PASSWORD, ADMIN_USERNAME, BASE_URL } from "./env" +import { send } from "./request" + +export default (req: Request): Response | Promise | undefined => { + const url = new URL(req.url) + + if(!url.pathname.startsWith('/admin')) return undefined + url.pathname = url.pathname.substring(6) + + if(ADMIN_USERNAME && ADMIN_PASSWORD && !checkAuth(req.headers)) return new Response("", { status: 401 }) + + let match + if(req.method === "GET" && (match = url.pathname.match(/^\/test\/?$/i))) return new Response("", { status: 204 }) + else if(req.method == "POST" && (match = url.pathname.match(/^\/create\/?$/i))) return create(req) + else if(req.method == "POST" && (match = url.pathname.match(/^\/follow\/([^\/]+)\/?$/i))) return follow(req, match[1]) + else if(req.method == "DELETE" && (match = url.pathname.match(/^\/follow\/([^\/]+)\/?$/i))) return unfollow(req, match[1]) + + return undefined +} + +const checkAuth = (headers: Headers): Boolean => { + // check the basic auth header + const auth = headers.get("Authorization") + const split = auth?.split("") + if(!split || split.length != 2 || split[0] !== "Basic") return false + const decoded = atob(split[1]) + const [username, password] = decoded.split(":") + return username === ADMIN_USERNAME && password === ADMIN_PASSWORD +} + +// create an activity +const create = async (req:Request):Promise => { + const body = await req.json() + + // create the object, merging in supplied data + const date = new Date() + const id = date.getTime().toString(16) + const object = { + attributedTo: ACTOR, + published: date.toISOString(), + to: ["https://www.w3.org/ns/activitystreams#Public"], + cc: [`${ACTOR}/followers`], + url: `${ACTOR}/post/${id}`, + id: `${ACTOR}/post/${id}`, + ...body.object + } + + const activity = { + "@context": "https://www.w3.org/ns/activitystreams", + id: `${ACTOR}/post/${id}/activity`, + type: "Create", + published: date.toISOString(), + actor: ACTOR, + to: ["https://www.w3.org/ns/activitystreams#Public"], + cc: [`${ACTOR}/followers`], + ...body, + object: { ...object } + } + + // TODO: actually create the object (and the activity??) + await doActivity(activity, id) + + // loop through the list of followers + for (const follower of await listFollowers()) { + // send the activity to each follower + send(ACTOR, follower.actor, { + ...activity, + cc: [follower.actor], + }); + } + + // return HTTP 204: no content (success) + return new Response("", { status: 204 }) +} + +const follow = async (req:Request, handle:string):Promise => { + const id = BASE_URL + '@' + handle + // send the follow request to the supplied actor + await send(ACTOR, handle, { + "@context": "https://www.w3.org/ns/activitystreams", + id, + type: "Follow", + actor: ACTOR, + object: handle, + }); + await createFollowing(handle, id) + + return new Response("", { status: 204 }) +} + +const unfollow = async (req:Request, handle:string):Promise => { + // check to see if we are already following. If not, just return success + const existing = await getFollowing(handle) + if (!existing) return new Response("", { status: 204 }) + + // TODO: send the unfollow request (technically an undo follow activity) + await send(ACTOR, handle, { + "@context": "https://www.w3.org/ns/activitystreams", + id: existing.id + "/undo", + type: "Undo", + actor: ACTOR, + object: { + id: existing.id, + type: "Follow", + actor: ACTOR, + object: handle, + }, + }); + + // delete the following reference from the database + deleteFollowing(handle); + + return new Response("", { status: 204 }) +} \ No newline at end of file diff --git a/src/db.ts b/src/db.ts new file mode 100644 index 0000000..936a92b --- /dev/null +++ b/src/db.ts @@ -0,0 +1,94 @@ +import { ACTIVITY_PATH, ACTOR, BASE_URL, DATA_PATH, POSTS_PATH } from "./env"; +import path from "path" +import { readdir } from "fs/promises" +const matter = require('gray-matter') + +export async function doActivity(activity:any, object_id:string|null|undefined) { + if(activity.type === "Create" && activity.object) { + if(!object_id) object_id = new Date(activity.object.published).getTime().toString(16) + const file = Bun.file(path.join(POSTS_PATH, `${object_id}.md`)) + const { content, published, id, attributedTo } = activity.object + //TODO: add appropriate content for different types (e.g. like-of, etc) + await Bun.write(file, matter.stringify(content || "", { id, published, attributedTo })) + const activityFile = Bun.file(path.join(ACTIVITY_PATH, `${object_id}.activity.json`)) + await Bun.write(activityFile, JSON.stringify(activity)) + } +} + +export async function getPost(id:string) { + const file = Bun.file(path.join(POSTS_PATH, `${id}.md`)) + const { data, content } = matter(await file.text()) + return { + ...data, + content: content.trim() + } +} + +export async function listPosts() { + return await Promise.all((await readdir(POSTS_PATH)).filter(v => v.endsWith('.md')).map(async filename => await getPost(filename.slice(0, -3)))) +} + +export async function getActivity(id:string) { + const file = Bun.file(path.join(ACTIVITY_PATH, `${id}.activity.json`)) + return await file.json() +} + +export async function listActivities() { + return await Promise.all((await readdir(ACTIVITY_PATH)).filter(v => v.endsWith('.activity.json')).map(async filename => await Bun.file(path.join(ACTIVITY_PATH, filename)).json())) +} + +export async function createFollowing(handle:string, id:string) { + const file = Bun.file(path.join(DATA_PATH, `following.json`)) + const following_list = await file.json() as Array + if(!following_list.find(v => v.id === id || v.handle === handle)) following_list.push({id, handle, createdAt: new Date().toISOString()}) + await Bun.write(file, JSON.stringify(following_list)) +} + +export async function deleteFollowing(handle:string) { + const file = Bun.file(path.join(DATA_PATH, `following.json`)) + const following_list = await file.json() as Array + await Bun.write(file, JSON.stringify(following_list.filter(v => v.handle !== handle))) +} + +export async function getFollowing(handle:string) { + const file = Bun.file(path.join(DATA_PATH, `following.json`)) + const following_list = await file.json() as Array + return following_list.find(v => v.handle === handle) +} + +export async function listFollowing() { + const file = Bun.file(path.join(DATA_PATH, `following.json`)) + return await file.json() as Array +} + +export async function acceptFollowing(handle:string) { + const file = Bun.file(path.join(DATA_PATH, `following.json`)) + const following_list = await file.json() as Array + const following = following_list.find(v => v.handle === handle) + if(following) following.accepted = new Date().toISOString() + await Bun.write(file, JSON.stringify(following_list)) +} + +export async function createFollower(actor:string, id:string) { + const file = Bun.file(path.join(DATA_PATH, `followers.json`)) + const followers_list = await file.json() as Array + if(!followers_list.find(v => v.id === id || v.actor === actor)) followers_list.push({id, actor, createdAt: new Date().toISOString()}) + await Bun.write(file, JSON.stringify(followers_list)) +} + +export async function deleteFollower(actor:string) { + const file = Bun.file(path.join(DATA_PATH, `followers.json`)) + const followers_list = await file.json() as Array + await Bun.write(file, JSON.stringify(followers_list.filter(v => v.actor !== actor))) +} + +export async function getFollower(actor:string) { + const file = Bun.file(path.join(DATA_PATH, `followers.json`)) + const followers_list = await file.json() as Array + return followers_list.find(v => v.actor === actor) +} + +export async function listFollowers() { + const file = Bun.file(path.join(DATA_PATH, `followers.json`)) + return await file.json() as Array +} \ No newline at end of file diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..625de89 --- /dev/null +++ b/src/env.ts @@ -0,0 +1,38 @@ +import forge from "node-forge" // import crypto from "node:crypto" +import path from "path" + +// change "activitypub" to whatever you want your account name to be +export const ACCOUNT = Bun.env.ACCOUNT || "activitypub" + +// set up username and password for admin actions +export const ADMIN_USERNAME = process.env.ADMIN_USERNAME || ""; +export const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || ""; + +// get the hostname (`PROJECT_DOMAIN` is set via glitch, but since we're using Bun now, this won't matter) +export const HOSTNAME = /*(Bun.env.PROJECT_DOMAIN && `${Bun.env.PROJECT_DOMAIN}.glitch.me`) ||*/ Bun.env.HOSTNAME || "localhost" +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 ACTOR = BASE_URL + ACCOUNT + +// in development, generate a key pair to make it easier to get started +const keypair = + NODE_ENV === "development" + ? forge.pki.rsa.generateKeyPair({bits: 4096}) //crypto.generateKeyPairSync("rsa", { modulusLength: 4096 }) + : undefined + +export const PUBLIC_KEY = + process.env.PUBLIC_KEY || + (keypair && forge.pki.publicKeyToPem(keypair.publicKey)) || //keypair?.publicKey.export({ type: "spki", format: "pem" }) || + "" +export const PRIVATE_KEY = + process.env.PRIVATE_KEY || + (keypair && forge.pki.privateKeyToPem(keypair.privateKey)) || //keypair?.privateKey.export({ type: "pkcs8", format: "pem" }) || + "" + +export const CONTENT_PATH = path.join('.', '_content') +export const POSTS_PATH = path.join(CONTENT_PATH, "posts") +export const ACTIVITY_PATH = path.join(CONTENT_PATH, "posts") +export const DATA_PATH = path.join(CONTENT_PATH, "_data") \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..78b1799 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,33 @@ +import { ACCOUNT, ACTOR, HOSTNAME, PORT } from "./env"; +import admin from './admin' +import activitypub from "./activitypub"; + +const server = Bun.serve({ + port: 3000, + fetch(req): 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 admin(req) || activitypub(req) || new Response("How did we get here?", { status: 404 }) + }, +}); + +console.log(`Listening on http://localhost:${server.port} ...`); \ No newline at end of file diff --git a/src/request.ts b/src/request.ts new file mode 100644 index 0000000..933ff1a --- /dev/null +++ b/src/request.ts @@ -0,0 +1,132 @@ +import forge from "node-forge" // import crypto from "node:crypto" +import { PRIVATE_KEY } from "./env"; + +export function reqIsActivityPub(req:Request) { + const contentType = req.headers.get("Accept") + return contentType?.includes('application/activity+json') + || contentType?.includes('application/ld+json; profile="https://www.w3.org/ns/activitystreams"') + || contentType?.includes('application/ld+json') +} + +/** Fetches and returns an actor at a URL. */ +async function fetchActor(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 actor.`); + } + + return res.json(); +} + +/** Sends a signed message from the sender to the recipient. + * @param sender The sender's actor URL. + * @param recipient The recipient's actor URL. + * @param message the body of the request to send. + */ +export async function send(sender:string, recipient:string, message:any) { + const url = new URL(recipient) + 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, { + 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, + }); + + if (res.status < 200 || 299 < res.status) { + throw new Error(res.statusText + ": " + (await res.text())); + } + + return res; +} + +/** Verifies that a request came from an actor. + * Returns the actor's ID if the verification succeeds; throws otherwise. + * @param req An Express request. + * @returns The actor's ID. */ +export async function verify(req:Request, body:string) { + // get headers included in signature + const included:any = {} + for (const header of req.headers.get("signature")?.split(",") ?? []) { + const [key, value] = header.split("=") + if (!key || !value) continue + included[key] = value.replace(/^"|"$/g, "") + } + + /** the URL of the actor document containing the signature's public key */ + const keyId = included.keyId + if (!keyId) throw new Error(`Missing "keyId" in signature header.`) + + /** the signed request headers */ + const signedHeaders = included.headers + if (!signedHeaders) throw new Error(`Missing "headers" in signature header.`) + + /** the signature itself */ + const signature = Buffer.from(included.signature ?? "", "base64") + if (!signature) throw new Error(`Missing "signature" in signature header.`) + + // ensure that the digest header matches the digest of the body + const digestHeader = req.headers.get("digest") + if (digestHeader) { + const digestBody = new Bun.CryptoHasher("sha256").update(body).digest("base64") + if (digestHeader !== "SHA-256=" + digestBody) { + throw new Error(`Incorrect digest header.`) + } + } + + // get the actor's public key + const actor = await fetchActor(keyId); + if (!actor.publicKey) throw new Error("No public key found.") + const key = forge.pki.publicKeyFromPem(actor.publicKey.publicKeyPem) + + // reconstruct the signed header string + const url = new URL(req.url) + const comparison:string = signedHeaders + .split(" ") + .map((header:any) => { + if (header === "(request-target)") + return "(request-target): post " + url.pathname + return `${header}: ${req.headers.get(header)}` + }) + .join("\n") + const data = Buffer.from(comparison); + + // verify the signature against the headers using the actor's public key + const verified = key.verify(forge.sha256.create().update(comparison).digest().bytes(), signature.toString('binary')) + if (!verified) throw new Error("Invalid request signature.") + + // ensure the request was made recently + const now = new Date(); + const date = new Date(req.headers.get("date") ?? 0); + if (now.getTime() - date.getTime() > 30_000) + throw new Error("Request date too old."); + + return actor.id; +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1449bc3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "module": "esnext", + "target": "esnext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "allowImportingTsExtensions": true, + "noEmit": true, + "composite": true, + "strict": true, + "downlevelIteration": true, + "skipLibCheck": true, + "jsx": "preserve", + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "allowJs": true, + "types": [ + "bun-types" // add Bun global + ] + } +}