diff options
-rw-r--r-- | FEDERATION.md | 23 | ||||
-rwxr-xr-x | routes.js | 101 |
2 files changed, 75 insertions, 49 deletions
diff --git a/FEDERATION.md b/FEDERATION.md index ae5163e..8466414 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -10,20 +10,26 @@ To keep things simple, sometimes you will see things formatted like `Create/Note * `Delete/Event`: a `Delete` activity containing an `Event` in the `object` field * `Undo/Follow`: an `Undo` activity containing a `Follow` in the `object` field +When the word "broadcast" is used in this document, it means to send an Activity to individual inbox of each of the followers of a given Actor. + This document has three main sections: * __Federation philosophy__ lays out the general model of how this is intended to federate * __Inbox behavior__ lists every incoming ActivityPub activity that the server recognizes, and tells you what it does in response to that activity, including any other ActivityPub activities it sends back out. * __Activities triggered from the web app__ tells you what circumstances on the web application cause the server to emit ActivityPub activities. (For example, when an event is updated via the web application, it lets all the ActivityPub followers know that the event has been updated.) +Please note: there is an unfortunate collision between the English language and the ActivityPub spec that can make this document confusing. When this document uses the word 'event' with a lowercase-e and not in monospace, it refers to the thing that is being tracked in gathio: events that are being organized. When this document uses the word `Event` with a capital E and in monospace, it refers to the [`Event` object defined in the ActivityStreams Vocabulary spec](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-event). + ## Federation philosophy The first-class Actor in gathio is an event. So every time an event organizer creates a page for a new event, there is a new, followable Actor on the fediverse. The idea is that humans want to follow events and get updates on important changes to the events. -This differs from other ActivityPub-compatible software I've seen, which considers _people_ first class, and you follow an Actor representing a person and then you get updates on all their events. I think that is silly, and I like my model better. +This differs from other ActivityPub-compatible software I've seen, which considers _people_ first class, and you follow an Actor representing a person and then you get updates on all their events. I think that is silly, and I like my model better. From my perspective, the accounts of _people_ should live on people-focused services like Mastodon/Pleroma/Friendica/etc. This service is for events, and thus events are its first-class Actor. Also, gathio prides itself on deleting ALL data related to an event 7 days after the event is over. So we don't retain old messages once an event is deleted, and events are meant to be represented by Actors that only exist for the duration of the event plus 7 days. This is handled via thorough `Delete` messaging. +The point of federating this is so that people can simply follow an event and get all the updates they care about, and even RSVP to and comment on the event directly from their ActivityPub client. This is all without signing up or anything on gathio. + ## Inbox behavior This section describes how gathio responds to _incoming messages_ to its inbox. @@ -34,7 +40,7 @@ Gathio has a single, universal inbox shared between all Actors. The url is: `https://DOMAIN/activitypub/inbox` -You can talk to gathio by POSTing to that url as you would any ActivityPub server. +You can talk to gathio by POSTing to that url as you would any ActivityPub server. The `to` (or sometimes `cc` field) is what lets us know which event Actor you're interacting with. ### Follow @@ -44,7 +50,7 @@ Assuming we can find the `Actor ` object, then we emit an `Accept` Activity back After this, we *also* send a `Create` Activity to the actor's inbox, containing an `Event` object with the information for this event. This is, at the moment, future compatibility for servers that consume `Event` objects. This is sent as a "direct message", directly to the inbox with no `cc` field and not addressing the public timeline. -And finally we send the user a `Create` Activity containing a `Question` object. Mastodon renders this as a poll to the user, which lets them send back to us a "Yes" RSVP directly from their client UI should they so choose. This is also sent as a "direct message". +And finally we send the user a `Create` Activity containing a `Question` object. The `Question` is an invitation to RSVP to the event. Mastodon renders this as a poll to the user, which lets them send back to us a "Yes" RSVP directly from their client UI should they so choose. This is also sent as a "direct message". ### Unfollow @@ -52,7 +58,14 @@ When the server receives an `Undo/Follow`, it checks to see if that follower exi We currently do _not_ send an `Accept/Undo` in response, as I'm not sure this is ever needed in the wild. -### RSVP (aka voting in the poll) +### RSVP + +The plan is to have this support two ways to RSVP: + +1. The user answers the `Question` sent out to the prospective attendee in the form of a `Create/Note` in the style of Mastodon polls. This is mostly a hack for implementations like Mastodon that don't have vocabulary built in to RSVP to `Event`s. +2. (TODO) The user sends a `Accept/Event`, `Reject/Event`, or `TentativeAccept/Event` back to our server. This is for implementations that support `Event` and do things like automatically render incoming events in their UI with an RSVP interface. + +The first method is the only one implemented right now. It works as follows. If the inbox gets a `Create/Note`, there is a chance that this is a response to a `Question` that we sent a user. So the first thing we do is check its `inReplyTo` property. If it matches the id of a `Question` we sent this user, and this user is still following us, then we fetch the user's profile info. This is to make sure we have their newest `preferredName` in their Actor object, which we will honor as the name we display on the RSVP. We then add this person to our database as an attendee of the event. @@ -62,7 +75,7 @@ Next we confirm that the user has RSVPed. We do this by sending them a `Create/N If we are CC'ed on a _public or unlisted_ `Create/Note`, then that is considered to be a comment on the event, which we store in our database and render on the event page if the administrator has enabled commenting. -After the comment is added and rendered on the front page, we also broadcast the comment as a `Create/Note` to all followers. It appears in their home timelines as though the event they are following posted some content for them to see. +After the comment is added and rendered on the front page, we also broadcast to our followers an `Announce/Note`, containing a copy of the `Note` we just received. Some implementations treat this as a "boost", where people following our account, but not necessarily following the account that wrote the `Note`, will see the `Note` rendered with credit to the original author, promoted on behalf of our account. ### Delete comment @@ -189,6 +189,7 @@ function createActivityPubActor(eventID, domain, pubkey, description, name, loca 'type': 'Person', 'preferredUsername': `${eventID}`, 'inbox': `https://${domain}/activitypub/inbox`, + 'outbox': `https://${domain}/${eventID}/outbox`, 'followers': `https://${domain}/${eventID}/followers`, 'summary': `<p>${description}</p>`, 'name': name, @@ -362,6 +363,57 @@ function broadcastMessage(apObject, followers, eventID, callback) { } // end followers } +// sends an Announce for the apObject +function broadcastAnnounceMessage(apObject, followers, eventID, callback) { + callback = callback || function() {}; + let guidUpdate = crypto.randomBytes(16).toString('hex'); + console.log('broadcasting announce'); + // iterate over followers + for (const follower of followers) { + let actorId = follower.actorId; + let myURL = new URL(actorId); + let targetDomain = myURL.hostname; + // get the inbox + Event.findOne({ + id: eventID, + }, function(err, event) { + console.log('found the event for broadcast') + if (event) { + const follower = event.followers.find(el => el.actorId === actorId); + if (follower) { + const actorJson = JSON.parse(follower.actorJson); + const inbox = actorJson.inbox; + const announceMessage = { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': `https://${domain}/m/${guidUpdate}`, + 'cc': 'https://www.w3.org/ns/activitystreams#Public', + 'type': 'Announce', + 'actor': `https://${domain}/${eventID}`, + 'object': apObject, + 'to': actorId + }; + signAndSend(announceMessage, eventID, targetDomain, inbox, function(err, resp, status) { + if (err) { + console.log(`Didn't sent to ${actorId}, status ${status} with error ${err}`); + } + else { + console.log('sent to', actorId); + } + }); + } + else { + console.log(`No follower found with the id ${actorId}`); + callback(`No follower found with the id ${actorId}`, null, 404); + } + } + else { + console.log(`No event found with the id ${eventID}`); + callback(`No event found with the id ${eventID}`, null, 404); + } + }); + } // end followers +} +// sends an Update for the apObject function broadcastUpdateMessage(apObject, followers, eventID, callback) { callback = callback || function() {}; let guidUpdate = crypto.randomBytes(16).toString('hex'); @@ -2120,7 +2172,7 @@ function processInbox(req, res) { .catch((err) => { res.sendStatus(500); addToLog("deleteComment", "error", "Attempt to delete comment " + req.body.object.id + "from event " + eventWithComment.id + " failed with error: " + err);}); }); } - // if we are CC'ed on a public or unlisted Create/Note, then this is a comment to us we should replicate + // if we are CC'ed on a public or unlisted Create/Note, then this is a comment to us we should boost (Announce) to our followers if (req.body && req.body.type === 'Create' && req.body.object && req.body.object.type === 'Note' && req.body.object.to) { // figure out what this is in reply to -- it should be addressed specifically to us let {attributedTo, inReplyTo, to, cc} = req.body.object; @@ -2177,16 +2229,10 @@ function processInbox(req, res) { event.save() .then(() => { addToLog("addEventComment", "success", "Comment added to event " + eventID); - // broadcast an identical message to all followers, will show in their home timeline const guidObject = crypto.randomBytes(16).toString('hex'); - const jsonObject = { - "@context": "https://www.w3.org/ns/activitystreams", - "id": `https://${domain}/m/${guidObject}`, - "name": `Comment on ${event.name}`, - "type": "Note", - "content": newComment.content, - } - broadcastMessage(jsonObject, event.followers, req.params.eventID) + const jsonObject = req.body.object; + jsonObject.attributedTo = newComment.actorId; + broadcastAnnounceMessage(jsonObject, event.followers, eventID) console.log('added comment'); res.sendStatus(200); }) @@ -2196,40 +2242,7 @@ function processInbox(req, res) { }); } // end ourevent } // end public message - // if it's not a public message, AND it's not a vote let them know that we only support public messages right now - else if (req.body.object.name !== 'Yes') { - if (!cc) { - cc = []; - } - // figure out which event(s) of ours it was addressing - ourEvents = cc.concat(to).filter(el => el.includes(`https://${domain}/`)) - .map(el => el.replace(`https://${domain}/`,'')); - // comments should only be on one event. if more than one, ignore (spam, probably) - if (ourEvents.length === 1) { - let eventID = ourEvents[0]; - // get the user's actor info - request({ - url: req.body.actor, - headers: { - 'Accept': 'application/activity+json', - 'Content-Type': 'application/activity+json' - }}, function (error, response, actor) { - actor = JSON.parse(actor); - const name = actor.preferredUsername || actor.name || req.body.actor; - const jsonObject = { - "@context": "https://www.w3.org/ns/activitystreams", - "type": "Note", - "inReplyTo": req.body.object.id, - "content": `<span class=\"h-card\"><a href="${req.body.actor}" class="u-url mention">@<span>${name}</span></a></span> Sorry, this service only supports posting public messages to the event page. Try contacting the event organizer directly if you need to have a private conversation.`, - "tag":[{"type":"Mention","href":req.body.actor,"name":name}] - } - res.sendStatus(200); - sendDirectMessage(jsonObject, req.body.actor, eventID); - } - ); - } - } - } + } // CC'ed } router.use(function(req, res, next){ |