Files
festipod/backend/services/attendees.service.js
T
Sylvain Duchesne a0d2cb8f73 Make events and meeting points readable by anyone only when Festipod marks them public
Festipod's create path now addresses events and meeting points to the ActivityStreams Public collection (to: as:Public), and the backend grants acl:Read to foaf:Agent on a created as:Event only when it carries that public intent (one predicate, hasPublicIntent, in backend/services/public-intent.js). Before this, every as:Event written to a user's outbox would have been made world-readable, including other apps' events and events addressed to a single friend. The grant is idempotent (it reads the ACL first) and never throws into the webhook path.

The attendees service overrides the collections mixin so that an event posted with apods:attendees already attached no longer reaches new URL(undefined) and takes the whole backend process down. Attendee lists stay world-readable as the pod creates them; whether they should is an open product question.
2026-10-12 16:59:00 +02:00

84 lines
3.9 KiB
JavaScript

const urlJoin = require('url-join');
const { PodCollectionsHandlerMixin } = require('@activitypods/app');
const CONFIG = require('../config/config');
const { hasPublicIntent } = require('./public-intent');
// Attaches an `apods:attendees` collection (and a WAC group of the same members) to every
// `as:Event` created in a user's pod that this backend learns of through the outbox watcher —
// plain events as well as meeting points (observed 2026-10-12). A join adds the actor to it.
//
// Who may read the collection: the pod creates it world-readable (`acl:Read` for
// `foaf:Agent`) — a platform default, observed 2026-10-12, not a choice made here. Whether
// attendee lists SHOULD be world-readable is an open product question: the functional-domain
// doctrine places participation in the protected scope. This service does not settle it. On a
// resource carrying Festipod's public intent it also applies `public-read.grant` to the
// collection, which finds it already world-readable and adds nothing; on any other resource the
// backend grants nothing on the collection.
module.exports = {
name: 'attendees',
mixins: [PodCollectionsHandlerMixin],
settings: {
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/as/Event'),
attachPredicate: 'http://activitypods.org/ns/core#attendees',
collectionOptions: {
ordered: false,
summary: 'Meeting point attendees'
},
createWacGroup: true
},
dependencies: ['public-read'],
actions: {
// Replaces the mixin's action of the same name (`PodCollectionsHandlerMixin`,
// @activitypods/app 2.3.2) with one difference. `pod-collections.createAndAttach` returns
// undefined when it attaches nothing — the resource already carries an `apods:attendees`
// value (written by its creator or by another app), or the pod refused to create the
// collection — and the mixin then derives a WAC group slug from that undefined URI:
// `new URL(undefined)` throws. The webhook call that runs the watcher is not awaited by the
// platform, so that throw ended the whole backend process (observed 2026-10-12, an event
// POSTed with `apods:attendees` already set). Here, nothing attached means no group to create.
createAndAttach: {
async handler(ctx) {
const { resourceUri, actorUri } = ctx.params;
const { attachPredicate, collectionOptions, createWacGroup } = this.settings;
const collectionUri = await ctx.call('pod-collections.createAndAttach', {
resourceUri,
attachPredicate,
collectionOptions,
actorUri
});
if (!collectionUri) {
this.logger.info(`No attendees collection attached to ${resourceUri} (already carries one, or creation refused)`);
return undefined;
}
if (createWacGroup) {
await ctx.call('pod-wac-groups.create', {
groupSlug: this.getGroupSlugFromCollectionUri(collectionUri),
actorUri
});
}
return collectionUri;
}
}
},
methods: {
// Replaces the mixin's `onCreate`. The watcher runs the handlers of one `Create` in sequence
// and does not catch, in an order that is not fixed (attendees ran before public-read and
// catalog in the runs observed 2026-10-12): a throw here would skip whichever handlers come
// after it, and — the webhook call being un-awaited — end the process. So a failure is
// logged, never thrown.
async onCreate(ctx, resource, actorUri) {
const resourceUri = resource.id || resource['@id'];
try {
const collectionUri = await this.actions.createAndAttach({ resourceUri, actorUri }, { parentCtx: ctx });
if (collectionUri && hasPublicIntent(resource)) {
await ctx.call('public-read.grant', { resourceUri: collectionUri, actorUri });
}
} catch (e) {
this.logger.error(`Could not set up the attendees collection of ${resourceUri}: ${e.message}`);
}
}
}
};