From 5faad9ea56dcf2d715c9e11e07490f50115d25bb Mon Sep 17 00:00:00 2001 From: Darius Kazemi Date: Wed, 11 Dec 2019 18:11:09 -0800 Subject: First pass at federation! --- .gitignore | 1 + README.md | 2 +- config/domain-example.js | 3 + models/Event.js | 14 ++- package-lock.json | 5 ++ package.json | 1 + routes.js | 195 +++++++++++++++++++++++++++++++++--------- views/event.handlebars | 4 +- views/eventgroup.handlebars | 6 +- views/layouts/main.handlebars | 8 +- 10 files changed, 186 insertions(+), 53 deletions(-) create mode 100644 config/domain-example.js diff --git a/.gitignore b/.gitignore index a31f385..16d3fa8 100755 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ config/api.js config/database.js +config/domain.js public/events/* !public/events/.gitkeep diff --git a/README.md b/README.md index d40c0f0..6b4025d 100644 --- a/README.md +++ b/README.md @@ -8,5 +8,5 @@ You can use the publicly hosted version [here](https://gath.io). 1. Clone the repository 2. Open the directory, run `npm install` -3. Rename `config/api-example.js` and `config/database-example.js` to `config/api.js` and `config/database.js`. For locally hosted versions, the local MongoDB configuration will work fine. To send emails, you need to set up a Sendgrid account and get an API key, which you should paste into `config/api.js`. +3. Rename `config/api-example.js` and `config/database-example.js` and `config/domain-example.js` to `config/api.js` and `config/database.js` and `config/domain.js`. For locally hosted versions, the local MongoDB configuration will work fine. To send emails, you need to set up a Sendgrid account and get an API key, which you should paste into `config/api.js`. 4. Run `npm start`. Enjoy! diff --git a/config/domain-example.js b/config/domain-example.js new file mode 100644 index 0000000..3cb4c85 --- /dev/null +++ b/config/domain-example.js @@ -0,0 +1,3 @@ +module.exports = { + 'domain' : 'localhost:3000' // Your domain goes here +}; diff --git a/models/Event.js b/models/Event.js index 3c0bb8c..1de6088 100755 --- a/models/Event.js +++ b/models/Event.js @@ -159,7 +159,19 @@ const EventSchema = new mongoose.Schema({ maxAttendees: { type: Number }, - comments: [CommentSchema] + comments: [CommentSchema], + activityPubActor: { + type: String, + trim: true + }, + publicKey: { + type: String, + trim: true + }, + privateKey: { + type: String, + trim: true + } }); module.exports = mongoose.model('Event', EventSchema); diff --git a/package-lock.json b/package-lock.json index 2e1d899..58c466e 100755 --- a/package-lock.json +++ b/package-lock.json @@ -2560,6 +2560,11 @@ "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", "dev": true }, + "generate-rsa-keypair": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/generate-rsa-keypair/-/generate-rsa-keypair-0.2.1.tgz", + "integrity": "sha512-vxLfzfy6WbMLtkKV4AJtg7QH0ZqGGNkSYM6S0Q72Z70QXsztLklKFtX15te3YLIqmiQAYi3g3MWsTfXd6djkpg==" + }, "get-stream": { "version": "3.0.0", "resolved": "http://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", diff --git a/package.json b/package.json index fff1c2d..1368503 100755 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "express-jwt": "^5.3.1", "express-session": "^1.15.6", "express-validator": "^5.3.0", + "generate-rsa-keypair": "^0.2.1", "greenlock": "^2.6.7", "greenlock-express": "^2.6.7", "ical": "^0.6.0", diff --git a/routes.js b/routes.js index ee723e2..c04077b 100755 --- a/routes.js +++ b/routes.js @@ -20,6 +20,11 @@ var moment = require('moment-timezone'); const marked = require('marked'); +const generateRSAKeypair = require('generate-rsa-keypair'); + +const domain = require('./config/domain.js').domain; +const contactEmail = require('./config/domain.js').email; + // Extra marked renderer (used to render plaintext event description for page metadata) // Adapted from https://dustinpfister.github.io/2017/11/19/nodejs-marked/ // ? to ? helper @@ -121,10 +126,49 @@ const deleteOldEvents = schedule.scheduleJob('59 23 * * *', function(fireDate){ }); +// ACTIVITYPUB HELPER FUNCTIONS +function createWebfinger(eventID, domain) { + return { + 'subject': `acct:${eventID}@${domain}`, + + 'links': [ + { + 'rel': 'self', + 'type': 'application/activity+json', + 'href': `https://${domain}/${eventID}` + } + ] + }; +} + +function createActivityPubActor(eventID, domain, pubkey) { + return JSON.stringify({ + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1' + ], + + 'id': `https://${domain}/u/${eventID}`, + 'type': 'Person', + 'preferredUsereventID': `${eventID}`, + 'inbox': `https://${domain}/api/inbox`, + 'followers': `https://${domain}/u/${eventID}/followers`, + + 'publicKey': { + 'id': `https://${domain}/u/${eventID}#main-key`, + 'owner': `https://${domain}/u/${eventID}`, + 'publicKeyPem': pubkey + } + }); +} + // FRONTEND ROUTES router.get('/', (req, res) => { - res.render('home'); + res.render('home', { + domain: domain, + email: contactEmail, + }); }); router.get('/new', (req, res) => { @@ -174,6 +218,48 @@ router.get('/new/event/public', (req, res) => { }); }) +router.get('/.well-known/webfinger', (req, res) => { + console.log(req.query); + 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 { + // "foo@domain" + let activityPubAccount = resource.replace('acct:',''); + // "foo" + let eventID = activityPubAccount.replace(/@.*/,''); + console.log(eventID); + Event.findOne({ + id: eventID + }) + .then((event) => { + if (!event) { + res.status(404); + res.render('404', { url: req.url }); + } + else { + res.json(createWebfinger(eventID, domain)); + } + }) + .catch((err) => { + addToLog("renderWebfinger", "error", "Attempt to render webfinger for " + req.params.eventID + " failed with error: " + err); + console.log(err) + res.status(404); + res.render('404', { url: req.url }); + return; + }); + //let db = req.app.get('db'); + //let result = db.prepare('select webfinger from accounts where name = ?').get(name); + //if (result === undefined) { + // return res.status(404).send(`No record found for ${name}.`); + //} + //else { + // res.json(JSON.parse(result.webfinger)); + //} + } +}); + router.get('/:eventID', (req, res) => { Event.findOne({ id: req.params.eventID @@ -256,34 +342,51 @@ router.get('/:eventID', (req, res) => { let metadata = { title: event.name, description: marked(event.description, { renderer: render_plain()}).split(" ").splice(0,40).join(" ").trim(), - image: (eventHasCoverImage ? 'https://gath.io/events/' + event.image : null), - url: 'https://gath.io/' + req.params.eventID + image: (eventHasCoverImage ? `https://${domain}/events/` + event.image : null), + url: `https://${domain}/` + req.params.eventID }; - res.set("X-Robots-Tag", "noindex"); - res.render('event', { - title: event.name, - escapedName: escapedName, - eventData: event, - eventAttendees: eventAttendees, - spotsRemaining: spotsRemaining, - noMoreSpots: noMoreSpots, - eventStartISO: eventStartISO, - eventEndISO: eventEndISO, - parsedLocation: parsedLocation, - parsedStart: parsedStart, - parsedEnd: parsedEnd, - displayDate: displayDate, - fromNow: fromNow, - timezone: event.timezone, - parsedDescription: parsedDescription, - editingEnabled: editingEnabled, - eventHasCoverImage: eventHasCoverImage, - eventHasHost: eventHasHost, - firstLoad: firstLoad, - eventHasConcluded: eventHasConcluded, - eventHasBegun: eventHasBegun, - metadata: metadata, - }) + ///////////////////// + if (req.headers.accept && (req.headers.accept.includes('application/activity+json') || req.headers.accept.includes('application/json') || req.headers.accept.includes('application/json+ld'))) { + res.json(JSON.parse(event.activityPubActor)); + + //let tempActor = JSON.parse(result.actor); + //// Added this followers URI for Pleroma compatibility, see https://github.com/dariusk/rss-to-activitypub/issues/11#issuecomment-471390881 + //// New Actors should have this followers URI but in case of migration from an old version this will add it in on the fly + //if (tempActor.followers === undefined) { + // tempActor.followers = `https://${domain}/u/${username}/followers`; + //} + //res.json(tempActor); + } + ///////////////// + else { + res.set("X-Robots-Tag", "noindex"); + res.render('event', { + domain: domain, + email: contactEmail, + title: event.name, + escapedName: escapedName, + eventData: event, + eventAttendees: eventAttendees, + spotsRemaining: spotsRemaining, + noMoreSpots: noMoreSpots, + eventStartISO: eventStartISO, + eventEndISO: eventEndISO, + parsedLocation: parsedLocation, + parsedStart: parsedStart, + parsedEnd: parsedEnd, + displayDate: displayDate, + fromNow: fromNow, + timezone: event.timezone, + parsedDescription: parsedDescription, + editingEnabled: editingEnabled, + eventHasCoverImage: eventHasCoverImage, + eventHasHost: eventHasHost, + firstLoad: firstLoad, + eventHasConcluded: eventHasConcluded, + eventHasBegun: eventHasBegun, + metadata: metadata, + }) + } } else { res.status(404); @@ -375,11 +478,12 @@ router.get('/group/:eventGroupID', (req, res) => { let metadata = { title: eventGroup.name, description: marked(eventGroup.description, { renderer: render_plain()}).split(" ").splice(0,40).join(" ").trim(), - image: (eventGroupHasCoverImage ? 'https://gath.io/events/' + eventGroup.image : null), - url: 'https://gath.io/' + req.params.eventID + image: (eventGroupHasCoverImage ? `https://${domain}/events/` + eventGroup.image : null), + url: `https://${domain}/` + req.params.eventID }; res.set("X-Robots-Tag", "noindex"); res.render('eventgroup', { + domain: domain, title: eventGroup.name, eventGroupData: eventGroup, escapedName: escapedName, @@ -445,6 +549,10 @@ router.post('/newevent', async (req, res) => { isPartOfEventGroup = true; } } + + // generate RSA keypair for ActivityPub + let pair = generateRSAKeypair(); + const event = new Event({ id: eventID, type: req.body.eventType, @@ -466,7 +574,10 @@ router.post('/newevent', async (req, res) => { showUsersList: req.body.guestlistCheckbox ? true : false, usersCanComment: req.body.interactionCheckbox ? true : false, maxAttendees: req.body.maxAttendees, - firstLoad: true + firstLoad: true, + activityPubActor: createActivityPubActor(eventID, domain, pair.public), + publicKey: pair.public, + privateKey: pair.private }); event.save() .then((event) => { @@ -477,7 +588,7 @@ router.post('/newevent', async (req, res) => { to: req.body.creatorEmail, from: { name: 'Gathio', - email: 'notifications@gath.io', + email: contactEmail, }, templateId: 'd-00330b8278ab463e9f88c16566487d97', dynamic_template_data: { @@ -547,7 +658,7 @@ router.post('/importevent', (req, res) => { to: creatorEmail, from: { name: 'Gathio', - email: 'notifications@gath.io', + email: contactEmail, }, templateId: 'd-00330b8278ab463e9f88c16566487d97', dynamic_template_data: { @@ -609,7 +720,7 @@ router.post('/neweventgroup', (req, res) => { to: req.body.creatorEmail, from: { name: 'Gathio', - email: 'notifications@gath.io', + email: contactEmail, }, templateId: 'd-4c5ddcb34ac44ec5b2313c6da4e405f3', dynamic_template_data: { @@ -701,7 +812,7 @@ router.post('/editevent/:eventID/:editToken', (req, res) => { to: attendeeEmails, from: { name: 'Gathio', - email: 'notifications@gath.io', + email: contactEmail, }, templateId: 'd-e21f3ca49d82476b94ddd8892c72a162', dynamic_template_data: { @@ -781,7 +892,7 @@ router.post('/editeventgroup/:eventGroupID/:editToken', (req, res) => { // to: attendeeEmails, // from: { // name: 'Gathio', - // email: 'notifications@gath.io', + // email: contactEmail, // }, // templateId: 'd-e21f3ca49d82476b94ddd8892c72a162', // dynamic_template_data: { @@ -837,7 +948,7 @@ router.post('/deleteevent/:eventID/:editToken', (req, res) => { to: attendeeEmails, from: { name: 'Gathio', - email: 'notifications@gath.io', + email: contactEmail, }, templateId: 'd-e21f3ca49d82476b94ddd8892c72a162', dynamic_template_data: { @@ -964,7 +1075,7 @@ router.post('/attendevent/:eventID', (req, res) => { to: req.body.attendeeEmail, from: { name: 'Gathio', - email: 'notifications@gath.io', + email: contactEmail, }, templateId: 'd-977612474bba49c48b58e269f04f927c', dynamic_template_data: { @@ -999,7 +1110,7 @@ router.post('/unattendevent/:eventID', (req, res) => { to: req.body.attendeeEmail, from: { name: 'Gathio', - email: 'notifications@gath.io', + email: contactEmail, }, templateId: 'd-56c97755d6394c23be212fef934b0f1f', dynamic_template_data: { @@ -1034,7 +1145,7 @@ router.post('/removeattendee/:eventID/:attendeeID', (req, res) => { to: req.body.attendeeEmail, from: { name: 'Gathio', - email: 'notifications@gath.io', + email: contactEmail, }, templateId: 'd-f8ee9e1e2c8a48e3a329d1630d0d371f', dynamic_template_data: { @@ -1080,7 +1191,7 @@ router.post('/post/comment/:eventID', (req, res) => { to: attendeeEmails, from: { name: 'Gathio', - email: 'notifications@gath.io', + email: contactEmail, }, templateId: 'd-756d078561e047aba307155f02b6686d', dynamic_template_data: { @@ -1131,7 +1242,7 @@ router.post('/post/reply/:eventID/:commentID', (req, res) => { to: attendeeEmails, from: { name: 'Gathio', - email: 'notifications@gath.io', + email: contactEmail, }, templateId: 'd-756d078561e047aba307155f02b6686d', dynamic_template_data: { diff --git a/views/event.handlebars b/views/event.handlebars index 4d0cf28..e2529b8 100755 --- a/views/event.handlebars +++ b/views/event.handlebars @@ -76,8 +76,8 @@ - gath.io/{{eventData.id}} - diff --git a/views/eventgroup.handlebars b/views/eventgroup.handlebars index 00bae2c..dffb847 100755 --- a/views/eventgroup.handlebars +++ b/views/eventgroup.handlebars @@ -49,10 +49,10 @@ - - gath.io/group/{{eventGroupData.id}} + + {{domain}}/group/{{eventGroupData.id}} - diff --git a/views/layouts/main.handlebars b/views/layouts/main.handlebars index 4f0cfbc..3cde5d5 100755 --- a/views/layouts/main.handlebars +++ b/views/layouts/main.handlebars @@ -18,13 +18,13 @@ - - + + - + {{#if title}}{{title}} · {{/if}}gathio @@ -73,7 +73,7 @@
- GitHub · Made with by Raphael · Need help? Email us.
+ GitHub · Made with by Raphael · Need help? Email us.
If you like gathio, you might like sweet, my utopian social network.
-- cgit v1.2.3 From 1157143d851ad53a095d7047e0a8d53c085bddd4 Mon Sep 17 00:00:00 2001 From: Darius Kazemi Date: Wed, 11 Dec 2019 18:37:03 -0800 Subject: config port --- config/domain-example.js | 5 ++++- start.js | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/config/domain-example.js b/config/domain-example.js index 3cb4c85..2f22fe7 100644 --- a/config/domain-example.js +++ b/config/domain-example.js @@ -1,3 +1,6 @@ module.exports = { - 'domain' : 'localhost:3000' // Your domain goes here + // Your domain goes here. If there is a port it should be 'domain:port', but otherwise just 'domain' + 'domain' : 'localhost:3000' , + 'port': '3000', + 'email': 'contact@example.com' }; diff --git a/start.js b/start.js index b47d73c..1a001d3 100755 --- a/start.js +++ b/start.js @@ -5,6 +5,7 @@ const path = require('path'); const mongoose = require('mongoose'); const databaseCredentials = require('./config/database.js'); +const port = require('./config/domain.js').port; mongoose.connect(databaseCredentials.url, { useNewUrlParser: true }); mongoose.set('useCreateIndex', true); @@ -26,6 +27,6 @@ const app = require('./app'); global.appRoot = path.resolve(__dirname); -const server = app.listen(3000, () => { +const server = app.listen(port, () => { console.log(`Welcome to gathio! The app is now running on http://localhost:${server.address().port}`); }); -- cgit v1.2.3 From 51b42d13a370a9a79a618742c62de42c6cb666d8 Mon Sep 17 00:00:00 2001 From: Darius Kazemi Date: Wed, 11 Dec 2019 19:03:42 -0800 Subject: Fixing federation bug --- package-lock.json | 8 ++------ routes.js | 10 +++++----- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 58c466e..59aac7d 100755 --- a/package-lock.json +++ b/package-lock.json @@ -1140,7 +1140,6 @@ "version": "2.3.5", "bundled": true, "dev": true, - "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -1159,7 +1158,6 @@ "version": "0.5.1", "bundled": true, "dev": true, - "optional": true, "requires": { "minimist": "0.0.8" } @@ -1339,8 +1337,7 @@ "safe-buffer": { "version": "5.1.2", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "safer-buffer": { "version": "2.1.2", @@ -1446,8 +1443,7 @@ "yallist": { "version": "3.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true } } }, diff --git a/routes.js b/routes.js index c04077b..e442e1a 100755 --- a/routes.js +++ b/routes.js @@ -148,15 +148,15 @@ function createActivityPubActor(eventID, domain, pubkey) { 'https://w3id.org/security/v1' ], - 'id': `https://${domain}/u/${eventID}`, + 'id': `https://${domain}/${eventID}`, 'type': 'Person', - 'preferredUsereventID': `${eventID}`, + 'preferredUsername': `${eventID}`, 'inbox': `https://${domain}/api/inbox`, - 'followers': `https://${domain}/u/${eventID}/followers`, + 'followers': `https://${domain}/${eventID}/followers`, 'publicKey': { - 'id': `https://${domain}/u/${eventID}#main-key`, - 'owner': `https://${domain}/u/${eventID}`, + 'id': `https://${domain}/${eventID}#main-key`, + 'owner': `https://${domain}/${eventID}`, 'publicKeyPem': pubkey } }); -- cgit v1.2.3 From 821017e5337612a37179b586d5506666ab70ab77 Mon Sep 17 00:00:00 2001 From: Darius Kazemi Date: Wed, 11 Dec 2019 22:13:39 -0800 Subject: follow Undo now works --- app.js | 2 +- models/Event.js | 14 ++++- package-lock.json | 51 ++++++---------- package.json | 1 + routes.js | 172 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 5 files changed, 194 insertions(+), 46 deletions(-) diff --git a/app.js b/app.js index a8d2889..ab4eb56 100755 --- a/app.js +++ b/app.js @@ -45,7 +45,7 @@ app.set('view engine', 'handlebars'); app.use(express.static('public')); // Router // - +app.use(bodyParser.json({ type: "application/activity+json" })); // support json encoded bodies app.use(bodyParser.urlencoded({ extended: true })); app.use('/', routes); diff --git a/models/Event.js b/models/Event.js index 1de6088..d68621a 100755 --- a/models/Event.js +++ b/models/Event.js @@ -15,6 +15,17 @@ const Attendees = new mongoose.Schema({ } }) +const Followers = new mongoose.Schema({ + followId: { + type: String, + trim: true + }, + account: { + type: String, + trim: true + } +}, {_id: false}) + const ReplySchema = new mongoose.Schema({ id: { type: String, @@ -171,7 +182,8 @@ const EventSchema = new mongoose.Schema({ privateKey: { type: String, trim: true - } + }, + followers: [Followers], }); module.exports = mongoose.model('Event', EventSchema); diff --git a/package-lock.json b/package-lock.json index 59aac7d..24f5ad7 100755 --- a/package-lock.json +++ b/package-lock.json @@ -586,9 +586,9 @@ "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" }, "aws4": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.8.0.tgz", - "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==" + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.9.0.tgz", + "integrity": "sha512-Uvq6hVe90D0B2WEnUqtdgY1bATGz3mw33nH9Y+dmA+w5DHvUmBgkr5rM/KCHpCsiFNRUfokW/szpPPgMK2hm4A==" }, "balanced-match": { "version": "1.0.0", @@ -923,8 +923,7 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "aproba": { "version": "1.2.0", @@ -945,14 +944,12 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -967,20 +964,17 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "core-util-is": { "version": "1.0.2", @@ -1097,8 +1091,7 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "ini": { "version": "1.3.5", @@ -1110,7 +1103,6 @@ "version": "1.0.0", "bundled": true, "dev": true, - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -1125,7 +1117,6 @@ "version": "3.0.4", "bundled": true, "dev": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -1133,8 +1124,7 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "minipass": { "version": "2.3.5", @@ -1238,8 +1228,7 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "object-assign": { "version": "4.1.1", @@ -1251,7 +1240,6 @@ "version": "1.4.0", "bundled": true, "dev": true, - "optional": true, "requires": { "wrappy": "1" } @@ -1373,7 +1361,6 @@ "version": "1.0.2", "bundled": true, "dev": true, - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -1393,7 +1380,6 @@ "version": "3.0.1", "bundled": true, "dev": true, - "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -1437,8 +1423,7 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "yallist": { "version": "3.0.3", @@ -4205,9 +4190,9 @@ "dev": true }, "psl": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.2.0.tgz", - "integrity": "sha512-GEn74ZffufCmkDDLNcl3uuyF/aSD6exEyh1v/ZSdAomB82t6G9hzJVRx0jBmLDW+VfZqks3aScmMw9DszwUalA==" + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.6.0.tgz", + "integrity": "sha512-SYKKmVel98NCOYXpkwUqZqh0ahZeeKfmisiLIcEZdsb+WbLv02g/dI5BUmZnIyOe7RzZtLax81nnb2HbvC2tzA==" }, "pstree.remy": { "version": "1.1.2", @@ -5299,9 +5284,9 @@ "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" }, "uuid": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", - "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.3.tgz", + "integrity": "sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ==" }, "v8-compile-cache": { "version": "2.0.3", diff --git a/package.json b/package.json index 1368503..5c3c0ed 100755 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "multer": "^1.4.1", "node-schedule": "^1.3.1", "randomstring": "^1.1.5", + "request": "^2.88.0", "shortid": "^2.2.14" }, "devDependencies": { diff --git a/routes.js b/routes.js index e442e1a..0371d96 100755 --- a/routes.js +++ b/routes.js @@ -21,6 +21,8 @@ var moment = require('moment-timezone'); const marked = require('marked'); const generateRSAKeypair = require('generate-rsa-keypair'); +const crypto = require('crypto'); +const request = require('request'); const domain = require('./config/domain.js').domain; const contactEmail = require('./config/domain.js').email; @@ -151,7 +153,7 @@ function createActivityPubActor(eventID, domain, pubkey) { 'id': `https://${domain}/${eventID}`, 'type': 'Person', 'preferredUsername': `${eventID}`, - 'inbox': `https://${domain}/api/inbox`, + 'inbox': `https://${domain}/activitypub/inbox`, 'followers': `https://${domain}/${eventID}/followers`, 'publicKey': { @@ -162,6 +164,63 @@ function createActivityPubActor(eventID, domain, pubkey) { }); } +function sendAcceptMessage(thebody, eventID, 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}/${eventID}`, + 'object': thebody, + }; + signAndSend(message, eventID, domain, req, res, targetDomain); +} + +function signAndSend(message, eventID, domain, req, res, targetDomain) { + // get the URI of the actor object and append 'inbox' to it + let inbox = message.object.actor+'/inbox'; + let inboxFragment = inbox.replace('https://'+targetDomain,''); + // get the private key + Event.findOne({ + id: eventID + }) + .then((event) => { + if (event) { + const privateKey = event.privateKey; + 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(privateKey); + const signature_b64 = signature.toString('base64'); + const header = `keyId="https://${domain}/${eventID}",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){ + if (error) { + console.log('Error:', error, response.body); + } + else { + console.log('Response:', response.body); + } + }); + return res.status(200); + } + else { + return res.status(404).send(`No record found for ${eventID}.`); + } + }); +} + // FRONTEND ROUTES router.get('/', (req, res) => { @@ -345,19 +404,9 @@ router.get('/:eventID', (req, res) => { image: (eventHasCoverImage ? `https://${domain}/events/` + event.image : null), url: `https://${domain}/` + req.params.eventID }; - ///////////////////// if (req.headers.accept && (req.headers.accept.includes('application/activity+json') || req.headers.accept.includes('application/json') || req.headers.accept.includes('application/json+ld'))) { res.json(JSON.parse(event.activityPubActor)); - - //let tempActor = JSON.parse(result.actor); - //// Added this followers URI for Pleroma compatibility, see https://github.com/dariusk/rss-to-activitypub/issues/11#issuecomment-471390881 - //// New Actors should have this followers URI but in case of migration from an old version this will add it in on the fly - //if (tempActor.followers === undefined) { - // tempActor.followers = `https://${domain}/u/${username}/followers`; - //} - //res.json(tempActor); } - ///////////////// else { res.set("X-Robots-Tag", "noindex"); res.render('event', { @@ -403,6 +452,37 @@ router.get('/:eventID', (req, res) => { }); }) +router.get('/:eventID/followers', (req, res) => { + const eventID = req.params.eventID; + Event.findOne({ + id: eventID + }) + .then((event) => { + if (event) { + console.log(event.followers); + const followers = event.followers.map(el => el.account); + console.log(followers) + let followersCollection = { + "type": "OrderedCollection", + "totalItems": followers.length, + "id": `https://${domain}/${eventID}/followers`, + "first": { + "type": "OrderedCollectionPage", + "totalItems": followers.length, + "partOf": `https://${domain}/${eventID}/followers`, + "orderedItems": followers, + "id": `https://${domain}/${eventID}/followers?page=1` + }, + "@context":["https://www.w3.org/ns/activitystreams"] + }; + return res.json(followersCollection); + } + else { + return res.status(400).send('Bad request.'); + } + }) +}) + router.get('/group/:eventGroupID', (req, res) => { EventGroup.findOne({ id: req.params.eventGroupID @@ -1295,6 +1375,76 @@ router.post('/deletecomment/:eventID/:commentID/:editToken', (req, res) => { .catch((err) => { res.send('Sorry! Something went wrong: ' + err); addToLog("deleteComment", "error", "Attempt to delete comment " + req.params.commentID + "from event " + req.params.eventID + " failed with error: " + err);}); }); +router.post('/activitypub/inbox', (req, res) => { + console.log('got a inbox message') + const myURL = new URL(req.body.actor); + let targetDomain = myURL.hostname; + // if a Follow activity hits the inbox + if (typeof req.body.object === 'string' && req.body.type === 'Follow') { + console.log('follow!') + let eventID = req.body.object.replace(`https://${domain}/`,''); + sendAcceptMessage(req.body, eventID, domain, req, res, targetDomain); + // Add the user to the DB of accounts that follow the account + console.log(req.body) + + const newFollower = { + account: req.body.actor, + followId: req.body.id + }; + + Event.findOne({ + id: eventID, + }, function(err,event) { + console.log(event.followers) + // if this account is NOT already in our followers list, add it + if (!event.followers.map(el => el.account).includes(req.body.actor)) { + event.followers.push(newFollower); + console.log(event.followers) + event.save() + .then(() => { + addToLog("addEventFollower", "success", "Follower added to event " + eventID); + console.log('successful follower add') + }) + .catch((err) => { res.send('Database error, please try again :('); addToLog("addEventFollower", "error", "Attempt to add follower to event " + eventID + " failed with error: " + err); + console.log('error', err) + }); + } + }); + } + // if an Undo activity with a Follow object hits the inbox + if (req.body && req.body.type === 'Undo' && req.body.object && req.body.object.type === 'Follow') { + console.log('undo follow!') + console.log(req.body) + // get the record of all followers for this account + let eventID = req.body.object.object.replace(`https://${domain}/`,''); + Event.findOne({ + id: eventID, + }, function(err,event) { + // check to see if the Follow object's id matches the id we have on record + console.log(event.followers) + // is this even someone who follows us + const indexOfFollower = event.followers.findIndex(el => {console.log(el.account, req.body.object.actor); return el.account === req.body.object.actor;}); + console.log(indexOfFollower) + if (indexOfFollower !== -1) { + // does the id we have match the id we are being given + if (event.followers[indexOfFollower].followId === req.body.object.id) { + // we have a match and can trust the Undo! remove this person from the followers list + event.followers.splice(indexOfFollower, 1); + console.log('new', indexOfFollower, event.followers); + event.save() + .then(() => { + addToLog("removeEventFollower", "success", "Follower removed from event " + eventID); + console.log('successful follower removal') + }) + .catch((err) => { res.send('Database error, please try again :('); addToLog("removeEventFollower", "error", "Attempt to remove follower from event " + eventID + " failed with error: " + err); + console.log('error', err) + }); + } + } + }); + } +}); + router.use(function(req, res, next){ res.status(404); res.render('404', { url: req.url }); -- cgit v1.2.3 From b8d8d5fcd29f3c5492491e3482319e0efc838030 Mon Sep 17 00:00:00 2001 From: Darius Kazemi Date: Wed, 11 Dec 2019 23:24:49 -0800 Subject: sending pollls --- routes.js | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 5 deletions(-) diff --git a/routes.js b/routes.js index 0371d96..836a55c 100755 --- a/routes.js +++ b/routes.js @@ -173,12 +173,41 @@ function sendAcceptMessage(thebody, eventID, domain, req, res, targetDomain) { 'actor': `https://${domain}/${eventID}`, 'object': thebody, }; - signAndSend(message, eventID, domain, req, res, targetDomain); -} - -function signAndSend(message, eventID, domain, req, res, targetDomain) { // get the URI of the actor object and append 'inbox' to it let inbox = message.object.actor+'/inbox'; + signAndSend(message, eventID, domain, req, res, targetDomain, inbox); +} + +function rawMessage(json, eventID, domain, follower) { + const guidCreate = crypto.randomBytes(16).toString('hex'); + const guidNote = crypto.randomBytes(16).toString('hex'); + // let db = req.app.get('db'); + let d = new Date(); + + let rawMessagePayload = json; + + rawMessagePayload.published = d.toISOString(); + rawMessagePayload.attributedTo = `https://${domain}/${eventID}`; + rawMessagePayload.to = [follower]; + rawMessagePayload.id = `https://${domain}/m/${guidNote}`; + rawMessagePayload.content = unescape(rawMessagePayload.content) + + let createMessage = { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': `https://${domain}/m/${guidCreate}`, + 'type': 'Create', + 'actor': `https://${domain}/${eventID}`, + 'to': [follower], + 'object': rawMessagePayload + }; + + //db.prepare('insert or replace into messages(guid, message) values(?, ?)').run( guidCreate, JSON.stringify(createMessage)); + //db.prepare('insert or replace into messages(guid, message) values(?, ?)').run( guidNote, JSON.stringify(rawMessagePayload)); + + return createMessage; +} + +function signAndSend(message, eventID, domain, req, res, targetDomain, inbox) { let inboxFragment = inbox.replace('https://'+targetDomain,''); // get the private key Event.findOne({ @@ -1377,12 +1406,14 @@ router.post('/deletecomment/:eventID/:commentID/:editToken', (req, res) => { router.post('/activitypub/inbox', (req, res) => { console.log('got a inbox message') + console.log(req.body); const myURL = new URL(req.body.actor); let targetDomain = myURL.hostname; // if a Follow activity hits the inbox if (typeof req.body.object === 'string' && req.body.type === 'Follow') { console.log('follow!') let eventID = req.body.object.replace(`https://${domain}/`,''); + // Accept the follow request sendAcceptMessage(req.body, eventID, domain, req, res, targetDomain); // Add the user to the DB of accounts that follow the account console.log(req.body) @@ -1403,7 +1434,28 @@ router.post('/activitypub/inbox', (req, res) => { event.save() .then(() => { addToLog("addEventFollower", "success", "Follower added to event " + eventID); - console.log('successful follower add') + console.log('successful follower add'); + // send a Question to the new follower + let inbox = req.body.actor+'/inbox'; + let myURL = new URL(req.body.actor); + let targetDomain = myURL.hostname; + const jsonObject = { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "http://polls.example.org/question/1", + "name": `RSVP to ${event.name}`, + "type": "Question", + "content": `Will you attend ${event.name}?`, + "oneOf": [ + {"type":"Note","name": "Yes"}, + {"type":"Note","name": "No"}, + {"type":"Note","name": "Maybe"} + ], + "endTime":event.start.toISOString() + } + let message = rawMessage(jsonObject, eventID, domain, req.body.actor); + console.log('!!!!!!!!! sending') + console.log(message) + signAndSend(message, eventID, domain, req, res, targetDomain, inbox); }) .catch((err) => { res.send('Database error, please try again :('); addToLog("addEventFollower", "error", "Attempt to add follower to event " + eventID + " failed with error: " + err); console.log('error', err) -- cgit v1.2.3 From f1e62ef6fa94c3cfb6afadd0dc865f5c502a6a60 Mon Sep 17 00:00:00 2001 From: Darius Kazemi Date: Sun, 15 Dec 2019 13:07:50 -0800 Subject: Big refactor and new features --- models/Event.js | 52 +++- package-lock.json | 164 +++++++++++++ package.json | 1 + public/css/style.css | 3 +- routes.js | 635 +++++++++++++++++++++++++++++++++++++++++-------- views/event.handlebars | 23 +- views/home.handlebars | 27 ++- 7 files changed, 798 insertions(+), 107 deletions(-) diff --git a/models/Event.js b/models/Event.js index d68621a..9ab455f 100755 --- a/models/Event.js +++ b/models/Event.js @@ -12,18 +12,33 @@ const Attendees = new mongoose.Schema({ email: { type: String, trim: true - } + }, + id: { + type: String, + trim: true + } }) const Followers = new mongoose.Schema({ + // this is the id of the original follow *request*, which we use to validate Undo events followId: { type: String, trim: true }, - account: { + // this is the actual remote user profile id + actorId: { type: String, trim: true - } + }, + // this is the stringified JSON of the entire user profile + actorJson: { + type: String, + trim: true + }, + name: { + type: String, + trim: true + }, }, {_id: false}) const ReplySchema = new mongoose.Schema({ @@ -50,6 +65,20 @@ const ReplySchema = new mongoose.Schema({ } }) +const ActivityPubMessages = new mongoose.Schema({ + id: { + type: String, + required: true, + unique: true, + sparse: true + }, + content: { + type: String, + trim: true, + required: true + } +}) + const CommentSchema = new mongoose.Schema({ id: { type: String, @@ -72,6 +101,22 @@ const CommentSchema = new mongoose.Schema({ trim: true, required: true }, + activityJson: { + type: String, + trim: true + }, + actorJson: { + type: String, + trim: true + }, + activityId: { + type: String, + trim: true + }, + actorId: { + type: String, + trim: true + }, replies: [ReplySchema] }) @@ -184,6 +229,7 @@ const EventSchema = new mongoose.Schema({ trim: true }, followers: [Followers], + activityPubMessages: [ActivityPubMessages] }); module.exports = mongoose.model('Event', EventSchema); diff --git a/package-lock.json b/package-lock.json index 24f5ad7..e9b5b66 100755 --- a/package-lock.json +++ b/package-lock.json @@ -1803,11 +1803,54 @@ "esutils": "^2.0.2" } }, + "dom-serializer": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz", + "integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==", + "requires": { + "domelementtype": "^2.0.1", + "entities": "^2.0.0" + }, + "dependencies": { + "domelementtype": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.0.1.tgz", + "integrity": "sha512-5HOHUDsYZWV8FGWN0Njbr/Rn7f/eWSQi1v7+HsUVwXgn8nWWlL64zKDkS0n8ZmQ3mlWOMuXOnR+7Nx/5tMO5AQ==" + }, + "entities": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.0.0.tgz", + "integrity": "sha512-D9f7V0JSRwIxlRI2mjMqufDrRDnx8p+eEOz7aUM9SuvF8gsBzra0/6tbjl1m8eQHrZlYj6PxqE00hZ1SAIKPLw==" + } + } + }, "dom-walk": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.1.tgz", "integrity": "sha1-ZyIm3HTI95mtNTB9+TaroRrNYBg=" }, + "domelementtype": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz", + "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==" + }, + "domhandler": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz", + "integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==", + "requires": { + "domelementtype": "1" + } + }, + "domutils": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz", + "integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==", + "requires": { + "dom-serializer": "0", + "domelementtype": "1" + } + }, "dot-prop": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-4.2.0.tgz", @@ -1866,6 +1909,11 @@ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" }, + "entities": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz", + "integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==" + }, "es-abstract": { "version": "1.13.0", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.13.0.tgz", @@ -2765,6 +2813,31 @@ } } }, + "htmlparser2": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz", + "integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==", + "requires": { + "domelementtype": "^1.3.1", + "domhandler": "^2.3.0", + "domutils": "^1.5.1", + "entities": "^1.1.1", + "inherits": "^2.0.1", + "readable-stream": "^3.1.1" + }, + "dependencies": { + "readable-stream": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.4.0.tgz", + "integrity": "sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } + } + }, "http-errors": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz", @@ -3386,6 +3459,16 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" }, + "lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=" + }, + "lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha1-ZHYsSGGAglGKw99Mz11YhtriA0c=" + }, "lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -3416,6 +3499,11 @@ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" }, + "lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==" + }, "lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", @@ -3850,6 +3938,11 @@ "path-key": "^2.0.0" } }, + "number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=" + }, "oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", @@ -4138,6 +4231,51 @@ "integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=", "dev": true }, + "postcss": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.24.tgz", + "integrity": "sha512-Xl0XvdNWg+CblAXzNvbSOUvgJXwSjmbAKORqyw9V2AlHrm1js2gFw9y3jibBAhpKZi8b5JzJCVh/FyzPsTtgTA==", + "requires": { + "chalk": "^2.4.2", + "source-map": "^0.6.1", + "supports-color": "^6.1.0" + }, + "dependencies": { + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "dependencies": { + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + }, + "supports-color": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz", + "integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==", + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, "prelude-ls": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", @@ -4502,6 +4640,23 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "sanitize-html": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-1.20.1.tgz", + "integrity": "sha512-txnH8TQjaQvg2Q0HY06G6CDJLVYCpbnxrdO0WN8gjCKaU5J0KbyGYhZxx5QJg3WLZ1lB7XU9kDkfrCXUozqptA==", + "requires": { + "chalk": "^2.4.1", + "htmlparser2": "^3.10.0", + "lodash.clonedeep": "^4.5.0", + "lodash.escaperegexp": "^4.1.2", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.mergewith": "^4.6.1", + "postcss": "^7.0.5", + "srcset": "^1.0.0", + "xtend": "^4.0.1" + } + }, "saslprep": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/saslprep/-/saslprep-1.0.3.tgz", @@ -4807,6 +4962,15 @@ "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", "dev": true }, + "srcset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/srcset/-/srcset-1.0.0.tgz", + "integrity": "sha1-pWad4StC87HV6D7QPHEEb8SPQe8=", + "requires": { + "array-uniq": "^1.0.2", + "number-is-nan": "^1.0.0" + } + }, "sshpk": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", diff --git a/package.json b/package.json index 5c3c0ed..b957322 100755 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "node-schedule": "^1.3.1", "randomstring": "^1.1.5", "request": "^2.88.0", + "sanitize-html": "^1.20.1", "shortid": "^2.2.14" }, "devDependencies": { diff --git a/public/css/style.css b/public/css/style.css index 4085875..681273b 100755 --- a/public/css/style.css +++ b/public/css/style.css @@ -268,6 +268,7 @@ body, html { text-overflow: ""; overflow: hidden; max-width: 62px; + color: #fff; } .remove-attendee { @@ -337,4 +338,4 @@ body, html { .code { font-family: 'Courier New', Courier, monospace; overflow-wrap: anywhere; -} \ No newline at end of file +} diff --git a/routes.js b/routes.js index 836a55c..6d09ab7 100755 --- a/routes.js +++ b/routes.js @@ -26,6 +26,7 @@ const request = require('request'); const domain = require('./config/domain.js').domain; const contactEmail = require('./config/domain.js').email; +var sanitizeHtml = require('sanitize-html'); // Extra marked renderer (used to render plaintext event description for page metadata) // Adapted from https://dustinpfister.github.io/2017/11/19/nodejs-marked/ @@ -143,8 +144,8 @@ function createWebfinger(eventID, domain) { }; } -function createActivityPubActor(eventID, domain, pubkey) { - return JSON.stringify({ +function createActivityPubActor(eventID, domain, pubkey, description, name, location, imageFilename) { + let actor = { '@context': [ 'https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1' @@ -155,17 +156,32 @@ function createActivityPubActor(eventID, domain, pubkey) { 'preferredUsername': `${eventID}`, 'inbox': `https://${domain}/activitypub/inbox`, 'followers': `https://${domain}/${eventID}/followers`, + 'summary': description, + 'name': name, 'publicKey': { 'id': `https://${domain}/${eventID}#main-key`, 'owner': `https://${domain}/${eventID}`, 'publicKeyPem': pubkey } - }); + }; + if (location) { + actor.summary += ` Location: ${location}.` + } + if (imageFilename) { + actor.icon = { + 'type': 'Image', + 'mediaType': 'image/jpg', + 'url': `https://${domain}/events/${imageFilename}`, + }; + } + return JSON.stringify(actor); } -function sendAcceptMessage(thebody, eventID, domain, req, res, targetDomain) { +function sendAcceptMessage(thebody, eventID, targetDomain, callback) { + callback = callback || function() {}; const guid = crypto.randomBytes(16).toString('hex'); + const actorId = thebody.actor; let message = { '@context': 'https://www.w3.org/ns/activitystreams', 'id': `https://${domain}/${guid}`, @@ -173,41 +189,119 @@ function sendAcceptMessage(thebody, eventID, domain, req, res, targetDomain) { 'actor': `https://${domain}/${eventID}`, 'object': thebody, }; - // get the URI of the actor object and append 'inbox' to it - let inbox = message.object.actor+'/inbox'; - signAndSend(message, eventID, domain, req, res, targetDomain, inbox); + // get the inbox + Event.findOne({ + id: eventID, + }, function(err, event) { + if (event) { + const follower = event.followers.find(el => el.actorId === actorId); + if (follower) { + const actorJson = JSON.parse(follower.actorJson); + const inbox = actorJson.inbox; + signAndSend(message, eventID, targetDomain, inbox, callback); + } + } + else { + callback(`Could not find event ${eventID}`, null, 404); + } + }); } -function rawMessage(json, eventID, domain, follower) { +// this sends a message "to:" an individual fediverse user +function sendDirectMessage(apObject, actorId, eventID, callback) { + callback = callback || function() {}; const guidCreate = crypto.randomBytes(16).toString('hex'); - const guidNote = crypto.randomBytes(16).toString('hex'); - // let db = req.app.get('db'); + const guidObject = crypto.randomBytes(16).toString('hex'); let d = new Date(); - let rawMessagePayload = json; - - rawMessagePayload.published = d.toISOString(); - rawMessagePayload.attributedTo = `https://${domain}/${eventID}`; - rawMessagePayload.to = [follower]; - rawMessagePayload.id = `https://${domain}/m/${guidNote}`; - rawMessagePayload.content = unescape(rawMessagePayload.content) + apObject.published = d.toISOString(); + apObject.attributedTo = `https://${domain}/${eventID}`; + apObject.to = actorId; + apObject.id = `https://${domain}/m/${guidObject}`; + apObject.content = unescape(apObject.content) let createMessage = { '@context': 'https://www.w3.org/ns/activitystreams', 'id': `https://${domain}/m/${guidCreate}`, 'type': 'Create', 'actor': `https://${domain}/${eventID}`, - 'to': [follower], - 'object': rawMessagePayload + 'to': [actorId], + 'object': apObject }; - //db.prepare('insert or replace into messages(guid, message) values(?, ?)').run( guidCreate, JSON.stringify(createMessage)); - //db.prepare('insert or replace into messages(guid, message) values(?, ?)').run( guidNote, JSON.stringify(rawMessagePayload)); + let myURL = new URL(actorId); + let targetDomain = myURL.hostname; + // get the inbox + Event.findOne({ + id: eventID, + }, function(err, event) { + if (event) { + const follower = event.followers.find(el => el.actorId === actorId); + if (follower) { + const actorJson = JSON.parse(follower.actorJson); + const inbox = actorJson.inbox; + signAndSend(createMessage, eventID, targetDomain, inbox, callback); + } + else { + callback(`No follower found with the id ${actorId}`, null, 404); + } + } + else { + callback(`No event found with the id ${eventID}`, null, 404); + } + }); +} - return createMessage; +// this function sends something to the timeline of every follower in the followers array +function broadcastMessage(apObject, followers, eventID, callback) { + callback = callback || function() {}; + let guidCreate = crypto.randomBytes(16).toString('hex'); + console.log('broadcasting'); + // iterate over followers + for (const follower of followers) { + let actorId = follower.actorId; + let myURL = new URL(actorId); + let targetDomain = myURL.hostname; + // get the inbox + Event.findOne({ + id: eventID, + }, function(err, event) { + console.log('found the event for broadcast') + if (event) { + const follower = event.followers.find(el => el.actorId === actorId); + if (follower) { + const actorJson = JSON.parse(follower.actorJson); + const inbox = actorJson.inbox; + console.log('found the inbox for', actorId) + const createMessage = { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': `https://${domain}/m/${guidCreate}`, + 'type': 'Create', + 'actor': `https://${domain}/${eventID}`, + 'to': [actorId], + 'object': apObject + }; + signAndSend(createMessage, eventID, targetDomain, inbox, function(err, resp, status) { + if (err) { + console.log(`Didn't sent to ${actorId}, status ${status} with error ${err}`); + } + else { + console.log('sent to', actorId); + } + }); + } + else { + callback(`No follower found with the id ${actorId}`, null, 404); + } + } + else { + callback(`No event found with the id ${eventID}`, null, 404); + } + }); + } // end followers } -function signAndSend(message, eventID, domain, req, res, targetDomain, inbox) { +function signAndSend(message, eventID, targetDomain, inbox, callback) { let inboxFragment = inbox.replace('https://'+targetDomain,''); // get the private key Event.findOne({ @@ -237,15 +331,37 @@ function signAndSend(message, eventID, domain, req, res, targetDomain, inbox) { }, function (error, response){ if (error) { console.log('Error:', error, response.body); + callback(error, null, 500); } else { - console.log('Response:', response.body); + console.log('Response:', response.statusCode); + // Add the message to the database + const messageID = message.id; + const newMessage = { + id: message.id, + content: JSON.stringify(message) + }; + Event.findOne({ + id: eventID, + }, function(err,event) { + if (!event) return; + event.activityPubMessages.push(newMessage); + event.save() + .then(() => { + addToLog("addActivityPubMessage", "success", "ActivityPubMessage added to event " + eventID); + console.log('successful ActivityPubMessage add'); + callback(null, message.id, 200); + }) + .catch((err) => { addToLog("addActivityPubMessage", "error", "Attempt to add ActivityPubMessage to event " + eventID + " failed with error: " + err); + console.log('error', err) + callback(err, null, 500); + }); + }) } }); - return res.status(200); } else { - return res.status(404).send(`No record found for ${eventID}.`); + callback(`No record found for ${eventID}.`, null, 404); } }); } @@ -411,7 +527,7 @@ router.get('/:eventID', (req, res) => { console.log("No edit token set"); } else { - if (req.query.e == eventEditToken){ + if (req.query.e === eventEditToken){ editingEnabled = true; } else { @@ -489,7 +605,7 @@ router.get('/:eventID/followers', (req, res) => { .then((event) => { if (event) { console.log(event.followers); - const followers = event.followers.map(el => el.account); + const followers = event.followers.map(el => el.actorId); console.log(followers) let followersCollection = { "type": "OrderedCollection", @@ -556,7 +672,7 @@ router.get('/group/:eventGroupID', (req, res) => { }) let upcomingEventsExist = false; - if (events.some(e => e.eventHasConcluded == false)) { + if (events.some(e => e.eventHasConcluded === false)) { upcomingEventsExist = true; } @@ -576,7 +692,7 @@ router.get('/group/:eventGroupID', (req, res) => { console.log("No edit token set"); } else { - if (req.query.e == eventGroupEditToken){ + if (req.query.e === eventGroupEditToken){ editingEnabled = true; } else { @@ -632,10 +748,13 @@ router.get('/group/:eventGroupID', (req, res) => { router.post('/newevent', async (req, res) => { let eventID = shortid.generate(); + // this is a hack, activitypub does not like "-" in ids so we are essentially going + // to have a 63-character alphabet instead of a 64-character one + eventID = eventID.replace(/-/g,'_'); let editToken = randomstring.generate(); let eventImageFilename = ""; let isPartOfEventGroup = false; - if (req.files && Object.keys(req.files).length != 0) { + if (req.files && Object.keys(req.files).length !== 0) { let eventImageBuffer = req.files.imageUpload.data; Jimp.read(eventImageBuffer, (err, img) => { if (err) addToLog("Jimp", "error", "Attempt to edit image failed with error: " + err); @@ -684,7 +803,7 @@ router.post('/newevent', async (req, res) => { usersCanComment: req.body.interactionCheckbox ? true : false, maxAttendees: req.body.maxAttendees, firstLoad: true, - activityPubActor: createActivityPubActor(eventID, domain, pair.public), + activityPubActor: createActivityPubActor(eventID, domain, pair.public, marked(req.body.eventDescription), req.body.eventName, req.body.eventLocation, eventImageFilename), publicKey: pair.public, privateKey: pair.private }); @@ -722,7 +841,7 @@ router.post('/newevent', async (req, res) => { router.post('/importevent', (req, res) => { let eventID = shortid.generate(); let editToken = randomstring.generate(); - if (req.files && Object.keys(req.files).length != 0) { + if (req.files && Object.keys(req.files).length !== 0) { importediCalObject = ical.parseICS(req.files.icsImportControl.data.toString('utf8')); for (var key in importediCalObject) { importedEventData = importediCalObject[key]; @@ -744,7 +863,7 @@ router.post('/importevent', (req, res) => { location: importedEventData.location, start: importedEventData.start, end: importedEventData.end, - timezone: typeof importedEventData.start.tz != 'undefined' ? importedEventData.start.tz : "Etc/UTC", + timezone: typeof importedEventData.start.tz !== 'undefined' ? importedEventData.start.tz : "Etc/UTC", description: importedEventData.description, image: '', creatorEmail: creatorEmail, @@ -798,7 +917,7 @@ router.post('/neweventgroup', (req, res) => { let eventGroupID = shortid.generate(); let editToken = randomstring.generate(); let eventGroupImageFilename = ""; - if (req.files && Object.keys(req.files).length != 0) { + if (req.files && Object.keys(req.files).length !== 0) { let eventImageBuffer = req.files.imageUpload.data; Jimp.read(eventImageBuffer, (err, img) => { if (err) addToLog("Jimp", "error", "Attempt to edit image failed with error: " + err); @@ -864,7 +983,7 @@ router.post('/editevent/:eventID/:editToken', (req, res) => { // If there is a new image, upload that first let eventID = req.params.eventID; let eventImageFilename = event.image; - if (req.files && Object.keys(req.files).length != 0) { + if (req.files && Object.keys(req.files).length !== 0) { let eventImageBuffer = req.files.imageUpload.data; Jimp.read(eventImageBuffer, (err, img) => { if (err) throw err; @@ -887,7 +1006,7 @@ router.post('/editevent/:eventID/:editToken', (req, res) => { if (eventGroup) { isPartOfEventGroup = true; } - } + } const updatedEvent = { name: req.body.eventName, location: req.body.eventLocation, @@ -904,6 +1023,28 @@ router.post('/editevent/:eventID/:editToken', (req, res) => { maxAttendees: req.body.maxAttendeesCheckbox ? req.body.maxAttendees : null, eventGroup: isPartOfEventGroup ? eventGroup._id : null } + let diffText = '

This event was just updated with new information.

    '; + let displayDate; + // TODO: send an Update Profile message if needed? + if (event.location !== updatedEvent.location) { + diffText += `
  • the location changed to ${updatedEvent.location}
  • `; + } + if (event.start.toISOString() !== updatedEvent.start.toISOString()) { + displayDate = moment.tz(updatedEvent.start, updatedEvent.timezone).format('dddd D MMMM YYYY h:mm a'); + diffText += `
  • the start time changed to ${displayDate}
  • `; + } + if (event.end.toISOString() !== updatedEvent.end.toISOString()) { + displayDate = moment.tz(updatedEvent.end, updatedEvent.timezone).format('dddd D MMMM YYYY h:mm a'); + diffText += `
  • the end time changed to ${displayDate}
  • `; + } + if (event.timezone !== updatedEvent.timezone) { + console.log(typeof event.timezone, JSON.stringify(event.timezone), JSON.stringify(updatedEvent.timezone)) + diffText += `
  • the time zone changed to ${updatedEvent.timezone}
  • `; + } + if (event.description !== updatedEvent.description) { + diffText += `
  • the event description changed
  • `; + } + diffText += `
`; Event.findOneAndUpdate({id: req.params.eventID}, updatedEvent, function(err, raw) { if (err) { addToLog("editEvent", "error", "Attempt to edit event " + req.params.eventID + " failed with error: " + err); @@ -912,10 +1053,39 @@ router.post('/editevent/:eventID/:editToken', (req, res) => { }) .then(() => { addToLog("editEvent", "success", "Event " + req.params.eventID + " edited"); + // send update to ActivityPub subscribers + Event.findOne({id: req.params.eventID}, function(err,event) { + if (!event) return; + let attendees = event.attendees.filter(el => el.id); + if (!err) { + // broadcast an identical message to all followers, will show in home timeline + const guidObject = crypto.randomBytes(16).toString('hex'); + const jsonObject = { + "@context": "https://www.w3.org/ns/activitystreams", + "id": `https://${domain}/m/${guidObject}`, + "name": `RSVP to ${event.name}`, + "type": "Note", + "content": `${diffText} See here: https://${domain}/${req.params.eventID}`, + } + broadcastMessage(jsonObject, event.followers, eventID) + // DM to attendees + for (const attendee of attendees) { + const jsonObject = { + "@context": "https://www.w3.org/ns/activitystreams", + "name": `RSVP to ${event.name}`, + "type": "Note", + "content": `@${attendee.name} ${diffText} See here: https://${domain}/${req.params.eventID}`, + "tag":[{"type":"Mention","href":attendee.id,"name":attendee.name}] + } + // send direct message to user + sendDirectMessage(jsonObject, attendee.id, eventID); + } + } + }) if (sendEmails) { Event.findOne({id: req.params.eventID}).distinct('attendees.email', function(error, ids) { attendeeEmails = ids; - if (!error && attendeeEmails != ""){ + if (!error && attendeeEmails !== ""){ console.log("Sending emails to: " + attendeeEmails); const msg = { to: attendeeEmails, @@ -966,7 +1136,7 @@ router.post('/editeventgroup/:eventGroupID/:editToken', (req, res) => { // If there is a new image, upload that first let eventGroupID = req.params.eventGroupID; let eventGroupImageFilename = eventGroup.image; - if (req.files && Object.keys(req.files).length != 0) { + if (req.files && Object.keys(req.files).length !== 0) { let eventImageBuffer = req.files.eventGroupImageUpload.data; Jimp.read(eventImageBuffer, (err, img) => { if (err) throw err; @@ -1174,6 +1344,7 @@ router.post('/attendevent/:eventID', (req, res) => { Event.findOne({ id: req.params.eventID, }, function(err,event) { + if (!event) return; event.attendees.push(newAttendee); event.save() .then(() => { @@ -1287,6 +1458,7 @@ router.post('/post/comment/:eventID', (req, res) => { Event.findOne({ id: req.params.eventID, }, function(err,event) { + if (!event) return; event.comments.push(newComment); event.save() .then(() => { @@ -1337,6 +1509,7 @@ router.post('/post/reply/:eventID/:commentID', (req, res) => { Event.findOne({ id: req.params.eventID, }, function(err,event) { + if (!event) return; var parentComment = event.comments.id(commentID); parentComment.replies.push(newReply); event.save() @@ -1405,86 +1578,149 @@ router.post('/deletecomment/:eventID/:commentID/:editToken', (req, res) => { }); router.post('/activitypub/inbox', (req, res) => { - console.log('got a inbox message') - console.log(req.body); - const myURL = new URL(req.body.actor); - let targetDomain = myURL.hostname; + console.log('got an inbox message of type', req.body.type, req.body) + + // validate the incoming message + const signature = req.get('Signature'); + let signature_header = signature.split(',').map(pair => { + return pair.split('=').map(value => { + return value.replace(/^"/g, '').replace(/"$/g, '') + }); + }).reduce((acc, el) => { + acc[el[0]] = el[1]; + return acc; + }, {}); + + // get the actor + request({ + url: signature_header.keyId, + headers: { + 'Accept': 'application/activity+json', + 'Content-Type': 'application/activity+json' + }}, function (error, response, actor) { + publicKey = JSON.parse(actor).publicKey.publicKeyPem; + + let comparison_string = signature_header.headers.split(' ').map(header => { + if (header === '(request-target)') { + return '(request-target): post /activitypub/inbox'; + } + else { + return `${header}: ${req.get(header)}` + } + }).join('\n'); + + const verifier = crypto.createVerify('RSA-SHA256') + verifier.update(comparison_string, 'ascii') + const publicKeyBuf = new Buffer(publicKey, 'ascii') + const signatureBuf = new Buffer(signature_header.signature, 'base64') + const result = verifier.verify(publicKeyBuf, signatureBuf) + console.log('VALIDATE RESULT:', result) + if (!result) { + res.status(401).send('Signature could not be verified.'); + } + else { + processInbox(req, res); + } + }); +}); + + +function processInbox(req, res) { + if (req.body.object) console.log('containing object of type', req.body.object.type) // if a Follow activity hits the inbox if (typeof req.body.object === 'string' && req.body.type === 'Follow') { - console.log('follow!') + const myURL = new URL(req.body.actor); + let targetDomain = myURL.hostname; let eventID = req.body.object.replace(`https://${domain}/`,''); - // Accept the follow request - sendAcceptMessage(req.body, eventID, domain, req, res, targetDomain); // Add the user to the DB of accounts that follow the account - console.log(req.body) - - const newFollower = { - account: req.body.actor, - followId: req.body.id - }; - - Event.findOne({ - id: eventID, - }, function(err,event) { - console.log(event.followers) - // if this account is NOT already in our followers list, add it - if (!event.followers.map(el => el.account).includes(req.body.actor)) { - event.followers.push(newFollower); - console.log(event.followers) - event.save() - .then(() => { - addToLog("addEventFollower", "success", "Follower added to event " + eventID); - console.log('successful follower add'); - // send a Question to the new follower - let inbox = req.body.actor+'/inbox'; - let myURL = new URL(req.body.actor); - let targetDomain = myURL.hostname; - const jsonObject = { - "@context": "https://www.w3.org/ns/activitystreams", - "id": "http://polls.example.org/question/1", - "name": `RSVP to ${event.name}`, - "type": "Question", - "content": `Will you attend ${event.name}?`, - "oneOf": [ - {"type":"Note","name": "Yes"}, - {"type":"Note","name": "No"}, - {"type":"Note","name": "Maybe"} - ], - "endTime":event.start.toISOString() + // get the follower's username + request({ + url: req.body.actor, + headers: { + 'Accept': 'application/activity+json', + 'Content-Type': 'application/activity+json' + }}, function (error, response, body) { + body = JSON.parse(body) + const name = body.preferredUsername || body.name || attributedTo; + const newFollower = { + actorId: req.body.actor, + followId: req.body.id, + name: name, + actorJson: JSON.stringify(body) + }; + Event.findOne({ + id: eventID, + }, function(err,event) { + // if this account is NOT already in our followers list, add it + if (event && !event.followers.map(el => el.actorId).includes(req.body.actor)) { + console.log('made it!') + event.followers.push(newFollower); + event.save() + .then(() => { + addToLog("addEventFollower", "success", "Follower added to event " + eventID); + console.log('successful follower add'); + // Accept the follow request + sendAcceptMessage(req.body, eventID, targetDomain, function(err, resp, status) { + if (err) { + console.log(`Didn't send Accept to ${req.body.actor}, status ${status} with error ${err}`); + } + else { + console.log('sent Accept to', req.body.actor); + // if users can self-RSVP, send a Question to the new follower + if (event.usersCanAttend) { + const jsonObject = { + "@context": "https://www.w3.org/ns/activitystreams", + "name": `RSVP to ${event.name}`, + "type": "Question", + "content": `@${name} Will you attend ${event.name}?`, + "oneOf": [ + {"type":"Note","name": "Yes"}, + {"type":"Note","name": "No"}, + {"type":"Note","name": "Maybe"} + ], + "endTime":event.start.toISOString(), + "tag":[{"type":"Mention","href":req.body.actor,"name":name}] + } + // send direct message to user + sendDirectMessage(jsonObject, req.body.actor, eventID, function (error, response, statuscode) { + if (error) { + console.log(error); + res.status(statuscode).json(error); + } + else { + res.status(statuscode).json({messageid: response}); + } + }); + } + } + }); + }) + .catch((err) => { res.status(500).send('Database error, please try again :('); addToLog("addEventFollower", "error", "Attempt to add follower to event " + eventID + " failed with error: " + err); + console.log('ERROR', err); + }); } - let message = rawMessage(jsonObject, eventID, domain, req.body.actor); - console.log('!!!!!!!!! sending') - console.log(message) - signAndSend(message, eventID, domain, req, res, targetDomain, inbox); }) - .catch((err) => { res.send('Database error, please try again :('); addToLog("addEventFollower", "error", "Attempt to add follower to event " + eventID + " failed with error: " + err); - console.log('error', err) - }); - } - }); + }) //end request } // if an Undo activity with a Follow object hits the inbox if (req.body && req.body.type === 'Undo' && req.body.object && req.body.object.type === 'Follow') { - console.log('undo follow!') - console.log(req.body) // get the record of all followers for this account - let eventID = req.body.object.object.replace(`https://${domain}/`,''); + const eventID = req.body.object.object.replace(`https://${domain}/`,''); Event.findOne({ id: eventID, }, function(err,event) { + if (!event) return; // check to see if the Follow object's id matches the id we have on record - console.log(event.followers) // is this even someone who follows us - const indexOfFollower = event.followers.findIndex(el => {console.log(el.account, req.body.object.actor); return el.account === req.body.object.actor;}); - console.log(indexOfFollower) + const indexOfFollower = event.followers.findIndex(el => el.actorId === req.body.object.actor); if (indexOfFollower !== -1) { // does the id we have match the id we are being given if (event.followers[indexOfFollower].followId === req.body.object.id) { // we have a match and can trust the Undo! remove this person from the followers list event.followers.splice(indexOfFollower, 1); - console.log('new', indexOfFollower, event.followers); event.save() .then(() => { + res.send(200); addToLog("removeEventFollower", "success", "Follower removed from event " + eventID); console.log('successful follower removal') }) @@ -1495,7 +1731,210 @@ router.post('/activitypub/inbox', (req, res) => { } }); } -}); + // if a Create activity with a Note object hits the inbox, it might be a vote in a poll + if (req.body && req.body.type === 'Create' && req.body.object && req.body.object.type === 'Note' && req.body.object.inReplyTo && req.body.object.to) { + console.log('create note inreplyto!!!') + // figure out what this is in reply to -- it should be addressed specifically to us + let {name, attributedTo, inReplyTo, to} = req.body.object; + // if it's an array just grab the first element, since a poll should only broadcast back to the pollster + if (Array.isArray(to)) { + to = to[0]; + } + const eventID = to.replace(`https://${domain}/`,''); + // make sure this person is actually a follower + Event.findOne({ + id: eventID, + }, function(err,event) { + if (!event) return; + // is this even someone who follows us + const indexOfFollower = event.followers.findIndex(el => el.actorId === req.body.object.attributedTo); + if (indexOfFollower !== -1) { + console.log('this person does follow us!') + // compare the inReplyTo to its stored message, if it exists and it's going to the right follower then this is a valid reply + const message = event.activityPubMessages.find(el => { + const content = JSON.parse(el.content); + return inReplyTo === (content.object && content.object.id); + }); + if (message) { + console.log(message); + const content = JSON.parse(message.content); + // check if the message we sent out was sent to the actor this incoming message is attributedTo + if (content.to[0] === attributedTo) { + // it's a match, this is a valid poll response, add RSVP to database + // fetch the profile information of the user + request({ + url: attributedTo, + headers: { + 'Accept': 'application/activity+json', + 'Content-Type': 'application/activity+json' + }}, function (error, response, body) { + body = JSON.parse(body) + // if this account is NOT already in our attendees list, add it + if (!event.attendees.map(el => el.id).includes(attributedTo)) { + const attendeeName = body.preferredUsername || body.name || attributedTo; + const newAttendee = { + name: attendeeName, + status: 'attending', + id: attributedTo + }; + event.attendees.push(newAttendee); + event.save() + .then(() => { + addToLog("addEventAttendee", "success", "Attendee added to event " + req.params.eventID); + console.log('added attendee', attendeeName) + res.send(200); + }) + .catch((err) => { res.send('Database error, please try again :('); addToLog("addEventAttendee", "error", "Attempt to add attendee to event " + req.params.eventID + " failed with error: " + err); }); + } + }); + } + } + } + }); + } + if (req.body && req.body.type === 'Delete') { + // figure out if we have a matching comment by id + const deleteObjectId = req.body.object.id; + // find all events with comments from the author + Event.find({ + "comments.actorId":req.body.actor + }, function(err,events) { + if (!events) { + res.sendStatus(404); + return; + } + + // find the event with THIS comment from the author + let eventWithComment = events.find(event => { + let comments = event.comments; + return comments.find(comment => { + if (!comment.activityJson) { + return false; + } + return JSON.parse(comment.activityJson).object.id === req.body.object.id; + }) + }); + + if (!eventWithComment) { + res.sendStatus(404); + return; + } + + // delete the comment + // find the index of the comment + let indexOfComment = eventWithComment.comments.findIndex(comment => { + return JSON.parse(comment.activityJson).object.id === req.body.object.id; + }); + eventWithComment.comments.splice(indexOfComment, 1); + eventWithComment.save() + .then(() => { + addToLog("deleteComment", "success", "Comment deleted from event " + eventWithComment.id); + console.log('deleted comment!') + res.sendStatus(200); + }) + .catch((err) => { res.sendStatus(500); addToLog("deleteComment", "error", "Attempt to delete comment " + req.body.object.id + "from event " + eventWithComment.id + " failed with error: " + err);}); + }); + } + // if we are CC'ed on a public or unlisted Create/Note, then this is a comment to us we should replicate + if (req.body && req.body.type === 'Create' && req.body.object && req.body.object.type === 'Note' && req.body.object.to) { + console.log('create note!!') + // figure out what this is in reply to -- it should be addressed specifically to us + let {name, attributedTo, inReplyTo, to, cc} = req.body.object; + // if it's an array just grab the first element, since a poll should only broadcast back to the pollster + if (Array.isArray(to)) { + to = to[0]; + } + + // normalize cc into an array + if (typeof cc === 'string') { + cc = [cc]; + } + + // if this is a public message (in the to or cc fields) + if (to === 'https://www.w3.org/ns/activitystreams#Public' || (Array.isArray(cc) && cc.includes('https://www.w3.org/ns/activitystreams#Public'))) { + // figure out which event(s) of ours it was addressing + ourEvents = cc.filter(el => el.includes(`https://${domain}/`)) + .map(el => el.replace(`https://${domain}/`,'')); + // comments should only be on one event. if more than one, ignore (spam, probably) + if (ourEvents.length === 1) { + let eventID = ourEvents[0]; + // add comment + let commentID = shortid.generate(); + // get the actor for the commenter + request({ + url: req.body.actor, + headers: { + 'Accept': 'application/activity+json', + 'Content-Type': 'application/activity+json' + }}, function (error, response, actor) { + if (!error) { + const parsedActor = JSON.parse(actor); + const name = parsedActor.preferredUsername || parsedActor.name || req.body.actor; + const newComment = { + id: commentID, + actorId: req.body.actor, + activityId: req.body.object.id, + author: name, + content: sanitizeHtml(req.body.object.content, {allowedTags: [], allowedAttributes: {}}).replace('@'+eventID,''), + timestamp: moment(), + activityJson: JSON.stringify(req.body), + actorJson: actor + }; + + Event.findOne({ + id: eventID, + }, function(err,event) { + if (!event) { + return res.sendStatus(404); + } + if (!event.usersCanComment) { + return res.sendStatus(200); + } + event.comments.push(newComment); + event.save() + .then(() => { + addToLog("addEventComment", "success", "Comment added to event " + eventID); + console.log('added comment'); + res.sendStatus(200); + }) + .catch((err) => { res.status(500).send('Database error, please try again :(' + err); addToLog("addEventComment", "error", "Attempt to add comment to event " + eventID + " failed with error: " + err); console.log('error', err)}); + }); + } + }); + } // end ourevent + } // end public message + // if it's not a public message, let them know that we only support public messages right now + else { + // figure out which event(s) of ours it was addressing + ourEvents = cc.concat(to).filter(el => el.includes(`https://${domain}/`)) + .map(el => el.replace(`https://${domain}/`,'')); + // comments should only be on one event. if more than one, ignore (spam, probably) + if (ourEvents.length === 1) { + let eventID = ourEvents[0]; + // get the user's actor info + request({ + url: req.body.actor, + headers: { + 'Accept': 'application/activity+json', + 'Content-Type': 'application/activity+json' + }}, function (error, response, actor) { + actor = JSON.parse(actor); + const name = actor.preferredUsername || actor.name || req.body.actor; + const jsonObject = { + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Note", + "inReplyTo": req.body.object.id, + "content": `@${name} Sorry, this service only supports posting public messages to the event page. Try contacting the event organizer directly if you need to have a private conversation.`, + "tag":[{"type":"Mention","href":req.body.actor,"name":name}] + } + res.send(200); + sendDirectMessage(jsonObject, req.body.actor, eventID); + } + ); + } + } + } +} router.use(function(req, res, next){ res.status(404); diff --git a/views/event.handlebars b/views/event.handlebars index e2529b8..a57f62a 100755 --- a/views/event.handlebars +++ b/views/event.handlebars @@ -81,6 +81,15 @@ Copy +
  • + + + + @{{eventData.id}}@{{domain}} + +
  • @@ -125,7 +134,7 @@ {{#if eventAttendees}}
      {{#each eventAttendees}} - {{this.name}}{{#if ../editingEnabled}} {{/if}} + {{this.name}}{{#if ../editingEnabled}} {{/if}} {{/each}}
    {{else}} @@ -246,7 +255,11 @@
    -

    {{this.author}} {{this.timestamp}}

    + {{#if this.actorId}} +

    {{this.author}} {{this.timestamp}}

    + {{else}} +

    {{this.author}} {{this.timestamp}}

    + {{/if}}

    {{this.content}}

    {{#if this.replies}}
    @@ -398,6 +411,12 @@ $(this).html(' Copied!'); setTimeout(function(){ $("#copyEventLink").html(' Copy');}, 5000); }) + new ClipboardJS('#copyAPLink'); + $("#copyAPLink").click(function(){ + console.log('hhhhh') + $(this).html(' Copied!'); + setTimeout(function(){ $("#copyAPLink").html(' Copy');}, 5000); + }) $(".daysToDeletion").html(moment("{{eventEndISO}}").add(7, 'days').fromNow()); if ($("#joinCheckbox").is(':checked')){ $("#maxAttendeesCheckboxContainer").css("display","flex"); diff --git a/views/home.handlebars b/views/home.handlebars index c1a610f..6f62a16 100755 --- a/views/home.handlebars +++ b/views/home.handlebars @@ -1,11 +1,32 @@ -

    Organise all the things

    - +

    🚨Experimental Fediverse Event Software🚨

    - gathio is a quick and easy way to make and share events which respects your privacy. + This is experimental software from Darius Kazemi.


    +

    The Fediverse needs an event organizing system, so I've taken the incredibly lovely open source event organizing software gath.io and added my lightweight ActivityPub server to the mix.

    + +

    I fully expect this to break, and I would love early testers. I'm especially interested to know how this interacts with people who aren't on Mastodon.

    + +

    Directions

    + +

    Hit the green New Event button and put in your event details. Your event will have a nice looking home page and it will also have an ActivityPub-compatible account and profile. It will give you the account handle for the event, which will look something like @aB3_2HI@{{domain}} and can be shared with people on the Fediverse who want to follow your event.

    + +

    When a person follows your event, they'll follow a feed that updates whenever you update event details. After a person follows, this software will DM the person a poll (aka an ActivityPub "Question") where, at least in Mastodon, they can vote Yes/No/Maybe. If they vote Yes, then they will be registered as an attendee on your page. No and Maybe don't do anything yet.

    + +

    Also when you update your event (changing any of the fields), you send a DM notification to every user who is registered as attending.

    + +

    Further info

    + +

    I'll publish the source code soon, but it's in crummy and undocumented shape right now and I'd rather see it perform "in the wild" before I do an official release.

    + +

    If you'd like to chat with me about this software, I can be reached at @darius@friend.camp.

    + +

    What follows is the documentation from gath.io.

    + +
    +

    You don't need to sign up for an account - we just use your email to send you a secret link you can use to edit or delete your event. Send all your guests the public link, and all your co-hosts the editing link. A week after the event finishes, it's deleted from our servers for ever, and your email goes with it.

    -- cgit v1.2.3 From 49817373b16a7b4c36d32a9d23563c95c40ca685 Mon Sep 17 00:00:00 2001 From: Darius Kazemi Date: Sun, 15 Dec 2019 14:11:00 -0800 Subject: profile updates, better diffs --- routes.js | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 92 insertions(+), 12 deletions(-) diff --git a/routes.js b/routes.js index 6d09ab7..2475904 100755 --- a/routes.js +++ b/routes.js @@ -144,7 +144,7 @@ function createWebfinger(eventID, domain) { }; } -function createActivityPubActor(eventID, domain, pubkey, description, name, location, imageFilename) { +function createActivityPubActor(eventID, domain, pubkey, description, name, location, imageFilename, startUTC, endUTC, timezone) { let actor = { '@context': [ 'https://www.w3.org/ns/activitystreams', @@ -156,7 +156,7 @@ function createActivityPubActor(eventID, domain, pubkey, description, name, loca 'preferredUsername': `${eventID}`, 'inbox': `https://${domain}/activitypub/inbox`, 'followers': `https://${domain}/${eventID}/followers`, - 'summary': description, + 'summary': `

    ${description}

    `, 'name': name, 'publicKey': { @@ -166,7 +166,34 @@ function createActivityPubActor(eventID, domain, pubkey, description, name, loca } }; if (location) { - actor.summary += ` Location: ${location}.` + actor.summary += `

    Location: ${location}.

    ` + } + let displayDate; + if (startUTC && timezone) { + displayDate = moment.tz(startUTC, timezone).format('D MMMM YYYY h:mm a'); + actor.summary += `

    Starting ${displayDate}.

    `; + } + if (imageFilename) { + actor.icon = { + 'type': 'Image', + 'mediaType': 'image/jpg', + 'url': `https://${domain}/events/${imageFilename}`, + }; + } + return JSON.stringify(actor); +} + +function updateActivityPubActor(actor, description, name, location, imageFilename, startUTC, endUTC, timezone) { + if (!actor) return; + actor.summary = `

    ${description}

    `; + actor.name = name; + if (location) { + actor.summary += `

    Location: ${location}.

    ` + } + let displayDate; + if (startUTC && timezone) { + displayDate = moment.tz(startUTC, timezone).format('D MMMM YYYY h:mm a'); + actor.summary += `

    Starting ${displayDate}.

    `; } if (imageFilename) { actor.icon = { @@ -301,6 +328,54 @@ function broadcastMessage(apObject, followers, eventID, callback) { } // end followers } +function broadcastUpdateMessage(apObject, followers, eventID, callback) { + callback = callback || function() {}; + let guidUpdate = crypto.randomBytes(16).toString('hex'); + console.log('broadcasting update'); + // iterate over followers + for (const follower of followers) { + let actorId = follower.actorId; + let myURL = new URL(actorId); + let targetDomain = myURL.hostname; + // get the inbox + Event.findOne({ + id: eventID, + }, function(err, event) { + console.log('found the event for broadcast') + if (event) { + const follower = event.followers.find(el => el.actorId === actorId); + if (follower) { + const actorJson = JSON.parse(follower.actorJson); + const inbox = actorJson.inbox; + console.log('found the inbox for', actorId) + const createMessage = { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': `https://${domain}/m/${guidUpdate}`, + 'type': 'Update', + 'actor': `https://${domain}/${eventID}`, + 'object': apObject + }; + console.log('UPDATE') + console.log(JSON.stringify(createMessage)); + signAndSend(createMessage, eventID, targetDomain, inbox, function(err, resp, status) { + if (err) { + console.log(`Didn't sent to ${actorId}, status ${status} with error ${err}`); + } + else { + console.log('sent to', actorId); + } + }); + } + else { + callback(`No follower found with the id ${actorId}`, null, 404); + } + } + else { + callback(`No event found with the id ${eventID}`, null, 404); + } + }); + } // end followers +} function signAndSend(message, eventID, targetDomain, inbox, callback) { let inboxFragment = inbox.replace('https://'+targetDomain,''); // get the private key @@ -803,7 +878,7 @@ router.post('/newevent', async (req, res) => { usersCanComment: req.body.interactionCheckbox ? true : false, maxAttendees: req.body.maxAttendees, firstLoad: true, - activityPubActor: createActivityPubActor(eventID, domain, pair.public, marked(req.body.eventDescription), req.body.eventName, req.body.eventLocation, eventImageFilename), + activityPubActor: createActivityPubActor(eventID, domain, pair.public, marked(req.body.eventDescription), req.body.eventName, req.body.eventLocation, eventImageFilename, req.body.startUTC, req.body.endUTC, req.body.timezone), publicKey: pair.public, privateKey: pair.private }); @@ -1021,7 +1096,8 @@ router.post('/editevent/:eventID/:editToken', (req, res) => { showUsersList: req.body.guestlistCheckbox ? true : false, usersCanComment: req.body.interactionCheckbox ? true : false, maxAttendees: req.body.maxAttendeesCheckbox ? req.body.maxAttendees : null, - eventGroup: isPartOfEventGroup ? eventGroup._id : null + eventGroup: isPartOfEventGroup ? eventGroup._id : null, + activityPubActor: updateActivityPubActor(JSON.parse(event.activityPubActor), req.body.eventDescription, req.body.eventName, req.body.eventLocation, eventImageFilename, startUTC, endUTC, req.body.timezone) } let diffText = '

    This event was just updated with new information.

      '; let displayDate; @@ -1068,6 +1144,11 @@ router.post('/editevent/:eventID/:editToken', (req, res) => { "content": `${diffText} See here: https://${domain}/${req.params.eventID}`, } broadcastMessage(jsonObject, event.followers, eventID) + // also broadcast an Update profile message to all followers so that at least Mastodon servers will update the local profile information + const guidUpdateObject = crypto.randomBytes(16).toString('hex'); + const jsonUpdateObject = JSON.parse(event.activityPubActor); + broadcastUpdateMessage(jsonUpdateObject, event.followers, eventID) + // DM to attendees for (const attendee of attendees) { const jsonObject = { @@ -1672,7 +1753,7 @@ function processInbox(req, res) { "@context": "https://www.w3.org/ns/activitystreams", "name": `RSVP to ${event.name}`, "type": "Question", - "content": `@${name} Will you attend ${event.name}?`, + "content": `@${name} Will you attend ${event.name}? (If you reply "Yes", you'll be listed as an attendee on the event page.)`, "oneOf": [ {"type":"Note","name": "Yes"}, {"type":"Note","name": "No"}, @@ -1840,18 +1921,17 @@ function processInbox(req, res) { console.log('create note!!') // figure out what this is in reply to -- it should be addressed specifically to us let {name, attributedTo, inReplyTo, to, cc} = req.body.object; - // if it's an array just grab the first element, since a poll should only broadcast back to the pollster - if (Array.isArray(to)) { - to = to[0]; - } - // normalize cc into an array if (typeof cc === 'string') { cc = [cc]; } + // normalize to into an array + if (typeof to === 'string') { + to = [to]; + } // if this is a public message (in the to or cc fields) - if (to === 'https://www.w3.org/ns/activitystreams#Public' || (Array.isArray(cc) && cc.includes('https://www.w3.org/ns/activitystreams#Public'))) { + if (to.includes('https://www.w3.org/ns/activitystreams#Public') || (Array.isArray(cc) && cc.includes('https://www.w3.org/ns/activitystreams#Public'))) { // figure out which event(s) of ours it was addressing ourEvents = cc.filter(el => el.includes(`https://${domain}/`)) .map(el => el.replace(`https://${domain}/`,'')); -- cgit v1.2.3 From 8587b5b41e5781ea6fe4ce130e36ac327548864b Mon Sep 17 00:00:00 2001 From: Darius Kazemi Date: Sat, 4 Jan 2020 13:33:35 -0800 Subject: Email refactoring Tons of refactoring of email. This no longer uses Sendgrid templates and now uses source-controlled handlebars files in the `views/emails/` directory. This means that email messages are now source-controlled and vastly reduces the sendgrid setup process. --- app.js | 2 + config/domain-example.js | 3 +- package-lock.json | 20 +- routes.js | 370 +++++++++++++++------------- views/emails/addeventattendee.handlebars | 7 + views/emails/addeventcomment.handlebars | 7 + views/emails/createevent.handlebars | 19 ++ views/emails/createeventgroup.handlebars | 27 ++ views/emails/deleteevent.handlebars | 4 + views/emails/editevent.handlebars | 8 + views/emails/removeeventattendee.handlebars | 4 + views/emails/unattendevent.handlebars | 8 + views/layouts/email.handlebars | 126 ++++++++++ 13 files changed, 426 insertions(+), 179 deletions(-) create mode 100644 views/emails/addeventattendee.handlebars create mode 100644 views/emails/addeventcomment.handlebars create mode 100644 views/emails/createevent.handlebars create mode 100644 views/emails/createeventgroup.handlebars create mode 100644 views/emails/deleteevent.handlebars create mode 100644 views/emails/editevent.handlebars create mode 100644 views/emails/removeeventattendee.handlebars create mode 100644 views/emails/unattendevent.handlebars create mode 100644 views/layouts/email.handlebars diff --git a/app.js b/app.js index ab4eb56..c6e0647 100755 --- a/app.js +++ b/app.js @@ -19,6 +19,7 @@ const app = express(); hbsInstance = hbs.create({ defaultLayout: 'main', partialsDir: ['views/partials/'], + layoutsDir: 'views/layouts/', helpers: { plural: function(number, text) { var singular = number === 1; @@ -39,6 +40,7 @@ hbsInstance = hbs.create({ }); app.engine('handlebars', hbsInstance.engine); app.set('view engine', 'handlebars'); +app.set('hbsInstance', hbsInstance); // Static files // diff --git a/config/domain-example.js b/config/domain-example.js index 2f22fe7..3b77197 100644 --- a/config/domain-example.js +++ b/config/domain-example.js @@ -2,5 +2,6 @@ module.exports = { // Your domain goes here. If there is a port it should be 'domain:port', but otherwise just 'domain' 'domain' : 'localhost:3000' , 'port': '3000', - 'email': 'contact@example.com' + 'email': 'contact@example.com', + 'sitename': 'gathio' }; diff --git a/package-lock.json b/package-lock.json index e9b5b66..10d0d73 100755 --- a/package-lock.json +++ b/package-lock.json @@ -1535,9 +1535,9 @@ } }, "commander": { - "version": "2.20.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.0.tgz", - "integrity": "sha512-7j2y+40w61zy6YC2iRNpUe/NwhNyoXrYpHMrSunaMG64nRnaf96zO/KMQR4OyN/UnE5KLyEBnKHd4aG3rskjpQ==", + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "optional": true }, "component-emitter": { @@ -2732,9 +2732,9 @@ } }, "handlebars": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.1.2.tgz", - "integrity": "sha512-nvfrjqvt9xQ8Z/w0ijewdD/vvWDTOweBUm96NTr66Wfvo1mJenBLwcYmPs3TIBP5ruzYGD7Hx/DaM9RmhroGPw==", + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.5.3.tgz", + "integrity": "sha512-3yPecJoJHK/4c6aZhSvxOyG4vJKDshV36VHp0iVCDVh7o9w2vwi3NSnL2MMPj3YdduqaBcu7cGbggJQM0br9xA==", "requires": { "neo-async": "^2.6.0", "optimist": "^0.6.1", @@ -5276,12 +5276,12 @@ "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" }, "uglify-js": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.6.0.tgz", - "integrity": "sha512-W+jrUHJr3DXKhrsS7NUVxn3zqMOFn0hL/Ei6v0anCIMoKC93TjcflTagwIHLW7SfMFfiQuktQyFVCFHGUE0+yg==", + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.7.3.tgz", + "integrity": "sha512-7tINm46/3puUA4hCkKYo4Xdts+JDaVC9ZPRcG8Xw9R4nhO/gZgUM3TENq8IF4Vatk8qCig4MzP/c8G4u2BkVQg==", "optional": true, "requires": { - "commander": "~2.20.0", + "commander": "~2.20.3", "source-map": "~0.6.1" }, "dependencies": { diff --git a/routes.js b/routes.js index 2475904..34dc1f8 100755 --- a/routes.js +++ b/routes.js @@ -26,6 +26,7 @@ const request = require('request'); const domain = require('./config/domain.js').domain; const contactEmail = require('./config/domain.js').email; +const siteName = require('./config/domain.js').sitename var sanitizeHtml = require('sanitize-html'); // Extra marked renderer (used to render plaintext event description for page metadata) @@ -610,13 +611,23 @@ router.get('/:eventID', (req, res) => { } } } - let eventAttendees = event.attendees.sort((a,b) => (a.name > b.name) ? 1 : ((b.name > a.name) ? -1 : 0)); - let spotsRemaining, noMoreSpots; - if (event.maxAttendees) { - spotsRemaining = event.maxAttendees - eventAttendees.length; - if (spotsRemaining <= 0) { - noMoreSpots = true; - } + let eventAttendees = event.attendees.sort((a,b) => (a.name > b.name) ? 1 : ((b.name > a.name) ? -1 : 0)) + .map(el => { + if (!el.id) { + el.id = el._id; + } + return el; + }) + .filter((obj, pos, arr) => { + return arr.map(mapObj => mapObj.id).indexOf(obj.id) === pos; + }); + + let spotsRemaining, noMoreSpots; + if (event.maxAttendees) { + spotsRemaining = event.maxAttendees - eventAttendees.length; + if (spotsRemaining <= 0) { + noMoreSpots = true; + } } let metadata = { title: event.name, @@ -887,30 +898,28 @@ router.post('/newevent', async (req, res) => { addToLog("createEvent", "success", "Event " + eventID + "created"); // Send email with edit link if (sendEmails) { - const msg = { - to: req.body.creatorEmail, - from: { - name: 'Gathio', - email: contactEmail, - }, - templateId: 'd-00330b8278ab463e9f88c16566487d97', - dynamic_template_data: { - subject: 'gathio: ' + req.body.eventName, - eventID: eventID, - editToken: editToken - }, - }; - sgMail.send(msg).catch(e => { - console.error(e.toString()); - res.status(500).end(); - }); + req.app.get('hbsInstance').renderView('./views/emails/createevent.handlebars', {eventID, editToken, siteName, domain, cache: true, layout: 'email.handlebars'}, function(err, html) { + const msg = { + to: req.body.creatorEmail, + from: { + name: siteName, + email: contactEmail, + }, + subject: `${siteName}: ${req.body.eventName}`, + html, + }; + sgMail.send(msg).catch(e => { + console.error(e.toString()); + res.status(500).end(); + }); + }); } res.writeHead(302, { 'Location': '/' + eventID + '?e=' + editToken }); res.end(); }) - .catch((err) => { res.send('Database error, please try again :( - ' + err); addToLog("createEvent", "error", "Attempt to create event failed with error: " + err);}); + .catch((err) => { res.status(500).send('Database error, please try again :( - ' + err); addToLog("createEvent", "error", "Attempt to create event failed with error: " + err);}); }); router.post('/importevent', (req, res) => { @@ -957,23 +966,21 @@ router.post('/importevent', (req, res) => { addToLog("createEvent", "success", "Event " + eventID + " created"); // Send email with edit link if (sendEmails) { - const msg = { - to: creatorEmail, - from: { - name: 'Gathio', - email: contactEmail, - }, - templateId: 'd-00330b8278ab463e9f88c16566487d97', - dynamic_template_data: { - subject: 'gathio: ' + req.body.eventName, - eventID: eventID, - editToken: editToken - }, - }; - sgMail.send(msg).catch(e => { - console.error(e.toString()); - res.status(500).end(); - }); + req.app.get('hbsInstance').renderView('./views/emails/createevent.handlebars', {eventID, editToken, siteName, domain, cache: true, layout: 'email.handlebars'}, function(err, html) { + const msg = { + to: req.body.creatorEmail, + from: { + name: siteName, + email: contactEmail, + }, + subject: `${siteName}: ${req.body.eventName}`, + html, + }; + sgMail.send(msg).catch(e => { + console.error(e.toString()); + res.status(500).end(); + }); + }); } res.writeHead(302, { 'Location': '/' + eventID + '?e=' + editToken @@ -1019,23 +1026,21 @@ router.post('/neweventgroup', (req, res) => { addToLog("createEventGroup", "success", "Event group " + eventGroupID + " created"); // Send email with edit link if (sendEmails) { - const msg = { - to: req.body.creatorEmail, - from: { - name: 'Gathio', - email: contactEmail, - }, - templateId: 'd-4c5ddcb34ac44ec5b2313c6da4e405f3', - dynamic_template_data: { - subject: 'gathio: ' + req.body.eventGroupName, - eventGroupID: eventGroupID, - editToken: editToken - }, - }; - sgMail.send(msg).catch(e => { - console.error(e.toString()); - res.status(500).end(); - }); + req.app.get('hbsInstance').renderView('./views/emails/createeventgroup.handlebars', {eventGroupID, editToken, siteName, domain, cache: true, layout: 'email.handlebars'}, function(err, html) { + const msg = { + to: req.body.creatorEmail, + from: { + name: siteName, + email: contactEmail, + }, + subject: `${siteName}: ${req.body.eventGroupName}`, + html, + }; + sgMail.send(msg).catch(e => { + console.error(e.toString()); + res.status(500).end(); + }); + }); } res.writeHead(302, { 'Location': '/group/' + eventGroupID + '?e=' + editToken @@ -1168,21 +1173,21 @@ router.post('/editevent/:eventID/:editToken', (req, res) => { attendeeEmails = ids; if (!error && attendeeEmails !== ""){ console.log("Sending emails to: " + attendeeEmails); - const msg = { - to: attendeeEmails, - from: { - name: 'Gathio', - email: contactEmail, - }, - templateId: 'd-e21f3ca49d82476b94ddd8892c72a162', - dynamic_template_data: { - subject: 'gathio: Event edited', - actionType: 'edited', - eventExists: true, - eventID: req.params.eventID - } - } - sgMail.sendMultiple(msg); + req.app.get('hbsInstance').renderView('./views/emails/editevent.handlebars', {diffText, eventID: req.params.eventID, siteName, domain, cache: true, layout: 'email.handlebars'}, function(err, html) { + const msg = { + to: attendeeEmails, + from: { + name: siteName, + email: contactEmail, + }, + subject: `${siteName}: ${event.name} was just edited`, + html, + }; + sgMail.sendMultiple(msg).catch(e => { + console.error(e.toString()); + res.status(500).end(); + }); + }); } else { console.log("Nothing to send!"); @@ -1294,6 +1299,7 @@ router.post('/deleteevent/:eventID/:editToken', (req, res) => { if (event.editToken === submittedEditToken) { // Token matches + let eventImage; if (event.image){ eventImage = event.image; } @@ -1304,21 +1310,21 @@ router.post('/deleteevent/:eventID/:editToken', (req, res) => { attendeeEmails = ids; if (!error){ console.log("Sending emails to: " + attendeeEmails); - const msg = { - to: attendeeEmails, - from: { - name: 'Gathio', - email: contactEmail, - }, - templateId: 'd-e21f3ca49d82476b94ddd8892c72a162', - dynamic_template_data: { - subject: 'gathio: Event "' + event.name + '" deleted', - actionType: 'deleted', - eventExists: false, - eventID: req.params.eventID - } - } - sgMail.sendMultiple(msg); + req.app.get('hbsInstance').renderView('./views/emails/deleteevent.handlebars', {siteName, domain, eventName: event.name, cache: true, layout: 'email.handlebars'}, function(err, html) { + const msg = { + to: attendeeEmails, + from: { + name: siteName, + email: contactEmail, + }, + subject: `${siteName}: ${event.name} was deleted`, + html, + }; + sgMail.sendMultiple(msg).catch(e => { + console.error(e.toString()); + res.status(500).end(); + }); + }); } else { console.log("Nothing to send!"); @@ -1432,22 +1438,23 @@ router.post('/attendevent/:eventID', (req, res) => { addToLog("addEventAttendee", "success", "Attendee added to event " + req.params.eventID); if (sendEmails) { if (req.body.attendeeEmail){ - const msg = { - to: req.body.attendeeEmail, - from: { - name: 'Gathio', - email: contactEmail, - }, - templateId: 'd-977612474bba49c48b58e269f04f927c', - dynamic_template_data: { - subject: 'gathio: ' + event.name, - eventID: req.params.eventID - }, - }; - sgMail.send(msg); + req.app.get('hbsInstance').renderView('./views/emails/addeventattendee.handlebars', {eventID: req.params.eventID, siteName, domain, cache: true, layout: 'email.handlebars'}, function(err, html) { + const msg = { + to: req.body.attendeeEmail, + from: { + name: siteName, + email: contactEmail, + }, + subject: `${siteName}: You're RSVPed to ${event.name}`, + html, + }; + sgMail.send(msg).catch(e => { + console.error(e.toString()); + res.status(500).end(); + }); + }); } } - res.writeHead(302, { 'Location': '/' + req.params.eventID }); @@ -1464,22 +1471,23 @@ router.post('/unattendevent/:eventID', (req, res) => { ) .then(response => { console.log(response) - addToLog("removeEventAttendee", "success", "Attendee removed from event " + req.params.eventID); + addToLog("unattendEvent", "success", "Attendee removed self from event " + req.params.eventID); if (sendEmails) { if (req.body.attendeeEmail){ - const msg = { - to: req.body.attendeeEmail, - from: { - name: 'Gathio', - email: contactEmail, - }, - templateId: 'd-56c97755d6394c23be212fef934b0f1f', - dynamic_template_data: { - subject: 'gathio: You have been removed from an event', - eventID: req.params.eventID - }, - }; - sgMail.send(msg); + req.app.get('hbsInstance').renderView('./views/emails/unattendevent.handlebars', {eventID: req.params.eventID, siteName, domain, cache: true, layout: 'email.handlebars'}, function(err, html) { const msg = { + to: req.body.attendeeEmail, + from: { + name: siteName, + email: contactEmail, + }, + subject: `${siteName}: You have been removed from an event`, + html, + }; + sgMail.send(msg).catch(e => { + console.error(e.toString()); + res.status(500).end(); + }); + }); } } res.writeHead(302, { @@ -1501,20 +1509,22 @@ router.post('/removeattendee/:eventID/:attendeeID', (req, res) => { console.log(response) addToLog("removeEventAttendee", "success", "Attendee removed by admin from event " + req.params.eventID); if (sendEmails) { + // currently this is never called because we don't have the email address if (req.body.attendeeEmail){ - const msg = { - to: req.body.attendeeEmail, - from: { - name: 'Gathio', - email: contactEmail, - }, - templateId: 'd-f8ee9e1e2c8a48e3a329d1630d0d371f', - dynamic_template_data: { - subject: 'gathio: You have been removed from an event', - eventID: req.params.eventID - }, - }; - sgMail.send(msg); + req.app.get('hbsInstance').renderView('./views/emails/removeeventattendee.handlebars', {eventName: req.params.eventName, siteName, domain, cache: true, layout: 'email.handlebars'}, function(err, html) { const msg = { + to: req.body.attendeeEmail, + from: { + name: siteName, + email: contactEmail, + }, + subject: `${siteName}: You have been removed from an event`, + html, + }; + sgMail.send(msg).catch(e => { + console.error(e.toString()); + res.status(500).end(); + }); + }); } } res.writeHead(302, { @@ -1549,20 +1559,21 @@ router.post('/post/comment/:eventID', (req, res) => { attendeeEmails = ids; if (!error){ console.log("Sending emails to: " + attendeeEmails); - const msg = { - to: attendeeEmails, - from: { - name: 'Gathio', - email: contactEmail, - }, - templateId: 'd-756d078561e047aba307155f02b6686d', - dynamic_template_data: { - subject: 'gathio: New comment in ' + event.name, - commentAuthor: req.body.commentAuthor, - eventID: req.params.eventID - } - } - sgMail.sendMultiple(msg); + req.app.get('hbsInstance').renderView('./views/emails/addeventcomment.handlebars', {siteName, domain, eventID: req.params.eventID, commentAuthor: req.body.commentAuthor, cache: true, layout: 'email.handlebars'}, function(err, html) { + const msg = { + to: attendeeEmails, + from: { + name: siteName, + email: contactEmail, + }, + subject: `${siteName}: New comment in ${event.name}`, + html, + }; + sgMail.sendMultiple(msg).catch(e => { + console.error(e.toString()); + res.status(500).end(); + }); + }); } else { console.log("Nothing to send!"); @@ -1601,20 +1612,21 @@ router.post('/post/reply/:eventID/:commentID', (req, res) => { attendeeEmails = ids; if (!error){ console.log("Sending emails to: " + attendeeEmails); - const msg = { - to: attendeeEmails, - from: { - name: 'Gathio', - email: contactEmail, - }, - templateId: 'd-756d078561e047aba307155f02b6686d', - dynamic_template_data: { - subject: 'gathio: New comment in ' + event.name, - commentAuthor: req.body.commentAuthor, - eventID: req.params.eventID - } - } - sgMail.sendMultiple(msg); + req.app.get('hbsInstance').renderView('./views/emails/addeventcomment.handlebars', {siteName, domain, eventID: req.params.eventID, commentAuthor: req.body.replyAuthor, cache: true, layout: 'email.handlebars'}, function(err, html) { + const msg = { + to: attendeeEmails, + from: { + name: siteName, + email: contactEmail, + }, + subject: `${siteName}: New comment in ${event.name}`, + html, + }; + sgMail.sendMultiple(msg).catch(e => { + console.error(e.toString()); + res.status(500).end(); + }); + }); } else { console.log("Nothing to send!"); @@ -1673,13 +1685,23 @@ router.post('/activitypub/inbox', (req, res) => { }, {}); // get the actor + // TODO if this is a Delete for an Actor this won't work request({ url: signature_header.keyId, headers: { 'Accept': 'application/activity+json', 'Content-Type': 'application/activity+json' }}, function (error, response, actor) { - publicKey = JSON.parse(actor).publicKey.publicKeyPem; + let publicKey = ''; + + try { + if (JSON.parse(actor).publicKey) { + publicKey = JSON.parse(actor).publicKey.publicKeyPem; + } + } + catch(err) { + return res.status(500).send('Actor could not be parsed' + err); + } let comparison_string = signature_header.headers.split(' ').map(header => { if (header === '(request-target)') { @@ -1694,10 +1716,14 @@ router.post('/activitypub/inbox', (req, res) => { verifier.update(comparison_string, 'ascii') const publicKeyBuf = new Buffer(publicKey, 'ascii') const signatureBuf = new Buffer(signature_header.signature, 'base64') - const result = verifier.verify(publicKeyBuf, signatureBuf) - console.log('VALIDATE RESULT:', result) + try { + const result = verifier.verify(publicKeyBuf, signatureBuf) + } + catch(err) { + return res.status(401).send('Signature could not be verified: ' + err); + } if (!result) { - res.status(401).send('Signature could not be verified.'); + return res.status(401).send('Signature could not be verified.'); } else { processInbox(req, res); @@ -1863,9 +1889,13 @@ function processInbox(req, res) { .then(() => { addToLog("addEventAttendee", "success", "Attendee added to event " + req.params.eventID); console.log('added attendee', attendeeName) - res.send(200); + return res.sendStatus(200); }) - .catch((err) => { res.send('Database error, please try again :('); addToLog("addEventAttendee", "error", "Attempt to add attendee to event " + req.params.eventID + " failed with error: " + err); }); + .catch((err) => { addToLog("addEventAttendee", "error", "Attempt to add attendee to event " + req.params.eventID + " failed with error: " + err); return res.status(500).send('Database error, please try again :('); }); + } + else { + // it's a duplicate and this person is already rsvped so just say OK + return res.status(200).send("Attendee is already registered."); } }); } @@ -1874,6 +1904,7 @@ function processInbox(req, res) { }); } if (req.body && req.body.type === 'Delete') { + // TODO: only do this if it's a delete for a Note // figure out if we have a matching comment by id const deleteObjectId = req.body.object.id; // find all events with comments from the author @@ -1920,7 +1951,7 @@ function processInbox(req, res) { if (req.body && req.body.type === 'Create' && req.body.object && req.body.object.type === 'Note' && req.body.object.to) { console.log('create note!!') // figure out what this is in reply to -- it should be addressed specifically to us - let {name, attributedTo, inReplyTo, to, cc} = req.body.object; + let {attributedTo, inReplyTo, to, cc} = req.body.object; // normalize cc into an array if (typeof cc === 'string') { cc = [cc]; @@ -1983,8 +2014,11 @@ function processInbox(req, res) { }); } // end ourevent } // end public message - // if it's not a public message, let them know that we only support public messages right now - else { + // if it's not a public message, AND it's not a vote let them know that we only support public messages right now + else if (req.body.object.name !== 'Yes') { + if (!cc) { + cc = []; + } // figure out which event(s) of ours it was addressing ourEvents = cc.concat(to).filter(el => el.includes(`https://${domain}/`)) .map(el => el.replace(`https://${domain}/`,'')); @@ -2007,7 +2041,7 @@ function processInbox(req, res) { "content": `@${name} Sorry, this service only supports posting public messages to the event page. Try contacting the event organizer directly if you need to have a private conversation.`, "tag":[{"type":"Mention","href":req.body.actor,"name":name}] } - res.send(200); + res.sendStatus(200); sendDirectMessage(jsonObject, req.body.actor, eventID); } ); diff --git a/views/emails/addeventattendee.handlebars b/views/emails/addeventattendee.handlebars new file mode 100644 index 0000000..f49c790 --- /dev/null +++ b/views/emails/addeventattendee.handlebars @@ -0,0 +1,7 @@ +

      You just marked yourself as attending an event on {{siteName}}. Thank you! We'll send you another email if there are any updates to the event. Your email will be automatically removed from the database once the event finishes.

      +

      Follow this link to open the event page any time: https://{{domain}}/{{eventID}}

      +

      Love,

      +

      {{siteName}}

      +
      +

      Hold up - I have no idea what this email is about!

      +

      If you didn't mark yourself as attending an event on {{siteName}}, someone may have accidentally typed your email instead of theirs. Don't worry - there isn't anything you need to do. Your email will be removed from the system when the event finishes.

      diff --git a/views/emails/addeventcomment.handlebars b/views/emails/addeventcomment.handlebars new file mode 100644 index 0000000..8ab7ec1 --- /dev/null +++ b/views/emails/addeventcomment.handlebars @@ -0,0 +1,7 @@ +

      {{commentAuthor}} has just posted a comment on an event you're attending on {{siteName}}.

      +

      Click here to see the comment: https://{{domain}}/{{eventID}}

      +

      Love,

      +

      {{siteName}}

      +
      +

      Hold up - I have no idea what this email is about!

      +

      If you didn't mark yourself as attending an event on {{siteName}}, someone may have accidentally typed your email instead of theirs. Don't worry - there isn't anything you need to do. Your email will be removed from the system when the event finishes.

      diff --git a/views/emails/createevent.handlebars b/views/emails/createevent.handlebars new file mode 100644 index 0000000..030ee58 --- /dev/null +++ b/views/emails/createevent.handlebars @@ -0,0 +1,19 @@ +

      Your event has been created!

      +

      Use this link to share it with people: https://{{domain}}/{{eventID}}

      +

      Click this button to edit your event. DO NOT SHARE THIS, as anyone with this link can edit your event.

      + + + + + + + +
      + + + + + + +
      Edit Your Event
      +
      diff --git a/views/emails/createeventgroup.handlebars b/views/emails/createeventgroup.handlebars new file mode 100644 index 0000000..3f03345 --- /dev/null +++ b/views/emails/createeventgroup.handlebars @@ -0,0 +1,27 @@ +

      You just created a new event group on {{siteName}}! Thanks a bunch - we're delighted to have you.

      +

      You can edit your event group by clicking the button below, or just following this link: https://{{domain}}/{{eventGroupID}}?e={{editToken}}

      +

      To add events to this group (whether brand new events or ones you've already made), click the 'This event is part of an event group' checkbox. You will need to copy the following two codes into the dark grey box which opens:

      +

      Event group ID: {{eventGroupID}}

      +

      Event group secret editing code: {{editToken}}

      + + + + + + +
      + + + + + + +
      Edit event group
      +
      +

      To let others know about your event group, send them this link: https://{{domain}}/{{eventGroupID}}

      +

      And that's it - have a great day!

      +

      Love,

      +

      {{siteName}}

      +
      +

      Hold up - I have no idea what this email is about!

      +

      If you didn't make an event group on {{siteName}}, someone may have accidentally typed your email instead of theirs when they were making an event. Don't worry - there isn't anything you need to do. Your email will be removed from the system when the event finishes, and if you're still worried, just click on the edit link above and delete that event group, which removes your email from the system as well.

      diff --git a/views/emails/deleteevent.handlebars b/views/emails/deleteevent.handlebars new file mode 100644 index 0000000..5a3670c --- /dev/null +++ b/views/emails/deleteevent.handlebars @@ -0,0 +1,4 @@ +

      The {{eventName}} event you're attending on {{siteName}} was just deleted by its creator.

      +
      +

      Hold up - I have no idea what this email is about!

      +

      If you didn't mark yourself as attending an event on {{siteName}}, someone may have accidentally typed your email instead of theirs. Don't worry - that event, and your email, is deleted from the system now.

      diff --git a/views/emails/editevent.handlebars b/views/emails/editevent.handlebars new file mode 100644 index 0000000..ddb9885 --- /dev/null +++ b/views/emails/editevent.handlebars @@ -0,0 +1,8 @@ +

      An event you're attending on {{siteName}} has just been edited.

      +

      {{{diffText}}}

      +

      Click here to see the event: https://{{domain}}/{{eventID}}

      +

      Love,

      +

      {{siteName}}

      +
      +

      Hold up - I have no idea what this email is about!

      +

      If you didn't mark yourself as attending an event on {{siteName}}, someone may have accidentally typed your email instead of theirs. Don't worry - there isn't anything you need to do. Your email will be removed from the system when the event finishes.

      diff --git a/views/emails/removeeventattendee.handlebars b/views/emails/removeeventattendee.handlebars new file mode 100644 index 0000000..66ca858 --- /dev/null +++ b/views/emails/removeeventattendee.handlebars @@ -0,0 +1,4 @@ +

      You have been removed from the event {{eventName}} on {{siteName}} by the organizer of the event.

      +
      +

      Hold up - I have no idea what this email is about!

      +

      If you didn't mark yourself as attending an event on {{siteName}}, someone may have accidentally typed your email instead of theirs. Don't worry - you won't receive any more of these emails for this event, and your email has been removed from the database.

      diff --git a/views/emails/unattendevent.handlebars b/views/emails/unattendevent.handlebars new file mode 100644 index 0000000..62dac8a --- /dev/null +++ b/views/emails/unattendevent.handlebars @@ -0,0 +1,8 @@ +

      You just removed yourself from an event on {{siteName}}. You will no longer receive update emails for this event.

      +

      If you didn't mean to do this, someone else who knows your email removed you from the event.

      +

      Follow this link to open the event page any time: https://{{domain}}/{{eventID}}

      +

      Love,

      +

      {{siteName}}

      +
      +

      Hold up - I have no idea what this email is about!

      +

      If you didn't mark yourself as attending an event on {{siteName}}, someone may have accidentally typed your email instead of theirs, then removed it. Don't worry - you won't receive any more emails linked to this event.

      diff --git a/views/layouts/email.handlebars b/views/layouts/email.handlebars new file mode 100644 index 0000000..6158ddb --- /dev/null +++ b/views/layouts/email.handlebars @@ -0,0 +1,126 @@ + + + + + +{{ subject }} + + + + + + + + + +
        +
      + + + + + + + + +
      + + + + +
      + {{{ body }}} +
      +
      + +
      +
       
      + + -- cgit v1.2.3 From ef5aadc56f821c31d324fd3ec29f646a331b4612 Mon Sep 17 00:00:00 2001 From: Darius Kazemi Date: Sat, 4 Jan 2020 21:59:57 -0800 Subject: Enhancements to AP and one-click removal This adds AP Delete/Event, AP Delete/Actor, and one-click removal from RSVP lists that can be included in the "you have rsvped" confirmation message. --- models/Event.js | 4 + routes.js | 332 ++++++++++++++++++++++++++++++++---------- views/event.handlebars | 2 +- views/layouts/main.handlebars | 6 +- 4 files changed, 266 insertions(+), 78 deletions(-) diff --git a/models/Event.js b/models/Event.js index 9ab455f..19505ea 100755 --- a/models/Event.js +++ b/models/Event.js @@ -220,6 +220,10 @@ const EventSchema = new mongoose.Schema({ type: String, trim: true }, + activityPubEvent: { + type: String, + trim: true + }, publicKey: { type: String, trim: true diff --git a/routes.js b/routes.js index 34dc1f8..eb17d37 100755 --- a/routes.js +++ b/routes.js @@ -117,12 +117,18 @@ const deleteOldEvents = schedule.scheduleJob('59 23 * * *', function(fireDate){ addToLog("deleteOldEvents", "error", "Image deleted for old event "+event.id); }) } - Event.remove({"_id": event._id}) - .then(response => { - addToLog("deleteOldEvents", "success", "Old event "+event.id+" deleted"); - }).catch((err) => { - addToLog("deleteOldEvents", "error", "Attempt to delete old event "+event.id+" failed with error: " + err); - }); + // broadcast a Delete profile message to all followers so that at least Mastodon servers will delete their local profile information + const guidUpdateObject = crypto.randomBytes(16).toString('hex'); + const jsonUpdateObject = JSON.parse(event.activityPubActor); + // first broadcast AP messages, THEN delete from DB + broadcastDeleteMessage(jsonUpdateObject, event.followers, event.id, function(statuses) { + Event.remove({"_id": event._id}) + .then(response => { + addToLog("deleteOldEvents", "success", "Old event "+event.id+" deleted"); + }).catch((err) => { + addToLog("deleteOldEvents", "error", "Attempt to delete old event "+event.id+" failed with error: " + err); + }); + }); }) }).catch((err) => { addToLog("deleteOldEvents", "error", "Attempt to delete old event "+event.id+" failed with error: " + err); @@ -145,6 +151,33 @@ function createWebfinger(eventID, domain) { }; } +function createActivityPubEvent(name, startUTC, endUTC, timezone) { + const guid = crypto.randomBytes(16).toString('hex'); + let eventObject = { + "@context": "https://www.w3.org/ns/activitystreams", + 'id': `https://${domain}/${guid}`, + "name": name, + "type": "Event", + "startTime": moment.tz(startUTC, timezone).format(), + "endTime": moment.tz(endUTC, timezone).format(), + } + return JSON.stringify(eventObject); +} + +function updateActivityPubEvent(oldEvent, name, startUTC, endUTC, timezone) { + // we want to persist the old ID no matter what happens to the Event itself + const id = oldEvent.id; + let eventObject = { + "@context": "https://www.w3.org/ns/activitystreams", + 'id': id, + "name": name, + "type": "Event", + "startTime": moment.tz(startUTC, timezone).format(), + "endTime": moment.tz(endUTC, timezone).format(), + } + return JSON.stringify(eventObject); +} + function createActivityPubActor(eventID, domain, pubkey, description, name, location, imageFilename, startUTC, endUTC, timezone) { let actor = { '@context': [ @@ -348,7 +381,6 @@ function broadcastUpdateMessage(apObject, followers, eventID, callback) { if (follower) { const actorJson = JSON.parse(follower.actorJson); const inbox = actorJson.inbox; - console.log('found the inbox for', actorId) const createMessage = { '@context': 'https://www.w3.org/ns/activitystreams', 'id': `https://${domain}/m/${guidUpdate}`, @@ -356,8 +388,6 @@ function broadcastUpdateMessage(apObject, followers, eventID, callback) { 'actor': `https://${domain}/${eventID}`, 'object': apObject }; - console.log('UPDATE') - console.log(JSON.stringify(createMessage)); signAndSend(createMessage, eventID, targetDomain, inbox, function(err, resp, status) { if (err) { console.log(`Didn't sent to ${actorId}, status ${status} with error ${err}`); @@ -377,6 +407,69 @@ function broadcastUpdateMessage(apObject, followers, eventID, callback) { }); } // end followers } + +function broadcastDeleteMessage(apObject, followers, eventID, callback) { + callback = callback || function() {}; + // we need to build an array of promises for each message we're sending, run Promise.all(), and then that will resolve when every message has been sent (or failed) + // per spec, each promise will execute *as it is built*, which is fine, we just need the guarantee that they are all done + let promises = []; + + let guidUpdate = crypto.randomBytes(16).toString('hex'); + console.log('building promises'); + // iterate over followers + for (const follower of followers) { + promises.push(new Promise((resolve, reject) => { + let actorId = follower.actorId; + let myURL = new URL(actorId); + let targetDomain = myURL.hostname; + // get the inbox + Event.findOne({ + id: eventID, + }, function(err, event) { + console.log('found the event for broadcast'); + if (event) { + const follower = event.followers.find(el => el.actorId === actorId); + if (follower) { + const actorJson = JSON.parse(follower.actorJson); + const inbox = actorJson.inbox; + const createMessage = { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': `https://${domain}/m/${guidUpdate}`, + 'type': 'Delete', + 'actor': `https://${domain}/${eventID}`, + 'object': apObject + }; + signAndSend(createMessage, eventID, targetDomain, inbox, function(err, resp, status) { + if (err) { + console.log(`Didn't send to ${actorId}, status ${status} with error ${err}`); + reject(`Didn't send to ${actorId}, status ${status} with error ${err}`); + } + else { + console.log('sent to', actorId); + resolve('sent to', actorId); + } + }); + } + else { + console.log(`No follower found with the id ${actorId}`, null, 404); + reject(`No follower found with the id ${actorId}`, null, 404); + } + } + else { + console.log(`No event found with the id ${eventID}`, null, 404); + reject(`No event found with the id ${eventID}`, null, 404); + } + }); + })); + } // end followers + + Promise.all(promises.map(p => p.catch(e => e))).then(statuses => { + console.log('DONE') + console.log(statuses) + callback(statuses); + }); +} + function signAndSend(message, eventID, targetDomain, inbox, callback) { let inboxFragment = inbox.replace('https://'+targetDomain,''); // get the private key @@ -448,6 +541,7 @@ router.get('/', (req, res) => { res.render('home', { domain: domain, email: contactEmail, + siteName: siteName, }); }); @@ -665,6 +759,7 @@ router.get('/:eventID', (req, res) => { eventHasConcluded: eventHasConcluded, eventHasBegun: eventHasBegun, metadata: metadata, + siteName: siteName }) } } @@ -890,6 +985,7 @@ router.post('/newevent', async (req, res) => { maxAttendees: req.body.maxAttendees, firstLoad: true, activityPubActor: createActivityPubActor(eventID, domain, pair.public, marked(req.body.eventDescription), req.body.eventName, req.body.eventLocation, eventImageFilename, req.body.startUTC, req.body.endUTC, req.body.timezone), + activityPubEvent: createActivityPubEvent(req.body.eventName, req.body.startUTC, req.body.endUTC, req.body.timezone), publicKey: pair.public, privateKey: pair.private }); @@ -1102,11 +1198,14 @@ router.post('/editevent/:eventID/:editToken', (req, res) => { usersCanComment: req.body.interactionCheckbox ? true : false, maxAttendees: req.body.maxAttendeesCheckbox ? req.body.maxAttendees : null, eventGroup: isPartOfEventGroup ? eventGroup._id : null, - activityPubActor: updateActivityPubActor(JSON.parse(event.activityPubActor), req.body.eventDescription, req.body.eventName, req.body.eventLocation, eventImageFilename, startUTC, endUTC, req.body.timezone) + activityPubActor: updateActivityPubActor(JSON.parse(event.activityPubActor), req.body.eventDescription, req.body.eventName, req.body.eventLocation, eventImageFilename, startUTC, endUTC, req.body.timezone), + activityPubEvent: updateActivityPubEvent(JSON.parse(event.activityPubEvent), req.body.eventName, req.body.startUTC, req.body.endUTC, req.body.timezone), } let diffText = '

      This event was just updated with new information.

        '; let displayDate; - // TODO: send an Update Profile message if needed? + if (event.name !== updatedEvent.name) { + diffText += `
      • the event name changed to ${updatedEvent.name}
      • `; + } if (event.location !== updatedEvent.location) { diffText += `
      • the location changed to ${updatedEvent.location}
      • `; } @@ -1150,9 +1249,11 @@ router.post('/editevent/:eventID/:editToken', (req, res) => { } broadcastMessage(jsonObject, event.followers, eventID) // also broadcast an Update profile message to all followers so that at least Mastodon servers will update the local profile information - const guidUpdateObject = crypto.randomBytes(16).toString('hex'); const jsonUpdateObject = JSON.parse(event.activityPubActor); broadcastUpdateMessage(jsonUpdateObject, event.followers, eventID) + // also broadcast an Update/Event for any calendar apps that are consuming our Events + const jsonEventObject = JSON.parse(event.activityPubEvent); + broadcastUpdateMessage(jsonEventObject, event.followers, eventID) // DM to attendees for (const attendee of attendees) { @@ -1304,58 +1405,63 @@ router.post('/deleteevent/:eventID/:editToken', (req, res) => { eventImage = event.image; } - // Send emails here otherwise they don't exist lol - if (sendEmails) { - Event.findOne({id: req.params.eventID}).distinct('attendees.email', function(error, ids) { - attendeeEmails = ids; - if (!error){ - console.log("Sending emails to: " + attendeeEmails); - req.app.get('hbsInstance').renderView('./views/emails/deleteevent.handlebars', {siteName, domain, eventName: event.name, cache: true, layout: 'email.handlebars'}, function(err, html) { - const msg = { - to: attendeeEmails, - from: { - name: siteName, - email: contactEmail, - }, - subject: `${siteName}: ${event.name} was deleted`, - html, - }; - sgMail.sendMultiple(msg).catch(e => { - console.error(e.toString()); - res.status(500).end(); - }); + // broadcast a Delete profile message to all followers so that at least Mastodon servers will delete their local profile information + const guidUpdateObject = crypto.randomBytes(16).toString('hex'); + const jsonUpdateObject = JSON.parse(event.activityPubActor); + // first broadcast AP messages, THEN delete from DB + broadcastDeleteMessage(jsonUpdateObject, event.followers, req.params.eventID, function(statuses) { + Event.deleteOne({id: req.params.eventID}, function(err, raw) { + if (err) { + res.send(err); + addToLog("deleteEvent", "error", "Attempt to delete event " + req.params.eventID + " failed with error: " + err); + } + }) + .then(() => { + // Delete image + if (eventImage){ + fs.unlink(global.appRoot + '/public/events/' + eventImage, (err) => { + if (err) { + res.send(err); + addToLog("deleteEvent", "error", "Attempt to delete event image for event " + req.params.eventID + " failed with error: " + err); + } + // Image removed + addToLog("deleteEvent", "success", "Event " + req.params.eventID + " deleted"); + }) + } + res.writeHead(302, { + 'Location': '/' }); - } - else { - console.log("Nothing to send!"); - } - }); - } - - Event.deleteOne({id: req.params.eventID}, function(err, raw) { - if (err) { - res.send(err); - addToLog("deleteEvent", "error", "Attempt to delete event " + req.params.eventID + " failed with error: " + err); - } - }) - .then(() => { - // Delete image - if (eventImage){ - fs.unlink(global.appRoot + '/public/events/' + eventImage, (err) => { - if (err) { - res.send(err); - addToLog("deleteEvent", "error", "Attempt to delete event image for event " + req.params.eventID + " failed with error: " + err); - } - // Image removed - addToLog("deleteEvent", "success", "Event " + req.params.eventID + " deleted"); - }) - } - res.writeHead(302, { - 'Location': '/' - }); - res.end(); - }) - .catch((err) => { res.send('Sorry! Something went wrong (error deleting): ' + err); addToLog("deleteEvent", "error", "Attempt to delete event " + req.params.eventID + " failed with error: " + err);}); + res.end(); + }) + .catch((err) => { res.send('Sorry! Something went wrong (error deleting): ' + err); addToLog("deleteEvent", "error", "Attempt to delete event " + req.params.eventID + " failed with error: " + err);}); + }); + // send emails here otherwise they don't exist lol + if (sendEmails) { + Event.findOne({id: req.params.eventID}).distinct('attendees.email', function(error, ids) { + attendeeEmails = ids; + if (!error){ + console.log("Sending emails to: " + attendeeEmails); + req.app.get('hbsInstance').renderView('./views/emails/deleteevent.handlebars', {siteName, domain, eventName: event.name, cache: true, layout: 'email.handlebars'}, function(err, html) { + const msg = { + to: attendeeEmails, + from: { + name: siteName, + email: contactEmail, + }, + subject: `${siteName}: ${event.name} was deleted`, + html, + }; + sgMail.sendMultiple(msg).catch(e => { + console.error(e.toString()); + res.status(500).end(); + }); + }); + } + else { + console.log("Nothing to send!"); + } + }); + } } else { // Token doesn't match @@ -1500,6 +1606,44 @@ router.post('/unattendevent/:eventID', (req, res) => { }); }); +router.get('/oneclickunattendevent/:eventID/:attendeeID', (req, res) => { + console.log(req.params.eventID, req.params.attendeeID) + Event.update( + { id: req.params.eventID }, + { $pull: { attendees: { _id: req.params.attendeeID } } } + ) + .then(response => { + console.log(response) + addToLog("oneClickUnattend", "success", "Attendee removed via one click unattend " + req.params.eventID); + if (sendEmails) { + // currently this is never called because we don't have the email address + if (req.body.attendeeEmail){ + req.app.get('hbsInstance').renderView('./views/emails/removeeventattendee.handlebars', {eventName: req.params.eventName, siteName, domain, cache: true, layout: 'email.handlebars'}, function(err, html) { const msg = { + to: req.body.attendeeEmail, + from: { + name: siteName, + email: contactEmail, + }, + subject: `${siteName}: You have been removed from an event`, + html, + }; + sgMail.send(msg).catch(e => { + console.error(e.toString()); + res.status(500).end(); + }); + }); + } + } + res.writeHead(302, { + 'Location': '/' + req.params.eventID + }); + res.end(); + }) + .catch((err) => { + res.send('Database error, please try again :('); addToLog("removeEventAttendee", "error", "Attempt to remove attendee by admin from event " + req.params.eventID + " failed with error: " + err); + }); +}); + router.post('/removeattendee/:eventID/:attendeeID', (req, res) => { Event.update( { id: req.params.eventID }, @@ -1554,6 +1698,16 @@ router.post('/post/comment/:eventID', (req, res) => { event.save() .then(() => { addToLog("addEventComment", "success", "Comment added to event " + req.params.eventID); + // broadcast an identical message to all followers, will show in their home timeline + const guidObject = crypto.randomBytes(16).toString('hex'); + const jsonObject = { + "@context": "https://www.w3.org/ns/activitystreams", + "id": `https://${domain}/m/${guidObject}`, + "name": `Comment on ${event.name}`, + "type": "Note", + "content": `

        ${req.body.commentAuthor} commented: ${req.body.commentContent}.

        See the full conversation here.

        `, + } + broadcastMessage(jsonObject, event.followers, req.params.eventID) if (sendEmails) { Event.findOne({id: req.params.eventID}).distinct('attendees.email', function(error, ids) { attendeeEmails = ids; @@ -1607,6 +1761,16 @@ router.post('/post/reply/:eventID/:commentID', (req, res) => { event.save() .then(() => { addToLog("addEventReply", "success", "Reply added to comment " + commentID + " in event " + req.params.eventID); + // broadcast an identical message to all followers, will show in their home timeline + const guidObject = crypto.randomBytes(16).toString('hex'); + const jsonObject = { + "@context": "https://www.w3.org/ns/activitystreams", + "id": `https://${domain}/m/${guidObject}`, + "name": `Comment on ${event.name}`, + "type": "Note", + "content": `

        ${req.body.replyAuthor} commented: ${req.body.replyContent}

        See the full conversation here.

        `, + } + broadcastMessage(jsonObject, event.followers, req.params.eventID) if (sendEmails) { Event.findOne({id: req.params.eventID}).distinct('attendees.email', function(error, ids) { attendeeEmails = ids; @@ -1718,16 +1882,11 @@ router.post('/activitypub/inbox', (req, res) => { const signatureBuf = new Buffer(signature_header.signature, 'base64') try { const result = verifier.verify(publicKeyBuf, signatureBuf) + processInbox(req, res); } catch(err) { return res.status(401).send('Signature could not be verified: ' + err); } - if (!result) { - return res.status(401).send('Signature could not be verified.'); - } - else { - processInbox(req, res); - } }); }); @@ -1773,6 +1932,11 @@ function processInbox(req, res) { } else { console.log('sent Accept to', req.body.actor); + // ALSO send an ActivityPub Event activity since this person is "interested" in the event, as indicated by the Follow + const jsonEventObject = JSON.parse(event.activityPubEvent); + // send direct message to user + sendDirectMessage(jsonEventObject, newFollower.actorId, event.id); + // if users can self-RSVP, send a Question to the new follower if (event.usersCanAttend) { const jsonObject = { @@ -1886,9 +2050,20 @@ function processInbox(req, res) { }; event.attendees.push(newAttendee); event.save() - .then(() => { + .then((fullEvent) => { addToLog("addEventAttendee", "success", "Attendee added to event " + req.params.eventID); - console.log('added attendee', attendeeName) + // get the new attendee with its hidden id from the full event + let fullAttendee = fullEvent.attendees.find(el => el.id === attributedTo); + // send a "click here to remove yourself" link back to the user as a DM + const jsonObject = { + "@context": "https://www.w3.org/ns/activitystreams", + "name": `RSVP to ${event.name}`, + "type": "Note", + "content": `@${newAttendee.name} Thanks for RSVPing! You can remove yourself from the RSVP list by clicking here: https://${domain}/oneclickunattendevent/${event.id}/${fullAttendee._id}`, + "tag":[{"type":"Mention","href":newAttendee.id,"name":newAttendee.name}] + } + // send direct message to user + sendDirectMessage(jsonObject, newAttendee.id, event.id); return res.sendStatus(200); }) .catch((err) => { addToLog("addEventAttendee", "error", "Attempt to add attendee to event " + req.params.eventID + " failed with error: " + err); return res.status(500).send('Database error, please try again :('); }); @@ -1933,9 +2108,9 @@ function processInbox(req, res) { } // delete the comment - // find the index of the comment + // find the index of the comment, it should have an activityJson field because from an AP server you can only delete an AP-originated comment (and of course it needs to be yours) let indexOfComment = eventWithComment.comments.findIndex(comment => { - return JSON.parse(comment.activityJson).object.id === req.body.object.id; + return comment.activityJson && JSON.parse(comment.activityJson).object.id === req.body.object.id; }); eventWithComment.comments.splice(indexOfComment, 1); eventWithComment.save() @@ -1949,7 +2124,6 @@ function processInbox(req, res) { } // if we are CC'ed on a public or unlisted Create/Note, then this is a comment to us we should replicate if (req.body && req.body.type === 'Create' && req.body.object && req.body.object.type === 'Note' && req.body.object.to) { - console.log('create note!!') // figure out what this is in reply to -- it should be addressed specifically to us let {attributedTo, inReplyTo, to, cc} = req.body.object; // normalize cc into an array @@ -2005,6 +2179,16 @@ function processInbox(req, res) { event.save() .then(() => { addToLog("addEventComment", "success", "Comment added to event " + eventID); + // broadcast an identical message to all followers, will show in their home timeline + const guidObject = crypto.randomBytes(16).toString('hex'); + const jsonObject = { + "@context": "https://www.w3.org/ns/activitystreams", + "id": `https://${domain}/m/${guidObject}`, + "name": `Comment on ${event.name}`, + "type": "Note", + "content": newComment.content, + } + broadcastMessage(jsonObject, event.followers, req.params.eventID) console.log('added comment'); res.sendStatus(200); }) diff --git a/views/event.handlebars b/views/event.handlebars index a57f62a..efd700a 100755 --- a/views/event.handlebars +++ b/views/event.handlebars @@ -134,7 +134,7 @@ {{#if eventAttendees}}
          {{#each eventAttendees}} - {{this.name}}{{#if ../editingEnabled}} {{/if}} + {{#if this.email}}{{this.name}}{{else}}{{this.name}}{{/if}}{{#if ../editingEnabled}} {{/if}} {{/each}}
        {{else}} diff --git a/views/layouts/main.handlebars b/views/layouts/main.handlebars index 3cde5d5..d7c3bf2 100755 --- a/views/layouts/main.handlebars +++ b/views/layouts/main.handlebars @@ -17,17 +17,17 @@ - + - + - {{#if title}}{{title}} · {{/if}}gathio + {{#if title}}{{title}} · {{/if}}{{siteName}} -- cgit v1.2.3 From 7fc93dbe9d4d99457a0e85c6c532112f415b7af2 Mon Sep 17 00:00:00 2001 From: Darius Kazemi Date: Sat, 4 Jan 2020 22:52:54 -0800 Subject: add federation document --- FEDERATION.md | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ routes.js | 2 -- 2 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 FEDERATION.md diff --git a/FEDERATION.md b/FEDERATION.md new file mode 100644 index 0000000..ae5163e --- /dev/null +++ b/FEDERATION.md @@ -0,0 +1,99 @@ +# Federation + +This document is meant to be a reference for all the ActivityPub federation-related behavior that gath.io has. This means: every user action that can trigger an Activity, every Object that is created and sent out and/or stored. + +## Documentation conventions + +To keep things simple, sometimes you will see things formatted like `Create/Note` or `Delete/Event` or `Undo/Follow`. The thing before the slash is the Activity, and the thing after the slash is the Object inside the Activity, in an `object` property. So these are to be read as follows: + +* `Create/Note`: a `Create` activity containing a `Note` in the `object` field +* `Delete/Event`: a `Delete` activity containing an `Event` in the `object` field +* `Undo/Follow`: an `Undo` activity containing a `Follow` in the `object` field + +This document has three main sections: + +* __Federation philosophy__ lays out the general model of how this is intended to federate +* __Inbox behavior__ lists every incoming ActivityPub activity that the server recognizes, and tells you what it does in response to that activity, including any other ActivityPub activities it sends back out. +* __Activities triggered from the web app__ tells you what circumstances on the web application cause the server to emit ActivityPub activities. (For example, when an event is updated via the web application, it lets all the ActivityPub followers know that the event has been updated.) + +## Federation philosophy + +The first-class Actor in gathio is an event. So every time an event organizer creates a page for a new event, there is a new, followable Actor on the fediverse. The idea is that humans want to follow events and get updates on important changes to the events. + +This differs from other ActivityPub-compatible software I've seen, which considers _people_ first class, and you follow an Actor representing a person and then you get updates on all their events. I think that is silly, and I like my model better. + +Also, gathio prides itself on deleting ALL data related to an event 7 days after the event is over. So we don't retain old messages once an event is deleted, and events are meant to be represented by Actors that only exist for the duration of the event plus 7 days. This is handled via thorough `Delete` messaging. + +## Inbox behavior + +This section describes how gathio responds to _incoming messages_ to its inbox. + +### Inbox structure + +Gathio has a single, universal inbox shared between all Actors. The url is: + +`https://DOMAIN/activitypub/inbox` + +You can talk to gathio by POSTing to that url as you would any ActivityPub server. + +### Follow + +When the server receives a `Follow` Activity, it grabs the `actor` property on the `Follow`, and then makes a GET request to that URI with `'Content-Type': 'application/activity+json'` (we assume that `actor` is a dereferencable uri that returns us the JSON for the `Actor`). + +Assuming we can find the `Actor ` object, then we emit an `Accept` Activity back to the server, containing the full `Follow` that we just parsed. This lets the other server know that we have fully processed the follow request. + +After this, we *also* send a `Create` Activity to the actor's inbox, containing an `Event` object with the information for this event. This is, at the moment, future compatibility for servers that consume `Event` objects. This is sent as a "direct message", directly to the inbox with no `cc` field and not addressing the public timeline. + +And finally we send the user a `Create` Activity containing a `Question` object. Mastodon renders this as a poll to the user, which lets them send back to us a "Yes" RSVP directly from their client UI should they so choose. This is also sent as a "direct message". + +### Unfollow + +When the server receives an `Undo/Follow`, it checks to see if that follower exists in the database. If it does, then it deletes the follower from the database. + +We currently do _not_ send an `Accept/Undo` in response, as I'm not sure this is ever needed in the wild. + +### RSVP (aka voting in the poll) + +If the inbox gets a `Create/Note`, there is a chance that this is a response to a `Question` that we sent a user. So the first thing we do is check its `inReplyTo` property. If it matches the id of a `Question` we sent this user, and this user is still following us, then we fetch the user's profile info. This is to make sure we have their newest `preferredName` in their Actor object, which we will honor as the name we display on the RSVP. We then add this person to our database as an attendee of the event. + +Next we confirm that the user has RSVPed. We do this by sending them a `Create/Note` via direct message. The note tells them they RSVPed, and gives them a URL they can click on to instantly un-RSVP if they need to. + +### Comment on an event + +If we are CC'ed on a _public or unlisted_ `Create/Note`, then that is considered to be a comment on the event, which we store in our database and render on the event page if the administrator has enabled commenting. + +After the comment is added and rendered on the front page, we also broadcast the comment as a `Create/Note` to all followers. It appears in their home timelines as though the event they are following posted some content for them to see. + +### Delete comment + +Since a user can comment on the event via ActivityPub, they should be able to delete their comment via ActivityPub as well. When the inbox gets a `Delete/Note` and we can match the note and its sender to a comment in our database, we delete the comment and it is no longer rendered on the event page. The comment is also deleted from its profile via sending a `Delete/Note` out to all followers (corresponding to the comment that was copied to the event actor's profile, not the Note originally made by the commenter on their own server, we don't own that!). + +### Incoming private messages + +*TODO*: If someone tries to DM the event, we need to reply with a message like "Sorry, this service only supports posting public messages to the event page. Try contacting the event organizer directly if you need to have a private conversation." + +## Activities triggered from the web app + +### Create event + +When an event is created, we create the webfinger and `Actor` object necessary for the Actor to be found. We also create an `Event` object that is stored in the database; this is later referenced and updated so we can send things out to calendar applications. + +### Update event + +When any field of an event is updated, we send a `Create/Note` out to all of the followers, containing a message that says something like "Such and such event just changed its start time to (blah), click here to see more." So this causes the message to appear in the home feed of all followers. + +We also send a direct message with a `Create/Note` to everyone who is RSVPed informing them of the same thing, since changes to the event are high priority for them to know about. + +And finally we send an `Update/Event` out with the new event details in the `Event` object, so that people's federated calendar apps can sync. + +### Delete event + +When an event is deleted by its administrator, or the event has been deleted due to it being one week after the event has ended, we send a `Delete/Actor` out to followers. This lets followers know that the event has been deleted, and their server should remove its profile from their database. (On Mastodon this results in an automatic "unfollow", which is good because we want people's follow counts to go back to normal after an event is over and has been deleted.) + +### Comment on an event + +When a comment is created via the web application, a `Create/Note` is sent to update the home timelines of all the event's followers. This way if you're following the event and someone who is not on the Fediverse makes a comment on the event, you are informed (but not direct messaged, because that would be annoying). + +### TODO: Delete comment + +When a comment that was created via the web app is deleted from the web app, it should also propagate a `Delete/Note` out to followers, which would remove that comment from the profile/timeline for the event. diff --git a/routes.js b/routes.js index eb17d37..5d2110a 100755 --- a/routes.js +++ b/routes.js @@ -2079,8 +2079,6 @@ function processInbox(req, res) { }); } if (req.body && req.body.type === 'Delete') { - // TODO: only do this if it's a delete for a Note - // figure out if we have a matching comment by id const deleteObjectId = req.body.object.id; // find all events with comments from the author Event.find({ -- cgit v1.2.3 From dcbf6c7268261639d67b0c8502cd205f815ba2fa Mon Sep 17 00:00:00 2001 From: Darius Kazemi Date: Sat, 4 Jan 2020 23:07:22 -0800 Subject: update federation doc --- FEDERATION.md | 23 ++++++++++--- routes.js | 101 +++++++++++++++++++++++++++++++++------------------------- 2 files changed, 75 insertions(+), 49 deletions(-) diff --git a/FEDERATION.md b/FEDERATION.md index ae5163e..8466414 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -10,20 +10,26 @@ To keep things simple, sometimes you will see things formatted like `Create/Note * `Delete/Event`: a `Delete` activity containing an `Event` in the `object` field * `Undo/Follow`: an `Undo` activity containing a `Follow` in the `object` field +When the word "broadcast" is used in this document, it means to send an Activity to individual inbox of each of the followers of a given Actor. + This document has three main sections: * __Federation philosophy__ lays out the general model of how this is intended to federate * __Inbox behavior__ lists every incoming ActivityPub activity that the server recognizes, and tells you what it does in response to that activity, including any other ActivityPub activities it sends back out. * __Activities triggered from the web app__ tells you what circumstances on the web application cause the server to emit ActivityPub activities. (For example, when an event is updated via the web application, it lets all the ActivityPub followers know that the event has been updated.) +Please note: there is an unfortunate collision between the English language and the ActivityPub spec that can make this document confusing. When this document uses the word 'event' with a lowercase-e and not in monospace, it refers to the thing that is being tracked in gathio: events that are being organized. When this document uses the word `Event` with a capital E and in monospace, it refers to the [`Event` object defined in the ActivityStreams Vocabulary spec](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-event). + ## Federation philosophy The first-class Actor in gathio is an event. So every time an event organizer creates a page for a new event, there is a new, followable Actor on the fediverse. The idea is that humans want to follow events and get updates on important changes to the events. -This differs from other ActivityPub-compatible software I've seen, which considers _people_ first class, and you follow an Actor representing a person and then you get updates on all their events. I think that is silly, and I like my model better. +This differs from other ActivityPub-compatible software I've seen, which considers _people_ first class, and you follow an Actor representing a person and then you get updates on all their events. I think that is silly, and I like my model better. From my perspective, the accounts of _people_ should live on people-focused services like Mastodon/Pleroma/Friendica/etc. This service is for events, and thus events are its first-class Actor. Also, gathio prides itself on deleting ALL data related to an event 7 days after the event is over. So we don't retain old messages once an event is deleted, and events are meant to be represented by Actors that only exist for the duration of the event plus 7 days. This is handled via thorough `Delete` messaging. +The point of federating this is so that people can simply follow an event and get all the updates they care about, and even RSVP to and comment on the event directly from their ActivityPub client. This is all without signing up or anything on gathio. + ## Inbox behavior This section describes how gathio responds to _incoming messages_ to its inbox. @@ -34,7 +40,7 @@ Gathio has a single, universal inbox shared between all Actors. The url is: `https://DOMAIN/activitypub/inbox` -You can talk to gathio by POSTing to that url as you would any ActivityPub server. +You can talk to gathio by POSTing to that url as you would any ActivityPub server. The `to` (or sometimes `cc` field) is what lets us know which event Actor you're interacting with. ### Follow @@ -44,7 +50,7 @@ Assuming we can find the `Actor ` object, then we emit an `Accept` Activity back After this, we *also* send a `Create` Activity to the actor's inbox, containing an `Event` object with the information for this event. This is, at the moment, future compatibility for servers that consume `Event` objects. This is sent as a "direct message", directly to the inbox with no `cc` field and not addressing the public timeline. -And finally we send the user a `Create` Activity containing a `Question` object. Mastodon renders this as a poll to the user, which lets them send back to us a "Yes" RSVP directly from their client UI should they so choose. This is also sent as a "direct message". +And finally we send the user a `Create` Activity containing a `Question` object. The `Question` is an invitation to RSVP to the event. Mastodon renders this as a poll to the user, which lets them send back to us a "Yes" RSVP directly from their client UI should they so choose. This is also sent as a "direct message". ### Unfollow @@ -52,7 +58,14 @@ When the server receives an `Undo/Follow`, it checks to see if that follower exi We currently do _not_ send an `Accept/Undo` in response, as I'm not sure this is ever needed in the wild. -### RSVP (aka voting in the poll) +### RSVP + +The plan is to have this support two ways to RSVP: + +1. The user answers the `Question` sent out to the prospective attendee in the form of a `Create/Note` in the style of Mastodon polls. This is mostly a hack for implementations like Mastodon that don't have vocabulary built in to RSVP to `Event`s. +2. (TODO) The user sends a `Accept/Event`, `Reject/Event`, or `TentativeAccept/Event` back to our server. This is for implementations that support `Event` and do things like automatically render incoming events in their UI with an RSVP interface. + +The first method is the only one implemented right now. It works as follows. If the inbox gets a `Create/Note`, there is a chance that this is a response to a `Question` that we sent a user. So the first thing we do is check its `inReplyTo` property. If it matches the id of a `Question` we sent this user, and this user is still following us, then we fetch the user's profile info. This is to make sure we have their newest `preferredName` in their Actor object, which we will honor as the name we display on the RSVP. We then add this person to our database as an attendee of the event. @@ -62,7 +75,7 @@ Next we confirm that the user has RSVPed. We do this by sending them a `Create/N If we are CC'ed on a _public or unlisted_ `Create/Note`, then that is considered to be a comment on the event, which we store in our database and render on the event page if the administrator has enabled commenting. -After the comment is added and rendered on the front page, we also broadcast the comment as a `Create/Note` to all followers. It appears in their home timelines as though the event they are following posted some content for them to see. +After the comment is added and rendered on the front page, we also broadcast to our followers an `Announce/Note`, containing a copy of the `Note` we just received. Some implementations treat this as a "boost", where people following our account, but not necessarily following the account that wrote the `Note`, will see the `Note` rendered with credit to the original author, promoted on behalf of our account. ### Delete comment diff --git a/routes.js b/routes.js index 5d2110a..920d36b 100755 --- a/routes.js +++ b/routes.js @@ -189,6 +189,7 @@ function createActivityPubActor(eventID, domain, pubkey, description, name, loca 'type': 'Person', 'preferredUsername': `${eventID}`, 'inbox': `https://${domain}/activitypub/inbox`, + 'outbox': `https://${domain}/${eventID}/outbox`, 'followers': `https://${domain}/${eventID}/followers`, 'summary': `

        ${description}

        `, 'name': name, @@ -362,6 +363,57 @@ function broadcastMessage(apObject, followers, eventID, callback) { } // end followers } +// sends an Announce for the apObject +function broadcastAnnounceMessage(apObject, followers, eventID, callback) { + callback = callback || function() {}; + let guidUpdate = crypto.randomBytes(16).toString('hex'); + console.log('broadcasting announce'); + // iterate over followers + for (const follower of followers) { + let actorId = follower.actorId; + let myURL = new URL(actorId); + let targetDomain = myURL.hostname; + // get the inbox + Event.findOne({ + id: eventID, + }, function(err, event) { + console.log('found the event for broadcast') + if (event) { + const follower = event.followers.find(el => el.actorId === actorId); + if (follower) { + const actorJson = JSON.parse(follower.actorJson); + const inbox = actorJson.inbox; + const announceMessage = { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': `https://${domain}/m/${guidUpdate}`, + 'cc': 'https://www.w3.org/ns/activitystreams#Public', + 'type': 'Announce', + 'actor': `https://${domain}/${eventID}`, + 'object': apObject, + 'to': actorId + }; + signAndSend(announceMessage, eventID, targetDomain, inbox, function(err, resp, status) { + if (err) { + console.log(`Didn't sent to ${actorId}, status ${status} with error ${err}`); + } + else { + console.log('sent to', actorId); + } + }); + } + else { + console.log(`No follower found with the id ${actorId}`); + callback(`No follower found with the id ${actorId}`, null, 404); + } + } + else { + console.log(`No event found with the id ${eventID}`); + callback(`No event found with the id ${eventID}`, null, 404); + } + }); + } // end followers +} +// sends an Update for the apObject function broadcastUpdateMessage(apObject, followers, eventID, callback) { callback = callback || function() {}; let guidUpdate = crypto.randomBytes(16).toString('hex'); @@ -2120,7 +2172,7 @@ function processInbox(req, res) { .catch((err) => { res.sendStatus(500); addToLog("deleteComment", "error", "Attempt to delete comment " + req.body.object.id + "from event " + eventWithComment.id + " failed with error: " + err);}); }); } - // if we are CC'ed on a public or unlisted Create/Note, then this is a comment to us we should replicate + // if we are CC'ed on a public or unlisted Create/Note, then this is a comment to us we should boost (Announce) to our followers if (req.body && req.body.type === 'Create' && req.body.object && req.body.object.type === 'Note' && req.body.object.to) { // figure out what this is in reply to -- it should be addressed specifically to us let {attributedTo, inReplyTo, to, cc} = req.body.object; @@ -2177,16 +2229,10 @@ function processInbox(req, res) { event.save() .then(() => { addToLog("addEventComment", "success", "Comment added to event " + eventID); - // broadcast an identical message to all followers, will show in their home timeline const guidObject = crypto.randomBytes(16).toString('hex'); - const jsonObject = { - "@context": "https://www.w3.org/ns/activitystreams", - "id": `https://${domain}/m/${guidObject}`, - "name": `Comment on ${event.name}`, - "type": "Note", - "content": newComment.content, - } - broadcastMessage(jsonObject, event.followers, req.params.eventID) + const jsonObject = req.body.object; + jsonObject.attributedTo = newComment.actorId; + broadcastAnnounceMessage(jsonObject, event.followers, eventID) console.log('added comment'); res.sendStatus(200); }) @@ -2196,40 +2242,7 @@ function processInbox(req, res) { }); } // end ourevent } // end public message - // if it's not a public message, AND it's not a vote let them know that we only support public messages right now - else if (req.body.object.name !== 'Yes') { - if (!cc) { - cc = []; - } - // figure out which event(s) of ours it was addressing - ourEvents = cc.concat(to).filter(el => el.includes(`https://${domain}/`)) - .map(el => el.replace(`https://${domain}/`,'')); - // comments should only be on one event. if more than one, ignore (spam, probably) - if (ourEvents.length === 1) { - let eventID = ourEvents[0]; - // get the user's actor info - request({ - url: req.body.actor, - headers: { - 'Accept': 'application/activity+json', - 'Content-Type': 'application/activity+json' - }}, function (error, response, actor) { - actor = JSON.parse(actor); - const name = actor.preferredUsername || actor.name || req.body.actor; - const jsonObject = { - "@context": "https://www.w3.org/ns/activitystreams", - "type": "Note", - "inReplyTo": req.body.object.id, - "content": `@${name} Sorry, this service only supports posting public messages to the event page. Try contacting the event organizer directly if you need to have a private conversation.`, - "tag":[{"type":"Mention","href":req.body.actor,"name":name}] - } - res.sendStatus(200); - sendDirectMessage(jsonObject, req.body.actor, eventID); - } - ); - } - } - } + } // CC'ed } router.use(function(req, res, next){ -- cgit v1.2.3 From 8029cfcd9221da9164d731ab3e7c20740f52fab7 Mon Sep 17 00:00:00 2001 From: Darius Kazemi Date: Mon, 6 Jan 2020 21:35:42 -0800 Subject: lots of refactoring --- FEDERATION.md | 59 +- activitypub.js | 937 ++++++++++++++++++++++++ helpers.js | 19 + routes.js | 1022 ++++----------------------- views/partials/neweventgroupform.handlebars | 2 +- 5 files changed, 1161 insertions(+), 878 deletions(-) create mode 100644 activitypub.js create mode 100644 helpers.js diff --git a/FEDERATION.md b/FEDERATION.md index 8466414..fde2d2d 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -12,9 +12,10 @@ To keep things simple, sometimes you will see things formatted like `Create/Note When the word "broadcast" is used in this document, it means to send an Activity to individual inbox of each of the followers of a given Actor. -This document has three main sections: +This document has four main sections: * __Federation philosophy__ lays out the general model of how this is intended to federate +* __General Actor information__ contains the basics of what to expect from our `Actor` objects * __Inbox behavior__ lists every incoming ActivityPub activity that the server recognizes, and tells you what it does in response to that activity, including any other ActivityPub activities it sends back out. * __Activities triggered from the web app__ tells you what circumstances on the web application cause the server to emit ActivityPub activities. (For example, when an event is updated via the web application, it lets all the ActivityPub followers know that the event has been updated.) @@ -30,6 +31,42 @@ Also, gathio prides itself on deleting ALL data related to an event 7 days after The point of federating this is so that people can simply follow an event and get all the updates they care about, and even RSVP to and comment on the event directly from their ActivityPub client. This is all without signing up or anything on gathio. +## General Actor information + +Every event has an Actor. The Actor looks like this: + +```json +{ + "@context":[ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1" + ], + "id": "https://DOMAIN/EVENTID", + "type": "Person", + "preferredUsername": "EVENTID", + "inbox": "https://DOMAIN/activitypub/inbox", + "outbox": "https://DOMAIN/EVENTID/outbox", + "followers": "https://DOMAIN/EVENTID/followers", + "summary": "

        DESCRIPTION

        \n

        Location: LOCATION.

        Starting DATETIME (human readable).

        ", + "name": "EVENTNAME", + "featured": "https://DOMAIN/EVENTID/featured", + "publicKey":{ + "id": "https://DOMAIN/EVENTID#main-key", + "owner": "https://DOMAIN/EVENTID", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nOURPUBLICKEY\n-----END PUBLIC KEY-----\n" + }, + "icon":{ + "type": "Image", + "mediaType": "image/jpg", + "url": "https://DOMAIN/events/EVENTID.jpg" + } +} +``` + +The Actor is of type "Person". This is because we choose to interpret the ActivityPub "Person" designation as any individual actor that can be followed and interacted with like a person. + +There is always a featured post `OrderedCollection` at the url "https://DOMAIN/EVENTID/featured", and it always contains the full object of a single featured post that can be retrieved at "https://DOMAIN/EVENTID/m/featuredPost". This featured post (a "pinned post" in Mastodon parlance) contains basic instructions for how to follow and interact with the event. Implementations like Mastodon will render this in the timeline, which both lets us give users a small tutorial and also means the timeline doesn't appear "blank" on first follow. + ## Inbox behavior This section describes how gathio responds to _incoming messages_ to its inbox. @@ -44,13 +81,13 @@ You can talk to gathio by POSTing to that url as you would any ActivityPub serve ### Follow -When the server receives a `Follow` Activity, it grabs the `actor` property on the `Follow`, and then makes a GET request to that URI with `'Content-Type': 'application/activity+json'` (we assume that `actor` is a dereferencable uri that returns us the JSON for the `Actor`). +When the server receives a `Follow` Activity, it grabs the `actor` property on the `Follow`, and then makes a GET request to that URI with `'Content-Type': 'application/activity+json'` (we assume that `actor` is a dereferencable uri that returns us the JSON for the Actor). -Assuming we can find the `Actor ` object, then we emit an `Accept` Activity back to the server, containing the full `Follow` that we just parsed. This lets the other server know that we have fully processed the follow request. +Assuming we can find the Actor object, then we emit an `Accept` Activity back to the server, containing the full `Follow` that we just parsed. This lets the other server know that we have fully processed the follow request. After this, we *also* send a `Create` Activity to the actor's inbox, containing an `Event` object with the information for this event. This is, at the moment, future compatibility for servers that consume `Event` objects. This is sent as a "direct message", directly to the inbox with no `cc` field and not addressing the public timeline. -And finally we send the user a `Create` Activity containing a `Question` object. The `Question` is an invitation to RSVP to the event. Mastodon renders this as a poll to the user, which lets them send back to us a "Yes" RSVP directly from their client UI should they so choose. This is also sent as a "direct message". +And finally we send the user a `Create` Activity containing a `Question` object. The `Question` is an invitation to RSVP to the event. Mastodon renders this as a poll to the user, which lets them send back to us a "Yes" RSVP directly from their client UI should they so choose. This is also sent as a "direct message". Some clients like Friendica, simply ignore `Question` objects, which is fine since the user can use built-in RSVP function of Friendica to RSVP anyway (see below). ### Unfollow @@ -63,11 +100,17 @@ We currently do _not_ send an `Accept/Undo` in response, as I'm not sure this is The plan is to have this support two ways to RSVP: 1. The user answers the `Question` sent out to the prospective attendee in the form of a `Create/Note` in the style of Mastodon polls. This is mostly a hack for implementations like Mastodon that don't have vocabulary built in to RSVP to `Event`s. -2. (TODO) The user sends a `Accept/Event`, `Reject/Event`, or `TentativeAccept/Event` back to our server. This is for implementations that support `Event` and do things like automatically render incoming events in their UI with an RSVP interface. +2. The user sends a `Accept/Event` or `Undo/Accept/Event` back to our server. This is for implementations like Friendica that support `Event` and do things like automatically render incoming events in their UI with an RSVP interface. We currently don't accept `Reject/Event` or `TentativeAccept/Event` because gathio has no concept of a "Maybe" or "No" RSVP. It probably should have that in the future, at which case we could meaningfully parse this stuff. + +__The `Question` method__ + +If the inbox gets a `Create/Note`, there is a chance that this is a response to a `Question` that we sent a user. So the first thing we do is check its `inReplyTo` property. If it matches the id of a `Question` we sent this user, and this user is still following us, then we fetch the user's profile info. This is to make sure we have their newest `preferredUsername` in their `Actor` object (falling back to `name` and then `actor`), which we will honor as the name we display on the RSVP. We then add this person to our database as an attendee of the event. -The first method is the only one implemented right now. It works as follows. +Next we confirm that the user has RSVPed. We do this by sending them a `Create/Note` via direct message. The note tells them they RSVPed, and gives them a URL they can click on to instantly un-RSVP if they need to. + +__The `Accept/Event` method__ -If the inbox gets a `Create/Note`, there is a chance that this is a response to a `Question` that we sent a user. So the first thing we do is check its `inReplyTo` property. If it matches the id of a `Question` we sent this user, and this user is still following us, then we fetch the user's profile info. This is to make sure we have their newest `preferredName` in their Actor object, which we will honor as the name we display on the RSVP. We then add this person to our database as an attendee of the event. +If the inbox gets an `Accept/Event`, then it assumes this is an affirmative RSVP from the actor who sent it. We check to see if the `id` of the `Event` matches the `id` of an `Event` that we sent ot this actor. If it does, then it must be a valid, affirmative RSVP. We then get the `preferredUsername` or `name` from the actor object, and add that actor to the database as an attendee. TODO: support either object URI or embedded object here. Next we confirm that the user has RSVPed. We do this by sending them a `Create/Note` via direct message. The note tells them they RSVPed, and gives them a URL they can click on to instantly un-RSVP if they need to. @@ -103,6 +146,8 @@ And finally we send an `Update/Event` out with the new event details in the `Eve When an event is deleted by its administrator, or the event has been deleted due to it being one week after the event has ended, we send a `Delete/Actor` out to followers. This lets followers know that the event has been deleted, and their server should remove its profile from their database. (On Mastodon this results in an automatic "unfollow", which is good because we want people's follow counts to go back to normal after an event is over and has been deleted.) +We also send a `Delete/Event` out to followers. For an application like Friendica, this removes the event from the calendar of a follower. + ### Comment on an event When a comment is created via the web application, a `Create/Note` is sent to update the home timelines of all the event's followers. This way if you're following the event and someone who is not on the Fediverse makes a comment on the event, you are informed (but not direct messaged, because that would be annoying). diff --git a/activitypub.js b/activitypub.js new file mode 100644 index 0000000..3d7fb10 --- /dev/null +++ b/activitypub.js @@ -0,0 +1,937 @@ +const domain = require('./config/domain.js').domain; +const contactEmail = require('./config/domain.js').email; +const siteName = require('./config/domain.js').sitename +const request = require('request'); +const addToLog = require('./helpers.js').addToLog; +const crypto = require('crypto'); +const shortid = require('shortid'); +var moment = require('moment-timezone'); +const mongoose = require('mongoose'); +const Event = mongoose.model('Event'); +const EventGroup = mongoose.model('EventGroup'); +var sanitizeHtml = require('sanitize-html'); + +function createActivityPubActor(eventID, domain, pubkey, description, name, location, imageFilename, startUTC, endUTC, timezone) { + let actor = { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1' + ], + + 'id': `https://${domain}/${eventID}`, + 'type': 'Person', + 'preferredUsername': `${eventID}`, + 'inbox': `https://${domain}/activitypub/inbox`, + 'outbox': `https://${domain}/${eventID}/outbox`, + 'followers': `https://${domain}/${eventID}/followers`, + 'summary': `

        ${description}

        `, + 'name': name, + 'featured': `https://${domain}/${eventID}/featured`, + + 'publicKey': { + 'id': `https://${domain}/${eventID}#main-key`, + 'owner': `https://${domain}/${eventID}`, + 'publicKeyPem': pubkey + } + }; + if (location) { + actor.summary += `

        Location: ${location}.

        ` + } + let displayDate; + if (startUTC && timezone) { + displayDate = moment.tz(startUTC, timezone).format('D MMMM YYYY h:mm a'); + actor.summary += `

        Starting ${displayDate} ${timezone}.

        `; + } + if (imageFilename) { + actor.icon = { + 'type': 'Image', + 'mediaType': 'image/jpg', + 'url': `https://${domain}/events/${imageFilename}`, + }; + } + return JSON.stringify(actor); +} + +function createActivityPubEvent(name, startUTC, endUTC, timezone, description, location) { + const guid = crypto.randomBytes(16).toString('hex'); + console.log(startUTC); + let eventObject = { + "@context": "https://www.w3.org/ns/activitystreams", + 'id': `https://${domain}/${guid}`, + "name": name, + "type": "Event", + "startTime": moment.tz(startUTC, timezone).format(), + "endTime": moment.tz(endUTC, timezone).format(), + "content": description, + "location": location + } + return JSON.stringify(eventObject); +} + +function createFeaturedPost(eventID, name, startUTC, endUTC, timezone, description, location) { + const featured = { + "@context": "https://www.w3.org/ns/activitystreams", + "id": `https://${domain}/${eventID}/m/featuredPost`, + "type": "Note", + "name": "Test", + 'cc': 'https://www.w3.org/ns/activitystreams#Public', + "content": `

        This is an event that was posted on ${siteName}. If you follow this account, you'll see updates in your timeline about the event. If your software supports polls, you should get a poll in your DMs asking if you want to RSVP. You can reply and RSVP right from there. If your software has an event calendar built in, you should get an event in your inbox that you can RSVP to like you respond to any event.

        For more information on how to interact with this, check out this link.

        `, + 'attributedTo': `https://${domain}/${eventID}`, + } + return featured; +} + +function updateActivityPubEvent(oldEvent, name, startUTC, endUTC, timezone, description, location) { + // we want to persist the old ID no matter what happens to the Event itself + const id = oldEvent.id; + let eventObject = { + "@context": "https://www.w3.org/ns/activitystreams", + 'id': id, + "name": name, + "type": "Event", + "startTime": moment.tz(startUTC, timezone).format(), + "endTime": moment.tz(endUTC, timezone).format(), + "content": description, + "location": location + } + return JSON.stringify(eventObject); +} + + +function updateActivityPubActor(actor, description, name, location, imageFilename, startUTC, endUTC, timezone) { + if (!actor) return; + actor.summary = `

        ${description}

        `; + actor.name = name; + if (location) { + actor.summary += `

        Location: ${location}.

        ` + } + let displayDate; + if (startUTC && timezone) { + displayDate = moment.tz(startUTC, timezone).format('D MMMM YYYY h:mm a'); + actor.summary += `

        Starting ${displayDate} ${timezone}.

        `; + } + if (imageFilename) { + actor.icon = { + 'type': 'Image', + 'mediaType': 'image/jpg', + 'url': `https://${domain}/events/${imageFilename}`, + }; + } + return JSON.stringify(actor); +} + +function signAndSend(message, eventID, targetDomain, inbox, callback) { + let inboxFragment = inbox.replace('https://'+targetDomain,''); + // get the private key + Event.findOne({ + id: eventID + }) + .then((event) => { + if (event) { + const privateKey = event.privateKey; + 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(privateKey); + const signature_b64 = signature.toString('base64'); + const header = `keyId="https://${domain}/${eventID}",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){ + if (error) { + console.log('Error:', error, response.body); + callback(error, null, 500); + } + else { + console.log('Response:', response.statusCode); + // Add the message to the database + const messageID = message.id; + const newMessage = { + id: message.id, + content: JSON.stringify(message) + }; + Event.findOne({ + id: eventID, + }, function(err,event) { + if (!event) return; + event.activityPubMessages.push(newMessage); + // also add the message's object if it has one + if (message.object && message.object.id) { + event.activityPubMessages.push({ + id: message.object.id, + content: JSON.stringify(message.object) + }); + } + event.save() + .then(() => { + addToLog("addActivityPubMessage", "success", "ActivityPubMessage added to event " + eventID); + console.log('successful ActivityPubMessage add'); + callback(null, message.id, 200); + }) + .catch((err) => { addToLog("addActivityPubMessage", "error", "Attempt to add ActivityPubMessage to event " + eventID + " failed with error: " + err); + console.log('error', err) + callback(err, null, 500); + }); + }) + } + }); + } + else { + callback(`No record found for ${eventID}.`, null, 404); + } + }); +} + +// this function sends something to the timeline of every follower in the followers array +// it's also an unlisted public message, meaning non-followers can see the message if they look at +// the profile but it doesn't spam federated timelines +function broadcastCreateMessage(apObject, followers, eventID, callback) { + callback = callback || function() {}; + let guidCreate = crypto.randomBytes(16).toString('hex'); + console.log('broadcasting'); + // iterate over followers + for (const follower of followers) { + let actorId = follower.actorId; + let myURL = new URL(actorId); + let targetDomain = myURL.hostname; + // get the inbox + Event.findOne({ + id: eventID, + }, function(err, event) { + console.log('found the event for broadcast') + if (event) { + const follower = event.followers.find(el => el.actorId === actorId); + if (follower) { + const actorJson = JSON.parse(follower.actorJson); + const inbox = actorJson.inbox; + console.log('found the inbox for', actorId) + const createMessage = { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': `https://${domain}/${eventID}/m/${guidCreate}`, + 'type': 'Create', + 'actor': `https://${domain}/${eventID}`, + 'to': [actorId], + 'cc': 'https://www.w3.org/ns/activitystreams#Public', + 'object': apObject + }; + signAndSend(createMessage, eventID, targetDomain, inbox, function(err, resp, status) { + if (err) { + console.log(`Didn't sent to ${actorId}, status ${status} with error ${err}`); + } + else { + console.log('sent to', actorId); + } + }); + } + else { + callback(`No follower found with the id ${actorId}`, null, 404); + } + } + else { + callback(`No event found with the id ${eventID}`, null, 404); + } + }); + } // end followers +} + + +// sends an Announce for the apObject +function broadcastAnnounceMessage(apObject, followers, eventID, callback) { + callback = callback || function() {}; + let guidUpdate = crypto.randomBytes(16).toString('hex'); + console.log('broadcasting announce'); + // iterate over followers + for (const follower of followers) { + let actorId = follower.actorId; + let myURL = new URL(actorId); + let targetDomain = myURL.hostname; + // get the inbox + Event.findOne({ + id: eventID, + }, function(err, event) { + console.log('found the event for broadcast') + if (event) { + const follower = event.followers.find(el => el.actorId === actorId); + if (follower) { + const actorJson = JSON.parse(follower.actorJson); + const inbox = actorJson.inbox; + const announceMessage = { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': `https://${domain}/${eventID}/m/${guidUpdate}`, + 'cc': 'https://www.w3.org/ns/activitystreams#Public', + 'type': 'Announce', + 'actor': `https://${domain}/${eventID}`, + 'object': apObject, + 'to': actorId + }; + signAndSend(announceMessage, eventID, targetDomain, inbox, function(err, resp, status) { + if (err) { + console.log(`Didn't sent to ${actorId}, status ${status} with error ${err}`); + } + else { + console.log('sent to', actorId); + } + }); + } + else { + console.log(`No follower found with the id ${actorId}`); + callback(`No follower found with the id ${actorId}`, null, 404); + } + } + else { + console.log(`No event found with the id ${eventID}`); + callback(`No event found with the id ${eventID}`, null, 404); + } + }); + } // end followers +} + +// sends an Update for the apObject +function broadcastUpdateMessage(apObject, followers, eventID, callback) { + callback = callback || function() {}; + let guidUpdate = crypto.randomBytes(16).toString('hex'); + console.log('broadcasting update'); + // iterate over followers + for (const follower of followers) { + let actorId = follower.actorId; + let myURL = new URL(actorId); + let targetDomain = myURL.hostname; + // get the inbox + Event.findOne({ + id: eventID, + }, function(err, event) { + console.log('found the event for broadcast') + if (event) { + const follower = event.followers.find(el => el.actorId === actorId); + if (follower) { + const actorJson = JSON.parse(follower.actorJson); + const inbox = actorJson.inbox; + const createMessage = { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': `https://${domain}/${eventID}/m/${guidUpdate}`, + 'type': 'Update', + 'actor': `https://${domain}/${eventID}`, + 'object': apObject + }; + signAndSend(createMessage, eventID, targetDomain, inbox, function(err, resp, status) { + if (err) { + console.log(`Didn't sent to ${actorId}, status ${status} with error ${err}`); + } + else { + console.log('sent to', actorId); + } + }); + } + else { + callback(`No follower found with the id ${actorId}`, null, 404); + } + } + else { + callback(`No event found with the id ${eventID}`, null, 404); + } + }); + } // end followers +} + +function broadcastDeleteMessage(apObject, followers, eventID, callback) { + callback = callback || function() {}; + // we need to build an array of promises for each message we're sending, run Promise.all(), and then that will resolve when every message has been sent (or failed) + // per spec, each promise will execute *as it is built*, which is fine, we just need the guarantee that they are all done + let promises = []; + + let guidUpdate = crypto.randomBytes(16).toString('hex'); + console.log('building promises'); + // iterate over followers + for (const follower of followers) { + promises.push(new Promise((resolve, reject) => { + let actorId = follower.actorId; + let myURL = new URL(actorId); + let targetDomain = myURL.hostname; + // get the inbox + Event.findOne({ + id: eventID, + }, function(err, event) { + console.log('found the event for broadcast'); + if (event) { + const follower = event.followers.find(el => el.actorId === actorId); + if (follower) { + const actorJson = JSON.parse(follower.actorJson); + const inbox = actorJson.inbox; + const createMessage = { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': `https://${domain}/${eventID}/m/${guidUpdate}`, + 'type': 'Delete', + 'actor': `https://${domain}/${eventID}`, + 'object': apObject + }; + signAndSend(createMessage, eventID, targetDomain, inbox, function(err, resp, status) { + if (err) { + console.log(`Didn't send to ${actorId}, status ${status} with error ${err}`); + reject(`Didn't send to ${actorId}, status ${status} with error ${err}`); + } + else { + console.log('sent to', actorId); + resolve('sent to', actorId); + } + }); + } + else { + console.log(`No follower found with the id ${actorId}`, null, 404); + reject(`No follower found with the id ${actorId}`, null, 404); + } + } + else { + console.log(`No event found with the id ${eventID}`, null, 404); + reject(`No event found with the id ${eventID}`, null, 404); + } + }); + })); + } // end followers + + Promise.all(promises.map(p => p.catch(e => e))).then(statuses => { + console.log('DONE') + console.log(statuses) + callback(statuses); + }); +} + +// this sends a message "to:" an individual fediverse user +function sendDirectMessage(apObject, actorId, eventID, callback) { + callback = callback || function() {}; + const guidCreate = crypto.randomBytes(16).toString('hex'); + const guidObject = crypto.randomBytes(16).toString('hex'); + let d = new Date(); + + apObject.published = d.toISOString(); + apObject.attributedTo = `https://${domain}/${eventID}`; + apObject.to = actorId; + apObject.id = `https://${domain}/${eventID}/m/${guidObject}`; + apObject.content = unescape(apObject.content) + + let createMessage = { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': `https://${domain}/${eventID}/m/${guidCreate}`, + 'type': 'Create', + 'actor': `https://${domain}/${eventID}`, + 'to': [actorId], + 'object': apObject + }; + + let myURL = new URL(actorId); + let targetDomain = myURL.hostname; + // get the inbox + Event.findOne({ + id: eventID, + }, function(err, event) { + if (event) { + const follower = event.followers.find(el => el.actorId === actorId); + if (follower) { + const actorJson = JSON.parse(follower.actorJson); + const inbox = actorJson.inbox; + signAndSend(createMessage, eventID, targetDomain, inbox, callback); + } + else { + callback(`No follower found with the id ${actorId}`, null, 404); + } + } + else { + callback(`No event found with the id ${eventID}`, null, 404); + } + }); +} + +function sendAcceptMessage(thebody, eventID, targetDomain, callback) { + callback = callback || function() {}; + const guid = crypto.randomBytes(16).toString('hex'); + const actorId = thebody.actor; + let message = { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': `https://${domain}/${guid}`, + 'type': 'Accept', + 'actor': `https://${domain}/${eventID}`, + 'object': thebody, + }; + // get the inbox + Event.findOne({ + id: eventID, + }, function(err, event) { + if (event) { + const follower = event.followers.find(el => el.actorId === actorId); + if (follower) { + const actorJson = JSON.parse(follower.actorJson); + const inbox = actorJson.inbox; + signAndSend(message, eventID, targetDomain, inbox, callback); + } + } + else { + callback(`Could not find event ${eventID}`, null, 404); + } + }); +} + +function _handleFollow(req, res) { + const myURL = new URL(req.body.actor); + let targetDomain = myURL.hostname; + let eventID = req.body.object.replace(`https://${domain}/`,''); + // Add the user to the DB of accounts that follow the account + // get the follower's username + request({ + url: req.body.actor, + headers: { + 'Accept': 'application/activity+json', + 'Content-Type': 'application/activity+json' + }}, function (error, response, body) { + body = JSON.parse(body) + const name = body.preferredUsername || body.name || body.attributedTo; + const newFollower = { + actorId: req.body.actor, + followId: req.body.id, + name: name, + actorJson: JSON.stringify(body) + }; + Event.findOne({ + id: eventID, + }, function(err,event) { + // if this account is NOT already in our followers list, add it + if (event && !event.followers.map(el => el.actorId).includes(req.body.actor)) { + console.log('made it!') + event.followers.push(newFollower); + event.save() + .then(() => { + addToLog("addEventFollower", "success", "Follower added to event " + eventID); + console.log('successful follower add'); + // Accept the follow request + sendAcceptMessage(req.body, eventID, targetDomain, function(err, resp, status) { + if (err) { + console.log(`Didn't send Accept to ${req.body.actor}, status ${status} with error ${err}`); + } + else { + console.log('sent Accept to', req.body.actor); + // ALSO send an ActivityPub Event activity since this person is "interested" in the event, as indicated by the Follow + const jsonEventObject = JSON.parse(event.activityPubEvent); + // send direct message to user + sendDirectMessage(jsonEventObject, newFollower.actorId, event.id); + + // if users can self-RSVP, send a Question to the new follower + if (event.usersCanAttend) { + const jsonObject = { + "@context": "https://www.w3.org/ns/activitystreams", + "name": `RSVP to ${event.name}`, + "type": "Question", + "content": `@${name} Will you attend ${event.name}? (If you reply "Yes", you'll be listed as an attendee on the event page.)`, + "oneOf": [ + {"type":"Note","name": "Yes"}, + {"type":"Note","name": "No"}, + {"type":"Note","name": "Maybe"} + ], + "endTime":event.start.toISOString(), + "tag":[{"type":"Mention","href":req.body.actor,"name":name}] + } + // send direct message to user + sendDirectMessage(jsonObject, req.body.actor, eventID, function (error, response, statuscode) { + if (error) { + console.log(error); + return res.status(statuscode).json(error); + } + else { + return res.status(statuscode).json({messageid: response}); + } + }); + } + } + }); + }) + .catch((err) => { + addToLog("addEventFollower", "error", "Attempt to add follower to event " + eventID + " failed with error: " + err); + return res.status(500).send('Database error, please try again :('); + }); + } + else { + // this person is already a follower so just say "ok" + return res.status(200); + } + }) + }) //end request +} + +function _handleUndoFollow(req, res) { + // get the record of all followers for this account + const eventID = req.body.object.object.replace(`https://${domain}/`,''); + Event.findOne({ + id: eventID, + }, function(err,event) { + if (!event) return; + // check to see if the Follow object's id matches the id we have on record + // is this even someone who follows us + const indexOfFollower = event.followers.findIndex(el => el.actorId === req.body.object.actor); + if (indexOfFollower !== -1) { + // does the id we have match the id we are being given + if (event.followers[indexOfFollower].followId === req.body.object.id) { + // we have a match and can trust the Undo! remove this person from the followers list + event.followers.splice(indexOfFollower, 1); + event.save() + .then(() => { + addToLog("removeEventFollower", "success", "Follower removed from event " + eventID); + return res.sendStatus(200); + }) + .catch((err) => { + addToLog("removeEventFollower", "error", "Attempt to remove follower from event " + eventID + " failed with error: " + err); + return res.send('Database error, please try again :('); + }); + } + } + }); +} + +function _handleAcceptEvent(req, res) { + let {name, attributedTo, inReplyTo, to, actor} = req.body; + if (Array.isArray(to)) { + to = to[0]; + } + const eventID = to.replace(`https://${domain}/`,''); + Event.findOne({ + id: eventID, + }, function(err,event) { + if (!event) return; + // does the id we got match the id of a thing we sent out + const message = event.activityPubMessages.find(el => el.id === req.body.object); + if (message) { + // it's a match + request({ + url: actor, + headers: { + 'Accept': 'application/activity+json', + 'Content-Type': 'application/activity+json' + }}, function (error, response, body) { + body = JSON.parse(body) + // if this account is NOT already in our attendees list, add it + if (!event.attendees.map(el => el.id).includes(actor)) { + const attendeeName = body.preferredUsername || body.name || actor; + const newAttendee = { + name: attendeeName, + status: 'attending', + id: actor + }; + event.attendees.push(newAttendee); + event.save() + .then((fullEvent) => { + addToLog("addEventAttendee", "success", "Attendee added to event " + req.params.eventID); + // get the new attendee with its hidden id from the full event + let fullAttendee = fullEvent.attendees.find(el => el.id === actor); + // send a "click here to remove yourself" link back to the user as a DM + const jsonObject = { + "@context": "https://www.w3.org/ns/activitystreams", + "name": `RSVP to ${event.name}`, + "type": "Note", + "content": `@${newAttendee.name} Thanks for RSVPing! You can remove yourself from the RSVP list by clicking here: https://${domain}/oneclickunattendevent/${event.id}/${fullAttendee._id}`, + "tag":[{"type":"Mention","href":newAttendee.id,"name":newAttendee.name}] + } + // send direct message to user + sendDirectMessage(jsonObject, newAttendee.id, event.id); + return res.sendStatus(200); + }) + .catch((err) => { + addToLog("addEventAttendee", "error", "Attempt to add attendee to event " + req.params.eventID + " failed with error: " + err); + return res.status(500).send('Database error, please try again :('); + }); + } + else { + // it's a duplicate and this person is already rsvped so just say OK + return res.status(200).send("Attendee is already registered."); + } + }); + } + }); +} + +function _handleUndoAcceptEvent(req, res) { + let {name, attributedTo, inReplyTo, to, actor} = req.body; + if (Array.isArray(to)) { + to = to[0]; + } + const eventID = to.replace(`https://${domain}/`,''); + console.log(eventID) + Event.findOne({ + id: eventID, + }, function(err,event) { + if (!event) return; + // does the id we got match the id of a thing we sent out + console.log('EVENT MESSAGES') + console.log(event.activityPubMessages); + const message = event.activityPubMessages.find(el => el.id === req.body.object.object); + if (message) { + // it's a match + console.log('match!!!!') + Event.update( + { id: eventID }, + { $pull: { attendees: { id: actor } } } + ) + .then(response => { + console.log(response) + addToLog("oneClickUnattend", "success", "Attendee removed via one click unattend " + req.params.eventID); + }); + } + }); +} + +function _handleCreateNote(req, res) { + console.log('create note inreplyto!!!') + // figure out what this is in reply to -- it should be addressed specifically to us + let {name, attributedTo, inReplyTo, to} = req.body.object; + // if it's an array just grab the first element, since a poll should only broadcast back to the pollster + if (Array.isArray(to)) { + to = to[0]; + } + const eventID = to.replace(`https://${domain}/`,''); + // make sure this person is actually a follower + Event.findOne({ + id: eventID, + }, function(err,event) { + if (!event) return; + // is this even someone who follows us + const indexOfFollower = event.followers.findIndex(el => el.actorId === req.body.object.attributedTo); + if (indexOfFollower !== -1) { + console.log('this person does follow us!') + // compare the inReplyTo to its stored message, if it exists and it's going to the right follower then this is a valid reply + const message = event.activityPubMessages.find(el => { + const content = JSON.parse(el.content); + return inReplyTo === (content.object && content.object.id); + }); + if (message) { + console.log(message); + const content = JSON.parse(message.content); + // check if the message we sent out was sent to the actor this incoming message is attributedTo + if (content.to[0] === attributedTo) { + // it's a match, this is a valid poll response, add RSVP to database + // fetch the profile information of the user + request({ + url: attributedTo, + headers: { + 'Accept': 'application/activity+json', + 'Content-Type': 'application/activity+json' + }}, function (error, response, body) { + body = JSON.parse(body) + // if this account is NOT already in our attendees list, add it + if (!event.attendees.map(el => el.id).includes(attributedTo)) { + const attendeeName = body.preferredUsername || body.name || attributedTo; + const newAttendee = { + name: attendeeName, + status: 'attending', + id: attributedTo + }; + event.attendees.push(newAttendee); + event.save() + .then((fullEvent) => { + addToLog("addEventAttendee", "success", "Attendee added to event " + req.params.eventID); + // get the new attendee with its hidden id from the full event + let fullAttendee = fullEvent.attendees.find(el => el.id === attributedTo); + // send a "click here to remove yourself" link back to the user as a DM + const jsonObject = { + "@context": "https://www.w3.org/ns/activitystreams", + "name": `RSVP to ${event.name}`, + "type": "Note", + "content": `@${newAttendee.name} Thanks for RSVPing! You can remove yourself from the RSVP list by clicking here: https://${domain}/oneclickunattendevent/${event.id}/${fullAttendee._id}`, + "tag":[{"type":"Mention","href":newAttendee.id,"name":newAttendee.name}] + } + // send direct message to user + sendDirectMessage(jsonObject, newAttendee.id, event.id); + return res.sendStatus(200); + }) + .catch((err) => { + addToLog("addEventAttendee", "error", "Attempt to add attendee to event " + req.params.eventID + " failed with error: " + err); + return res.status(500).send('Database error, please try again :('); + }); + } + else { + // it's a duplicate and this person is already rsvped so just say OK + return res.status(200).send("Attendee is already registered."); + } + }); + } + } + } + }); +} + +function _handleDelete(req, res) { + const deleteObjectId = req.body.object.id; + // find all events with comments from the author + Event.find({ + "comments.actorId":req.body.actor + }, function(err,events) { + if (!events) { + return res.sendStatus(404); + } + + // find the event with THIS comment from the author + let eventWithComment = events.find(event => { + let comments = event.comments; + return comments.find(comment => { + if (!comment.activityJson) { + return false; + } + return JSON.parse(comment.activityJson).object.id === req.body.object.id; + }) + }); + + if (!eventWithComment) { + return res.sendStatus(404); + } + + // delete the comment + // find the index of the comment, it should have an activityJson field because from an AP server you can only delete an AP-originated comment (and of course it needs to be yours) + let indexOfComment = eventWithComment.comments.findIndex(comment => { + return comment.activityJson && JSON.parse(comment.activityJson).object.id === req.body.object.id; + }); + eventWithComment.comments.splice(indexOfComment, 1); + eventWithComment.save() + .then(() => { + addToLog("deleteComment", "success", "Comment deleted from event " + eventWithComment.id); + console.log('deleted comment!') + return res.sendStatus(200); + }) + .catch((err) => { + addToLog("deleteComment", "error", "Attempt to delete comment " + req.body.object.id + "from event " + eventWithComment.id + " failed with error: " + err); + return res.sendStatus(500); + }); + }); +} + +function _handleCreateNoteComment(req, res) { + // figure out what this is in reply to -- it should be addressed specifically to us + let {attributedTo, inReplyTo, to, cc} = req.body.object; + // normalize cc into an array + if (typeof cc === 'string') { + cc = [cc]; + } + // normalize to into an array + if (typeof to === 'string') { + to = [to]; + } + + // if this is a public message (in the to or cc fields) + if (to.includes('https://www.w3.org/ns/activitystreams#Public') || (Array.isArray(cc) && cc.includes('https://www.w3.org/ns/activitystreams#Public'))) { + // figure out which event(s) of ours it was addressing + let ourEvents = cc.filter(el => el.includes(`https://${domain}/`)) + .map(el => el.replace(`https://${domain}/`,'')); + // comments should only be on one event. if more than one, ignore (spam, probably) + if (ourEvents.length === 1) { + let eventID = ourEvents[0]; + // add comment + let commentID = shortid.generate(); + // get the actor for the commenter + request({ + url: req.body.actor, + headers: { + 'Accept': 'application/activity+json', + 'Content-Type': 'application/activity+json' + }}, function (error, response, actor) { + if (!error) { + const parsedActor = JSON.parse(actor); + const name = parsedActor.preferredUsername || parsedActor.name || req.body.actor; + const newComment = { + id: commentID, + actorId: req.body.actor, + activityId: req.body.object.id, + author: name, + content: sanitizeHtml(req.body.object.content, {allowedTags: [], allowedAttributes: {}}).replace('@'+eventID,''), + timestamp: moment(), + activityJson: JSON.stringify(req.body), + actorJson: actor + }; + + Event.findOne({ + id: eventID, + }, function(err,event) { + if (!event) { + return res.sendStatus(404); + } + if (!event.usersCanComment) { + return res.sendStatus(200); + } + event.comments.push(newComment); + event.save() + .then(() => { + addToLog("addEventComment", "success", "Comment added to event " + eventID); + const guidObject = crypto.randomBytes(16).toString('hex'); + const jsonObject = req.body.object; + jsonObject.attributedTo = newComment.actorId; + broadcastAnnounceMessage(jsonObject, event.followers, eventID) + console.log('added comment'); + return res.sendStatus(200); + }) + .catch((err) => { + addToLog("addEventComment", "error", "Attempt to add comment to event " + eventID + " failed with error: " + err); console.log('error', err) + res.status(500).send('Database error, please try again :(' + err); + }); + }); + } + }); + } // end ourevent + } // end public message +} + +function processInbox(req, res) { + console.log('PROCESS INBOX') + console.log(req.body, req.body.type); + try { + if (req.body.object) console.log('containing object of type', req.body.object.type); + // if a Follow activity hits the inbox + if (typeof req.body.object === 'string' && req.body.type === 'Follow') { + _handleFollow(req, res); + } + // if an Undo activity with a Follow object hits the inbox + if (req.body && req.body.type === 'Undo' && req.body.object && req.body.object.type === 'Follow') { + _handleUndoFollow(req, res); + } + // if an Accept activity with the id of the Event we sent out hits the inbox, it is an affirmative RSVP + if (req.body && req.body.type === 'Accept' && req.body.object && typeof req.body.object === 'string') { + _handleAcceptEvent(req, res); + } + // if an Undo activity containing an Accept containing the id of the Event we sent out hits the inbox, it is an undo RSVP + if (req.body && req.body.type === 'Undo' && req.body.object && req.body.object.object && typeof req.body.object.object === 'string' && req.body.object.type !== 'Follow') { + _handleUndoAcceptEvent(req, res); + } + // if a Create activity with a Note object hits the inbox, and it's a reply, it might be a vote in a poll + if (req.body && req.body.type === 'Create' && req.body.object && req.body.object.type === 'Note' && req.body.object.inReplyTo && req.body.object.to) { + _handleCreateNote(req, res); + } + // if a Delete activity hits the inbox, it might a deletion of a comment + if (req.body && req.body.type === 'Delete') { + _handleDelete(req, res); + } + // if we are CC'ed on a public or unlisted Create/Note, then this is a comment to us we should boost (Announce) to our followers + if (req.body && req.body.type === 'Create' && req.body.object && req.body.object.type === 'Note' && req.body.object.to) { + _handleCreateNoteComment(req, res); + } // CC'ed + } + catch(e) { + console.log('Error:', e) + } +} + +module.exports = { + processInbox, + sendAcceptMessage, + sendDirectMessage, + broadcastAnnounceMessage, + broadcastUpdateMessage, + broadcastDeleteMessage, + broadcastCreateMessage, + signAndSend, + createActivityPubActor, + updateActivityPubActor, + createActivityPubEvent, + updateActivityPubEvent, + createFeaturedPost, +} diff --git a/helpers.js b/helpers.js new file mode 100644 index 0000000..629f2d0 --- /dev/null +++ b/helpers.js @@ -0,0 +1,19 @@ +const mongoose = require('mongoose'); +const Log = mongoose.model('Log'); +var moment = require('moment-timezone'); + +// LOGGING + +function addToLog(process, status, message) { + let logEntry = new Log({ + status: status, + process: process, + message: message, + timestamp: moment() + }); + logEntry.save().catch(() => { console.log("Error saving log entry!") }); +} + +module.exports = { + addToLog +} diff --git a/routes.js b/routes.js index 920d36b..203134c 100755 --- a/routes.js +++ b/routes.js @@ -14,7 +14,7 @@ const router = express.Router(); const Event = mongoose.model('Event'); const EventGroup = mongoose.model('EventGroup'); -const Log = mongoose.model('Log'); +const addToLog = require('./helpers.js').addToLog; var moment = require('moment-timezone'); @@ -27,47 +27,47 @@ const request = require('request'); const domain = require('./config/domain.js').domain; const contactEmail = require('./config/domain.js').email; const siteName = require('./config/domain.js').sitename -var sanitizeHtml = require('sanitize-html'); +const ap = require('./activitypub.js'); // Extra marked renderer (used to render plaintext event description for page metadata) // Adapted from https://dustinpfister.github.io/2017/11/19/nodejs-marked/ // ? to ? helper -htmlEscapeToText = function (text) { - return text.replace(/\&\#[0-9]*;|&/g, function (escapeCode) { - if (escapeCode.match(/amp/)) { - return '&'; - } - return String.fromCharCode(escapeCode.match(/[0-9]+/)); - }); +function htmlEscapeToText (text) { + return text.replace(/\&\#[0-9]*;|&/g, function (escapeCode) { + if (escapeCode.match(/amp/)) { + return '&'; + } + return String.fromCharCode(escapeCode.match(/[0-9]+/)); + }); } -render_plain = function () { - var render = new marked.Renderer(); - // render just the text of a link, strong, em - render.link = function (href, title, text) { - return text; - }; - render.strong = function(text) { - return text; - } - render.em = function(text) { - return text; - } - // render just the text of a paragraph - render.paragraph = function (text) { - return htmlEscapeToText(text)+'\r\n'; - }; - // render nothing for headings, images, and br - render.heading = function (text, level) { - return ''; - }; - render.image = function (href, title, text) { - return ''; - }; +function render_plain () { + var render = new marked.Renderer(); + // render just the text of a link, strong, em + render.link = function (href, title, text) { + return text; + }; + render.strong = function(text) { + return text; + } + render.em = function(text) { + return text; + } + // render just the text of a paragraph + render.paragraph = function (text) { + return htmlEscapeToText(text)+'\r\n'; + }; + // render nothing for headings, images, and br + render.heading = function (text, level) { + return ''; + }; + render.image = function (href, title, text) { + return ''; + }; render.br = function () { - return ''; + return ''; }; - return render; + return render; } const ical = require('ical'); @@ -86,17 +86,6 @@ const fileUpload = require('express-fileupload'); var Jimp = require('jimp'); router.use(fileUpload()); -// LOGGING - -function addToLog(process, status, message) { - let logEntry = new Log({ - status: status, - process: process, - message: message, - timestamp: moment() - }); - logEntry.save().catch(() => { console.log("Error saving log entry!") }); -} // SCHEDULED DELETION @@ -120,13 +109,16 @@ const deleteOldEvents = schedule.scheduleJob('59 23 * * *', function(fireDate){ // broadcast a Delete profile message to all followers so that at least Mastodon servers will delete their local profile information const guidUpdateObject = crypto.randomBytes(16).toString('hex'); const jsonUpdateObject = JSON.parse(event.activityPubActor); + const jsonEventObject = JSON.parse(event.activityPubEvent); // first broadcast AP messages, THEN delete from DB - broadcastDeleteMessage(jsonUpdateObject, event.followers, event.id, function(statuses) { - Event.remove({"_id": event._id}) - .then(response => { - addToLog("deleteOldEvents", "success", "Old event "+event.id+" deleted"); - }).catch((err) => { - addToLog("deleteOldEvents", "error", "Attempt to delete old event "+event.id+" failed with error: " + err); + ap.broadcastDeleteMessage(jsonUpdateObject, event.followers, event.id, function(statuses) { + ap.broadcastDeleteMessage(jsonEventObject, event.followers, event.id, function(statuses) { + Event.remove({"_id": event._id}) + .then(response => { + addToLog("deleteOldEvents", "success", "Old event "+event.id+" deleted"); + }).catch((err) => { + addToLog("deleteOldEvents", "error", "Attempt to delete old event "+event.id+" failed with error: " + err); + }); }); }); }) @@ -151,441 +143,7 @@ function createWebfinger(eventID, domain) { }; } -function createActivityPubEvent(name, startUTC, endUTC, timezone) { - const guid = crypto.randomBytes(16).toString('hex'); - let eventObject = { - "@context": "https://www.w3.org/ns/activitystreams", - 'id': `https://${domain}/${guid}`, - "name": name, - "type": "Event", - "startTime": moment.tz(startUTC, timezone).format(), - "endTime": moment.tz(endUTC, timezone).format(), - } - return JSON.stringify(eventObject); -} - -function updateActivityPubEvent(oldEvent, name, startUTC, endUTC, timezone) { - // we want to persist the old ID no matter what happens to the Event itself - const id = oldEvent.id; - let eventObject = { - "@context": "https://www.w3.org/ns/activitystreams", - 'id': id, - "name": name, - "type": "Event", - "startTime": moment.tz(startUTC, timezone).format(), - "endTime": moment.tz(endUTC, timezone).format(), - } - return JSON.stringify(eventObject); -} - -function createActivityPubActor(eventID, domain, pubkey, description, name, location, imageFilename, startUTC, endUTC, timezone) { - let actor = { - '@context': [ - 'https://www.w3.org/ns/activitystreams', - 'https://w3id.org/security/v1' - ], - - 'id': `https://${domain}/${eventID}`, - 'type': 'Person', - 'preferredUsername': `${eventID}`, - 'inbox': `https://${domain}/activitypub/inbox`, - 'outbox': `https://${domain}/${eventID}/outbox`, - 'followers': `https://${domain}/${eventID}/followers`, - 'summary': `

        ${description}

        `, - 'name': name, - - 'publicKey': { - 'id': `https://${domain}/${eventID}#main-key`, - 'owner': `https://${domain}/${eventID}`, - 'publicKeyPem': pubkey - } - }; - if (location) { - actor.summary += `

        Location: ${location}.

        ` - } - let displayDate; - if (startUTC && timezone) { - displayDate = moment.tz(startUTC, timezone).format('D MMMM YYYY h:mm a'); - actor.summary += `

        Starting ${displayDate}.

        `; - } - if (imageFilename) { - actor.icon = { - 'type': 'Image', - 'mediaType': 'image/jpg', - 'url': `https://${domain}/events/${imageFilename}`, - }; - } - return JSON.stringify(actor); -} - -function updateActivityPubActor(actor, description, name, location, imageFilename, startUTC, endUTC, timezone) { - if (!actor) return; - actor.summary = `

        ${description}

        `; - actor.name = name; - if (location) { - actor.summary += `

        Location: ${location}.

        ` - } - let displayDate; - if (startUTC && timezone) { - displayDate = moment.tz(startUTC, timezone).format('D MMMM YYYY h:mm a'); - actor.summary += `

        Starting ${displayDate}.

        `; - } - if (imageFilename) { - actor.icon = { - 'type': 'Image', - 'mediaType': 'image/jpg', - 'url': `https://${domain}/events/${imageFilename}`, - }; - } - return JSON.stringify(actor); -} - -function sendAcceptMessage(thebody, eventID, targetDomain, callback) { - callback = callback || function() {}; - const guid = crypto.randomBytes(16).toString('hex'); - const actorId = thebody.actor; - let message = { - '@context': 'https://www.w3.org/ns/activitystreams', - 'id': `https://${domain}/${guid}`, - 'type': 'Accept', - 'actor': `https://${domain}/${eventID}`, - 'object': thebody, - }; - // get the inbox - Event.findOne({ - id: eventID, - }, function(err, event) { - if (event) { - const follower = event.followers.find(el => el.actorId === actorId); - if (follower) { - const actorJson = JSON.parse(follower.actorJson); - const inbox = actorJson.inbox; - signAndSend(message, eventID, targetDomain, inbox, callback); - } - } - else { - callback(`Could not find event ${eventID}`, null, 404); - } - }); -} - -// this sends a message "to:" an individual fediverse user -function sendDirectMessage(apObject, actorId, eventID, callback) { - callback = callback || function() {}; - const guidCreate = crypto.randomBytes(16).toString('hex'); - const guidObject = crypto.randomBytes(16).toString('hex'); - let d = new Date(); - - apObject.published = d.toISOString(); - apObject.attributedTo = `https://${domain}/${eventID}`; - apObject.to = actorId; - apObject.id = `https://${domain}/m/${guidObject}`; - apObject.content = unescape(apObject.content) - - let createMessage = { - '@context': 'https://www.w3.org/ns/activitystreams', - 'id': `https://${domain}/m/${guidCreate}`, - 'type': 'Create', - 'actor': `https://${domain}/${eventID}`, - 'to': [actorId], - 'object': apObject - }; - - let myURL = new URL(actorId); - let targetDomain = myURL.hostname; - // get the inbox - Event.findOne({ - id: eventID, - }, function(err, event) { - if (event) { - const follower = event.followers.find(el => el.actorId === actorId); - if (follower) { - const actorJson = JSON.parse(follower.actorJson); - const inbox = actorJson.inbox; - signAndSend(createMessage, eventID, targetDomain, inbox, callback); - } - else { - callback(`No follower found with the id ${actorId}`, null, 404); - } - } - else { - callback(`No event found with the id ${eventID}`, null, 404); - } - }); -} - -// this function sends something to the timeline of every follower in the followers array -function broadcastMessage(apObject, followers, eventID, callback) { - callback = callback || function() {}; - let guidCreate = crypto.randomBytes(16).toString('hex'); - console.log('broadcasting'); - // iterate over followers - for (const follower of followers) { - let actorId = follower.actorId; - let myURL = new URL(actorId); - let targetDomain = myURL.hostname; - // get the inbox - Event.findOne({ - id: eventID, - }, function(err, event) { - console.log('found the event for broadcast') - if (event) { - const follower = event.followers.find(el => el.actorId === actorId); - if (follower) { - const actorJson = JSON.parse(follower.actorJson); - const inbox = actorJson.inbox; - console.log('found the inbox for', actorId) - const createMessage = { - '@context': 'https://www.w3.org/ns/activitystreams', - 'id': `https://${domain}/m/${guidCreate}`, - 'type': 'Create', - 'actor': `https://${domain}/${eventID}`, - 'to': [actorId], - 'object': apObject - }; - signAndSend(createMessage, eventID, targetDomain, inbox, function(err, resp, status) { - if (err) { - console.log(`Didn't sent to ${actorId}, status ${status} with error ${err}`); - } - else { - console.log('sent to', actorId); - } - }); - } - else { - callback(`No follower found with the id ${actorId}`, null, 404); - } - } - else { - callback(`No event found with the id ${eventID}`, null, 404); - } - }); - } // end followers -} -// sends an Announce for the apObject -function broadcastAnnounceMessage(apObject, followers, eventID, callback) { - callback = callback || function() {}; - let guidUpdate = crypto.randomBytes(16).toString('hex'); - console.log('broadcasting announce'); - // iterate over followers - for (const follower of followers) { - let actorId = follower.actorId; - let myURL = new URL(actorId); - let targetDomain = myURL.hostname; - // get the inbox - Event.findOne({ - id: eventID, - }, function(err, event) { - console.log('found the event for broadcast') - if (event) { - const follower = event.followers.find(el => el.actorId === actorId); - if (follower) { - const actorJson = JSON.parse(follower.actorJson); - const inbox = actorJson.inbox; - const announceMessage = { - '@context': 'https://www.w3.org/ns/activitystreams', - 'id': `https://${domain}/m/${guidUpdate}`, - 'cc': 'https://www.w3.org/ns/activitystreams#Public', - 'type': 'Announce', - 'actor': `https://${domain}/${eventID}`, - 'object': apObject, - 'to': actorId - }; - signAndSend(announceMessage, eventID, targetDomain, inbox, function(err, resp, status) { - if (err) { - console.log(`Didn't sent to ${actorId}, status ${status} with error ${err}`); - } - else { - console.log('sent to', actorId); - } - }); - } - else { - console.log(`No follower found with the id ${actorId}`); - callback(`No follower found with the id ${actorId}`, null, 404); - } - } - else { - console.log(`No event found with the id ${eventID}`); - callback(`No event found with the id ${eventID}`, null, 404); - } - }); - } // end followers -} -// sends an Update for the apObject -function broadcastUpdateMessage(apObject, followers, eventID, callback) { - callback = callback || function() {}; - let guidUpdate = crypto.randomBytes(16).toString('hex'); - console.log('broadcasting update'); - // iterate over followers - for (const follower of followers) { - let actorId = follower.actorId; - let myURL = new URL(actorId); - let targetDomain = myURL.hostname; - // get the inbox - Event.findOne({ - id: eventID, - }, function(err, event) { - console.log('found the event for broadcast') - if (event) { - const follower = event.followers.find(el => el.actorId === actorId); - if (follower) { - const actorJson = JSON.parse(follower.actorJson); - const inbox = actorJson.inbox; - const createMessage = { - '@context': 'https://www.w3.org/ns/activitystreams', - 'id': `https://${domain}/m/${guidUpdate}`, - 'type': 'Update', - 'actor': `https://${domain}/${eventID}`, - 'object': apObject - }; - signAndSend(createMessage, eventID, targetDomain, inbox, function(err, resp, status) { - if (err) { - console.log(`Didn't sent to ${actorId}, status ${status} with error ${err}`); - } - else { - console.log('sent to', actorId); - } - }); - } - else { - callback(`No follower found with the id ${actorId}`, null, 404); - } - } - else { - callback(`No event found with the id ${eventID}`, null, 404); - } - }); - } // end followers -} - -function broadcastDeleteMessage(apObject, followers, eventID, callback) { - callback = callback || function() {}; - // we need to build an array of promises for each message we're sending, run Promise.all(), and then that will resolve when every message has been sent (or failed) - // per spec, each promise will execute *as it is built*, which is fine, we just need the guarantee that they are all done - let promises = []; - - let guidUpdate = crypto.randomBytes(16).toString('hex'); - console.log('building promises'); - // iterate over followers - for (const follower of followers) { - promises.push(new Promise((resolve, reject) => { - let actorId = follower.actorId; - let myURL = new URL(actorId); - let targetDomain = myURL.hostname; - // get the inbox - Event.findOne({ - id: eventID, - }, function(err, event) { - console.log('found the event for broadcast'); - if (event) { - const follower = event.followers.find(el => el.actorId === actorId); - if (follower) { - const actorJson = JSON.parse(follower.actorJson); - const inbox = actorJson.inbox; - const createMessage = { - '@context': 'https://www.w3.org/ns/activitystreams', - 'id': `https://${domain}/m/${guidUpdate}`, - 'type': 'Delete', - 'actor': `https://${domain}/${eventID}`, - 'object': apObject - }; - signAndSend(createMessage, eventID, targetDomain, inbox, function(err, resp, status) { - if (err) { - console.log(`Didn't send to ${actorId}, status ${status} with error ${err}`); - reject(`Didn't send to ${actorId}, status ${status} with error ${err}`); - } - else { - console.log('sent to', actorId); - resolve('sent to', actorId); - } - }); - } - else { - console.log(`No follower found with the id ${actorId}`, null, 404); - reject(`No follower found with the id ${actorId}`, null, 404); - } - } - else { - console.log(`No event found with the id ${eventID}`, null, 404); - reject(`No event found with the id ${eventID}`, null, 404); - } - }); - })); - } // end followers - - Promise.all(promises.map(p => p.catch(e => e))).then(statuses => { - console.log('DONE') - console.log(statuses) - callback(statuses); - }); -} - -function signAndSend(message, eventID, targetDomain, inbox, callback) { - let inboxFragment = inbox.replace('https://'+targetDomain,''); - // get the private key - Event.findOne({ - id: eventID - }) - .then((event) => { - if (event) { - const privateKey = event.privateKey; - 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(privateKey); - const signature_b64 = signature.toString('base64'); - const header = `keyId="https://${domain}/${eventID}",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){ - if (error) { - console.log('Error:', error, response.body); - callback(error, null, 500); - } - else { - console.log('Response:', response.statusCode); - // Add the message to the database - const messageID = message.id; - const newMessage = { - id: message.id, - content: JSON.stringify(message) - }; - Event.findOne({ - id: eventID, - }, function(err,event) { - if (!event) return; - event.activityPubMessages.push(newMessage); - event.save() - .then(() => { - addToLog("addActivityPubMessage", "success", "ActivityPubMessage added to event " + eventID); - console.log('successful ActivityPubMessage add'); - callback(null, message.id, 200); - }) - .catch((err) => { addToLog("addActivityPubMessage", "error", "Attempt to add ActivityPubMessage to event " + eventID + " failed with error: " + err); - console.log('error', err) - callback(err, null, 500); - }); - }) - } - }); - } - else { - callback(`No record found for ${eventID}.`, null, 404); - } - }); -} // FRONTEND ROUTES @@ -614,7 +172,11 @@ router.get('/new', (req, res) => { //}); router.get('/new/event', (req, res) => { - res.render('newevent'); + res.render('newevent', { + domain: domain, + email: contactEmail, + siteName: siteName, + }); }); router.get('/new/event/public', (req, res) => { let isPrivate = false; @@ -640,12 +202,61 @@ router.get('/new/event/public', (req, res) => { isPublic: isPublic, isOrganisation: isOrganisation, isUnknownType: isUnknownType, - eventType: 'public' + eventType: 'public', + domain: domain, + email: contactEmail, + siteName: siteName, }); }) +router.get('/:eventID/featured', (req, res) => { + const {eventID} = req.params; + const guidObject = crypto.randomBytes(16).toString('hex'); + const featured = { + "@context": "https://www.w3.org/ns/activitystreams", + "id": `https://${domain}/${eventID}/featured`, + "type": "OrderedCollection", + "orderedItems": [ + ap.createFeaturedPost(eventID) + ] + } + res.json(featured); +}); + +router.get('/:eventID/m/:hash', (req, res) => { + const {hash, eventID} = req.params; + const id = `https://${domain}/${eventID}/m/${hash}`; + console.log(id); + + Event.findOne({ + id: eventID + }) + .then((event) => { + if (!event) { + res.status(404); + res.render('404', { url: req.url }); + } + else { + const message = event.activityPubMessages.find(el => el.id === id); + if (message) { + return res.json(JSON.parse(message.content)); + } + else { + res.status(404); + return res.render('404', { url: req.url }); + } + } + }) + .catch((err) => { + addToLog("getActivityPubMessage", "error", "Attempt to get Activity Pub Message for " + id + " failed with error: " + err); + console.log(err) + res.status(404); + res.render('404', { url: req.url }); + return; + }); +}); + router.get('/.well-known/webfinger', (req, res) => { - console.log(req.query); 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.'); @@ -655,7 +266,6 @@ router.get('/.well-known/webfinger', (req, res) => { let activityPubAccount = resource.replace('acct:',''); // "foo" let eventID = activityPubAccount.replace(/@.*/,''); - console.log(eventID); Event.findOne({ id: eventID }) @@ -675,14 +285,6 @@ router.get('/.well-known/webfinger', (req, res) => { res.render('404', { url: req.url }); return; }); - //let db = req.app.get('db'); - //let result = db.prepare('select webfinger from accounts where name = ?').get(name); - //if (result === undefined) { - // return res.status(404).send(`No record found for ${name}.`); - //} - //else { - // res.json(JSON.parse(result.webfinger)); - //} } }); @@ -693,7 +295,8 @@ router.get('/:eventID', (req, res) => { .populate('eventGroup') .then((event) => { if (event) { - parsedLocation = event.location.replace(/\s+/g, '+'); + const parsedLocation = event.location.replace(/\s+/g, '+'); + let displayDate; if (moment.tz(event.end, event.timezone).isSame(event.start, 'day')){ // Happening during one day displayDate = moment.tz(event.start, event.timezone).format('dddd D MMMM YYYY [from] h:mm a') + moment.tz(event.end, event.timezone).format(' [to] h:mm a [](z)[]'); @@ -701,10 +304,10 @@ router.get('/:eventID', (req, res) => { else { displayDate = moment.tz(event.start, event.timezone).format('dddd D MMMM YYYY [at] h:mm a') + moment.tz(event.end, event.timezone).format(' [–] dddd D MMMM YYYY [at] h:mm a [](z)[]'); } - eventStartISO = moment.tz(event.start, "Etc/UTC").toISOString(); - eventEndISO = moment.tz(event.end, "Etc/UTC").toISOString(); - parsedStart = moment.tz(event.start, event.timezone).format('YYYYMMDD[T]HHmmss'); - parsedEnd = moment.tz(event.end, event.timezone).format('YYYYMMDD[T]HHmmss'); + let eventStartISO = moment.tz(event.start, "Etc/UTC").toISOString(); + let eventEndISO = moment.tz(event.end, "Etc/UTC").toISOString(); + let parsedStart = moment.tz(event.start, event.timezone).format('YYYYMMDD[T]HHmmss'); + let parsedEnd = moment.tz(event.end, event.timezone).format('YYYYMMDD[T]HHmmss'); let eventHasConcluded = false; if (moment.tz(event.end, event.timezone).isBefore(moment.tz(event.timezone))){ eventHasConcluded = true; @@ -713,11 +316,11 @@ router.get('/:eventID', (req, res) => { if (moment.tz(event.start, event.timezone).isBefore(moment.tz(event.timezone))){ eventHasBegun = true; } - fromNow = moment.tz(event.start, event.timezone).fromNow(); - parsedDescription = marked(event.description); - eventEditToken = event.editToken; + let fromNow = moment.tz(event.start, event.timezone).fromNow(); + let parsedDescription = marked(event.description); + let eventEditToken = event.editToken; - escapedName = event.name.replace(/\s+/g, '+'); + let escapedName = event.name.replace(/\s+/g, '+'); let eventHasCoverImage = false; if( event.image ) { @@ -867,10 +470,10 @@ router.get('/group/:eventGroupID', (req, res) => { }) .then(async (eventGroup) => { if (eventGroup) { - parsedDescription = marked(eventGroup.description); - eventGroupEditToken = eventGroup.editToken; + let parsedDescription = marked(eventGroup.description); + let eventGroupEditToken = eventGroup.editToken; - escapedName = eventGroup.name.replace(/\s+/g, '+'); + let escapedName = eventGroup.name.replace(/\s+/g, '+'); let eventGroupHasCoverImage = false; if( eventGroup.image ) { @@ -998,8 +601,8 @@ router.post('/newevent', async (req, res) => { }); eventImageFilename = eventID + '.jpg'; } - startUTC = moment.tz(req.body.eventStart, 'D MMMM YYYY, hh:mm a', req.body.timezone); - endUTC = moment.tz(req.body.eventEnd, 'D MMMM YYYY, hh:mm a', req.body.timezone); + let startUTC = moment.tz(req.body.eventStart, 'D MMMM YYYY, hh:mm a', req.body.timezone); + let endUTC = moment.tz(req.body.eventEnd, 'D MMMM YYYY, hh:mm a', req.body.timezone); let eventGroup; if (req.body.eventGroupCheckbox) { eventGroup = await EventGroup.findOne({ @@ -1036,8 +639,9 @@ router.post('/newevent', async (req, res) => { usersCanComment: req.body.interactionCheckbox ? true : false, maxAttendees: req.body.maxAttendees, firstLoad: true, - activityPubActor: createActivityPubActor(eventID, domain, pair.public, marked(req.body.eventDescription), req.body.eventName, req.body.eventLocation, eventImageFilename, req.body.startUTC, req.body.endUTC, req.body.timezone), - activityPubEvent: createActivityPubEvent(req.body.eventName, req.body.startUTC, req.body.endUTC, req.body.timezone), + activityPubActor: ap.createActivityPubActor(eventID, domain, pair.public, marked(req.body.eventDescription), req.body.eventName, req.body.eventLocation, eventImageFilename, startUTC, endUTC, req.body.timezone), + activityPubEvent: ap.createActivityPubEvent(req.body.eventName, startUTC, endUTC, req.body.timezone, req.body.eventDescription, req.body.eventLocation), + activityPubMessages: [ { id: `https://${domain}/${eventID}/m/featuredPost`, content: JSON.stringify(ap.createFeaturedPost(eventID, req.body.eventName, startUTC, endUTC, req.body.timezone, req.body.eventDescription, req.body.eventLocation)) } ], publicKey: pair.public, privateKey: pair.private }); @@ -1074,10 +678,8 @@ router.post('/importevent', (req, res) => { let eventID = shortid.generate(); let editToken = randomstring.generate(); if (req.files && Object.keys(req.files).length !== 0) { - importediCalObject = ical.parseICS(req.files.icsImportControl.data.toString('utf8')); - for (var key in importediCalObject) { - importedEventData = importediCalObject[key]; - } + let importediCalObject = ical.parseICS(req.files.icsImportControl.data.toString('utf8')); + let importedEventData = importediCalObject; console.log(importedEventData) let creatorEmail; if (req.body.creatorEmail) { @@ -1222,12 +824,13 @@ router.post('/editevent/:eventID/:editToken', (req, res) => { }); eventImageFilename = eventID + '.jpg'; } - startUTC = moment.tz(req.body.eventStart, 'D MMMM YYYY, hh:mm a', req.body.timezone); - endUTC = moment.tz(req.body.eventEnd, 'D MMMM YYYY, hh:mm a', req.body.timezone); + let startUTC = moment.tz(req.body.eventStart, 'D MMMM YYYY, hh:mm a', req.body.timezone); + let endUTC = moment.tz(req.body.eventEnd, 'D MMMM YYYY, hh:mm a', req.body.timezone); - var isPartOfEventGroup = false; + let isPartOfEventGroup = false; + let eventGroup; if (req.body.eventGroupCheckbox) { - var eventGroup = await EventGroup.findOne({ + eventGroup = await EventGroup.findOne({ id: req.body.eventGroupID, editToken: req.body.eventGroupEditToken }) @@ -1250,8 +853,8 @@ router.post('/editevent/:eventID/:editToken', (req, res) => { usersCanComment: req.body.interactionCheckbox ? true : false, maxAttendees: req.body.maxAttendeesCheckbox ? req.body.maxAttendees : null, eventGroup: isPartOfEventGroup ? eventGroup._id : null, - activityPubActor: updateActivityPubActor(JSON.parse(event.activityPubActor), req.body.eventDescription, req.body.eventName, req.body.eventLocation, eventImageFilename, startUTC, endUTC, req.body.timezone), - activityPubEvent: updateActivityPubEvent(JSON.parse(event.activityPubEvent), req.body.eventName, req.body.startUTC, req.body.endUTC, req.body.timezone), + activityPubActor: ap.updateActivityPubActor(JSON.parse(event.activityPubActor), req.body.eventDescription, req.body.eventName, req.body.eventLocation, eventImageFilename, startUTC, endUTC, req.body.timezone), + activityPubEvent: ap.updateActivityPubEvent(JSON.parse(event.activityPubEvent), req.body.eventName, req.body.startUTC, req.body.endUTC, req.body.timezone), } let diffText = '

        This event was just updated with new information.

          '; let displayDate; @@ -1294,18 +897,19 @@ router.post('/editevent/:eventID/:editToken', (req, res) => { const guidObject = crypto.randomBytes(16).toString('hex'); const jsonObject = { "@context": "https://www.w3.org/ns/activitystreams", - "id": `https://${domain}/m/${guidObject}`, + "id": `https://${domain}/${req.params.eventID}/m/${guidObject}`, "name": `RSVP to ${event.name}`, "type": "Note", - "content": `${diffText} See here: https://${domain}/${req.params.eventID}`, + 'cc': 'https://www.w3.org/ns/activitystreams#Public', + "content": `${diffText} See here: https://${domain}/${req.params.eventID}`, } - broadcastMessage(jsonObject, event.followers, eventID) + ap.broadcastCreateMessage(jsonObject, event.followers, eventID) // also broadcast an Update profile message to all followers so that at least Mastodon servers will update the local profile information const jsonUpdateObject = JSON.parse(event.activityPubActor); - broadcastUpdateMessage(jsonUpdateObject, event.followers, eventID) + ap.broadcastUpdateMessage(jsonUpdateObject, event.followers, eventID) // also broadcast an Update/Event for any calendar apps that are consuming our Events const jsonEventObject = JSON.parse(event.activityPubEvent); - broadcastUpdateMessage(jsonEventObject, event.followers, eventID) + ap.broadcastUpdateMessage(jsonEventObject, event.followers, eventID) // DM to attendees for (const attendee of attendees) { @@ -1317,13 +921,13 @@ router.post('/editevent/:eventID/:editToken', (req, res) => { "tag":[{"type":"Mention","href":attendee.id,"name":attendee.name}] } // send direct message to user - sendDirectMessage(jsonObject, attendee.id, eventID); + ap.sendDirectMessage(jsonObject, attendee.id, eventID); } } }) if (sendEmails) { Event.findOne({id: req.params.eventID}).distinct('attendees.email', function(error, ids) { - attendeeEmails = ids; + let attendeeEmails = ids; if (!error && attendeeEmails !== ""){ console.log("Sending emails to: " + attendeeEmails); req.app.get('hbsInstance').renderView('./views/emails/editevent.handlebars', {diffText, eventID: req.params.eventID, siteName, domain, cache: true, layout: 'email.handlebars'}, function(err, html) { @@ -1401,32 +1005,6 @@ router.post('/editeventgroup/:eventGroupID/:editToken', (req, res) => { }) .then(() => { addToLog("editEventGroup", "success", "Event group " + req.params.eventGroupID + " edited"); - // if (sendEmails) { - // Event.findOne({id: req.params.eventID}).distinct('attendees.email', function(error, ids) { - // attendeeEmails = ids; - // if (!error && attendeeEmails != ""){ - // console.log("Sending emails to: " + attendeeEmails); - // const msg = { - // to: attendeeEmails, - // from: { - // name: 'Gathio', - // email: contactEmail, - // }, - // templateId: 'd-e21f3ca49d82476b94ddd8892c72a162', - // dynamic_template_data: { - // subject: 'gathio: Event edited', - // actionType: 'edited', - // eventExists: true, - // eventID: req.params.eventID - // } - // } - // sgMail.sendMultiple(msg); - // } - // else { - // console.log("Nothing to send!"); - // } - // }) - // } res.writeHead(302, { 'Location': '/group/' + req.params.eventGroupID + '?e=' + req.params.editToken }); @@ -1461,7 +1039,7 @@ router.post('/deleteevent/:eventID/:editToken', (req, res) => { const guidUpdateObject = crypto.randomBytes(16).toString('hex'); const jsonUpdateObject = JSON.parse(event.activityPubActor); // first broadcast AP messages, THEN delete from DB - broadcastDeleteMessage(jsonUpdateObject, event.followers, req.params.eventID, function(statuses) { + ap.broadcastDeleteMessage(jsonUpdateObject, event.followers, req.params.eventID, function(statuses) { Event.deleteOne({id: req.params.eventID}, function(err, raw) { if (err) { res.send(err); @@ -1490,7 +1068,7 @@ router.post('/deleteevent/:eventID/:editToken', (req, res) => { // send emails here otherwise they don't exist lol if (sendEmails) { Event.findOne({id: req.params.eventID}).distinct('attendees.email', function(error, ids) { - attendeeEmails = ids; + let attendeeEmails = ids; if (!error){ console.log("Sending emails to: " + attendeeEmails); req.app.get('hbsInstance').renderView('./views/emails/deleteevent.handlebars', {siteName, domain, eventName: event.name, cache: true, layout: 'email.handlebars'}, function(err, html) { @@ -1751,18 +1329,20 @@ router.post('/post/comment/:eventID', (req, res) => { .then(() => { addToLog("addEventComment", "success", "Comment added to event " + req.params.eventID); // broadcast an identical message to all followers, will show in their home timeline + // and in the home timeline of the event const guidObject = crypto.randomBytes(16).toString('hex'); const jsonObject = { "@context": "https://www.w3.org/ns/activitystreams", - "id": `https://${domain}/m/${guidObject}`, + "id": `https://${domain}/${req.params.eventID}/m/${guidObject}`, "name": `Comment on ${event.name}`, "type": "Note", + 'cc': 'https://www.w3.org/ns/activitystreams#Public', "content": `

          ${req.body.commentAuthor} commented: ${req.body.commentContent}.

          See the full conversation here.

          `, } - broadcastMessage(jsonObject, event.followers, req.params.eventID) + ap.broadcastCreateMessage(jsonObject, event.followers, req.params.eventID) if (sendEmails) { Event.findOne({id: req.params.eventID}).distinct('attendees.email', function(error, ids) { - attendeeEmails = ids; + let attendeeEmails = ids; if (!error){ console.log("Sending emails to: " + attendeeEmails); req.app.get('hbsInstance').renderView('./views/emails/addeventcomment.handlebars', {siteName, domain, eventID: req.params.eventID, commentAuthor: req.body.commentAuthor, cache: true, layout: 'email.handlebars'}, function(err, html) { @@ -1817,15 +1397,16 @@ router.post('/post/reply/:eventID/:commentID', (req, res) => { const guidObject = crypto.randomBytes(16).toString('hex'); const jsonObject = { "@context": "https://www.w3.org/ns/activitystreams", - "id": `https://${domain}/m/${guidObject}`, + "id": `https://${domain}/${req.params.eventID}/m/${guidObject}`, "name": `Comment on ${event.name}`, "type": "Note", + 'cc': 'https://www.w3.org/ns/activitystreams#Public', "content": `

          ${req.body.replyAuthor} commented: ${req.body.replyContent}

          See the full conversation here.

          `, } - broadcastMessage(jsonObject, event.followers, req.params.eventID) + ap.broadcastCreateMessage(jsonObject, event.followers, req.params.eventID) if (sendEmails) { Event.findOne({id: req.params.eventID}).distinct('attendees.email', function(error, ids) { - attendeeEmails = ids; + let attendeeEmails = ids; if (!error){ console.log("Sending emails to: " + attendeeEmails); req.app.get('hbsInstance').renderView('./views/emails/addeventcomment.handlebars', {siteName, domain, eventID: req.params.eventID, commentAuthor: req.body.replyAuthor, cache: true, layout: 'email.handlebars'}, function(err, html) { @@ -1887,8 +1468,6 @@ router.post('/deletecomment/:eventID/:commentID/:editToken', (req, res) => { }); router.post('/activitypub/inbox', (req, res) => { - console.log('got an inbox message of type', req.body.type, req.body) - // validate the incoming message const signature = req.get('Signature'); let signature_header = signature.split(',').map(pair => { @@ -1934,7 +1513,13 @@ router.post('/activitypub/inbox', (req, res) => { const signatureBuf = new Buffer(signature_header.signature, 'base64') try { const result = verifier.verify(publicKeyBuf, signatureBuf) - processInbox(req, res); + if (result) { + // actually process the ActivityPub message now that it's been verified + ap.processInbox(req, res); + } + else { + return res.status(401).send('Signature could not be verified.'); + } } catch(err) { return res.status(401).send('Signature could not be verified: ' + err); @@ -1942,309 +1527,6 @@ router.post('/activitypub/inbox', (req, res) => { }); }); - -function processInbox(req, res) { - if (req.body.object) console.log('containing object of type', req.body.object.type) - // if a Follow activity hits the inbox - if (typeof req.body.object === 'string' && req.body.type === 'Follow') { - const myURL = new URL(req.body.actor); - let targetDomain = myURL.hostname; - let eventID = req.body.object.replace(`https://${domain}/`,''); - // Add the user to the DB of accounts that follow the account - // get the follower's username - request({ - url: req.body.actor, - headers: { - 'Accept': 'application/activity+json', - 'Content-Type': 'application/activity+json' - }}, function (error, response, body) { - body = JSON.parse(body) - const name = body.preferredUsername || body.name || attributedTo; - const newFollower = { - actorId: req.body.actor, - followId: req.body.id, - name: name, - actorJson: JSON.stringify(body) - }; - Event.findOne({ - id: eventID, - }, function(err,event) { - // if this account is NOT already in our followers list, add it - if (event && !event.followers.map(el => el.actorId).includes(req.body.actor)) { - console.log('made it!') - event.followers.push(newFollower); - event.save() - .then(() => { - addToLog("addEventFollower", "success", "Follower added to event " + eventID); - console.log('successful follower add'); - // Accept the follow request - sendAcceptMessage(req.body, eventID, targetDomain, function(err, resp, status) { - if (err) { - console.log(`Didn't send Accept to ${req.body.actor}, status ${status} with error ${err}`); - } - else { - console.log('sent Accept to', req.body.actor); - // ALSO send an ActivityPub Event activity since this person is "interested" in the event, as indicated by the Follow - const jsonEventObject = JSON.parse(event.activityPubEvent); - // send direct message to user - sendDirectMessage(jsonEventObject, newFollower.actorId, event.id); - - // if users can self-RSVP, send a Question to the new follower - if (event.usersCanAttend) { - const jsonObject = { - "@context": "https://www.w3.org/ns/activitystreams", - "name": `RSVP to ${event.name}`, - "type": "Question", - "content": `@${name} Will you attend ${event.name}? (If you reply "Yes", you'll be listed as an attendee on the event page.)`, - "oneOf": [ - {"type":"Note","name": "Yes"}, - {"type":"Note","name": "No"}, - {"type":"Note","name": "Maybe"} - ], - "endTime":event.start.toISOString(), - "tag":[{"type":"Mention","href":req.body.actor,"name":name}] - } - // send direct message to user - sendDirectMessage(jsonObject, req.body.actor, eventID, function (error, response, statuscode) { - if (error) { - console.log(error); - res.status(statuscode).json(error); - } - else { - res.status(statuscode).json({messageid: response}); - } - }); - } - } - }); - }) - .catch((err) => { res.status(500).send('Database error, please try again :('); addToLog("addEventFollower", "error", "Attempt to add follower to event " + eventID + " failed with error: " + err); - console.log('ERROR', err); - }); - } - }) - }) //end request - } - // if an Undo activity with a Follow object hits the inbox - if (req.body && req.body.type === 'Undo' && req.body.object && req.body.object.type === 'Follow') { - // get the record of all followers for this account - const eventID = req.body.object.object.replace(`https://${domain}/`,''); - Event.findOne({ - id: eventID, - }, function(err,event) { - if (!event) return; - // check to see if the Follow object's id matches the id we have on record - // is this even someone who follows us - const indexOfFollower = event.followers.findIndex(el => el.actorId === req.body.object.actor); - if (indexOfFollower !== -1) { - // does the id we have match the id we are being given - if (event.followers[indexOfFollower].followId === req.body.object.id) { - // we have a match and can trust the Undo! remove this person from the followers list - event.followers.splice(indexOfFollower, 1); - event.save() - .then(() => { - res.send(200); - addToLog("removeEventFollower", "success", "Follower removed from event " + eventID); - console.log('successful follower removal') - }) - .catch((err) => { res.send('Database error, please try again :('); addToLog("removeEventFollower", "error", "Attempt to remove follower from event " + eventID + " failed with error: " + err); - console.log('error', err) - }); - } - } - }); - } - // if a Create activity with a Note object hits the inbox, it might be a vote in a poll - if (req.body && req.body.type === 'Create' && req.body.object && req.body.object.type === 'Note' && req.body.object.inReplyTo && req.body.object.to) { - console.log('create note inreplyto!!!') - // figure out what this is in reply to -- it should be addressed specifically to us - let {name, attributedTo, inReplyTo, to} = req.body.object; - // if it's an array just grab the first element, since a poll should only broadcast back to the pollster - if (Array.isArray(to)) { - to = to[0]; - } - const eventID = to.replace(`https://${domain}/`,''); - // make sure this person is actually a follower - Event.findOne({ - id: eventID, - }, function(err,event) { - if (!event) return; - // is this even someone who follows us - const indexOfFollower = event.followers.findIndex(el => el.actorId === req.body.object.attributedTo); - if (indexOfFollower !== -1) { - console.log('this person does follow us!') - // compare the inReplyTo to its stored message, if it exists and it's going to the right follower then this is a valid reply - const message = event.activityPubMessages.find(el => { - const content = JSON.parse(el.content); - return inReplyTo === (content.object && content.object.id); - }); - if (message) { - console.log(message); - const content = JSON.parse(message.content); - // check if the message we sent out was sent to the actor this incoming message is attributedTo - if (content.to[0] === attributedTo) { - // it's a match, this is a valid poll response, add RSVP to database - // fetch the profile information of the user - request({ - url: attributedTo, - headers: { - 'Accept': 'application/activity+json', - 'Content-Type': 'application/activity+json' - }}, function (error, response, body) { - body = JSON.parse(body) - // if this account is NOT already in our attendees list, add it - if (!event.attendees.map(el => el.id).includes(attributedTo)) { - const attendeeName = body.preferredUsername || body.name || attributedTo; - const newAttendee = { - name: attendeeName, - status: 'attending', - id: attributedTo - }; - event.attendees.push(newAttendee); - event.save() - .then((fullEvent) => { - addToLog("addEventAttendee", "success", "Attendee added to event " + req.params.eventID); - // get the new attendee with its hidden id from the full event - let fullAttendee = fullEvent.attendees.find(el => el.id === attributedTo); - // send a "click here to remove yourself" link back to the user as a DM - const jsonObject = { - "@context": "https://www.w3.org/ns/activitystreams", - "name": `RSVP to ${event.name}`, - "type": "Note", - "content": `@${newAttendee.name} Thanks for RSVPing! You can remove yourself from the RSVP list by clicking here: https://${domain}/oneclickunattendevent/${event.id}/${fullAttendee._id}`, - "tag":[{"type":"Mention","href":newAttendee.id,"name":newAttendee.name}] - } - // send direct message to user - sendDirectMessage(jsonObject, newAttendee.id, event.id); - return res.sendStatus(200); - }) - .catch((err) => { addToLog("addEventAttendee", "error", "Attempt to add attendee to event " + req.params.eventID + " failed with error: " + err); return res.status(500).send('Database error, please try again :('); }); - } - else { - // it's a duplicate and this person is already rsvped so just say OK - return res.status(200).send("Attendee is already registered."); - } - }); - } - } - } - }); - } - if (req.body && req.body.type === 'Delete') { - const deleteObjectId = req.body.object.id; - // find all events with comments from the author - Event.find({ - "comments.actorId":req.body.actor - }, function(err,events) { - if (!events) { - res.sendStatus(404); - return; - } - - // find the event with THIS comment from the author - let eventWithComment = events.find(event => { - let comments = event.comments; - return comments.find(comment => { - if (!comment.activityJson) { - return false; - } - return JSON.parse(comment.activityJson).object.id === req.body.object.id; - }) - }); - - if (!eventWithComment) { - res.sendStatus(404); - return; - } - - // delete the comment - // find the index of the comment, it should have an activityJson field because from an AP server you can only delete an AP-originated comment (and of course it needs to be yours) - let indexOfComment = eventWithComment.comments.findIndex(comment => { - return comment.activityJson && JSON.parse(comment.activityJson).object.id === req.body.object.id; - }); - eventWithComment.comments.splice(indexOfComment, 1); - eventWithComment.save() - .then(() => { - addToLog("deleteComment", "success", "Comment deleted from event " + eventWithComment.id); - console.log('deleted comment!') - res.sendStatus(200); - }) - .catch((err) => { res.sendStatus(500); addToLog("deleteComment", "error", "Attempt to delete comment " + req.body.object.id + "from event " + eventWithComment.id + " failed with error: " + err);}); - }); - } - // if we are CC'ed on a public or unlisted Create/Note, then this is a comment to us we should boost (Announce) to our followers - if (req.body && req.body.type === 'Create' && req.body.object && req.body.object.type === 'Note' && req.body.object.to) { - // figure out what this is in reply to -- it should be addressed specifically to us - let {attributedTo, inReplyTo, to, cc} = req.body.object; - // normalize cc into an array - if (typeof cc === 'string') { - cc = [cc]; - } - // normalize to into an array - if (typeof to === 'string') { - to = [to]; - } - - // if this is a public message (in the to or cc fields) - if (to.includes('https://www.w3.org/ns/activitystreams#Public') || (Array.isArray(cc) && cc.includes('https://www.w3.org/ns/activitystreams#Public'))) { - // figure out which event(s) of ours it was addressing - ourEvents = cc.filter(el => el.includes(`https://${domain}/`)) - .map(el => el.replace(`https://${domain}/`,'')); - // comments should only be on one event. if more than one, ignore (spam, probably) - if (ourEvents.length === 1) { - let eventID = ourEvents[0]; - // add comment - let commentID = shortid.generate(); - // get the actor for the commenter - request({ - url: req.body.actor, - headers: { - 'Accept': 'application/activity+json', - 'Content-Type': 'application/activity+json' - }}, function (error, response, actor) { - if (!error) { - const parsedActor = JSON.parse(actor); - const name = parsedActor.preferredUsername || parsedActor.name || req.body.actor; - const newComment = { - id: commentID, - actorId: req.body.actor, - activityId: req.body.object.id, - author: name, - content: sanitizeHtml(req.body.object.content, {allowedTags: [], allowedAttributes: {}}).replace('@'+eventID,''), - timestamp: moment(), - activityJson: JSON.stringify(req.body), - actorJson: actor - }; - - Event.findOne({ - id: eventID, - }, function(err,event) { - if (!event) { - return res.sendStatus(404); - } - if (!event.usersCanComment) { - return res.sendStatus(200); - } - event.comments.push(newComment); - event.save() - .then(() => { - addToLog("addEventComment", "success", "Comment added to event " + eventID); - const guidObject = crypto.randomBytes(16).toString('hex'); - const jsonObject = req.body.object; - jsonObject.attributedTo = newComment.actorId; - broadcastAnnounceMessage(jsonObject, event.followers, eventID) - console.log('added comment'); - res.sendStatus(200); - }) - .catch((err) => { res.status(500).send('Database error, please try again :(' + err); addToLog("addEventComment", "error", "Attempt to add comment to event " + eventID + " failed with error: " + err); console.log('error', err)}); - }); - } - }); - } // end ourevent - } // end public message - } // CC'ed -} - router.use(function(req, res, next){ res.status(404); res.render('404', { url: req.url }); diff --git a/views/partials/neweventgroupform.handlebars b/views/partials/neweventgroupform.handlebars index 20dd832..fddc795 100755 --- a/views/partials/neweventgroupform.handlebars +++ b/views/partials/neweventgroupform.handlebars @@ -1,6 +1,6 @@

          Create an event group

          An event group is a holding area for a set of linked events, like a series of film nights, a festival, or a band tour. You can share a public link to your event group just like an individual event link, and people who know the secret editing code (sent in an email when you create the event group) will be able to add future events to the group.

          -

          Event groups do not get automatically removed like events do, but events which have been removed from Gathio will of course not show up in an event group.

          +

          Event groups do not get automatically removed like events do, but events which have been removed from {{siteName}} will of course not show up in an event group.

          -- cgit v1.2.3 From 9ff73b7a4c218fdd0c3fff7b7947ff8e965da4a9 Mon Sep 17 00:00:00 2001 From: Darius Kazemi Date: Mon, 6 Jan 2020 21:47:38 -0800 Subject: update wiki link --- activitypub.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activitypub.js b/activitypub.js index 3d7fb10..7d9a903 100644 --- a/activitypub.js +++ b/activitypub.js @@ -75,7 +75,7 @@ function createFeaturedPost(eventID, name, startUTC, endUTC, timezone, descripti "type": "Note", "name": "Test", 'cc': 'https://www.w3.org/ns/activitystreams#Public', - "content": `

          This is an event that was posted on ${siteName}. If you follow this account, you'll see updates in your timeline about the event. If your software supports polls, you should get a poll in your DMs asking if you want to RSVP. You can reply and RSVP right from there. If your software has an event calendar built in, you should get an event in your inbox that you can RSVP to like you respond to any event.

          For more information on how to interact with this, check out this link.

          `, + "content": `

          This is an event that was posted on ${siteName}. If you follow this account, you'll see updates in your timeline about the event. If your software supports polls, you should get a poll in your DMs asking if you want to RSVP. You can reply and RSVP right from there. If your software has an event calendar built in, you should get an event in your inbox that you can RSVP to like you respond to any event.

          For more information on how to interact with this, check out this link.

          `, 'attributedTo': `https://${domain}/${eventID}`, } return featured; -- cgit v1.2.3 From a40bf2370726b51829cdbb2cbd57f491bf764478 Mon Sep 17 00:00:00 2001 From: Darius Kazemi Date: Mon, 6 Jan 2020 22:22:41 -0800 Subject: more refactors --- activitypub.js | 134 ++++++++++++++++++++++++++------------------------------- 1 file changed, 60 insertions(+), 74 deletions(-) diff --git a/activitypub.js b/activitypub.js index 7d9a903..9d69ab5 100644 --- a/activitypub.js +++ b/activitypub.js @@ -175,11 +175,9 @@ function signAndSend(message, eventID, targetDomain, inbox, callback) { event.save() .then(() => { addToLog("addActivityPubMessage", "success", "ActivityPubMessage added to event " + eventID); - console.log('successful ActivityPubMessage add'); callback(null, message.id, 200); }) .catch((err) => { addToLog("addActivityPubMessage", "error", "Attempt to add ActivityPubMessage to event " + eventID + " failed with error: " + err); - console.log('error', err) callback(err, null, 500); }); }) @@ -195,26 +193,22 @@ function signAndSend(message, eventID, targetDomain, inbox, callback) { // this function sends something to the timeline of every follower in the followers array // it's also an unlisted public message, meaning non-followers can see the message if they look at // the profile but it doesn't spam federated timelines -function broadcastCreateMessage(apObject, followers, eventID, callback) { - callback = callback || function() {}; +function broadcastCreateMessage(apObject, followers, eventID) { let guidCreate = crypto.randomBytes(16).toString('hex'); - console.log('broadcasting'); - // iterate over followers - for (const follower of followers) { - let actorId = follower.actorId; - let myURL = new URL(actorId); - let targetDomain = myURL.hostname; - // get the inbox - Event.findOne({ - id: eventID, - }, function(err, event) { - console.log('found the event for broadcast') - if (event) { - const follower = event.followers.find(el => el.actorId === actorId); - if (follower) { + Event.findOne({ + id: eventID, + }, function(err, event) { + if (event) { + // iterate over followers + for (const follower of followers) { + let actorId = follower.actorId; + let myURL = new URL(actorId); + let targetDomain = myURL.hostname; + // get the inbox + const followerFound = event.followers.find(el => el.actorId === actorId); + if (followerFound) { const actorJson = JSON.parse(follower.actorJson); const inbox = actorJson.inbox; - console.log('found the inbox for', actorId) const createMessage = { '@context': 'https://www.w3.org/ns/activitystreams', 'id': `https://${domain}/${eventID}/m/${guidCreate}`, @@ -234,35 +228,32 @@ function broadcastCreateMessage(apObject, followers, eventID, callback) { }); } else { - callback(`No follower found with the id ${actorId}`, null, 404); + console.log(`No follower found with the id ${actorId}`); } - } - else { - callback(`No event found with the id ${eventID}`, null, 404); - } - }); - } // end followers + } // end followers + } // end if event + else { + console.log(`No event found with the id ${eventID}`); + } + }); } // sends an Announce for the apObject -function broadcastAnnounceMessage(apObject, followers, eventID, callback) { - callback = callback || function() {}; +function broadcastAnnounceMessage(apObject, followers, eventID) { let guidUpdate = crypto.randomBytes(16).toString('hex'); - console.log('broadcasting announce'); - // iterate over followers - for (const follower of followers) { - let actorId = follower.actorId; - let myURL = new URL(actorId); - let targetDomain = myURL.hostname; - // get the inbox - Event.findOne({ - id: eventID, - }, function(err, event) { - console.log('found the event for broadcast') - if (event) { - const follower = event.followers.find(el => el.actorId === actorId); - if (follower) { + Event.findOne({ + id: eventID, + }, function(err, event) { + if (event) { + // iterate over followers + for (const follower of followers) { + let actorId = follower.actorId; + let myURL = new URL(actorId); + let targetDomain = myURL.hostname; + // get the inbox + const followerFound = event.followers.find(el => el.actorId === actorId); + if (followerFound) { const actorJson = JSON.parse(follower.actorJson); const inbox = actorJson.inbox; const announceMessage = { @@ -276,7 +267,7 @@ function broadcastAnnounceMessage(apObject, followers, eventID, callback) { }; signAndSend(announceMessage, eventID, targetDomain, inbox, function(err, resp, status) { if (err) { - console.log(`Didn't sent to ${actorId}, status ${status} with error ${err}`); + console.log(`Didn't send to ${actorId}, status ${status} with error ${err}`); } else { console.log('sent to', actorId); @@ -285,35 +276,30 @@ function broadcastAnnounceMessage(apObject, followers, eventID, callback) { } else { console.log(`No follower found with the id ${actorId}`); - callback(`No follower found with the id ${actorId}`, null, 404); } - } - else { - console.log(`No event found with the id ${eventID}`); - callback(`No event found with the id ${eventID}`, null, 404); - } - }); - } // end followers + } // end followers + } // end if event + else { + console.log(`No event found with the id ${eventID}`); + } + }); } // sends an Update for the apObject -function broadcastUpdateMessage(apObject, followers, eventID, callback) { - callback = callback || function() {}; +function broadcastUpdateMessage(apObject, followers, eventID) { let guidUpdate = crypto.randomBytes(16).toString('hex'); - console.log('broadcasting update'); // iterate over followers - for (const follower of followers) { - let actorId = follower.actorId; - let myURL = new URL(actorId); - let targetDomain = myURL.hostname; - // get the inbox - Event.findOne({ - id: eventID, - }, function(err, event) { - console.log('found the event for broadcast') - if (event) { - const follower = event.followers.find(el => el.actorId === actorId); - if (follower) { + Event.findOne({ + id: eventID, + }, function(err, event) { + if (event) { + for (const follower of followers) { + let actorId = follower.actorId; + let myURL = new URL(actorId); + let targetDomain = myURL.hostname; + // get the inbox + const followerFound = event.followers.find(el => el.actorId === actorId); + if (followerFound) { const actorJson = JSON.parse(follower.actorJson); const inbox = actorJson.inbox; const createMessage = { @@ -333,14 +319,14 @@ function broadcastUpdateMessage(apObject, followers, eventID, callback) { }); } else { - callback(`No follower found with the id ${actorId}`, null, 404); + console.log(`No follower found with the id ${actorId}`); } - } - else { - callback(`No event found with the id ${eventID}`, null, 404); - } - }); - } // end followers + } // end followers + } + else { + console.log(`No event found with the id ${eventID}`); + } + }); } function broadcastDeleteMessage(apObject, followers, eventID, callback) { @@ -394,7 +380,7 @@ function broadcastDeleteMessage(apObject, followers, eventID, callback) { console.log(`No event found with the id ${eventID}`, null, 404); reject(`No event found with the id ${eventID}`, null, 404); } - }); + }); // end event })); } // end followers -- cgit v1.2.3 From 6dc03139921414d24f0e24efba224bf0c8e0581f Mon Sep 17 00:00:00 2001 From: Darius Kazemi Date: Mon, 6 Jan 2020 22:26:29 -0800 Subject: delete console logs --- activitypub.js | 22 +--------------------- routes.js | 7 ------- 2 files changed, 1 insertion(+), 28 deletions(-) diff --git a/activitypub.js b/activitypub.js index 9d69ab5..22404cf 100644 --- a/activitypub.js +++ b/activitypub.js @@ -54,7 +54,6 @@ function createActivityPubActor(eventID, domain, pubkey, description, name, loca function createActivityPubEvent(name, startUTC, endUTC, timezone, description, location) { const guid = crypto.randomBytes(16).toString('hex'); - console.log(startUTC); let eventObject = { "@context": "https://www.w3.org/ns/activitystreams", 'id': `https://${domain}/${guid}`, @@ -336,7 +335,6 @@ function broadcastDeleteMessage(apObject, followers, eventID, callback) { let promises = []; let guidUpdate = crypto.randomBytes(16).toString('hex'); - console.log('building promises'); // iterate over followers for (const follower of followers) { promises.push(new Promise((resolve, reject) => { @@ -347,7 +345,6 @@ function broadcastDeleteMessage(apObject, followers, eventID, callback) { Event.findOne({ id: eventID, }, function(err, event) { - console.log('found the event for broadcast'); if (event) { const follower = event.followers.find(el => el.actorId === actorId); if (follower) { @@ -385,8 +382,6 @@ function broadcastDeleteMessage(apObject, followers, eventID, callback) { } // end followers Promise.all(promises.map(p => p.catch(e => e))).then(statuses => { - console.log('DONE') - console.log(statuses) callback(statuses); }); } @@ -490,12 +485,10 @@ function _handleFollow(req, res) { }, function(err,event) { // if this account is NOT already in our followers list, add it if (event && !event.followers.map(el => el.actorId).includes(req.body.actor)) { - console.log('made it!') event.followers.push(newFollower); event.save() .then(() => { addToLog("addEventFollower", "success", "Follower added to event " + eventID); - console.log('successful follower add'); // Accept the follow request sendAcceptMessage(req.body, eventID, targetDomain, function(err, resp, status) { if (err) { @@ -646,24 +639,19 @@ function _handleUndoAcceptEvent(req, res) { to = to[0]; } const eventID = to.replace(`https://${domain}/`,''); - console.log(eventID) Event.findOne({ id: eventID, }, function(err,event) { if (!event) return; // does the id we got match the id of a thing we sent out - console.log('EVENT MESSAGES') - console.log(event.activityPubMessages); const message = event.activityPubMessages.find(el => el.id === req.body.object.object); if (message) { // it's a match - console.log('match!!!!') Event.update( { id: eventID }, { $pull: { attendees: { id: actor } } } ) .then(response => { - console.log(response) addToLog("oneClickUnattend", "success", "Attendee removed via one click unattend " + req.params.eventID); }); } @@ -671,7 +659,6 @@ function _handleUndoAcceptEvent(req, res) { } function _handleCreateNote(req, res) { - console.log('create note inreplyto!!!') // figure out what this is in reply to -- it should be addressed specifically to us let {name, attributedTo, inReplyTo, to} = req.body.object; // if it's an array just grab the first element, since a poll should only broadcast back to the pollster @@ -687,14 +674,12 @@ function _handleCreateNote(req, res) { // is this even someone who follows us const indexOfFollower = event.followers.findIndex(el => el.actorId === req.body.object.attributedTo); if (indexOfFollower !== -1) { - console.log('this person does follow us!') // compare the inReplyTo to its stored message, if it exists and it's going to the right follower then this is a valid reply const message = event.activityPubMessages.find(el => { const content = JSON.parse(el.content); return inReplyTo === (content.object && content.object.id); }); if (message) { - console.log(message); const content = JSON.parse(message.content); // check if the message we sent out was sent to the actor this incoming message is attributedTo if (content.to[0] === attributedTo) { @@ -783,7 +768,6 @@ function _handleDelete(req, res) { eventWithComment.save() .then(() => { addToLog("deleteComment", "success", "Comment deleted from event " + eventWithComment.id); - console.log('deleted comment!') return res.sendStatus(200); }) .catch((err) => { @@ -853,11 +837,10 @@ function _handleCreateNoteComment(req, res) { const jsonObject = req.body.object; jsonObject.attributedTo = newComment.actorId; broadcastAnnounceMessage(jsonObject, event.followers, eventID) - console.log('added comment'); return res.sendStatus(200); }) .catch((err) => { - addToLog("addEventComment", "error", "Attempt to add comment to event " + eventID + " failed with error: " + err); console.log('error', err) + addToLog("addEventComment", "error", "Attempt to add comment to event " + eventID + " failed with error: " + err); res.status(500).send('Database error, please try again :(' + err); }); }); @@ -868,10 +851,7 @@ function _handleCreateNoteComment(req, res) { } function processInbox(req, res) { - console.log('PROCESS INBOX') - console.log(req.body, req.body.type); try { - if (req.body.object) console.log('containing object of type', req.body.object.type); // if a Follow activity hits the inbox if (typeof req.body.object === 'string' && req.body.type === 'Follow') { _handleFollow(req, res); diff --git a/routes.js b/routes.js index 203134c..b9b9cdf 100755 --- a/routes.js +++ b/routes.js @@ -226,7 +226,6 @@ router.get('/:eventID/featured', (req, res) => { router.get('/:eventID/m/:hash', (req, res) => { const {hash, eventID} = req.params; const id = `https://${domain}/${eventID}/m/${hash}`; - console.log(id); Event.findOne({ id: eventID @@ -249,7 +248,6 @@ router.get('/:eventID/m/:hash', (req, res) => { }) .catch((err) => { addToLog("getActivityPubMessage", "error", "Attempt to get Activity Pub Message for " + id + " failed with error: " + err); - console.log(err) res.status(404); res.render('404', { url: req.url }); return; @@ -280,7 +278,6 @@ router.get('/.well-known/webfinger', (req, res) => { }) .catch((err) => { addToLog("renderWebfinger", "error", "Attempt to render webfinger for " + req.params.eventID + " failed with error: " + err); - console.log(err) res.status(404); res.render('404', { url: req.url }); return; @@ -440,9 +437,7 @@ router.get('/:eventID/followers', (req, res) => { }) .then((event) => { if (event) { - console.log(event.followers); const followers = event.followers.map(el => el.actorId); - console.log(followers) let followersCollection = { "type": "OrderedCollection", "totalItems": followers.length, @@ -1237,13 +1232,11 @@ router.post('/unattendevent/:eventID', (req, res) => { }); router.get('/oneclickunattendevent/:eventID/:attendeeID', (req, res) => { - console.log(req.params.eventID, req.params.attendeeID) Event.update( { id: req.params.eventID }, { $pull: { attendees: { _id: req.params.attendeeID } } } ) .then(response => { - console.log(response) addToLog("oneClickUnattend", "success", "Attendee removed via one click unattend " + req.params.eventID); if (sendEmails) { // currently this is never called because we don't have the email address -- cgit v1.2.3 From 783a5fbc270452148a6bcc92cb37469e6da349d7 Mon Sep 17 00:00:00 2001 From: Darius Kazemi Date: Mon, 6 Jan 2020 22:31:17 -0800 Subject: update homepage --- views/home.handlebars | 27 +++------------------------ 1 file changed, 3 insertions(+), 24 deletions(-) diff --git a/views/home.handlebars b/views/home.handlebars index 6f62a16..164250c 100755 --- a/views/home.handlebars +++ b/views/home.handlebars @@ -1,32 +1,11 @@ -

          🚨Experimental Fediverse Event Software🚨

          +

          {{siteName}}

          +

          - This is experimental software from Darius Kazemi. + gathio is a quick and easy way to make and share events which respects your privacy.


          -

          The Fediverse needs an event organizing system, so I've taken the incredibly lovely open source event organizing software gath.io and added my lightweight ActivityPub server to the mix.

          - -

          I fully expect this to break, and I would love early testers. I'm especially interested to know how this interacts with people who aren't on Mastodon.

          - -

          Directions

          - -

          Hit the green New Event button and put in your event details. Your event will have a nice looking home page and it will also have an ActivityPub-compatible account and profile. It will give you the account handle for the event, which will look something like @aB3_2HI@{{domain}} and can be shared with people on the Fediverse who want to follow your event.

          - -

          When a person follows your event, they'll follow a feed that updates whenever you update event details. After a person follows, this software will DM the person a poll (aka an ActivityPub "Question") where, at least in Mastodon, they can vote Yes/No/Maybe. If they vote Yes, then they will be registered as an attendee on your page. No and Maybe don't do anything yet.

          - -

          Also when you update your event (changing any of the fields), you send a DM notification to every user who is registered as attending.

          - -

          Further info

          - -

          I'll publish the source code soon, but it's in crummy and undocumented shape right now and I'd rather see it perform "in the wild" before I do an official release.

          - -

          If you'd like to chat with me about this software, I can be reached at @darius@friend.camp.

          - -

          What follows is the documentation from gath.io.

          - -
          -

          You don't need to sign up for an account - we just use your email to send you a secret link you can use to edit or delete your event. Send all your guests the public link, and all your co-hosts the editing link. A week after the event finishes, it's deleted from our servers for ever, and your email goes with it.

          -- cgit v1.2.3