├── .gitignore ├── config-template.json ├── routes ├── index.js ├── message.js ├── webfinger.js ├── admin.js ├── user.js ├── inbox.js └── api.js ├── package.json ├── LICENSE-MIT ├── index.js ├── public └── admin │ └── index.html └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | package-lock.json 3 | *.db 4 | config.json 5 | -------------------------------------------------------------------------------- /config-template.json: -------------------------------------------------------------------------------- 1 | { 2 | "USER": "", 3 | "PASS": "", 4 | "DOMAIN": "", 5 | "PORT": "3000", 6 | "PRIVKEY_PATH": "", 7 | "CERT_PATH": "" 8 | } 9 | -------------------------------------------------------------------------------- /routes/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | api: require('./api'), 5 | admin: require('./admin'), 6 | user: require('./user'), 7 | message: require('./message'), 8 | inbox: require('./inbox'), 9 | webfinger: require('./webfinger'), 10 | }; 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bot-node", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "dependencies": { 7 | "better-sqlite3": "^8.5.1", 8 | "body-parser": "^1.18.3", 9 | "cors": "^2.8.4", 10 | "express": "^4.16.3", 11 | "express-basic-auth": "^1.1.5", 12 | "request": "^2.87.0" 13 | }, 14 | "engines": { 15 | "node": ">=10.12.0" 16 | }, 17 | "scripts": { 18 | "test": "echo \"Error: no test specified\" && exit 1" 19 | }, 20 | "author": "", 21 | "license": "MIT" 22 | } 23 | -------------------------------------------------------------------------------- /routes/message.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const express = require('express'), 3 | router = express.Router(); 4 | 5 | router.get('/:guid', function (req, res) { 6 | let guid = req.params.guid; 7 | if (!guid) { 8 | return res.status(400).send('Bad request.'); 9 | } 10 | else { 11 | let db = req.app.get('db'); 12 | let result = db.prepare('select message from messages where guid = ?').get(guid); 13 | if (result === undefined) { 14 | return res.status(404).send(`No record found for ${guid}.`); 15 | } 16 | else { 17 | res.set('Content-Type', 'application/activity+json'); 18 | res.json(JSON.parse(result.message)); 19 | } 20 | } 21 | }); 22 | 23 | module.exports = router; 24 | -------------------------------------------------------------------------------- /routes/webfinger.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const express = require('express'), 3 | router = express.Router(); 4 | 5 | router.get('/', function (req, res) { 6 | let resource = req.query.resource; 7 | if (!resource || !resource.includes('acct:')) { 8 | return res.status(400).send('Bad request. Please make sure "acct:USER@DOMAIN" is what you are sending as the "resource" query parameter.'); 9 | } 10 | else { 11 | let name = resource.replace('acct:',''); 12 | let db = req.app.get('db'); 13 | let result = db.prepare('select webfinger from accounts where name = ?').get(name); 14 | if (result === undefined) { 15 | return res.status(404).send(`No record found for ${name}.`); 16 | } 17 | else { 18 | res.json(JSON.parse(result.webfinger)); 19 | } 20 | } 21 | }); 22 | 23 | module.exports = router; 24 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Darius Kazemi 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /routes/admin.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const express = require('express'), 3 | router = express.Router(), 4 | crypto = require('crypto'); 5 | 6 | function createActor(name, domain, pubkey) { 7 | return { 8 | '@context': [ 9 | 'https://www.w3.org/ns/activitystreams', 10 | 'https://w3id.org/security/v1' 11 | ], 12 | 13 | 'id': `https://${domain}/u/${name}`, 14 | 'type': 'Person', 15 | 'preferredUsername': `${name}`, 16 | 'inbox': `https://${domain}/api/inbox`, 17 | 'outbox': `https://${domain}/u/${name}/outbox`, 18 | 'followers': `https://${domain}/u/${name}/followers`, 19 | 20 | 'publicKey': { 21 | 'id': `https://${domain}/u/${name}#main-key`, 22 | 'owner': `https://${domain}/u/${name}`, 23 | 'publicKeyPem': pubkey 24 | } 25 | }; 26 | } 27 | 28 | function createWebfinger(name, domain) { 29 | return { 30 | 'subject': `acct:${name}@${domain}`, 31 | 32 | 'links': [ 33 | { 34 | 'rel': 'self', 35 | 'type': 'application/activity+json', 36 | 'href': `https://${domain}/u/${name}` 37 | } 38 | ] 39 | }; 40 | } 41 | 42 | router.post('/create', function (req, res) { 43 | // pass in a name for an account, if the account doesn't exist, create it! 44 | const account = req.body.account; 45 | if (account === undefined) { 46 | return res.status(400).json({msg: 'Bad request. Please make sure "account" is a property in the POST body.'}); 47 | } 48 | let db = req.app.get('db'); 49 | let domain = req.app.get('domain'); 50 | // create keypair 51 | crypto.generateKeyPair('rsa', { 52 | modulusLength: 4096, 53 | publicKeyEncoding: { 54 | type: 'spki', 55 | format: 'pem' 56 | }, 57 | privateKeyEncoding: { 58 | type: 'pkcs8', 59 | format: 'pem' 60 | } 61 | }, (err, publicKey, privateKey) => { 62 | let actorRecord = createActor(account, domain, publicKey); 63 | let webfingerRecord = createWebfinger(account, domain); 64 | const apikey = crypto.randomBytes(16).toString('hex'); 65 | try { 66 | db.prepare('insert or replace into accounts(name, actor, apikey, pubkey, privkey, webfinger) values(?, ?, ?, ?, ?, ?)').run(`${account}@${domain}`, JSON.stringify(actorRecord), apikey, publicKey, privateKey, JSON.stringify(webfingerRecord)); 67 | res.status(200).json({msg: 'ok', apikey}); 68 | } 69 | catch(e) { 70 | res.status(200).json({error: e}); 71 | } 72 | }); 73 | }); 74 | 75 | module.exports = router; 76 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const config = require('./config.json'); 2 | const { USER, PASS, DOMAIN, PRIVKEY_PATH, CERT_PATH, PORT } = config; 3 | const express = require('express'); 4 | const app = express(); 5 | const Database = require('better-sqlite3'); 6 | const db = new Database('bot-node.db'); 7 | const fs = require('fs'); 8 | const routes = require('./routes'), 9 | bodyParser = require('body-parser'), 10 | cors = require('cors'), 11 | http = require('http'), 12 | basicAuth = require('express-basic-auth'); 13 | let sslOptions; 14 | 15 | try { 16 | sslOptions = { 17 | key: fs.readFileSync(PRIVKEY_PATH), 18 | cert: fs.readFileSync(CERT_PATH) 19 | }; 20 | } catch(err) { 21 | if (err.errno === -2) { 22 | console.log('No SSL key and/or cert found, not enabling https server'); 23 | } 24 | else { 25 | console.log(err); 26 | } 27 | } 28 | 29 | // if there is no `accounts` table in the DB, create an empty table 30 | db.prepare('CREATE TABLE IF NOT EXISTS accounts (name TEXT PRIMARY KEY, privkey TEXT, pubkey TEXT, webfinger TEXT, actor TEXT, apikey TEXT, followers TEXT, messages TEXT)').run(); 31 | // if there is no `messages` table in the DB, create an empty table 32 | db.prepare('CREATE TABLE IF NOT EXISTS messages (guid TEXT PRIMARY KEY, message TEXT)').run(); 33 | 34 | app.set('db', db); 35 | app.set('domain', DOMAIN); 36 | app.set('port', process.env.PORT || PORT || 3000); 37 | app.set('port-https', process.env.PORT_HTTPS || 8443); 38 | app.use(bodyParser.json({type: 'application/activity+json'})); // support json encoded bodies 39 | app.use(bodyParser.urlencoded({ extended: true })); // support encoded bodies 40 | 41 | // basic http authorizer 42 | let basicUserAuth = basicAuth({ 43 | authorizer: asyncAuthorizer, 44 | authorizeAsync: true, 45 | challenge: true 46 | }); 47 | 48 | function asyncAuthorizer(username, password, cb) { 49 | let isAuthorized = false; 50 | const isPasswordAuthorized = username === USER; 51 | const isUsernameAuthorized = password === PASS; 52 | isAuthorized = isPasswordAuthorized && isUsernameAuthorized; 53 | if (isAuthorized) { 54 | return cb(null, true); 55 | } 56 | else { 57 | return cb(null, false); 58 | } 59 | } 60 | 61 | app.get('/', (req, res) => res.send('Hello World!')); 62 | 63 | // admin page 64 | app.options('/api', cors()); 65 | app.use('/api', cors(), routes.api); 66 | app.use('/api/admin', cors({ credentials: true, origin: true }), basicUserAuth, routes.admin); 67 | app.use('/admin', express.static('public/admin')); 68 | app.use('/.well-known/webfinger', cors(), routes.webfinger); 69 | app.use('/u', cors(), routes.user); 70 | app.use('/m', cors(), routes.message); 71 | app.use('/api/inbox', cors(), routes.inbox); 72 | 73 | http.createServer(app).listen(app.get('port'), function(){ 74 | console.log('Express server listening on port ' + app.get('port')); 75 | }); 76 | -------------------------------------------------------------------------------- /routes/user.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const express = require('express'), 3 | router = express.Router(); 4 | 5 | router.get('/:name', function (req, res) { 6 | let name = req.params.name; 7 | if (!name) { 8 | return res.status(400).send('Bad request.'); 9 | } 10 | else { 11 | let db = req.app.get('db'); 12 | let domain = req.app.get('domain'); 13 | let username = name; 14 | name = `${name}@${domain}`; 15 | let result = db.prepare('select actor from accounts where name = ?').get(name); 16 | if (result === undefined) { 17 | return res.status(404).send(`No record found for ${name}.`); 18 | } 19 | else { 20 | let tempActor = JSON.parse(result.actor); 21 | // Added this followers URI for Pleroma compatibility, see https://github.com/dariusk/rss-to-activitypub/issues/11#issuecomment-471390881 22 | // New Actors should have this followers URI but in case of migration from an old version this will add it in on the fly 23 | if (tempActor.followers === undefined) { 24 | tempActor.followers = `https://${domain}/u/${username}/followers`; 25 | } 26 | res.set('Content-Type', 'application/activity+json'); 27 | res.json(tempActor); 28 | } 29 | } 30 | }); 31 | 32 | router.get('/:name/followers', function (req, res) { 33 | let name = req.params.name; 34 | if (!name) { 35 | return res.status(400).send('Bad request.'); 36 | } 37 | else { 38 | let db = req.app.get('db'); 39 | let domain = req.app.get('domain'); 40 | let result = db.prepare('select followers from accounts where name = ?').get(`${name}@${domain}`); 41 | console.log(result); 42 | result.followers = result.followers || '[]'; 43 | let followers = JSON.parse(result.followers); 44 | let followersCollection = { 45 | "type":"OrderedCollection", 46 | "totalItems":followers.length, 47 | "id":`https://${domain}/u/${name}/followers`, 48 | "first": { 49 | "type":"OrderedCollectionPage", 50 | "totalItems":followers.length, 51 | "partOf":`https://${domain}/u/${name}/followers`, 52 | "orderedItems": followers, 53 | "id":`https://${domain}/u/${name}/followers?page=1` 54 | }, 55 | "@context":["https://www.w3.org/ns/activitystreams"] 56 | }; 57 | res.set('Content-Type', 'application/activity+json'); 58 | res.json(followersCollection); 59 | } 60 | }); 61 | 62 | router.get('/:name/outbox', function (req, res) { 63 | let name = req.params.name; 64 | if (!name) { 65 | return res.status(400).send('Bad request.'); 66 | } 67 | else { 68 | let domain = req.app.get('domain'); 69 | let messages = []; 70 | let outboxCollection = { 71 | "type":"OrderedCollection", 72 | "totalItems":messages.length, 73 | "id":`https://${domain}/u/${name}/outbox`, 74 | "first": { 75 | "type":"OrderedCollectionPage", 76 | "totalItems":messages.length, 77 | "partOf":`https://${domain}/u/${name}/outbox`, 78 | "orderedItems": messages, 79 | "id":`https://${domain}/u/${name}/outbox?page=1` 80 | }, 81 | "@context":["https://www.w3.org/ns/activitystreams"] 82 | }; 83 | res.set('Content-Type', 'application/activity+json'); 84 | res.json(outboxCollection); 85 | } 86 | }); 87 | 88 | module.exports = router; 89 | -------------------------------------------------------------------------------- /public/admin/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Admin Page 6 | 31 | 32 | 33 |

Admin Page

34 |

Create Account

35 |

Create a new ActivityPub Actor (account). Requires the admin user/pass on submit.

36 |

37 | 38 |

39 | 40 |

41 |

Send Message To Followers

42 |

Enter an account name, its API key, and a message. This message will send to all its followers.

43 |

44 | 45 |

46 |

47 |
a long hex key you got when you created your account 48 |

49 |

50 |
51 |

52 | 53 |

54 | 55 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /routes/inbox.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const express = require('express'), 3 | crypto = require('crypto'), 4 | request = require('request'), 5 | router = express.Router(); 6 | 7 | function signAndSend(message, name, domain, req, res, targetDomain) { 8 | // get the URI of the actor object and append 'inbox' to it 9 | let inbox = message.object.actor+'/inbox'; 10 | let inboxFragment = inbox.replace('https://'+targetDomain,''); 11 | // get the private key 12 | let db = req.app.get('db'); 13 | let result = db.prepare('select privkey from accounts where name = ?').get(`${name}@${domain}`); 14 | if (result === undefined) { 15 | return res.status(404).send(`No record found for ${name}.`); 16 | } 17 | else { 18 | let privkey = result.privkey; 19 | const digestHash = crypto.createHash('sha256').update(JSON.stringify(message)).digest('base64'); 20 | const signer = crypto.createSign('sha256'); 21 | let d = new Date(); 22 | let stringToSign = `(request-target): post ${inboxFragment}\nhost: ${targetDomain}\ndate: ${d.toUTCString()}\ndigest: SHA-256=${digestHash}`; 23 | signer.update(stringToSign); 24 | signer.end(); 25 | const signature = signer.sign(privkey); 26 | const signature_b64 = signature.toString('base64'); 27 | let header = `keyId="https://${domain}/u/${name}",headers="(request-target) host date digest",signature="${signature_b64}"`; 28 | request({ 29 | url: inbox, 30 | headers: { 31 | 'Host': targetDomain, 32 | 'Date': d.toUTCString(), 33 | 'Digest': `SHA-256=${digestHash}`, 34 | 'Signature': header 35 | }, 36 | method: 'POST', 37 | json: true, 38 | body: message 39 | }, function (error, response){ 40 | if (error) { 41 | console.log('Error:', error, response.body); 42 | } 43 | else { 44 | console.log('Response:', response.body); 45 | } 46 | }); 47 | return res.status(200); 48 | } 49 | } 50 | 51 | function sendAcceptMessage(thebody, name, domain, req, res, targetDomain) { 52 | const guid = crypto.randomBytes(16).toString('hex'); 53 | let message = { 54 | '@context': 'https://www.w3.org/ns/activitystreams', 55 | 'id': `https://${domain}/${guid}`, 56 | 'type': 'Accept', 57 | 'actor': `https://${domain}/u/${name}`, 58 | 'object': thebody, 59 | }; 60 | signAndSend(message, name, domain, req, res, targetDomain); 61 | } 62 | 63 | function parseJSON(text) { 64 | try { 65 | return JSON.parse(text); 66 | } catch(e) { 67 | return null; 68 | } 69 | } 70 | 71 | router.post('/', function (req, res) { 72 | // pass in a name for an account, if the account doesn't exist, create it! 73 | let domain = req.app.get('domain'); 74 | const myURL = new URL(req.body.actor); 75 | let targetDomain = myURL.hostname; 76 | // TODO: add "Undo" follow event 77 | if (typeof req.body.object === 'string' && req.body.type === 'Follow') { 78 | let name = req.body.object.replace(`https://${domain}/u/`,''); 79 | sendAcceptMessage(req.body, name, domain, req, res, targetDomain); 80 | // Add the user to the DB of accounts that follow the account 81 | let db = req.app.get('db'); 82 | // get the followers JSON for the user 83 | let result = db.prepare('select followers from accounts where name = ?').get(`${name}@${domain}`); 84 | if (result === undefined) { 85 | console.log(`No record found for ${name}.`); 86 | } 87 | else { 88 | // update followers 89 | let followers = parseJSON(result.followers); 90 | if (followers) { 91 | followers.push(req.body.actor); 92 | // unique items 93 | followers = [...new Set(followers)]; 94 | } 95 | else { 96 | followers = [req.body.actor]; 97 | } 98 | let followersText = JSON.stringify(followers); 99 | try { 100 | // update into DB 101 | let newFollowers = db.prepare('update accounts set followers=? where name = ?').run(followersText, `${name}@${domain}`); 102 | console.log('updated followers!', newFollowers); 103 | } 104 | catch(e) { 105 | console.log('error', e); 106 | } 107 | } 108 | } 109 | }); 110 | 111 | module.exports = router; 112 | -------------------------------------------------------------------------------- /routes/api.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const express = require('express'), 3 | router = express.Router(), 4 | request = require('request'), 5 | crypto = require('crypto'); 6 | 7 | router.post('/sendMessage', function (req, res) { 8 | let db = req.app.get('db'); 9 | let domain = req.app.get('domain'); 10 | let acct = req.body.acct; 11 | let apikey = req.body.apikey; 12 | let message = req.body.message; 13 | // check to see if your API key matches 14 | let result = db.prepare('select apikey from accounts where name = ?').get(`${acct}@${domain}`); 15 | if (result.apikey === apikey) { 16 | sendCreateMessage(message, acct, domain, req, res); 17 | } 18 | else { 19 | res.status(403).json({msg: 'wrong api key'}); 20 | } 21 | }); 22 | 23 | function signAndSend(message, name, domain, req, res, targetDomain, inbox) { 24 | // get the private key 25 | let db = req.app.get('db'); 26 | let inboxFragment = inbox.replace('https://'+targetDomain,''); 27 | let result = db.prepare('select privkey from accounts where name = ?').get(`${name}@${domain}`); 28 | if (result === undefined) { 29 | console.log(`No record found for ${name}.`); 30 | } 31 | else { 32 | let privkey = result.privkey; 33 | const digestHash = crypto.createHash('sha256').update(JSON.stringify(message)).digest('base64'); 34 | const signer = crypto.createSign('sha256'); 35 | let d = new Date(); 36 | let stringToSign = `(request-target): post ${inboxFragment}\nhost: ${targetDomain}\ndate: ${d.toUTCString()}\ndigest: SHA-256=${digestHash}`; 37 | signer.update(stringToSign); 38 | signer.end(); 39 | const signature = signer.sign(privkey); 40 | const signature_b64 = signature.toString('base64'); 41 | let header = `keyId="https://${domain}/u/${name}",headers="(request-target) host date digest",signature="${signature_b64}"`; 42 | request({ 43 | url: inbox, 44 | headers: { 45 | 'Host': targetDomain, 46 | 'Date': d.toUTCString(), 47 | 'Digest': `SHA-256=${digestHash}`, 48 | 'Signature': header 49 | }, 50 | method: 'POST', 51 | json: true, 52 | body: message 53 | }, function (error, response){ 54 | console.log(`Sent message to an inbox at ${targetDomain}!`); 55 | if (error) { 56 | console.log('Error:', error, response); 57 | } 58 | else { 59 | console.log('Response Status Code:', response.statusCode); 60 | } 61 | }); 62 | } 63 | } 64 | 65 | function createMessage(text, name, domain, req, res, follower) { 66 | const guidCreate = crypto.randomBytes(16).toString('hex'); 67 | const guidNote = crypto.randomBytes(16).toString('hex'); 68 | let db = req.app.get('db'); 69 | let d = new Date(); 70 | 71 | let noteMessage = { 72 | 'id': `https://${domain}/m/${guidNote}`, 73 | 'type': 'Note', 74 | 'published': d.toISOString(), 75 | 'attributedTo': `https://${domain}/u/${name}`, 76 | 'content': text, 77 | 'to': ['https://www.w3.org/ns/activitystreams#Public'], 78 | }; 79 | 80 | let createMessage = { 81 | '@context': 'https://www.w3.org/ns/activitystreams', 82 | 83 | 'id': `https://${domain}/m/${guidCreate}`, 84 | 'type': 'Create', 85 | 'actor': `https://${domain}/u/${name}`, 86 | 'to': ['https://www.w3.org/ns/activitystreams#Public'], 87 | 'cc': [follower], 88 | 89 | 'object': noteMessage 90 | }; 91 | 92 | db.prepare('insert or replace into messages(guid, message) values(?, ?)').run( guidCreate, JSON.stringify(createMessage)); 93 | db.prepare('insert or replace into messages(guid, message) values(?, ?)').run( guidNote, JSON.stringify(noteMessage)); 94 | 95 | return createMessage; 96 | } 97 | 98 | function sendCreateMessage(text, name, domain, req, res) { 99 | let db = req.app.get('db'); 100 | 101 | let result = db.prepare('select followers from accounts where name = ?').get(`${name}@${domain}`); 102 | let followers = JSON.parse(result.followers); 103 | console.log(followers); 104 | console.log('type',typeof followers); 105 | if (followers === null) { 106 | console.log('aaaa'); 107 | res.status(400).json({msg: `No followers for account ${name}@${domain}`}); 108 | } 109 | else { 110 | for (let follower of followers) { 111 | let inbox = follower+'/inbox'; 112 | let myURL = new URL(follower); 113 | let targetDomain = myURL.host; 114 | let message = createMessage(text, name, domain, req, res, follower); 115 | signAndSend(message, name, domain, req, res, targetDomain, inbox); 116 | } 117 | res.status(200).json({msg: 'ok'}); 118 | } 119 | } 120 | 121 | module.exports = router; 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Express ActivityPub Server 2 | 3 | A very simple standalone ActivityPub server that supports: 4 | 5 | * creation of new Actors via API 6 | * discovery of our Actors via webfinger (so you can find these accounts from other instances) 7 | * notifying followers of new posts (so new posts show up in their timeline) 8 | 9 | _This is meant as a reference implementation!_ This code implements a very small subset of ActivityPub and is supposed to help you get your bearings when it comes to making your own barebones ActivityPub support in your own projects. (Of course you can still fork this and start building on it as well, but it's not exactly hardened production code.) 10 | 11 | Example use case: I own tinysubversions.com. I can have this server run on bots.tinysubversions.com. All of my bots are stored and published and discoverable there. If I want to create a new bot, I go to bots.tinysubversions.com/admin and enter an account name, enter my admin user/pass on prompt, and it creates an account record and it gives me back an API key. I then make POST calls to the API passing the API key in a header and it publishes those things to followers. 12 | 13 | ## Requirements 14 | 15 | This requires Node.js v10.10.0 or above. 16 | 17 | ## Installation 18 | 19 | Clone the repository, then `cd` into its root directory. Install dependencies: 20 | 21 | `npm i` 22 | 23 | Copy `config-template.json` to `config.json`. 24 | 25 | `cp config-template.json config.json` 26 | 27 | Update your `config.json` file: 28 | 29 | ```js 30 | { 31 | "USER": "pickAUsername", 32 | "PASS": "pickAPassword", 33 | "DOMAIN": "mydomain.com", // your domain! this should be a discoverable domain of some kind like "example.com" 34 | "PORT": "3000", // the port that Express runs on 35 | "PRIVKEY_PATH": "/path/to/your/ssl/privkey.pem", // point this to your private key you got from Certbot or similar 36 | "CERT_PATH": "/path/to/your/ssl/cert.pem" // point this to your cert you got from Certbot or similar 37 | } 38 | ``` 39 | 40 | Run the server! 41 | 42 | `node index.js` 43 | 44 | Go to the admin page and create an account: 45 | 46 | `http://yourdomain.com/admin` 47 | 48 | Enter "test" in the "Create Account" section and hit the "Create Account" button. It will prompt you for the user/pass you just set in your config file, and then you should get a message with some verification instructions, pointing you to some URLs that should be serving some ActivityPub JSON now. 49 | 50 | ## Local testing 51 | 52 | You can use a service like [ngrok](https://ngrok.com/) to test things out before you deploy on a real server. All you need to do is install ngrok and run `ngrok http 3000` (or whatever port you're using if you changed it). Then go to your `config.json` and update the `DOMAIN` field to whatever `abcdef.ngrok.io` domain that ngrok gives you and restart your server. *For local testing you do not need to specify `PRIVKEY_PATH` or `CERT_PARTH`.* 53 | 54 | ## Admin Page 55 | 56 | For your convenience, if you go to the `/admin` endpoint in a browser, you will see an admin page. Don't worry, nothing is possible here unless either your admin user/pass (for creating accounts) or a valid API key (for sending messages as an account). This page provides a simple web form for both creating accounts and sending messages to followers. 57 | 58 | ## API 59 | 60 | ### Create Account 61 | 62 | Create a new account. This is a new ActivityPub Actor, along with its webfinger record. This creates a new row in the `accounts` table in the database. 63 | 64 | Send a POST to `/api/admin/create` using basic HTTP auth with the admin username/password. The form body needs an "account" field. An example CURL request: 65 | 66 | ``` 67 | curl -u adminUsername:adminPassword -d "account=test" -H "Content-Type: application/x-www-form-urlencoded" -X POST http://example.com/api/admin/create 68 | ``` 69 | 70 | This will return a 200 status and `{msg: "ok", apikey: "yourapikey"}` if all goes well. 71 | 72 | ### Send Message to Followers 73 | 74 | Send a message to followers. This is NOT a direct message or an @-mention. This simply means that the message you post via this endpoint will appear in the timelines (AKA inboxes) of every one of the account's followers. 75 | 76 | Send a POST to `api/sendMessage` with the form fields `acct`, `apikey`, and `message`. 77 | 78 | * `acct`: the account name in the form "myAccountName" (no domain or @'s needed) 79 | * `apikey`: your hex API key 80 | * `message`: the message you want to send -- for Mastodon-compatible posts this might be plain text or simple HTML, but ActivityPub is a lot more flexible than just Mastodon! In theory, according to the [ActivityPub spec](https://www.w3.org/TR/activitypub/#create-activity-outbox) it can be any [ActivityStreams object](https://www.w3.org/TR/activitystreams-core/#object) 81 | 82 | ## Database 83 | 84 | This server uses a SQLite database to keep track of all the data. There is one table in the database: `accounts`. 85 | 86 | ### `accounts` 87 | 88 | This table keeps track of all the data needed for the accounts. Columns: 89 | 90 | * `name` `TEXT PRIMARY KEY`: the account name, in the form `thename@example.com` 91 | * `privkey` `TEXT`: the RSA private key for the account 92 | * `pubkey` `TEXT`: the RSA public key for the account 93 | * `webfinger` `TEXT`: the entire contents of the webfinger JSON served for this account 94 | * `actor` `TEXT`: the entire contents of the actor JSON served for this account 95 | * `apikey` `TEXT`: the API key associated with this account 96 | * `followers` `TEXT`: a JSON-formatted array of the URL for the Actor JSON of all followers, in the form `["https://remote.server/users/somePerson", "https://another.remote.server/ourUsers/anotherPerson"]` 97 | * `messages` `TEXT`: not yet used but will eventually store all messages so we can render them on a "profile" page 98 | 99 | ### `messages` 100 | 101 | This table holds all messages sent by the server, which are served at the url `/m/some-id-number/`. 102 | 103 | * `guid` `TEXT PRIMARY KEY`: an id for the message 104 | * `message` `TEXT`: a JSON object encoding the full message 105 | 106 | ## License 107 | 108 | Copyright (c) 2018 Darius Kazemi. Licensed under the MIT license. 109 | 110 | --------------------------------------------------------------------------------