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.

`; 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}} {{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