├── .github ├── dependabot.yml └── workflows │ ├── main-docker.yml │ └── tag-docker.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── bots └── index.js ├── docs └── activitypub.bot.drawio ├── index.js ├── lib ├── activitydistributor.js ├── activityhandler.js ├── activitypubclient.js ├── actorstorage.js ├── app.js ├── authorizer.js ├── bot.js ├── botcontext.js ├── botdatastorage.js ├── bots │ ├── donothing.js │ └── ok.js ├── digester.js ├── httpsignature.js ├── httpsignatureauthenticator.js ├── keystorage.js ├── microsyntax.js ├── objectcache.js ├── objectstorage.js ├── remotekeystorage.js ├── routes │ ├── collection.js │ ├── health.js │ ├── inbox.js │ ├── object.js │ ├── server.js │ ├── user.js │ └── webfinger.js └── urlformatter.js ├── package-lock.json ├── package.json └── tests ├── activitydistributor.test.js ├── activityhandler.test.js ├── activitypubclient.test.js ├── actorstorage.test.js ├── app.test.js ├── authorizer.test.js ├── bot.donothing.test.js ├── bot.ok.test.js ├── botcontext.test.js ├── botdatastorage.test.js ├── digester.test.js ├── fixtures └── bots.js ├── httpsignature.test.js ├── httpsignatureauthenticator.test.js ├── keystorage.test.js ├── microsyntax.test.js ├── objectcache.test.js ├── objectstorage.test.js ├── remotekeystorage.test.js ├── routes.actor.test.js ├── routes.collection.test.js ├── routes.health.test.js ├── routes.inbox.test.js ├── routes.object.test.js ├── routes.server.test.js ├── routes.webfinger.test.js ├── urlformatter.test.js └── utils ├── digest.js └── nock.js /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/main-docker.yml: -------------------------------------------------------------------------------- 1 | name: Build and push Docker image 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | 9 | permissions: 10 | contents: read 11 | packages: write 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | 17 | env: 18 | REGISTRY: ghcr.io 19 | REPOSITORY: evanp/activitypub-bot 20 | 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v2 24 | 25 | - name: Set up Docker Buildx 26 | uses: docker/setup-buildx-action@v1 27 | 28 | - name: Log in to GitHub Container Registry 29 | uses: docker/login-action@v1 30 | with: 31 | registry: ${{ env.REGISTRY }} 32 | username: ${{ github.actor }} 33 | password: ${{ secrets.GITHUB_TOKEN }} 34 | 35 | - name: Build and push 36 | uses: docker/build-push-action@v5 37 | with: 38 | context: . 39 | push: true 40 | platforms: linux/amd64,linux/arm64 41 | tags: | 42 | ${{ env.REGISTRY }}/${{ env.REPOSITORY }}:latest 43 | ${{ env.REGISTRY }}/${{ env.REPOSITORY }}:${{ github.sha }} 44 | cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.REPOSITORY }}:latest 45 | cache-to: type=inline -------------------------------------------------------------------------------- /.github/workflows/tag-docker.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v[0-9]+.[0-9]+.[0-9]+' 7 | 8 | jobs: 9 | build-and-push: 10 | runs-on: ubuntu-latest 11 | 12 | permissions: 13 | contents: read 14 | packages: write 15 | 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v3 19 | 20 | - name: Set up Docker Buildx 21 | uses: docker/setup-buildx-action@v2 22 | 23 | - name: Extract version components from tag 24 | id: get_version 25 | run: | 26 | FULL_VERSION=${GITHUB_REF#refs/tags/v} 27 | MAJOR=$(echo $FULL_VERSION | cut -d. -f1) 28 | MINOR=$(echo $FULL_VERSION | cut -d. -f2) 29 | PATCH=$(echo $FULL_VERSION | cut -d. -f3) 30 | 31 | echo "FULL_VERSION=$FULL_VERSION" >> $GITHUB_OUTPUT 32 | echo "MAJOR_VERSION=$MAJOR" >> $GITHUB_OUTPUT 33 | echo "MAJOR_MINOR_VERSION=$MAJOR.$MINOR" >> $GITHUB_OUTPUT 34 | 35 | - name: Login to GitHub Container Registry 36 | uses: docker/login-action@v2 37 | with: 38 | registry: ghcr.io 39 | username: ${{ github.actor }} 40 | password: ${{ secrets.GITHUB_TOKEN }} 41 | 42 | - name: Build and push Docker image 43 | uses: docker/build-push-action@v4 44 | with: 45 | context: . 46 | push: true 47 | platforms: linux/amd64,linux/arm64 48 | tags: | 49 | ghcr.io/${{ github.repository }}:${{ steps.get_version.outputs.FULL_VERSION }} 50 | ghcr.io/${{ github.repository }}:${{ steps.get_version.outputs.MAJOR_MINOR_VERSION }} 51 | ghcr.io/${{ github.repository }}:${{ steps.get_version.outputs.MAJOR_VERSION }} 52 | ghcr.io/${{ github.repository }}:${{ github.sha }} 53 | cache-from: type=gha 54 | cache-to: type=gha,mode=max -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | .eslintrc.js 132 | .vscode/* 133 | 134 | # Mac system files 135 | .DS_Store 136 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine AS builder 2 | 3 | WORKDIR /app 4 | 5 | RUN apk add --no-cache python3 make g++ py3-setuptools 6 | 7 | COPY package.json package-lock.json ./ 8 | RUN npm ci 9 | 10 | COPY index.js . 11 | COPY lib lib 12 | COPY bots bots 13 | COPY README.md . 14 | 15 | FROM node:20-alpine 16 | 17 | WORKDIR /app 18 | 19 | RUN apk add --no-cache libstdc++ sqlite sqlite-libs 20 | 21 | COPY --from=builder /app/ ./ 22 | 23 | CMD ["npm", "start"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # activitypub-bot 2 | 3 | An ActivityPub server-side bot framework 🤠 4 | 5 | ## License 6 | 7 | Copyright (C) 2023-2025 Evan Prodromou 8 | 9 | This program is free software: you can redistribute it and/or modify 10 | it under the terms of the GNU Affero General Public License as published 11 | by the Free Software Foundation, either version 3 of the License, or 12 | (at your option) any later version. 13 | 14 | This program is distributed in the hope that it will be useful, 15 | but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | GNU Affero General Public License for more details. 18 | 19 | You should have received a copy of the GNU Affero General Public License 20 | along with this program. If not, see . 21 | 22 | ## Installation 23 | 24 | ## Bots 25 | 26 | ## -------------------------------------------------------------------------------- /bots/index.js: -------------------------------------------------------------------------------- 1 | import DoNothingBot from '../lib/bots/donothing.js' 2 | import OKBot from '../lib/bots/ok.js' 3 | 4 | export default { 5 | ok: new OKBot('ok'), 6 | null: new DoNothingBot('null') 7 | } 8 | -------------------------------------------------------------------------------- /docs/activitypub.bot.drawio: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import { makeApp } from './lib/app.js' 2 | 3 | const DATABASE_URL = process.env.DATABASE_URL || 'sqlite::memory:' 4 | const ORIGIN = process.env.ORIGIN || 'https://activitypubbot.test' 5 | const PORT = process.env.PORT || 9000 // HAL 6 | const BOTS_CONFIG_FILE = process.env.BOTS_CONFIG_FILE || './bots/index.js' 7 | const LOG_LEVEL = process.env.LOG_LEVEL || (process.env.NODE_ENV === 'test' ? 'silent' : 'info') 8 | 9 | const bots = (await import(BOTS_CONFIG_FILE)).default 10 | 11 | const app = await makeApp(DATABASE_URL, ORIGIN, bots, LOG_LEVEL) 12 | 13 | const server = app.listen(parseInt(PORT), () => { 14 | app.locals.logger.info(`Listening on port ${PORT}`) 15 | }) 16 | 17 | process.on('SIGTERM', () => { 18 | console.log('Received SIGTERM') 19 | server.close(async () => { 20 | await app.cleanup() 21 | process.exit(0) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /lib/activitydistributor.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | import as2 from 'activitystrea.ms' 3 | import { LRUCache } from 'lru-cache' 4 | import PQueue from 'p-queue' 5 | import { setTimeout } from 'node:timers/promises' 6 | 7 | export class ActivityDistributor { 8 | static #MAX_CACHE_SIZE = 1000000 9 | static #CONCURRENCY = 32 10 | static #MAX_ATTEMPTS = 16 11 | static #PUBLIC = [ 12 | 'https://www.w3.org/ns/activitystreams#Public', 13 | 'as:Public', 14 | 'Public' 15 | ] 16 | 17 | #client = null 18 | #formatter = null 19 | #actorStorage = null 20 | #directInboxCache = null 21 | #sharedInboxCache = null 22 | #queue = null 23 | #retryQueue = null 24 | #logger = null 25 | 26 | constructor (client, formatter, actorStorage, logger = null) { 27 | this.#client = client 28 | this.#formatter = formatter 29 | this.#actorStorage = actorStorage 30 | this.#logger = logger.child({ class: this.constructor.name }) 31 | this.#directInboxCache = new LRUCache({ max: ActivityDistributor.#MAX_CACHE_SIZE }) 32 | this.#sharedInboxCache = new LRUCache({ max: ActivityDistributor.#MAX_CACHE_SIZE }) 33 | this.#queue = new PQueue({ concurrency: ActivityDistributor.#CONCURRENCY }) 34 | this.#retryQueue = new PQueue() 35 | } 36 | 37 | async distribute (activity, username) { 38 | const stripped = await this.#strip(activity) 39 | const actorId = this.#formatter.format({ username }) 40 | 41 | const delivered = new Set() 42 | const localDelivered = new Set() 43 | 44 | for await (const recipient of this.#public(activity, username)) { 45 | if (await this.#isLocal(recipient)) { 46 | if (recipient !== actorId && !localDelivered.has(recipient)) { 47 | localDelivered.add(recipient) 48 | this.#queue.add(() => 49 | this.#deliverLocal(recipient, stripped, username)) 50 | } 51 | } else { 52 | const inbox = await this.#getInbox(recipient, username) 53 | if (!inbox) { 54 | this.#logger.warn({ id: recipient.id }, 'No inbox') 55 | } else if (!delivered.has(inbox)) { 56 | delivered.add(inbox) 57 | this.#queue.add(() => 58 | this.#deliver(inbox, stripped, username) 59 | ) 60 | } 61 | } 62 | } 63 | 64 | for await (const recipient of this.#private(activity, username)) { 65 | if (await this.#isLocal(recipient)) { 66 | if (recipient !== actorId && !localDelivered.has(recipient)) { 67 | localDelivered.add(recipient) 68 | this.#queue.add(() => 69 | this.#deliverLocal(recipient, stripped, username)) 70 | } 71 | } else { 72 | const inbox = await this.#getDirectInbox(recipient, username) 73 | if (!inbox) { 74 | this.#logger.warn({ id: recipient.id }, 'No direct inbox') 75 | } else if (!delivered.has(inbox)) { 76 | delivered.add(inbox) 77 | this.#queue.add(() => 78 | this.#deliver(inbox, stripped, username) 79 | ) 80 | } 81 | } 82 | } 83 | } 84 | 85 | async onIdle () { 86 | await this.#retryQueue.onIdle() 87 | await this.#queue.onIdle() 88 | } 89 | 90 | async * #public (activity, username) { 91 | const followers = this.#formatter.format({ 92 | username, 93 | collection: 'followers' 94 | }) 95 | for (const prop of ['to', 'cc', 'audience']) { 96 | const p = activity.get(prop) 97 | if (p) { 98 | for (const value of p) { 99 | const id = value.id 100 | if (id === followers || 101 | ActivityDistributor.#PUBLIC.includes(id)) { 102 | for await (const follower of this.#actorStorage.items(username, 'followers')) { 103 | yield follower.id 104 | } 105 | } else { 106 | yield id 107 | } 108 | } 109 | } 110 | } 111 | } 112 | 113 | async * #private (activity, username) { 114 | const followers = this.#formatter.format({ 115 | username, 116 | collection: 'followers' 117 | }) 118 | for (const prop of ['bto', 'bcc']) { 119 | const p = activity.get(prop) 120 | if (p) { 121 | for (const value of p) { 122 | const id = value.id 123 | if (id === followers || 124 | ActivityDistributor.#PUBLIC.includes(id)) { 125 | for await (const follower of this.#actorStorage.items(username, 'followers')) { 126 | yield follower.id 127 | } 128 | } else { 129 | yield id 130 | } 131 | } 132 | } 133 | } 134 | } 135 | 136 | async #getInbox (actorId, username) { 137 | assert.ok(actorId) 138 | assert.equal(typeof actorId, 'string') 139 | assert.ok(username) 140 | assert.equal(typeof username, 'string') 141 | 142 | let sharedInbox = this.#sharedInboxCache.get(actorId) 143 | 144 | if (sharedInbox) { 145 | return sharedInbox 146 | } 147 | 148 | const obj = await this.#client.get(actorId, username) 149 | 150 | // Get the shared inbox if it exists 151 | 152 | const endpoints = obj.get('endpoints') 153 | if (endpoints) { 154 | const firstEndpoint = Array.from(endpoints)[0] 155 | const sharedInboxEndpoint = firstEndpoint.get('sharedInbox') 156 | if (sharedInboxEndpoint) { 157 | const firstSharedInbox = Array.from(sharedInboxEndpoint)[0] 158 | sharedInbox = firstSharedInbox.id 159 | this.#sharedInboxCache.set(actorId, sharedInbox) 160 | return sharedInbox 161 | } 162 | } 163 | 164 | let directInbox = this.#directInboxCache.get(actorId) 165 | if (directInbox) { 166 | return directInbox 167 | } 168 | 169 | if (!obj.inbox) { 170 | return null 171 | } 172 | const inboxes = Array.from(obj.inbox) 173 | if (inboxes.length === 0) { 174 | return null 175 | } 176 | directInbox = inboxes[0].id 177 | this.#directInboxCache.set(actorId, directInbox) 178 | return directInbox 179 | } 180 | 181 | async #getDirectInbox (actorId, username) { 182 | assert.ok(actorId) 183 | assert.equal(typeof actorId, 'string') 184 | assert.ok(username) 185 | assert.equal(typeof username, 'string') 186 | let directInbox = this.#directInboxCache.get(actorId) 187 | if (directInbox) { 188 | return directInbox 189 | } 190 | 191 | const obj = await this.#client.get(actorId, username) 192 | 193 | if (!obj.inbox) { 194 | return null 195 | } 196 | const inboxes = Array.from(obj.inbox) 197 | if (inboxes.length === 0) { 198 | return null 199 | } 200 | directInbox = inboxes[0].id 201 | this.#directInboxCache.set(actorId, directInbox) 202 | return directInbox 203 | } 204 | 205 | async #strip (activity) { 206 | const exported = await activity.export() 207 | delete exported.bcc 208 | delete exported.bto 209 | return await as2.import(exported) 210 | } 211 | 212 | async #deliver (inbox, activity, username, attempt = 1) { 213 | try { 214 | await this.#client.post(inbox, activity, username) 215 | this.#logInfo(`Delivered ${activity.id} to ${inbox}`) 216 | } catch (error) { 217 | if (!error.status) { 218 | this.#logError(`Could not deliver ${activity.id} to ${inbox}: ${error.message}`) 219 | this.#logError(error.stack) 220 | } else if (error.status >= 300 && error.status < 400) { 221 | this.#logError(`Unexpected redirect code delivering ${activity.id} to ${inbox}: ${error.status} ${error.message}`) 222 | } else if (error.status >= 400 && error.status < 500) { 223 | this.#logError(`Bad request delivering ${activity.id} to ${inbox}: ${error.status} ${error.message}`) 224 | } else if (error.status >= 500 && error.status < 600) { 225 | if (attempt >= ActivityDistributor.#MAX_ATTEMPTS) { 226 | this.#logError(`Server error delivering ${activity.id} to ${inbox}: ${error.status} ${error.message}; giving up after ${attempt} attempts`) 227 | } 228 | const delay = Math.round((2 ** (attempt - 1) * 1000) * (0.5 + Math.random())) 229 | this.#logWarning(`Server error delivering ${activity.id} to ${inbox}: ${error.status} ${error.message}; will retry in ${delay} ms (${attempt} of ${ActivityDistributor.#MAX_ATTEMPTS})`) 230 | this.#retryQueue.add(() => setTimeout(delay).then(() => this.#deliver(inbox, activity, username, attempt + 1))) 231 | } 232 | } 233 | } 234 | 235 | #logError (message) { 236 | if (this.#logger) { 237 | this.#logger.error(message) 238 | } 239 | } 240 | 241 | #logWarning (message) { 242 | if (this.#logger) { 243 | this.#logger.warn(message) 244 | } 245 | } 246 | 247 | #logInfo (message) { 248 | if (this.#logger) { 249 | this.#logger.info(message) 250 | } 251 | } 252 | 253 | #isLocal (id) { 254 | return this.#formatter.isLocal(id) 255 | } 256 | 257 | async #deliverLocal (id, activity) { 258 | const username = this.#formatter.getUserName(id) 259 | if (username) { 260 | await this.#actorStorage.addToCollection(username, 'inbox', activity) 261 | } 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /lib/activitypubclient.js: -------------------------------------------------------------------------------- 1 | import as2 from 'activitystrea.ms' 2 | import fetch from 'node-fetch' 3 | import assert from 'node:assert' 4 | import createHttpError from 'http-errors' 5 | import fs from 'node:fs' 6 | import path from 'node:path' 7 | import { fileURLToPath } from 'node:url' 8 | 9 | const __filename = fileURLToPath(import.meta.url) 10 | const __dirname = path.dirname(__filename) 11 | 12 | const { version } = JSON.parse( 13 | fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8') 14 | ) 15 | 16 | export class ActivityPubClient { 17 | static #githubUrl = 'https://github.com/evanp/activitypub-bot' 18 | static #userAgent = `activitypub.bot/${version} (${ActivityPubClient.#githubUrl})` 19 | static #accept = 20 | ['application/activity+json', 21 | 'application/ld+json', 22 | 'application/json'].join(',') 23 | 24 | #keyStorage = null 25 | #urlFormatter = null 26 | #signer = null 27 | #digester = null 28 | #logger = null 29 | 30 | constructor (keyStorage, urlFormatter, signer, digester, logger) { 31 | this.#keyStorage = keyStorage 32 | this.#urlFormatter = urlFormatter 33 | this.#signer = signer 34 | this.#digester = digester 35 | this.#logger = logger.child({ class: this.constructor.name }) 36 | } 37 | 38 | async get (url, username = null) { 39 | assert.ok(url) 40 | assert.equal(typeof url, 'string') 41 | const res = await this.#getRes(url, username, true) 42 | return await this.#handleRes(res, url) 43 | } 44 | 45 | async getKey (url) { 46 | assert.ok(url) 47 | assert.equal(typeof url, 'string') 48 | let res = await this.#getRes(url, null, false) 49 | if ([401, 403, 404].includes(res.status)) { 50 | // If we get a 401, 403, or 404, we should try again with the key 51 | res = await this.#getRes(url, null, true) 52 | } 53 | return await this.#handleRes(res, url) 54 | } 55 | 56 | async #getRes (url, username = null, sign = false) { 57 | assert.ok(url) 58 | assert.equal(typeof url, 'string') 59 | const date = new Date().toUTCString() 60 | const headers = { 61 | accept: ActivityPubClient.#accept, 62 | date, 63 | 'user-agent': ActivityPubClient.#userAgent 64 | } 65 | const method = 'GET' 66 | if (sign) { 67 | headers.signature = 68 | await this.#sign({ username, url, method, headers }) 69 | } 70 | return await fetch(url, 71 | { 72 | method, 73 | headers 74 | } 75 | ) 76 | } 77 | 78 | async #handleRes (res, url) { 79 | if (res.status < 200 || res.status > 299) { 80 | throw createHttpError(res.status, `Could not fetch ${url}`) 81 | } 82 | const json = await res.json() 83 | const obj = await as2.import(json) 84 | return obj 85 | } 86 | 87 | async post (url, obj, username) { 88 | assert.ok(url) 89 | assert.equal(typeof url, 'string') 90 | assert.ok(obj) 91 | assert.equal(typeof obj, 'object') 92 | assert.ok(username) 93 | assert.equal(typeof username, 'string') 94 | const body = await obj.write() 95 | const headers = { 96 | date: new Date().toUTCString(), 97 | 'user-agent': ActivityPubClient.#userAgent, 98 | 'content-type': 'application/activity+json', 99 | digest: await this.#digester.digest(body) 100 | } 101 | const method = 'POST' 102 | assert.ok(headers) 103 | headers.signature = await this.#sign({ username, url, method, headers }) 104 | const res = await fetch(url, 105 | { 106 | method, 107 | headers, 108 | body 109 | } 110 | ) 111 | if (res.status < 200 || res.status > 299) { 112 | throw createHttpError(res.status, await res.text()) 113 | } 114 | } 115 | 116 | async #sign ({ username, url, method, headers }) { 117 | assert.ok(url) 118 | assert.ok(method) 119 | assert.ok(headers) 120 | const privateKey = await this.#keyStorage.getPrivateKey(username) 121 | const keyId = (username) 122 | ? this.#urlFormatter.format({ username, type: 'publickey' }) 123 | : this.#urlFormatter.format({ server: true, type: 'publickey' }) 124 | return this.#signer.sign({ privateKey, keyId, url, method, headers }) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /lib/actorstorage.js: -------------------------------------------------------------------------------- 1 | import as2 from 'activitystrea.ms' 2 | import assert from 'node:assert' 3 | 4 | export class ActorStorage { 5 | #connection = null 6 | #formatter = null 7 | static #MAX_ITEMS_PER_PAGE = 20 8 | constructor (connection, formatter) { 9 | this.#connection = connection 10 | this.#formatter = formatter 11 | } 12 | 13 | async initialize () { 14 | await this.#connection.query(` 15 | CREATE TABLE IF NOT EXISTS actorcollection ( 16 | username varchar(512) NOT NULL, 17 | property varchar(512) NOT NULL, 18 | first INTEGER NOT NULL, 19 | totalItems INTEGER NOT NULL DEFAULT 0, 20 | createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 21 | updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 22 | PRIMARY KEY (username, property) 23 | );` 24 | ) 25 | await this.#connection.query(` 26 | CREATE TABLE IF NOT EXISTS actorcollectionpage ( 27 | username varchar(512) NOT NULL, 28 | property varchar(512) NOT NULL, 29 | item varchar(512) NOT NULL, 30 | page INTEGER NOT NULL, 31 | createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 32 | PRIMARY KEY (username, property, item) 33 | );` 34 | ) 35 | await this.#connection.query( 36 | `CREATE INDEX IF NOT EXISTS actorcollectionpage_username_property_page 37 | ON actorcollectionpage (username, property, page);` 38 | ) 39 | } 40 | 41 | async getActor (username, props = {}) { 42 | assert.ok(username) 43 | assert.equal(typeof username, 'string') 44 | const type = ('type' in props) 45 | ? (Array.isArray(props.type)) 46 | ? [...props.type, 'Service'] 47 | : [props.type, 'Service'] 48 | : ['Service'] 49 | // XXX: spread props first so type is not overwritten 50 | return await as2.import({ 51 | ...props, 52 | id: this.#formatter.format({ username }), 53 | type, 54 | preferredUsername: username, 55 | inbox: this.#formatter.format({ username, collection: 'inbox' }), 56 | outbox: this.#formatter.format({ username, collection: 'outbox' }), 57 | followers: this.#formatter.format({ username, collection: 'followers' }), 58 | following: this.#formatter.format({ username, collection: 'following' }), 59 | liked: this.#formatter.format({ username, collection: 'liked' }), 60 | to: 'as:Public' 61 | }) 62 | } 63 | 64 | async getActorById (id) { 65 | assert.ok(id) 66 | assert.equal(typeof id, 'string') 67 | const username = this.#formatter.getUserName(id) 68 | return await this.getActor(username) 69 | } 70 | 71 | async getCollection (username, property) { 72 | assert.ok(username) 73 | assert.equal(typeof username, 'string') 74 | assert.ok(property) 75 | assert.equal(typeof property, 'string') 76 | const [totalItems, first, createdAt, updatedAt] = 77 | await this.#getCollectionInfo( 78 | username, 79 | property 80 | ) 81 | return await as2.import({ 82 | id: this.#formatter.format({ username, collection: property }), 83 | type: 'OrderedCollection', 84 | attributedTo: this.#formatter.format({ username }), 85 | to: 'as:Public', 86 | summaryMap: { 87 | en: 'The ' + property + ' collection for user ' + username 88 | }, 89 | totalItems, 90 | first: this.#formatter.format({ 91 | username, 92 | collection: property, 93 | page: first 94 | }), 95 | last: this.#formatter.format({ username, collection: property, page: 1 }), 96 | published: createdAt, 97 | updated: updatedAt 98 | }) 99 | } 100 | 101 | async getCollectionPage (username, property, page) { 102 | assert.ok(username) 103 | assert.equal(typeof username, 'string') 104 | assert.ok(property) 105 | assert.equal(typeof property, 'string') 106 | assert.ok(page) 107 | assert.equal(typeof page, 'number') 108 | assert.ok(page > 0) 109 | const [, first] = await this.#getCollectionInfo(username, property) 110 | if (page > first) { 111 | throw new Error('page out of range') 112 | } 113 | const result = await this.#connection.query( 114 | `SELECT item 115 | FROM actorcollectionpage 116 | WHERE username = ? AND property = ? AND page = ?;`, 117 | { replacements: [username, property, page] }) 118 | const items = [] 119 | for (const row of result[0]) { 120 | items.push(row.item) 121 | } 122 | return await as2.import({ 123 | id: this.#formatter.format({ username, collection: property, page }), 124 | type: 'OrderedCollectionPage', 125 | partOf: this.#formatter.format({ username, collection: property }), 126 | attributedTo: this.#formatter.format({ username }), 127 | to: 'as:Public', 128 | summaryMap: { 129 | en: `Page ${page} of the ${property} collection for user ${username}` 130 | }, 131 | next: (page === 1) 132 | ? null 133 | : this.#formatter.format({ username, collection: property, page: page - 1 }), 134 | prev: (page === first) 135 | ? null 136 | : this.#formatter.format({ username, collection: property, page: page + 1 }), 137 | items 138 | }) 139 | } 140 | 141 | async addToCollection (username, property, object) { 142 | assert.ok(this.#connection, 'ActorStorage not initialized') 143 | assert.ok(username, 'username is required') 144 | assert.equal(typeof username, 'string', 'username must be a string') 145 | assert.ok(property, 'property is required') 146 | assert.equal(typeof property, 'string', 'property must be a string') 147 | assert.ok(object, 'object is required') 148 | assert.equal(typeof object, 'object', 'object must be an object') 149 | 150 | const [, first, createdAt] = await this.#getCollectionInfo( 151 | username, 152 | property 153 | ) 154 | 155 | if (createdAt === null) { 156 | await this.#connection.query( 157 | `INSERT INTO actorcollection (username, property, first, totalItems) 158 | VALUES (?, ?, 1, 0)`, 159 | { replacements: [username, property] } 160 | ) 161 | } 162 | 163 | const count = await this.#itemCount(username, property, first) 164 | 165 | const page = (count >= ActorStorage.#MAX_ITEMS_PER_PAGE) 166 | ? first + 1 167 | : first 168 | 169 | if (page > first) { 170 | await this.#connection.query( 171 | `UPDATE actorcollection 172 | SET first = ?, updatedAt = CURRENT_TIMESTAMP 173 | WHERE username = ? AND property = ?`, 174 | { replacements: [page, username, property] } 175 | ) 176 | } 177 | 178 | await this.#connection.query( 179 | `INSERT INTO actorcollectionpage (username, property, item, page) 180 | VALUES (?, ?, ?, ?)`, 181 | { replacements: [username, property, object.id, page] } 182 | ) 183 | 184 | await this.#connection.query( 185 | `UPDATE actorcollection 186 | SET totalItems = totalItems + 1, updatedAt = CURRENT_TIMESTAMP 187 | WHERE username = ? AND property = ?`, 188 | { replacements: [username, property] } 189 | ) 190 | } 191 | 192 | async removeFromCollection (username, property, object) { 193 | assert.ok(username, 'username is required') 194 | assert.equal(typeof username, 'string', 'username must be a string') 195 | assert.ok(property, 'property is required') 196 | assert.equal(typeof property, 'string', 'property must be a string') 197 | assert.ok(object, 'object is required') 198 | assert.equal(typeof object, 'object', 'object must be an object') 199 | 200 | if (!await this.isInCollection(username, property, object)) { 201 | return 0 202 | } 203 | 204 | await this.#connection.query( 205 | `DELETE FROM actorcollectionpage 206 | WHERE username = ? AND property = ? AND item = ?`, 207 | { replacements: [username, property, object.id] } 208 | ) 209 | 210 | await this.#connection.query( 211 | `UPDATE actorcollection 212 | SET totalItems = totalItems - 1, updatedAt = CURRENT_TIMESTAMP 213 | WHERE username = ? AND property = ?`, 214 | { replacements: [username, property] } 215 | ) 216 | 217 | return 1 218 | } 219 | 220 | async * items (username, property) { 221 | assert.ok(username, 'username is required') 222 | assert.equal(typeof username, 'string', 'username must be a string') 223 | assert.ok(property, 'property is required') 224 | assert.equal(typeof property, 'string', 'property must be a string') 225 | 226 | const result = await this.#connection.query( 227 | `SELECT item 228 | FROM actorcollectionpage 229 | WHERE username = ? AND property = ? 230 | ORDER BY page DESC, createdAt DESC;`, 231 | { replacements: [username, property] } 232 | ) 233 | for (const row of result[0]) { 234 | yield as2.import({ id: row.item }) 235 | } 236 | } 237 | 238 | async isInCollection (username, property, object) { 239 | assert.ok(username, 'username is required') 240 | assert.equal(typeof username, 'string', 'username must be a string') 241 | assert.ok(property, 'property is required') 242 | assert.equal(typeof property, 'string', 'property must be a string') 243 | assert.ok(object, 'object is required') 244 | assert.equal(typeof object, 'object', 'object must be an object') 245 | const [result] = await this.#connection.query( 246 | `SELECT COUNT(*) as item_count 247 | FROM actorcollectionpage 248 | WHERE username = ? AND property = ? AND item = ?`, 249 | { replacements: [username, property, object.id] } 250 | ) 251 | return result[0].item_count > 0 252 | } 253 | 254 | async hasPage (username, property, page) { 255 | const [, first] = await this.#getCollectionInfo( 256 | username, 257 | property 258 | ) 259 | return page <= first 260 | } 261 | 262 | async #getCollectionInfo (username, property) { 263 | const [result] = await this.#connection.query( 264 | `SELECT first, totalItems, createdAt, updatedAt 265 | FROM actorcollection 266 | WHERE username = ? AND property = ?;`, 267 | { replacements: [username, property] } 268 | ) 269 | if (result.length > 0) { 270 | const row = result[0] 271 | return [row.totalItems, row.first, row.createdAt, row.updatedAt] 272 | } else { 273 | return [0, 1, null, null] 274 | } 275 | } 276 | 277 | async #itemCount (username, property, page) { 278 | assert.ok(this.#connection, 'ActorStorage not initialized') 279 | assert.ok(username, 'username is required') 280 | assert.equal(typeof username, 'string', 'username must be a string') 281 | assert.ok(property, 'property is required') 282 | assert.equal(typeof property, 'string', 'property must be a string') 283 | assert.ok(page, 'page is required') 284 | assert.equal(typeof page, 'number', 'page must be a number') 285 | assert.ok(page >= 1, 'page must be greater than or equal to 1') 286 | const rows = await this.#connection.query( 287 | `SELECT COUNT(*) as item_count FROM actorcollectionpage 288 | WHERE username = ? AND property = ? AND page = ?`, 289 | { replacements: [username, property, page] } 290 | ) 291 | return rows[0][0].item_count 292 | } 293 | } 294 | -------------------------------------------------------------------------------- /lib/app.js: -------------------------------------------------------------------------------- 1 | import { Sequelize } from 'sequelize' 2 | import express from 'express' 3 | import Logger from 'pino' 4 | import HTTPLogger from 'pino-http' 5 | import http from 'node:http' 6 | import { ActivityDistributor } from './activitydistributor.js' 7 | import { ActivityPubClient } from './activitypubclient.js' 8 | import { ActorStorage } from './actorstorage.js' 9 | import { BotDataStorage } from './botdatastorage.js' 10 | import { KeyStorage } from './keystorage.js' 11 | import { ObjectStorage } from './objectstorage.js' 12 | import { UrlFormatter } from './urlformatter.js' 13 | import { HTTPSignature } from './httpsignature.js' 14 | import { Authorizer } from './authorizer.js' 15 | import { RemoteKeyStorage } from './remotekeystorage.js' 16 | import { ActivityHandler } from './activityhandler.js' 17 | import { ObjectCache } from '../lib/objectcache.js' 18 | import serverRouter from './routes/server.js' 19 | import userRouter from './routes/user.js' 20 | import objectRouter from './routes/object.js' 21 | import collectionRouter from './routes/collection.js' 22 | import inboxRouter from './routes/inbox.js' 23 | import healthRouter from './routes/health.js' 24 | import webfingerRouter from './routes/webfinger.js' 25 | import { BotContext } from './botcontext.js' 26 | import { Transformer } from './microsyntax.js' 27 | import { HTTPSignatureAuthenticator } from './httpsignatureauthenticator.js' 28 | import { Digester } from './digester.js' 29 | 30 | export async function makeApp (databaseUrl, origin, bots, logLevel = 'silent') { 31 | const logger = Logger({ 32 | level: logLevel 33 | }) 34 | logger.debug('Logger initialized') 35 | const connection = new Sequelize(databaseUrl, { logging: false }) 36 | const formatter = new UrlFormatter(origin) 37 | const signer = new HTTPSignature(logger) 38 | const digester = new Digester(logger) 39 | const actorStorage = new ActorStorage(connection, formatter) 40 | await actorStorage.initialize() 41 | const botDataStorage = new BotDataStorage(connection) 42 | await botDataStorage.initialize() 43 | const keyStorage = new KeyStorage(connection, logger) 44 | await keyStorage.initialize() 45 | const objectStorage = new ObjectStorage(connection) 46 | await objectStorage.initialize() 47 | const client = 48 | new ActivityPubClient(keyStorage, formatter, signer, digester, logger) 49 | const remoteKeyStorage = new RemoteKeyStorage(client, connection, logger) 50 | await remoteKeyStorage.initialize() 51 | const signature = new HTTPSignatureAuthenticator(remoteKeyStorage, signer, digester, logger) 52 | const distributor = new ActivityDistributor( 53 | client, 54 | formatter, 55 | actorStorage, 56 | logger 57 | ) 58 | const authorizer = new Authorizer(actorStorage, formatter, client) 59 | const cache = new ObjectCache({ 60 | longTTL: 3600 * 1000, 61 | shortTTL: 300 * 1000, 62 | maxItems: 1000 63 | }) 64 | const activityHandler = new ActivityHandler( 65 | actorStorage, 66 | objectStorage, 67 | distributor, 68 | formatter, 69 | cache, 70 | authorizer, 71 | logger, 72 | client 73 | ) 74 | // TODO: Make an endpoint for tagged objects 75 | const transformer = new Transformer(origin + '/tag/', client) 76 | await Promise.all( 77 | Object.values(bots).map(bot => bot.initialize( 78 | new BotContext( 79 | bot.username, 80 | botDataStorage, 81 | objectStorage, 82 | actorStorage, 83 | client, 84 | distributor, 85 | formatter, 86 | transformer, 87 | logger 88 | ) 89 | )) 90 | ) 91 | 92 | const app = express() 93 | 94 | app.locals = { 95 | connection, 96 | formatter, 97 | actorStorage, 98 | botDataStorage, 99 | keyStorage, 100 | objectStorage, 101 | remoteKeyStorage, 102 | client, 103 | distributor, 104 | signature, 105 | logger, 106 | authorizer, 107 | bots, 108 | activityHandler, 109 | origin 110 | } 111 | 112 | app.use(HTTPLogger({ 113 | logger, 114 | level: logLevel 115 | })) 116 | 117 | app.use(express.json({ 118 | type: [ 119 | 'application/activity+json', 120 | 'application/ld+json', 121 | 'application/json' 122 | ], 123 | verify: (req, res, buf, encoding) => { 124 | req.rawBodyText = buf.toString(encoding || 'utf8') 125 | } 126 | })) 127 | 128 | app.use(signature.authenticate.bind(signature)) 129 | 130 | app.use('/', serverRouter) 131 | app.use('/', userRouter) 132 | app.use('/', collectionRouter) 133 | app.use('/', objectRouter) 134 | app.use('/', inboxRouter) 135 | app.use('/', healthRouter) 136 | app.use('/', webfingerRouter) 137 | 138 | app.use((err, req, res, next) => { 139 | const { logger } = req.app.locals 140 | let status = 500 141 | if (err.status) { 142 | status = err.status 143 | } 144 | const title = (http.STATUS_CODES[status]) 145 | ? http.STATUS_CODES[status] 146 | : 'Unknown Status' 147 | 148 | if (status >= 500 && status < 600) { 149 | logger.error(err) 150 | } else if (status >= 400 && status < 500) { 151 | logger.warn(err) 152 | } else { 153 | logger.debug(err) 154 | } 155 | res.status(status) 156 | res.type('application/problem+json') 157 | res.json({ type: 'about:blank', title, status, detail: err.message }) 158 | }) 159 | 160 | app.onIdle = async () => { 161 | await distributor.onIdle() 162 | } 163 | 164 | app.cleanup = async () => { 165 | logger.info('Closing app') 166 | logger.info('Waiting for distributor queue') 167 | await distributor.onIdle() 168 | logger.info('Closing database connection') 169 | await connection.close() 170 | } 171 | 172 | return app 173 | } 174 | -------------------------------------------------------------------------------- /lib/authorizer.js: -------------------------------------------------------------------------------- 1 | export class Authorizer { 2 | #PUBLIC = 'https://www.w3.org/ns/activitystreams#Public' 3 | #actorStorage = null 4 | #formatter = null 5 | #activityPubClient = null 6 | constructor (actorStorage, formatter, activityPubClient) { 7 | this.#actorStorage = actorStorage 8 | this.#formatter = formatter 9 | this.#activityPubClient = activityPubClient 10 | } 11 | 12 | async canRead (actor, object) { 13 | if (typeof object !== 'object') { 14 | throw new Error('object must be an object') 15 | } 16 | if (!('id' in object)) { 17 | throw new Error('object must have an id property') 18 | } 19 | if (typeof object.id !== 'string') { 20 | throw new Error('object.id must be a string') 21 | } 22 | return (this.#formatter.isLocal(object.id)) 23 | ? await this.#canReadLocal(actor, object) 24 | : await this.#canReadRemote(actor, object) 25 | } 26 | 27 | async isOwner (actor, object) { 28 | const owner = await this.#getOwner(object) 29 | return actor.id === owner.id 30 | } 31 | 32 | async sameOrigin (actor, object) { 33 | const actorUrl = new URL(actor.id) 34 | const objectUrl = new URL(object.id) 35 | return actorUrl.origin === objectUrl.origin 36 | } 37 | 38 | async #canReadLocal (actor, object) { 39 | const recipients = this.#getRecipients(object) 40 | if (!actor) { 41 | return recipients.has(this.#PUBLIC) 42 | } 43 | const ownerId = (await this.#getOwner(object))?.id 44 | if (!ownerId) { 45 | throw new Error(`no owner for ${object.id}`) 46 | } 47 | if (actor.id === ownerId) { 48 | return true 49 | } 50 | const owner = await this.#actorStorage.getActorById(ownerId) 51 | if (!owner) { 52 | throw new Error(`no actor for ${ownerId}`) 53 | } 54 | const ownerName = owner.get('preferredUsername')?.first 55 | if (!ownerName) { 56 | throw new Error(`no preferredUsername for ${owner.id}`) 57 | } 58 | if (await this.#actorStorage.isInCollection(ownerName, 'blocked', actor)) { 59 | return false 60 | } 61 | if (recipients.has(actor.id)) { 62 | return true 63 | } 64 | if (recipients.has(this.#PUBLIC)) { 65 | return true 66 | } 67 | const followers = this.#formatter.format({ username: ownerName, collection: 'followers' }) 68 | if (recipients.has(followers) && await this.#actorStorage.isInCollection(ownerName, 'followers', actor)) { 69 | return true 70 | } 71 | return false 72 | } 73 | 74 | async #canReadRemote (actor, object) { 75 | const recipients = this.#getRecipients(object) 76 | if (!actor) { 77 | return recipients.has(this.#PUBLIC) 78 | } 79 | if (recipients.has(actor.id)) { 80 | return true 81 | } 82 | if (recipients.has(this.#PUBLIC)) { 83 | return true 84 | } 85 | // TODO: check if it's to followers, actor is local, and actor 86 | // is a follower 87 | // TODO: check if it's to a collection, and actor is in the 88 | // collection 89 | return null 90 | } 91 | 92 | async #getOwner (object) { 93 | if (object.attributedTo) { 94 | return object.attributedTo.first 95 | } else if (object.actor) { 96 | return object.actor.first 97 | } else if (object.owner) { 98 | return object.owner.first 99 | } else { 100 | return null 101 | } 102 | } 103 | 104 | #getRecipients (activity) { 105 | const recipientIds = new Set() 106 | if (activity.to) { 107 | for (const to of activity.to) { 108 | recipientIds.add(to.id) 109 | } 110 | } 111 | if (activity.cc) { 112 | for (const cc of activity.cc) { 113 | recipientIds.add(cc.id) 114 | } 115 | } 116 | if (activity.audience) { 117 | for (const audience of activity.audience) { 118 | recipientIds.add(audience.id) 119 | } 120 | } 121 | if (activity.bto) { 122 | for (const bto of activity.bto) { 123 | recipientIds.add(bto.id) 124 | } 125 | } 126 | if (activity.bcc) { 127 | for (const bcc of activity.bcc) { 128 | recipientIds.add(bcc.id) 129 | } 130 | } 131 | return recipientIds 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /lib/bot.js: -------------------------------------------------------------------------------- 1 | export default class Bot { 2 | #context = null 3 | #username = null 4 | 5 | constructor (username) { 6 | this.#username = username 7 | } 8 | 9 | async initialize (context) { 10 | this.#context = context 11 | } 12 | 13 | get fullname () { 14 | return 'Bot' 15 | } 16 | 17 | get description () { 18 | return 'A default, do-nothing bot.' 19 | } 20 | 21 | get username () { 22 | return this.#username 23 | } 24 | 25 | get _context () { 26 | return this.#context 27 | } 28 | 29 | async onMention (object, activity) { 30 | ; // no-op 31 | } 32 | 33 | async onFollow (actor, activity) { 34 | ; // no-op 35 | } 36 | 37 | async onLike (object, activity) { 38 | ; // no-op 39 | } 40 | 41 | async onAnnounce (object, activity) { 42 | ; // no-op 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /lib/botdatastorage.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | 3 | export class NoSuchValueError extends Error { 4 | constructor (username, key) { 5 | const message = `No such value ${key} for user ${username}` 6 | super(message) 7 | this.name = 'NoSuchValueError' 8 | } 9 | } 10 | 11 | export class BotDataStorage { 12 | #connection = null 13 | 14 | constructor (connection) { 15 | this.#connection = connection 16 | } 17 | 18 | async initialize () { 19 | await this.#connection.query(` 20 | CREATE TABLE IF NOT EXISTS botdata ( 21 | username VARCHAR(512) not null, 22 | key VARCHAR(512) not null, 23 | value TEXT not null, 24 | createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 25 | updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 26 | PRIMARY KEY (username, key) 27 | ) 28 | `) 29 | } 30 | 31 | async terminate () { 32 | 33 | } 34 | 35 | async set (username, key, value) { 36 | assert.ok(this.#connection, 'BotDataStorage not initialized') 37 | assert.ok(username, 'username is required') 38 | assert.equal(typeof username, 'string', 'username must be a string') 39 | assert.ok(key, 'key is required') 40 | assert.equal(typeof key, 'string', 'key must be a string') 41 | await this.#connection.query(` 42 | INSERT INTO botdata (value, username, key) VALUES (?, ?, ?) 43 | ON CONFLICT DO UPDATE SET value = EXCLUDED.value, updatedAt = CURRENT_TIMESTAMP`, 44 | { replacements: [JSON.stringify(value), username, key] } 45 | ) 46 | } 47 | 48 | async get (username, key) { 49 | assert.ok(this.#connection, 'BotDataStorage not initialized') 50 | assert.ok(username, 'username is required') 51 | assert.equal(typeof username, 'string', 'username must be a string') 52 | assert.ok(key, 'key is required') 53 | assert.equal(typeof key, 'string', 'key must be a string') 54 | const rows = await this.#connection.query(` 55 | SELECT value FROM botdata WHERE username = ? AND key = ?`, 56 | { replacements: [username, key] } 57 | ) 58 | if (rows[0].length === 0) { 59 | throw new NoSuchValueError(username, key) 60 | } 61 | return JSON.parse(rows[0][0].value) 62 | } 63 | 64 | async has (username, key) { 65 | assert.ok(this.#connection, 'BotDataStorage not initialized') 66 | assert.ok(username, 'username is required') 67 | assert.equal(typeof username, 'string', 'username must be a string') 68 | assert.ok(key, 'key is required') 69 | assert.equal(typeof key, 'string', 'key must be a string') 70 | const rows = await this.#connection.query(` 71 | SELECT count(*) as count FROM botdata WHERE username = ? AND key = ?`, 72 | { replacements: [username, key] } 73 | ) 74 | return (rows[0][0].count > 0) 75 | } 76 | 77 | async delete (username, key) { 78 | assert.ok(this.#connection, 'BotDataStorage not initialized') 79 | assert.ok(username, 'username is required') 80 | assert.equal(typeof username, 'string', 'username must be a string') 81 | assert.ok(key, 'key is required') 82 | await this.#connection.query(` 83 | DELETE FROM botdata WHERE username = ? AND key = ?`, 84 | { replacements: [username, key] } 85 | ) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /lib/bots/donothing.js: -------------------------------------------------------------------------------- 1 | import Bot from '../bot.js' 2 | 3 | export default class DoNothingBot extends Bot { 4 | get fullname () { 5 | return 'Do Nothing Bot' 6 | } 7 | 8 | get description () { 9 | return 'A bot that does nothing.' 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /lib/bots/ok.js: -------------------------------------------------------------------------------- 1 | import Bot from '../bot.js' 2 | 3 | export default class OKBot extends Bot { 4 | get fullname () { 5 | return 'OK Bot' 6 | } 7 | 8 | get description () { 9 | return 'A bot that says "OK" when mentioned.' 10 | } 11 | 12 | async onMention (object, activity) { 13 | if (!await this.hasSeen(object)) { 14 | const attributedTo = 15 | object.attributedTo?.first.id || 16 | activity.actor?.first.id 17 | const wf = await this._context.toWebfinger(attributedTo) 18 | this._context.logger.info({ object: object.id, attributedTo, wf }, 'received mention') 19 | const content = (wf) ? `@${wf} OK` : 'OK' 20 | const reply = await this._context.sendReply(content, object) 21 | this._context.logger.info({ 22 | reply: reply.id, 23 | content, 24 | inReplyTo: reply.inReplyTo.id 25 | }, 'sent reply') 26 | await this.setSeen(object) 27 | } 28 | } 29 | 30 | async hasSeen (object) { 31 | const id = object.id 32 | const key = `seen:${id}` 33 | return this._context.hasData(key) 34 | } 35 | 36 | async setSeen (object) { 37 | const id = object.id 38 | const key = `seen:${id}` 39 | return this._context.setData(key, true) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lib/digester.js: -------------------------------------------------------------------------------- 1 | import crypto from 'node:crypto' 2 | 3 | export class Digester { 4 | #logger 5 | constructor (logger) { 6 | this.#logger = logger.child({ class: this.constructor.name }) 7 | } 8 | 9 | async digest (body) { 10 | const digest = crypto.createHash('sha256') 11 | digest.update(body) 12 | return `sha-256=${digest.digest('base64')}` 13 | } 14 | 15 | equals (digest1, digest2) { 16 | const [alg1, hash1] = digest1.split('=', 2) 17 | const [alg2, hash2] = digest2.split('=', 2) 18 | if (alg1.toLowerCase() !== alg2.toLowerCase()) { 19 | return false 20 | } 21 | return hash1 === hash2 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /lib/httpsignature.js: -------------------------------------------------------------------------------- 1 | import createHttpError from 'http-errors' 2 | import crypto from 'node:crypto' 3 | import assert from 'node:assert' 4 | 5 | export class HTTPSignature { 6 | static #maxDateDiff = 5 * 60 * 1000 // 5 minutes 7 | #logger = null 8 | constructor (logger) { 9 | this.#logger = logger.child({ class: this.constructor.name }) 10 | } 11 | 12 | keyId (signature) { 13 | const params = this.#parseSignatureHeader(signature) 14 | if (!params.keyId) { 15 | throw createHttpError(401, 'No keyId provided') 16 | } 17 | return params.keyId 18 | } 19 | 20 | async validate (publicKeyPem, signature, method, path, headers) { 21 | if (!signature) { 22 | throw createHttpError(401, 'No signature provided') 23 | } 24 | if (!method) { 25 | throw createHttpError(400, 'No HTTP method provided') 26 | } 27 | if (!path) { 28 | throw createHttpError(400, 'No URL path provided') 29 | } 30 | if (!headers) { 31 | throw createHttpError(400, 'No request headers provided') 32 | } 33 | 34 | const params = this.#parseSignatureHeader(signature) 35 | 36 | const keyId = params.keyId 37 | if (!keyId) { 38 | throw createHttpError(401, 'No keyId provided') 39 | } 40 | this.#logger.debug({ keyId }, 'validating signature') 41 | const algorithm = params.algorithm 42 | if (!algorithm) { 43 | throw createHttpError(401, 'No algorithm provided') 44 | } 45 | this.#logger.debug({ algorithm }, 'validating signature') 46 | if (algorithm !== 'rsa-sha256') { 47 | throw createHttpError(401, 'Only rsa-sha256 is supported') 48 | } 49 | if (!params.headers) { 50 | throw createHttpError(401, 'No headers provided') 51 | } 52 | const signedHeaders = params.headers.split(' ') 53 | this.#logger.debug({ signedHeaders }, 'validating signature') 54 | const signatureString = params.signature 55 | if (!signatureString) { 56 | throw createHttpError(401, 'No signature field provided in signature header') 57 | } 58 | this.#logger.debug({ signatureString }, 'validating signature') 59 | const signingString = this.#signingString({ 60 | method, 61 | target: path, 62 | host: headers.host, 63 | headers, 64 | headersList: signedHeaders 65 | }) 66 | this.#logger.debug({ signingString }, 'validating signature') 67 | return this.#verify(publicKeyPem, signingString, signatureString) 68 | } 69 | 70 | async sign ({ privateKey, keyId, url, method, headers }) { 71 | assert.ok(privateKey) 72 | assert.equal(typeof privateKey, 'string') 73 | assert.ok(keyId) 74 | assert.equal(typeof keyId, 'string') 75 | assert.ok(url) 76 | assert.equal(typeof url, 'string') 77 | assert.ok(method) 78 | assert.equal(typeof method, 'string') 79 | assert.ok(headers) 80 | assert.equal(typeof headers, 'object') 81 | 82 | this.#logger.debug({ keyId, url, method, headers }, 'signing a request') 83 | 84 | const algorithm = 'rsa-sha256' 85 | const headersList = (method === 'POST') 86 | ? ['(request-target)', 'host', 'date', 'user-agent', 'content-type', 'digest'] 87 | : ['(request-target)', 'host', 'date', 'user-agent', 'accept'] 88 | 89 | this.#logger.debug({ algorithm, headersList }, 'signing a request') 90 | 91 | const parsed = new URL(url) 92 | const target = (parsed.search && parsed.search.length) 93 | ? `${parsed.pathname}${parsed.search}` 94 | : `${parsed.pathname}` 95 | const host = parsed.host 96 | 97 | this.#logger.debug({ parsed, target, host }, 'signing a request') 98 | 99 | const signingString = this.#signingString({ 100 | method, 101 | host, 102 | target, 103 | headers, 104 | headersList 105 | }) 106 | 107 | this.#logger.debug({ signingString }, 'signing a request') 108 | 109 | const signature = this.#signWithKey({ 110 | privateKey, 111 | signingString, 112 | algorithm 113 | }) 114 | 115 | this.#logger.debug({ signature }, 'signed a request') 116 | 117 | const signatureHeader = this.#signatureHeader({ keyId, headersList, signature, algorithm }) 118 | 119 | this.#logger.debug({ signatureHeader }, 'signed a request') 120 | return signatureHeader 121 | } 122 | 123 | #signWithKey ({ privateKey, signingString, algorithm }) { 124 | if (algorithm !== 'rsa-sha256') { 125 | throw new Error('Only rsa-sha256 is supported') 126 | } 127 | const signer = crypto.createSign('sha256') 128 | signer.update(signingString) 129 | const signature = signer.sign(privateKey).toString('base64') 130 | signer.end() 131 | 132 | return signature 133 | } 134 | 135 | #signingString ({ method, host, target, headers, headersList }) { 136 | const lines = [] 137 | const canon = {} 138 | for (const key in headers) { 139 | canon[key.toLowerCase()] = headers[key] 140 | } 141 | for (const headerName of headersList) { 142 | if (headerName === '(request-target)') { 143 | lines.push(`(request-target): ${method.toLowerCase()} ${target.trim()}`) 144 | } else if (headerName === 'host') { 145 | lines.push(`host: ${host.trim()}`) 146 | } else if (headerName in canon) { 147 | assert.ok(typeof canon[headerName] === 'string', `Header ${headerName} is not a string: ${canon[headerName]}`) 148 | lines.push(`${headerName}: ${canon[headerName].trim()}`) 149 | } else { 150 | throw new Error(`Missing header: ${headerName}`) 151 | } 152 | } 153 | 154 | return lines.join('\n') 155 | } 156 | 157 | #signatureHeader ({ keyId, headersList, signature, algorithm }) { 158 | const components = { 159 | keyId, 160 | headers: headersList.join(' '), 161 | signature, 162 | algorithm 163 | } 164 | const properties = ['keyId', 'headers', 'signature', 'algorithm'] 165 | 166 | const pairs = [] 167 | for (const prop of properties) { 168 | pairs.push(`${prop}="${this.#escape(components[prop])}"`) 169 | } 170 | 171 | return pairs.join(',') 172 | } 173 | 174 | #escape (value) { 175 | return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"') 176 | } 177 | 178 | #parseSignatureHeader (signature) { 179 | const parts = signature.split(',') 180 | const params = {} 181 | for (const part of parts) { 182 | const [key, value] = part.split('=') 183 | params[key] = value.replace(/"/g, '') 184 | } 185 | return params 186 | } 187 | 188 | #verify (publicKeyPem, signingString, signature) { 189 | const verifier = crypto.createVerify('sha256') 190 | verifier.update(signingString) 191 | const isValid = verifier.verify(publicKeyPem, signature, 'base64') 192 | verifier.end() 193 | return isValid 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /lib/httpsignatureauthenticator.js: -------------------------------------------------------------------------------- 1 | import createHttpError from 'http-errors' 2 | 3 | export class HTTPSignatureAuthenticator { 4 | static #maxDateDiff = 5 * 60 * 1000 // 5 minutes 5 | #remoteKeyStorage = null 6 | #logger = null 7 | #digester = null 8 | #signer = null 9 | constructor (remoteKeyStorage, signer, digester, logger) { 10 | this.#remoteKeyStorage = remoteKeyStorage 11 | this.#signer = signer 12 | this.#digester = digester 13 | this.#logger = logger.child({ class: this.constructor.name }) 14 | } 15 | 16 | async authenticate (req, res, next) { 17 | const signature = req.get('Signature') 18 | if (!signature) { 19 | // Just continue 20 | return next() 21 | } 22 | const date = req.get('Date') 23 | if (!date) { 24 | return next(createHttpError(400, 'No date provided')) 25 | } 26 | try { 27 | if (Math.abs(Date.parse(date) - Date.now()) > 28 | HTTPSignatureAuthenticator.#maxDateDiff) { 29 | return next(createHttpError(400, 'Time skew too large')) 30 | } 31 | } catch (err) { 32 | // for date parsing errors 33 | return next(err) 34 | } 35 | if (req.rawBodyText && req.rawBodyText.length > 0) { 36 | const digest = req.get('Digest') 37 | if (!digest) { 38 | return next(createHttpError(400, 'No digest provided')) 39 | } 40 | const calculated = await this.#digester.digest(req.rawBodyText) 41 | if (!this.#digester.equals(digest, calculated)) { 42 | this.#logger.debug(`calculated: ${calculated} digest: ${digest}`) 43 | return next(createHttpError(400, 'Digest mismatch')) 44 | } 45 | } 46 | const { method, headers } = req 47 | const originalUrl = req.originalUrl 48 | this.#logger.debug({ originalUrl }, 'original URL') 49 | try { 50 | const keyId = this.#signer.keyId(signature) 51 | const ok = await this.#remoteKeyStorage.getPublicKey(keyId) 52 | let owner = ok.owner 53 | let publicKeyPem = ok.publicKeyPem 54 | let result = await this.#signer.validate(publicKeyPem, signature, method, originalUrl, headers) 55 | this.#logger.debug(`First validation result: ${result}`) 56 | if (!result) { 57 | // May be key rotation. Try again with uncached key 58 | const ok2 = await this.#remoteKeyStorage.getPublicKey(keyId, false) 59 | if (ok2.publicKeyPem === ok.publicKeyPem) { 60 | this.#logger.debug('same keys') 61 | } else { 62 | this.#logger.debug('different keys') 63 | owner = ok2.owner 64 | publicKeyPem = ok2.publicKeyPem 65 | result = await this.#signer.validate(publicKeyPem, signature, method, originalUrl, headers) 66 | this.#logger.debug(`Validation result: ${result}`) 67 | } 68 | } 69 | if (result) { 70 | this.#logger.debug(`Signature valid for ${keyId}`) 71 | req.auth = req.auth || {} 72 | req.auth.subject = owner 73 | return next() 74 | } else { 75 | return next(createHttpError(401, 'Unauthorized')) 76 | } 77 | } catch (err) { 78 | return next(err) 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /lib/keystorage.js: -------------------------------------------------------------------------------- 1 | import { promisify } from 'util' 2 | import crypto from 'node:crypto' 3 | import HumanHasher from 'humanhash' 4 | import assert from 'node:assert' 5 | 6 | const generateKeyPair = promisify(crypto.generateKeyPair) 7 | 8 | export class KeyStorage { 9 | #connection = null 10 | #logger = null 11 | #hasher = null 12 | constructor (connection, logger) { 13 | assert.ok(connection, 'connection is required') 14 | assert.ok(logger, 'logger is required') 15 | this.#connection = connection 16 | this.#logger = logger.child({ class: this.constructor.name }) 17 | this.#hasher = new HumanHasher() 18 | } 19 | 20 | async initialize () { 21 | await this.#connection.query(` 22 | CREATE TABLE IF NOT EXISTS new_keys ( 23 | username varchar(512) PRIMARY KEY, 24 | public_key TEXT, 25 | private_key TEXT 26 | ) 27 | `) 28 | try { 29 | await this.#connection.query(` 30 | INSERT OR IGNORE INTO new_keys (username, public_key, private_key) 31 | SELECT bot_id, public_key, private_key 32 | FROM keys 33 | `) 34 | } catch (error) { 35 | this.#logger.debug( 36 | { error, method: 'KeyStorage.initialize' }, 37 | 'failed to copy keys to new_keys table') 38 | } 39 | } 40 | 41 | async getPublicKey (username) { 42 | this.#logger.debug( 43 | { username, method: 'KeyStorage.getPublicKey' }, 44 | 'getting public key for bot') 45 | const [publicKey] = await this.#getKeys(username) 46 | return publicKey 47 | } 48 | 49 | async getPrivateKey (username) { 50 | this.#logger.debug( 51 | { username, method: 'KeyStorage.getPrivateKey' }, 52 | 'getting private key for bot') 53 | const [, privateKey] = await this.#getKeys(username) 54 | return privateKey 55 | } 56 | 57 | async #getKeys (username) { 58 | let privateKey 59 | let publicKey 60 | const [result] = await this.#connection.query(` 61 | SELECT public_key, private_key FROM new_keys WHERE username = ? 62 | `, { replacements: [username] }) 63 | if (result.length > 0) { 64 | this.#logger.debug( 65 | { username, method: 'KeyStorage.#getKeys' }, 66 | 'found key for bot in database') 67 | publicKey = result[0].public_key 68 | privateKey = result[0].private_key 69 | } else { 70 | this.#logger.debug( 71 | { username, method: 'KeyStorage.#getKeys' }, 72 | 'no key for bot, generating new key' 73 | ); 74 | [publicKey, privateKey] = await this.#newKeyPair(username) 75 | this.#logger.debug( 76 | { username, method: 'KeyStorage.#getKeys' }, 77 | 'saving new keypair to database' 78 | ) 79 | await this.#saveKeyPair(username, publicKey, privateKey) 80 | } 81 | this.#logger.debug({ 82 | username, 83 | method: 'KeyStorage.#getKeys', 84 | publicKeyHash: this.#hasher.humanize(publicKey), 85 | privateKeyHash: this.#hasher.humanize(privateKey) 86 | }) 87 | return [publicKey, privateKey] 88 | } 89 | 90 | async #newKeyPair (username) { 91 | const { publicKey, privateKey } = await generateKeyPair( 92 | 'rsa', 93 | { 94 | modulusLength: 2048, 95 | privateKeyEncoding: { 96 | type: 'pkcs8', 97 | format: 'pem' 98 | }, 99 | publicKeyEncoding: { 100 | type: 'spki', 101 | format: 'pem' 102 | } 103 | } 104 | ) 105 | return [publicKey, privateKey] 106 | } 107 | 108 | async #saveKeyPair (username, publicKey, privateKey) { 109 | await this.#connection.query(` 110 | INSERT INTO new_keys (username, public_key, private_key) VALUES (?, ?, ?) 111 | `, { replacements: [username, publicKey, privateKey] }) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /lib/microsyntax.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | import fetch from 'node-fetch' 3 | 4 | const AS2 = 'https://www.w3.org/ns/activitystreams#' 5 | 6 | export class Transformer { 7 | #tagNamespace = null 8 | #client = null 9 | constructor (tagNamespace, client) { 10 | this.#tagNamespace = tagNamespace 11 | this.#client = client 12 | assert.ok(this.#tagNamespace) 13 | assert.ok(this.#client) 14 | } 15 | 16 | async transform (text) { 17 | let html = text 18 | let tag = []; 19 | ({ html, tag } = this.#replaceUrls(html, tag)); 20 | ({ html, tag } = this.#replaceHashtags(html, tag)); 21 | ({ html, tag } = await this.#replaceMentions(html, tag)) 22 | html = `

${html}

` 23 | return { html, tag } 24 | } 25 | 26 | #replaceUrls (html, tag) { 27 | const url = /https?:\/\/\S+/g 28 | const segments = this.#segment(html) 29 | for (const i in segments) { 30 | const segment = segments[i] 31 | if (this.#isLink(segment)) continue 32 | segments[i] = segment.replace(url, (match) => { 33 | return `${match}` 34 | }) 35 | } 36 | return { html: segments.join(''), tag } 37 | } 38 | 39 | #replaceHashtags (html, tag) { 40 | const hashtag = /#(\w+)/g 41 | const segments = this.#segment(html) 42 | for (const i in segments) { 43 | const segment = segments[i] 44 | if (this.#isLink(segment)) continue 45 | segments[i] = segment.replace(hashtag, (match, name) => { 46 | const href = this.#tagNamespace + name 47 | tag.push({ type: AS2 + 'Hashtag', name: match, href }) 48 | return `${match}` 49 | }) 50 | } 51 | return { html: segments.join(''), tag } 52 | } 53 | 54 | async #replaceMentions (html, tag) { 55 | const self = this 56 | const webfinger = /@[a-zA-Z0-9_]+([a-zA-Z0-9_.-]+[a-zA-Z0-9_]+)?@[a-zA-Z0-9_.-]+/g 57 | const segments = this.#segment(html) 58 | for (const i in segments) { 59 | const segment = segments[i] 60 | if (this.#isLink(segment)) continue 61 | segments[i] = await this.#replaceAsync(segments[i], webfinger, async (match) => { 62 | const href = await self.#homePage(match.slice(1)) 63 | if (!href) return match 64 | tag.push({ type: 'Mention', name: match, href }) 65 | return `${match}` 66 | }) 67 | } 68 | return { html: segments.join(''), tag } 69 | } 70 | 71 | async #homePage (webfinger) { 72 | if (!this.#client) return null 73 | const [username, domain] = webfinger.split('@') 74 | const url = `https://${domain}/.well-known/webfinger?` + 75 | `resource=acct:${username}@${domain}` 76 | let json = null 77 | try { 78 | const response = await fetch(url, 79 | { headers: { Accept: 'application/jrd+json' } }) 80 | if (response.status !== 200) return null 81 | json = await response.json() 82 | } catch (error) { 83 | return null 84 | } 85 | if (!json.links) return null 86 | const link = json.links.find( 87 | link => link.rel === 'self' && 88 | (link.type === 'application/activity+json' || 89 | link.type === 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"')) 90 | if (!link) return null 91 | const actorId = link.href 92 | if (!actorId) return null 93 | let actor = null 94 | try { 95 | actor = await this.#client.get(actorId) 96 | } catch (error) { 97 | console.error(error) 98 | return null 99 | } 100 | if (!actor) return null 101 | if (!actor.url) { 102 | return actorId 103 | } 104 | for (const url of actor.url) { 105 | if (url.type === AS2 + 'Link' && 106 | url.mediaType === 'text/html' && 107 | url.href) { 108 | return url.href 109 | } 110 | } 111 | // Fallback to first URL 112 | if (actor.url.length === 1 && !actor.url.first.type) { 113 | return actor.url.first.id 114 | } 115 | // Fallback even further to actor ID 116 | return actorId 117 | } 118 | 119 | #isLink (segment) { 120 | return (segment.startsWith('') || segment.startsWith('') 121 | } 122 | 123 | #segment (html) { 124 | return html.split(/(<[^>]+>[^<]+<\/[^>]+>)/) 125 | } 126 | 127 | async #replaceAsync (str, regex, asyncFn) { 128 | const promises = [] 129 | str.replace(regex, (match, ...args) => { 130 | // Add a promise for each match 131 | promises.push(asyncFn(match, ...args)) 132 | }) 133 | 134 | // Wait for all async replacements to resolve 135 | const replacements = await Promise.all(promises) 136 | 137 | // Replace the matches with their respective replacements 138 | return str.replace(regex, () => replacements.shift()) 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /lib/objectcache.js: -------------------------------------------------------------------------------- 1 | import TTLCache from '@isaacs/ttlcache' 2 | 3 | export class ObjectCache { 4 | #objects = null 5 | #members = null 6 | constructor ({ longTTL, shortTTL, maxItems }) { 7 | this.#objects = new TTLCache({ ttl: shortTTL, max: maxItems }) 8 | this.#members = new TTLCache({ ttl: shortTTL, max: maxItems }) 9 | this.longTTL = longTTL 10 | this.shortTTL = shortTTL 11 | this.maxItems = maxItems 12 | } 13 | 14 | async initialize () { 15 | } 16 | 17 | async get (id) { 18 | return this.#objects.get(id) 19 | } 20 | 21 | async save (object) { 22 | return this.#objects.set(object.id, object, { ttl: this.longTTL }) 23 | } 24 | 25 | async saveReceived (object) { 26 | return this.#objects.set(object.id, object, { ttl: this.shortTTL }) 27 | } 28 | 29 | async clear (object) { 30 | return this.#objects.delete(object.id) 31 | } 32 | 33 | membershipKey (collection, object) { 34 | return `${collection.id}:${object.id}` 35 | } 36 | 37 | async saveMembership (collection, object, isMember = true) { 38 | return this.#members.set(this.membershipKey(collection, object), isMember, { ttl: this.longTTL }) 39 | } 40 | 41 | async saveMembershipReceived (collection, object, isMember = true) { 42 | return this.#members.set(this.membershipKey(collection, object), isMember, { ttl: this.shortTTL }) 43 | } 44 | 45 | async isMember (collection, object) { 46 | return this.#members.get(this.membershipKey(collection, object)) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /lib/objectstorage.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | import as2 from 'activitystrea.ms' 3 | 4 | export class NoSuchObjectError extends Error { 5 | constructor (id) { 6 | const message = `No such object: ${id}` 7 | super(message) 8 | this.name = 'NoSuchObjectError' 9 | } 10 | } 11 | 12 | export class ObjectStorage { 13 | #connection = null 14 | static #MAX_ITEMS_PER_PAGE = 20 15 | 16 | constructor (connection) { 17 | this.#connection = connection 18 | } 19 | 20 | async initialize () { 21 | await this.#connection.query(` 22 | CREATE TABLE IF NOT EXISTS objects ( 23 | id VARCHAR(512) PRIMARY KEY, 24 | data TEXT NOT NULL, 25 | createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 26 | updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP 27 | ) 28 | `) 29 | await this.#connection.query(` 30 | CREATE TABLE IF NOT EXISTS collections ( 31 | id VARCHAR(512) NOT NULL, 32 | property VARCHAR(512) NOT NULL, 33 | first INTEGER NOT NULL, 34 | createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 35 | updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 36 | PRIMARY KEY (id, property) 37 | ) 38 | `) 39 | await this.#connection.query(` 40 | CREATE TABLE IF NOT EXISTS pages ( 41 | id VARCHAR(512) NOT NULL, 42 | property VARCHAR(64) NOT NULL, 43 | item VARCHAR(512) NOT NULL, 44 | page INTEGER NOT NULL, 45 | createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 46 | PRIMARY KEY (id, property, item) 47 | ) 48 | `) 49 | 50 | await this.#connection.query( 51 | `CREATE INDEX IF NOT EXISTS pages_username_property_page 52 | ON pages (id, property, page);` 53 | ) 54 | } 55 | 56 | async create (object) { 57 | assert.ok(this.#connection, 'ObjectStorage not initialized') 58 | assert.ok(object, 'object is required') 59 | assert.ok(object.id, 'object.id is required') 60 | const id = object.id 61 | const data = await object.write() 62 | assert.ok(data, 'object is required') 63 | assert.ok(typeof data === 'string', 'data must be a string') 64 | await this.#connection.query( 65 | 'INSERT INTO objects (id, data) VALUES (?, ?)', 66 | { replacements: [id, data] } 67 | ) 68 | } 69 | 70 | async read (id) { 71 | assert.ok(this.#connection, 'ObjectStorage not initialized') 72 | assert.ok(id, 'id is required') 73 | const data = await this.#connection.query( 74 | 'SELECT data FROM objects WHERE id = ?', 75 | { replacements: [id] } 76 | ) 77 | if (data[0].length === 0) { 78 | throw new NoSuchObjectError(id) 79 | } 80 | return await as2.import(JSON.parse(data[0][0].data)) 81 | } 82 | 83 | async update (object) { 84 | assert.ok(this.#connection, 'ObjectStorage not initialized') 85 | assert.ok(object, 'object is required') 86 | assert.ok(object.id, 'object.id is required') 87 | const id = object.id 88 | const data = await object.write() 89 | assert.ok(data, 'object is required') 90 | assert.ok(typeof data === 'string', 'data must be a string') 91 | await this.#connection.query( 92 | 'UPDATE objects SET data = ?, updatedAt = CURRENT_TIMESTAMP WHERE id = ?', 93 | { replacements: [data, id] } 94 | ) 95 | } 96 | 97 | async delete (object) { 98 | assert.ok(this.#connection, 'ObjectStorage not initialized') 99 | assert.ok(object, 'object is required') 100 | assert.ok(object.id, 'object.id is required') 101 | const id = object.id 102 | await this.#connection.query('DELETE FROM objects WHERE id = ?', { 103 | replacements: [id] 104 | }) 105 | } 106 | 107 | async #collectionInfo (id, property) { 108 | assert.ok(id, 'id is required') 109 | assert.equal(typeof id, 'string', 'id must be a string') 110 | assert.ok(property, 'property is required') 111 | assert.equal(typeof property, 'string', 'property must be a string') 112 | let totalItems = 0 113 | let first = 1 114 | let createdAt = null 115 | const row = await this.#connection.query( 116 | 'SELECT first, createdAt FROM collections WHERE id = ? AND property = ?', 117 | { replacements: [id, property] } 118 | ) 119 | if (row[0].length > 0) { 120 | first = row[0][0].first 121 | createdAt = row[0][0].createdAt 122 | } 123 | const count = await this.#connection.query( 124 | 'SELECT COUNT(*) FROM pages WHERE id = ? AND property = ?', 125 | { replacements: [id, property] } 126 | ) 127 | if (count[0].length > 0) { 128 | totalItems = count[0][0]['COUNT(*)'] 129 | } 130 | assert.equal(typeof totalItems, 'number', 'totalItems must be a number') 131 | assert.ok(totalItems >= 0, 'totalItems must be greater than or equal to 0') 132 | assert.equal(typeof first, 'number', 'first must be a number') 133 | assert.ok(first >= 1, 'first must be greater than or equal to 1') 134 | return [totalItems, first, createdAt] 135 | } 136 | 137 | async getCollection (id, property) { 138 | assert.ok(this.#connection, 'ObjectStorage not initialized') 139 | assert.ok(id, 'id is required') 140 | assert.ok(property, 'property is required') 141 | const [totalItems, first, createdAt] = await this.#collectionInfo( 142 | id, 143 | property 144 | ) 145 | const collection = await as2.import({ 146 | id: `${id}/${property}`, 147 | type: 'OrderedCollection', 148 | totalItems, 149 | first: `${id}/${property}/${first}`, 150 | last: `${id}/${property}/1`, 151 | published: createdAt 152 | }) 153 | assert.ok(collection, 'collection is required') 154 | assert.equal(typeof collection, 'object', 'collection must be an object') 155 | return collection 156 | } 157 | 158 | async getCollectionPage (id, property, page) { 159 | assert.ok(this.#connection, 'ObjectStorage not initialized') 160 | assert.ok(id, 'id is required') 161 | assert.equal(typeof id, 'string', 'id must be a string') 162 | assert.ok(property, 'property is required') 163 | assert.equal(typeof property, 'string', 'property must be a string') 164 | assert.ok(page, 'pageNo is required') 165 | assert.equal(typeof page, 'number', 'page must be a number') 166 | assert.ok(page >= 1, 'page must be greater than or equal to 1') 167 | 168 | const [, first] = await this.#collectionInfo( 169 | id, 170 | property 171 | ) 172 | 173 | if (page > first) { 174 | throw new NoSuchObjectError(`${id}/${property}/${page}`) 175 | } 176 | 177 | let items = [] 178 | 179 | const rows = await this.#connection.query( 180 | 'SELECT item FROM pages WHERE id = ? AND property = ? and page = ? ORDER BY createdAt ASC', 181 | { replacements: [id, property, page] } 182 | ) 183 | 184 | if (rows[0].length > 0) { 185 | items = rows[0].map((row) => row.item) 186 | } 187 | 188 | const pageObject = await as2.import({ 189 | id: `${id}/${property}/${page}`, 190 | type: 'OrderedCollectionPage', 191 | partOf: `${id}/${property}`, 192 | next: page === 1 ? null : `${id}/${property}/${page - 1}`, 193 | prev: page === first ? null : `${id}/${property}/${page + 1}`, 194 | items 195 | }) 196 | 197 | assert.ok(pageObject, 'collection is required') 198 | assert.equal(typeof pageObject, 'object', 'collection must be an object') 199 | 200 | return pageObject 201 | } 202 | 203 | async addToCollection (id, property, object) { 204 | assert.ok(this.#connection, 'ObjectStorage not initialized') 205 | assert.ok(id, 'id is required') 206 | assert.equal(typeof id, 'string', 'id must be a string') 207 | assert.ok(property, 'property is required') 208 | assert.equal(typeof property, 'string', 'property must be a string') 209 | assert.ok(object, 'object is required') 210 | assert.equal(typeof object, 'object', 'object must be an object') 211 | 212 | const [, first, createdAt] = await this.#collectionInfo( 213 | id, 214 | property 215 | ) 216 | 217 | if (createdAt === null) { 218 | await this.#connection.query( 219 | 'INSERT INTO collections (id, property, first) VALUES (?, ?, 1)', 220 | { replacements: [id, property] } 221 | ) 222 | } 223 | 224 | const count = await this.#itemCount(id, property, first) 225 | 226 | const page = count >= ObjectStorage.#MAX_ITEMS_PER_PAGE ? first + 1 : first 227 | 228 | if (page > first) { 229 | await this.#connection.query( 230 | 'UPDATE collections SET first = ?, updatedAt = CURRENT_TIMESTAMP WHERE id = ? AND property = ?', 231 | { replacements: [page, id, property] } 232 | ) 233 | } 234 | 235 | await this.#connection.query( 236 | 'INSERT INTO pages (id, property, item, page) VALUES (?, ?, ?, ?)', 237 | { replacements: [id, property, object.id, page] } 238 | ) 239 | } 240 | 241 | async #itemCount (id, property, page) { 242 | assert.ok(this.#connection, 'ObjectStorage not initialized') 243 | assert.ok(id, 'id is required') 244 | assert.equal(typeof id, 'string', 'id must be a string') 245 | assert.ok(property, 'property is required') 246 | assert.equal(typeof property, 'string', 'property must be a string') 247 | assert.ok(page, 'page is required') 248 | assert.equal(typeof page, 'number', 'page must be a number') 249 | assert.ok(page >= 1, 'page must be greater than or equal to 1') 250 | const rows = await this.#connection.query( 251 | 'SELECT COUNT(*) FROM pages WHERE id = ? AND property = ? AND page = ?', 252 | { replacements: [id, property, page] } 253 | ) 254 | return rows[0][0]['COUNT(*)'] 255 | } 256 | 257 | async removeFromCollection (id, property, object) { 258 | assert.ok(this.#connection, 'ObjectStorage not initialized') 259 | assert.ok(id, 'id is required') 260 | assert.equal(typeof id, 'string', 'id must be a string') 261 | assert.ok(property, 'property is required') 262 | assert.equal(typeof property, 'string', 'property must be a string') 263 | assert.ok(object, 'object is required') 264 | assert.equal(typeof object, 'object', 'object must be an object') 265 | 266 | await this.#connection.query( 267 | 'DELETE FROM pages WHERE id = ? AND property = ? AND item = ?', 268 | { replacements: [id, property, object.id] } 269 | ) 270 | } 271 | 272 | async isInCollection (id, property, object) { 273 | assert.ok(this.#connection, 'ObjectStorage not initialized') 274 | assert.ok(id, 'id is required') 275 | assert.equal(typeof id, 'string', 'id must be a string') 276 | assert.ok(property, 'property is required') 277 | assert.equal(typeof property, 'string', 'property must be a string') 278 | assert.ok(object, 'object is required') 279 | assert.equal(typeof object, 'object', 'object must be an object') 280 | assert.ok(object.id, 'object.id is required') 281 | assert.equal(typeof object.id, 'string', 'object.id must be a string') 282 | const [result] = await this.#connection.query( 283 | `SELECT COUNT(*) as item_count 284 | FROM pages 285 | WHERE id = ? AND property = ? AND item = ?`, 286 | { replacements: [id, property, object.id] } 287 | ) 288 | return result[0].item_count > 0 289 | } 290 | 291 | async * items (id, property) { 292 | assert.ok(id, 'username is required') 293 | assert.equal(typeof id, 'string', 'username must be a string') 294 | assert.ok(property, 'property is required') 295 | assert.equal(typeof property, 'string', 'property must be a string') 296 | 297 | const result = await this.#connection.query( 298 | `SELECT item 299 | FROM pages 300 | WHERE id = ? AND property = ? 301 | ORDER BY page DESC, createdAt DESC;`, 302 | { replacements: [id, property] } 303 | ) 304 | for (const row of result[0]) { 305 | yield as2.import({ id: row.item }) 306 | } 307 | } 308 | } 309 | -------------------------------------------------------------------------------- /lib/remotekeystorage.js: -------------------------------------------------------------------------------- 1 | const SEC_NS = 'https://w3id.org/security#' 2 | 3 | export class RemoteKeyStorage { 4 | #client = null 5 | #connection = null 6 | #logger = null 7 | constructor (client, connection, logger) { 8 | this.#client = client 9 | this.#connection = connection 10 | this.#logger = logger.child({ class: this.constructor.name }) 11 | } 12 | 13 | async initialize () { 14 | await this.#connection.query( 15 | `CREATE TABLE IF NOT EXISTS new_remotekeys ( 16 | id VARCHAR(512) PRIMARY KEY, 17 | owner VARCHAR(512) NOT NULL, 18 | publicKeyPem TEXT NOT NULL, 19 | createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 20 | updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP 21 | )` 22 | ) 23 | try { 24 | await this.#connection.query( 25 | `INSERT OR IGNORE INTO new_remotekeys (id, owner, publicKeyPem) 26 | SELECT id, owner, publicKeyPem 27 | FROM remotekeys` 28 | ) 29 | } catch (error) { 30 | this.#logger.debug( 31 | { error, method: 'RemoteKeyStorage.initialize' }, 32 | 'failed to copy remotekeys to new_remotekeys table' 33 | ) 34 | } 35 | } 36 | 37 | async getPublicKey (id, useCache = true) { 38 | this.debug(`getPublicKey(${id})`) 39 | if (useCache) { 40 | this.debug('using cache') 41 | const cached = await this.#getCachedPublicKey(id) 42 | if (cached) { 43 | this.debug(`getPublicKey(${id}) - cached`) 44 | return cached 45 | } 46 | } else { 47 | this.debug('skipping cache') 48 | } 49 | const remote = await this.#getRemotePublicKey(id) 50 | if (!remote) { 51 | this.debug(`getPublicKey(${id}) - remote not found`) 52 | return null 53 | } 54 | await this.#cachePublicKey(id, remote.owner, remote.publicKeyPem) 55 | return remote 56 | } 57 | 58 | async #getCachedPublicKey (id) { 59 | this.debug(`#getCachedPublicKey(${id})`) 60 | const [result] = await this.#connection.query( 61 | 'SELECT publicKeyPem, owner FROM new_remotekeys WHERE id = ?', 62 | { replacements: [id] } 63 | ) 64 | if (result.length > 0) { 65 | this.debug(`cache hit for ${id}`) 66 | return { 67 | publicKeyPem: result[0].publicKeyPem, 68 | owner: result[0].owner 69 | } 70 | } else { 71 | this.debug(`cache miss for ${id}`) 72 | return null 73 | } 74 | } 75 | 76 | async #getRemotePublicKey (id) { 77 | this.debug(`#getRemotePublicKey(${id})`) 78 | const response = await this.#client.getKey(id) 79 | if (!response) { 80 | return null 81 | } 82 | this.debug(`getRemotePublicKey(${id}) - response: ${await response.id}`) 83 | let owner = null 84 | let publicKeyPem = null 85 | if (response.get(SEC_NS + 'publicKeyPem')) { 86 | this.debug(`getRemotePublicKey(${id}) - publicKeyPem`) 87 | owner = response.get(SEC_NS + 'owner')?.first?.id 88 | publicKeyPem = response.get(SEC_NS + 'publicKeyPem')?.first 89 | } else if (response.get(SEC_NS + 'publicKey')) { 90 | this.debug(`getRemotePublicKey(${id}) - publicKey`) 91 | const publicKey = response.get(SEC_NS + 'publicKey').first 92 | if (publicKey) { 93 | owner = publicKey.get(SEC_NS + 'owner')?.first?.id 94 | publicKeyPem = publicKey.get(SEC_NS + 'publicKeyPem')?.first 95 | } 96 | } 97 | if (!owner || !publicKeyPem) { 98 | return null 99 | } 100 | return { owner, publicKeyPem } 101 | } 102 | 103 | async #cachePublicKey (id, owner, publicKeyPem) { 104 | await this.#connection.query( 105 | 'INSERT INTO new_remotekeys (id, owner, publicKeyPem) VALUES (?, ?, ?) ' + ' ON CONFLICT(id) DO UPDATE ' + 106 | 'SET owner=excluded.owner, publicKeyPem = excluded.publicKeyPem;', 107 | { replacements: [id, owner, publicKeyPem] } 108 | ) 109 | } 110 | 111 | debug (...args) { 112 | if (this.#logger) { 113 | this.#logger.debug(...args) 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /lib/routes/collection.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import as2 from 'activitystrea.ms' 3 | import createHttpError from 'http-errors' 4 | 5 | const router = express.Router() 6 | 7 | async function filterAsync (array, asyncPredicate) { 8 | // 1. Kick off all predicate calls in parallel: 9 | const checks = array.map(item => asyncPredicate(item)) 10 | 11 | // 2. Wait for all to settle into [true, false, …]: 12 | const booleans = await Promise.all(checks) 13 | 14 | // 3. Pick only those whose boolean was true: 15 | return array.filter((_, idx) => booleans[idx]) 16 | } 17 | 18 | router.get('/user/:username/:collection', async (req, res, next) => { 19 | const { username, collection } = req.params 20 | const { actorStorage, bots } = req.app.locals 21 | if (!(username in bots)) { 22 | return next(createHttpError(404, `User ${username} not found`)) 23 | } 24 | if (collection === 'inbox') { 25 | return next(createHttpError(403, `No access to ${collection} collection`)) 26 | } 27 | if (!['outbox', 'liked', 'followers', 'following'].includes(req.params.collection)) { 28 | return next(createHttpError(404, 29 | `No such collection ${collection} for user ${username}`)) 30 | } 31 | const coll = await actorStorage.getCollection(username, collection) 32 | res.status(200) 33 | res.type(as2.mediaType) 34 | res.end(await coll.prettyWrite()) 35 | }) 36 | 37 | router.get('/user/:username/:collection/:n(\\d+)', async (req, res, next) => { 38 | const { username, collection, n } = req.params 39 | const { actorStorage, bots, authorizer, objectStorage, formatter, client } = req.app.locals 40 | 41 | if (!(username in bots)) { 42 | return next(createHttpError(404, `User ${username} not found`)) 43 | } 44 | if (collection === 'inbox') { 45 | return next(createHttpError(403, `No access to ${collection} collection`)) 46 | } 47 | if (!['outbox', 'liked', 'followers', 'following'].includes(collection)) { 48 | return next(createHttpError(404, 49 | `No such collection ${collection} for user ${username}`)) 50 | } 51 | if (!await actorStorage.hasPage(username, collection, parseInt(n))) { 52 | return next(createHttpError(404, `No such page ${n} for collection ${collection} for user ${username}`)) 53 | } 54 | 55 | let exported = null 56 | 57 | try { 58 | const id = req.auth?.subject 59 | const remote = (id) ? await as2.import({ id }) : null 60 | const page = await actorStorage.getCollectionPage( 61 | username, 62 | collection, 63 | parseInt(n) 64 | ) 65 | exported = await page.export() 66 | 67 | if (['outbox', 'liked'].includes(collection)) { 68 | exported.items = await filterAsync(exported.items, async (id) => { 69 | const object = (formatter.isLocal(id)) 70 | ? await objectStorage.read(id) 71 | : await client.get(id) 72 | if (!object) { 73 | req.log.warn({ id }, 'could not load object') 74 | return false 75 | } 76 | req.log.debug({ id, object }, 'loaded object') 77 | return await authorizer.canRead(remote, object) 78 | }) 79 | } 80 | } catch (error) { 81 | req.log.error( 82 | { err: error, username, collection, n }, 83 | 'error loading collection page' 84 | ) 85 | return next(createHttpError(500, 'Error loading collection page')) 86 | } 87 | res.status(200) 88 | res.type(as2.mediaType) 89 | res.end(JSON.stringify(exported)) 90 | }) 91 | 92 | export default router 93 | -------------------------------------------------------------------------------- /lib/routes/health.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import createHttpError from 'http-errors' 3 | 4 | const router = express.Router() 5 | 6 | router.get('/livez', async (req, res) => { 7 | res.status(200) 8 | res.type('text/plain') 9 | res.end('OK') 10 | }) 11 | 12 | router.get('/readyz', async (req, res, next) => { 13 | const connection = req.app.locals.connection 14 | try { 15 | await connection.query('SELECT 1') 16 | res.status(200) 17 | res.type('text/plain') 18 | res.end('OK') 19 | } catch (err) { 20 | return next(createHttpError(503, 'Service Unavailable')) 21 | } 22 | }) 23 | 24 | export default router 25 | -------------------------------------------------------------------------------- /lib/routes/inbox.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import as2 from 'activitystrea.ms' 3 | import createHttpError from 'http-errors' 4 | 5 | const router = express.Router() 6 | 7 | function isActivity (object) { 8 | return true 9 | } 10 | 11 | function getActor (activity) { 12 | return activity.actor?.first 13 | } 14 | 15 | router.post('/user/:username/inbox', async (req, res, next) => { 16 | const { username } = req.params 17 | const { bots, actorStorage, activityHandler } = req.app.locals 18 | const { subject } = req.auth 19 | const { logger } = req.app.locals 20 | 21 | const bot = bots[username] 22 | if (!bot) { 23 | return next(createHttpError(404, 'Not Found')) 24 | } 25 | 26 | if (!subject) { 27 | return next(createHttpError(401, 'Unauthorized')) 28 | } 29 | 30 | if (!req.body) { 31 | return next(createHttpError(400, 'No request body provided')) 32 | } 33 | 34 | let activity 35 | 36 | try { 37 | activity = await as2.import(req.body) 38 | } catch (err) { 39 | logger.warn('Failed to import activity', err) 40 | logger.debug('Request body', req.body) 41 | return next(createHttpError(400, 'Invalid request body')) 42 | } 43 | 44 | if (!isActivity(activity)) { 45 | return next(createHttpError(400, 'Request body is not an activity')) 46 | } 47 | 48 | const actor = getActor(activity) 49 | 50 | if (!actor) { 51 | return next(createHttpError(400, 'No actor found in activity')) 52 | } 53 | 54 | if (!actor.id) { 55 | return next(createHttpError(400, 'No actor id found in activity')) 56 | } 57 | 58 | if (actor.id !== subject) { 59 | return next(createHttpError(403, `${subject} is not the actor ${actor.id}`)) 60 | } 61 | 62 | if (await actorStorage.isInCollection(username, 'blocked', actor)) { 63 | return next(createHttpError(403, 'Forbidden')) 64 | } 65 | 66 | if (await actorStorage.isInCollection(bot.username, 'inbox', activity)) { 67 | return next(createHttpError(400, 'Activity already delivered')) 68 | } 69 | 70 | try { 71 | await activityHandler.handleActivity(bot, activity) 72 | } catch (err) { 73 | return next(err) 74 | } 75 | 76 | await actorStorage.addToCollection(bot.username, 'inbox', activity) 77 | 78 | res.status(200) 79 | res.type('text/plain') 80 | res.send('OK') 81 | }) 82 | 83 | export default router 84 | -------------------------------------------------------------------------------- /lib/routes/object.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import as2 from 'activitystrea.ms' 3 | import createHttpError from 'http-errors' 4 | 5 | const router = express.Router() 6 | 7 | export default router 8 | 9 | router.get('/user/:username/:type/:nanoid([A-Za-z0-9_\\-]{21})', async (req, res, next) => { 10 | const { username, type, nanoid } = req.params 11 | const { objectStorage, formatter, authorizer } = req.app.locals 12 | const id = formatter.format({ username, type, nanoid }) 13 | const object = await objectStorage.read(id) 14 | if (!object) { 15 | return next(createHttpError(404, `Object ${id} not found`)) 16 | } 17 | const remote = (req.auth?.subject) ? await as2.import({ id: req.auth.subject }) : null 18 | if (!await authorizer.canRead(remote, object)) { 19 | return next(createHttpError(403, `Forbidden to read object ${id}`)) 20 | } 21 | res.status(200) 22 | res.type(as2.mediaType) 23 | res.end(await object.prettyWrite()) 24 | }) 25 | 26 | router.get('/user/:username/:type/:nanoid([A-Za-z0-9_\\-]{21})/:collection', async (req, res, next) => { 27 | const { objectStorage, formatter, authorizer } = req.app.locals 28 | if (!['replies', 'likes', 'shares'].includes(req.params.collection)) { 29 | return next(createHttpError(404, 'Not Found')) 30 | } 31 | const id = formatter.format({ username: req.params.username, type: req.params.type, nanoid: req.params.nanoid }) 32 | const object = await objectStorage.read(id) 33 | if (!object) { 34 | return next(createHttpError(404, 'Not Found')) 35 | } 36 | const remote = (req.auth?.subject) ? await as2.import({ id: req.auth.subject }) : null 37 | if (!await authorizer.canRead(remote, object)) { 38 | return next(createHttpError(403, 'Forbidden')) 39 | } 40 | const collection = await objectStorage.getCollection(id, req.params.collection) 41 | res.status(200) 42 | res.type(as2.mediaType) 43 | res.end(await collection.prettyWrite()) 44 | }) 45 | 46 | router.get('/user/:username/:type/:nanoid([A-Za-z0-9_\\-]{21})/:collection/:n(\\d+)', async (req, res, next) => { 47 | const { username, type, nanoid, collection, n } = req.params 48 | const { objectStorage, formatter, authorizer } = req.app.locals 49 | if (!['replies', 'likes', 'shares'].includes(req.params.collection)) { 50 | return next(createHttpError(404, 'Not Found')) 51 | } 52 | const id = formatter.format({ username, type, nanoid }) 53 | const object = await objectStorage.read(id) 54 | if (!object) { 55 | return next(createHttpError(404, 'Not Found')) 56 | } 57 | const remote = (req.auth?.subject) ? await as2.import({ id: req.auth.subject }) : null 58 | if (!await authorizer.canRead(remote, object)) { 59 | return next(createHttpError(403, 'Forbidden')) 60 | } 61 | const collectionPage = await objectStorage.getCollectionPage(id, collection, parseInt(n)) 62 | const exported = await collectionPage.export() 63 | if (!Array.isArray(exported.items)) { 64 | exported.items = [exported.items] 65 | } 66 | res.status(200) 67 | res.type(as2.mediaType) 68 | res.json(exported) 69 | }) 70 | -------------------------------------------------------------------------------- /lib/routes/server.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import as2 from 'activitystrea.ms' 3 | 4 | const router = express.Router() 5 | 6 | router.get('/', async (req, res) => { 7 | const { formatter } = req.app.locals 8 | const server = await as2.import({ 9 | '@context': [ 10 | 'https://www.w3.org/ns/activitystreams', 11 | 'https://w3id.org/security/v1' 12 | ], 13 | id: formatter.format({ server: true }), 14 | type: 'Service', 15 | publicKey: formatter.format({ server: true, type: 'publickey' }) 16 | }) 17 | res.status(200) 18 | res.type(as2.mediaType) 19 | const body = await server.prettyWrite( 20 | { additional_context: 'https://w3id.org/security/v1' } 21 | ) 22 | res.end(body) 23 | }) 24 | 25 | router.get('/publickey', async (req, res) => { 26 | const { formatter, keyStorage } = req.app.locals 27 | const publicKeyPem = await keyStorage.getPublicKey(null) 28 | const publicKey = await as2.import({ 29 | '@context': [ 30 | 'https://www.w3.org/ns/activitystreams', 31 | 'https://w3id.org/security/v1' 32 | ], 33 | publicKeyPem, 34 | id: formatter.format({ server: true, type: 'publickey' }), 35 | owner: formatter.format({ server: true }), 36 | type: 'CryptographicKey', 37 | to: 'https://www.w3.org/ns/activitystreams#Public' 38 | }) 39 | res.status(200) 40 | res.type(as2.mediaType) 41 | const body = await publicKey.prettyWrite( 42 | { additional_context: 'https://w3id.org/security/v1' } 43 | ) 44 | res.end(body) 45 | }) 46 | 47 | export default router 48 | -------------------------------------------------------------------------------- /lib/routes/user.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import as2 from 'activitystrea.ms' 3 | import createHttpError from 'http-errors' 4 | 5 | const router = express.Router() 6 | 7 | router.get('/user/:username', async (req, res, next) => { 8 | const { username } = req.params 9 | const { actorStorage, keyStorage, formatter, bots } = req.app.locals 10 | if (!(username in bots)) { 11 | return next(createHttpError(404, `User ${username} not found`)) 12 | } 13 | const publicKeyPem = await keyStorage.getPublicKey(username) 14 | const actor = await actorStorage.getActor(username, { 15 | '@context': [ 16 | 'https://www.w3.org/ns/activitystreams', 17 | 'https://w3id.org/security/v1' 18 | ], 19 | name: bots[username].fullname, 20 | summary: bots[username].description, 21 | publicKey: { 22 | publicKeyPem, 23 | id: formatter.format({ username, type: 'publickey' }), 24 | owner: formatter.format({ username }), 25 | type: 'CryptographicKey', 26 | to: 'as:Public' 27 | } 28 | }) 29 | res.status(200) 30 | res.type(as2.mediaType) 31 | const body = await actor.prettyWrite( 32 | { additional_context: 'https://w3id.org/security/v1' } 33 | ) 34 | res.end(body) 35 | }) 36 | 37 | router.get('/user/:username/publickey', async (req, res, next) => { 38 | const { username } = req.params 39 | const { formatter, keyStorage, bots } = req.app.locals 40 | if (!(username in bots)) { 41 | return next(createHttpError(404, `User ${username} not found`)) 42 | } 43 | const publicKeyPem = await keyStorage.getPublicKey(username) 44 | const publicKey = await as2.import({ 45 | '@context': [ 46 | 'https://www.w3.org/ns/activitystreams', 47 | 'https://w3id.org/security/v1' 48 | ], 49 | publicKeyPem, 50 | id: formatter.format({ username, type: 'publickey' }), 51 | owner: formatter.format({ username }), 52 | type: 'CryptographicKey', 53 | to: 'as:Public' 54 | }) 55 | res.status(200) 56 | res.type(as2.mediaType) 57 | const body = await publicKey.prettyWrite( 58 | { additional_context: 'https://w3id.org/security/v1' } 59 | ) 60 | res.end(body) 61 | }) 62 | 63 | export default router 64 | -------------------------------------------------------------------------------- /lib/routes/webfinger.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import createHttpError from 'http-errors' 3 | 4 | const router = Router() 5 | 6 | router.get('/.well-known/webfinger', (req, res, next) => { 7 | const { resource } = req.query 8 | if (!resource) { 9 | return next(createHttpError(400, 'resource parameter is required')) 10 | } 11 | const [username, domain] = resource.substring(5).split('@') 12 | if (!username || !domain) { 13 | return next(createHttpError(400, 'Invalid resource parameter')) 14 | } 15 | const { host } = new URL(req.app.locals.origin) 16 | if (domain !== host) { 17 | return next(createHttpError(400, 'Invalid domain in resource parameter')) 18 | } 19 | if (!(username in req.app.locals.bots)) { 20 | return next(createHttpError(404, 'Bot not found')) 21 | } 22 | res.status(200) 23 | res.type('application/jrd+json') 24 | res.json({ 25 | subject: resource, 26 | links: [ 27 | { 28 | rel: 'self', 29 | type: 'application/activity+json', 30 | href: req.app.locals.formatter.format({ username }) 31 | } 32 | ] 33 | }) 34 | }) 35 | 36 | export default router 37 | -------------------------------------------------------------------------------- /lib/urlformatter.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | 3 | export class UrlFormatter { 4 | #origin = null 5 | constructor (origin) { 6 | this.#origin = origin 7 | } 8 | 9 | format ({ username, type, nanoid, collection, page, server }) { 10 | let base = null 11 | if (server) { 12 | base = `${this.#origin}` 13 | } else if (username) { 14 | base = `${this.#origin}/user/${username}` 15 | } else { 16 | throw new Error('Cannot format URL without username or server') 17 | } 18 | let major = null 19 | if (type) { 20 | if (nanoid) { 21 | major = `${base}/${type}/${nanoid}` 22 | } else if (type === 'publickey') { 23 | major = `${base}/${type}` 24 | } else { 25 | throw new Error('Cannot format URL without nanoid') 26 | } 27 | } else { 28 | major = base 29 | } 30 | let url = null 31 | if (collection) { 32 | if (page) { 33 | url = `${major}/${collection}/${page}` 34 | } else { 35 | url = `${major}/${collection}` 36 | } 37 | } else { 38 | url = major 39 | } 40 | // For the base case, we want a trailing slash. 41 | if (url === this.#origin) { 42 | url = `${url}/` 43 | } 44 | return url 45 | } 46 | 47 | isLocal (url) { 48 | assert.equal(typeof url, 'string', 'url must be a string') 49 | return url.startsWith(this.#origin) 50 | } 51 | 52 | getUserName (url) { 53 | assert.equal(typeof url, 'string', 'url must be a string') 54 | const parsed = new URL(url) 55 | const match = parsed.pathname.match(/\/user\/([^/]+)$/) 56 | return (match) ? match[1] : null 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "activitypub-bot", 3 | "version": "0.7.5", 4 | "description": "server-side ActivityPub bot framework", 5 | "type": "module", 6 | "main": "index.js", 7 | "scripts": { 8 | "test": "NODE_ENV=test node --test", 9 | "start": "node index.js" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+ssh://git@github.com/evanp/activitypub-bot.git" 14 | }, 15 | "keywords": [ 16 | "activitypub", 17 | "fediverse", 18 | "bots", 19 | "server" 20 | ], 21 | "author": "Evan Prodromou ", 22 | "license": "AGPL-3.0", 23 | "bugs": { 24 | "url": "https://github.com/evanp/activitypub-bot/issues" 25 | }, 26 | "homepage": "https://github.com/evanp/activitypub-bot#readme", 27 | "dependencies": { 28 | "@isaacs/ttlcache": "^1.4.1", 29 | "activitystrea.ms": "^3.1.0", 30 | "express": "^4.18.2", 31 | "http-errors": "^2.0.0", 32 | "humanhash": "^1.0.4", 33 | "lru-cache": "^11.1.0", 34 | "nanoid": "^5.1.5", 35 | "node-fetch": "^3.3.2", 36 | "p-queue": "^8.1.0", 37 | "pino": "^9.7.0", 38 | "pino-http": "^10.4.0", 39 | "sequelize": "^6.37.7" 40 | }, 41 | "devDependencies": { 42 | "nock": "^14.0.5", 43 | "standard": "^17.1.2", 44 | "supertest": "^7.1.1" 45 | }, 46 | "optionalDependencies": { 47 | "mysql2": "^3.9.1", 48 | "pg": "^8.16.0", 49 | "sqlite3": "^5.1.7" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/activitypubclient.test.js: -------------------------------------------------------------------------------- 1 | import { describe, before, after, it, beforeEach } from 'node:test' 2 | import { KeyStorage } from '../lib/keystorage.js' 3 | import { UrlFormatter } from '../lib/urlformatter.js' 4 | import { ActivityPubClient } from '../lib/activitypubclient.js' 5 | import assert from 'node:assert' 6 | import { Sequelize } from 'sequelize' 7 | import nock from 'nock' 8 | import as2 from 'activitystrea.ms' 9 | import Logger from 'pino' 10 | import { HTTPSignature } from '../lib/httpsignature.js' 11 | import { Digester } from '../lib/digester.js' 12 | 13 | const makeActor = (username) => 14 | as2.import({ 15 | id: `https://social.example/user/${username}`, 16 | type: 'Person', 17 | preferredUsername: username, 18 | inbox: `https://social.example/user/${username}/inbox`, 19 | outbox: `https://social.example/user/${username}/outbox`, 20 | followers: `https://social.example/user/${username}/followers`, 21 | following: `https://social.example/user/${username}/following`, 22 | liked: `https://social.example/user/${username}/liked`, 23 | publicKey: { 24 | id: `https://social.example/user/${username}/publickey`, 25 | owner: `https://social.example/user/${username}`, 26 | type: 'CryptographicKey', 27 | publicKeyPem: '-----BEGIN PUBLIC KEY-----\nFAKEFAKEFAKE\n-----END PUBLIC KEY-----' 28 | } 29 | }) 30 | 31 | const makeKey = (username) => 32 | as2.import({ 33 | id: `https://social.example/user/${username}/publickey`, 34 | owner: `https://social.example/user/${username}`, 35 | type: 'CryptographicKey', 36 | publicKeyPem: '-----BEGIN PUBLIC KEY-----\nFAKEFAKEFAKE\n-----END PUBLIC KEY-----' 37 | }) 38 | 39 | const makeNote = (username, num) => 40 | as2.import({ 41 | id: `https://social.example/user/${username}/note/${num}`, 42 | type: 'Object', 43 | attributedTo: `https://social.example/user/${username}`, 44 | to: 'https://www.w3.org/ns/activitystreams#Public', 45 | content: `This is note ${num} by ${username}.` 46 | }) 47 | 48 | describe('ActivityPubClient', async () => { 49 | let connection = null 50 | let keyStorage = null 51 | let formatter = null 52 | let client = null 53 | let postInbox = null 54 | let signature = null 55 | let digest = null 56 | let date = null 57 | let signer = null 58 | let digester = null 59 | let logger = null 60 | before(async () => { 61 | logger = new Logger({ 62 | level: 'silent' 63 | }) 64 | digester = new Digester(logger) 65 | signer = new HTTPSignature(logger) 66 | connection = new Sequelize('sqlite::memory:', { logging: false }) 67 | await connection.authenticate() 68 | keyStorage = new KeyStorage(connection, logger) 69 | await keyStorage.initialize() 70 | formatter = new UrlFormatter('https://activitypubbot.example') 71 | const remote = 'https://social.example' 72 | nock(remote) 73 | .get(/\/user\/(\w+)$/) 74 | .reply(async function (uri, requestBody) { 75 | const headers = this.req.headers 76 | signature[remote + uri] = headers.signature 77 | digest[remote + uri] = headers.digest 78 | date[remote + uri] = headers.date 79 | const username = uri.match(/\/user\/(\w+)$/)[1] 80 | const actor = await makeActor(username) 81 | const actorText = await actor.write() 82 | return [200, actorText, { 'Content-Type': 'application/activity+json' }] 83 | }) 84 | .persist() 85 | .post(/\/user\/(\w+)\/inbox$/) 86 | .reply(async function (uri, requestBody) { 87 | const headers = this.req.headers 88 | signature[remote + uri] = headers.signature 89 | digest[remote + uri] = headers.digest 90 | date[remote + uri] = headers.date 91 | const username = uri.match(/\/user\/(\w+)\/inbox$/)[1] 92 | if (username in postInbox) { 93 | postInbox[username] += 1 94 | } else { 95 | postInbox[username] = 1 96 | } 97 | return [202, 'accepted'] 98 | }) 99 | .persist() 100 | .get(/\/user\/(\w+)\/note\/(\d+)$/) 101 | .reply(async function (uri, requestBody) { 102 | const headers = this.req.headers 103 | signature[remote + uri] = headers.signature 104 | digest[remote + uri] = headers.digest 105 | date[remote + uri] = headers.date 106 | const match = uri.match(/\/user\/(\w+)\/note\/(\d+)$/) 107 | const username = match[1] 108 | const num = match[2] 109 | const obj = await makeNote(username, num) 110 | const objText = await obj.write() 111 | return [200, objText, { 'Content-Type': 'application/activity+json' }] 112 | }) 113 | .get(/\/user\/(\w+)\/inbox$/) 114 | .reply(async function (uri, requestBody) { 115 | return [403, 'Forbidden', { 'Content-Type': 'text/plain' }] 116 | }) 117 | .get(/\/user\/(\w+)\/publickey$/) 118 | .reply(async function (uri, requestBody) { 119 | const headers = this.req.headers 120 | signature[remote + uri] = headers.signature 121 | digest[remote + uri] = headers.digest 122 | date[remote + uri] = headers.date 123 | const username = uri.match(/\/user\/(\w+)\/publickey$/)[1] 124 | const key = await makeKey(username) 125 | const keyText = await key.write() 126 | return [200, keyText, { 'Content-Type': 'application/activity+json' }] 127 | }) 128 | .persist() 129 | }) 130 | after(async () => { 131 | await connection.close() 132 | keyStorage = null 133 | connection = null 134 | formatter = null 135 | client = null 136 | postInbox = null 137 | signature = null 138 | logger = null 139 | digester = null 140 | signer = null 141 | }) 142 | beforeEach(async () => { 143 | signature = {} 144 | digest = {} 145 | postInbox = {} 146 | date = {} 147 | }) 148 | it('can initialize', () => { 149 | client = new ActivityPubClient(keyStorage, formatter, signer, digester, logger) 150 | assert.ok(client) 151 | }) 152 | it('can get a remote object with a username', async () => { 153 | const id = 'https://social.example/user/evan/note/1' 154 | const obj = await client.get(id, 'foobot') 155 | assert.ok(obj) 156 | assert.equal(typeof obj, 'object') 157 | assert.equal(obj.id, id) 158 | assert.ok(signature[id]) 159 | assert.match(signature[id], /^keyId="https:\/\/activitypubbot\.example\/user\/foobot\/publickey",headers="\(request-target\) host date user-agent accept",signature=".*",algorithm="rsa-sha256"$/) 160 | assert.equal(typeof digest[id], 'undefined') 161 | assert.equal(typeof date[id], 'string') 162 | assert.match(date[id], /^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/) 163 | assert.doesNotThrow(() => { 164 | Date.parse(date[id]) 165 | }) 166 | }) 167 | it('can get a remote object without a username', async () => { 168 | const id = 'https://social.example/user/evan/note/1' 169 | const obj = await client.get(id) 170 | assert.ok(obj) 171 | assert.equal(typeof obj, 'object') 172 | assert.equal(obj.id, id) 173 | assert.ok(signature[id]) 174 | assert.match(signature[id], /^keyId="https:\/\/activitypubbot\.example\/publickey",headers="\(request-target\) host date user-agent accept",signature=".*",algorithm="rsa-sha256"$/) 175 | assert.equal(typeof digest[id], 'undefined') 176 | assert.equal(typeof date[id], 'string') 177 | assert.match(date[id], /^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/) 178 | assert.doesNotThrow(() => { 179 | Date.parse(date[id]) 180 | }) 181 | }) 182 | it('can get a remote key without a signature', async () => { 183 | const id = 'https://social.example/user/evan/publickey' 184 | const obj = await client.getKey(id) 185 | assert.ok(obj) 186 | assert.equal(typeof obj, 'object') 187 | assert.equal(obj.id, id) 188 | assert.equal(signature[id], undefined) 189 | assert.equal(typeof digest[id], 'undefined') 190 | assert.equal(typeof date[id], 'string') 191 | assert.match(date[id], /^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/) 192 | assert.doesNotThrow(() => { 193 | Date.parse(date[id]) 194 | }) 195 | }) 196 | it('can deliver an activity', async () => { 197 | const obj = as2.follow() 198 | .actor('https://activitypubbot.example/user/foobot') 199 | .object('https://social.example/user/evan') 200 | .to('https://social.example/user/evan') 201 | .publishedNow() 202 | .get() 203 | const inbox = 'https://social.example/user/evan/inbox' 204 | await client.post(inbox, obj, 'foobot') 205 | assert.ok(signature[inbox]) 206 | assert.ok(digest[inbox]) 207 | assert.match(signature[inbox], /^keyId="https:\/\/activitypubbot\.example\/user\/foobot\/publickey",headers="\(request-target\) host date user-agent content-type digest",signature=".*",algorithm="rsa-sha256"$/) 208 | assert.match(digest[inbox], /^sha-256=[0-9a-zA-Z=+/]*$/) 209 | assert.equal(typeof date[inbox], 'string') 210 | assert.match(date[inbox], /^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/) 211 | assert.doesNotThrow(() => { 212 | Date.parse(date[inbox]) 213 | }) 214 | }) 215 | it('throws an error on a non-2xx response', async () => { 216 | const inbox = 'https://social.example/user/evan/inbox' 217 | try { 218 | await client.get(inbox, 'foobot') 219 | assert.fail('should have thrown') 220 | } catch (error) { 221 | assert.ok(error) 222 | assert.equal(error.status, 403) 223 | } 224 | }) 225 | }) 226 | -------------------------------------------------------------------------------- /tests/actorstorage.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, before, after } from 'node:test' 2 | import assert from 'node:assert' 3 | import { ActorStorage } from '../lib/actorstorage.js' 4 | import { Sequelize } from 'sequelize' 5 | import { UrlFormatter } from '../lib/urlformatter.js' 6 | import as2 from 'activitystrea.ms' 7 | 8 | const AS2_NS = 'https://www.w3.org/ns/activitystreams#' 9 | 10 | describe('ActorStorage', () => { 11 | let connection = null 12 | let storage = null 13 | let formatter = null 14 | let other = null 15 | before(async () => { 16 | connection = new Sequelize('sqlite::memory:', { logging: false }) 17 | await connection.authenticate() 18 | formatter = new UrlFormatter('https://activitypubbot.example') 19 | other = await as2.import({ 20 | id: 'https://social.example/user/test2', 21 | type: 'Person' 22 | }) 23 | }) 24 | after(async () => { 25 | await connection.close() 26 | connection = null 27 | formatter = null 28 | }) 29 | it('can create an instance', () => { 30 | storage = new ActorStorage(connection, formatter) 31 | assert.ok(storage instanceof ActorStorage) 32 | }) 33 | it('can initialize the storage', async () => { 34 | await storage.initialize() 35 | }) 36 | it('can get an actor', async () => { 37 | const actor = await storage.getActor('test') 38 | assert.ok(actor) 39 | assert.ok(actor.id) 40 | assert.ok(actor.inbox) 41 | assert.ok(actor.outbox) 42 | assert.ok(actor.followers) 43 | assert.ok(actor.following) 44 | assert.ok(actor.liked) 45 | assert.strictEqual(actor.get('preferredUsername').first, 'test') 46 | }) 47 | 48 | it('can get an actor by id', async () => { 49 | const actor = await storage.getActorById('https://social.example/user/test') 50 | assert.ok(actor) 51 | assert.ok(actor.id) 52 | assert.ok(actor.inbox) 53 | assert.ok(actor.outbox) 54 | assert.ok(actor.followers) 55 | assert.ok(actor.following) 56 | assert.ok(actor.liked) 57 | assert.strictEqual(actor.get('preferredUsername').first, 'test') 58 | }) 59 | it('can get an empty collection', async () => { 60 | const collection = await storage.getCollection('test', 'followers') 61 | assert.ok(collection) 62 | assert.strictEqual(collection.id, 'https://activitypubbot.example/user/test/followers') 63 | assert.strictEqual(collection.type, 'https://www.w3.org/ns/activitystreams#OrderedCollection') 64 | assert.strictEqual(collection.totalItems, 0) 65 | assert.ok(collection.first) 66 | assert.ok(collection.last) 67 | }) 68 | it('can get an empty collection page', async () => { 69 | const page = await storage.getCollectionPage('test', 'followers', 1) 70 | assert.ok(page) 71 | assert.strictEqual( 72 | page.id, 73 | 'https://activitypubbot.example/user/test/followers/1' 74 | ) 75 | assert.strictEqual(page.type, 'https://www.w3.org/ns/activitystreams#OrderedCollectionPage') 76 | assert.strictEqual( 77 | page.partOf.id, 78 | 'https://activitypubbot.example/user/test/followers' 79 | ) 80 | assert.ok(!page.next) 81 | assert.ok(!page.prev) 82 | }) 83 | it('can add to a collection', async () => { 84 | const collection = await storage.getCollection('test3', 'followers') 85 | assert.strictEqual(collection.totalItems, 0) 86 | await storage.addToCollection( 87 | 'test3', 88 | 'followers', 89 | other 90 | ) 91 | const collection2 = await storage.getCollection('test3', 'followers') 92 | assert.strictEqual(collection2.totalItems, 1) 93 | const page = await storage.getCollectionPage('test3', 'followers', 1) 94 | assert.strictEqual(page.items.length, 1) 95 | assert.strictEqual(Array.from(page.items)[0].id, 'https://social.example/user/test2') 96 | }) 97 | it('can remove from a collection', async () => { 98 | await storage.removeFromCollection( 99 | 'test3', 100 | 'followers', 101 | other 102 | ) 103 | const collection2 = await storage.getCollection('test3', 'followers') 104 | assert.strictEqual(collection2.totalItems, 0) 105 | const page = await storage.getCollectionPage('test3', 'followers', 1) 106 | assert.ok(!page.items) 107 | }) 108 | it('can add a lot of items a collection', async () => { 109 | for (let i = 0; i < 100; i++) { 110 | const other = await as2.import({ 111 | id: `https://social.example/user/foo/note/${i}`, 112 | type: 'Note', 113 | content: `Hello World ${i}` 114 | }) 115 | await storage.addToCollection( 116 | 'test4', 117 | 'liked', 118 | other 119 | ) 120 | } 121 | const collection = await storage.getCollection('test4', 'liked') 122 | assert.strictEqual(collection.totalItems, 100) 123 | const page = await storage.getCollectionPage('test4', 'liked', 3) 124 | assert.strictEqual(page.items.length, 20) 125 | assert.strictEqual(page.next.id, 'https://activitypubbot.example/user/test4/liked/2') 126 | }) 127 | it('can iterate over a collection', async () => { 128 | const seen = new Set() 129 | for await (const item of storage.items('test4', 'liked')) { 130 | assert.ok(!(item.id in seen)) 131 | seen.add(item.id) 132 | } 133 | assert.strictEqual(seen.size, 100) 134 | }) 135 | it('can add twice and remove once from a collection', async () => { 136 | const other = await as2.import({ 137 | id: 'https://social.example/user/foo/note/200', 138 | type: 'Note', 139 | content: 'Hello World 200' 140 | }) 141 | const other2 = await as2.import({ 142 | id: 'https://social.example/user/foo/note/201', 143 | type: 'Note', 144 | content: 'Hello World 201' 145 | }) 146 | const collection = await storage.getCollection('test5', 'liked') 147 | assert.strictEqual(collection.totalItems, 0) 148 | await storage.addToCollection( 149 | 'test5', 150 | 'liked', 151 | other 152 | ) 153 | await storage.addToCollection( 154 | 'test5', 155 | 'liked', 156 | other2 157 | ) 158 | const collection2 = await storage.getCollection('test5', 'liked') 159 | assert.strictEqual(collection2.totalItems, 2) 160 | await storage.removeFromCollection( 161 | 'test5', 162 | 'liked', 163 | other 164 | ) 165 | const collection3 = await storage.getCollection('test5', 'liked') 166 | assert.strictEqual(collection3.totalItems, 1) 167 | }) 168 | it('can check if something is in the collection', async () => { 169 | const other = await as2.import({ 170 | id: 'https://social.example/user/foo/note/300', 171 | type: 'Note', 172 | content: 'Hello World 300' 173 | }) 174 | const other2 = await as2.import({ 175 | id: 'https://social.example/user/foo/note/301', 176 | type: 'Note', 177 | content: 'Hello World 301' 178 | }) 179 | let collection = await storage.getCollection('test6', 'liked') 180 | assert.strictEqual(collection.totalItems, 0) 181 | await storage.addToCollection( 182 | 'test6', 183 | 'liked', 184 | other 185 | ) 186 | collection = await storage.getCollection('test6', 'liked') 187 | assert.strictEqual(collection.totalItems, 1) 188 | assert.ok(await storage.isInCollection( 189 | 'test6', 190 | 'liked', 191 | other 192 | )) 193 | assert.ok(!await storage.isInCollection( 194 | 'test6', 195 | 'liked', 196 | other2 197 | )) 198 | }) 199 | 200 | it('retains totalItems when we remove an absent object', async () => { 201 | const other = await as2.import({ 202 | id: 'https://social.example/user/foo/note/400', 203 | type: 'Note', 204 | content: 'Hello World 400' 205 | }) 206 | const other2 = await as2.import({ 207 | id: 'https://social.example/user/foo/note/401', 208 | type: 'Note', 209 | content: 'Hello World 401' 210 | }) 211 | const other3 = await as2.import({ 212 | id: 'https://social.example/user/foo/note/402', 213 | type: 'Note', 214 | content: 'Hello World 402' 215 | }) 216 | let collection = await storage.getCollection('test7', 'liked') 217 | assert.strictEqual(collection.totalItems, 0) 218 | await storage.addToCollection( 219 | 'test7', 220 | 'liked', 221 | other 222 | ) 223 | await storage.addToCollection( 224 | 'test7', 225 | 'liked', 226 | other2 227 | ) 228 | collection = await storage.getCollection('test7', 'liked') 229 | assert.strictEqual(collection.totalItems, 2) 230 | await storage.removeFromCollection( 231 | 'test7', 232 | 'liked', 233 | other3 234 | ) 235 | collection = await storage.getCollection('test7', 'liked') 236 | assert.strictEqual(collection.totalItems, 2) 237 | }) 238 | it('can get an actor with custom properties', async () => { 239 | const props = { 240 | name: 'Test User', 241 | summary: 'A test user', 242 | type: 'Person' 243 | } 244 | const actor = await storage.getActor('test8', props) 245 | assert.ok(actor) 246 | assert.ok(actor.id) 247 | assert.ok(actor.inbox) 248 | assert.ok(actor.outbox) 249 | assert.ok(actor.followers) 250 | assert.ok(actor.following) 251 | assert.ok(actor.liked) 252 | assert.strictEqual(actor.get('preferredUsername').first, 'test8') 253 | assert.strictEqual(actor.name.get(), 'Test User') 254 | assert.strictEqual(actor.summary.get(), 'A test user') 255 | console.log(actor.type) 256 | console.log(await actor.write()) 257 | assert.ok(Array.isArray(actor.type)) 258 | assert.ok(actor.type.includes(AS2_NS + 'Person')) 259 | assert.ok(actor.type.includes(AS2_NS + 'Service')) 260 | }) 261 | }) 262 | -------------------------------------------------------------------------------- /tests/app.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test' 2 | import assert from 'node:assert' 3 | import { makeApp } from '../lib/app.js' 4 | import bots from './fixtures/bots.js' 5 | 6 | describe('app', async () => { 7 | const databaseUrl = 'sqlite::memory:' 8 | const origin = 'https://activitypubbot.test' 9 | let app = null 10 | it('should be a function', async () => { 11 | assert.strictEqual(typeof makeApp, 'function') 12 | }) 13 | it('should return a function', async () => { 14 | app = await makeApp(databaseUrl, origin, bots, 'silent') 15 | assert.strictEqual(typeof app, 'function') 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /tests/authorizer.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, before, after } from 'node:test' 2 | import { Authorizer } from '../lib/authorizer.js' 3 | import { ActorStorage } from '../lib/actorstorage.js' 4 | import { Sequelize } from 'sequelize' 5 | import { UrlFormatter } from '../lib/urlformatter.js' 6 | import { ObjectStorage } from '../lib/objectstorage.js' 7 | import { KeyStorage } from '../lib/keystorage.js' 8 | import { ActivityPubClient } from '../lib/activitypubclient.js' 9 | import as2 from 'activitystrea.ms' 10 | import assert from 'node:assert/strict' 11 | import { nanoid } from 'nanoid' 12 | import { HTTPSignature } from '../lib/httpsignature.js' 13 | import Logger from 'pino' 14 | import { Digester } from '../lib/digester.js' 15 | 16 | describe('Authorizer', () => { 17 | let authorizer = null 18 | let actorStorage = null 19 | let formatter = null 20 | let connection = null 21 | let objectStorage = null 22 | let keyStorage = null 23 | let client = null 24 | 25 | let actor1 = null 26 | let actor2 = null 27 | let actor3 = null 28 | let publicObject = null 29 | let followersOnlyObject = null 30 | let privateObject = null 31 | let remoteUnconnected = null 32 | let remoteFollower = null 33 | let remoteAddressee = null 34 | let remotePublicObject = null 35 | let remotePrivateObject = null 36 | 37 | before(async () => { 38 | const logger = Logger({ 39 | level: 'silent' 40 | }) 41 | formatter = new UrlFormatter('https://activitypubbot.example') 42 | connection = new Sequelize('sqlite::memory:', { logging: false }) 43 | await connection.authenticate() 44 | actorStorage = new ActorStorage(connection, formatter) 45 | await actorStorage.initialize() 46 | objectStorage = new ObjectStorage(connection) 47 | await objectStorage.initialize() 48 | keyStorage = new KeyStorage(connection, logger) 49 | await keyStorage.initialize() 50 | const signer = new HTTPSignature(logger) 51 | const digester = new Digester(logger) 52 | client = new ActivityPubClient(keyStorage, formatter, signer, digester, logger) 53 | actor1 = await actorStorage.getActor('test1') 54 | actor2 = await actorStorage.getActor('test2') 55 | await actorStorage.addToCollection( 56 | 'test1', 57 | 'followers', 58 | actor2 59 | ) 60 | actor3 = await actorStorage.getActor('test3') 61 | remoteUnconnected = await as2.import({ 62 | id: 'https://remote.example/user/remote1', 63 | type: 'Person', 64 | preferredUsername: 'remote1', 65 | to: 'as:Public' 66 | }) 67 | remoteFollower = await as2.import({ 68 | id: 'https://remote.example/user/remote2', 69 | type: 'Person', 70 | preferredUsername: 'remote2', 71 | to: 'as:Public' 72 | }) 73 | await actorStorage.addToCollection( 74 | 'test1', 75 | 'followers', 76 | remoteFollower 77 | ) 78 | remoteAddressee = await as2.import({ 79 | id: 'https://remote.example/user/remote3', 80 | type: 'Person', 81 | preferredUsername: 'remote3', 82 | to: 'as:Public' 83 | }) 84 | publicObject = await as2.import({ 85 | id: formatter.format({ 86 | username: 'test1', 87 | type: 'object', 88 | nanoid: nanoid() 89 | }), 90 | type: 'Object', 91 | attributedTo: actor1.id, 92 | to: 'as:Public' 93 | }) 94 | followersOnlyObject = await as2.import({ 95 | id: formatter.format({ 96 | username: 'test1', 97 | type: 'object', 98 | nanoid: nanoid() 99 | }), 100 | type: 'Object', 101 | attributedTo: actor1.id, 102 | to: formatter.format({ 103 | username: 'test1', 104 | collection: 'followers' 105 | }) 106 | }) 107 | privateObject = await as2.import({ 108 | id: formatter.format({ 109 | username: 'test1', 110 | type: 'object', 111 | nanoid: nanoid() 112 | }), 113 | type: 'Object', 114 | attributedTo: actor1.id, 115 | to: [actor2.id, remoteAddressee.id] 116 | }) 117 | remotePublicObject = await as2.import({ 118 | id: 'https://remote.example/user/remote1/object/1', 119 | type: 'Object', 120 | attributedTo: remoteUnconnected.id, 121 | to: 'as:Public' 122 | }) 123 | remotePrivateObject = await as2.import({ 124 | id: 'https://remote.example/user/remote1/object/2', 125 | type: 'Object', 126 | attributedTo: remoteUnconnected.id, 127 | to: actor2.id 128 | }) 129 | }) 130 | 131 | after(async () => { 132 | await connection.close() 133 | formatter = null 134 | actorStorage = null 135 | connection = null 136 | authorizer = null 137 | objectStorage = null 138 | }) 139 | 140 | it('should be a class', async () => { 141 | assert.strictEqual(typeof Authorizer, 'function') 142 | }) 143 | 144 | it('can be instantiated', async () => { 145 | try { 146 | authorizer = new Authorizer(actorStorage, formatter, client) 147 | assert.strictEqual(typeof authorizer, 'object') 148 | } catch (error) { 149 | assert.fail(error) 150 | } 151 | }) 152 | 153 | it('can check the creator can read a public local object', async () => { 154 | assert.strictEqual(true, await authorizer.canRead(actor1, publicObject)) 155 | }) 156 | 157 | it('can check the creator can read a followers-only local object', async () => { 158 | assert.strictEqual( 159 | true, 160 | await authorizer.canRead(actor1, followersOnlyObject) 161 | ) 162 | }) 163 | 164 | it('can check the creator can read a private local object', async () => { 165 | assert.strictEqual( 166 | true, 167 | await authorizer.canRead(actor1, privateObject) 168 | ) 169 | }) 170 | 171 | it('can check if a local follower can read a public local object', async () => { 172 | assert.strictEqual(true, await authorizer.canRead(actor2, publicObject)) 173 | }) 174 | 175 | it('can check if a local follower can read a followers-only local object', async () => { 176 | assert.strictEqual(true, await authorizer.canRead(actor2, followersOnlyObject)) 177 | }) 178 | 179 | it('can check if a local addressee can read a private local object', async () => { 180 | assert.strictEqual(true, await authorizer.canRead(actor2, privateObject)) 181 | }) 182 | 183 | it('can check if a local non-follower can read a public local object', async () => { 184 | assert.strictEqual(true, await authorizer.canRead(actor3, publicObject)) 185 | }) 186 | 187 | it('can check if a local non-follower can read a followers-only local object', async () => { 188 | assert.strictEqual(false, await authorizer.canRead(actor3, followersOnlyObject)) 189 | }) 190 | 191 | it('can check if a local non-addressee can read a private local object', async () => { 192 | assert.strictEqual(false, await authorizer.canRead(actor3, privateObject)) 193 | }) 194 | 195 | it('can check if the null actor can read a public local object', async () => { 196 | assert.strictEqual(true, await authorizer.canRead(null, publicObject)) 197 | }) 198 | 199 | it('can check if the null actor can read a followers-only local object', async () => { 200 | assert.strictEqual(false, await authorizer.canRead(null, followersOnlyObject)) 201 | }) 202 | 203 | it('can check if the null actor can read a private local object', async () => { 204 | assert.strictEqual(false, await authorizer.canRead(null, privateObject)) 205 | }) 206 | 207 | it('can check that an unconnected remote actor can read a public local object', async () => { 208 | assert.strictEqual(true, await authorizer.canRead(remoteUnconnected, publicObject)) 209 | }) 210 | 211 | it('can check that an unconnected remote actor cannot read a followers-only local object', async () => { 212 | assert.strictEqual( 213 | false, 214 | await authorizer.canRead(remoteUnconnected, followersOnlyObject) 215 | ) 216 | }) 217 | 218 | it('can check that an unconnected remote actor cannot read a private local object', async () => { 219 | assert.strictEqual( 220 | false, 221 | await authorizer.canRead(remoteUnconnected, privateObject) 222 | ) 223 | }) 224 | 225 | it('can check that a remote follower can read a public local object', async () => { 226 | assert.strictEqual(true, await authorizer.canRead(remoteFollower, publicObject)) 227 | }) 228 | 229 | it('can check that a remote follower can read a followers-only local object', async () => { 230 | assert.strictEqual( 231 | true, 232 | await authorizer.canRead(remoteFollower, followersOnlyObject) 233 | ) 234 | }) 235 | 236 | it('can check that a remote follower cannot read a private local object', async () => { 237 | assert.strictEqual( 238 | false, 239 | await authorizer.canRead(remoteFollower, privateObject) 240 | ) 241 | }) 242 | 243 | it('can check that a remote addressee can read a private local object', async () => { 244 | assert.strictEqual(true, await authorizer.canRead(remoteAddressee, privateObject)) 245 | }) 246 | 247 | it('can check that a local actor can read a public remote object', async () => { 248 | assert.strictEqual(true, await authorizer.canRead(actor1, remotePublicObject)) 249 | }) 250 | 251 | it('can check that a local non-addressee cannot read a private remote object', async () => { 252 | assert.strictEqual(null, await authorizer.canRead(actor1, remotePrivateObject)) 253 | }) 254 | 255 | it('can check that a local addressee can read a private remote object', async () => { 256 | assert.strictEqual(true, await authorizer.canRead(actor2, remotePrivateObject)) 257 | }) 258 | 259 | it('can check that two objects have the same origin', async () => { 260 | const object1 = await as2.import({ 261 | id: 'https://example.com/object/1', 262 | type: 'Object' 263 | }) 264 | const object2 = await as2.import({ 265 | id: 'https://example.com/object/2', 266 | type: 'Object' 267 | }) 268 | assert.strictEqual(true, await authorizer.sameOrigin(object1, object2)) 269 | }) 270 | 271 | it('can check that two objects have different origins', async () => { 272 | const object1 = await as2.import({ 273 | id: 'https://example.com/object/1', 274 | type: 'Object' 275 | }) 276 | const object2 = await as2.import({ 277 | id: 'https://other.example/object/2', 278 | type: 'Object' 279 | }) 280 | assert.strictEqual(false, await authorizer.sameOrigin(object1, object2)) 281 | }) 282 | 283 | it('can check that two objects have different origins by port', async () => { 284 | const object1 = await as2.import({ 285 | id: 'https://example.com/object/1', 286 | type: 'Object' 287 | }) 288 | const object2 = await as2.import({ 289 | id: 'https://example.com:8000/object/2', 290 | type: 'Object' 291 | }) 292 | assert.strictEqual(false, await authorizer.sameOrigin(object1, object2)) 293 | }) 294 | 295 | it('can check that two objects have different origins by protocol', async () => { 296 | const object1 = await as2.import({ 297 | id: 'https://example.com/object/1', 298 | type: 'Object' 299 | }) 300 | const object2 = await as2.import({ 301 | id: 'http://example.com/object/2', 302 | type: 'Object' 303 | }) 304 | assert.strictEqual(false, await authorizer.sameOrigin(object1, object2)) 305 | }) 306 | }) 307 | -------------------------------------------------------------------------------- /tests/bot.donothing.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, before } from 'node:test' 2 | import assert from 'node:assert' 3 | import request from 'supertest' 4 | 5 | import { makeApp } from '../lib/app.js' 6 | 7 | import { nockSetup } from './utils/nock.js' 8 | import bots from './fixtures/bots.js' 9 | 10 | describe('DoNothing bot', async () => { 11 | const host = 'activitypubbot.example' 12 | const origin = `https://${host}` 13 | const databaseUrl = 'sqlite::memory:' 14 | let app = null 15 | 16 | before(async () => { 17 | nockSetup('social.example') 18 | app = await makeApp(databaseUrl, origin, bots, 'silent') 19 | }) 20 | 21 | describe('Bot exists', async () => { 22 | let response = null 23 | it('should work without an error', async () => { 24 | response = await request(app).get('/user/null') 25 | }) 26 | it('should return 200 OK', async () => { 27 | assert.strictEqual(response.status, 200) 28 | }) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /tests/bot.ok.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, before } from 'node:test' 2 | import assert from 'node:assert' 3 | import as2 from 'activitystrea.ms' 4 | import request from 'supertest' 5 | 6 | import { makeApp } from '../lib/app.js' 7 | 8 | import { nockSetup, nockSignature, nockFormat, postInbox } from './utils/nock.js' 9 | import { makeDigest } from './utils/digest.js' 10 | import bots from './fixtures/bots.js' 11 | 12 | describe('OK bot', async () => { 13 | const host = 'activitypubbot.example' 14 | const origin = `https://${host}` 15 | const databaseUrl = 'sqlite::memory:' 16 | let app = null 17 | 18 | before(async () => { 19 | nockSetup('social.example') 20 | app = await makeApp(databaseUrl, origin, bots, 'silent') 21 | }) 22 | 23 | describe('responds to a mention', async () => { 24 | const username = 'actor2' 25 | const path = '/user/ok/inbox' 26 | const url = `${origin}${path}` 27 | const date = new Date().toUTCString() 28 | const activity = await as2.import({ 29 | type: 'Create', 30 | actor: nockFormat({ username }), 31 | id: nockFormat({ username, type: 'create', num: 1 }), 32 | object: { 33 | id: nockFormat({ username, type: 'note', num: 1 }), 34 | type: 'Note', 35 | source: 'Hello, @ok!', 36 | content: `Hello, @ok!`, 37 | to: `${origin}/user/ok`, 38 | cc: 'as:Public', 39 | attributedTo: nockFormat({ username }), 40 | tag: [ 41 | { 42 | type: 'Mention', 43 | href: `${origin}/user/ok`, 44 | name: `@ok@${host}` 45 | } 46 | ] 47 | }, 48 | to: `${origin}/user/ok`, 49 | cc: 'as:Public' 50 | }) 51 | const body = await activity.write() 52 | const digest = makeDigest(body) 53 | const signature = await nockSignature({ 54 | method: 'POST', 55 | username, 56 | url, 57 | digest, 58 | date 59 | }) 60 | let response = null 61 | it('should work without an error', async () => { 62 | response = await request(app) 63 | .post(path) 64 | .send(body) 65 | .set('Signature', signature) 66 | .set('Date', date) 67 | .set('Host', host) 68 | .set('Digest', digest) 69 | .set('Content-Type', 'application/activity+json') 70 | assert.ok(response) 71 | await app.onIdle() 72 | }) 73 | it('should return a 200 status', async () => { 74 | assert.strictEqual(response.status, 200, JSON.stringify(response.body)) 75 | }) 76 | it('should deliver the reply to the mentioned actor', async () => { 77 | assert.strictEqual(postInbox.actor2, 1) 78 | }) 79 | it('should have the reply in its outbox', async () => { 80 | const { actorStorage } = app.locals 81 | const outbox = await actorStorage.getCollection('ok', 'outbox') 82 | assert.strictEqual(outbox.totalItems, 1) 83 | const outboxPage = await actorStorage.getCollectionPage('ok', 'outbox', 1) 84 | assert.strictEqual(outboxPage.items.length, 1) 85 | }) 86 | }) 87 | }) 88 | -------------------------------------------------------------------------------- /tests/botdatastorage.test.js: -------------------------------------------------------------------------------- 1 | import { describe, before, after, it } from 'node:test' 2 | import { BotDataStorage, NoSuchValueError } from '../lib/botdatastorage.js' 3 | import assert from 'node:assert' 4 | import { Sequelize } from 'sequelize' 5 | 6 | describe('BotDataStorage', async () => { 7 | let connection = null 8 | let storage = null 9 | before(async () => { 10 | connection = new Sequelize('sqlite::memory:', { logging: false }) 11 | await connection.authenticate() 12 | }) 13 | after(async () => { 14 | await connection.close() 15 | }) 16 | it('can initialize', async () => { 17 | storage = new BotDataStorage(connection) 18 | await storage.initialize() 19 | }) 20 | it('can set a value', async () => { 21 | await storage.set('test', 'key1', 'value1') 22 | }) 23 | it('can get a value', async () => { 24 | const value = await storage.get('test', 'key1') 25 | assert.equal(value, 'value1') 26 | }) 27 | it('knows if a value exists', async () => { 28 | const flag = await storage.has('test', 'key1') 29 | assert.ok(flag) 30 | }) 31 | it('knows if a value does not exist', async () => { 32 | const flag = await storage.has('test', 'nonexistent1') 33 | assert.ok(!flag) 34 | }) 35 | it('raises an error on a non-existent value', async () => { 36 | try { 37 | await storage.get('test', 'nonexistent2') 38 | assert.fail('Did not raise an exception getting a nonexistent key') 39 | } catch (e) { 40 | assert.ok(e instanceof NoSuchValueError) 41 | } 42 | }) 43 | it('can delete a value', async () => { 44 | await storage.delete('test', 'key1') 45 | }) 46 | it('knows if a value has been deleted', async () => { 47 | const flag = await storage.has('test', 'key1') 48 | assert.ok(!flag) 49 | }) 50 | it('raises an error on a deleted value', async () => { 51 | try { 52 | await storage.get('test', 'key1') 53 | assert.fail('Did not raise an exception getting a deleted key') 54 | } catch (e) { 55 | assert.ok(e instanceof NoSuchValueError) 56 | } 57 | }) 58 | it('stores different data at different keys for the same bot', async () => { 59 | await storage.set('test', 'key2', 'value2') 60 | await storage.set('test', 'key3', 'value3') 61 | const value2 = await storage.get('test', 'key2') 62 | const value3 = await storage.get('test', 'key3') 63 | assert.notEqual(value2, value3) 64 | }) 65 | it('stores different data at the same key for different bots', async () => { 66 | await storage.set('test2', 'key4', 'value4') 67 | await storage.set('test3', 'key4', 'value5') 68 | const value4 = await storage.get('test2', 'key4') 69 | const value5 = await storage.get('test3', 'key4') 70 | assert.notEqual(value4, value5) 71 | }) 72 | it('can store numbers', async () => { 73 | await storage.set('test', 'numberkey1', 23) 74 | const value = await storage.get('test', 'numberkey1') 75 | assert.equal(value, 23) 76 | }) 77 | it('can store arrays', async () => { 78 | await storage.set('test', 'arraykey1', [1, 2, 3]) 79 | const value = await storage.get('test', 'arraykey1') 80 | assert.deepEqual(value, [1, 2, 3]) 81 | }) 82 | it('can store objects', async () => { 83 | await storage.set('test', 'objectkey1', { a: 1, b: 2, c: 3 }) 84 | const value = await storage.get('test', 'objectkey1') 85 | assert.deepEqual(value, { a: 1, b: 2, c: 3 }) 86 | }) 87 | }) 88 | -------------------------------------------------------------------------------- /tests/digester.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, before, after } from 'node:test' 2 | import assert from 'node:assert' 3 | import Logger from 'pino' 4 | import { Digester } from '../lib/digester.js' 5 | 6 | describe('Digester', () => { 7 | let digester = null 8 | let logger = null 9 | 10 | before(() => { 11 | logger = new Logger({ 12 | level: 'silent' 13 | }) 14 | }) 15 | 16 | after(async () => { 17 | logger = null 18 | }) 19 | 20 | it('can initialize', async () => { 21 | digester = new Digester(logger) 22 | assert.ok(digester) 23 | }) 24 | 25 | it('can digest a string', async () => { 26 | const text = 'Hello, world!' 27 | const digest = await digester.digest(text) 28 | assert.ok(digest) 29 | assert.equal(digest, 'sha-256=MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=') 30 | }) 31 | 32 | it('can compare two equal digests', async () => { 33 | const text = 'Hello, world!' 34 | const digest1 = await digester.digest(text) 35 | const digest2 = await digester.digest(text) 36 | const result = await digester.equals(digest1, digest2) 37 | assert.ok(result) 38 | }) 39 | 40 | it('can compare two different digests', async () => { 41 | const text1 = 'Hello, world!' 42 | const text2 = 'Hello, world!!' 43 | const digest1 = await digester.digest(text1) 44 | const digest2 = await digester.digest(text2) 45 | const result = await digester.equals(digest1, digest2) 46 | assert.ok(!result) 47 | }) 48 | 49 | it('can compare two digests that differ only in case of the algorithm', async () => { 50 | const text = 'Hello, world!' 51 | const digest1 = await digester.digest(text) 52 | const digest2 = digest1.replace('sha-256', 'SHA-256') 53 | const result = await digester.equals(digest1, digest2) 54 | assert.ok(result) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /tests/fixtures/bots.js: -------------------------------------------------------------------------------- 1 | import DoNothingBot from '../../lib/bots/donothing.js' 2 | import OKBot from '../../lib/bots/ok.js' 3 | 4 | export default { 5 | ok: new OKBot('ok'), 6 | null: new DoNothingBot('null'), 7 | test0: new DoNothingBot('test0'), 8 | test1: new DoNothingBot('test1'), 9 | test2: new DoNothingBot('test2'), 10 | test3: new DoNothingBot('test3'), 11 | test4: new DoNothingBot('test4'), 12 | test5: new DoNothingBot('test5'), 13 | test6: new DoNothingBot('test6'), 14 | test7: new DoNothingBot('test7') 15 | } 16 | -------------------------------------------------------------------------------- /tests/httpsignature.test.js: -------------------------------------------------------------------------------- 1 | import { describe, before, after, it } from 'node:test' 2 | import assert from 'node:assert' 3 | import { Sequelize } from 'sequelize' 4 | import { KeyStorage } from '../lib/keystorage.js' 5 | import { nockSetup, nockSignature, nockKeyRotate, getPublicKey, getPrivateKey, nockFormat } from './utils/nock.js' 6 | import { HTTPSignature } from '../lib/httpsignature.js' 7 | import Logger from 'pino' 8 | import { Digester } from '../lib/digester.js' 9 | 10 | describe('HTTPSignature', async () => { 11 | const domain = 'activitypubbot.example' 12 | const origin = `https://${domain}` 13 | let connection = null 14 | let httpSignature = null 15 | let logger = null 16 | let digester = null 17 | before(async () => { 18 | logger = Logger({ 19 | level: 'silent' 20 | }) 21 | connection = new Sequelize('sqlite::memory:', { logging: false }) 22 | await connection.authenticate() 23 | const keyStorage = new KeyStorage(connection, logger) 24 | await keyStorage.initialize() 25 | nockSetup('social.example') 26 | digester = new Digester(logger) 27 | }) 28 | 29 | after(async () => { 30 | await connection.close() 31 | digester = null 32 | }) 33 | 34 | it('can initialize', async () => { 35 | httpSignature = new HTTPSignature(logger) 36 | assert.ok(httpSignature) 37 | }) 38 | 39 | it('can validate a signature', async () => { 40 | const username = 'test' 41 | const date = new Date().toUTCString() 42 | const signature = await nockSignature({ 43 | url: `${origin}/user/ok/outbox`, 44 | date, 45 | username 46 | }) 47 | const headers = { 48 | date, 49 | signature, 50 | host: URL.parse(origin).host 51 | } 52 | const publicKeyPem = await getPublicKey(username) 53 | const method = 'GET' 54 | const path = '/user/ok/outbox' 55 | const result = await httpSignature.validate(publicKeyPem, signature, method, path, headers) 56 | assert.ok(result) 57 | }) 58 | 59 | it('can validate a signature on an URL with parameters', async () => { 60 | const username = 'test' 61 | const lname = 'ok' 62 | const date = new Date().toUTCString() 63 | const signature = await nockSignature({ 64 | url: `${origin}/.well-known/webfinger?resource=acct:${lname}@${domain}`, 65 | date, 66 | username 67 | }) 68 | const headers = { 69 | date, 70 | signature, 71 | host: URL.parse(origin).host 72 | } 73 | const publicKeyPem = await getPublicKey(username) 74 | const method = 'GET' 75 | const path = `/.well-known/webfinger?resource=acct:${lname}@${domain}` 76 | const result = await httpSignature.validate(publicKeyPem, signature, method, path, headers) 77 | assert.ok(result) 78 | }) 79 | 80 | it('can handle key rotation', async () => { 81 | const username = 'rotate' 82 | const date = new Date().toUTCString() 83 | const signature = await nockSignature({ 84 | url: `${origin}/user/ok/outbox`, 85 | date, 86 | username 87 | }) 88 | const headers = { 89 | date, 90 | signature, 91 | host: URL.parse(origin).host 92 | } 93 | const publicKeyPem = await getPublicKey(username) 94 | const method = 'GET' 95 | const path = '/user/ok/outbox' 96 | await httpSignature.validate(publicKeyPem, signature, method, path, headers) 97 | await nockKeyRotate(username) 98 | const signature2 = await nockSignature({ 99 | url: `${origin}/user/ok/outbox`, 100 | date, 101 | username 102 | }) 103 | const headers2 = { 104 | date, 105 | signature, 106 | host: URL.parse(origin).host 107 | } 108 | const publicKeyPem2 = await getPublicKey(username) 109 | assert.notStrictEqual(publicKeyPem, publicKeyPem2) 110 | const result2 = await httpSignature.validate(publicKeyPem2, signature2, method, path, headers2) 111 | assert.ok(result2) 112 | }) 113 | 114 | it('can sign a GET request', async () => { 115 | const date = new Date().toUTCString() 116 | const headers = { 117 | Date: date, 118 | Host: URL.parse(origin).host, 119 | 'X-Unused-Header': 'test', 120 | Accept: 'application/activity+json', 121 | 'User-Agent': 'activitypubbot-test/0.0.1' 122 | } 123 | const privateKey = await getPrivateKey('test') 124 | const method = 'GET' 125 | const url = nockFormat({ username: 'test', obj: 'outbox' }) 126 | const keyId = nockFormat({ username: 'test', key: true }) 127 | const signature = await httpSignature.sign({ privateKey, keyId, url, method, headers }) 128 | assert.ok(signature) 129 | assert.match(signature, /^keyId="https:\/\/social\.example\/user\/test\/publickey",headers="\(request-target\) host date user-agent accept",signature=".*",algorithm="rsa-sha256"$/) 130 | }) 131 | 132 | it('can sign a POST request', async () => { 133 | const body = JSON.stringify({ 134 | '@context': 'https://www.w3.org/ns/activitystreams', 135 | type: 'Create', 136 | actor: nockFormat({ username: 'test' }), 137 | object: nockFormat({ username: 'test', obj: 'note', num: 1 }) 138 | }) 139 | const headers = { 140 | date: new Date().toUTCString(), 141 | host: URL.parse(origin).host, 142 | digest: await digester.digest(body), 143 | 'content-type': 'application/activity+json', 144 | 'User-Agent': 'activitypubbot-test/0.0.1' 145 | } 146 | const privateKey = await getPrivateKey('test') 147 | const method = 'POST' 148 | const url = nockFormat({ username: 'test', obj: 'outbox' }) 149 | const keyId = nockFormat({ username: 'test', key: true }) 150 | const signature = await httpSignature.sign({ privateKey, keyId, url, method, headers }) 151 | assert.ok(signature) 152 | assert.match(signature, /^keyId="https:\/\/social\.example\/user\/test\/publickey",headers="\(request-target\) host date user-agent content-type digest",signature=".*",algorithm="rsa-sha256"$/) 153 | }) 154 | 155 | it('errors if required GET headers not present', async () => { 156 | const date = new Date().toUTCString() 157 | const headers = { 158 | Date: date, 159 | Host: URL.parse(origin).host, 160 | 'User-Agent': 'activitypubbot-test/0.0.1' 161 | } 162 | const privateKey = await getPrivateKey('test') 163 | const method = 'GET' 164 | const url = nockFormat({ username: 'test', obj: 'outbox' }) 165 | const keyId = nockFormat({ username: 'test', key: true }) 166 | try { 167 | await httpSignature.sign({ privateKey, keyId, url, method, headers }) 168 | assert.fail('Expected error not thrown') 169 | } catch (err) { 170 | assert.equal(err.name, 'Error') 171 | assert.equal(err.message, 'Missing header: accept') 172 | } 173 | }) 174 | 175 | it('errors if required POST headers not present', async () => { 176 | const body = JSON.stringify({ 177 | '@context': 'https://www.w3.org/ns/activitystreams', 178 | type: 'Create', 179 | actor: nockFormat({ username: 'test' }), 180 | object: nockFormat({ username: 'test', obj: 'note', num: 1 }) 181 | }) 182 | const headers = { 183 | date: new Date().toUTCString(), 184 | host: URL.parse(origin).host, 185 | digest: await digester.digest(body), 186 | 'User-Agent': 'activitypubbot-test/0.0.1' 187 | } 188 | const privateKey = await getPrivateKey('test') 189 | const method = 'POST' 190 | const url = nockFormat({ username: 'test', obj: 'outbox' }) 191 | const keyId = nockFormat({ username: 'test', key: true }) 192 | try { 193 | await httpSignature.sign({ privateKey, keyId, url, method, headers }) 194 | assert.fail('Expected error not thrown') 195 | } catch (err) { 196 | assert.equal(err.name, 'Error') 197 | assert.equal(err.message, 'Missing header: content-type') 198 | } 199 | }) 200 | }) 201 | -------------------------------------------------------------------------------- /tests/keystorage.test.js: -------------------------------------------------------------------------------- 1 | import { describe, before, after, it } from 'node:test' 2 | import { KeyStorage } from '../lib/keystorage.js' 3 | import assert from 'node:assert' 4 | import { Sequelize } from 'sequelize' 5 | import Logger from 'pino' 6 | 7 | describe('KeyStorage', async () => { 8 | let connection = null 9 | let storage = null 10 | let logger = null 11 | let firstPublicKey = null 12 | let firstPrivateKey = null 13 | let secondPublicKey = null 14 | let secondPrivateKey = null 15 | before(async () => { 16 | connection = new Sequelize('sqlite::memory:', { logging: false }) 17 | await connection.authenticate() 18 | logger = new Logger({ 19 | level: 'silent' 20 | }) 21 | }) 22 | after(async () => { 23 | await connection.close() 24 | connection = null 25 | logger = null 26 | }) 27 | it('can initialize', async () => { 28 | storage = new KeyStorage(connection, logger) 29 | await storage.initialize() 30 | }) 31 | it('can get a public key', async () => { 32 | firstPublicKey = await storage.getPublicKey('test1') 33 | assert.ok(firstPublicKey) 34 | assert.equal(typeof firstPublicKey, 'string') 35 | assert.match(firstPublicKey, /^-----BEGIN PUBLIC KEY-----\n/) 36 | assert.match(firstPublicKey, /-----END PUBLIC KEY-----\n$/) 37 | }) 38 | it('can get a public key again', async () => { 39 | secondPublicKey = await storage.getPublicKey('test1') 40 | assert.ok(secondPublicKey) 41 | assert.equal(typeof secondPublicKey, 'string') 42 | assert.match(secondPublicKey, /^-----BEGIN PUBLIC KEY-----\n/) 43 | assert.match(secondPublicKey, /-----END PUBLIC KEY-----\n$/) 44 | assert.equal(firstPublicKey, secondPublicKey) 45 | }) 46 | it('can get a private key after getting a public key', async () => { 47 | const privateKey = await storage.getPrivateKey('test1') 48 | assert.ok(privateKey) 49 | assert.equal(typeof privateKey, 'string') 50 | assert.match(privateKey, /^-----BEGIN PRIVATE KEY-----\n/) 51 | assert.match(privateKey, /-----END PRIVATE KEY-----\n$/) 52 | }) 53 | it('can get a private key', async () => { 54 | firstPrivateKey = await storage.getPrivateKey('test2') 55 | assert.ok(firstPrivateKey) 56 | assert.equal(typeof firstPrivateKey, 'string') 57 | assert.match(firstPrivateKey, /^-----BEGIN PRIVATE KEY-----\n/) 58 | assert.match(firstPrivateKey, /-----END PRIVATE KEY-----\n$/) 59 | }) 60 | it('can get a private key again', async () => { 61 | secondPrivateKey = await storage.getPrivateKey('test2') 62 | assert.ok(secondPrivateKey) 63 | assert.equal(typeof secondPrivateKey, 'string') 64 | assert.match(secondPrivateKey, /^-----BEGIN PRIVATE KEY-----\n/) 65 | assert.match(secondPrivateKey, /-----END PRIVATE KEY-----\n$/) 66 | assert.equal(firstPrivateKey, secondPrivateKey) 67 | }) 68 | it('can get a public key after getting a private key', async () => { 69 | const publicKey = await storage.getPublicKey('test2') 70 | assert.ok(publicKey) 71 | assert.equal(typeof publicKey, 'string') 72 | assert.match(publicKey, /^-----BEGIN PUBLIC KEY-----\n/) 73 | assert.match(publicKey, /-----END PUBLIC KEY-----\n$/) 74 | }) 75 | it('can get distinct public keys for distinct bots', async () => { 76 | const publicKey = await storage.getPublicKey('test1') 77 | const publicKey2 = await storage.getPublicKey('test2') 78 | assert.ok(publicKey) 79 | assert.ok(publicKey2) 80 | assert.notEqual(publicKey, publicKey2) 81 | }) 82 | it('can get distinct private keys for distinct bots', async () => { 83 | const privateKey = await storage.getPrivateKey('test1') 84 | const privateKey2 = await storage.getPrivateKey('test2') 85 | assert.ok(privateKey) 86 | assert.ok(privateKey2) 87 | assert.notEqual(privateKey, privateKey2) 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /tests/microsyntax.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test' 2 | import assert from 'node:assert' 3 | import { Sequelize } from 'sequelize' 4 | import { Transformer } from '../lib/microsyntax.js' 5 | import { UrlFormatter } from '../lib/urlformatter.js' 6 | import { KeyStorage } from '../lib/keystorage.js' 7 | import { ActivityPubClient } from '../lib/activitypubclient.js' 8 | import { nockSetup } from './utils/nock.js' 9 | import { HTTPSignature } from '../lib/httpsignature.js' 10 | import Logger from 'pino' 11 | import { Digester } from '../lib/digester.js' 12 | 13 | const AS2 = 'https://www.w3.org/ns/activitystreams#' 14 | 15 | describe('microsyntax', async () => { 16 | const tagNamespace = 'https://tags.example/tag/' 17 | const origin = 'https://activitypubbot.example' 18 | 19 | nockSetup('social.example') 20 | 21 | const logger = Logger({ 22 | level: 'silent' 23 | }) 24 | const digester = new Digester(logger) 25 | const connection = new Sequelize('sqlite::memory:', { logging: false }) 26 | await connection.authenticate() 27 | const keyStorage = new KeyStorage(connection, logger) 28 | await keyStorage.initialize() 29 | const formatter = new UrlFormatter(origin) 30 | const signer = new HTTPSignature(logger) 31 | const client = new ActivityPubClient(keyStorage, formatter, signer, digester, logger) 32 | const transformer = new Transformer(tagNamespace, client) 33 | 34 | it('has transformer', () => { 35 | assert.ok(transformer) 36 | }) 37 | 38 | describe('transform tagless text', async () => { 39 | const text = 'Hello, world!' 40 | const { html } = await transformer.transform(text) 41 | it('has output', () => { 42 | assert.ok(html) 43 | }) 44 | it('is the same as input', () => { 45 | assert.equal(html, `

${text}

`) 46 | }) 47 | }) 48 | 49 | describe('transform hashtag', async () => { 50 | const text = 'Hello, World! #greeting' 51 | const { html, tag } = await transformer.transform(text) 52 | it('has html output', () => { 53 | assert.ok(html) 54 | }) 55 | it('has tag', () => { 56 | assert.ok(tag) 57 | }) 58 | it('has correct html', () => { 59 | assert.equal(html, '

Hello, World! #greeting

') 60 | }) 61 | it('has correct tag', () => { 62 | assert.equal(tag.length, 1) 63 | assert.equal(tag[0].type, AS2 + 'Hashtag') 64 | assert.equal(tag[0].name, '#greeting') 65 | assert.equal(tag[0].href, 'https://tags.example/tag/greeting') 66 | }) 67 | }) 68 | 69 | describe('transform url', async () => { 70 | const text = 'Please visit https://example.com for more information.' 71 | const { html, tag } = await transformer.transform(text) 72 | it('has html output', () => { 73 | assert.ok(html) 74 | }) 75 | it('has tag', () => { 76 | assert.ok(tag) 77 | }) 78 | it('has correct html', () => { 79 | assert.equal(html, '

Please visit https://example.com for more information.

') 80 | }) 81 | it('has correct tag', () => { 82 | assert.equal(tag.length, 0) 83 | }) 84 | }) 85 | 86 | describe('transform url with fragment', async () => { 87 | const text = 'Please visit https://example.com#fragment for more information.' 88 | const { html, tag } = await transformer.transform(text) 89 | it('has html output', () => { 90 | assert.ok(html) 91 | }) 92 | it('has tag', () => { 93 | assert.ok(tag) 94 | }) 95 | it('has correct html', () => { 96 | assert.equal(html, '

Please visit https://example.com#fragment for more information.

') 97 | }) 98 | it('has correct tag', () => { 99 | assert.equal(tag.length, 0) 100 | }) 101 | }) 102 | 103 | describe('transform full mention', async () => { 104 | const text = 'Hello, @world@social.example !' 105 | const { html, tag } = await transformer.transform(text) 106 | it('has html output', () => { 107 | assert.ok(html) 108 | }) 109 | it('has tag', () => { 110 | assert.ok(tag) 111 | }) 112 | it('has correct html', () => { 113 | assert.equal(html, '

Hello, @world@social.example !

') 114 | }) 115 | it('has correct tag', () => { 116 | assert.equal(tag.length, 1) 117 | assert.equal(tag[0].type, 'Mention') 118 | assert.equal(tag[0].name, '@world@social.example') 119 | assert.equal(tag[0].href, 'https://social.example/profile/world') 120 | }) 121 | }) 122 | }) 123 | -------------------------------------------------------------------------------- /tests/objectcache.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test' 2 | import { ObjectCache } from '../lib/objectcache.js' 3 | import assert from 'node:assert/strict' 4 | import as2 from 'activitystrea.ms' 5 | 6 | describe('ObjectCache', async () => { 7 | let cache = null 8 | const longTTL = 3600 * 1000 9 | const shortTTL = 300 * 1000 10 | const maxItems = 1000 11 | const makeObject = (num) => 12 | as2.import({ 13 | id: `https://example.com/${num}`, 14 | name: `Object ${num}`, 15 | type: 'Object', 16 | attributedTo: `https://example.com/user${num}`, 17 | to: 'https://www.w3.org/ns/activitystreams#Public' 18 | }) 19 | const makeCollection = (num) => 20 | as2.import({ 21 | id: `https://example.com/collection${num}`, 22 | type: 'Collection', 23 | name: `Collection ${num}`, 24 | totalItems: 1 25 | }) 26 | 27 | const object1 = await makeObject(1) 28 | const object2 = await makeObject(2) 29 | const object3 = await makeObject(3) 30 | const object4 = await makeObject(4) 31 | const object5 = await makeObject(5) 32 | const badid = 'https://example.com/badid' 33 | const badcoll = 'https://example.com/badcoll' 34 | const collection1 = await makeCollection(1) 35 | const collection2 = await makeCollection(2) 36 | const collection3 = await makeCollection(3) 37 | 38 | it('should be a class', async () => { 39 | assert.strictEqual(typeof ObjectCache, 'function') 40 | }) 41 | 42 | it('can be instantiated', async () => { 43 | cache = new ObjectCache({ longTTL, shortTTL, maxItems }) 44 | assert.strictEqual(typeof cache, 'object') 45 | }) 46 | 47 | it('can be initialized', async () => { 48 | try { 49 | await cache.initialize() 50 | assert.ok(true) 51 | } catch (error) { 52 | assert.fail(error) 53 | } 54 | }) 55 | 56 | it('returns undefined if not found', async () => { 57 | try { 58 | const value = await cache.get(badid) 59 | assert.strictEqual(value, undefined) 60 | } catch (error) { 61 | assert.fail(error) 62 | } 63 | }) 64 | 65 | it('can save', async () => { 66 | try { 67 | await cache.save(object1) 68 | const dupe = await cache.get(object1.id) 69 | assert.strictEqual(dupe.id, object1.id) 70 | } catch (error) { 71 | assert.fail(error) 72 | } 73 | }) 74 | 75 | it('can saveReceived', async () => { 76 | try { 77 | await cache.saveReceived(object2) 78 | const dupe = await cache.get(object2.id) 79 | assert.strictEqual(dupe.id, object2.id) 80 | } catch (error) { 81 | assert.fail(error) 82 | } 83 | }) 84 | 85 | it('can clear', async () => { 86 | try { 87 | await cache.save(object3) 88 | await cache.clear(object3) 89 | const dupe = await cache.get(object3.id) 90 | assert.strictEqual(dupe, undefined) 91 | } catch (error) { 92 | assert.fail(error) 93 | } 94 | }) 95 | 96 | it('fails membership for unknown collection', async () => { 97 | try { 98 | const flag = await cache.isMember(collection3, badid) 99 | assert.strictEqual(flag, undefined) 100 | } catch (error) { 101 | assert.fail(error) 102 | } 103 | }) 104 | 105 | it('fails membership for unknown object', async () => { 106 | try { 107 | const flag = await cache.isMember(badcoll, object4) 108 | assert.strictEqual(flag, undefined) 109 | } catch (error) { 110 | assert.fail(error) 111 | } 112 | }) 113 | 114 | it('can saveMembership', async () => { 115 | try { 116 | await cache.saveMembership(collection1, object4) 117 | const flag = await cache.isMember(collection1, object4) 118 | assert.ok(flag) 119 | } catch (error) { 120 | assert.fail(error) 121 | } 122 | }) 123 | 124 | it('can saveMembershipReceived', async () => { 125 | try { 126 | await cache.saveMembershipReceived(collection2, object5) 127 | const flag = await cache.isMember(collection2, object5) 128 | assert.ok(flag) 129 | } catch (error) { 130 | assert.fail(error) 131 | } 132 | }) 133 | }) 134 | -------------------------------------------------------------------------------- /tests/objectstorage.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, before, after } from 'node:test' 2 | import as2 from 'activitystrea.ms' 3 | import assert from 'node:assert' 4 | import { ObjectStorage, NoSuchObjectError } from '../lib/objectstorage.js' 5 | import { Sequelize } from 'sequelize' 6 | 7 | describe('ObjectStorage', async () => { 8 | let doc = null 9 | let doc2 = null 10 | let doc3 = null 11 | let connection = null 12 | let storage = null 13 | before(async () => { 14 | doc = await as2.import({ 15 | '@context': 'https://www.w3.org/ns/activitystreams', 16 | id: 'https://social.example/users/test/note/1', 17 | type: 'Note', 18 | name: 'test', 19 | content: 'test' 20 | }) 21 | doc2 = await as2.import({ 22 | '@context': 'https://www.w3.org/ns/activitystreams', 23 | id: 'https://social.example/users/test/note/2', 24 | type: 'Note', 25 | name: 'test', 26 | content: 'test', 27 | inReplyTo: doc.id 28 | }) 29 | doc3 = await as2.import({ 30 | '@context': 'https://www.w3.org/ns/activitystreams', 31 | id: 'https://social.example/users/test/note/3', 32 | type: 'Note', 33 | name: 'test', 34 | content: 'test' 35 | }) 36 | connection = new Sequelize('sqlite::memory:', { logging: false }) 37 | await connection.authenticate() 38 | }) 39 | after(async () => { 40 | await connection.close() 41 | }) 42 | it('can initialize', async () => { 43 | storage = new ObjectStorage(connection) 44 | await storage.initialize() 45 | }) 46 | it('can create a new object', async () => { 47 | await storage.create(doc) 48 | }) 49 | it('can read a created object', async () => { 50 | await storage.read(doc.id) 51 | }) 52 | it('can update a created object', async () => { 53 | const doc2 = await as2.import({ 54 | '@context': 'https://www.w3.org/ns/activitystreams', 55 | id: 'https://social.example/users/test/note/1', 56 | type: 'Note', 57 | name: 'test2', 58 | content: 'test2' 59 | }) 60 | await storage.update(doc2) 61 | const read = await storage.read(doc2.id) 62 | assert.equal(read.name.get('en'), 'test2') 63 | }) 64 | it('can delete a created object', async () => { 65 | await storage.delete(doc) 66 | try { 67 | await storage.read(doc.id) 68 | assert.fail('should not be able to read deleted object') 69 | } catch (err) { 70 | assert.ok(err instanceof NoSuchObjectError) 71 | } 72 | }) 73 | it('can get a collection', async () => { 74 | const collection = await storage.getCollection(doc.id, 'replies') 75 | assert.equal(typeof (collection), 'object') 76 | assert.equal(typeof (collection.id), 'string') 77 | assert.equal(collection.id, `${doc.id}/replies`) 78 | assert.equal(collection.type, 'https://www.w3.org/ns/activitystreams#OrderedCollection') 79 | assert.equal(collection.totalItems, 0) 80 | assert.equal(collection.first.id, `${doc.id}/replies/1`) 81 | assert.equal(collection.last.id, `${doc.id}/replies/1`) 82 | }) 83 | it('can get a collection page', async () => { 84 | const page = await storage.getCollectionPage(doc.id, 'replies', 1) 85 | assert.equal(typeof page, 'object') 86 | assert.equal(page.id, `${doc.id}/replies/1`) 87 | assert.equal(page.type, 'https://www.w3.org/ns/activitystreams#OrderedCollectionPage') 88 | assert.equal(page.partOf.id, `${doc.id}/replies`) 89 | assert.ok(!page.next) 90 | assert.ok(!page.prev) 91 | assert.ok(!page.items) 92 | }) 93 | it('can add to a collection', async () => { 94 | await storage.addToCollection(doc.id, 'replies', doc2) 95 | const page = await storage.getCollectionPage(doc.id, 'replies', 1) 96 | assert.ok(Array.from(page.items).find(item => item.id === doc2.id)) 97 | }) 98 | it('can check collection membership', async () => { 99 | assert.strictEqual(true, await storage.isInCollection(doc.id, 'replies', doc2)) 100 | assert.strictEqual(false, await storage.isInCollection(doc.id, 'replies', doc3)) 101 | }) 102 | it('can remove from a collection', async () => { 103 | await storage.removeFromCollection(doc.id, 'replies', doc2) 104 | const page = await storage.getCollectionPage(doc.id, 'replies', 1) 105 | assert.ok(!page.items) 106 | }) 107 | it('can add many items to a collection', async () => { 108 | for (let i = 3; i < 103; i++) { 109 | const reply = await as2.import({ 110 | '@context': 'https://www.w3.org/ns/activitystreams', 111 | id: `https://social.example/users/test/note/${i}`, 112 | type: 'Note', 113 | name: 'test', 114 | content: 'test', 115 | inReplyTo: doc.id 116 | }) 117 | await storage.addToCollection(doc.id, 'replies', reply) 118 | } 119 | const collection = await storage.getCollection(doc.id, 'replies') 120 | assert.equal(collection.totalItems, 100) 121 | assert.equal(collection.first.id, `${doc.id}/replies/5`) 122 | assert.equal(collection.last.id, `${doc.id}/replies/1`) 123 | const page = await storage.getCollectionPage(doc.id, 'replies', 3) 124 | assert.ok(page.next) 125 | // assert.ok(page.prev) 126 | assert.ok(page.items) 127 | assert.equal(Array.from(page.items).length, 20) 128 | }) 129 | it('can iterate over a collection', async () => { 130 | const seen = new Set() 131 | for await (const item of storage.items(doc.id, 'replies')) { 132 | assert.ok(!(item.id in seen)) 133 | seen.add(item.id) 134 | } 135 | assert.strictEqual(seen.size, 100) 136 | }) 137 | }) 138 | -------------------------------------------------------------------------------- /tests/remotekeystorage.test.js: -------------------------------------------------------------------------------- 1 | import { describe, before, after, it } from 'node:test' 2 | import { RemoteKeyStorage } from '../lib/remotekeystorage.js' 3 | import assert from 'node:assert' 4 | import { Sequelize } from 'sequelize' 5 | import { KeyStorage } from '../lib/keystorage.js' 6 | import { UrlFormatter } from '../lib/urlformatter.js' 7 | import { ActivityPubClient } from '../lib/activitypubclient.js' 8 | import { nockSetup, nockFormat, getPublicKey, nockKeyRotate } from './utils/nock.js' 9 | import { HTTPSignature } from '../lib/httpsignature.js' 10 | import Logger from 'pino' 11 | import { Digester } from '../lib/digester.js' 12 | 13 | describe('RemoteKeyStorage', async () => { 14 | const origin = 'https://activitypubbot.example' 15 | let connection = null 16 | let remoteKeyStorage = null 17 | let client = null 18 | let logger = null 19 | before(async () => { 20 | logger = Logger({ 21 | level: 'silent' 22 | }) 23 | connection = new Sequelize('sqlite::memory:', { logging: false }) 24 | await connection.authenticate() 25 | const keyStorage = new KeyStorage(connection, logger) 26 | await keyStorage.initialize() 27 | const formatter = new UrlFormatter(origin) 28 | const digester = new Digester(logger) 29 | const signer = new HTTPSignature(logger) 30 | client = new ActivityPubClient(keyStorage, formatter, signer, digester, logger) 31 | nockSetup('social.example') 32 | }) 33 | 34 | after(async () => { 35 | await connection.close() 36 | logger = null 37 | }) 38 | 39 | it('can initialize', async () => { 40 | remoteKeyStorage = new RemoteKeyStorage(client, connection, logger) 41 | assert.ok(remoteKeyStorage) 42 | await remoteKeyStorage.initialize() 43 | assert.ok(true) 44 | }) 45 | 46 | it('can get a remote public key', async () => { 47 | const username = 'test' 48 | const domain = 'social.example' 49 | const id = nockFormat({ username, key: true, domain }) 50 | const publicKey = await getPublicKey(username, domain) 51 | const remote = await remoteKeyStorage.getPublicKey(id) 52 | assert.equal(remote.publicKeyPem, publicKey) 53 | }) 54 | 55 | it('can get the same remote public key twice', async () => { 56 | const username = 'test' 57 | const domain = 'social.example' 58 | const id = nockFormat({ username, key: true, domain }) 59 | const publicKey = await getPublicKey(username, domain) 60 | const remote = await remoteKeyStorage.getPublicKey(id) 61 | assert.equal(remote.publicKeyPem, publicKey) 62 | }) 63 | 64 | it('can get the right public key after key rotation', async () => { 65 | const username = 'test' 66 | const domain = 'social.example' 67 | const id = nockFormat({ username, key: true, domain }) 68 | const publicKey = await getPublicKey(username, domain) 69 | const remote = await remoteKeyStorage.getPublicKey(id) 70 | assert.equal(remote.publicKeyPem, publicKey) 71 | await nockKeyRotate(username) 72 | const publicKey2 = await getPublicKey(username, domain) 73 | const remote2 = await remoteKeyStorage.getPublicKey(id, false) 74 | assert.equal(remote2.publicKeyPem, publicKey2) 75 | }) 76 | }) 77 | -------------------------------------------------------------------------------- /tests/routes.actor.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test' 2 | import assert from 'node:assert' 3 | import { makeApp } from '../lib/app.js' 4 | import request from 'supertest' 5 | import bots from './fixtures/bots.js' 6 | 7 | describe('actor routes', async () => { 8 | const databaseUrl = 'sqlite::memory:' 9 | const origin = 'https://activitypubbot.test' 10 | const app = await makeApp(databaseUrl, origin, bots, 'silent') 11 | 12 | describe('GET /user/{botid}', async () => { 13 | let response = null 14 | it('should work without an error', async () => { 15 | response = await request(app).get('/user/ok') 16 | }) 17 | it('should return 200 OK', async () => { 18 | assert.strictEqual(response.status, 200) 19 | }) 20 | it('should return AS2', async () => { 21 | assert.strictEqual(response.type, 'application/activity+json') 22 | }) 23 | it('should return an object', async () => { 24 | assert.strictEqual(typeof response.body, 'object') 25 | }) 26 | it('should return an object with an id', async () => { 27 | assert.strictEqual(typeof response.body.id, 'string') 28 | }) 29 | it('should return an object with an id matching the request', async () => { 30 | assert.strictEqual(response.body.id, origin + '/user/ok') 31 | }) 32 | it('should return an object with a type', async () => { 33 | assert.strictEqual(typeof response.body.type, 'string') 34 | }) 35 | it('should return an object with a type matching the request', async () => { 36 | assert.strictEqual(response.body.type, 'Service') 37 | }) 38 | it('should return an object with a preferredUsername', async () => { 39 | assert.strictEqual(typeof response.body.preferredUsername, 'string') 40 | }) 41 | it('should return an object with a preferredUsername matching the request', async () => { 42 | assert.strictEqual(response.body.preferredUsername, 'ok') 43 | }) 44 | it('should return an object with an inbox', async () => { 45 | assert.strictEqual(typeof response.body.inbox, 'string') 46 | }) 47 | it('should return an object with an outbox', async () => { 48 | assert.strictEqual(typeof response.body.outbox, 'string') 49 | }) 50 | it('should return an object with a followers', async () => { 51 | assert.strictEqual(typeof response.body.followers, 'string') 52 | }) 53 | it('should return an object with a following', async () => { 54 | assert.strictEqual(typeof response.body.following, 'string') 55 | }) 56 | it('should return an object with a liked', async () => { 57 | assert.strictEqual(typeof response.body.liked, 'string') 58 | }) 59 | it('should return an object with a to', async () => { 60 | assert.strictEqual(typeof response.body.to, 'string') 61 | }) 62 | it('should return an object with a to matching the request', async () => { 63 | assert.strictEqual(response.body.to, 'as:Public') 64 | }) 65 | it('should return an object with a summary', async () => { 66 | assert.strictEqual(typeof response.body.summary, 'string') 67 | }) 68 | it('should return an object with a summary matching the request', async () => { 69 | assert.strictEqual(response.body.summary, 'A bot that says "OK" when mentioned.') 70 | }) 71 | it('should return an object with a name', async () => { 72 | assert.strictEqual(typeof response.body.name, 'string') 73 | }) 74 | it('should return an object with a name matching the request', async () => { 75 | assert.strictEqual(response.body.name, 'OK Bot') 76 | }) 77 | it('should return an object with a publicKey', async () => { 78 | assert.strictEqual(typeof response.body.publicKey, 'object') 79 | assert.ok(response.body.publicKey) 80 | }) 81 | it('should return an object with a publicKey matching the request', async () => { 82 | assert.strictEqual(response.body.publicKey.id, origin + '/user/ok/publickey') 83 | }) 84 | it('should return an object with a publicKey with an owner matching the request', async () => { 85 | assert.strictEqual(response.body.publicKey.owner, origin + '/user/ok') 86 | }) 87 | it('should return an object with a publicKey with a type', async () => { 88 | assert.strictEqual(response.body.publicKey.type, 'CryptographicKey') 89 | }) 90 | it('should return an object with a publicKey with a to', async () => { 91 | assert.strictEqual(response.body.publicKey.to, 'as:Public') 92 | }) 93 | it('should return an object with a publicKey with a publicKeyPem', async () => { 94 | assert.strictEqual(typeof response.body.publicKey.publicKeyPem, 'string') 95 | }) 96 | it('publicKeyPem should be an RSA PKCS-8 key', async () => { 97 | assert.match(response.body.publicKey.publicKeyPem, /^-----BEGIN PUBLIC KEY-----\n/) 98 | assert.match(response.body.publicKey.publicKeyPem, /\n-----END PUBLIC KEY-----\n$/) 99 | }) 100 | }) 101 | 102 | describe('GET non-existent user', async () => { 103 | let response = null 104 | it('should work without an error', async () => { 105 | response = await request(app).get('/user/dne') 106 | }) 107 | it('should return 404 Not Found', async () => { 108 | assert.strictEqual(response.status, 404) 109 | }) 110 | it('should return Problem Details JSON', async () => { 111 | assert.strictEqual(response.type, 'application/problem+json') 112 | }) 113 | it('should return an object', async () => { 114 | assert.strictEqual(typeof response.body, 'object') 115 | }) 116 | it('should return an object with a type', async () => { 117 | assert.strictEqual(typeof response.body.type, 'string') 118 | }) 119 | it('should return an object with an type matching the request', async () => { 120 | assert.strictEqual(response.body.type, 'about:blank') 121 | }) 122 | it('should return an object with a title', async () => { 123 | assert.strictEqual(typeof response.body.title, 'string') 124 | }) 125 | it('should return an object with a title matching the request', async () => { 126 | assert.strictEqual(response.body.title, 'Not Found') 127 | }) 128 | it('should return an object with a status', async () => { 129 | assert.strictEqual(typeof response.body.status, 'number') 130 | }) 131 | it('should return an object with a status matching the request', async () => { 132 | assert.strictEqual(response.body.status, 404) 133 | }) 134 | it('should return an object with a detail', async () => { 135 | assert.strictEqual(typeof response.body.detail, 'string') 136 | }) 137 | it('should return an object with a detail matching the request', async () => { 138 | assert.strictEqual(response.body.detail, 'User dne not found') 139 | }) 140 | }) 141 | describe('GET /user/{dne}/publickey', async () => { 142 | let response = null 143 | it('should work without an error', async () => { 144 | response = await request(app).get('/user/dne') 145 | }) 146 | it('should return 404 Not Found', async () => { 147 | assert.strictEqual(response.status, 404) 148 | }) 149 | it('should return Problem Details JSON', async () => { 150 | assert.strictEqual(response.type, 'application/problem+json') 151 | }) 152 | it('should return the right object', async () => { 153 | assert.strictEqual(typeof response.body, 'object') 154 | assert.strictEqual(response.body.type, 'about:blank') 155 | assert.strictEqual(response.body.title, 'Not Found') 156 | assert.strictEqual(response.body.status, 404) 157 | assert.strictEqual(response.body.detail, 'User dne not found') 158 | }) 159 | }) 160 | 161 | describe('GET /user/{botid}/publickey', async () => { 162 | let response = null 163 | it('should work without an error', async () => { 164 | response = await request(app).get('/user/ok/publickey') 165 | }) 166 | it('should return 200 OK', async () => { 167 | assert.strictEqual(response.status, 200) 168 | }) 169 | it('should return AS2', async () => { 170 | assert.strictEqual(response.type, 'application/activity+json') 171 | }) 172 | it('should return an object', async () => { 173 | assert.strictEqual(typeof response.body, 'object') 174 | }) 175 | it('should return an object with an id', async () => { 176 | assert.strictEqual(typeof response.body.id, 'string') 177 | }) 178 | it('should return an object with the requested public key id', async () => { 179 | assert.strictEqual(response.body.id, origin + '/user/ok/publickey') 180 | }) 181 | it('should return an object with an owner', async () => { 182 | assert.strictEqual(typeof response.body.owner, 'string') 183 | }) 184 | it('should return an object with the bot as owner', async () => { 185 | assert.strictEqual(response.body.owner, origin + '/user/ok') 186 | }) 187 | it('should return an object with a publicKeyPem', async () => { 188 | assert.strictEqual(typeof response.body.publicKeyPem, 'string') 189 | }) 190 | it('publicKeyPem should be an RSA PKCS-8 key', async () => { 191 | assert.match(response.body.publicKeyPem, /^-----BEGIN PUBLIC KEY-----\n/) 192 | assert.match(response.body.publicKeyPem, /\n-----END PUBLIC KEY-----\n$/) 193 | }) 194 | it('should return an object with a type', async () => { 195 | assert.strictEqual(typeof response.body.type, 'string') 196 | }) 197 | it('should return an object with a type matching the request', async () => { 198 | assert.strictEqual(response.body.type, 'CryptographicKey') 199 | }) 200 | it('should return an object with a to', async () => { 201 | assert.strictEqual(typeof response.body.to, 'string') 202 | }) 203 | it('should return an object with a to matching the request', async () => { 204 | assert.strictEqual(response.body.to, 'as:Public') 205 | }) 206 | }) 207 | }) 208 | -------------------------------------------------------------------------------- /tests/routes.health.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test' 2 | import assert from 'node:assert' 3 | import { makeApp } from '../lib/app.js' 4 | import request from 'supertest' 5 | import bots from './fixtures/bots.js' 6 | 7 | describe('health check routes', async () => { 8 | const databaseUrl = 'sqlite::memory:' 9 | const origin = 'https://activitypubbot.test' 10 | const app = await makeApp(databaseUrl, origin, bots, 'silent') 11 | describe('GET /livez', async () => { 12 | let response = null 13 | it('should work without an error', async () => { 14 | response = await request(app).get('/livez') 15 | }) 16 | it('should return 200 OK', async () => { 17 | assert.strictEqual(response.status, 200) 18 | }) 19 | it('should return plain text', async () => { 20 | assert.strictEqual(response.type, 'text/plain') 21 | }) 22 | it('should return an OK flag', async () => { 23 | assert.strictEqual(response.text, 'OK') 24 | }) 25 | }) 26 | describe('GET /readyz', async () => { 27 | let response = null 28 | it('should work without an error', async () => { 29 | response = await request(app).get('/readyz') 30 | }) 31 | it('should return 200 OK', async () => { 32 | assert.strictEqual(response.status, 200) 33 | }) 34 | it('should return plain text', async () => { 35 | assert.strictEqual(response.type, 'text/plain') 36 | }) 37 | it('should return an OK flag', async () => { 38 | assert.strictEqual(response.text, 'OK') 39 | }) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /tests/routes.inbox.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, before } from 'node:test' 2 | import assert from 'node:assert' 3 | import as2 from 'activitystrea.ms' 4 | import request from 'supertest' 5 | 6 | import { makeApp } from '../lib/app.js' 7 | 8 | import { nockSetup, nockSignature, nockFormat } from './utils/nock.js' 9 | import { makeDigest } from './utils/digest.js' 10 | import bots from './fixtures/bots.js' 11 | 12 | describe('routes.inbox', async () => { 13 | const host = 'activitypubbot.test' 14 | const origin = `https://${host}` 15 | const databaseUrl = 'sqlite::memory:' 16 | let app = null 17 | 18 | before(async () => { 19 | nockSetup('social.example') 20 | app = await makeApp(databaseUrl, origin, bots, 'silent') 21 | }) 22 | 23 | describe('can handle an incoming activity', async () => { 24 | const username = 'actor1' 25 | const botName = 'test0' 26 | const path = `/user/${botName}/inbox` 27 | const url = `${origin}${path}` 28 | const date = new Date().toUTCString() 29 | const activity = await as2.import({ 30 | type: 'Activity', 31 | actor: nockFormat({ username }), 32 | id: nockFormat({ username, type: 'activity', num: 1 }) 33 | }) 34 | const body = await activity.write() 35 | const digest = makeDigest(body) 36 | const signature = await nockSignature({ 37 | method: 'POST', 38 | username, 39 | url, 40 | digest, 41 | date 42 | }) 43 | let response = null 44 | it('should work without an error', async () => { 45 | response = await request(app) 46 | .post(path) 47 | .send(body) 48 | .set('Signature', signature) 49 | .set('Date', date) 50 | .set('Host', host) 51 | .set('Digest', digest) 52 | .set('Content-Type', 'application/activity+json') 53 | assert.ok(response) 54 | await app.onIdle() 55 | }) 56 | it('should return a 200 status', async () => { 57 | assert.strictEqual(response.status, 200) 58 | }) 59 | it('should appear in the inbox', async () => { 60 | const { actorStorage } = app.locals 61 | assert.strictEqual( 62 | true, 63 | await actorStorage.isInCollection( 64 | botName, 65 | 'inbox', 66 | activity 67 | ) 68 | ) 69 | }) 70 | }) 71 | describe('can handle a duplicate incoming activity', async () => { 72 | const username = 'actor2' 73 | const botName = 'test1' 74 | const path = `/user/${botName}/inbox` 75 | const url = `${origin}${path}` 76 | const date = new Date().toUTCString() 77 | const activity = await as2.import({ 78 | type: 'Activity', 79 | actor: nockFormat({ username }), 80 | id: nockFormat({ username, type: 'activity', num: 2 }), 81 | to: 'as:Public' 82 | }) 83 | const body = await activity.write() 84 | const digest = makeDigest(body) 85 | const signature = await nockSignature({ 86 | method: 'POST', 87 | username, 88 | url, 89 | digest, 90 | date 91 | }) 92 | let response = null 93 | it('should work without an error', async () => { 94 | response = await request(app) 95 | .post(path) 96 | .send(body) 97 | .set('Signature', signature) 98 | .set('Date', date) 99 | .set('Host', host) 100 | .set('Digest', digest) 101 | .set('Content-Type', 'application/activity+json') 102 | assert.ok(response) 103 | await app.onIdle() 104 | }) 105 | it('should return a 200 status', async () => { 106 | assert.strictEqual(response.status, 200) 107 | }) 108 | it('should appear in the inbox', async () => { 109 | const { actorStorage } = app.locals 110 | assert.strictEqual( 111 | true, 112 | await actorStorage.isInCollection( 113 | botName, 114 | 'inbox', 115 | activity 116 | ) 117 | ) 118 | }) 119 | it('should fail the second time', async () => { 120 | response = await request(app) 121 | .post(path) 122 | .send(body) 123 | .set('Signature', signature) 124 | .set('Date', date) 125 | .set('Host', host) 126 | .set('Digest', digest) 127 | .set('Content-Type', 'application/activity+json') 128 | assert.ok(response) 129 | await app.onIdle() 130 | }) 131 | it('should return a 400 status', async () => { 132 | assert.strictEqual(response.status, 400) 133 | }) 134 | }) 135 | }) 136 | -------------------------------------------------------------------------------- /tests/routes.server.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test' 2 | import assert from 'node:assert' 3 | import { makeApp } from '../lib/app.js' 4 | import request from 'supertest' 5 | import bots from './fixtures/bots.js' 6 | 7 | describe('server routes', async () => { 8 | const databaseUrl = 'sqlite::memory:' 9 | const origin = 'https://activitypubbot.test' 10 | const app = await makeApp(databaseUrl, origin, bots, 'silent') 11 | describe('GET /', async () => { 12 | let response = null 13 | it('should work without an error', async () => { 14 | response = await request(app).get('/') 15 | }) 16 | it('should return 200 OK', async () => { 17 | assert.strictEqual(response.status, 200) 18 | }) 19 | it('should return AS2', async () => { 20 | assert.strictEqual(response.type, 'application/activity+json') 21 | }) 22 | it('should return an object', async () => { 23 | assert.strictEqual(typeof response.body, 'object') 24 | }) 25 | it('should return an object with an id', async () => { 26 | assert.strictEqual(typeof response.body.id, 'string') 27 | }) 28 | it('should return an object with an id matching the origin', async () => { 29 | assert.strictEqual(response.body.id, origin + '/') 30 | }) 31 | it('should return an object with a publicKey', async () => { 32 | assert.strictEqual(typeof response.body.publicKey, 'string') 33 | }) 34 | }) 35 | describe('GET /publickey', async () => { 36 | let response = null 37 | it('should work without an error', async () => { 38 | response = await request(app).get('/publickey') 39 | }) 40 | it('should return 200 OK', async () => { 41 | assert.strictEqual(response.status, 200) 42 | }) 43 | it('should return AS2', async () => { 44 | assert.strictEqual(response.type, 'application/activity+json') 45 | }) 46 | it('should return an object', async () => { 47 | assert.strictEqual(typeof response.body, 'object') 48 | }) 49 | it('should return an object with an id', async () => { 50 | assert.strictEqual(typeof response.body.id, 'string') 51 | }) 52 | it('should return an object with an id matching the origin', async () => { 53 | assert.strictEqual(response.body.id, origin + '/publickey') 54 | }) 55 | it('should return an object with an owner', async () => { 56 | assert.strictEqual(typeof response.body.owner, 'string') 57 | }) 58 | it('should return an object with the origin as owner', async () => { 59 | assert.strictEqual(response.body.owner, origin + '/') 60 | }) 61 | it('should return an object with a publicKeyPem', async () => { 62 | assert.strictEqual(typeof response.body.publicKeyPem, 'string') 63 | }) 64 | it('publicKeyPem should be an RSA PKCS-8 key', async () => { 65 | assert.match(response.body.publicKeyPem, /^-----BEGIN PUBLIC KEY-----\n/) 66 | assert.match(response.body.publicKeyPem, /\n-----END PUBLIC KEY-----\n$/) 67 | }) 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /tests/routes.webfinger.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test' 2 | import assert from 'node:assert' 3 | import { makeApp } from '../lib/app.js' 4 | import request from 'supertest' 5 | import bots from './fixtures/bots.js' 6 | 7 | describe('webfinger routes', async () => { 8 | const databaseUrl = 'sqlite::memory:' 9 | const origin = 'https://activitypubbot.test' 10 | const app = await makeApp(databaseUrl, origin, bots, 'silent') 11 | describe('GET /.well-known/webfinger', async () => { 12 | let response = null 13 | it('should work without an error', async () => { 14 | response = await request(app).get('/.well-known/webfinger?resource=acct%3Aok%40activitypubbot.test') 15 | }) 16 | it('should return 200 OK', async () => { 17 | assert.strictEqual(response.status, 200) 18 | }) 19 | it('should return JRD', async () => { 20 | assert.strictEqual(response.type, 'application/jrd+json') 21 | }) 22 | it('should return an object with a subject', async () => { 23 | assert.strictEqual(typeof response.body.subject, 'string') 24 | }) 25 | it('should return an object with an subject matching the request', async () => { 26 | assert.strictEqual(response.body.subject, 'acct:ok@activitypubbot.test') 27 | }) 28 | it('should return an object with a links array', async () => { 29 | assert.strictEqual(Array.isArray(response.body.links), true) 30 | }) 31 | it('should return an object with a links array containing the actor id', async () => { 32 | assert.strictEqual(response.body.links.length, 1) 33 | assert.strictEqual(typeof response.body.links[0].rel, 'string') 34 | assert.strictEqual(response.body.links[0].rel, 'self') 35 | assert.strictEqual(typeof response.body.links[0].type, 'string') 36 | assert.strictEqual(response.body.links[0].type, 'application/activity+json') 37 | assert.strictEqual(typeof response.body.links[0].href, 'string') 38 | assert.strictEqual(response.body.links[0].href, 'https://activitypubbot.test/user/ok') 39 | }) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /tests/urlformatter.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test' 2 | import assert from 'node:assert' 3 | import { UrlFormatter } from '../lib/urlformatter.js' 4 | 5 | describe('UrlFormatter', () => { 6 | const origin = 'https://activitypubbot.example' 7 | let formatter = null 8 | it('can initialize', () => { 9 | formatter = new UrlFormatter(origin) 10 | }) 11 | it('can format a user URL', () => { 12 | const url = formatter.format({ username: 'megabot' }) 13 | assert.equal(url, 'https://activitypubbot.example/user/megabot') 14 | }) 15 | it('can format a public key URL', () => { 16 | const url = formatter.format({ username: 'megabot', type: 'publickey' }) 17 | assert.equal(url, 'https://activitypubbot.example/user/megabot/publickey') 18 | }) 19 | it('can format an inbox URL', () => { 20 | const url = formatter.format({ username: 'megabot', collection: 'inbox' }) 21 | assert.equal(url, 'https://activitypubbot.example/user/megabot/inbox') 22 | }) 23 | it('can format an inbox URL page', () => { 24 | const url = formatter.format({ 25 | username: 'megabot', 26 | collection: 'inbox', 27 | page: 3 28 | }) 29 | assert.equal(url, 'https://activitypubbot.example/user/megabot/inbox/3') 30 | }) 31 | it('can format an activity URL', () => { 32 | const url = formatter.format({ 33 | username: 'megabot', 34 | type: 'like', 35 | nanoid: 'LNPUlv9kmvhAdr4eoqkil' 36 | }) 37 | assert.equal(url, 'https://activitypubbot.example/user/megabot/like/LNPUlv9kmvhAdr4eoqkil') 38 | }) 39 | it('can format a note URL', () => { 40 | const url = formatter.format({ 41 | username: 'megabot', 42 | type: 'note', 43 | nanoid: 'LNPUlv9kmvhAdr4eoqkil' 44 | }) 45 | assert.equal(url, 'https://activitypubbot.example/user/megabot/note/LNPUlv9kmvhAdr4eoqkil') 46 | }) 47 | it('can format a note replies URL', () => { 48 | const url = formatter.format({ 49 | username: 'megabot', 50 | type: 'note', 51 | nanoid: 'LNPUlv9kmvhAdr4eoqkil', 52 | collection: 'replies' 53 | }) 54 | assert.equal(url, 'https://activitypubbot.example/user/megabot/note/LNPUlv9kmvhAdr4eoqkil/replies') 55 | }) 56 | it('can format a note replies page URL', () => { 57 | const url = formatter.format({ 58 | username: 'megabot', 59 | type: 'note', 60 | nanoid: 'LNPUlv9kmvhAdr4eoqkil', 61 | collection: 'replies', 62 | page: 4 63 | }) 64 | assert.equal(url, 'https://activitypubbot.example/user/megabot/note/LNPUlv9kmvhAdr4eoqkil/replies/4') 65 | }) 66 | it('can format a server URL', () => { 67 | const url = formatter.format({ 68 | server: true 69 | }) 70 | assert.equal(url, 'https://activitypubbot.example/') 71 | }) 72 | it('can format a server public key URL', () => { 73 | const url = formatter.format({ 74 | server: true, 75 | type: 'publickey' 76 | }) 77 | assert.equal(url, 'https://activitypubbot.example/publickey') 78 | }) 79 | it('can tell if an URL is local', () => { 80 | assert.ok(formatter.isLocal('https://activitypubbot.example/user/megabot')) 81 | assert.ok(!formatter.isLocal('https://social.example/user/megabot')) 82 | }) 83 | it('can get a username from a user URL', () => { 84 | const username = formatter.getUserName('https://activitypubbot.example/user/megabot') 85 | assert.equal(username, 'megabot') 86 | }) 87 | }) 88 | -------------------------------------------------------------------------------- /tests/utils/digest.js: -------------------------------------------------------------------------------- 1 | import crypto from 'node:crypto' 2 | 3 | export function makeDigest (body) { 4 | const digest = crypto.createHash('sha256') 5 | digest.update(body) 6 | return `sha-256=${digest.digest('base64')}` 7 | } 8 | -------------------------------------------------------------------------------- /tests/utils/nock.js: -------------------------------------------------------------------------------- 1 | import as2 from 'activitystrea.ms' 2 | import nock from 'nock' 3 | import crypto from 'node:crypto' 4 | import { promisify } from 'node:util' 5 | 6 | const generateKeyPair = promisify(crypto.generateKeyPair) 7 | 8 | const domains = new Map() 9 | domains['social.example'] = new Map() 10 | 11 | const newKeyPair = async () => { 12 | return await generateKeyPair( 13 | 'rsa', 14 | { 15 | modulusLength: 2048, 16 | privateKeyEncoding: { 17 | type: 'pkcs8', 18 | format: 'pem' 19 | }, 20 | publicKeyEncoding: { 21 | type: 'spki', 22 | format: 'pem' 23 | } 24 | } 25 | ) 26 | } 27 | 28 | export const getPair = async (username, domain = 'social.example') => { 29 | if (!domains.has(domain)) { 30 | domains.set(domain, new Map()) 31 | } 32 | if (!domains.get(domain).has(username)) { 33 | const pair = await newKeyPair(username) 34 | domains.get(domain).set(username, pair) 35 | } 36 | return domains.get(domain).get(username) 37 | } 38 | 39 | export const getPublicKey = async (username, domain = 'social.example') => { 40 | const pair = await getPair(username, domain) 41 | return pair.publicKey 42 | } 43 | 44 | export const getPrivateKey = async (username, domain = 'social.example') => { 45 | const pair = await getPair(username, domain) 46 | return pair.privateKey 47 | } 48 | 49 | export const nockSignature = async ({ method = 'GET', url, date, digest = null, username, domain = 'social.example' }) => { 50 | const privateKey = await getPrivateKey(username, domain) 51 | const keyId = nockFormat({ username, key: true, domain }) 52 | const parsed = new URL(url) 53 | const target = (parsed.search && parsed.search.length) 54 | ? `${parsed.pathname}${parsed.search}` 55 | : `${parsed.pathname}` 56 | let data = `(request-target): ${method.toLowerCase()} ${target}\n` 57 | data += `host: ${parsed.host}\n` 58 | data += `date: ${date}` 59 | if (digest) { 60 | data += `\ndigest: ${digest}` 61 | } 62 | const signer = crypto.createSign('sha256') 63 | signer.update(data) 64 | const signature = signer.sign(privateKey).toString('base64') 65 | signer.end() 66 | return `keyId="${keyId}",headers="(request-target) host date${(digest) ? ' digest' : ''}",signature="${signature.replace(/"/g, '\\"')}",algorithm="rsa-sha256"` 67 | } 68 | 69 | export const nockSignatureFragment = async ({ method = 'GET', url, date, digest = null, username, domain = 'social.example' }) => { 70 | const keyId = nockFormat({ username, domain }) + '#main-key' 71 | const privateKey = await getPrivateKey(username, domain) 72 | const parsed = new URL(url) 73 | const target = (parsed.search && parsed.search.length) 74 | ? `${parsed.pathname}?${parsed.search}` 75 | : `${parsed.pathname}` 76 | let data = `(request-target): ${method.toLowerCase()} ${target}\n` 77 | data += `host: ${parsed.host}\n` 78 | data += `date: ${date}` 79 | if (digest) { 80 | data += `\ndigest: ${digest}` 81 | } 82 | const signer = crypto.createSign('sha256') 83 | signer.update(data) 84 | const signature = signer.sign(privateKey).toString('base64') 85 | signer.end() 86 | return `keyId="${keyId}",headers="(request-target) host date${(digest) ? ' digest' : ''}",signature="${signature.replace(/"/g, '\\"')}",algorithm="rsa-sha256"` 87 | } 88 | 89 | export const nockKeyRotate = async (username, domain = 'social.example') => 90 | domains.get(domain).set(username, await newKeyPair(username)) 91 | 92 | export const makeActor = async (username, domain = 'social.example') => 93 | await as2.import({ 94 | '@context': [ 95 | 'https://www.w3.org/ns/activitystreams', 96 | 'https://w3id.org/security/v1' 97 | ], 98 | id: `https://${domain}/user/${username}`, 99 | type: 'Person', 100 | preferredUsername: username, 101 | inbox: `https://${domain}/user/${username}/inbox`, 102 | outbox: `https://${domain}/user/${username}/outbox`, 103 | followers: `https://${domain}/user/${username}/followers`, 104 | following: `https://${domain}/user/${username}/following`, 105 | liked: `https://${domain}/user/${username}/liked`, 106 | to: ['as:Public'], 107 | publicKey: { 108 | id: `https://${domain}/user/${username}/publickey`, 109 | type: 'CryptographicKey', 110 | owner: `https://${domain}/user/${username}`, 111 | publicKeyPem: await getPublicKey(username, domain) 112 | }, 113 | url: { 114 | type: 'Link', 115 | href: `https://${domain}/profile/${username}`, 116 | mediaType: 'text/html' 117 | } 118 | }) 119 | 120 | // Just the types we use here 121 | const isActivityType = (type) => ['Create', 'Update', 'Delete', 'Add', 'Remove', 'Follow', 'Accept', 'Reject', 'Like', 'Block', 'Flag', 'Undo'].includes(uppercase(type)) 122 | 123 | export const makeObject = async (username, type, num, domain = 'social.example') => 124 | as2.import({ 125 | id: nockFormat({ username, type, num, domain }), 126 | type: uppercase(type), 127 | to: 'as:Public', 128 | actor: (isActivityType(type) ? nockFormat({ username, domain }) : undefined), 129 | attributedTo: (isActivityType(type) ? undefined : nockFormat({ username, domain })) 130 | }) 131 | 132 | export const makeTransitive = (username, type, num, obj, domain = 'social.example') => 133 | as2.import({ 134 | id: nockFormat({ username, type, num, obj, domain }), 135 | type: uppercase(type), 136 | to: 'as:Public', 137 | actor: nockFormat({ username, domain }), 138 | object: `https://${obj}` 139 | }) 140 | 141 | const uppercase = (str) => str.charAt(0).toUpperCase() + str.slice(1) 142 | const lowercase = (str) => str.charAt(0).toLowerCase() + str.slice(1) 143 | 144 | export const postInbox = {} 145 | 146 | export const resetInbox = () => { 147 | for (const username in postInbox) { 148 | postInbox[username] = 0 149 | } 150 | } 151 | 152 | export const nockSetup = (domain) => 153 | nock(`https://${domain}`) 154 | .get(/^\/.well-known\/webfinger/) 155 | .reply(async (uri, requestBody) => { 156 | const parsed = new URL(uri, `https://${domain}`) 157 | const resource = parsed.searchParams.get('resource') 158 | if (!resource) { 159 | return [400, 'Bad Request'] 160 | } 161 | const username = resource.slice(5).split('@')[0] 162 | const webfinger = { 163 | subject: resource, 164 | links: [ 165 | { 166 | rel: 'self', 167 | type: 'application/activity+json', 168 | href: `https://${domain}/user/${username}` 169 | } 170 | ] 171 | } 172 | return [200, 173 | JSON.stringify(webfinger), 174 | { 'Content-Type': 'application/jrd+json' }] 175 | }) 176 | .get(/^\/user\/(\w+)$/) 177 | .reply(async (uri, requestBody) => { 178 | const username = uri.match(/^\/user\/(\w+)$/)[1] 179 | const actor = await makeActor(username, domain) 180 | const actorText = await actor.prettyWrite( 181 | { additional_context: 'https://w3id.org/security/v1' } 182 | ) 183 | return [200, actorText, { 'Content-Type': 'application/activity+json' }] 184 | }) 185 | .persist() 186 | .post(/^\/user\/(\w+)\/inbox$/) 187 | .reply(async (uri, requestBody) => { 188 | const username = uri.match(/^\/user\/(\w+)\/inbox$/)[1] 189 | if (username in postInbox) { 190 | postInbox[username] += 1 191 | } else { 192 | postInbox[username] = 1 193 | } 194 | return [202, 'accepted'] 195 | }) 196 | .persist() 197 | .get(/^\/user\/(\w+)\/publickey$/) 198 | .reply(async (uri, requestBody) => { 199 | const username = uri.match(/^\/user\/(\w+)\/publickey$/)[1] 200 | const publicKey = await as2.import({ 201 | '@context': [ 202 | 'https://www.w3.org/ns/activitystreams', 203 | 'https://w3id.org/security/v1' 204 | ], 205 | id: `https://${domain}/user/${username}/publickey`, 206 | owner: `https://${domain}/user/${username}`, 207 | type: 'CryptographicKey', 208 | publicKeyPem: await getPublicKey(username, domain) 209 | }) 210 | const publicKeyText = await publicKey.prettyWrite( 211 | { additional_context: 'https://w3id.org/security/v1' } 212 | ) 213 | return [200, publicKeyText, { 'Content-Type': 'application/activity+json' }] 214 | }) 215 | .persist() 216 | .get(/^\/user\/(\w+)\/(\w+)\/(\d+)$/) 217 | .reply(async (uri, requestBody) => { 218 | const match = uri.match(/^\/user\/(\w+)\/(\w+)\/(\d+)$/) 219 | const username = match[1] 220 | const type = uppercase(match[2]) 221 | const num = match[3] 222 | const obj = await makeObject(username, type, num, domain) 223 | const objText = await obj.write() 224 | return [200, objText, { 'Content-Type': 'application/activity+json' }] 225 | }) 226 | .persist() 227 | .get(/^\/user\/(\w+)\/(\w+)\/(\d+)\/(.*)$/) 228 | .reply(async (uri, requestBody) => { 229 | const match = uri.match(/^\/user\/(\w+)\/(\w+)\/(\d+)\/(.*)$/) 230 | const username = match[1] 231 | const type = match[2] 232 | const num = match[3] 233 | const obj = match[4] 234 | const act = await makeTransitive(username, type, num, obj, domain) 235 | const actText = await act.write() 236 | return [200, actText, { 'Content-Type': 'application/activity+json' }] 237 | }) 238 | 239 | export function nockFormat ({ username, type, num, obj, key, domain = 'social.example' }) { 240 | let url = `https://${domain}/user/${username}` 241 | if (key) { 242 | url = `${url}/publickey` 243 | } else { 244 | if (type && num) { 245 | url = `${url}/${lowercase(type)}/${num}` 246 | if (obj) { 247 | if (obj.startsWith('https://')) { 248 | url = `${url}/${obj.slice(8)}` 249 | } else { 250 | url = `${url}/${obj}` 251 | } 252 | } 253 | } 254 | } 255 | return url 256 | } 257 | --------------------------------------------------------------------------------