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}`); } } } };