├── .gitignore ├── .env.default ├── package.json ├── src ├── types.ts ├── env.ts ├── index.ts ├── admin.ts ├── db.ts ├── request.ts └── activitypub.ts ├── tsconfig.json ├── db └── schema.sql ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | build 4 | .env 5 | 6 | db/database.sqlite3 7 | -------------------------------------------------------------------------------- /.env.default: -------------------------------------------------------------------------------- 1 | # The Node environment 2 | NODE_ENV="development" 3 | 4 | # The path to the database schema 5 | SCHEMA_PATH="db/schema.sql" 6 | 7 | # The path to the database file 8 | DATABASE_PATH="db/database.sqlite3" 9 | 10 | # The hostname (i.e. the "example.com" part of https://example.com/alice) 11 | HOSTNAME="" 12 | 13 | # The account name (i.e. the "alice" part of https://example.com/alice) 14 | ACCOUNT="" 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dumbo", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "./build/index.js", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "ts-node --esm src/index.ts", 9 | "build": "tsc", 10 | "start": "node build/index.js" 11 | }, 12 | "author": "", 13 | "license": "ISC", 14 | "devDependencies": { 15 | "@types/better-sqlite3": "^7.6.3", 16 | "@types/express": "^4.17.15", 17 | "@types/morgan": "^1.9.3", 18 | "@types/node": "^18.11.17", 19 | "ts-node": "^10.9.1", 20 | "typescript": "^4.9.4" 21 | }, 22 | "dependencies": { 23 | "better-sqlite3": "^8.0.1", 24 | "dotenv": "^16.0.3", 25 | "express": "^4.18.2", 26 | "express-basic-auth": "^1.2.1", 27 | "morgan": "^1.10.0", 28 | "node-fetch": "^3.3.0", 29 | "superstruct": "^1.0.3" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { array, assign, optional, string, type } from "superstruct"; 2 | 3 | export const Object = type({ 4 | id: string(), 5 | type: string(), 6 | to: optional(array(string())), 7 | bto: optional(array(string())), 8 | cc: optional(array(string())), 9 | bcc: optional(array(string())), 10 | }); 11 | 12 | export const Actor = assign( 13 | Object, 14 | type({ 15 | inbox: string(), 16 | outbox: optional(string()), 17 | followers: optional(string()), 18 | following: optional(string()), 19 | endpoints: optional( 20 | type({ 21 | sharedInbox: optional(string()), 22 | }) 23 | ), 24 | publicKey: optional( 25 | type({ 26 | publicKeyPem: string(), 27 | }) 28 | ), 29 | }) 30 | ); 31 | 32 | export const Activity = assign( 33 | Object, 34 | type({ actor: string(), object: Object }) 35 | ); 36 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": ["esnext", "dom"], 5 | "skipLibCheck": true, 6 | 7 | // build options 8 | "baseUrl": "src", 9 | "outDir": "build", 10 | 11 | // module resolution 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "isolatedModules": true, 15 | 16 | // ecmascript features 17 | "downlevelIteration": true, 18 | "esModuleInterop": true, 19 | 20 | // strict typechecking 21 | "strict": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noImplicitAny": true, 24 | "noImplicitReturns": true, 25 | "noImplicitThis": true, 26 | "noUnusedLocals": true, 27 | "noUnusedParameters": true, 28 | "importsNotUsedAsValues": "error", 29 | "forceConsistentCasingInFileNames": true, 30 | "noUncheckedIndexedAccess": true 31 | }, 32 | "include": ["src/**/*"], 33 | "exclude": ["node_modules"] 34 | } 35 | -------------------------------------------------------------------------------- /db/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS `followers` ( 2 | `id` TEXT PRIMARY KEY, 3 | `actor` TEXT NOT NULL, 4 | `uri` TEXT NOT NULL, 5 | `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 6 | ); 7 | 8 | CREATE UNIQUE INDEX IF NOT EXISTS `followers_actor_idx` ON `followers`(`actor` COLLATE NOCASE); 9 | CREATE UNIQUE INDEX IF NOT EXISTS `followers_uri_idx` ON `followers`(`uri` COLLATE NOCASE); 10 | 11 | CREATE TABLE IF NOT EXISTS `following` ( 12 | `id` TEXT PRIMARY KEY, 13 | `actor` INTEGER NOT NULL, 14 | `uri` TEXT NOT NULL, 15 | `confirmed` BOOLEAN NOT NULL DEFAULT 0, 16 | `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 17 | ); 18 | 19 | CREATE UNIQUE INDEX IF NOT EXISTS `following_actor_idx` ON `following`(`actor` COLLATE NOCASE); 20 | CREATE UNIQUE INDEX IF NOT EXISTS `following_uri_idx` ON `following`(`uri` COLLATE NOCASE); 21 | 22 | CREATE TABLE IF NOT EXISTS `posts` ( 23 | `id` TEXT PRIMARY KEY, 24 | `contents` JSON NOT NULL, 25 | `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 26 | ); 27 | -------------------------------------------------------------------------------- /src/env.ts: -------------------------------------------------------------------------------- 1 | import crypto from "node:crypto"; 2 | 3 | import dotenv from "dotenv"; 4 | 5 | dotenv.config(); 6 | 7 | export const PORT = process.env.PORT || "3000"; 8 | 9 | export const ACCOUNT = process.env.ACCOUNT || ""; 10 | export const HOSTNAME = process.env.HOSTNAME || ""; 11 | 12 | export const DATABASE_PATH = process.env.DATABASE_PATH || ""; 13 | export const SCHEMA_PATH = process.env.SCHEMA_PATH || ""; 14 | 15 | // in development, generate a key pair to make it easier to get started 16 | const keypair = 17 | process.env.NODE_ENV === "development" 18 | ? crypto.generateKeyPairSync("rsa", { modulusLength: 4096 }) 19 | : undefined; 20 | 21 | export const PUBLIC_KEY = 22 | process.env.PUBLIC_KEY || 23 | keypair?.publicKey.export({ type: "spki", format: "pem" }) || 24 | ""; 25 | export const PRIVATE_KEY = 26 | process.env.PRIVATE_KEY || 27 | keypair?.privateKey.export({ type: "pkcs8", format: "pem" }) || 28 | ""; 29 | 30 | export const ADMIN_USERNAME = process.env.ADMIN_USERNAME || ""; 31 | export const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || ""; 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Jake Lazaroff 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import morgan from "morgan"; 3 | 4 | import { ACCOUNT, HOSTNAME, PORT } from "./env.js"; 5 | import { activitypub } from "./activitypub.js"; 6 | import { admin } from "./admin.js"; 7 | 8 | const app = express(); 9 | 10 | app.set("actor", `https://${HOSTNAME}/${ACCOUNT}`); 11 | 12 | app.use( 13 | express.text({ type: ["application/json", "application/activity+json"] }) 14 | ); 15 | 16 | app.use(morgan("tiny")); 17 | 18 | app.get("/.well-known/webfinger", async (req, res) => { 19 | const actor: string = req.app.get("actor"); 20 | 21 | const resource = req.query.resource; 22 | if (resource !== `acct:${ACCOUNT}@${HOSTNAME}`) return res.sendStatus(404); 23 | 24 | return res.contentType("application/activity+json").json({ 25 | subject: `acct:${ACCOUNT}@${HOSTNAME}`, 26 | links: [ 27 | { 28 | rel: "self", 29 | type: "application/activity+json", 30 | href: actor, 31 | }, 32 | ], 33 | }); 34 | }); 35 | 36 | app.use("/admin", admin).use(activitypub); 37 | 38 | app.listen(PORT, () => { 39 | console.log(`Dumbo listening on port ${PORT}…`); 40 | }); 41 | -------------------------------------------------------------------------------- /src/admin.ts: -------------------------------------------------------------------------------- 1 | import crypto from "node:crypto"; 2 | 3 | import { is, omit, type } from "superstruct"; 4 | import { Router } from "express"; 5 | import basicAuth from "express-basic-auth"; 6 | 7 | import { ADMIN_PASSWORD, ADMIN_USERNAME, HOSTNAME } from "./env.js"; 8 | import { 9 | createFollowing, 10 | createPost, 11 | deleteFollowing, 12 | getFollowing, 13 | listFollowers, 14 | } from "./db.js"; 15 | import { send } from "./request.js"; 16 | import { Object } from "./types.js"; 17 | 18 | export const admin = Router(); 19 | 20 | if (ADMIN_USERNAME && ADMIN_PASSWORD) { 21 | admin.use(basicAuth({ users: { [ADMIN_USERNAME]: ADMIN_PASSWORD } })); 22 | } 23 | 24 | admin.post("/create", async (req, res) => { 25 | const actor: string = req.app.get("actor"); 26 | 27 | const create = type({ object: omit(Object, ["id"]) }); 28 | 29 | const body = JSON.parse(req.body); 30 | if (!is(body, create)) return res.sendStatus(400); 31 | 32 | const date = new Date(); 33 | 34 | const object = createPost({ 35 | attributedTo: actor, 36 | published: date.toISOString(), 37 | to: ["https://www.w3.org/ns/activitystreams#Public"], 38 | cc: [`${actor}/followers`], 39 | ...body.object, 40 | }); 41 | 42 | const activity = createPost({ 43 | "@context": "https://www.w3.org/ns/activitystreams", 44 | type: "Create", 45 | published: date.toISOString(), 46 | actor, 47 | to: ["https://www.w3.org/ns/activitystreams#Public"], 48 | cc: [`${actor}/followers`], 49 | ...body, 50 | object: { ...object.contents, id: `${actor}/post/${object.id}` }, 51 | }); 52 | 53 | for (const follower of listFollowers()) { 54 | send(actor, follower.actor, { 55 | ...activity.contents, 56 | id: `${actor}/post/${activity.id}`, 57 | cc: [follower.actor], 58 | }); 59 | } 60 | 61 | return res.sendStatus(204); 62 | }); 63 | 64 | admin.post("/follow/:actor", async (req, res) => { 65 | const actor: string = req.app.get("actor"); 66 | 67 | const object = req.params.actor; 68 | const uri = `https://${HOSTNAME}/@${crypto.randomUUID()}`; 69 | await send(actor, object, { 70 | "@context": "https://www.w3.org/ns/activitystreams", 71 | id: uri, 72 | type: "Follow", 73 | actor, 74 | object, 75 | }); 76 | 77 | createFollowing({ actor: object, uri }); 78 | res.sendStatus(204); 79 | }); 80 | 81 | admin.delete("/follow/:actor", async (req, res) => { 82 | const actor: string = req.app.get("actor"); 83 | 84 | const object = req.params.actor; 85 | const following = getFollowing(object); 86 | if (!following) return res.sendStatus(204); 87 | 88 | await send(actor, object, { 89 | "@context": "https://www.w3.org/ns/activitystreams", 90 | id: following.uri + "/undo", 91 | type: "Undo", 92 | actor: actor, 93 | object: { 94 | id: following.uri, 95 | type: "Follow", 96 | actor, 97 | object, 98 | }, 99 | }); 100 | 101 | deleteFollowing({ actor: object, uri: following.uri }); 102 | return res.sendStatus(204); 103 | }); 104 | -------------------------------------------------------------------------------- /src/db.ts: -------------------------------------------------------------------------------- 1 | import crypto from "node:crypto"; 2 | import { readFileSync } from "node:fs"; 3 | 4 | import Database from "better-sqlite3"; 5 | 6 | import { DATABASE_PATH, SCHEMA_PATH } from "./env.js"; 7 | 8 | const db = new Database(DATABASE_PATH); 9 | 10 | const migration = readFileSync(SCHEMA_PATH); 11 | db.exec(migration.toString()); 12 | 13 | interface Post { 14 | id: string; 15 | contents: object; 16 | createdAt: Date; 17 | } 18 | 19 | export function createPost(object: object): Post { 20 | const id = crypto.randomUUID(); 21 | 22 | const result = db 23 | .prepare("INSERT INTO posts (id, contents) VALUES (?, ?) RETURNING *") 24 | .get(id, JSON.stringify(object)); 25 | 26 | return { 27 | ...result, 28 | contents: JSON.parse(result.contents), 29 | createdAt: new Date(result.created_at), 30 | }; 31 | } 32 | 33 | export function listPosts(): Post[] { 34 | const results = db.prepare("SELECT * FROM posts").all(); 35 | return results.map((result) => ({ 36 | ...result, 37 | contents: JSON.parse(result.contents), 38 | createdAt: new Date(result.created_at), 39 | })); 40 | } 41 | 42 | export function findPost(id: string): Post | undefined { 43 | const result = db.prepare("SELECT * FROM posts WHERE id = ?").get(id); 44 | 45 | if (!result) return; 46 | return { 47 | ...result, 48 | createdAt: new Date(result.created_at), 49 | }; 50 | } 51 | 52 | interface Follower { 53 | id: string; 54 | actor: string; 55 | uri: string; 56 | createdAt: Date; 57 | } 58 | 59 | export function createFollower(input: { actor: string; uri: string }) { 60 | db.prepare( 61 | "INSERT INTO followers (id, actor, uri) VALUES (?, ?, ?) ON CONFLICT DO UPDATE SET uri = excluded.uri" 62 | ).run(crypto.randomUUID(), input.actor, input.uri); 63 | } 64 | 65 | export function listFollowers(): Follower[] { 66 | const results = db.prepare("SELECT * FROM followers").all(); 67 | return results.map((result) => ({ 68 | ...result, 69 | createdAt: new Date(result.created_at), 70 | })); 71 | } 72 | 73 | export async function deleteFollower(input: { actor: string; uri: string }) { 74 | db.prepare("DELETE FROM followers WHERE actor = ? AND uri = ?") 75 | .bind(input.actor, input.uri) 76 | .run(); 77 | } 78 | 79 | interface Following { 80 | id: string; 81 | actor: string; 82 | uri: string; 83 | confirmed: boolean; 84 | createdAt: Date; 85 | } 86 | 87 | export function createFollowing(input: { actor: string; uri: string }) { 88 | db.prepare( 89 | "INSERT INTO following (id, actor, uri) VALUES (?, ?, ?) ON CONFLICT DO UPDATE SET uri = excluded.uri" 90 | ).run(crypto.randomUUID(), input.actor, input.uri); 91 | } 92 | 93 | export function listFollowing(): Following[] { 94 | const results = db.prepare("SELECT * FROM following").all(); 95 | return results.map((result) => ({ 96 | ...result, 97 | confirmed: Boolean(result.confirmed), 98 | createdAt: new Date(result.created_at), 99 | })); 100 | } 101 | 102 | export function getFollowing(actor: string): Following | undefined { 103 | const result = db 104 | .prepare("SELECT * FROM following WHERE actor = ?") 105 | .get(actor); 106 | 107 | if (!result) return; 108 | return { 109 | ...result, 110 | confirmed: Boolean(result.confirmed), 111 | createdAt: new Date(result.created_at), 112 | }; 113 | } 114 | 115 | export function updateFollowing(input: { 116 | actor: string; 117 | uri: string; 118 | confirmed: boolean; 119 | }) { 120 | db.prepare( 121 | "UPDATE following SET confirmed = ? WHERE actor = ? AND uri = ?" 122 | ).run(Number(input.confirmed), input.actor, input.uri); 123 | } 124 | 125 | export async function deleteFollowing(input: { actor: string; uri: string }) { 126 | db.prepare("DELETE FROM following WHERE actor = ? AND uri = ?") 127 | .bind(input.actor, input.uri) 128 | .run(); 129 | } 130 | -------------------------------------------------------------------------------- /src/request.ts: -------------------------------------------------------------------------------- 1 | import crypto from "node:crypto"; 2 | 3 | import type { Request } from "express"; 4 | import fetch from "node-fetch"; 5 | import { assert } from "superstruct"; 6 | 7 | import { PRIVATE_KEY } from "./env.js"; 8 | import { Actor } from "./types.js"; 9 | 10 | /** Fetches and returns an actor at a URL. */ 11 | async function fetchActor(url: string) { 12 | const res = await fetch(url, { 13 | headers: { accept: "application/activity+json" }, 14 | }); 15 | 16 | if (res.status < 200 || 299 < res.status) 17 | throw new Error(`Received ${res.status} fetching actor.`); 18 | 19 | const body = await res.json(); 20 | assert(body, Actor); 21 | 22 | return body; 23 | } 24 | 25 | /** Sends a signed message from the sender to the recipient. 26 | * @param sender The sender's actor URL. 27 | * @param recipient The recipient's actor URL. 28 | * @param message the body of the request to send. 29 | */ 30 | export async function send(sender: string, recipient: string, message: object) { 31 | const url = new URL(recipient); 32 | 33 | const actor = await fetchActor(recipient); 34 | const fragment = actor.inbox.replace("https://" + url.hostname, ""); 35 | const body = JSON.stringify(message); 36 | const digest = crypto.createHash("sha256").update(body).digest("base64"); 37 | const d = new Date(); 38 | 39 | const key = crypto.createPrivateKey(PRIVATE_KEY.toString()); 40 | const data = [ 41 | `(request-target): post ${fragment}`, 42 | `host: ${url.hostname}`, 43 | `date: ${d.toUTCString()}`, 44 | `digest: SHA-256=${digest}`, 45 | ].join("\n"); 46 | const signature = crypto 47 | .sign("sha256", Buffer.from(data), key) 48 | .toString("base64"); 49 | 50 | const res = await fetch(actor.inbox, { 51 | method: "POST", 52 | headers: { 53 | host: url.hostname, 54 | date: d.toUTCString(), 55 | digest: `SHA-256=${digest}`, 56 | "content-type": "application/json", 57 | signature: `keyId="${sender}#main-key",headers="(request-target) host date digest",signature="${signature}"`, 58 | accept: "application/json", 59 | }, 60 | body, 61 | }); 62 | 63 | if (res.status < 200 || 299 < res.status) { 64 | throw new Error(res.statusText + ": " + (await res.text())); 65 | } 66 | 67 | return res; 68 | } 69 | 70 | /** Verifies that a request came from an actor. 71 | * Returns the actor's ID if the verification succeeds; throws otherwise. 72 | * @param req An Express request. 73 | * @returns The actor's ID. */ 74 | export async function verify(req: Request): Promise { 75 | // get headers included in signature 76 | const included: Record = {}; 77 | for (const header of req.get("signature")?.split(",") ?? []) { 78 | const [key, value] = header.split("="); 79 | if (!key || !value) continue; 80 | 81 | included[key] = value.replace(/^"|"$/g, ""); 82 | } 83 | 84 | /** the URL of the actor document containing the signature's public key */ 85 | const keyId = included.keyId; 86 | if (!keyId) throw new Error(`Missing "keyId" in signature header.`); 87 | 88 | /** the signed request headers */ 89 | const signedHeaders = included.headers; 90 | if (!signedHeaders) throw new Error(`Missing "headers" in signature header.`); 91 | 92 | /** the signature itself */ 93 | const signature = Buffer.from(included.signature ?? "", "base64"); 94 | if (!signature) throw new Error(`Missing "signature" in signature header.`); 95 | 96 | // ensure that the digest header matches the digest of the body 97 | const digestHeader = req.get("digest"); 98 | if (digestHeader) { 99 | const digestBody = crypto 100 | .createHash("sha256") 101 | .update(req.body) 102 | .digest("base64"); 103 | if (digestHeader !== "SHA-256=" + digestBody) { 104 | throw new Error(`Incorrect digest header.`); 105 | } 106 | } 107 | 108 | // get the actor's public key 109 | const actor = await fetchActor(keyId); 110 | if (!actor.publicKey) throw new Error("No public key found."); 111 | const key = crypto.createPublicKey(actor.publicKey.publicKeyPem); 112 | 113 | // reconstruct the signed header string 114 | const comparison = signedHeaders 115 | .split(" ") 116 | .map((header) => { 117 | if (header === "(request-target)") 118 | return "(request-target): post " + req.baseUrl + req.path; 119 | return `${header}: ${req.get(header)}`; 120 | }) 121 | .join("\n"); 122 | const data = Buffer.from(comparison); 123 | 124 | // verify the signature against the headers using the actor's public key 125 | const verified = crypto.verify("sha256", data, key, signature); 126 | if (!verified) throw new Error("Invalid request signature."); 127 | 128 | // ensure the request was made recently 129 | const now = new Date(); 130 | const date = new Date(req.get("date") ?? 0); 131 | if (now.getTime() - date.getTime() > 30_000) 132 | throw new Error("Request date too old."); 133 | 134 | return actor.id; 135 | } 136 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ActivityPub Starter Kit 2 | 3 | A tiny, single user ActivityPub server. 4 | 5 | Although you can use this in production as-is, it’s really meant to be a starting point for your own ActivityPub projects. Here, some ideas to get you going: 6 | 7 | - Allow people to follow your blog on Mastodon. 8 | - Follow accounts and save links they post to a reading list. 9 | - Automatically post if your website goes down. 10 | - Whatever you can dream up! 11 | 12 | ActivityPub Stater Kit is easy to extend — built on Express with only a few dependencies. 13 | 14 | ## Local Development 15 | 16 | First, copy the `.env.default` file as `.env` and fill out the missing variables: 17 | 18 | - `ACCOUNT` is the account name (the “alice” part of `https://example.com/alice`). 19 | - `HOSTNAME` is the domain at which other servers will be able to find yours on the public internet (the “example.com” part of `@name@example.com`). 20 | 21 | Once you have the `.env` file filled out, just run `npm dev` and you’re off to the races! 22 | 23 | ### Getting Online 24 | 25 | In order to test how other servers interact with yours, you’ll need to make it available on the real internet. 26 | 27 | You can use a service like [ngrok](https://ngrok.com/) to quickly and safely get your server online. Sign up for an account, install the command line tool and then run this command: 28 | 29 | ```sh 30 | ngrok http 3000 31 | ``` 32 | 33 | ngrok will give you a public URL that looks something like `https://2698179b-26eb-4493-8e2e-a79f40b3e964.ngrok.io`. Set the `HOSTNAME` variable in your `.env` file to everything after the `https://` (in this example, `2698179b-26eb-4493-8e2e-a79f40b3e964.ngrok.io`). 34 | 35 | To find your account in another Fediverse app such as Mastodon, search for your account name and the ngrok URL you just put in your `HOSTNAME` variable. So if your account name were “alice”, you’d search for your actor alice (`https://2698179b-26eb-4493-8e2e-a79f40b3e964.ngrok.io/alice`) or `@alice@2698179b-26eb-4493-8e2e-a79f40b3e964.ngrok.io`. 36 | 37 | ## Doing Stuff 38 | 39 | ActivityPub Starter Kit doesn’t have a GUI — although you could make one! Instead, there’s an API that you can use. The activities that it supports are posting and following/unfollowing. 40 | 41 | ### Posting 42 | 43 | `POST /admin/create` adds a [`Create` activity](https://www.w3.org/TR/activitypub/#create-activity-outbox) to your outbox and notifies all your followers. The request body must be JSON and is used as the activity `object`. The only required field is `type`. You can omit fields that the server already knows, such as `@context`, `attributedTo`, `published` and `cc`. 44 | 45 | For example, you could send a POST request containing the following body: 46 | 47 | ```json 48 | { 49 | "object": { 50 | "type": "Note", 51 | "content": "Lorem ipsum dolor sit amet." 52 | } 53 | } 54 | ``` 55 | 56 | That will add this activity to your outbox: 57 | 58 | ```json 59 | { 60 | "@context": "https://www.w3.org/ns/activitystreams", 61 | "type": "Create", 62 | "id": "https://example.com/alice/post/123", 63 | "actor": "https://example.com/alice", 64 | "published": "2022-12-34T12:34:56Z", 65 | "to": ["https://www.w3.org/ns/activitystreams#Public"], 66 | "cc": ["https://example.com/alice/followers"], 67 | "object": { 68 | "id": "https://example.com/alice/post/456", 69 | "type": "Note", 70 | "attributedTo": "https://example.com/alice", 71 | "content": "Lorem ipsum dolor sit amet.", 72 | "published": "2022-12-34T12:34:56Z", 73 | "to": ["https://www.w3.org/ns/activitystreams#Public"], 74 | "cc": ["https://example.com/alice/followers"] 75 | } 76 | } 77 | ``` 78 | 79 | In addition, it will send this activity to each of your followers, with the top-level `cc` array replaced with their inbox address. 80 | 81 | ActivityPub Starter Kit provides sensible defaults for everything in the `Create` activity outside of the `object` property, but you can override them by supplying those properties alongside `object`. For example, if you wanted to backdate a post, you could supply your own `published` date: 82 | 83 | ```json 84 | { 85 | "published": "2019-12-34T12:34:56Z", 86 | "object": { 87 | "type": "Note", 88 | "content": "Lorem ipsum dolor sit amet.", 89 | "published": "2019-12-34T12:34:56Z" 90 | } 91 | } 92 | ``` 93 | 94 | ### Following 95 | 96 | `POST /admin/follow/:actor` follows another Fediverse account. This should cause them to send a request to your inbox whenever they post something. `:actor` should be replaced with the full actor ID; for example, to follow the account `https://example.com/bob`, you’d send a POST request to `/admin/follow/https://example.com/bob` 97 | 98 | `DELETE /admin/follow/:actor` unfollows another Fediverse account. This should cause them to stop sending requests to your inbox whenever they post something. As with the previous endpoint, `:actor` should be replaced with the full actor ID; for example, to unfollow the account `https://example.com/bob`, you’d send a DELETE request to `/admin/follow/https://example.com/bob` 99 | 100 | ## Deploying to Production 101 | 102 | When you deploy a real server on the public internet, there are a few more environment variables you’ll need to define: 103 | 104 | - `PUBLIC_KEY` and `PRIVATE_KEY` make up the key pair that your server will use to prove that it’s really making requests that appear to come from your domain. This prevents other servers from impersonating you! 105 | - `ADMIN_USERNAME` and `ADMIN_PASSWORD` prevent intruders from accessing the admin endpoints. You’ll need to supply these credentials using [HTTP basic authentication](https://swagger.io/docs/specification/2-0/authentication/basic-authentication/). 106 | 107 | If you need help creating a key pair, [here's a guide on how to do it](https://stackoverflow.com/a/44474607). 108 | -------------------------------------------------------------------------------- /src/activitypub.ts: -------------------------------------------------------------------------------- 1 | import crypto from "node:crypto"; 2 | 3 | import { Router } from "express"; 4 | 5 | import { 6 | createFollower, 7 | deleteFollower, 8 | findPost, 9 | listFollowers, 10 | listFollowing, 11 | listPosts, 12 | updateFollowing, 13 | } from "./db.js"; 14 | import { HOSTNAME, ACCOUNT, PUBLIC_KEY } from "./env.js"; 15 | import { send, verify } from "./request.js"; 16 | 17 | export const activitypub = Router(); 18 | 19 | activitypub.get("/:actor/outbox", async (req, res) => { 20 | const actor: string = req.app.get("actor"); 21 | if (req.params.actor !== ACCOUNT) return res.sendStatus(404); 22 | 23 | const posts = listPosts().filter( 24 | (post) => "type" in post.contents && post.contents.type === "Create" 25 | ); 26 | 27 | return res.contentType("application/activity+json").json({ 28 | "@context": "https://www.w3.org/ns/activitystreams", 29 | id: `${actor}/outbox`, 30 | type: "OrderedCollection", 31 | totalItems: posts.length, 32 | orderedItems: posts.map((post) => ({ 33 | ...post.contents, 34 | id: `${actor}/posts/${post.id}`, 35 | actor, 36 | published: post.createdAt.toISOString(), 37 | to: ["https://www.w3.org/ns/activitystreams#Public"], 38 | cc: [], 39 | })), 40 | }); 41 | }); 42 | 43 | activitypub.post("/:actor/inbox", async (req, res) => { 44 | const actor: string = req.app.get("actor"); 45 | if (req.params.actor !== ACCOUNT) return res.sendStatus(404); 46 | 47 | /** If the request successfully verifies against the public key, `from` is the actor who sent it. */ 48 | let from = ""; 49 | try { 50 | // verify the signed HTTP request 51 | from = await verify(req); 52 | } catch (err) { 53 | console.error(err); 54 | return res.sendStatus(401); 55 | } 56 | 57 | const body = JSON.parse(req.body); 58 | 59 | // ensure that the verified actor matches the actor in the request body 60 | if (from !== body.actor) return res.sendStatus(401); 61 | 62 | switch (body.type) { 63 | case "Follow": { 64 | await send(actor, body.actor, { 65 | "@context": "https://www.w3.org/ns/activitystreams", 66 | id: `https://${HOSTNAME}/${crypto.randomUUID()}`, 67 | type: "Accept", 68 | actor, 69 | object: body, 70 | }); 71 | 72 | createFollower({ actor: body.actor, uri: body.id }); 73 | break; 74 | } 75 | 76 | case "Undo": { 77 | if (body.object.type === "Follow") { 78 | deleteFollower({ actor: body.actor, uri: body.object.id }); 79 | } 80 | 81 | break; 82 | } 83 | 84 | case "Accept": { 85 | if (body.object.type === "Follow") { 86 | updateFollowing({ 87 | actor: body.actor, 88 | uri: body.object.id, 89 | confirmed: true, 90 | }); 91 | } 92 | 93 | break; 94 | } 95 | } 96 | 97 | return res.sendStatus(204); 98 | }); 99 | 100 | activitypub.get("/:actor/followers", async (req, res) => { 101 | const actor: string = req.app.get("actor"); 102 | 103 | if (req.params.actor !== ACCOUNT) return res.sendStatus(404); 104 | const page = req.query.page; 105 | 106 | const followers = listFollowers(); 107 | 108 | res.contentType("application/activity+json"); 109 | 110 | if (!page) { 111 | return res.json({ 112 | "@context": "https://www.w3.org/ns/activitystreams", 113 | id: `${actor}/followers`, 114 | type: "OrderedCollection", 115 | totalItems: followers.length, 116 | first: `${actor}/followers?page=1`, 117 | }); 118 | } 119 | 120 | return res.json({ 121 | "@context": "https://www.w3.org/ns/activitystreams", 122 | id: `${actor}/followers?page=${page}`, 123 | type: "OrderedCollectionPage", 124 | partOf: `${actor}/followers`, 125 | totalItems: followers.length, 126 | orderedItems: followers.map((follower) => follower.actor), 127 | }); 128 | }); 129 | 130 | activitypub.get("/:actor/following", async (req, res) => { 131 | const actor: string = req.app.get("actor"); 132 | 133 | if (req.params.actor !== ACCOUNT) return res.sendStatus(404); 134 | const page = req.query.page; 135 | 136 | const following = listFollowing(); 137 | 138 | res.contentType("application/activity+json"); 139 | 140 | if (!page) { 141 | return res.json({ 142 | "@context": "https://www.w3.org/ns/activitystreams", 143 | id: `${actor}/following`, 144 | type: "OrderedCollection", 145 | totalItems: following.length, 146 | first: `${actor}/following?page=1`, 147 | }); 148 | } 149 | 150 | return res.json({ 151 | "@context": "https://www.w3.org/ns/activitystreams", 152 | id: `${actor}/following?page=${page}`, 153 | type: "OrderedCollectionPage", 154 | partOf: `${actor}/following`, 155 | totalItems: following.length, 156 | orderedItems: following.map((follow) => follow.actor), 157 | }); 158 | }); 159 | 160 | activitypub.get("/:actor", async (req, res) => { 161 | const actor: string = req.app.get("actor"); 162 | 163 | if (req.params.actor !== ACCOUNT) return res.sendStatus(404); 164 | 165 | return res.contentType("application/activity+json").json({ 166 | "@context": [ 167 | "https://www.w3.org/ns/activitystreams", 168 | "https://w3id.org/security/v1", 169 | ], 170 | id: actor, 171 | type: "Person", 172 | preferredUsername: ACCOUNT, 173 | inbox: `${actor}/inbox`, 174 | outbox: `${actor}/outbox`, 175 | followers: `${actor}/followers`, 176 | following: `${actor}/following`, 177 | publicKey: { 178 | id: `${actor}#main-key`, 179 | owner: actor, 180 | publicKeyPem: PUBLIC_KEY, 181 | }, 182 | }); 183 | }); 184 | 185 | activitypub.get("/:actor/posts/:id", async (req, res) => { 186 | const actor: string = req.app.get("actor"); 187 | if (req.params.actor !== ACCOUNT) return res.sendStatus(404); 188 | 189 | const post = findPost(req.params.id); 190 | if (!post) return res.sendStatus(404); 191 | 192 | return res.contentType("application/activity+json").json({ 193 | ...post, 194 | id: `${actor}/posts/${req.params.id}`, 195 | }); 196 | }); 197 | --------------------------------------------------------------------------------