Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pleroma federation fixes and hidden RSVP functionality #129

Merged
merged 7 commits into from
Feb 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading