summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDarius Kazemi <darius.kazemi@gmail.com>2020-01-06 21:35:42 -0800
committerDarius Kazemi <darius.kazemi@gmail.com>2020-01-06 21:44:48 -0800
commit8029cfcd9221da9164d731ab3e7c20740f52fab7 (patch)
tree70eca52f290690c7f22ee189b8e8e680ca18d6d0
parentdcbf6c7268261639d67b0c8502cd205f815ba2fa (diff)
lots of refactoring
-rw-r--r--FEDERATION.md59
-rw-r--r--activitypub.js937
-rw-r--r--helpers.js19
-rwxr-xr-xroutes.js1022
-rwxr-xr-xviews/partials/neweventgroupform.handlebars2
5 files changed, 1161 insertions, 878 deletions
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": "<p><p>DESCRIPTION</p>\n</p><p>Location: LOCATION.</p><p>Starting DATETIME (human readable).</p>",
+ "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': `<p>${description}</p>`,
+ 'name': name,
+ 'featured': `https://${domain}/${eventID}/featured`,
+
+ 'publicKey': {
+ 'id': `https://${domain}/${eventID}#main-key`,
+ 'owner': `https://${domain}/${eventID}`,
+ 'publicKeyPem': pubkey
+ }
+ };
+ if (location) {
+ actor.summary += `<p>Location: ${location}.</p>`
+ }
+ let displayDate;
+ if (startUTC && timezone) {
+ displayDate = moment.tz(startUTC, timezone).format('D MMMM YYYY h:mm a');
+ actor.summary += `<p>Starting ${displayDate} ${timezone}.</p>`;
+ }
+ 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": `<p>This is an event that was posted on <a href="https://${domain}">${siteName}</a>. 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.</p><p>For more information on how to interact with this, <a href="https://github.com">check out this link</a>.</p>`,
+ '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 = `<p>${description}</p>`;
+ actor.name = name;
+ if (location) {
+ actor.summary += `<p>Location: ${location}.</p>`
+ }
+ let displayDate;
+ if (startUTC && timezone) {
+ displayDate = moment.tz(startUTC, timezone).format('D MMMM YYYY h:mm a');
+ actor.summary += `<p>Starting ${displayDate} ${timezone}.</p>`;
+ }
+ 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": `<span class=\"h-card\"><a href="${req.body.actor}" class="u-url mention">@<span>${name}</span></a></span> 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": `<span class=\"h-card\"><a href="${newAttendee.id}" class="u-url mention">@<span>${newAttendee.name}</span></a></span> Thanks for RSVPing! You can remove yourself from the RSVP list by clicking here: <a href="https://${domain}/oneclickunattendevent/${event.id}/${fullAttendee._id}">https://${domain}/oneclickunattendevent/${event.id}/${fullAttendee._id}</a>`,
+ "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": `<span class=\"h-card\"><a href="${newAttendee.id}" class="u-url mention">@<span>${newAttendee.name}</span></a></span> Thanks for RSVPing! You can remove yourself from the RSVP list by clicking here: <a href="https://${domain}/oneclickunattendevent/${event.id}/${fullAttendee._id}">https://${domain}/oneclickunattendevent/${event.id}/${fullAttendee._id}</a>`,
+ "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/
// &#63; to ? helper
-htmlEscapeToText = function (text) {
- return text.replace(/\&\#[0-9]*;|&amp;/g, function (escapeCode) {
- if (escapeCode.match(/amp/)) {
- return '&';
- }
- return String.fromCharCode(escapeCode.match(/[0-9]+/));
- });
+function htmlEscapeToText (text) {
+ return text.replace(/\&\#[0-9]*;|&amp;/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': `<p>${description}</p>`,
- 'name': name,
-
- 'publicKey': {
- 'id': `https://${domain}/${eventID}#main-key`,
- 'owner': `https://${domain}/${eventID}`,
- 'publicKeyPem': pubkey
- }
- };
- if (location) {
- actor.summary += `<p>Location: ${location}.</p>`
- }
- let displayDate;
- if (startUTC && timezone) {
- displayDate = moment.tz(startUTC, timezone).format('D MMMM YYYY h:mm a');
- actor.summary += `<p>Starting ${displayDate}.</p>`;
- }
- 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 = `<p>${description}</p>`;
- actor.name = name;
- if (location) {
- actor.summary += `<p>Location: ${location}.</p>`
- }
- let displayDate;
- if (startUTC && timezone) {
- displayDate = moment.tz(startUTC, timezone).format('D MMMM YYYY h:mm a');
- actor.summary += `<p>Starting ${displayDate}.</p>`;
- }
- 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 [<span class="text-muted">from</span>] h:mm a') + moment.tz(event.end, event.timezone).format(' [<span class="text-muted">to</span>] h:mm a [<span class="text-muted">](z)[</span>]');
@@ -701,10 +304,10 @@ router.get('/:eventID', (req, res) => {
else {
displayDate = moment.tz(event.start, event.timezone).format('dddd D MMMM YYYY [<span class="text-muted">at</span>] h:mm a') + moment.tz(event.end, event.timezone).format(' [<span class="text-muted">–</span>] dddd D MMMM YYYY [<span class="text-muted">at</span>] h:mm a [<span class="text-muted">](z)[</span>]');
}
- 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 = '<p>This event was just updated with new information.</p><ul>';
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: <a href="https://${domain}/${req.params.eventID}">https://${domain}/${req.params.eventID}</a>`,
+ 'cc': 'https://www.w3.org/ns/activitystreams#Public',
+ "content": `${diffText} See here: <a href="https://${domain}/${req.params.eventID}">https://${domain}/${req.params.eventID}</a>`,
}
- 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": `<p>${req.body.commentAuthor} commented: ${req.body.commentContent}.</p><p><a href="https://${domain}/${req.params.eventID}/">See the full conversation here.</a></p>`,
}
- 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": `<p>${req.body.replyAuthor} commented: ${req.body.replyContent}</p><p><a href="https://${domain}/${req.params.eventID}/">See the full conversation here.</a></p>`,
}
- 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": `<span class=\"h-card\"><a href="${req.body.actor}" class="u-url mention">@<span>${name}</span></a></span> 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": `<span class=\"h-card\"><a href="${newAttendee.id}" class="u-url mention">@<span>${newAttendee.name}</span></a></span> Thanks for RSVPing! You can remove yourself from the RSVP list by clicking here: <a href="https://${domain}/oneclickunattendevent/${event.id}/${fullAttendee._id}">https://${domain}/oneclickunattendevent/${event.id}/${fullAttendee._id}</a>`,
- "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 @@
<h4 class="mb-2">Create an event group</h4>
<p>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.</p>
-<p>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.</p>
+<p>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.</p>
<form id="newEventForm" action="/neweventgroup" method="post" enctype="multipart/form-data">
<div class="form-group row">
<label for="eventGroupName" class="col-sm-2 col-form-label">Event group name</label>