a0d2cb8f73
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.
95 lines
4.1 KiB
JavaScript
95 lines
4.1 KiB
JavaScript
const urlJoin = require('url-join');
|
|
const { PodResourcesHandlerMixin } = require('@activitypods/app');
|
|
const { arrayOf } = require('@semapps/ldp');
|
|
const CONFIG = require('../config/config');
|
|
const { hasPublicIntent } = require('./public-intent');
|
|
|
|
const ACL = 'http://www.w3.org/ns/auth/acl#';
|
|
const FOAF_AGENT = 'http://xmlns.com/foaf/0.1/Agent';
|
|
|
|
// The pod compacts ACL documents with the `acl:` and `foaf:` prefixes; accept both forms.
|
|
const expand = value => {
|
|
const id = typeof value === 'object' && value !== null ? value['@id'] || value.id : value;
|
|
if (typeof id !== 'string') return id;
|
|
if (id.startsWith('acl:')) return ACL + id.slice('acl:'.length);
|
|
if (id.startsWith('foaf:')) return 'http://xmlns.com/foaf/0.1/' + id.slice('foaf:'.length);
|
|
return id;
|
|
};
|
|
|
|
/** True when one of the ACL's authorizations already lets any agent (foaf:Agent) read. */
|
|
const grantsPublicRead = authorizations =>
|
|
arrayOf(authorizations).some(
|
|
authorization =>
|
|
arrayOf(authorization['acl:mode']).map(expand).includes(ACL + 'Read') &&
|
|
arrayOf(authorization['acl:agentClass']).map(expand).includes(FOAF_AGENT)
|
|
);
|
|
|
|
// Festipod creates its events and meeting points public: their bindings declare visibility
|
|
// 'public', and its create path addresses each one to the ActivityStreams Public collection
|
|
// (`src/shared/refine/providers/activitypods.ts`). This service owns the one WAC shape that makes
|
|
// a pod resource world-readable — `acl:Read` for `acl:agentClass foaf:Agent`, nothing more — and
|
|
// applies it, acting as the app actor, to an `as:Event` a user creates ONLY when the created
|
|
// object carries that public intent (`backend/services/public-intent.js`). It learns of creations
|
|
// through the same channel as the catalog: the app's watcher on each user's outbox, which calls
|
|
// `onCreate` for every `Create` of an `as:Event` — whichever client created it and whatever its
|
|
// audience, which is why the intent check, not the channel, decides.
|
|
module.exports = {
|
|
name: 'public-read',
|
|
mixins: [PodResourcesHandlerMixin],
|
|
settings: {
|
|
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/as/Event')
|
|
},
|
|
dependencies: ['pod-permissions'],
|
|
actions: {
|
|
/**
|
|
* Idempotent: reads the resource's ACL first and adds nothing when a public-read
|
|
* authorization is already there. Resolves `true` when it added the grant, `false` when the
|
|
* resource was already public; throws when the pod refuses the grant.
|
|
*/
|
|
grant: {
|
|
params: {
|
|
resourceUri: { type: 'string' },
|
|
actorUri: { type: 'string' }
|
|
},
|
|
async handler(ctx) {
|
|
const { resourceUri, actorUri } = ctx.params;
|
|
|
|
const authorizations = await ctx.call('pod-permissions.get', { uri: resourceUri, actorUri });
|
|
if (authorizations && grantsPublicRead(authorizations)) {
|
|
this.logger.info(`${resourceUri} is already publicly readable`);
|
|
return false;
|
|
}
|
|
|
|
const added = await ctx.call('pod-permissions.add', {
|
|
uri: resourceUri,
|
|
agentPredicate: 'acl:agentClass',
|
|
agentUri: FOAF_AGENT,
|
|
mode: 'acl:Read',
|
|
actorUri
|
|
});
|
|
if (!added) throw new Error(`The pod refused the public-read grant on ${resourceUri}`);
|
|
|
|
this.logger.info(`Granted public read on ${resourceUri}`);
|
|
return true;
|
|
}
|
|
}
|
|
},
|
|
methods: {
|
|
async onCreate(ctx, resource, actorUri) {
|
|
const resourceUri = resource.id || resource['@id'];
|
|
if (!hasPublicIntent(resource)) {
|
|
this.logger.info(`${resourceUri} is not addressed to the Public collection: no public read`);
|
|
return;
|
|
}
|
|
// The watcher runs every matching handler of an activity in sequence and does not catch: a
|
|
// throw here would skip the handlers after this one, and the platform does not await the
|
|
// webhook call that runs the watcher, so the rejection would also end the process. Log, never throw.
|
|
try {
|
|
await this.actions.grant({ resourceUri, actorUri }, { parentCtx: ctx });
|
|
} catch (e) {
|
|
this.logger.error(`Could not grant public read on ${resourceUri}: ${e.message}`);
|
|
}
|
|
}
|
|
}
|
|
};
|