import fs from "fs"; import express from "express"; import { customAlphabet } from "nanoid"; import randomstring from "randomstring"; import { getConfig } from "./lib/config.js"; import { addToLog, exportIcal } from "./helpers.js"; import moment from "moment-timezone"; import { marked } from "marked"; import generateRSAKeypair from "generate-rsa-keypair"; import crypto from "crypto"; import request from "request"; import niceware from "niceware"; import ical from "ical"; import sgMail from "@sendgrid/mail"; import nodemailer from "nodemailer"; import fileUpload from "express-fileupload"; import Jimp from "jimp"; import schedule from "node-schedule"; import { createActivityPubActor, createActivityPubEvent, createFeaturedPost, createWebfinger, updateActivityPubActor, updateActivityPubEvent, broadcastCreateMessage, broadcastUpdateMessage, broadcastDeleteMessage, sendDirectMessage, processInbox, } from "./activitypub.js"; import Event from "./models/Event.js"; import EventGroup from "./models/EventGroup.js"; import path from "path"; const config = getConfig(); const domain = config.general.domain; const contactEmail = config.general.email; const siteName = config.general.site_name; const mailService = config.general.mail_service; const siteLogo = config.general.email_logo_url; const isFederated = config.general.is_federated || true; const showKofi = config.general.show_kofi; // This alphabet (used to generate all event, group, etc. IDs) is missing '-' // because ActivityPub doesn't like it in IDs const nanoid = customAlphabet( "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_", 21, ); const router = express.Router(); let sendEmails = false; let nodemailerTransporter; if (config.general.mail_service) { switch (config.general.mail_service) { case "sendgrid": sgMail.setApiKey(config.sendgrid?.api_key); console.log("Sendgrid is ready to send emails."); sendEmails = true; break; case "nodemailer": nodemailerTransporter = nodemailer.createTransport({ host: config.nodemailer?.smtp_server, port: config.nodemailer?.smtp_port, secure: false, // true for 465, false for other ports auth: { user: config.nodemailer?.smtp_username, pass: config.nodemailer?.smtp_password, }, }); nodemailerTransporter.verify((error, success) => { if (error) { console.log(error); } else { console.log( "Nodemailer SMTP server is ready to send emails.", ); sendEmails = true; } }); break; default: console.error( "You have not configured this Gathio instance to send emails! This means that event creators will not receive emails when their events are created, which means they may end up locked out of editing events. Consider setting up an email service.", ); } } router.use(fileUpload()); // SCHEDULED DELETION schedule.scheduleJob("59 23 * * *", function (fireDate) { const too_old = moment.tz("Etc/UTC").subtract(7, "days").toDate(); console.log( "Old event deletion running! Deleting all events concluding before ", too_old, ); Event.find({ end: { $lte: too_old } }) .then((oldEvents) => { oldEvents.forEach((event) => { const deleteEventFromDB = (id) => { Event.remove({ _id: id }) .then((response) => { addToLog( "deleteOldEvents", "success", "Old event " + id + " deleted", ); }) .catch((err) => { addToLog( "deleteOldEvents", "error", "Attempt to delete old event " + id + " failed with error: " + err, ); }); }; if (event.image) { fs.unlink( path.join( process.cwd(), "/public/events/" + event.image, ), (err) => { if (err) { addToLog( "deleteOldEvents", "error", "Attempt to delete event image for old event " + event.id + " failed with error: " + err, ); } // Image removed addToLog( "deleteOldEvents", "error", "Image deleted for old event " + event.id, ); }, ); } // Check if event has ActivityPub fields if (event.activityPubActor && event.activityPubEvent) { // 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) { broadcastDeleteMessage( jsonEventObject, event.followers, event.id, function (statuses) { deleteEventFromDB(event._id); }, ); }, ); } else { // No ActivityPub data - simply delete the event deleteEventFromDB(event._id); } }); }) .catch((err) => { addToLog( "deleteOldEvents", "error", "Attempt to delete old event " + event.id + " failed with error: " + err, ); }); // TODO: While we're here, also remove all provisioned event attendees over a day // old (they're not going to become active) }); // return the JSON for the featured/pinned post for this event router.get("/:eventID/featured", (req, res) => { if (!isFederated) return res.sendStatus(404); 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: [createFeaturedPost(eventID)], }; if ( req.headers.accept && (req.headers.accept.includes("application/activity+json") || req.headers.accept.includes("application/ld+json")) ) { res.header("Content-Type", "application/activity+json").send(featured); } else { res.header("Content-Type", "application/json").send(featured); } }); // return the JSON for a given activitypub message router.get("/:eventID/m/:hash", (req, res) => { if (!isFederated) return res.sendStatus(404); const { hash, eventID } = req.params; const id = `https://${domain}/${eventID}/m/${hash}`; 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) { if ( req.headers.accept && (req.headers.accept.includes( "application/activity+json", ) || req.headers.accept.includes("application/ld+json")) ) { res.header( "Content-Type", "application/activity+json", ).send(JSON.parse(message.content)); } else { res.header("Content-Type", "application/json").send( 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, ); res.status(404); res.render("404", { url: req.url }); return; }); }); // return the webfinger record required for the initial activitypub handshake router.get("/.well-known/webfinger", (req, res) => { if (!isFederated) return res.sendStatus(404); 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.', ); } else { // "foo@domain" let activityPubAccount = resource.replace("acct:", ""); // "foo" let eventID = activityPubAccount.replace(/@.*/, ""); Event.findOne({ id: eventID, }) .then((event) => { if (!event) { res.status(404); res.render("404", { url: req.url }); } else { if ( req.headers.accept && (req.headers.accept.includes( "application/activity+json", ) || req.headers.accept.includes("application/ld+json")) ) { res.header( "Content-Type", "application/activity+json", ).send(createWebfinger(eventID, domain)); } else { res.header("Content-Type", "application/json").send( createWebfinger(eventID, domain), ); } } }) .catch((err) => { addToLog( "renderWebfinger", "error", "Attempt to render webfinger for " + req.params.eventID + " failed with error: " + err, ); res.status(404); res.render("404", { url: req.url }); return; }); } }); router.get("/:eventID/followers", (req, res) => { if (!isFederated) return res.sendStatus(404); const eventID = req.params.eventID; Event.findOne({ id: eventID, }).then((event) => { if (event) { const followers = event.followers.map((el) => el.actorId); let followersCollection = { type: "OrderedCollection", totalItems: followers.length, id: `https://${domain}/${eventID}/followers`, first: { type: "OrderedCollectionPage", totalItems: followers.length, partOf: `https://${domain}/${eventID}/followers`, orderedItems: followers, id: `https://${domain}/${eventID}/followers?page=1`, }, "@context": ["https://www.w3.org/ns/activitystreams"], }; if ( req.headers.accept && (req.headers.accept.includes("application/activity+json") || req.headers.accept.includes("application/ld+json")) ) { return res .header("Content-Type", "application/activity+json") .send(followersCollection); } else { return res .header("Content-Type", "application/json") .send(followersCollection); } } else { return res.status(400).send("Bad request."); } }); }); router.get("/group/:eventGroupID", (req, res) => { EventGroup.findOne({ id: req.params.eventGroupID, }) .lean() // Required, see: https://stackoverflow.com/questions/59690923/handlebars-access-has-been-denied-to-resolve-the-property-from-because-it-is .then(async (eventGroup) => { if (eventGroup) { let parsedDescription = marked.parse(eventGroup.description); let eventGroupEditToken = eventGroup.editToken; let escapedName = eventGroup.name.replace(/\s+/g, "+"); let eventGroupHasCoverImage = false; if (eventGroup.image) { eventGroupHasCoverImage = true; } else { eventGroupHasCoverImage = false; } let eventGroupHasHost = false; if (eventGroup.hostName) { eventGroupHasHost = true; } else { eventGroupHasHost = false; } let events = await Event.find({ eventGroup: eventGroup._id }) .lean() .sort("start"); events.map((event) => { if ( moment .tz(event.end, event.timezone) .isSame(event.start, "day") ) { // Happening during one day event.displayDate = moment .tz(event.start, event.timezone) .format("D MMM YYYY"); } else { event.displayDate = moment .tz(event.start, event.timezone) .format("D MMM YYYY") + moment .tz(event.end, event.timezone) .format(" - D MMM YYYY"); } if ( moment .tz(event.end, event.timezone) .isBefore(moment.tz(event.timezone)) ) { event.eventHasConcluded = true; } else { event.eventHasConcluded = false; } return (({ id, name, displayDate, eventHasConcluded }) => ({ id, name, displayDate, eventHasConcluded, }))(event); }); let upcomingEventsExist = false; if (events.some((e) => e.eventHasConcluded === false)) { upcomingEventsExist = true; } let firstLoad = false; if (eventGroup.firstLoad === true) { firstLoad = true; EventGroup.findOneAndUpdate( { id: req.params.eventGroupID }, { firstLoad: false }, function (err, raw) { if (err) { res.send(err); } }, ); } let editingEnabled = false; if (Object.keys(req.query).length !== 0) { if (!req.query.e) { editingEnabled = false; console.log("No edit token set"); } else { if (req.query.e === eventGroupEditToken) { editingEnabled = true; } else { editingEnabled = false; } } } let metadata = { title: eventGroup.name, description: marked .parse(eventGroup.description, { renderer: render_plain(), }) .split(" ") .splice(0, 40) .join(" ") .trim(), image: eventGroupHasCoverImage ? `https://${domain}/events/` + eventGroup.image : null, url: `https://${domain}/` + req.params.eventID, }; res.set("X-Robots-Tag", "noindex"); res.render("eventgroup", { domain: domain, title: eventGroup.name, eventGroupData: eventGroup, escapedName: escapedName, events: events, upcomingEventsExist: upcomingEventsExist, parsedDescription: parsedDescription, editingEnabled: editingEnabled, eventGroupHasCoverImage: eventGroupHasCoverImage, eventGroupHasHost: eventGroupHasHost, firstLoad: firstLoad, metadata: metadata, }); } else { res.status(404); res.render("404", { url: req.url }); } }) .catch((err) => { addToLog( "displayEventGroup", "error", "Attempt to display event group " + req.params.eventGroupID + " failed with error: " + err, ); console.log(err); res.status(404); res.render("404", { url: req.url }); return; }); }); router.get("/group/:eventGroupID/feed.ics", (req, res) => { EventGroup.findOne({ id: req.params.eventGroupID, }) .lean() // Required, see: https://stackoverflow.com/questions/59690923/handlebars-access-has-been-denied-to-resolve-the-property-from-because-it-is .then(async (eventGroup) => { if (eventGroup) { let events = await Event.find({ eventGroup: eventGroup._id }) .lean() .sort("start"); const string = exportIcal(events, eventGroup.name); res.set("Content-Type", "text/calendar"); return res.send(string); } }) .catch((err) => { addToLog( "eventGroupFeed", "error", "Attempt to display event group feed for " + req.params.eventGroupID + " failed with error: " + err, ); console.log(err); res.status(404); res.render("404", { url: req.url }); return; }); }); router.get("/exportevent/:eventID", (req, res) => { Event.findOne({ id: req.params.eventID, }) .populate("eventGroup") .then((event) => { if (event) { const string = exportIcal([event]); res.send(string); } }) .catch((err) => { addToLog( "exportEvent", "error", "Attempt to export event " + req.params.eventID + " failed with error: " + err, ); console.log(err); res.status(404); res.render("404", { url: req.url }); return; }); }); router.get("/exportgroup/:eventGroupID", (req, res) => { EventGroup.findOne({ id: req.params.eventGroupID, }) .lean() // Required, see: https://stackoverflow.com/questions/59690923/handlebars-access-has-been-denied-to-resolve-the-property-from-because-it-is .then(async (eventGroup) => { if (eventGroup) { let events = await Event.find({ eventGroup: eventGroup._id }) .lean() .sort("start"); const string = exportIcal(events); res.send(string); } }) .catch((err) => { addToLog( "exportEvent", "error", "Attempt to export event group " + req.params.eventGroupID + " failed with error: " + err, ); console.log(err); res.status(404); res.render("404", { url: req.url }); return; }); }); // BACKEND ROUTES router.post("/newevent", async (req, res) => { let eventID = nanoid(); let editToken = randomstring.generate(); let eventImageFilename = ""; let isPartOfEventGroup = false; if (req.files && Object.keys(req.files).length !== 0) { let eventImageBuffer = req.files.imageUpload.data; eventImageFilename = await Jimp.read(eventImageBuffer) .then((img) => { img.resize(920, Jimp.AUTO) // resize .quality(80) // set JPEG quality .write("./public/events/" + eventID + ".jpg"); // save const filename = eventID + ".jpg"; return filename; }) .catch((err) => { addToLog( "Jimp", "error", "Attempt to edit image failed with error: " + err, ); }); } 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({ id: req.body.eventGroupID, editToken: req.body.eventGroupEditToken, }); if (eventGroup) { isPartOfEventGroup = true; } } // generate RSA keypair for ActivityPub let pair = generateRSAKeypair(); const event = new Event({ id: eventID, type: "public", // This is for backwards compatibility name: req.body.eventName, location: req.body.eventLocation, start: startUTC, end: endUTC, timezone: req.body.timezone, description: req.body.eventDescription, image: eventImageFilename, creatorEmail: req.body.creatorEmail, url: req.body.eventURL, hostName: req.body.hostName, viewPassword: req.body.viewPassword, editPassword: req.body.editPassword, editToken: editToken, eventGroup: isPartOfEventGroup ? eventGroup._id : null, usersCanAttend: req.body.joinCheckbox ? true : false, showUsersList: req.body.guestlistCheckbox ? true : false, usersCanComment: req.body.interactionCheckbox ? true : false, maxAttendees: req.body.maxAttendees, firstLoad: true, activityPubActor: createActivityPubActor( eventID, domain, pair.public, marked.parse(req.body.eventDescription), req.body.eventName, req.body.eventLocation, eventImageFilename, startUTC, endUTC, req.body.timezone, ), activityPubEvent: 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( createFeaturedPost( eventID, req.body.eventName, startUTC, endUTC, req.body.timezone, req.body.eventDescription, req.body.eventLocation, ), ), }, ], publicKey: pair.public, privateKey: pair.private, }); event .save() .then((event) => { addToLog("createEvent", "success", "Event " + eventID + "created"); // Send email with edit link if (req.body.creatorEmail && sendEmails) { req.app.get("hbsInstance").renderView( "./views/emails/createevent.handlebars", { eventID, editToken, siteName, siteLogo, domain, cache: true, layout: "email.handlebars", }, function (err, html) { const msg = { to: req.body.creatorEmail, from: { name: siteName, email: contactEmail, address: contactEmail, }, subject: `${siteName}: ${req.body.eventName}`, html, }; switch (mailService) { case "sendgrid": sgMail.send(msg).catch((e) => { console.error(e.toString()); res.status(500).end(); }); break; case "nodemailer": nodemailerTransporter .sendMail(msg) .catch((e) => { console.error(e.toString()); res.status(500).end(); }); break; } }, ); } // If the event was added to a group, send an email to any group // subscribers if (event.eventGroup && sendEmails) { EventGroup.findOne({ _id: event.eventGroup._id }).then( (eventGroup) => { const subscribers = eventGroup.subscribers.reduce( (acc, current) => { if (acc.includes(current.email)) { return acc; } return [current.email, ...acc]; }, [], ); subscribers.forEach((emailAddress) => { req.app.get("hbsInstance").renderView( "./views/emails/eventgroupupdated.handlebars", { siteName, siteLogo, domain, eventID: req.params.eventID, eventGroupName: eventGroup.name, eventName: event.name, eventID: event.id, eventGroupID: eventGroup.id, emailAddress: encodeURIComponent(emailAddress), cache: true, layout: "email.handlebars", }, function (err, html) { const msg = { to: emailAddress, from: { name: siteName, email: contactEmail, }, subject: `${siteName}: New event in ${eventGroup.name}`, html, }; switch (mailService) { case "sendgrid": sgMail.send(msg).catch((e) => { console.error(e.toString()); res.status(500).end(); }); break; case "nodemailer": nodemailerTransporter .sendMail(msg) .catch((e) => { console.error(e.toString()); res.status(500).end(); }); break; } }, ); }); }, ); } res.writeHead(302, { Location: "/" + eventID + "?e=" + editToken, }); res.end(); }) .catch((err) => { console.error(err); res.status(500).send( "Database error, please try again :( - " + err, ); addToLog( "createEvent", "error", "Attempt to create event failed with error: " + err, ); }); }); router.post("/importevent", (req, res) => { let eventID = nanoid(); let editToken = randomstring.generate(); if (req.files && Object.keys(req.files).length !== 0) { let iCalObject = ical.parseICS( req.files.icsImportControl.data.toString("utf8"), ); let importedEventData = iCalObject[Object.keys(iCalObject)]; let creatorEmail; if (req.body.creatorEmail) { creatorEmail = req.body.creatorEmail; } else if (importedEventData.organizer) { creatorEmail = importedEventData.organizer.val.replace( "MAILTO:", "", ); } const event = new Event({ id: eventID, type: "public", name: importedEventData.summary, location: importedEventData.location, start: importedEventData.start, end: importedEventData.end, timezone: typeof importedEventData.start.tz !== "undefined" ? importedEventData.start.tz : "Etc/UTC", description: importedEventData.description, image: "", creatorEmail: creatorEmail, url: "", hostName: importedEventData.organizer ? importedEventData.organizer.params.CN.replace(/["]+/g, "") : "", viewPassword: "", editPassword: "", editToken: editToken, usersCanAttend: false, showUsersList: false, usersCanComment: false, firstLoad: true, }); event .save() .then(() => { addToLog( "createEvent", "success", "Event " + eventID + " created", ); // Send email with edit link if (creatorEmail && sendEmails) { req.app.get("hbsInstance").renderView( "./views/emails/createevent.handlebars", { eventID, editToken, siteName, siteLogo, domain, cache: true, layout: "email.handlebars", }, function (err, html) { const msg = { to: req.body.creatorEmail, from: { name: siteName, email: contactEmail, address: contactEmail, }, subject: `${siteName}: ${importedEventData.summary}`, html, }; switch (mailService) { case "sendgrid": sgMail.send(msg).catch((e) => { console.error(e.toString()); res.status(500).end(); }); break; case "nodemailer": nodemailerTransporter .sendMail(msg) .catch((e) => { console.error(e.toString()); res.status(500).end(); }); break; } }, ); } res.writeHead(302, { Location: "/" + eventID + "?e=" + editToken, }); res.end(); }) .catch((err) => { res.send("Database error, please try again :("); addToLog( "createEvent", "error", "Attempt to create event failed with error: " + err, ); }); } else { console.log("Files array is empty!"); res.status(500).end(); } }); router.post("/neweventgroup", (req, res) => { let eventGroupID = nanoid(); let editToken = randomstring.generate(); let eventGroupImageFilename = ""; if (req.files && Object.keys(req.files).length !== 0) { let eventImageBuffer = req.files.imageUpload.data; Jimp.read(eventImageBuffer, (err, img) => { if (err) addToLog( "Jimp", "error", "Attempt to edit image failed with error: " + err, ); img.resize(920, Jimp.AUTO) // resize .quality(80) // set JPEG quality .write("./public/events/" + eventGroupID + ".jpg"); // save }); eventGroupImageFilename = eventGroupID + ".jpg"; } const eventGroup = new EventGroup({ id: eventGroupID, name: req.body.eventGroupName, description: req.body.eventGroupDescription, image: eventGroupImageFilename, creatorEmail: req.body.creatorEmail, url: req.body.eventGroupURL, hostName: req.body.hostName, editToken: editToken, firstLoad: true, }); eventGroup .save() .then(() => { addToLog( "createEventGroup", "success", "Event group " + eventGroupID + " created", ); // Send email with edit link if (req.body.creatorEmail && sendEmails) { req.app.get("hbsInstance").renderView( "./views/emails/createeventgroup.handlebars", { eventGroupID, editToken, siteName, siteLogo, domain, cache: true, layout: "email.handlebars", }, function (err, html) { const msg = { to: req.body.creatorEmail, from: { name: siteName, email: contactEmail, address: contactEmail, }, subject: `${siteName}: ${req.body.eventGroupName}`, html, }; switch (mailService) { case "sendgrid": sgMail.send(msg).catch((e) => { console.error(e.toString()); res.status(500).end(); }); break; case "nodemailer": nodemailerTransporter .sendMail(msg) .catch((e) => { console.error(e.toString()); res.status(500).end(); }); break; } }, ); } res.writeHead(302, { Location: "/group/" + eventGroupID + "?e=" + editToken, }); res.end(); }) .catch((err) => { res.send("Database error, please try again :( - " + err); addToLog( "createEvent", "error", "Attempt to create event failed with error: " + err, ); }); }); router.post("/verifytoken/event/:eventID", (req, res) => { Event.findOne({ id: req.params.eventID, editToken: req.body.editToken, }).then((event) => { if (event) return res.sendStatus(200); return res.sendStatus(404); }); }); router.post("/verifytoken/group/:eventGroupID", (req, res) => { EventGroup.findOne({ id: req.params.eventGroupID, editToken: req.body.editToken, }).then((group) => { if (group) return res.sendStatus(200); return res.sendStatus(404); }); }); router.post("/editevent/:eventID/:editToken", (req, res) => { let submittedEditToken = req.params.editToken; Event.findOne({ id: req.params.eventID, }) .then(async (event) => { if (event.editToken === submittedEditToken) { // Token matches // If there is a new image, upload that first let eventID = req.params.eventID; let eventImageFilename = event.image; if (req.files && Object.keys(req.files).length !== 0) { let eventImageBuffer = req.files.imageUpload.data; Jimp.read(eventImageBuffer, (err, img) => { if (err) throw err; img.resize(920, Jimp.AUTO) // resize .quality(80) // set JPEG .write("./public/events/" + eventID + ".jpg"); // save }); eventImageFilename = eventID + ".jpg"; } 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 isPartOfEventGroup = false; let eventGroup; if (req.body.eventGroupCheckbox) { eventGroup = await EventGroup.findOne({ id: req.body.eventGroupID, editToken: req.body.eventGroupEditToken, }); if (eventGroup) { isPartOfEventGroup = true; } } const updatedEvent = { name: req.body.eventName, location: req.body.eventLocation, start: startUTC, end: endUTC, timezone: req.body.timezone, description: req.body.eventDescription, url: req.body.eventURL, hostName: req.body.hostName, image: eventImageFilename, usersCanAttend: req.body.joinCheckbox ? true : false, showUsersList: req.body.guestlistCheckbox ? true : false, usersCanComment: req.body.interactionCheckbox ? true : false, maxAttendees: req.body.maxAttendeesCheckbox ? req.body.maxAttendees : null, eventGroup: isPartOfEventGroup ? eventGroup._id : null, activityPubActor: event.activityPubActor ? updateActivityPubActor( JSON.parse(event.activityPubActor), req.body.eventDescription, req.body.eventName, req.body.eventLocation, eventImageFilename, startUTC, endUTC, req.body.timezone, ) : null, activityPubEvent: event.activityPubEvent ? updateActivityPubEvent( JSON.parse(event.activityPubEvent), req.body.eventName, req.body.startUTC, req.body.endUTC, req.body.timezone, ) : null, }; let diffText = "
This event was just updated with new information.
${req.body.commentAuthor} commented: ${req.body.commentContent}.
See the full conversation here.
`, }; broadcastCreateMessage( jsonObject, event.followers, req.params.eventID, ); if (sendEmails) { Event.findOne({ id: req.params.eventID }).then( (event) => { const attendeeEmails = event.attendees .filter( (o) => o.status === "attending" && o.email, ) .map((o) => o.email); if (attendeeEmails.length) { console.log( "Sending emails to: " + attendeeEmails, ); req.app.get("hbsInstance").renderView( "./views/emails/addeventcomment.handlebars", { siteName, siteLogo, domain, eventID: req.params.eventID, commentAuthor: req.body.commentAuthor, cache: true, layout: "email.handlebars", }, function (err, html) { const msg = { to: attendeeEmails, from: { name: siteName, email: contactEmail, }, subject: `${siteName}: New comment in ${event.name}`, html, }; switch (mailService) { case "sendgrid": sgMail .sendMultiple(msg) .catch((e) => { console.error( e.toString(), ); res.status( 500, ).end(); }); break; case "nodemailer": nodemailerTransporter .sendMail(msg) .catch((e) => { console.error( e.toString(), ); res.status( 500, ).end(); }); break; } }, ); } else { console.log("Nothing to send!"); } }, ); } res.writeHead(302, { Location: "/" + req.params.eventID, }); res.end(); }) .catch((err) => { res.send("Database error, please try again :(" + err); addToLog( "addEventComment", "error", "Attempt to add comment to event " + req.params.eventID + " failed with error: " + err, ); }); }, ); }); router.post("/post/reply/:eventID/:commentID", (req, res) => { let replyID = nanoid(); let commentID = req.params.commentID; const newReply = { id: replyID, author: req.body.replyAuthor, content: req.body.replyContent, timestamp: moment(), }; Event.findOne( { id: req.params.eventID, }, function (err, event) { if (!event) return; var parentComment = event.comments.id(commentID); parentComment.replies.push(newReply); event .save() .then(() => { addToLog( "addEventReply", "success", "Reply added to comment " + commentID + " in event " + req.params.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}/${req.params.eventID}/m/${guidObject}`, name: `Comment on ${event.name}`, type: "Note", cc: "https://www.w3.org/ns/activitystreams#Public", content: `${req.body.replyAuthor} commented: ${req.body.replyContent}
`, }; broadcastCreateMessage( jsonObject, event.followers, req.params.eventID, ); if (sendEmails) { Event.findOne({ id: req.params.eventID }).then( (event) => { const attendeeEmails = event.attendees .filter( (o) => o.status === "attending" && o.email, ) .map((o) => o.email); if (attendeeEmails.length) { console.log( "Sending emails to: " + attendeeEmails, ); req.app.get("hbsInstance").renderView( "./views/emails/addeventcomment.handlebars", { siteName, siteLogo, domain, eventID: req.params.eventID, commentAuthor: req.body.replyAuthor, cache: true, layout: "email.handlebars", }, function (err, html) { const msg = { to: attendeeEmails, from: { name: siteName, email: contactEmail, }, subject: `${siteName}: New comment in ${event.name}`, html, }; switch (mailService) { case "sendgrid": sgMail .sendMultiple(msg) .catch((e) => { console.error( e.toString(), ); res.status( 500, ).end(); }); break; case "nodemailer": nodemailerTransporter .sendMail(msg) .catch((e) => { console.error( e.toString(), ); res.status( 500, ).end(); }); break; } }, ); } else { console.log("Nothing to send!"); } }, ); } res.writeHead(302, { Location: "/" + req.params.eventID, }); res.end(); }) .catch((err) => { res.send("Database error, please try again :("); addToLog( "addEventReply", "error", "Attempt to add reply to comment " + commentID + " in event " + req.params.eventID + " failed with error: " + err, ); }); }, ); }); router.post("/deletecomment/:eventID/:commentID/:editToken", (req, res) => { let submittedEditToken = req.params.editToken; Event.findOne({ id: req.params.eventID, }) .then((event) => { if (event.editToken === submittedEditToken) { // Token matches event.comments.id(req.params.commentID).remove(); event .save() .then(() => { addToLog( "deleteComment", "success", "Comment deleted from event " + req.params.eventID, ); res.writeHead(302, { Location: "/" + req.params.eventID + "?e=" + req.params.editToken, }); res.end(); }) .catch((err) => { res.send( "Sorry! Something went wrong (error deleting): " + err, ); addToLog( "deleteComment", "error", "Attempt to delete comment " + req.params.commentID + "from event " + req.params.eventID + " failed with error: " + err, ); }); } else { // Token doesn't match res.send("Sorry! Something went wrong"); addToLog( "deleteComment", "error", "Attempt to delete comment " + req.params.commentID + "from event " + req.params.eventID + " failed with error: token does not match", ); } }) .catch((err) => { res.send("Sorry! Something went wrong: " + err); addToLog( "deleteComment", "error", "Attempt to delete comment " + req.params.commentID + "from event " + req.params.eventID + " failed with error: " + err, ); }); }); router.post("/activitypub/inbox", (req, res) => { if (!isFederated) return res.sendStatus(404); // validate the incoming message const signature = req.get("Signature"); let signature_header = signature .split(",") .map((pair) => { return pair.split("=").map((value) => { return value.replace(/^"/g, "").replace(/"$/g, ""); }); }) .reduce((acc, el) => { acc[el[0]] = el[1]; return acc; }, {}); // get the actor // TODO if this is a Delete for an Actor this won't work request( { url: signature_header.keyId, headers: { Accept: "application/activity+json", "Content-Type": "application/activity+json", }, }, function (error, response, actor) { let publicKey = ""; try { if (JSON.parse(actor).publicKey) { publicKey = JSON.parse(actor).publicKey.publicKeyPem; } } catch (err) { return res.status(500).send("Actor could not be parsed" + err); } let comparison_string = signature_header.headers .split(" ") .map((header) => { if (header === "(request-target)") { return "(request-target): post /activitypub/inbox"; } else { return `${header}: ${req.get(header)}`; } }) .join("\n"); const verifier = crypto.createVerify("RSA-SHA256"); verifier.update(comparison_string, "ascii"); const publicKeyBuf = new Buffer(publicKey, "ascii"); const signatureBuf = new Buffer( signature_header.signature, "base64", ); try { const result = verifier.verify(publicKeyBuf, signatureBuf); if (result) { // actually process the ActivityPub message now that it's been verified 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); } }, ); }); router.use(function (req, res, next) { res.status(404); res.render("404", { url: req.url }); return; }); addToLog("startup", "success", "Started up successfully"); export default router;