First commit.

This commit is contained in:
Darius Kazemi 2018-09-15 00:01:19 -07:00
commit d696a1f465
12 changed files with 652 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules/
package-lock.json
*.db

98
README.md Normal file
View File

@ -0,0 +1,98 @@
# Express ActivityPub Server
A very simple standalone ActivityPub server that supports:
* creation of new Actors via API
* discovery of our Actors via webfinger (so you can find these accounts from other instances)
* notifying followers of new posts (so new posts show up in their timeline)
_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.)
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.
## Requirements
This requires Node.js v10.10.0 or above.
## Installation
Clone the repository, then `cd` into its root directory. Install dependencies:
`npm i`
Update your `config.json` file:
```js
{
"USER": "pickAUsername",
"PASS": "pickAPassword",
"DOMAIN": "mydomain.com", // your domain! this should be a discoverable domain of some kind like "example.com"
"PORT": "3000", // the port that Express runs on
"PRIVKEY_PATH": "/path/to/your/ssl/privkey.pem", // point this to your private key you got from Certbot or similar
"CERT_PATH": "/path/to/your/ssl/cert.pem" // point this to your cert you got from Certbot or similar
}
```
Run the server!
`node index.js`
Go to the admin page and create an account:
`http://yourdomain.com/admin`
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.
## Local testing
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.
## Admin Page
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.
## API
### Create Account
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.
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:
```
curl -u adminUsername:adminPassword -d "account=test" -H "Content-Type: application/x-www-form-urlencoded" -X POST http://example.com/api/admin/create
```
This will return a 200 status and `{msg: "ok", apikey: "yourapikey"}` if all goes well.
### Send Message to Followers
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.
Send a POST to `api/sendMessage` with the form fields `acct`, `apikey`, and `message`.
* `acct`: the account name in the form "myAccountName" (no domain or @'s needed)
* `apikey`: your hex API
## Database
This server uses a SQLite database to keep track of all the data. There is one table in the database: `accounts`.
### `accounts`
This table keeps track of all the data needed for the accounts. Columns:
* `name` `TEXT PRIMARY KEY`: the account name, in the form `thename@example.com`
* `privkey` `TEXT`: the RSA private key for the account
* `pubkey` `TEXT`: the RSA public key for the account
* `webfinger` `TEXT`: the entire contents of the webfinger JSON served for this account
* `actor` `TEXT`: the entire contents of the actor JSON served for this account
* `apikey` `TEXT`: the API key associated with this account
* `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"]`
* `messages` `TEXT`: not yet used but will eventually store all messages so we can render them on a "profile" page
## License
Copyright (c) 2018 Darius Kazemi
Licensed under the MIT license.

8
config.json Normal file
View File

@ -0,0 +1,8 @@
{
"USER": "",
"PASS": "",
"DOMAIN": "",
"PORT": "3000",
"PRIVKEY_PATH": "",
"CERT_PATH": ""
}

72
index.js Normal file
View File

@ -0,0 +1,72 @@
const config = require('./config.json');
const { USER, PASS, DOMAIN, PRIVKEY_PATH, CERT_PATH, PORT } = config;
const express = require('express');
const app = express();
const sqlite3 = require('sqlite3').verbose();
const db = new sqlite3.Database('bot-node.db');
const fs = require('fs');
const routes = require('./routes'),
bodyParser = require('body-parser'),
cors = require('cors'),
http = require('http'),
basicAuth = require('express-basic-auth');
let sslOptions;
try {
sslOptions = {
key: fs.readFileSync(PRIVKEY_PATH),
cert: fs.readFileSync(CERT_PATH)
};
} catch(err) {
if (err.errno === -2) {
console.log('No SSL key and/or cert found, not enabling https server');
}
else {
console.log(err);
}
}
// if there is no `accounts` table in the DB, create an empty table
db.run('CREATE TABLE IF NOT EXISTS accounts (name TEXT PRIMARY KEY, privkey TEXT, pubkey TEXT, webfinger TEXT, actor TEXT, apikey TEXT, followers TEXT, messages TEXT)');
app.set('db', db);
app.set('domain', DOMAIN);
app.set('port', process.env.PORT || PORT || 3000);
app.set('port-https', process.env.PORT_HTTPS || 8443);
app.use(bodyParser.json({type: 'application/activity+json'})); // support json encoded bodies
app.use(bodyParser.urlencoded({ extended: true })); // support encoded bodies
// basic http authorizer
let basicUserAuth = basicAuth({
authorizer: asyncAuthorizer,
authorizeAsync: true,
challenge: true
});
function asyncAuthorizer(username, password, cb) {
let isAuthorized = false;
const isPasswordAuthorized = username === USER;
const isUsernameAuthorized = password === PASS;
isAuthorized = isPasswordAuthorized && isUsernameAuthorized;
if (isAuthorized) {
return cb(null, true);
}
else {
return cb(null, false);
}
}
app.get('/', (req, res) => res.send('Hello World!'));
// admin page
app.options('/api', cors());
app.use('/api', cors(), routes.api);
app.use('/api/admin', cors({ credentials: true, origin: true }), basicUserAuth, routes.admin);
app.use('/admin', express.static('public/admin'));
app.use('/.well-known/webfinger', cors(), routes.webfinger);
app.use('/u', cors(), routes.user);
app.use('/api/inbox', cors(), routes.inbox);
http.createServer(app).listen(app.get('port'), function(){
console.log('Express server listening on port ' + app.get('port'));
});

24
package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "bot-node",
"version": "1.0.0",
"description": "",
"main": "index.js",
"dependencies": {
"body-parser": "^1.18.3",
"cors": "^2.8.4",
"express": "^4.16.3",
"express-basic-auth": "^1.1.5",
"generate-rsa-keypair": "^0.1.2",
"request": "^2.87.0",
"sqlite3": "^4.0.2"
},
"engines": {
"node": ">=10.10.0"
},
"devDependencies": {},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC"
}

108
public/admin/index.html Normal file
View File

@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Admin Page</title>
<style>
body {
font-family: sans-serif;
max-width: 900px;
margin: 30px;
}
img {
max-width: 100px;
}
li {
margin-bottom: 0.2em;
}
.account {
}
input {
width: 300px;
font-size: 1.2em;
}
.hint {
font-size: 0.8em;
}
button {
font-size: 1.2em;
}
</style>
</head>
<body>
<h1>Admin Page</h1>
<h2>Create Account</h2>
<p>Create a new ActivityPub Actor (account). Requires the admin user/pass on submit.</p>
<p>
<input id="account" type="text" placeholder="myAccountName"/>
</p>
<button onclick="createAccount()">Create Account</button>
<p id="createOutput"></p>
<h2>Send Message To Followers</h2>
<p>Enter an account name, its API key, and a message. This message will send to all its followers.</p>
<p>
<input id="acct" type="text" placeholder="myAccountName"/>
</p>
<p>
<input id="apikey" type="text" placeholder="1234567890abcdef"/><br><span class="hint">a long hex key you got when you created your account</span>
</p>
<p>
<input id="message" type="text" placeholder="Hello there."/><br>
</p>
<button onclick="sendMessage()">Send Message</button>
<p id="sendOutput"></p>
<script>
function queryStringFromObject(obj) {
return Object.keys(obj).map(key => key + '=' + obj[key]).join('&');
}
function sendMessage() {
let acct = document.querySelector('#acct').value;
let apikey = document.querySelector('#apikey').value;
let message = document.querySelector('#message').value;
postData('/api/sendMessage', {acct, apikey, message})
.then(data => {
console.log(data);
if (data.msg && data.msg === 'ok') {
let outputElement = document.querySelector('#sendOutput');
outputElement.innerHTML = 'Message sent successfully!';
}
}) // JSON-string from `response.json()` call
.catch(error => console.error(error));
}
function createAccount() {
let account = document.querySelector('#account').value;
postData('/api/admin/create', {account})
.then(data => {
console.log('data', data);
if (data.msg && data.msg === 'ok') {
let outputElement = document.querySelector('#createOutput');
outputElement.innerHTML = `Account created successfully! To confirm, go to <a href="/u/${account}">this URL</a>, you should see JSON for the new account Actor. Next verify that there is some JSON being served from <a href="/.well-known/webfinger?resource=acct:${account}@${window.location.hostname}">at the account's webfinger URL</a>. Then try to find ${account}@${window.location.hostname} from the search in Mastodon or another ActivityPub client. You should be able to follow the account. <br><br>Your API key for sending messages is ${data.apikey} &mdash; please save this somewhere!`;
}
}) // JSON-string from `response.json()` call
.catch(error => console.error(error));
}
function postData(url = ``, data = {}) {
// Default options are marked with *
return fetch(url, {
method: "POST", // *GET, POST, PUT, DELETE, etc.
mode: "cors", // no-cors, cors, *same-origin
cache: "no-cache", // *default, no-cache, reload, force-cache, only-if-cached
credentials: "same-origin", // include, same-origin, *omit
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
redirect: "follow", // manual, *follow, error
referrer: "no-referrer", // no-referrer, *client
body: queryStringFromObject(data), // body data type must match "Content-Type" header
})
.then(response => response.json()); // parses response to JSON
}
</script>
</body>
</html>

66
routes/admin.js Normal file
View File

@ -0,0 +1,66 @@
'use strict';
const express = require('express'),
router = express.Router(),
crypto = require('crypto'),
generateRSAKeypair = require('generate-rsa-keypair');
function createActor(name, domain, pubkey) {
return {
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1'
],
'id': `https://${domain}/u/${name}`,
'type': 'Person',
'preferredUsername': `${name}`,
'inbox': `https://${domain}/api/inbox`,
'publicKey': {
'id': `https://${domain}/u/${name}#main-key`,
'owner': `https://${domain}/u/${name}`,
'publicKeyPem': pubkey
}
};
}
function createWebfinger(name, domain) {
return {
'subject': `acct:${name}@${domain}`,
'links': [
{
'rel': 'self',
'type': 'application/activity+json',
'href': `https://${domain}/u/${name}`
}
]
};
}
router.post('/create', function (req, res) {
// pass in a name for an account, if the account doesn't exist, create it!
const account = req.body.account;
if (account === undefined) {
return res.status(400).json({msg: 'Bad request. Please make sure "account" is a property in the POST body.'});
}
let db = req.app.get('db');
let domain = req.app.get('domain');
// create keypair
var pair = generateRSAKeypair();
let actorRecord = createActor(account, domain, pair.public);
let webfingerRecord = createWebfinger(account, domain);
const apikey = crypto.randomBytes(16).toString('hex');
db.run('insert or replace into accounts(name, actor, apikey, pubkey, privkey, webfinger) values($name, $actor, $apikey, $pubkey, $privkey, $webfinger)', {
$name: `${account}@${domain}`,
$apikey: apikey,
$pubkey: pair.public,
$privkey: pair.private,
$actor: JSON.stringify(actorRecord),
$webfinger: JSON.stringify(webfingerRecord)
}, (err, accounts) => {
res.status(200).json({msg: 'ok', apikey});
});
});
module.exports = router;

111
routes/api.js Normal file
View File

@ -0,0 +1,111 @@
'use strict';
const express = require('express'),
router = express.Router(),
request = require('request'),
crypto = require('crypto');
router.post('/sendMessage', function (req, res) {
let db = req.app.get('db');
let domain = req.app.get('domain');
let acct = req.body.acct;
let apikey = req.body.apikey;
let message = req.body.message;
// check to see if your API key matches
db.get('select apikey from accounts where name = $name', {$name: `${acct}@${domain}`}, (err, result) => {
if (result.apikey === apikey) {
sendCreateMessage(message, acct, domain, req, res);
}
else {
res.status(403).json({msg: 'wrong api key'});
}
});
});
function signAndSend(message, name, domain, req, res, targetDomain, inbox) {
// get the private key
let db = req.app.get('db');
let inboxFragment = inbox.replace('https://'+targetDomain,'');
db.get('select privkey from accounts where name = $name', {$name: `${name}@${domain}`}, (err, result) => {
if (result === undefined) {
console.log(`No record found for ${name}.`);
}
else {
let privkey = result.privkey;
const signer = crypto.createSign('sha256');
let d = new Date();
let stringToSign = `(request-target): post ${inboxFragment}\nhost: ${targetDomain}\ndate: ${d.toUTCString()}`;
signer.update(stringToSign);
signer.end();
const signature = signer.sign(privkey);
const signature_b64 = signature.toString('base64');
let header = `keyId="https://${domain}/u/${name}",headers="(request-target) host date",signature="${signature_b64}"`;
request({
url: inbox,
headers: {
'Host': targetDomain,
'Date': d.toUTCString(),
'Signature': header
},
method: 'POST',
json: true,
body: message
}, function (error, response){
console.log(`Sent message to an inbox at ${targetDomain}!`);
if (error) {
console.log('Error:', error, response);
}
else {
console.log('Response Status Code:', response.statusCode);
}
});
}
});
}
function createMessage(text, name, domain) {
const guid = crypto.randomBytes(16).toString('hex');
let d = new Date();
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': `https://${domain}/${guid}`,
'type': 'Create',
'actor': `https://${domain}/u/${name}`,
'object': {
'id': `https://${domain}/${guid}`,
'type': 'Note',
'published': d.toISOString(),
'attributedTo': `https://${domain}/u/${name}`,
'content': text,
'to': 'https://www.w3.org/ns/activitystreams#Public'
}
};
}
function sendCreateMessage(text, name, domain, req, res) {
let message = createMessage(text, name, domain);
let db = req.app.get('db');
db.get('select followers from accounts where name = $name', {$name: `${name}@${domain}`}, (err, result) => {
let followers = JSON.parse(result.followers);
console.log(followers);
console.log('type',typeof followers);
if (followers === null) {
console.log('aaaa');
res.status(400).json({msg: `No followers for account ${name}@${domain}`});
}
else {
for (let follower of followers) {
let inbox = follower+'/inbox';
let myURL = new URL(follower);
let targetDomain = myURL.hostname;
signAndSend(message, name, domain, req, res, targetDomain, inbox);
}
res.status(200).json({msg: 'ok'});
}
});
}
module.exports = router;

104
routes/inbox.js Normal file
View File

@ -0,0 +1,104 @@
'use strict';
const express = require('express'),
crypto = require('crypto'),
request = require('request'),
router = express.Router();
function signAndSend(message, name, domain, req, res, targetDomain) {
// get the private key
let db = req.app.get('db');
db.get('select privkey from accounts where name = $name', {$name: `${name}@${domain}`}, (err, result) => {
if (result === undefined) {
return res.status(404).send(`No record found for ${name}.`);
}
else {
let privkey = result.privkey;
const signer = crypto.createSign('sha256');
let d = new Date();
let stringToSign = `(request-target): post /inbox\nhost: ${targetDomain}\ndate: ${d.toUTCString()}`;
signer.update(stringToSign);
signer.end();
const signature = signer.sign(privkey);
const signature_b64 = signature.toString('base64');
let header = `keyId="https://${domain}/u/${name}",headers="(request-target) host date",signature="${signature_b64}"`;
request({
url: `https://${targetDomain}/inbox`,
headers: {
'Host': targetDomain,
'Date': d.toUTCString(),
'Signature': header
},
method: 'POST',
json: true,
body: message
}, function (error, response){
if (error) {
console.log('Error:', error, response.body);
}
else {
console.log('Response:', response.body);
}
});
return res.status(200);
}
});
}
function sendAcceptMessage(thebody, name, domain, req, res, targetDomain) {
const guid = crypto.randomBytes(16).toString('hex');
let message = {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': `https://${domain}/${guid}`,
'type': 'Accept',
'actor': `https://${domain}/u/${name}`,
'object': thebody,
};
signAndSend(message, name, domain, req, res, targetDomain);
}
function parseJSON(text) {
try {
return JSON.parse(text);
} catch(e) {
return null;
}
}
router.post('/', function (req, res) {
// pass in a name for an account, if the account doesn't exist, create it!
let domain = req.app.get('domain');
const myURL = new URL(req.body.actor);
let targetDomain = myURL.hostname;
// TODO: add "Undo" follow event
if (typeof req.body.object === 'string' && req.body.type === 'Follow') {
let name = req.body.object.replace(`https://${domain}/u/`,'');
sendAcceptMessage(req.body, name, domain, req, res, targetDomain);
// Add the user to the DB of accounts that follow the account
let db = req.app.get('db');
// get the followers JSON for the user
db.get('select followers from accounts where name = $name', {$name: `${name}@${domain}`}, (err, result) => {
if (result === undefined) {
console.log(`No record found for ${name}.`);
}
else {
// update followers
let followers = parseJSON(result.followers);
if (followers) {
followers.push(req.body.actor);
// unique items
followers = [...new Set(followers)];
}
else {
followers = [req.body.actor];
}
let followersText = JSON.stringify(followers);
// update into DB
db.run('update accounts set followers=$followers where name = $name', {$name: `${name}@${domain}`, $followers: followersText}, (err, result) => {
console.log('updated followers!', err, result);
});
}
});
}
});
module.exports = router;

9
routes/index.js Normal file
View File

@ -0,0 +1,9 @@
'use strict';
module.exports = {
api: require('./api'),
admin: require('./admin'),
user: require('./user'),
inbox: require('./inbox'),
webfinger: require('./webfinger'),
};

25
routes/user.js Normal file
View File

@ -0,0 +1,25 @@
'use strict';
const express = require('express'),
router = express.Router();
router.get('/:name', function (req, res) {
let name = req.params.name;
if (!name) {
return res.status(400).send('Bad request.');
}
else {
let db = req.app.get('db');
let domain = req.app.get('domain');
name = `${name}@${domain}`;
db.get('select actor from accounts where name = $name', {$name: name}, (err, result) => {
if (result === undefined) {
return res.status(404).send(`No record found for ${name}.`);
}
else {
res.json(JSON.parse(result.actor));
}
});
}
});
module.exports = router;

24
routes/webfinger.js Normal file
View File

@ -0,0 +1,24 @@
'use strict';
const express = require('express'),
router = express.Router();
router.get('/', function (req, res) {
let resource = req.query.resource;
if (!resource || !resource.includes('acct:')) {
return res.status(400).send('Bad request. Please make sure "acct:USER@DOMAIN" is what you are sending as the "resource" query parameter.');
}
else {
let name = resource.replace('acct:','');
let db = req.app.get('db');
db.get('select webfinger from accounts where name = $name', {$name: name}, (err, result) => {
if (result === undefined) {
return res.status(404).send(`No record found for ${name}.`);
}
else {
res.json(JSON.parse(result.webfinger));
}
});
}
});
module.exports = router;