├── img └── ghost-logo-orb.jpg ├── testData ├── database-v1-test.db ├── database-v1-test-user.db ├── follow_request.json └── delete_request.json ├── src ├── ghost.js ├── routes │ ├── outbox.js │ ├── featured.js │ ├── liked.js │ ├── inbox.js │ ├── wellKnown.js │ ├── tags.js │ ├── actor.js │ ├── profile.js │ └── post.js ├── gen_keys.js ├── environment.js ├── constants.js ├── OrderedCollection.js ├── CollectionResource.js ├── utils.js ├── activitypub.js └── db.js ├── .eslintrc.json ├── migrations ├── 20230324163902_v0.2.js └── 20230324163903_v0.3.js ├── ghostcms_activitypub.service ├── bin ├── new_release.js ├── manual_post.js └── www.js ├── package.json ├── LICENSE.txt ├── .gitignore ├── .env.template ├── app.js └── README.md /img/ghost-logo-orb.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josephquigley/ghostcms-activitypub/HEAD/img/ghost-logo-orb.jpg -------------------------------------------------------------------------------- /testData/database-v1-test.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josephquigley/ghostcms-activitypub/HEAD/testData/database-v1-test.db -------------------------------------------------------------------------------- /testData/database-v1-test-user.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josephquigley/ghostcms-activitypub/HEAD/testData/database-v1-test-user.db -------------------------------------------------------------------------------- /src/ghost.js: -------------------------------------------------------------------------------- 1 | import pkg from '@tryghost/content-api' 2 | const GhostContentAPI = pkg 3 | 4 | export const Ghost = new GhostContentAPI({ 5 | url: `https://${process.env.GHOST_SERVER}`, 6 | key: process.env.GHOST_CONTENT_API_KEY, 7 | version: 'v5.0' 8 | }) 9 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es2021": true 6 | }, 7 | "extends": "standard", 8 | "overrides": [ 9 | ], 10 | "parserOptions": { 11 | "ecmaVersion": "latest" 12 | }, 13 | "rules": { 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/routes/outbox.js: -------------------------------------------------------------------------------- 1 | import { url } from '../constants.js' 2 | import { CollectionResource } from '../CollectionResource.js' 3 | 4 | const collectionResource = new CollectionResource({ id: url.outbox }) 5 | 6 | export const outbox = collectionResource.rootRouter 7 | export const outboxPage = collectionResource.pageRouter 8 | -------------------------------------------------------------------------------- /testData/follow_request.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": "https://www.w3.org/ns/activitystreams", 3 | "id": "https://example.com/952feac3-591a-4a00-a2c0-ded179f1f88e", 4 | "type": "Follow", 5 | "actor": "https://example.com/users/quigs", 6 | "object": "https://74bd-24-247-114-185.ngrok.io/actors/test_quigs_blog" 7 | } 8 | -------------------------------------------------------------------------------- /src/routes/featured.js: -------------------------------------------------------------------------------- 1 | import { url } from '../constants.js' 2 | import { CollectionResource } from '../CollectionResource.js' 3 | 4 | const collectionResource = new CollectionResource({ id: url.featured, query: { filter: 'featured:true' } }) 5 | 6 | export const featuredRoute = collectionResource.rootRouter 7 | export const featuredPageRoute = collectionResource.pageRouter 8 | -------------------------------------------------------------------------------- /src/routes/liked.js: -------------------------------------------------------------------------------- 1 | import { url } from '../constants.js' 2 | import { OrderedCollection } from '../OrderedCollection.js' 3 | 4 | export const likedRoute = (req, res, next) => { 5 | try { 6 | const payload = new OrderedCollection({ id: url.liked, totalItems: 0 }) 7 | res.json(payload) 8 | return 9 | } catch (err) { 10 | console.error('Could not fetch Ghost posts:\n' + err) 11 | res.status(500).send() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /migrations/20230324163902_v0.2.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param { import('knex').Knex } knex 3 | * @returns { Promise } 4 | */ 5 | 6 | export const up = async function (knex) { 7 | if (!await knex.schema.hasTable('followers')) { 8 | await knex.schema.createTable('followers', table => { 9 | table.string('follower_uri') 10 | table.timestamp('date_followed').defaultTo(knex.fn.now()) 11 | table.timestamp('date_failed') 12 | }) 13 | } 14 | } 15 | 16 | /** 17 | * @param { import('knex').Knex } knex 18 | * @returns { Promise } 19 | */ 20 | export const down = async function (knex) { 21 | await knex.schema.dropTable('followers') 22 | } 23 | -------------------------------------------------------------------------------- /testData/delete_request.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": "https://www.w3.org/ns/activitystreams", 3 | "id": "https://hachyderm.io/users/jun886#delete", 4 | "type": "Delete", 5 | "actor": "https://hachyderm.io/users/jun886", 6 | "to": [ 7 | "https://www.w3.org/ns/activitystreams#Public" 8 | ], 9 | "object": "https://hachyderm.io/users/jun886", 10 | "signature": { 11 | "type": "RsaSignature2017", 12 | "creator": "https://hachyderm.io/users/jun886#main-key", 13 | "created": "2023-03-24T22:38:34Z", 14 | "signatureValue": "ObpZGGkJHErqWAlE09Kd3uTwpQvi6FteyMNNXFkArNyXGgr0KIMz08o2rOwZNhk8H+3vGEU7JGSC8R7xsohQJgl+>" 15 | } 16 | } -------------------------------------------------------------------------------- /ghostcms_activitypub.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=GhostCMS ActivityPub Server 3 | 4 | [Service] 5 | 6 | # make sure this user and group exist and have read and write permissions in your GhostCMS ActivityPub server folder. 7 | # if they do not exist yet create them with "sudo useradd -r username_here" 8 | # then give them permission with "chown -R username_here:username_here /ghostcms_activitypub_path" (path to your GhostCMS ActivityPub server folder) 9 | # you can adjust the users name according to your setup 10 | User= 11 | Group= 12 | Type=simple 13 | Restart=always 14 | RestartSec=1 15 | ExecStart=/bin/npm --prefix /ghostcms_activitypub_path start 16 | 17 | [Install] 18 | WantedBy=multi-user.target 19 | -------------------------------------------------------------------------------- /bin/new_release.js: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process' 2 | import fs from 'fs' 3 | 4 | let version = process.argv[2] 5 | const packageJsonPath = 'package.json' 6 | 7 | if (!version) { 8 | console.error('Please provide a semantic version argument (Eg: 1.0.4)') 9 | process.exit(1) 10 | } 11 | 12 | version = version.replace(/^v/, '') 13 | 14 | try { 15 | const packageJson = JSON.parse(fs.readFileSync(packageJsonPath)) 16 | packageJson.version = version 17 | 18 | fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)) 19 | execSync(`git commit ${packageJsonPath} -m 'Setting package version to ${version}' && git tag v${version} && git push --tags`) 20 | } catch (err) { 21 | console.error(err.message) 22 | process.exit(1) 23 | } 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ghostcms-activitypub", 3 | "version": "0.4.1", 4 | "private": true, 5 | "license": { 6 | "type": "MIT", 7 | "url": "https://opensource.org/licenses/MIT" 8 | }, 9 | "type": "module", 10 | "scripts": { 11 | "start": "NODE_ENV=production node ./bin/www.js", 12 | "manual_post": "./bin/manual_post.js", 13 | "lint": "npx eslint --fix app.js bin/ src/ migrations/", 14 | "dev": "npm run lint && NODE_ENV=development DEBUG=ghostcms-activitypub:* node ./bin/www.js", 15 | "newRelease": "node ./bin/new_release.js" 16 | }, 17 | "dependencies": { 18 | "@tryghost/content-api": "^1.11.7", 19 | "bent": "^7.3.12", 20 | "debug": "~2.6.9", 21 | "dotenv": "^16.0.3", 22 | "express": "^4.18.2", 23 | "knex": "^2.4.2", 24 | "morgan": "~1.9.1", 25 | "p-queue": "^7.3.4", 26 | "sqlite3": "^5.1.6" 27 | }, 28 | "devDependencies": { 29 | "eslint": "^8.36.0", 30 | "eslint-config-standard": "^17.0.0", 31 | "eslint-plugin-import": "^2.27.5", 32 | "eslint-plugin-n": "^15.6.1", 33 | "eslint-plugin-promise": "^6.1.1" 34 | } 35 | } -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Joseph Quigley 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/gen_keys.js: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process' 2 | import fs from 'fs' 3 | import crypto from 'crypto' 4 | 5 | let didInit = false 6 | 7 | export function genKeys (filePath) { 8 | function readKeys () { 9 | return { 10 | public: fs.readFileSync(filePath.publicKey, 'utf8'), 11 | private: fs.readFileSync(filePath.privateKey, 'utf8'), 12 | api: fs.readFileSync(filePath.apiKey, 'utf8') 13 | } 14 | } 15 | 16 | if (didInit) { 17 | return readKeys() 18 | } 19 | 20 | try { 21 | if (!fs.existsSync(filePath.certsDir)) { 22 | fs.mkdirSync(filePath.certsDir, { recursive: true }) 23 | } 24 | 25 | if (!fs.existsSync(filePath.privateKey)) { 26 | // If the private key does not exist then create it 27 | execSync(`openssl genrsa -out ${filePath.privateKey} && openssl rsa -in ${filePath.privateKey} -pubout -out ${filePath.publicKey}`) 28 | } 29 | 30 | if (!fs.existsSync(filePath.apiKey)) { 31 | fs.writeFileSync(filePath.apiKey, crypto.randomUUID()) 32 | } 33 | } catch (err) { 34 | console.error(err) 35 | process.exit(1) 36 | } 37 | 38 | didInit = true 39 | 40 | return readKeys() 41 | } 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Data folder 2 | data 3 | data/* 4 | 5 | dataBackup 6 | dataBackup/* 7 | 8 | # Logs 9 | logs 10 | *.log 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | 15 | # Runtime data 16 | pids 17 | *.pid 18 | *.seed 19 | *.pid.lock 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | 27 | # nyc test coverage 28 | .nyc_output 29 | 30 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 31 | .grunt 32 | 33 | # Bower dependency directory (https://bower.io/) 34 | bower_components 35 | 36 | # node-waf configuration 37 | .lock-wscript 38 | 39 | # Compiled binary addons (https://nodejs.org/api/addons.html) 40 | build/Release 41 | 42 | # Dependency directories 43 | node_modules/ 44 | jspm_packages/ 45 | 46 | # Typescript v1 declaration files 47 | typings/ 48 | 49 | # Optional npm cache directory 50 | .npm 51 | 52 | # Optional eslint cache 53 | .eslintcache 54 | 55 | # Optional REPL history 56 | .node_repl_history 57 | 58 | # Output of 'npm pack' 59 | *.tgz 60 | 61 | # Yarn Integrity file 62 | .yarn-integrity 63 | 64 | # dotenv environment variables file 65 | .env 66 | 67 | # next.js build output 68 | .next 69 | 70 | .DS_Store -------------------------------------------------------------------------------- /src/environment.js: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv' 2 | config() 3 | 4 | /** Sanitize user inputs **/ 5 | if (process.env.GHOST_SERVER === '') { 6 | throw new Error('.env GHOST_SERVER value is required. Got empty.') 7 | } 8 | 9 | process.env.GHOST_SERVER = process.env.GHOST_SERVER.replace(/^http[s]*:\/\//, '').replace(/\/\s*$/g, '') 10 | 11 | if (process.env.ACCOUNT_USERNAME === '') { 12 | throw new Error('.env ACCOUNT_USERNAME value is required. Got empty.') 13 | } 14 | 15 | if (process.env.GHOST_CONTENT_API_KEY === '') { 16 | throw new Error('.env GHOST_CONTENT_API_KEY value is required. Got empty.') 17 | } 18 | 19 | process.env.API_ROOT_PATH = process.env.API_ROOT_PATH.replace(/\s+/g, '') 20 | if (!process.env.API_ROOT_PATH.startsWith('/')) { 21 | process.env.API_ROOT_PATH = '/' + process.env.API_ROOT_PATH 22 | } 23 | 24 | process.env.API_ROOT_PATH = process.env.API_ROOT_PATH.replace(/\/\s*$/g, '') 25 | 26 | if (process.env.SERVER_DOMAIN === '') { 27 | process.env.SERVER_DOMAIN = process.env.GHOST_SERVER 28 | } 29 | process.env.SERVER_DOMAIN = process.env.SERVER_DOMAIN.replace(/^http[s]*:\/\//, '').replace(/\/\s*$/g, '') 30 | 31 | if (process.env.PROFILE_URL === '') { 32 | process.env.PROFILE_URL = 'https://' + process.env.GHOST_SERVER 33 | } 34 | 35 | if (process.env.SHOW_FOLLOWERS === '') { 36 | process.env.SHOW_FOLLOWERS = 'true' 37 | } 38 | 39 | /** End Sanitize user inputs **/ 40 | -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | # ------ REQUIRED ------ # 2 | GHOST_SERVER= 3 | 4 | # Ghost Content API Key to fill in Fediverse account info from 5 | GHOST_CONTENT_API_KEY= 6 | 7 | # The account to publish content from eg: @user@example.com 8 | ACCOUNT_USERNAME=example_blog 9 | 10 | PORT=3000 11 | 12 | # ----- Optional ----- # 13 | # The domain that this activitypub server runs on. Defaults to the Ghost domain if empty. 14 | # If empty, ensure that a reverse proxy is set up to route api calls from the Ghost server to this Express server! 15 | SERVER_DOMAIN= 16 | 17 | # Defaults to Ghost title if empty 18 | ACCOUNT_NAME= 19 | 20 | # Optional, defaults to Ghost domain if empty. Could be set to a specific author, tag, or any other url 21 | PROFILE_URL= 22 | 23 | # Path to serve content from (to avoid URL namespace collisions with Ghost if run on the same domain) 24 | # Eg: API_ROOT_PATH=/activitypub means Mastodon servers will query https://example.com/activitypub/actors/user for @user@example.com profile information 25 | API_ROOT_PATH=/ 26 | 27 | # Optional url to the Ghost publication's owner's account. Eg: https://mastodon.social/@ExampleUser 28 | FEDIVERSE_ACCOUNT_URL= 29 | 30 | # Limit the number of concurrent ActivityPub actions (primarily used for 'queuing' messages to followers). 31 | # Defaults to 100 if not set 32 | ACTIVITY_PUB_CONCURRENCY_LIMIT= 33 | 34 | # Show followers (true/false). Defaults to true if not set 35 | SHOW_FOLLOWERS= -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | import './environment.js' 2 | import { genKeys } from './gen_keys.js' 3 | 4 | const dataDir = `${process.cwd()}/data` 5 | const certsDir = `${dataDir}/certs` 6 | 7 | const actorPath = `${process.env.API_ROOT_PATH}/actors` 8 | const accountURL = `https://${process.env.SERVER_DOMAIN}${actorPath}/${process.env.ACCOUNT_USERNAME}` 9 | 10 | const urlPaths = { 11 | actor: actorPath, 12 | staticImages: `${process.env.API_ROOT_PATH}/img`, 13 | publicKey: '/public_key', 14 | tags: `${process.env.API_ROOT_PATH}/tags`, 15 | publish: `${process.env.API_ROOT_PATH}/publish`, 16 | delete: `${process.env.API_ROOT_PATH}/delete`, 17 | following: '/following', 18 | followers: '/followers', 19 | inbox: '/inbox', 20 | outbox: '/outbox', 21 | featured: '/featured', 22 | liked: '/liked' 23 | } 24 | 25 | export const filePath = { 26 | apiKey: `${dataDir}/apiKey.txt`, 27 | privateKey: `${certsDir}/key.pem`, 28 | publicKey: `${certsDir}/pubkey.pem`, 29 | database: `${dataDir}/database.db`, 30 | dataDir, 31 | certsDir 32 | } 33 | 34 | export const key = genKeys(filePath) 35 | 36 | export const url = { 37 | path: urlPaths, 38 | profile: 'https://' + process.env.PROFILE_URL.replace(/^http[s]*:\/\//, ''), 39 | publicKey: `${accountURL}${urlPaths.publicKey}`, 40 | account: accountURL, 41 | inbox: `${accountURL}${urlPaths.inbox}`, 42 | outbox: `${accountURL}${urlPaths.outbox}`, 43 | featured: `${accountURL}${urlPaths.featured}`, 44 | followers: `${accountURL}${urlPaths.followers}`, 45 | following: `${accountURL}${urlPaths.following}`, 46 | liked: `${accountURL}${urlPaths.liked}`, 47 | tags: `https://${process.env.SERVER_DOMAIN}${urlPaths.tags}`, 48 | publish: `https://${process.env.SERVER_DOMAIN}${urlPaths.publish}`, 49 | delete: `https://${process.env.SERVER_DOMAIN}${urlPaths.delete}` 50 | } 51 | -------------------------------------------------------------------------------- /src/routes/inbox.js: -------------------------------------------------------------------------------- 1 | import { Database } from '../db.js' 2 | import { url } from '../constants.js' 3 | import ActivityPub from '../activitypub.js' 4 | const db = new Database() 5 | 6 | function isFollowAccount (message) { 7 | return message.type === 'Follow' && message.object === url.account 8 | } 9 | 10 | function isUnfollowAccount (message) { 11 | return message.type === 'Undo' && isFollowAccount(message.object) 12 | } 13 | 14 | function isDeleteAccount (message) { 15 | return message.type === 'Delete' && message.object === message.actor 16 | } 17 | 18 | export const postInbox = async function (req, res) { 19 | const payload = req.body 20 | 21 | try { 22 | if (isUnfollowAccount(payload)) { 23 | ActivityPub.enqueue(async () => { 24 | await db.deleteFollowerWithUri(payload.object.actor) 25 | await ActivityPub.sendAcceptMessage(payload) 26 | }) 27 | } else if (isDeleteAccount(payload)) { 28 | ActivityPub.enqueue(async () => { 29 | // Don't send accept message for account deletions because the remote account is now gone 30 | await db.deleteFollowerWithUri(payload.object) 31 | }) 32 | } else if (isFollowAccount(payload)) { 33 | ActivityPub.enqueue(async () => { 34 | await db.createNewFollowerWithUri(payload.actor) 35 | await ActivityPub.sendAcceptMessage(payload) 36 | }) 37 | } else { 38 | if (process.env.NODE_ENV === 'development') { 39 | console.log(req.body, req.headers) 40 | } 41 | res.status(400).send('Bad request or ActivityPub message type not supported.') 42 | return 43 | } 44 | 45 | res.status(200).send() 46 | return 47 | } catch (err) { 48 | console.error('Could not perform /inbox operation:\n' + err) 49 | res.status(500).send('Could not complete operation.') 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import pkg from 'morgan' 3 | 4 | import { url, key } from './src/constants.js' 5 | import bodyParser from 'body-parser' 6 | 7 | import wellKnownRouter from './src/routes/wellKnown.js' 8 | import { actorRouter } from './src/routes/actor.js' 9 | import { postDeleteRoute, postPublishRoute } from './src/routes/post.js' 10 | import { profileRoute } from './src/routes/profile.js' 11 | import { router as tagsRouter } from './src/routes/tags.js' 12 | const logger = pkg 13 | 14 | const app = express() 15 | 16 | app.use(logger('dev')) 17 | app.use(express.json()) 18 | app.use(express.urlencoded({ extended: true })) 19 | app.use(bodyParser.json({ type: 'application/activity+json' })) // support json encoded bodies 20 | 21 | try { 22 | app.set('apiKey', key.api) 23 | } catch (err) { 24 | console.error('ERROR: Could not load API key\n', err) 25 | process.exit(1) 26 | } 27 | 28 | app.use((req, res, next) => { 29 | res.append('Access-Control-Allow-Origin', ['*']) 30 | res.append('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE') 31 | res.append('Access-Control-Allow-Headers', 'Content-Type') 32 | next() 33 | }) 34 | 35 | app.use('/.well-known', wellKnownRouter) 36 | app.get(`${url.path.actor}/${process.env.ACCOUNT_USERNAME}.json`, profileRoute) 37 | app.use(`${url.path.actor}/${process.env.ACCOUNT_USERNAME}`, actorRouter) 38 | app.use(url.path.staticImages, express.static('img')) 39 | app.use(url.path.publish, express.Router().post('/', postPublishRoute)) 40 | app.use(url.path.delete, express.Router().post('/', postDeleteRoute)) 41 | app.use(url.path.tags, tagsRouter) 42 | 43 | app.get('/', (req, res) => { 44 | res.redirect(url.profile) 45 | }) 46 | app.get('*', (req, res) => { 47 | res.status(404) 48 | res.send('Resource not found') 49 | }) 50 | 51 | app.post('*', (req, res) => { 52 | res.status(404) 53 | res.send('Resource not found') 54 | }) 55 | 56 | export default app 57 | -------------------------------------------------------------------------------- /src/routes/wellKnown.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import { url } from '../constants.js' 3 | const wellKnownRouter = express.Router() 4 | 5 | const subject = 'acct:' + process.env.ACCOUNT_USERNAME + '@' + process.env.SERVER_DOMAIN 6 | 7 | const webfingerPayload = { 8 | subject, 9 | aliases: [ 10 | subject, 11 | url.profile, 12 | url.account 13 | ], 14 | links: [ 15 | { 16 | rel: 'http://webfinger.net/rel/profile-page', 17 | type: 'text/html', 18 | href: url.profile 19 | }, 20 | { 21 | rel: 'self', 22 | type: 'application/activity+json', 23 | href: url.account 24 | } 25 | 26 | /* 27 | { 28 | rel: 'http://ostatus.org/schema/1.0/subscribe', 29 | template: `${url.account}/ostatus_subscribe?uri={uri}` 30 | } */ 31 | ] 32 | } 33 | 34 | /* Static webfinger for the Ghost activitypub account. */ 35 | wellKnownRouter.get('/webfinger', function (req, res, next) { 36 | const resource = req.query.resource 37 | 38 | res.set('Access-Control-Allow-Methods', 'GET') 39 | 40 | if (!resource || resource.length === 0 || !resource.startsWith('acct:')) { 41 | res.status(400) 42 | res.send("Bad Request: no 'resource' in request query") 43 | return 44 | } 45 | 46 | const account = resource.replace(/^acct:/, '') 47 | 48 | if (account === (process.env.ACCOUNT_USERNAME + '@' + process.env.SERVER_DOMAIN)) { 49 | res.json(webfingerPayload) 50 | } else { 51 | res.status(404) 52 | res.send('Account not found') 53 | } 54 | }) 55 | 56 | wellKnownRouter.get('/host-meta', function (req, res, next) { 57 | res.set('Access-Control-Allow-Methods', 'GET') 58 | res.set('Content-Type', 'application/xml') 59 | res.send(` 60 | 61 | 62 | 63 | 64 | `) 65 | }) 66 | 67 | export default wellKnownRouter 68 | -------------------------------------------------------------------------------- /bin/manual_post.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // Load config 4 | import { postJSON } from '../src/utils.js' 5 | import { url, key } from '../src/constants.js' 6 | import { getPostAsync } from '../src/routes/post.js' 7 | import path from 'path' 8 | 9 | const fakeWebhook = (post, mode) => { 10 | return { 11 | post: { 12 | current: mode === 'publish' ? post : {}, 13 | previous: mode === 'delete' ? post : {} 14 | } 15 | } 16 | } 17 | 18 | const arg = process.argv[2] 19 | const id = process.argv[3] 20 | const basename = `${path.basename(process.argv[1])}` 21 | 22 | function printHelpMessage () { 23 | console.log(`Usage: ${basename} [options] [ghost post id]`) 24 | console.log('Options:\n') 25 | console.log('\t-p\t\tPost a Ghost post to all followers.') 26 | console.log('\t-d\t\tDelete a Ghost from all followers.(Note, due to the open nature of\n\t\t\tActivityPub/the Fediverse, this is not a guarantee, only a request.') 27 | } 28 | 29 | if (process.argv.length <= 2) { 30 | printHelpMessage() 31 | process.exit(1) 32 | } 33 | 34 | if (arg === '-h' || arg === '--help') { 35 | printHelpMessage() 36 | process.exit(0) 37 | } 38 | 39 | if (arg !== '-p' && arg !== '-d') { 40 | console.log(`An action mode (-p to post, -d to delete a post) is required. Eg: '${basename} -p 641b53d20d3ab0e0000ecc89'`) 41 | process.exit(1) 42 | } 43 | 44 | if (!id) { 45 | console.log(`A message or post id is required. Eg: '${basename} ${arg} 641b53d20d3ab0e0000ecc89'`) 46 | process.exit(1) 47 | } 48 | 49 | process.env.NODE_ENV = 'development' 50 | 51 | // // if ghost post id 52 | // if (arg.match(/[\d\w]{24}/)) { 53 | // operation = () => { return post(arg) } 54 | // } else { 55 | // // Regular message 56 | // operation = () => { return message(arg) } 57 | // } 58 | 59 | getPostAsync(id).then(post => { 60 | if (arg === '-p') { 61 | return postJSON(`${url.publish}?apiKey=${key.api}`, fakeWebhook(post, 'publish')) 62 | } else if (arg === '-d') { 63 | return postJSON(`${url.delete}?apiKey=${key.api}`, fakeWebhook(post, 'delete')) 64 | } 65 | process.exit(0) 66 | }).catch(err => { 67 | // API sends back an empty response, which breaks JSON parsing in the bent library 68 | if (err instanceof SyntaxError && !err.statusCode) { 69 | process.exit(0) 70 | } 71 | console.error(err.statusCode, err.message) 72 | process.exit(1) 73 | }) 74 | -------------------------------------------------------------------------------- /src/routes/tags.js: -------------------------------------------------------------------------------- 1 | 2 | import express from 'express' 3 | import { Ghost } from '../ghost.js' 4 | import { url } from '../constants.js' 5 | 6 | function sanitizeHashtagName (name) { 7 | name = name.replace(/\s+/g, '').replace(/[_]+/g, '') 8 | let sanitizedName = '' 9 | 10 | name.match(/[\w\d]*/g).forEach((match) => { 11 | sanitizedName += match 12 | }) 13 | return sanitizedName 14 | } 15 | 16 | export function createTagCollectionPayload (ghostTag, ghostPosts) { 17 | return { 18 | '@context': 'https://www.w3.org/ns/activitystreams', 19 | id: `${url.tags}/${ghostTag.slug}`, 20 | type: 'OrderedCollection', 21 | totalItems: ghostTag.count.posts, 22 | orderedItems: [] // TODO parse ghostPosts array into post payload 23 | } 24 | } 25 | 26 | export function createTagHtml (ghostTag, name) { 27 | const tagObject = createTagPayload(ghostTag, name) 28 | return `` 29 | } 30 | 31 | export function createTagPayload (ghostTag, name) { 32 | let slug = '' 33 | if (typeof ghostTag === 'string') { 34 | slug = ghostTag 35 | } else { 36 | slug = ghostTag.slug 37 | name = ghostTag.name 38 | } 39 | return { 40 | type: 'Hashtag', 41 | href: `${url.tags}/${slug}`, 42 | name: `#${sanitizeHashtagName(name)}` 43 | } 44 | } 45 | 46 | export async function tagCollectionRoute (req, res) { 47 | const slug = req.path.replace(/\/*/, '') 48 | 49 | try { 50 | const tagData = await Ghost.tags.read({ slug }, { include: 'count.posts', filter: 'visibility:public' }) 51 | 52 | const shouldForwardHTMLToGhost = process.env.NODE_ENV === 'production' || req.query.forward 53 | 54 | if (req.get('Accept').includes('text/html') && shouldForwardHTMLToGhost) { 55 | res.redirect(tagData.url) 56 | return 57 | } 58 | 59 | const postsForTag = await Ghost.posts.browse({ limit: 10, filter: `tag:${slug}`, formats: ['html'] }) 60 | 61 | res.json(createTagCollectionPayload(tagData, postsForTag)) 62 | } catch (err) { 63 | if (err.response) { 64 | res.status(err.response.status).send(err.response.statusText) 65 | } else { 66 | res.status(500).send('Unable to fetch tag.') 67 | } 68 | } 69 | } 70 | 71 | export const router = express.Router().get('/:tagName', tagCollectionRoute) 72 | -------------------------------------------------------------------------------- /src/routes/actor.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import { postGetRoute } from './post.js' 3 | import { profileRoute } from './profile.js' 4 | import { outbox } from './outbox.js' 5 | import { postInbox } from './inbox.js' 6 | import { url } from '../constants.js' 7 | import { Database } from '../db.js' 8 | import { featuredRoute } from './featured.js' 9 | import { likedRoute } from './liked.js' 10 | 11 | const db = new Database() 12 | 13 | function orderedCollection (id) { 14 | return { 15 | '@context': 'https://www.w3.org/ns/activitystreams', 16 | id, 17 | type: 'OrderedCollection', 18 | totalItems: 0, 19 | first: null 20 | } 21 | } 22 | 23 | function followerCollectionPage (pageNum, next, prev) { 24 | return { 25 | '@context': 'https://www.w3.org/ns/activitystreams', 26 | id: `${url.followers}?page=${pageNum}`, 27 | type: 'OrderedCollectionPage', 28 | totalItems: 0, 29 | next: next ? `${url.followers}?page=${next}` : null, 30 | prev: prev ? `${url.followers}?page=${prev}` : null, 31 | partOf: url.followers, 32 | orderedItems: [ 33 | ] 34 | } 35 | } 36 | 37 | async function followersRoute (req, res) { 38 | const page = parseInt(req.query.page) 39 | if (isNaN(page)) { 40 | const payload = orderedCollection(url.followers) 41 | payload.totalItems = await db.countFollowers() 42 | 43 | if (payload.totalItems > 0) { 44 | payload.next = `${url.followers}?page=1` 45 | } 46 | res.json(payload) 47 | } else { 48 | const followers = await db.getFollowers({ page, limit: 15 }) 49 | const payload = followerCollectionPage(page, followers.meta.pagination.next, followers.meta.pagination.prev) 50 | 51 | payload.totalItems = followers.meta.pagination.total 52 | if (process.env.SHOW_FOLLOWERS === 'true') { 53 | payload.orderedItems = followers.map(follower => { 54 | return follower.uri 55 | }) 56 | } 57 | 58 | res.json(payload) 59 | } 60 | } 61 | 62 | async function followingRoute (req, res) { 63 | res.json(orderedCollection(url.following)) 64 | } 65 | 66 | export const actorRouter = express.Router() 67 | .get('/', profileRoute) 68 | .get(url.path.publicKey, profileRoute) 69 | .get(url.path.outbox, outbox) 70 | .get(url.path.inbox, (req, res) => { 71 | res.status(401).send('Unauthorized') 72 | }) 73 | .get(url.path.followers, followersRoute) 74 | .get(url.path.following, followingRoute) 75 | .get(url.path.featured, featuredRoute) 76 | .get(url.path.liked, likedRoute) 77 | .get('/:postId', postGetRoute) 78 | .post(url.path.inbox, postInbox) 79 | -------------------------------------------------------------------------------- /src/OrderedCollection.js: -------------------------------------------------------------------------------- 1 | export class OrderedCollection { 2 | constructor (params) { 3 | params ??= {} 4 | this['@context'] = [ 5 | 'https://www.w3.org/ns/activitystreams', 6 | { 7 | toot: 'http://joinmastodon.org/ns#', 8 | discoverable: 'toot:discoverable', 9 | Hashtag: 'as:Hashtag' 10 | } 11 | ] 12 | this.id = params.id ?? crypto.randomUUID() 13 | this.type = 'OrderedCollection' 14 | this.totalItems = params.totalItems ?? (Array.isArray(params.items) ? params.items.length : 0) 15 | 16 | if (Array.isArray(params.items)) { 17 | this.orderedItems = params.items 18 | } else if (Array.isArray(params.orderedItems)) { 19 | this.orderedItems = params.orderedItems 20 | } else { 21 | this.first = params.firstUri ?? null 22 | this.last = params.lastUri ?? null 23 | } 24 | 25 | if (this.totalItems === 0) { 26 | this.orderedItems = [] 27 | } 28 | } 29 | 30 | newPage (params) { 31 | params ??= {} 32 | params.partOfUri ??= this.id 33 | return new OrderedCollectionPage(params) 34 | } 35 | } 36 | 37 | export class OrderedCollectionPage { 38 | constructor (params) { 39 | params ??= {} 40 | this['@context'] = [ 41 | 'https://www.w3.org/ns/activitystreams', 42 | { 43 | toot: 'http://joinmastodon.org/ns#', 44 | discoverable: 'toot:discoverable', 45 | Hashtag: 'as:Hashtag' 46 | } 47 | ] 48 | this.id = params.id ?? crypto.randomUUID() 49 | this.type = 'OrderedCollectionPage' 50 | 51 | if (params.partOfUri) { 52 | this.partOf = params.partOfUri 53 | } else { 54 | throw new MissingRequiredParameter('partOfUri') 55 | } 56 | 57 | const items = params.orderedItems 58 | if (!items) { 59 | throw new MissingRequiredParameter('orderedItems') 60 | } else if (!Array.isArray(items)) { 61 | throw new MissingRequiredParameter('orderedItems', 'Array') 62 | } 63 | this.orderedItems = items 64 | 65 | if (params.totalItems) { 66 | this.totalItems = params.totalItems 67 | } 68 | 69 | this.next = params.nextUri ?? null 70 | this.prev = params.prevUri ?? null 71 | } 72 | } 73 | 74 | class MissingRequiredParameter extends Error { 75 | constructor (field, type, ...args) { 76 | const missingMessage = `The parameter, '${field}' is required.` 77 | const wrongTypeMessage = `The parameter, '${field}' is the wrong type. Expected ${type}.` 78 | const message = type ? wrongTypeMessage : missingMessage 79 | super(message, ...args) 80 | this.message = message 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /bin/www.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // Load config 4 | import '../src/environment.js' 5 | 6 | /** 7 | * Module dependencies. 8 | */ 9 | import fs from 'fs' 10 | 11 | import { Ghost } from '../src/ghost.js' 12 | import { Database } from '../src/db.js' 13 | 14 | import app from '../app.js' 15 | import http from 'http' 16 | 17 | import Debug from 'debug' 18 | 19 | if (!fs.existsSync('.env')) { 20 | console.error('ERROR: .env file is missing') 21 | process.exit(1) 22 | } 23 | const debug = Debug('ghostcms-activitypub:server') 24 | 25 | async function createServer (lang) { 26 | /** 27 | * Normalize a port into a number, string, or false. 28 | */ 29 | 30 | function normalizePort (val) { 31 | const port = parseInt(val, 10) 32 | 33 | if (isNaN(port)) { 34 | // named pipe 35 | return val 36 | } 37 | 38 | if (port >= 0) { 39 | // port number 40 | return port 41 | } 42 | 43 | return false 44 | } 45 | 46 | /** 47 | * Event listener for HTTP server "error" event. 48 | */ 49 | 50 | function onError (error) { 51 | if (error.syscall !== 'listen') { 52 | throw error 53 | } 54 | 55 | const bind = typeof port === 'string' 56 | ? 'Pipe ' + port 57 | : 'Port ' + port 58 | 59 | // handle specific listen errors with friendly messages 60 | switch (error.code) { 61 | case 'EACCES': 62 | console.error(bind + ' requires elevated privileges') 63 | process.exit(1) 64 | break 65 | case 'EADDRINUSE': 66 | console.error(bind + ' is already in use') 67 | process.exit(1) 68 | break 69 | default: 70 | throw error 71 | } 72 | } 73 | 74 | /** 75 | * Event listener for HTTP server "listening" event. 76 | */ 77 | 78 | function onListening () { 79 | const addr = server.address() 80 | const bind = typeof addr === 'string' 81 | ? 'pipe ' + addr 82 | : 'port ' + addr.port 83 | debug('Listening on ' + bind) 84 | } 85 | 86 | /** 87 | * Get port from environment and store in Express. 88 | */ 89 | const port = normalizePort(process.env.PORT || '3000') 90 | app.set('port', port) 91 | app.set('lang', lang) 92 | 93 | /** 94 | * Create HTTP server. 95 | */ 96 | 97 | const server = http.createServer(app) 98 | 99 | /** 100 | * Listen on provided port, on all network interfaces. 101 | */ 102 | 103 | server.listen(port) 104 | server.on('error', onError) 105 | server.on('listening', onListening) 106 | } 107 | 108 | Ghost.settings.browse().then(async settings => { 109 | await new Database().initialize() 110 | return settings.lang 111 | }).then(lang => { 112 | createServer(lang) 113 | }).catch(err => { 114 | console.error(err.message) 115 | }) 116 | -------------------------------------------------------------------------------- /src/CollectionResource.js: -------------------------------------------------------------------------------- 1 | import { getPostsAsync } from './routes/post.js' 2 | import { url } from './constants.js' 3 | import { OrderedCollection, OrderedCollectionPage } from './OrderedCollection.js' 4 | 5 | function structuredClone (obj) { 6 | const stringified = JSON.stringify(obj) 7 | return JSON.parse(stringified) 8 | } 9 | 10 | export class CollectionResource { 11 | constructor (params) { 12 | params ??= {} 13 | params.query ??= {} 14 | this.params = params 15 | 16 | // Bind `this` to the methods on `CollectionResource` so that the routers can be used as higher-order functions, decoupled from the class: 17 | // Eg: `const foo = new CollectionResource().rootRouter` 18 | Object.getOwnPropertyNames(CollectionResource.prototype).forEach((key) => { 19 | if (key !== 'constructor') { 20 | this[key] = this[key].bind(this) 21 | } 22 | }) 23 | } 24 | 25 | async rootRouter (req, res, next) { 26 | if (req.query && req.query.page) { 27 | return this.pageRouter(req, res, next) 28 | } 29 | 30 | try { 31 | const posts = await getPostsAsync(structuredClone(this.params.query)) 32 | const params = { id: this.params.id } 33 | 34 | if (posts.pagination.total > 15) { 35 | params.first = `${this.params.id}?page=1` 36 | params.last = `${this.params.id}?page=${posts.pagination.pages}` 37 | } else { 38 | params.orderedItems = posts.posts 39 | } 40 | 41 | params.totalItems = posts.pagination.total 42 | 43 | const payload = new OrderedCollection(params) 44 | res.json(payload) 45 | return 46 | } catch (err) { 47 | console.error('Could not fetch Ghost posts:\n' + err) 48 | res.status(500).send() 49 | } 50 | } 51 | 52 | async pageRouter (req, res, next) { 53 | try { 54 | const page = parseInt(req.query.page) 55 | const query = structuredClone(this.params.query) 56 | query.page = page 57 | const posts = await getPostsAsync(query) 58 | const payload = new OrderedCollectionPage({ id: `${this.params.id}?page=${page}`, partOfUri: this.params.id, orderedItems: posts.posts, totalItems: posts.pagination.total }) 59 | 60 | // Pagination relies on the Ghost api matching the same next/prev null/int format as ActivityPub 61 | // This could break if Ghost changes the behavior 62 | if (posts.pagination.next) { 63 | payload.next = `${url.outbox}?page=${Math.min(posts.pagination.next, posts.pagination.pages)}` 64 | } 65 | 66 | if (posts.pagination.prev) { 67 | payload.prev = `${url.outbox}?page=${Math.min(posts.pagination.prev, posts.pagination.pages)}` 68 | } 69 | 70 | res.json(payload) 71 | return 72 | } catch (err) { 73 | console.error('Could not fetch Ghost posts:\n' + err) 74 | res.status(500).send('Internal error while fetching posts') 75 | } 76 | } 77 | } 78 | 79 | export default CollectionResource 80 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto' 2 | import { url, key } from './constants.js' 3 | import bent from 'bent' 4 | 5 | export const postJSON = bent('POST', 'json', { 'Content-Type': 'application/activity+json' }, 200, 202) 6 | export const getJSON = bent('GET', 'json', { 'Content-Type': 'application/activity+json' }, 200, 202) 7 | 8 | export async function signAndSend (message, params) { 9 | let inbox = '' 10 | 11 | if (params.inbox) { 12 | inbox = new URL(params.inbox) 13 | } else { 14 | const actor = await getJSON(message.object.actor) 15 | inbox = new URL(actor.inbox) 16 | } 17 | 18 | const digestHash = crypto.createHash('sha256').update(JSON.stringify(message)).digest('base64') 19 | const signer = crypto.createSign('sha256') 20 | const d = new Date() 21 | const stringToSign = `(request-target): post ${inbox.pathname}\nhost: ${inbox.hostname}\ndate: ${d.toUTCString()}\ndigest: SHA-256=${digestHash}` 22 | signer.update(stringToSign) 23 | signer.end() 24 | const signature = signer.sign(key.private) 25 | const signatureB64 = signature.toString('base64') 26 | const header = `keyId="${url.account}/public_key",headers="(request-target) host date digest",signature="${signatureB64}"` 27 | 28 | const headers = { 29 | Host: inbox.hostname, 30 | Date: d.toUTCString(), 31 | Digest: `SHA-256=${digestHash}`, 32 | Signature: header, 33 | 'Content-Type': 'application/activity+json', 34 | Accept: 'application/activity+json' 35 | } 36 | 37 | try { 38 | if (process.env.NODE_ENV === 'development') { 39 | console.log('Signing and sending: ', inbox.toString(), message, headers) 40 | } 41 | await postJSON(inbox.toString(), message, headers) 42 | } catch (err) { 43 | // Mastodon sends back an empty response, which breaks JSON parsing in the bent library 44 | if (err instanceof SyntaxError) { 45 | return 46 | } 47 | 48 | console.error('Error sending signed message:', err.statusCode, err.message, JSON.stringify(message)) 49 | } 50 | 51 | if (params.res) { 52 | return params.res.status(200).send() 53 | } 54 | } 55 | 56 | export function removeHttpURI (str) { 57 | return str.replace(/^http[s]*:\/\//, '') 58 | } 59 | 60 | export function createNotification (type, object, notificationId, to, cc) { 61 | // Need a guid for some activities otherwise Mastodon returns a 401 for some reason on follower inbox POSTs 62 | notificationId = notificationId || `${url.account}/${type.replace(/[\s]+/g, '').toLowerCase()}-${crypto.randomBytes(16).toString('hex')}` 63 | const payload = { 64 | '@context': 'https://www.w3.org/ns/activitystreams', 65 | id: notificationId, 66 | type, 67 | actor: url.account, 68 | object, 69 | to: to || 'https://www.w3.org/ns/activitystreams#Public' 70 | } 71 | 72 | if (cc) { 73 | payload.cc = cc 74 | } 75 | 76 | return payload 77 | } 78 | 79 | export async function sendAcceptMessage (object, res) { 80 | await signAndSend(createNotification('Accept', object), { res }) 81 | } 82 | -------------------------------------------------------------------------------- /migrations/20230324163903_v0.3.js: -------------------------------------------------------------------------------- 1 | import { getJSON } from '../src/utils.js' 2 | import PQueue from 'p-queue' 3 | 4 | /** 5 | * @param { import('knex').Knex } knex 6 | * @returns { Promise } 7 | */ 8 | 9 | export const up = async function (knex) { 10 | await knex.schema.createTable('posts', table => { 11 | if (knex.client.config.client === 'sqlite3') { 12 | table.string('ghostId') 13 | } else { 14 | table.string('ghostId').notNullable() 15 | } 16 | table.string('state') 17 | table.timestamp('created_at').defaultTo(knex.fn.now()) 18 | table.index(['ghostId', 'state']) 19 | }) 20 | 21 | await knex.schema.alterTable('followers', table => { 22 | if (knex.client.config.client === 'sqlite3') { 23 | table.string('inbox') 24 | } else { 25 | table.string('inbox').notNullable() 26 | } 27 | table.renameColumn('date_followed', 'created_at') 28 | table.renameColumn('follower_uri', 'uri') 29 | console.log('Schema migration complete.') 30 | }) 31 | 32 | console.log('Seeding missing follower inbox values...') 33 | const followers = await knex.select('uri').from('followers') 34 | 35 | let concurrencyLimit = parseInt(process.env.ACTIVITY_PUB_CONCURRENCY_LIMIT) 36 | if (isNaN(concurrencyLimit)) { 37 | concurrencyLimit = 100 38 | } 39 | const queue = new PQueue({ concurrency: concurrencyLimit, throwOnTimeout: true }) 40 | 41 | // Create an array of action methods to fetch the actor inboxes 42 | // action methods are needed to avoid promise inits from performing work 43 | // so wrap the promise init in a method to be called with concurrency 44 | // limits via p-queue 45 | const actions = followers.map(follower => { 46 | return async () => { 47 | console.log('Fetching inbox for ' + follower.uri) 48 | try { 49 | const actor = await getJSON(follower.uri) 50 | const followersTable = knex('followers') 51 | if (actor.inbox) { 52 | return followersTable 53 | .where('uri', follower.uri) 54 | .update({ inbox: actor.inbox, date_failed: null }) 55 | .then(val => { 56 | console.log('Saved inbox for ' + follower.uri, val) 57 | }) 58 | } else { 59 | console.log('Deleting follower due to missing inbox: ' + follower.uri) 60 | // Delete follower if it doesn't have an inbox 61 | return followersTable.where('uri', follower.uri) 62 | .del() 63 | .then(() => {}) 64 | } 65 | } catch (err) { 66 | // Catch inbox fetch errors and remove the follower 67 | await knex('followers').where('uri', follower.uri).del() 68 | } 69 | } 70 | }) 71 | 72 | await queue.addAll(actions) 73 | } 74 | 75 | /** 76 | * @param { import('knex').Knex } knex 77 | * @returns { Promise } 78 | */ 79 | export const down = async function (knex) { 80 | await knex.schema.alterTable('followers', table => { 81 | table.dropColumn('inbox_uri') 82 | table.renameColumn('created_at', 'date_followed') 83 | }) 84 | } 85 | -------------------------------------------------------------------------------- /src/activitypub.js: -------------------------------------------------------------------------------- 1 | import PQueue from 'p-queue' 2 | import crypto from 'crypto' 3 | import { url, key } from './constants.js' 4 | import bent from 'bent' 5 | 6 | export const postJSON = bent('POST', 'json', { 'Content-Type': 'application/activity+json' }, 200, 202) 7 | export const getJSON = bent('GET', 'json', { 'Content-Type': 'application/activity+json' }, 200, 202) 8 | 9 | export class ActivityPub { 10 | #queue 11 | constructor (config) { 12 | config ??= {} 13 | config.concurrency ??= parseInt(process.env.ACTIVITY_PUB_CONCURRENCY_LIMIT) || 100 14 | config.throwOnTimeout ??= false 15 | this.#queue = new PQueue(config) 16 | } 17 | 18 | enqueue (fn) { 19 | if (Array.isArray(fn)) { 20 | this.#queue.addAll(fn) 21 | } else { 22 | this.#queue.add(fn) 23 | } 24 | } 25 | 26 | async sendAcceptMessage (object) { 27 | return await this.sendMessage(this.createNotification('Accept', object)) 28 | } 29 | 30 | async sendMessage (message, inbox) { 31 | if (inbox) { 32 | inbox = new URL(inbox) 33 | } else { 34 | const actor = await getJSON(message.object.actor) 35 | inbox = new URL(actor.inbox) 36 | } 37 | 38 | const digestHash = crypto.createHash('sha256').update(JSON.stringify(message)).digest('base64') 39 | const signer = crypto.createSign('sha256') 40 | const d = new Date() 41 | const stringToSign = `(request-target): post ${inbox.pathname}\nhost: ${inbox.hostname}\ndate: ${d.toUTCString()}\ndigest: SHA-256=${digestHash}` 42 | signer.update(stringToSign) 43 | signer.end() 44 | const signature = signer.sign(key.private) 45 | const signatureB64 = signature.toString('base64') 46 | const header = `keyId="${url.account}/public_key",headers="(request-target) host date digest",signature="${signatureB64}"` 47 | 48 | const headers = { 49 | Host: inbox.hostname, 50 | Date: d.toUTCString(), 51 | Digest: `SHA-256=${digestHash}`, 52 | Signature: header, 53 | 'Content-Type': 'application/activity+json', 54 | Accept: 'application/activity+json' 55 | } 56 | 57 | try { 58 | if (process.env.NODE_ENV === 'development') { 59 | console.log('Signing and sending: ', inbox.toString(), message, headers) 60 | } 61 | await postJSON(inbox.toString(), message, headers) 62 | } catch (err) { 63 | // Mastodon sends back an empty response, which breaks JSON parsing in the bent library 64 | if (err instanceof SyntaxError) { 65 | return 66 | } 67 | console.error('Error sending signed message:', err.statusCode, err.message, JSON.stringify(message), inbox) 68 | throw err 69 | } 70 | } 71 | 72 | enqueueMessage (activityPubMessage, inbox) { 73 | this.enqueue(async () => { 74 | await this.sendMessage(activityPubMessage, inbox) 75 | }) 76 | } 77 | 78 | createNotification (type, object, overrides) { 79 | if (typeof overrides === 'string') { 80 | overrides = { notificationId: overrides } 81 | } 82 | overrides ??= {} 83 | 84 | // Need a guid for some activities otherwise Mastodon returns a 401 for some reason on follower inbox POSTs 85 | overrides.notificationId ??= `${url.account}/${type.replace(/[\s]+/g, '').toLowerCase()}-${crypto.randomBytes(16).toString('hex')}` 86 | 87 | const payload = { 88 | '@context': 'https://www.w3.org/ns/activitystreams', 89 | id: overrides.notificationId, 90 | type, 91 | actor: url.account, 92 | object, 93 | to: overrides.to ?? 'https://www.w3.org/ns/activitystreams#Public' 94 | } 95 | 96 | if (overrides.cc) { 97 | payload.cc = overrides.cc 98 | } 99 | 100 | return payload 101 | } 102 | } 103 | 104 | export const MainQueue = new ActivityPub() 105 | export default MainQueue 106 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GhostCMS ActivityPub 2 | This is a simple ExpressJS server that integrates with GhostCMS webhooks and implements basic ActivityPub features such as accepting `Follow` requests and publishing to followers' inboxes whenever a GhostCMS post is published via `Create` `Article` actions. 3 | 4 | ## Mandatory Warning 5 | This project is currently experimental and could un-recoverably break something between versions. It should be used with caution, as only happy-path, Mastodon-compatible interaction has been tested. Although it should be safe to run after configuration, do not experiment with this code or config on your primary domain as you might find yourself ban-listed by a Mastodon instance for spam, should anything go wrong. Consider hosting locally and proxying via [ngrok](https://ngrok.com) to create a throw-away domain. 6 | 7 | ## Features 8 | * GhostCMS Webhooks for post creation and unpublish/delete activities. 9 | * A single ActivityPub actor for your site (@username@yoursite.tld) 10 | 11 | ## Issues and Feature Requests 12 | Feature requests and bugs can be tracked and reported at this project's [Codeberg issue](https://codeberg.org/quigs/ghostcms-activitypub/issues) tracker. 13 | 14 | ## Installation 15 | Download and install Node.js. This project has only been tested on Node.js 19, but earlier versions may be compatible. 16 | Installation is done using the npm install command: 17 | 18 | ``` 19 | $ npm install 20 | ``` 21 | 22 | Be sure to set the appropriate configuration settings by copying `.env.template` to `.env` and following the instructions in the file. Then run the project: 23 | ``` 24 | $ npm run dev # Debug 25 | $ npm run start # Production 26 | ``` 27 | 28 | On startup the service will create an API key for Ghost webhooks, available at `data/apiKey.txt`. To integrate with Ghost, create a `Post published` and a `Post unpublished` webhook that point to `/publish?apiKey=YOUR_KEY_HERE` and `/delete?apiKey=YOUR_KEY_HERE` respectively. If you changed the API path root from the default (none) then your webhooks will be located at `API_ROOT_PATH/publish` and `API_ROOT_PATH/delete`. For example, if your API root path is set to `/activitypub` and the activitypub is running on the same domain then Ghost will need to publish to `https://yourghostdomain/activitypub/publish?apiKey=YOUR_KEY_HERE`. 29 | 30 | ### Running As A Service 31 | A systemd service file is provided as well. You can follow the configuration instructions in the file and then copy that to `/etc/systemd/system/` and enable and run the service: 32 | 33 | ``` 34 | $ sudo systemctl enable ghostcms_activitypub && sudo systemctl start ghostcms_activitypub 35 | ``` 36 | 37 | ### Proxying 38 | The only 'acknowledged' way to use `ghostcms-activitypub` (I do not provide support, sorry!) is to use a [reverse proxy](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/). This allows you to serve activitypub content directly from your root domain (eg: https://example.com) but you can also configure the project to use a subdomain as well. 39 | 40 | If you use a root domain, be sure to set the `API_ROOT_PATH` config option to something like `/activitypub` to avoid potential path namespace collisions with Ghost. 41 | 42 | ``` 43 | location /.well-known/webfinger { 44 | proxy_set_header Host $host; 45 | proxy_set_header X-Real-IP $remote_addr; 46 | proxy_pass http://127.0.0.1:3000; # ActivityPub server 47 | } 48 | 49 | 50 | # Assuming `API_ROOT_PATH` is `/activitypub` Rewrite activitypub API calls (that may conflict with Ghost) to the path, but remove the prefix from the proxy 51 | location /activitypub/ { # The trailing slash is required to proxy all sub-paths 52 | proxy_set_header Host $host; 53 | proxy_set_header X-Real-IP $remote_addr; 54 | proxy_pass http://127.0.0.1:3000; # The trailing / and the headers ensure that /activitypub/foo gets rewritten to just /foo 55 | } 56 | ``` 57 | 58 | ## License 59 | MIT -------------------------------------------------------------------------------- /src/routes/profile.js: -------------------------------------------------------------------------------- 1 | import { removeHttpURI } from '../utils.js' 2 | import { Ghost } from '../ghost.js' 3 | import { url, key } from '../constants.js' 4 | 5 | const mastodonAttachments = 6 | [ 7 | { 8 | type: 'PropertyValue', 9 | name: 'Website', 10 | value: `${removeHttpURI(url.profile)}` 11 | } 12 | ] 13 | 14 | export function profilePayload () { 15 | return { 16 | '@context': [ 17 | 'https://www.w3.org/ns/activitystreams', 18 | 'https://w3id.org/security/v1', 19 | { 20 | PropertyValue: 'schema:PropertyValue', 21 | value: 'schema:value', 22 | toot: 'http://joinmastodon.org/ns#', 23 | discoverable: 'toot:discoverable', 24 | Hashtag: 'as:Hashtag', 25 | featured: { 26 | '@id': 'toot:featured', 27 | '@type': '@id' 28 | }, 29 | alsoKnownAs: { 30 | '@id': 'as:alsoKnownAs', 31 | '@type': '@id' 32 | }, 33 | publicKeyBase64: 'toot:publicKeyBase64' 34 | } 35 | ], 36 | id: url.account, 37 | type: 'Service', // Bots are 'Service' types 38 | following: url.following, 39 | followers: url.followers, 40 | featured: url.featured, 41 | inbox: url.inbox, 42 | liked: url.liked, 43 | 44 | // TODO: Debug why Mastodon doesn't show historical posts? 45 | outbox: url.outbox, 46 | // "featuredTags": "https://meta.masto.host/users/GamingNews/collections/tags", 47 | preferredUsername: process.env.ACCOUNT_USERNAME, 48 | name: process.env.ACCOUNT_NAME, 49 | summary: '', 50 | url: process.env.PROFILE_URL || url.profile, 51 | manuallyApprovesFollowers: false, 52 | discoverable: true, 53 | attachment: mastodonAttachments, 54 | publicKey: { 55 | id: url.publicKey, 56 | owner: url.account, 57 | publicKeyPem: key.public 58 | } 59 | } 60 | } 61 | 62 | const jpegContentType = 'image/jpeg' 63 | const webpContentType = 'image/webp' 64 | const svgContentType = 'image/svg+xml' 65 | const pngContentType = 'image/png' 66 | 67 | function imagePayload () { 68 | return { 69 | type: 'Image', 70 | mediaType: pngContentType 71 | } 72 | } 73 | 74 | function contentTypeFromUrl (url) { 75 | if (url.endsWith('jpg') || url.endsWith('jpeg')) { 76 | return jpegContentType 77 | } else if (url.endsWith('webp')) { 78 | return webpContentType 79 | } else if (url.endsWith('svg')) { 80 | return svgContentType 81 | } else { // Assume png 82 | return pngContentType 83 | } 84 | } 85 | 86 | let siteData = null 87 | 88 | export const profileRoute = async function (req, res, next) { 89 | const shouldForwardHTMLToGhost = process.env.NODE_ENV === 'production' || req.query.forward 90 | 91 | // If a web browser is requesting the profile, redirect to the Ghost website 92 | const acceptType = req.get('Accept') 93 | 94 | if (acceptType && acceptType.includes('text/html') && !req.path.endsWith('.json') && shouldForwardHTMLToGhost) { 95 | res.redirect(url.profile) 96 | return 97 | } 98 | 99 | if (siteData == null) { 100 | siteData = await Ghost.settings.browse() 101 | } 102 | 103 | const profile = profilePayload() 104 | 105 | profile.name = process.env.ACCOUNT_NAME || siteData.title 106 | profile.summary = `${siteData.description}\n
${url.profile}` // TODO add h-card data? 107 | 108 | if (!req.app.get('account_created_at')) { 109 | // Fetch the oldest post to determine the ActivityPub actor creation/published date 110 | try { 111 | const oldestPosts = await Ghost.posts.browse({ limit: 1, order: 'published_at asc' }) 112 | if (oldestPosts.length > 0) { 113 | req.app.set('account_created_at', oldestPosts[0].published_at) 114 | } else { 115 | throw new Error('No posts found.') 116 | } 117 | } catch (err) { 118 | req.app.set('account_created_at', new Date().toISOString()) 119 | } 120 | } 121 | 122 | profile.published = req.app.get('account_created_at') 123 | 124 | profile.icon = imagePayload() 125 | if (siteData.logo && siteData.logo !== '') { 126 | profile.icon.url = siteData.logo 127 | } else { 128 | profile.icon.url = `https://${process.env.SERVER_DOMAIN}${url.path.staticImages}/ghost-logo-orb.jpg` 129 | } 130 | 131 | if (siteData.cover_image && siteData.cover_image !== '') { 132 | profile.image = imagePayload() 133 | profile.image.url = siteData.cover_image 134 | } 135 | 136 | if (profile.icon) { 137 | profile.icon.mediaType = contentTypeFromUrl(profile.icon.url) 138 | } 139 | 140 | if (profile.image) { 141 | profile.image.mediaType = contentTypeFromUrl(profile.image.url) 142 | } 143 | 144 | res.json(profile) 145 | } 146 | -------------------------------------------------------------------------------- /src/db.js: -------------------------------------------------------------------------------- 1 | import { getJSON } from './utils.js' 2 | import { filePath, url } from './constants.js' 3 | import knex from 'knex' 4 | 5 | export class Follower { 6 | uri 7 | inbox 8 | } 9 | 10 | export class PostPublishState { 11 | ghostId 12 | state 13 | created_at 14 | 15 | get activityPubId () { 16 | return `${url.account}/${this.ghostId}_${this.created_at}` 17 | } 18 | } 19 | 20 | export class Database { 21 | #knex 22 | options 23 | constructor (options) { 24 | this.#knex = knex({ 25 | client: 'sqlite3', 26 | connection: { 27 | filename: filePath.database 28 | }, 29 | useNullAsDefault: true 30 | }) 31 | options = options || { migrate: true } 32 | this.options = options 33 | } 34 | 35 | async initialize () { 36 | // if (!fs.existsSync(filePath.database)) { 37 | // await this.#createDatabase() 38 | // } 39 | 40 | if (this.options.migrate) { 41 | await this.#knex.migrate.latest() 42 | } 43 | 44 | return this 45 | } 46 | 47 | async #createDatabase () { 48 | await this.#knex.schema.createTable('posts', table => { 49 | if (this.#knex.client.config.client === 'sqlite3') { 50 | table.string('ghostId') 51 | } else { 52 | table.string('ghostId').notNullable() 53 | } 54 | table.string('state') 55 | table.timestamp('created_at').defaultTo(this.#knex.fn.now()) 56 | table.index(['ghostId', 'state']) 57 | }) 58 | 59 | await this.#knex.schema.createTable('followers', table => { 60 | if (this.#knex.client.config.client === 'sqlite3') { 61 | table.string('inbox') 62 | } else { 63 | table.string('inbox').notNullable() 64 | } 65 | table.timestamp('created_at').defaultTo(this.#knex.fn.now()) 66 | table.string('uri') 67 | }) 68 | } 69 | 70 | /** 71 | * @returns { Promise } 72 | */ 73 | get #followers () { 74 | return this.#knex('followers') 75 | } 76 | 77 | get #posts () { 78 | return this.#knex('posts') 79 | } 80 | 81 | // TODO: paginate for large sets of followers 82 | /** 83 | * @returns { Promise } 84 | */ 85 | async getFollowers (options) { 86 | let followers = [] 87 | 88 | const totalCount = await this.countFollowers() 89 | 90 | options ??= {} 91 | options.limit ??= totalCount 92 | options.offset ??= (options.page ?? 0) * options.limit 93 | followers = await this.#followers.select().limit(options.limit).offset(options.offset) || [] 94 | 95 | const results = followers.map(follower => { 96 | return Object.assign(new Follower(), follower) 97 | }) 98 | 99 | const pages = Math.ceil(totalCount / Number.parseFloat(options.limit)) 100 | const page = Math.max(1, pages - Math.ceil((totalCount - options.offset) / Number.parseFloat(options.limit))) 101 | 102 | results.meta = { 103 | pagination: { 104 | total: totalCount, 105 | limit: options.limit, 106 | pages, 107 | page, 108 | next: page < pages ? page : null, 109 | prev: page > 1 ? page - 1 : null 110 | 111 | } 112 | } 113 | return results 114 | } 115 | 116 | /** 117 | * @returns { Promise } 118 | */ 119 | async getFollowerWithUri (uri) { 120 | const follower = await this.#followers.where({ uri }).first() 121 | 122 | if (follower) { 123 | return Object.assign(new Follower(), follower) 124 | } else { 125 | return null 126 | } 127 | } 128 | 129 | /** 130 | * @returns { Promise } 131 | */ 132 | async getFollowerWithInbox (inbox) { 133 | const follower = await this.#followers.where({ inbox }).first() 134 | 135 | if (follower) { 136 | return Object.assign(new Follower(), follower) 137 | } else { 138 | return null 139 | } 140 | } 141 | 142 | /** 143 | * @returns { Promise } 144 | */ 145 | async createNewFollowerWithUri (uri) { 146 | const actor = await getJSON(uri) 147 | 148 | if (actor.inbox) { 149 | const follower = { uri: actor.id, inbox: actor.inbox, created_at: new Date().getTime() } 150 | 151 | // Skip if follower exists 152 | if (!await this.#followers.where({ uri: actor.id }).first()) { 153 | await this.#followers.insert(follower) 154 | } 155 | return Object.assign(new Follower(), follower) 156 | } else { 157 | throw new Error('Actor is missing an inbox') 158 | } 159 | } 160 | 161 | /** 162 | * @returns { Promise } 163 | */ 164 | async deleteFollowerWithUri (uri) { 165 | await this.#followers.where({ uri }).del() 166 | } 167 | 168 | /** 169 | * @returns { Promise } 170 | */ 171 | async countFollowers () { 172 | const countArray = await this.#followers.count('uri', { as: 'count' }) 173 | if (countArray.length > 0) { 174 | return countArray[0].count 175 | } else { 176 | return 0 177 | } 178 | } 179 | 180 | /** 181 | * @returns { Promise } The Ghost post's publishing state 182 | */ 183 | async createPostState (ghostPost) { 184 | const postRecord = { ghostId: ghostPost.id, state: ghostPost.status, created_at: new Date().getTime() } 185 | await this.#posts.insert(postRecord) 186 | return Object.assign(new PostPublishState(), postRecord) 187 | } 188 | 189 | /** 190 | * @returns { Promise<[PostPublishState]> } The Ghost post's publishing states 191 | */ 192 | async getPostState (ghostId, state) { 193 | ghostId = ghostId.replace(/_[\d]+$/g, '') 194 | let states = [] 195 | if (state) { 196 | states = await this.#posts.where({ ghostId, state }).orderBy('created_at', 'desc') 197 | } else { 198 | states = await this.#posts.where({ ghostId }).orderBy('created_at', 'desc') 199 | } 200 | 201 | return states.map(state => { 202 | return Object.assign(new PostPublishState(), state) 203 | }) 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/routes/post.js: -------------------------------------------------------------------------------- 1 | import { createTagPayload } from './tags.js' 2 | import { Ghost } from '../ghost.js' 3 | import { url } from '../constants.js' 4 | import { Database, PostPublishState } from '../db.js' 5 | import ActivityPub from '../activitypub.js' 6 | 7 | const db = new Database() 8 | 9 | export function createPostPayload (ghostPost, language, postState) { 10 | if (!(postState instanceof PostPublishState)) { 11 | throw new Error('createPostPayload() requires a `postState`, got ' + typeof postState) 12 | } 13 | 14 | language ??= 'en' 15 | 16 | const id = postState.activityPubId 17 | 18 | const postPayload = { 19 | '@context': [ 20 | 'https://www.w3.org/ns/activitystreams', 21 | { 22 | Hashtag: 'as:Hashtag', 23 | toot: 'http://joinmastodon.org/ns#' 24 | } 25 | ], 26 | id, 27 | published: ghostPost.published_at, 28 | sensitive: false, 29 | summary: null, 30 | visibility: 'public', 31 | language, 32 | uri: id, 33 | url: ghostPost.url ?? null, 34 | atomUri: id, 35 | content: null, 36 | type: 'Article', 37 | to: ['https://www.w3.org/ns/activitystreams#Public'], 38 | cc: [ 39 | `${url.followers}` 40 | ], 41 | attributedTo: url.account, 42 | tag: [] 43 | } 44 | 45 | const excerpt = ghostPost.excerpt || ghostPost.custom_excerpt 46 | postPayload.summary = `${ghostPost.title}\n\n${excerpt}` 47 | 48 | // Check to see if the summary ends with punctuation, otherwise assume the text got cut off and add elipses. 49 | // Only add punctuation if the summary is shorter than the body 50 | if (excerpt.match(/[\d\w]$/g) && (ghostPost.plaintext != excerpt)) { // eslint-disable-line eqeqeq 51 | postPayload.summary += '...' 52 | } 53 | 54 | if (ghostPost.tags && ghostPost.tags.length > 0) { 55 | postPayload.tag = ghostPost.tags.map(createTagPayload) 56 | } 57 | 58 | postPayload.content = ghostPost.html 59 | return postPayload 60 | } 61 | 62 | const POST_QUERY_LIMIT = 10 63 | export async function getPostsAsync (filters, language) { 64 | filters = filters ?? { include: 'tags' } 65 | 66 | if (!filters.include) { 67 | filters.include = 'tags' 68 | } 69 | 70 | if (!filters.include.includes('tags')) { 71 | filters.include += ',tags' 72 | } 73 | 74 | if (!filters.limit) { 75 | filters.limit = POST_QUERY_LIMIT 76 | } 77 | 78 | const posts = await Ghost.posts.browse(filters) 79 | const activityPubPosts = posts.map(async (post) => { 80 | const postStates = await db.getPostState(post.id, 'published') 81 | let postState = postStates[0] 82 | 83 | // Older posts may not have been published to the Fediverse, so a new post state must be created for them 84 | if (postStates.length === 0) { 85 | postState = await db.createPostState(post) 86 | } 87 | return createPostPayload(post, language, postState) 88 | }) 89 | 90 | return Promise.all(activityPubPosts).then(activityPubPosts => { 91 | return { 92 | posts: activityPubPosts, 93 | pagination: posts.meta.pagination 94 | } 95 | }) 96 | } 97 | 98 | export async function getPostAsync (postId) { 99 | postId = postId.replace(/_[\d]+$/g, '') 100 | return await Ghost.posts.read({ id: postId, include: 'tags' }, { formats: ['html'] }) 101 | } 102 | 103 | export const postGetRoute = async function (req, res) { 104 | const postId = req.params.postId 105 | const language = req.app.get('language') 106 | if (!postId) { 107 | return res.status(400).send('Bad request.') 108 | } 109 | 110 | try { 111 | const post = await getPostAsync(postId) 112 | const postStates = await db.getPostState(postId, 'published') 113 | 114 | if (postStates.length === 0) { 115 | res.status(404).send() 116 | return 117 | } 118 | 119 | res.json(createPostPayload(post, language, postStates[0])) 120 | } catch (err) { 121 | if (err.response) { 122 | res.status(err.response.status).send(err.response.statusText) 123 | } else { 124 | console.error(err) 125 | res.status(500).send('Unable to fetch post.') 126 | } 127 | } 128 | } 129 | 130 | export const postPublishRoute = async function (req, res) { 131 | if (req.query.apiKey != req.app.get('apiKey')) { // eslint-disable-line eqeqeq 132 | res.status(401).send() 133 | return 134 | } 135 | 136 | const language = req.app.get('language') 137 | 138 | try { 139 | const post = req.body.post.current 140 | const postState = await db.createPostState(post) 141 | 142 | const followers = await db.getFollowers() 143 | const postObject = createPostPayload(post, language, postState) 144 | 145 | followers.forEach(follower => { 146 | ActivityPub.enqueue(async () => { 147 | try { 148 | await ActivityPub.sendMessage(ActivityPub.createNotification('Create', postObject), follower.inbox) 149 | } catch (err) { 150 | if (process.env.NODE_ENV === 'development') { 151 | console.error(err) 152 | } 153 | // If account is gone, delete follower 154 | if (err.statusCode === 410) { 155 | await db.deleteFollowerWithUri(follower.inbox) 156 | } 157 | } 158 | }) 159 | }) 160 | 161 | res.status(200).send() 162 | return 163 | } catch (err) { 164 | console.error(err.message) 165 | if (err.statusCode) { 166 | res.status(err.statusCode).send(err.message) 167 | } else { 168 | res.status(500).send('Unable to publish post') 169 | } 170 | } 171 | } 172 | 173 | export const postDeleteRoute = async function (req, res) { 174 | if (req.query.apiKey != req.app.get('apiKey')) { // eslint-disable-line eqeqeq 175 | res.status(401).send() 176 | return 177 | } 178 | 179 | if (process.env.NODE_ENV === 'development') { 180 | console.log(JSON.stringify(req.body)) 181 | } 182 | 183 | try { 184 | const followers = await db.getFollowers() 185 | const id = req.body.post.current.id ?? req.body.post.previous.id 186 | const status = req.body.post.previous.status 187 | 188 | const postState = await db.getPostState(id, status) 189 | if (postState.length === 0) { 190 | throw new Error('Expected a post state, but got none back for:', id, status) 191 | } 192 | 193 | // Get the most recent state 194 | const activityPubId = postState[0].activityPubId 195 | 196 | // Mark the post state as deleted if deleted 197 | if (req.body.post.current === {}) { 198 | await db.createPostState({ id, status: 'deleted' }) 199 | } 200 | 201 | followers.forEach(follower => { 202 | ActivityPub.enqueueMessage(ActivityPub.createNotification('Delete', activityPubId), follower.inbox) 203 | }) 204 | 205 | res.status(200).send() 206 | } catch (err) { 207 | console.error(err.message) 208 | if (err.statusCode) { 209 | res.status(err.statusCode).send(err.message) 210 | } else { 211 | res.status(500).send('Unable to delete/unpublish post') 212 | } 213 | throw err 214 | } 215 | } 216 | --------------------------------------------------------------------------------