From b3c9cba6478dc16d135313aa6d0adcc02d67ece6 Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Sun, 25 Feb 2024 21:34:33 +0000 Subject: feat: optional public events/groups --- config/config.example.toml | 3 ++ public/css/style.css | 39 ++++++++++++++++- public/js/modules/event-edit.js | 2 + public/js/modules/group-edit.js | 5 +++ public/js/modules/new.js | 7 ++++ src/lib/config.ts | 66 +++++++++++++++++++++++++++-- src/models/Event.ts | 5 +++ src/models/EventGroup.ts | 5 +++ src/routes/event.ts | 2 + src/routes/frontend.ts | 71 ++++++++++++++++++++++++++++++- src/routes/group.ts | 2 + src/util/validation.ts | 31 ++++++++++++-- views/event.handlebars | 3 +- views/eventgroup.handlebars | 1 + views/home.handlebars | 44 +++++++++++++++---- views/layouts/main.handlebars | 2 +- views/partials/eventForm.handlebars | 8 ++++ views/partials/eventGroupForm.handlebars | 11 +++++ views/partials/sidebar.handlebars | 12 ++++-- views/publicEventList.handlebars | 72 ++++++++++++++++++++++++++++++++ 20 files changed, 365 insertions(+), 26 deletions(-) create mode 100644 views/publicEventList.handlebars diff --git a/config/config.example.toml b/config/config.example.toml index e9995de..4e00171 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -16,6 +16,9 @@ email_logo_url = "" # Show a Ko-Fi box to donate money to Raphael (Gathio's creator) on the front # page. show_kofi = false +# Show a list of events and groups on the front page which have been marked as +# 'Display this event/group on the public event/group list'. +show_public_event_list = false # Which mail service to use to send emails to hosts and attendees. Options are # 'nodemailer' or 'sendgrid'. Configure settings for this mail # service below. diff --git a/public/css/style.css b/public/css/style.css index dd59d6b..24d10b8 100755 --- a/public/css/style.css +++ b/public/css/style.css @@ -381,7 +381,7 @@ li.hidden-attendee .attendee-name { } } -@media (min-width: 577px) { +@media (min-width: 576px) { #sidebar { border-right: 2px solid #e0e0e0; min-height: 100vh; @@ -422,7 +422,7 @@ li.hidden-attendee .attendee-name { } .list-group-item-action:hover { - background-color: #d4edda; + background-color: #f2f8ff; } .code { @@ -548,3 +548,38 @@ img.group-preview__image { opacity: 1; pointer-events: auto; } + +ul#sidebar__nav { + padding: 0; + margin: 0; + list-style: none; + display: flex; + flex-direction: row; + gap: 0.5rem; + align-items: center; + justify-content: center; + margin-bottom: 1rem; +} + +ul#sidebar__nav li { + padding: 0 1rem 0.5rem 1rem; + text-align: center; +} + +ul#sidebar__nav a { + display: block; + width: 100%; +} + +@media (min-width: 576px) { + ul#sidebar__nav { + flex-direction: column; + } + ul#sidebar__nav li { + width: 100%; + padding: 0 0 0.5rem 0; + } + ul#sidebar__nav li:has(a:not(.btn)):not(:last-child) { + border-bottom: 1px solid #e0e0e0; + } +} diff --git a/public/js/modules/event-edit.js b/public/js/modules/event-edit.js index 740c861..736547f 100644 --- a/public/js/modules/event-edit.js +++ b/public/js/modules/event-edit.js @@ -32,6 +32,7 @@ function editEventForm() { creatorEmail: window.eventData.creatorEmail, eventGroupID: window.eventData.eventGroupID, eventGroupEditToken: window.eventData.eventGroupEditToken, + publicCheckbox: window.eventData.showOnPublicList, interactionCheckbox: window.eventData.usersCanComment, joinCheckbox: window.eventData.usersCanAttend, maxAttendeesCheckbox: window.eventData.maxAttendees !== null, @@ -53,6 +54,7 @@ function editEventForm() { this.data.joinCheckbox = window.eventData.usersCanAttend; this.data.maxAttendeesCheckbox = window.eventData.maxAttendees !== null; + this.data.publicCheckbox = window.eventData.showOnPublicList; }, async submitForm() { this.submitting = true; diff --git a/public/js/modules/group-edit.js b/public/js/modules/group-edit.js index 1a2c1db..2d55346 100644 --- a/public/js/modules/group-edit.js +++ b/public/js/modules/group-edit.js @@ -27,6 +27,11 @@ function editEventGroupForm() { eventGroupURL: window.groupData.url, hostName: window.groupData.hostName, creatorEmail: window.groupData.creatorEmail, + publicCheckbox: window.groupData.showOnPublicList, + }, + init() { + // Set checkboxes + this.data.publicCheckbox = window.groupData.showOnPublicList; }, errors: [], submitting: false, diff --git a/public/js/modules/new.js b/public/js/modules/new.js index a018087..f7c3e34 100644 --- a/public/js/modules/new.js +++ b/public/js/modules/new.js @@ -87,6 +87,7 @@ function newEventForm() { creatorEmail: "", eventGroupID: "", eventGroupEditToken: "", + publicCheckbox: false, interactionCheckbox: false, joinCheckbox: false, maxAttendeesCheckbox: false, @@ -107,6 +108,7 @@ function newEventForm() { this.data.interactionCheckbox = false; this.data.joinCheckbox = false; this.data.maxAttendeesCheckbox = false; + this.data.publicCheckbox = false; }, async submitForm() { this.submitting = true; @@ -160,6 +162,11 @@ function newEventGroupForm() { eventGroupURL: "", hostName: "", creatorEmail: "", + publicCheckbox: false, + }, + init() { + // Reset checkboxes + this.data.publicCheckbox = false; }, errors: [], submitting: false, diff --git a/src/lib/config.ts b/src/lib/config.ts index 1029be9..b4385ca 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -14,10 +14,11 @@ interface GathioConfig { port: string; email: string; site_name: string; - delete_after_days: number | null; + delete_after_days: number; is_federated: boolean; email_logo_url: string; show_kofi: boolean; + show_public_event_list: boolean; mail_service: "nodemailer" | "sendgrid"; creator_email_addresses: string[]; }; @@ -42,6 +43,7 @@ interface FrontendConfig { isFederated: boolean; emailLogoUrl: string; showKofi: boolean; + showPublicEventList: boolean; showInstanceInformation: boolean; staticPages?: StaticPage[]; version: string; @@ -56,8 +58,10 @@ const defaultConfig: GathioConfig = { is_federated: true, delete_after_days: 7, email_logo_url: "", + show_public_event_list: false, show_kofi: false, mail_service: "nodemailer", + creator_email_addresses: [], }, database: { mongodb_url: "mongodb://localhost:27017/gathio", @@ -69,15 +73,71 @@ export const frontendConfig = (): FrontendConfig => { return { domain: config.general.domain, siteName: config.general.site_name, - isFederated: config.general.is_federated, + isFederated: !!config.general.is_federated, emailLogoUrl: config.general.email_logo_url, - showKofi: config.general.show_kofi, + showPublicEventList: !!config.general.show_public_event_list, + showKofi: !!config.general.show_kofi, showInstanceInformation: !!config.static_pages?.length, staticPages: config.static_pages, version: process.env.npm_package_version || "unknown", }; }; +interface InstanceRule { + icon: string; + text: string; +} + +export const instanceRules = (): InstanceRule[] => { + const config = getConfig(); + const rules = []; + rules.push( + config.general.show_public_event_list + ? { + text: "Public events and groups are displayed on the homepage", + icon: "fas fa-eye", + } + : { + text: "Events and groups can only be accessed by direct link", + icon: "fas fa-eye-slash", + }, + ); + rules.push( + config.general.creator_email_addresses?.length + ? { + text: "Only specific people can create events and groups", + icon: "fas fa-user-check", + } + : { + text: "Anyone can create events and groups", + icon: "fas fa-users", + }, + ); + rules.push( + config.general.delete_after_days > 0 + ? { + text: `Events are automatically deleted ${config.general.delete_after_days} days after they end`, + icon: "far fa-calendar-times", + } + : { + text: "Events are permanent, and are never automatically deleted", + icon: "far fa-calendar-check", + }, + ); + rules.push( + config.general.is_federated + ? { + text: "This instance federates with other instances using ActivityPub", + icon: "fas fa-globe", + } + : { + text: "This instance does not federate with other instances", + icon: "fas fa-globe", + }, + ); + return rules; +}; + // Attempt to load our global config. Will stop the app if the config file // cannot be read (there's no point trying to continue!) export const getConfig = (): GathioConfig => { diff --git a/src/models/Event.ts b/src/models/Event.ts index f67d40b..5731680 100644 --- a/src/models/Event.ts +++ b/src/models/Event.ts @@ -73,6 +73,7 @@ export interface IEvent extends mongoose.Document { privateKey?: string; followers?: IFollower[]; activityPubMessages?: IActivityPubMessage[]; + showOnPublicList?: boolean; } const Attendees = new mongoose.Schema({ @@ -334,6 +335,10 @@ const EventSchema = new mongoose.Schema({ }, followers: [Followers], activityPubMessages: [ActivityPubMessages], + showOnPublicList: { + type: Boolean, + default: false, + }, }); export default mongoose.model("Event", EventSchema); diff --git a/src/models/EventGroup.ts b/src/models/EventGroup.ts index 2b5c2aa..de7187d 100755 --- a/src/models/EventGroup.ts +++ b/src/models/EventGroup.ts @@ -16,6 +16,7 @@ export interface IEventGroup extends mongoose.Document { firstLoad?: boolean; events?: mongoose.Types.ObjectId[]; subscribers?: ISubscriber[]; + showOnPublicList?: boolean; } const Subscriber = new mongoose.Schema({ @@ -70,6 +71,10 @@ const EventGroupSchema = new mongoose.Schema({ }, events: [{ type: mongoose.Schema.Types.ObjectId, ref: "Event" }], subscribers: [Subscriber], + showOnPublicList: { + type: Boolean, + default: false, + }, }); export default mongoose.model("EventGroup", EventGroupSchema); diff --git a/src/routes/event.ts b/src/routes/event.ts index fb9d8c7..6be5ff8 100644 --- a/src/routes/event.ts +++ b/src/routes/event.ts @@ -140,6 +140,7 @@ router.post( viewPassword: "", // Backwards compatibility editPassword: "", // Backwards compatibility editToken: editToken, + showOnPublicList: eventData?.publicBoolean, eventGroup: isPartOfEventGroup ? eventGroup?._id : null, usersCanAttend: eventData.joinBoolean ? true : false, showUsersList: false, // Backwards compatibility @@ -371,6 +372,7 @@ router.put( url: eventData.eventURL, hostName: eventData.hostName, image: eventImageFilename, + showOnPublicList: eventData.publicBoolean, usersCanAttend: eventData.joinBoolean, showUsersList: false, // Backwards compatibility usersCanComment: eventData.interactionBoolean, diff --git a/src/routes/frontend.ts b/src/routes/frontend.ts index 0d8793a..4cdce8a 100644 --- a/src/routes/frontend.ts +++ b/src/routes/frontend.ts @@ -2,7 +2,7 @@ import { Router, Request, Response } from "express"; import moment from "moment-timezone"; import { marked } from "marked"; import { markdownToSanitizedHTML, renderPlain } from "../util/markdown.js"; -import getConfig, { frontendConfig } from "../lib/config.js"; +import getConfig, { frontendConfig, instanceRules } from "../lib/config.js"; import { addToLog, exportICal } from "../helpers.js"; import Event from "../models/Event.js"; import EventGroup, { IEventGroup } from "../models/EventGroup.js"; @@ -16,7 +16,20 @@ const config = getConfig(); const router = Router(); router.get("/", (_: Request, res: Response) => { - res.render("home", frontendConfig()); + if (config.general.show_public_event_list) { + return res.redirect("/events"); + } + return res.render("home", { + ...frontendConfig(), + instanceRules: instanceRules(), + }); +}); + +router.get("/about", (_: Request, res: Response) => { + return res.render("home", { + ...frontendConfig(), + instanceRules: instanceRules(), + }); }); router.get("/new", (_: Request, res: Response) => { @@ -57,6 +70,51 @@ router.get("/new/:magicLinkToken", async (req: Request, res: Response) => { }); }); +router.get("/events", async (_: Request, res: Response) => { + if (!config.general.show_public_event_list) { + return res.status(404).render("404", frontendConfig()); + } + const events = await Event.find({ showOnPublicList: true }) + .populate("eventGroup") + .lean() + .sort("start"); + const updatedEvents = events.map((event) => { + const startMoment = moment.tz(event.start, event.timezone); + const endMoment = moment.tz(event.end, event.timezone); + const isSameDay = startMoment.isSame(endMoment, "day"); + + return { + id: event.id, + name: event.name, + location: event.location, + displayDate: isSameDay + ? startMoment.format("D MMM YYYY") + : `${startMoment.format("D MMM YYYY")} - ${endMoment.format( + "D MMM YYYY", + )}`, + eventHasConcluded: endMoment.isBefore(moment.tz(event.timezone)), + eventGroup: event.eventGroup, + }; + }); + const upcomingEvents = updatedEvents.filter( + (event) => event.eventHasConcluded === false, + ); + const pastEvents = updatedEvents.filter( + (event) => event.eventHasConcluded === true, + ); + const eventGroups = await EventGroup.find({ + showOnPublicList: true, + }).lean(); + + res.render("publicEventList", { + title: "Public events", + upcomingEvents: upcomingEvents, + pastEvents: pastEvents, + eventGroups: eventGroups, + ...frontendConfig(), + }); +}); + router.get("/:eventID", async (req: Request, res: Response) => { try { const event = await Event.findOne({ @@ -266,6 +324,11 @@ router.get("/:eventID", async (req: Request, res: Response) => { firstLoad: firstLoad, eventHasConcluded: eventHasConcluded, eventHasBegun: eventHasBegun, + eventWillBeDeleted: config.general.delete_after_days > 0, + daysUntilDeletion: moment + .tz(event.end, event.timezone) + .add(config.general.delete_after_days, "days") + .fromNow(), metadata: metadata, jsonData: { name: event.name, @@ -276,6 +339,7 @@ router.get("/:eventID", async (req: Request, res: Response) => { url: event.url, hostName: event.hostName, creatorEmail: event.creatorEmail, + showOnPublicList: event.showOnPublicList, eventGroupID: event.eventGroup ? (event.eventGroup as unknown as IEventGroup).id : null, @@ -337,6 +401,7 @@ router.get("/group/:eventGroupID", async (req: Request, res: Response) => { return { id: event.id, name: event.name, + location: event.location, displayDate: isSameDay ? startMoment.format("D MMM YYYY") : `${startMoment.format("D MMM YYYY")} - ${endMoment.format( @@ -388,6 +453,7 @@ router.get("/group/:eventGroupID", async (req: Request, res: Response) => { res.set("X-Robots-Tag", "noindex"); res.render("eventgroup", { + ...frontendConfig(), domain: config.general.domain, title: eventGroup.name, eventGroupData: eventGroup, @@ -409,6 +475,7 @@ router.get("/group/:eventGroupID", async (req: Request, res: Response) => { creatorEmail: eventGroup.creatorEmail, image: eventGroup.image, editToken: editingEnabled ? eventGroupEditToken : null, + showOnPublicList: eventGroup.showOnPublicList, }, }); } catch (err) { diff --git a/src/routes/group.ts b/src/routes/group.ts index 34377b0..c006a5d 100644 --- a/src/routes/group.ts +++ b/src/routes/group.ts @@ -81,6 +81,7 @@ router.post( hostName: groupData.hostName, editToken: editToken, firstLoad: true, + showOnPublicList: groupData.publicBoolean, }); await eventGroup.save(); @@ -206,6 +207,7 @@ router.put( url: req.body.eventGroupURL, hostName: req.body.hostName, image: eventGroupImageFilename, + showOnPublicList: groupData.publicBoolean, }; await EventGroup.findOneAndUpdate( diff --git a/src/util/validation.ts b/src/util/validation.ts index 732fbf3..b9a0c8a 100644 --- a/src/util/validation.ts +++ b/src/util/validation.ts @@ -5,11 +5,16 @@ type Error = { field?: string; }; -type ValidationResponse = { +type EventValidationResponse = { data?: ValidatedEventData; errors?: Error[]; }; +type EventGroupValidationResponse = { + data?: ValidatedEventGroupData; + errors?: Error[]; +}; + interface EventData { eventName: string; eventLocation: string; @@ -21,6 +26,7 @@ interface EventData { imagePath: string; hostName: string; creatorEmail: string; + publicCheckbox: string; eventGroupCheckbox: string; eventGroupID: string; eventGroupEditToken: string; @@ -33,11 +39,13 @@ interface EventData { // EventData without the 'checkbox' fields export type ValidatedEventData = Omit< EventData, + | "publicCheckbox" | "eventGroupCheckbox" | "interactionCheckbox" | "joinCheckbox" | "maxAttendeesCheckbox" > & { + publicBoolean: boolean; eventGroupBoolean: boolean; interactionBoolean: boolean; joinBoolean: boolean; @@ -50,8 +58,13 @@ interface EventGroupData { eventGroupURL: string; hostName: string; creatorEmail: string; + publicCheckbox: string; } +export type ValidatedEventGroupData = Omit & { + publicBoolean: boolean; +}; + const validateEmail = (email: string) => { if (!email || email.length === 0 || typeof email !== "string") { return false; @@ -89,9 +102,12 @@ export const validateEventTime = (start: Date, end: Date): Error | boolean => { return true; }; -export const validateEventData = (eventData: EventData): ValidationResponse => { +export const validateEventData = ( + eventData: EventData, +): EventValidationResponse => { const validatedData: ValidatedEventData = { ...eventData, + publicBoolean: eventData.publicCheckbox === "true", eventGroupBoolean: eventData.eventGroupCheckbox === "true", interactionBoolean: eventData.interactionCheckbox === "true", joinBoolean: eventData.joinCheckbox === "true", @@ -186,7 +202,9 @@ export const validateEventData = (eventData: EventData): ValidationResponse => { }; }; -export const validateGroupData = (groupData: EventGroupData) => { +export const validateGroupData = ( + groupData: EventGroupData, +): EventGroupValidationResponse => { const errors: Error[] = []; if (!groupData.eventGroupName) { errors.push({ @@ -209,8 +227,13 @@ export const validateGroupData = (groupData: EventGroupData) => { } } + const validatedData: ValidatedEventGroupData = { + ...groupData, + publicBoolean: groupData.publicCheckbox === "true", + }; + return { - data: groupData, + data: validatedData, errors: errors, }; }; diff --git a/views/event.handlebars b/views/event.handlebars index 4402578..44c2f4b 100755 --- a/views/event.handlebars +++ b/views/event.handlebars @@ -109,7 +109,7 @@ {{#if eventHasConcluded}} {{/if}} {{#if firstLoad}} @@ -523,7 +523,6 @@ window.eventData = {{{ json jsonData }}}; $(this).html(' Copied!'); setTimeout(function(){ $("#copyAPLink").html(' Copy');}, 5000); }) - $(".daysToDeletion").html(moment("{{eventEndISO}}").add(7, 'days').fromNow()); if ($("#joinCheckbox").is(':checked')){ $("#maxAttendeesCheckboxContainer").css("display","flex"); } diff --git a/views/eventgroup.handlebars b/views/eventgroup.handlebars index 434f691..7ad3570 100755 --- a/views/eventgroup.handlebars +++ b/views/eventgroup.handlebars @@ -122,6 +122,7 @@ {{this.name}} + {{#if this.location}} {{this.location}}{{/if}} {{this.displayDate}} {{/unless}} diff --git a/views/home.handlebars b/views/home.handlebars index add7eac..bf92724 100755 --- a/views/home.handlebars +++ b/views/home.handlebars @@ -1,25 +1,53 @@ -

A quick and easy way to make and share events which respects your privacy.

+

Gathio is a simple, federated, privacy-first event hosting platform.

-
+
+
+ This instance, {{siteName}}, has the following features: +
-

You don't need to sign up for an account. When you create an event, we generate a password which allows you to edit the event. Send all your guests the public link, and all your co-hosts the secret editing link containing the password. A week after the event finishes, it's deleted from our servers for ever, and your email goes with it.

+
+
    + {{#each instanceRules}} +
  • {{this.text}}
  • + {{/each}} +
+
+
An example event page for a picnic. The page shows the event's location, host, date and time, and description, as well as buttons to save the event to Google Calendar, export it, and open the location in OpenStreetMap and Google Maps.
-

Also, we don't show you ads, don't sell your data, and never send you unnecessary emails.

+

Privacy-first

+ +

There are no accounts on Gathio. When you create an event, we generate a password which allows you to edit the event. Send all your guests the public link, and all your co-hosts the secret editing link containing the password.

+ +

If you supply your email, we'll send you the editing password so you don't lose it - but supplying your email is optional!

+ +

If this instance automatically deletes its events, sometime after the event finishes, it's deleted from the database for ever, and your data goes with it.

+ +

Also, Gathio doesn't show you ads, doesn't sell your data, and never sends you unnecessary emails.

+ +

But remember: all events are visible to anyone who knows the link, so probably don't use Gathio to plot your surprise birthday party or revolution. Or whatever, you do you.

+ +

Configurable

+ +

The flagship Gathio instance at gath.io is designed for anyone to create ephemeral, hidden events. Anyone can create an event; events are never displayed anywhere public; and they're deleted 7 days after they end.

+ +

But if your community sets up their own instance, you can limit event creation to a specific list of people, display events on a handy list on the homepage, and disable event deletion entirely!

+ +

Federation and self-hosting

-

But remember: all events are visible to anyone who knows the link, so probably don't use gathio to plot your surprise birthday party or revolution. Or whatever, you do you.

+

Gathio can easily be self-hosted, and supports ActivityPub services like Mastodon, Pleroma, and Friendica, allowing you to access events from anywhere on the Fediverse. We encourage you to spin up your own instance for your community. Detailed instructions on ActivityPub access and self-hosted installation live on our GitHub wiki. -

Federation and self-hosting

+

Open source

-

gathio can easily be self-hosted, and supports ActivityPub services like Mastodon, Pleroma, and Friendica, allowing you to access events from anywhere on the Fediverse. We encourage you to spin up your own instance for your community. Detailed instructions on ActivityPub access and self-hosted installation live on our GitHub wiki. Leave a question in our tracker if you encounter any issues.

+

Gathio is delighted to be open source, and is built by a lovely group of people. Leave a question in our tracker if you encounter any issues.

{{#if showKofi}}
-

If you find yourself using and enjoying gathio, consider buying me a coffee. It'll help keep the site running!

+

If you find yourself using and enjoying Gathio, consider buying Raphael a coffee. It'll help keep the project and main site running!

diff --git a/views/layouts/main.handlebars b/views/layouts/main.handlebars index d45d596..996d35f 100755 --- a/views/layouts/main.handlebars +++ b/views/layouts/main.handlebars @@ -70,7 +70,7 @@
{{#if showInstanceInformation}}

- {{domain}} + {{siteName}} {{#each staticPages}} {{#if @first}} · diff --git a/views/partials/eventForm.handlebars b/views/partials/eventForm.handlebars index c2eebc3..9227300 100755 --- a/views/partials/eventForm.handlebars +++ b/views/partials/eventForm.handlebars @@ -73,6 +73,14 @@

+ {{#if showPublicEventList}} +
+ + +
+ {{/if}}
Recommended dimensions (w x h): 920px by 300px.
+{{#if showPublicEventList}} +
+ +
+ + +
+
+{{/if}}
-

gathio

+

gathio

-

Nicer events

- - New +
diff --git a/views/publicEventList.handlebars b/views/publicEventList.handlebars new file mode 100644 index 0000000..6d85ca7 --- /dev/null +++ b/views/publicEventList.handlebars @@ -0,0 +1,72 @@ +
+

{{siteName}}

+

{{siteName}} is an instance of Gathio, a simple, federated, privacy-first event hosting platform.

+ + +
+
+
Upcoming events
+ +
+ +
+
Past events
+
+ {{#if pastEvents}} + {{#each pastEvents}} + + + {{this.name}} + {{this.displayDate}} + {{#if this.eventGroup}} + In group: {{this.eventGroup.name}} + {{/if}} + + {{/each}} + {{else}} +
No events!
+ {{/if}} +
+
+
+ +
+
+
Event groups
+
+ {{#if eventGroups}} + {{#each eventGroups}} + + + {{this.name}} + + {{/each}} + {{else}} +
No groups!
+ {{/if}} +
+ +
\ No newline at end of file -- cgit v1.2.3