diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..604df32 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +README.md +db.sqlite* +_data +.vscode +node_modules \ No newline at end of file diff --git a/.gitignore b/.gitignore index 38aec2f..f84af57 100644 --- a/.gitignore +++ b/.gitignore @@ -173,4 +173,5 @@ _content/_data/_inbox _content/_data/_outbox _content/posts _site +_data db.sqlite* diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..7187b84 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,38 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "shell", + "command": "ngrok tunnel --label edge=edghts_2VNJvaPttrFlAPWxrGyVKu0s3ad http://localhost:3000", + "windows":{ + "command": "C:\\Users\\death\\AppData\\Local\\Microsoft\\WinGet\\Links\\ngrok tunnel --label edge=edghts_2VNJvaPttrFlAPWxrGyVKu0s3ad http://localhost:3000" + }, + "label": "ngrok tunnel", + "detail": "ngrok tunnel --label edge=edghts_2VNJvaPttrFlAPWxrGyVKu0s3ad http://localhost:3000", + "presentation": { + "reveal": "always", + "panel": "dedicated" + } + }, + { + "type": "shell", + "command": "bun run --watch src/index.ts", + "label": "bun run", + "detail": "bun run --watch src/index.ts", + "presentation": { + "reveal": "always", + "panel": "dedicated" + } + }, + { + "type": "shell", + "command": "docker compose up", + "label": "docker compose up", + "detail": "docker compose up", + "presentation": { + "reveal": "always", + "panel": "dedicated" + } + } + ] +} \ No newline at end of file diff --git a/CNAME b/CNAME deleted file mode 100644 index c9df399..0000000 --- a/CNAME +++ /dev/null @@ -1 +0,0 @@ -death.id.au \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..65e5f62 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM oven/bun:latest + +WORKDIR /app + +COPY ./package.json /app +COPY ./bun.lockb /app +RUN bun install + +COPY . /app + +CMD [ "bun", "run", "/app/src/index.ts"] \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index e22d873..3c5abea 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..a23b824 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,19 @@ +version: '3' + +services: + app: + build: . + container_name: bun-activitypub + command: bun run --watch /app/src/index.ts + environment: + - ACCOUNT=death.au + - REAL_NAME=Gordon Pedersen + - HOSTNAME=death.id.au + - TOKEN_ENDPOINT=https://deathau-cellar-door.glitch.me/token + - WEB_SITE_HOSTNAME=www.death.id.au + - DATA_PATH=/data + ports: + - 3000:3000 + volumes: + - .:/app + - ./_data:/data \ No newline at end of file diff --git a/package.json b/package.json index 4240160..97d48c7 100644 --- a/package.json +++ b/package.json @@ -3,12 +3,11 @@ "module": "index.ts", "type": "module", "scripts": { - "start": "bun run --watch src/index.ts", - "ngrok": "ngrok tunnel --label edge=edghts_2VNJvaPttrFlAPWxrGyVKu0s3ad http://localhost:3000" + "start": "bun run --watch src/index.ts" }, "devDependencies": { "@types/node-forge": "^1.3.5", - "bun-types": "^1.0.3" + "bun-types": "^1.0.5" }, "peerDependencies": { "typescript": "^5.0.0" diff --git a/src/activitypub.ts b/src/activitypub.ts index 21d4ea7..5e8eba0 100644 --- a/src/activitypub.ts +++ b/src/activitypub.ts @@ -1,4 +1,4 @@ -import { verify } from "./request" +import { authorized, verify } from "./request" import outbox from "./outbox" import inbox from "./inbox" import ACTOR from "../actor" @@ -31,20 +31,12 @@ export function reqIsActivityPub(req:Request) { return activityPubTypes.some(t => contentType?.includes(t)) } -export function idsFromValue(value:any):string[] { - if (!value) return [] - else if (typeof value === 'string') return [value] - else if (value.id) return [value.id] - else if (Array.isArray(value)) return value.map(v => idsFromValue(v)).flat(Infinity) as string[] - return [] -} - const postOutbox = async (req:Request):Promise => { console.log("PostOutbox") - const bodyText = await req.text() + if(!authorized(req)) return new Response('', { status: 401 }) - // TODO: verify calls to the outbox, whether that be by basic authentication, bearer, or otherwise. + const bodyText = await req.text() const body = JSON.parse(bodyText) // ensure that the verified actor matches the actor in the request body diff --git a/src/admin.ts b/src/admin.ts index 83b4797..c353668 100644 --- a/src/admin.ts +++ b/src/admin.ts @@ -1,20 +1,19 @@ -import { idsFromValue } from "./activitypub" -import { ADMIN_PASSWORD, ADMIN_USERNAME, activityPubTypes } from "./env" +import { activityPubTypes } from "./env" import outbox from "./outbox" -import { fetchObject } from "./request" +import { authorized, fetchObject, idsFromValue } from "./request" import ACTOR from "../actor" -import ActivityPubDB from "./db" +import ActivityPubDB, { Activity } from "./db" let db:ActivityPubDB -export default (req: Request, database: ActivityPubDB): Response | Promise | undefined => { +export default async (req: Request, database: ActivityPubDB): Promise | undefined> => { db = database 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 }) + if(!(await authorized(req))) return new Response("", { status: 401 }) let match if(req.method === "GET" && (match = url.pathname.match(/^\/test\/?$/i))) return new Response("", { status: 204 }) @@ -31,30 +30,14 @@ export default (req: Request, database: ActivityPubDB): Response | Promise { - // 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 -} - -// // rebuild the 11ty static pages -// export const rebuild = async(req:Request):Promise => { -// db.rebuild() -// return new Response("", { status: 201 }) -// } - // create an activity const create = async (req:Request, inReplyTo:string|null = null):Promise => { - const body = await req.json() + const body = await req.json() as any if(!inReplyTo && body.object.inReplyTo) inReplyTo = body.object.inReplyTo @@ -100,7 +83,7 @@ const idFromHandle = async(handle:string) => { if(!host) return new Response('account not url or name@domain.tld', { status: 400 }) const res = await fetch(`https://${host}/.well-known/webfinger/?resource=acct:${handle}`, { headers: { 'accept': 'application/jrd+json'}}) - const webfinger = await res.json() + const webfinger = await res.json() as any if(!webfinger.links) return new Response("", { status: 404 }) const links:any[] = webfinger.links @@ -114,7 +97,7 @@ const idFromHandle = async(handle:string) => { const follow = async (req:Request, handle:string):Promise => { const url = await idFromHandle(handle) - console.log(`Following ${url}`) + console.info(`Following ${url}`) // send the follow request to the supplied actor return await outbox({ @@ -143,7 +126,7 @@ const unfollow = async (req:Request, handle:string):Promise => { } const like = async (req:Request, object_url:string):Promise => { - const object = await (await fetchObject(object_url)).json() + const object = await (await fetchObject(object_url)).json() as any return await outbox({ "@context": "https://www.w3.org/ns/activitystreams", @@ -160,7 +143,7 @@ const unlike = async (req:Request, activity_id:string):Promise => { const liked = db.listLiked() let existing = liked?.find(o => o?.activity_id === activity_id) if (!existing){ - const object = await (await fetchObject(activity_id)).json() + const object = await (await fetchObject(activity_id)).json() as any idsFromValue(object).forEach(id => { const e = liked?.find(o => o.activity_id === id) if(e) existing = e @@ -172,7 +155,7 @@ const unlike = async (req:Request, activity_id:string):Promise => { } const dislike = async (req:Request, object_url:string):Promise => { - const object = await (await fetchObject(object_url)).json() + const object = await (await fetchObject(object_url)).json() as any return await outbox({ "@context": "https://www.w3.org/ns/activitystreams", @@ -201,7 +184,7 @@ const undislike = async (req:Request, object_id:string):Promise => { } const share = async (req:Request, object_url:string):Promise => { - const object = await (await fetchObject(object_url)).json() + const object = await (await fetchObject(object_url)).json() as any return await outbox({ "@context": "https://www.w3.org/ns/activitystreams", diff --git a/src/db.ts b/src/db.ts index ffae1fe..38a62f8 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,12 +1,15 @@ import { Database, Statement } from "bun:sqlite" +import { existsSync, mkdirSync } from "fs" +import { dirname } from "path" +import { DB_PATH } from "./env" -export interface ActivityObject { +export type ActivityObject = { activity_id: string id: string published: string } -export interface Activity extends ActivityObject { +export type Activity = ActivityObject & { type: string actor: string published: string @@ -15,11 +18,11 @@ export interface Activity extends ActivityObject { object?: any } -export interface Following extends ActivityObject { +export type Following = ActivityObject & { accepted?: boolean } -export interface Post extends ActivityObject { +export type Post = ActivityObject & { attributedTo: string type: string content?: string @@ -32,7 +35,9 @@ export default class ActivityPubDB { // Cached Statements constructor() { - this.db = new Database('./db.sqlite') + const dir = dirname(DB_PATH) + if(!existsSync(dir)) mkdirSync(dir, { recursive: true }) + this.db = new Database(DB_PATH, {create:true, readwrite:true}) this.migrate() this.cacheQueries() } @@ -43,13 +48,11 @@ export default class ActivityPubDB { migrate() { let version = (this.db.query("PRAGMA user_version;").get() as {user_version:number})?.user_version - console.log(`Hi from migrate! User version: ${version}`) const statements:Statement[] = [] switch(version) { case 0: - console.log("migrating db version 1") this.db.exec("PRAGMA journal_mode = WAL;") // Create the inbox table @@ -59,7 +62,7 @@ export default class ActivityPubDB { [type] TEXT NOT NULL, [actor] TEXT NOT NULL, [published] TEXT NOT NULL, - [to] TEXT NOT NULL, + [to] TEXT, [cc] TEXT, [activity] TEXT NOT NULL )`)) @@ -71,7 +74,7 @@ export default class ActivityPubDB { [type] TEXT NOT NULL, [actor] TEXT NOT NULL, [published] TEXT NOT NULL, - [to] TEXT NOT NULL, + [to] TEXT, [cc] TEXT, [activity] TEXT NOT NULL )`)) @@ -130,8 +133,7 @@ export default class ActivityPubDB { } const migration = this.db.transaction((statements:Statement[]) => { - statements.forEach(s => { - console.log(s.toString()); + statements.forEach(s => { s.run(); s.finalize() }) @@ -271,7 +273,7 @@ export default class ActivityPubDB { $id: activity.id, $type: activity.type, $actor: activity.actor, - $published: activity.published, + $published: activity.published || new Date().toISOString(), $to: JSON.stringify(activity.to), $cc: JSON.stringify(activity.cc), $activity: JSON.stringify(activity) @@ -325,7 +327,6 @@ export default class ActivityPubDB { } deleteFollower(id:string) { - console.log("! DELETE Follower ", id) return this.queries.deleteFollower?.run(id) } diff --git a/src/env.ts b/src/env.ts index 4e3f0be..33999d5 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,53 +1,37 @@ -import forge from "node-forge" // import crypto from "node:crypto" +import forge from "node-forge" // Bun does not implement the required node:crypto functions import path from "path" -// set up username and password for admin actions -export const ADMIN_USERNAME = process.env.ADMIN_USERNAME || ""; -export const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || ""; +// the endpoint for auth token validation +export const TOKEN_ENDPOINT = process.env.TOKEN_ENDPOINT +export const WEB_SITE_HOSTNAME = process.env.WEB_SITE_HOSTNAME -// get the hostname (`PROJECT_DOMAIN` is set via glitch, but since we're using Bun now, this won't matter) -export const HOSTNAME = /*(process.env.PROJECT_DOMAIN && `${process.env.PROJECT_DOMAIN}.glitch.me`) ||*/ process.env.HOSTNAME || "localhost" -export const NODE_ENV = process.env.NODE_ENV || "development" +export const HOSTNAME = process.env.HOSTNAME || "localhost" export const PORT = process.env.PORT || "3000" - export const BASE_URL = (HOSTNAME === "localhost" ? "http://" : "https://") + HOSTNAME +export const NODE_ENV = process.env.NODE_ENV || "production" + // 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 }) + ? forge.pki.rsa.generateKeyPair({bits: 4096}) : undefined export const PUBLIC_KEY = process.env.PUBLIC_KEY || - (keypair && forge.pki.publicKeyToPem(keypair.publicKey)) || //keypair?.publicKey.export({ type: "spki", format: "pem" }) || + (keypair && forge.pki.publicKeyToPem(keypair.publicKey)) || "" export const PRIVATE_KEY = process.env.PRIVATE_KEY || - (keypair && forge.pki.privateKeyToPem(keypair.privateKey)) || //keypair?.privateKey.export({ type: "pkcs8", format: "pem" }) || + (keypair && forge.pki.privateKeyToPem(keypair.privateKey)) || "" -export const STATIC_PATH = path.join('.', '_site') -export const CONTENT_PATH = path.join('.', '_content') -export const POSTS_PATH = path.join(CONTENT_PATH, "posts") -export const DATA_PATH = path.join(CONTENT_PATH, "_data") -export const DB_PATH = path.join(DATA_PATH, "db.sqlite") -export const ACTIVITY_INBOX_PATH = path.join(DATA_PATH, "_inbox") -export const ACTIVITY_OUTBOX_PATH = path.join(DATA_PATH, "_outbox") - -export const DEFAULT_DOCUMENTS = process.env.DEFAULT_DOCUMENTS || [ - 'index.html', - 'index.shtml', - 'index.htm', - 'Index.html', - 'Index.shtml', - 'Index.htm', - 'default.html', - 'default.htm' -] +export const DATA_PATH = process.env.DATA_PATH || "/data" +export const DB_PATH = process.env.DB_PATH || path.join(DATA_PATH, "db.sqlite") export const activityPubTypes = [ 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + 'application/ld+json; profile="http://www.w3.org/ns/activitystreams"', 'application/activity+json' ] export const contentTypeHeader = { 'Content-Type': activityPubTypes[0] } \ No newline at end of file diff --git a/src/inbox.ts b/src/inbox.ts index f8559e0..bc0e3cc 100644 --- a/src/inbox.ts +++ b/src/inbox.ts @@ -1,6 +1,5 @@ -import { idsFromValue } from "./activitypub" import outbox from "./outbox" -import { send } from "./request" +import { idsFromValue, send } from "./request" import ACTOR from "../actor" import ActivityPubDB from "./db" @@ -18,7 +17,7 @@ export default async function inbox(activity:any, database:ActivityPubDB) { // save this activity to my inbox const activity_id = `${date.getTime().toString(32)}` - console.log(`New inbox activity ${activity_id} (${activity.type})`, activity) + console.info(`New inbox activity ${activity_id} (${activity.type})`, activity) db.createInboxActivity(activity_id, activity) // TODO: process the activity and update local data diff --git a/src/index.ts b/src/index.ts index 489ebee..70ba580 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,12 +3,14 @@ import activitypub, { reqIsActivityPub } from "./activitypub" import { fetchObject } from "./request" import { handle, webfinger } from "../actor" import ActivityPubDB from './db' +import { HOSTNAME, PORT, WEB_SITE_HOSTNAME } from './env' const db = new ActivityPubDB() const server = Bun.serve({ - port: 3000, - fetch(req: Request): Response | Promise { + port: PORT, + // hostname: HOSTNAME, + async fetch(req: Request): Promise { const url = new URL(req.url) // log the incoming request info @@ -41,9 +43,30 @@ const server = Bun.serve({ return fetchObject(object_url) } - // admin and activitypub routes - return admin(req, db) || activitypub(req, db) || new Response("", { status: 404 }) + // admin and activitypub routes, plus a fallback + return await admin(req, db) || activitypub(req, db) || fallback(req) }, }) -console.log(`Listening on http://localhost:${server.port} ...`) \ No newline at end of file +const fallback = async (req: Request) => { + // if we haven't a fallback hostname defined, just return 404 + if(!WEB_SITE_HOSTNAME) return new Response('', { status: 404 }) + + // swap the hostname in the current request + const url = new URL(req.url) + url.hostname = WEB_SITE_HOSTNAME + console.info(`➡️ Forwarding ${req.method} request to ${url}`) + // override the host header, because this can cause problems if wrong + if(req.headers.get("host")) req.headers.set('host', WEB_SITE_HOSTNAME) + + // const body = ['GET', 'HEAD'].includes(req.method) ? undefined : await req.blob() + // // just use *all the things* from the request object (body is handled separately) + + // TODO: possibly not forwarding the body correctly. + // My current website is static, but probably want to test and/or fix this in the future. + // @ts-ignore typescript doesn't think Request is compatible with RequestInit + const newRequest = new Request(url, req) + return await fetch(newRequest) +} + +console.info(`📡 Listening on http://${server.hostname}:${server.port} ...`) \ No newline at end of file diff --git a/src/outbox.ts b/src/outbox.ts index 576484b..d73dd74 100644 --- a/src/outbox.ts +++ b/src/outbox.ts @@ -1,7 +1,6 @@ -import { idsFromValue } from "./activitypub" -import { fetchObject, send } from "./request" +import { fetchObject, idsFromValue, send } from "./request" import ACTOR from "../actor" -import ActivityPubDB from "./db" +import ActivityPubDB, { Activity } from "./db" let db:ActivityPubDB @@ -9,7 +8,7 @@ export default async function outbox(activity:any, database:ActivityPubDB):Promi db = database const date = new Date() const activity_id = `${date.getTime().toString(32)}` - console.log('outbox', activity_id, activity) + console.info('outbox', activity_id, activity) // https://www.w3.org/TR/activitypub/#object-without-create if(!activity.actor && !(activity.object || activity.target || activity.result || activity.origin || activity.instrument)) { @@ -46,17 +45,11 @@ export default async function outbox(activity:any, database:ActivityPubDB):Promi delete activity.bcc // now that has been taken care of, it's time to update our local data, depending on the contents of the activity - switch(activity.type) { - case "Accept": await accept(activity, activity_id); break; - case "Follow": await follow(activity, activity_id); break; - case "Like": await like(activity, activity_id); break; - case "Dislike": await dislike(activity, activity_id); break; - case "Annouce": await announce(activity, activity_id); break; - case "Create": await create(activity, activity_id); break; - case "Undo": await undo(activity); break; - case "Delete": await deletePost(activity); break; - // TODO: case "Anncounce": return await share(activity) - } + // aka "side effects" + // Note: I do this *before* adding the activity to the database so I can cache specific objects + // for example, for likes / dislikes / shares, the object probably only exists as the url, + // but I want to fetch it and store it so I can use that data later. + await outboxSideEffect(activity_id, activity) // save the activity data for the outbox db.createOutboxActivity(activity_id, activity) @@ -70,6 +63,20 @@ export default async function outbox(activity:any, database:ActivityPubDB):Promi return new Response("", { status: 201, headers: { location: activity.id } }) } +async function outboxSideEffect(activity_id:string, activity:Activity) { + switch(activity.type) { + case "Accept": await accept(activity, activity_id); break; + case "Follow": await follow(activity, activity_id); break; + case "Like": await like(activity, activity_id); break; + case "Dislike": await dislike(activity, activity_id); break; + case "Annouce": await announce(activity, activity_id); break; + case "Create": await create(activity, activity_id); break; + case "Undo": await undo(activity); break; + case "Delete": await deletePost(activity); break; + // TODO: case "Anncounce": return await share(activity) + } +} + async function create(activity:any, activity_id:string) { activity.object.id = activity.object.url = `${ACTOR.url}/posts/${activity_id}` db.createPost(activity_id, activity.object) @@ -143,7 +150,7 @@ async function undo(activity:any) { if (!id) return true const match = id.match(/\/([0-9a-z]+)\/?$/) const activity_id = match ? match[1] : id - console.log('undo', activity_id) + console.info('undo', activity_id) try{ const existing = db.getOutboxActivity(activity_id) diff --git a/src/request.ts b/src/request.ts index 2235b95..1e7ece3 100644 --- a/src/request.ts +++ b/src/request.ts @@ -1,7 +1,15 @@ import forge from "node-forge" // import crypto from "node:crypto" -import { PRIVATE_KEY } from "./env"; +import { PRIVATE_KEY, TOKEN_ENDPOINT } from "./env"; import ACTOR from "../actor" +export function idsFromValue(value:any):string[] { + if (!value) return [] + else if (typeof value === 'string') return [value] + else if (value.id) return [value.id] + else if (Array.isArray(value)) return value.map(v => idsFromValue(v)).flat(Infinity) as string[] + return [] +} + // this function adds / modifies the appropriate headers for signing a request, then calls fetch export function signedFetch(url: string | URL | Request, init?: FetchRequestInit): Promise { @@ -52,7 +60,6 @@ async function fetchActor(url:string) { /** Fetches and returns an object at a URL. */ export async function fetchObject(object_url:string) { - console.log(`fetch ${object_url}`) const res = await signedFetch(object_url); @@ -69,9 +76,9 @@ export async function fetchObject(object_url:string) { * @param message the body of the request to send. */ export async function send(recipient:string, message:any, from:string=ACTOR.id) { - console.log(`Sending to ${recipient}`, message) + console.info(`Sending to ${recipient}`, message) // TODO: revisit fetch actor to use webfinger to get the inbox maybe? - const actor = await fetchActor(recipient) + const actor = await fetchActor(recipient) as any const body = JSON.stringify(message) @@ -122,7 +129,7 @@ export async function verify(req:Request, body:string) { } // get the actor's public key - const actor = await fetchActor(keyId); + const actor = await fetchActor(keyId) as any if (!actor.publicKey) throw new Error("No public key found.") const key = forge.pki.publicKeyFromPem(actor.publicKey.publicKeyPem) @@ -149,4 +156,17 @@ export async function verify(req:Request, body:string) { throw new Error("Request date too old."); return actor.id; +} + +/* verifies that the request is authorized + * (i.e. has a valid auth token) */ +export async function authorized(req:Request) { + if(!TOKEN_ENDPOINT) return false + const response = await fetch(TOKEN_ENDPOINT, { headers: {"authorization": req.headers.get("authorization") || ''} }) + if(!response.ok) return false + + const json = await response.json() as any + if(new URL(json.me).hostname !== new URL(ACTOR.id).hostname) return false + + return true } \ No newline at end of file