Skip to content

Commit

Permalink
Merge pull request #129 from lowercasename/rk/fix-pleroma
Browse files Browse the repository at this point in the history
Pleroma federation fixes and hidden RSVP functionality
  • Loading branch information
lowercasename authored Feb 6, 2024
2 parents ecff04b + e40ef51 commit 9e249e5
Show file tree
Hide file tree
Showing 12 changed files with 344 additions and 168 deletions.
16 changes: 15 additions & 1 deletion cypress/e2e/event.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ describe("Events", () => {
);
});

it("allows you to attend an event", function () {
it("allows you to attend an event - visible in public list", function () {
cy.get("button#attendEvent").click();
cy.get("#attendeeName").type("Test Attendee");
cy.get("#attendeeNumber").focus().clear();
Expand All @@ -99,6 +99,20 @@ describe("Events", () => {
);
});

it("allows you to attend an event - hidden from public list", function () {
cy.get("button#attendEvent").click();
cy.get("#attendeeName").type("Test Attendee");
cy.get("#attendeeNumber").focus().clear();
cy.get("#attendeeNumber").type("2");
cy.get("#attendeeVisible").uncheck();
cy.get("form#attendEventForm").submit();
cy.get("#attendees-alert").should("contain.text", "8 spots remaining");
cy.get(".attendeesList").should(
"contain.text",
"Test Attendee (2 people) (hidden from public list)",
);
});

it("allows you to comment on an event", function () {
cy.get("#commentAuthor").type("Test Author");
cy.get("#commentContent").type("Test Comment");
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"license": "GPL-3.0-or-later",
"dependencies": {
"@sendgrid/mail": "^6.5.5",
"activitypub-types": "^1.0.3",
"cors": "^2.8.5",
"dompurify": "^3.0.6",
"express": "^4.18.2",
Expand Down
7 changes: 7 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 26 additions & 0 deletions public/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,28 @@ body, html {
font-weight: bold;
}

.attendeesList > li.hidden-attendee {
border: 4px solid #ccc;
background: #eee;
}

.attendeesList > li.hidden-attendee a {
color: #555;
}

.hidden-attendees-message {
display: inline-block;
border: 4px solid #ccc;
text-align: center;
border-radius: 2em;
padding: 0.5em 1em;
background: #eee;
color: #555;
font-size: 0.95em;
font-weight: bold;
margin: 0;
}

.expand {
-webkit-transition: height 0.2s;
-moz-transition: height 0.2s;
Expand Down Expand Up @@ -321,6 +343,10 @@ body, html {
color: #fff;
}

li.hidden-attendee .attendee-name {
color: #555;
}

.remove-attendee {
color: #fff;
}
Expand Down
189 changes: 44 additions & 145 deletions src/activitypub.js

Large diffs are not rendered by default.

168 changes: 168 additions & 0 deletions src/lib/activitypub.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,18 @@
import { Request, Response } from "express";
import Event, { IAttendee } from "../models/Event.js";
import { sendDirectMessage } from "../activitypub.js";
import { successfulRSVPResponse } from "./activitypub/templates.js";

interface APObject {
type: "Note";
actor?: string;
id: string;
to?: string | string[];
cc?: string | string[];
attributedTo: string;
inReplyTo: string;
name: string;
}

// From https://www.w3.org/TR/activitypub/#client-to-server-interactions:
// "Servers MAY interpret a Content-Type or Accept header of application/activity+json
Expand All @@ -20,3 +34,157 @@ export const acceptsActivityPub = (req: Request) => {
(header) => req.headers.accept?.includes(header),
);
};

// At least for poll responses, Mastodon stores the recipient (the poll-maker)
// in the 'to' field, while Pleroma stores it in 'cc'
export const getNoteRecipient = (object: APObject): string | null => {
const { to, cc } = object;
if (!to && !cc) {
return "";
}
if (to && to.length > 0) {
if (Array.isArray(to)) {
return to[0];
}
if (typeof to === "string") {
return to;
}
return null;
} else if (cc && cc.length > 0) {
if (Array.isArray(cc)) {
return cc[0];
}
return cc;
}
return null;
};

// Returns the event ID from a URL like http://localhost:3000/123abc
// or https://gath.io/123abc
export const getEventId = (url: string): string => {
try {
return new URL(url).pathname.replace("/", "");
} catch (error) {
// Apparently not a URL so maybe it's just the ID
return url;
}
};

export const handlePollResponse = async (req: Request, res: Response) => {
try {
// figure out what this is in reply to -- it should be addressed specifically to us
const { attributedTo, inReplyTo, name } = req.body.object as APObject;
const recipient = getNoteRecipient(req.body.object);
if (!recipient) throw new Error("No recipient found");

const eventID = getEventId(recipient);
const event = await Event.findOne({ id: eventID });
if (!event) throw new Error("Event not found");

// make sure this person is actually a follower of the event
const senderAlreadyFollows = event.followers?.some(
(el) => el.actorId === attributedTo,
);
if (!senderAlreadyFollows) {
throw new Error("Poll response sender does not follow event");
}

// 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 matchingMessage = event.activityPubMessages?.find((el) => {
const content = JSON.parse(el.content || "");
return inReplyTo === content?.object?.id;
});
if (!matchingMessage) throw new Error("No matching message found");
const messageContent = JSON.parse(matchingMessage.content || "");
// check if the message we sent out was sent to the actor this incoming
// message is attributedTo
const messageRecipient = getNoteRecipient(messageContent.object);
if (!messageRecipient || messageRecipient !== attributedTo) {
throw new Error("Message recipient does not match attributedTo");
}

// it's a match, this is a valid poll response, add RSVP to database

// 'name' is the poll response
// - "Yes, and show me in the public list",
// - "Yes, but hide me from the public list",
// - "No"
if (
name !== "Yes, and show me in the public list" &&
name !== "Yes, but hide me from the public list" &&
name !== "No"
) {
throw new Error("Invalid poll response");
}

if (name === "No") {
// Why did you even respond?
return res.status(200).send("Thanks I guess?");
}

const visibility =
name === "Yes, and show me in the public list"
? "public"
: "private";

// fetch the profile information of the user
const response = await fetch(attributedTo, {
headers: {
Accept: activityPubContentType,
"Content-Type": activityPubContentType,
},
});
if (!response.ok) throw new Error("Actor not found");
const apActor = await response.json();

// If the actor is not already attending the event, add them
if (!event.attendees?.some((el) => el.id === attributedTo)) {
const attendeeName =
apActor.preferredUsername || apActor.name || attributedTo;
const newAttendee: Partial<IAttendee> = {
name: attendeeName,
status: "attending",
id: attributedTo,
number: 1,
visibility,
};
const updatedEvent = await Event.findOneAndUpdate(
{ id: eventID },
{ $push: { attendees: newAttendee } },
{ new: true },
).exec();
const fullAttendee = updatedEvent?.attendees?.find(
(el) => el.id === attributedTo,
);
if (!fullAttendee) throw new Error("Full attendee not found");

// 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: successfulRSVPResponse({
event,
newAttendee,
fullAttendee,
}),
tag: [
{
type: "Mention",
href: newAttendee.id,
name: newAttendee.name,
},
],
};
// send direct message to user
sendDirectMessage(jsonObject, newAttendee.id, event.id);
return res.sendStatus(200);
} else {
return res.status(200).send("Attendee is already registered.");
}
} catch (error) {
console.error(error);
return res.status(500).send("An unexpected error occurred.");
}
};
14 changes: 14 additions & 0 deletions src/lib/activitypub/templates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { IEvent } from "../../models/Event.js";
import getConfig from "../config.js";
const config = getConfig();

export const successfulRSVPResponse = ({
event,
newAttendee,
fullAttendee,
}: {
event: IEvent;
newAttendee: { id: string; name: string };
fullAttendee: { _id: string };
}) =>
`<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 <a href="https://${config.general.domain}/oneclickunattendevent/${event.id}/${fullAttendee._id}">here</a>.`;
6 changes: 6 additions & 0 deletions src/models/Event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface IAttendee {
number?: number;
created?: Date;
_id: string;
visibility?: "public" | "private";
}

export interface IReply {
Expand Down Expand Up @@ -105,6 +106,11 @@ const Attendees = new mongoose.Schema({
trim: true,
default: 1,
},
visibility: {
type: String,
trim: true,
default: "public",
},
created: Date,
});

Expand Down
Loading

0 comments on commit 9e249e5

Please sign in to comment.