const urlJoin = require('url-join'); const { PodResourcesHandlerMixin } = require('@activitypods/app'); const CONFIG = require('../config/config'); const { hasPublicIntent } = require('./public-intent'); const { isInActorPod } = require('./actor-pod'); const { createKeyedSerializer } = require('./keyed-serializer'); const { neverThrowingMatcher, verifiedEmit, uriOf, singleUriOf, hasAsType } = require('./emitted-activity'); const { grantsPublicRead, hasOwnPublicRead, hasInheritedPublicRead, revocationGraph, aclUriOf, readAcl, putOwnRights, FOAF_AGENT } = require('./public-read-acl'); const { recordGrant, forgetGrant, isGrantedByFestipod } = require('./public-read-ledger'); const APODS_ATTENDEES = 'http://activitypods.org/ns/core#attendees'; /** The attendees collection an event names (compacted or full-IRI key), when it names exactly one. */ const attendeesCollectionOf = event => singleUriOf(event['apods:attendees'] ?? event[APODS_ATTENDEES]); // 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 // keeps it in step with the object's CURRENT public intent (`public-intent.js`), acting as the app // actor: an `as:Event` addressed to the Public collection is world-readable, one that is not is not. // Its attendees collection follows it both ways (the pod creates that collection world-readable). // It takes back ONLY the public reads Festipod gave — recorded in `public-read-ledger.js` — and leaves // any other one (the owner's, another app's) untouched. // // It learns of changes through the app's watcher on each user's outbox — the `Create` and `Update` // of any `as:Event`, whichever client made it and whatever its audience — and every notification // leads to the same `reconcile`: under the event's lock (`keyed-serializer.js`), read the event NOW, // then grant or revoke on the event and its collection. The notification itself only names the // object; whatever order notifications arrive or run in, the last action follows the newest read. module.exports = { name: 'public-read', mixins: [PodResourcesHandlerMixin], settings: { shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/as/Event') }, dependencies: ['pod-permissions'], created() { this.serialize = createKeyedSerializer(); }, // The mixin's default handlers would act on the activity's self-declared `actor` and on the // object embedded in the (unauthenticated) webhook body. Override the whole map: keep the // mixin's create/update matchers (made never-throwing), but every `onEmit` acts only on an // activity `verifiedEmit` accepts — re-read from the pod as the app, attributed to the owner of // the outbox the webhook fired on, its object an `as:Event` in that owner's pod — and even then // only as a pointer to the event `reconcile` reads again. No delete handler: when an event is // deleted its ACL goes with it. activities: { create: { match: neverThrowingMatcher(PodResourcesHandlerMixin.activities.create.match), onEmit: verifiedEmit( 'Create', async function (ctx, { objectUri }, actorUri) { await this.onCreate(ctx, objectUri, actorUri); }, { objectType: 'Event' } ) }, update: { match: neverThrowingMatcher(PodResourcesHandlerMixin.activities.update.match), onEmit: verifiedEmit( 'Update', async function (ctx, { objectUri }, actorUri) { await this.onUpdate(ctx, objectUri, actorUri); }, { objectType: 'Event' } ) } }, actions: { /** * Brings the public read of an event of `actorUri`'s pod — and of its attendees collection — in * line with the event's current public intent. Serialized per event: the event is read when the * previous reconcile of the same event has finished, and that read decides. Resolves * `{ isPublic, targets }`, or undefined when the event could not be judged (gone, unreadable, * not an `as:Event`, not in the actor's pod). A failed grant or revoke is logged, never thrown. * A reconcile that timed out and then WROTE (a write in flight at the timeout that landed after * it) is followed by ONE fresh reconcile of the same event, which judges that late write against * the state as it then is; that re-decision schedules nothing more, and a timed-out reconcile that * wrote nothing late schedules nothing at all (see `keyed-serializer.js` `onLateSettle`). */ reconcile: { params: { resourceUri: { type: 'string' }, actorUri: { type: 'string' } }, async handler(ctx) { const { resourceUri, actorUri } = ctx.params; return this.serialize(resourceUri, signal => this.reconcileNow(ctx, resourceUri, actorUri, signal), { onLateSettle: result => { if (result && result.wroteLate) this.redecideOnce(ctx, resourceUri, actorUri); } }); } }, /** `grantNow` outside any lock (see there). */ grant: { params: { resourceUri: { type: 'string' }, actorUri: { type: 'string' } }, async handler(ctx) { return this.grantNow(ctx, ctx.params.resourceUri, ctx.params.actorUri); } }, /** * Records that Festipod created `resourceUri` (an event's attendees collection, right after it is * attached): whatever public read it has is Festipod's doing — the pod makes such collections * world-readable — so `revoke` may take it back. */ recordCreated: { params: { resourceUri: { type: 'string' }, actorUri: { type: 'string' } }, async handler(ctx) { const { resourceUri } = ctx.params; await recordGrant(ctx, { dataset: CONFIG.MAIN_DATASET, uri: resourceUri }); this.logger.info(`Recorded ${resourceUri}, created by Festipod, as Festipod's to make public or not`); return true; } }, /** `revokeNow` outside any lock (see there). */ revoke: { params: { resourceUri: { type: 'string' }, actorUri: { type: 'string' } }, async handler(ctx) { return this.revokeNow(ctx, ctx.params.resourceUri, ctx.params.actorUri); } } }, methods: { // The mixin's matchers fire only when these exist. Both take the object (or its URI) as a // POINTER: the decision is `reconcile`'s, on a read taken when acting. They never throw: the // watcher runs every matching handler of an activity in sequence without catching, inside a // webhook call the platform does not await. async onCreate(ctx, resource, actorUri) { await this.reconcileLogged(ctx, uriOf(resource), actorUri); }, async onUpdate(ctx, resource, actorUri) { await this.reconcileLogged(ctx, uriOf(resource), actorUri); }, async reconcileLogged(ctx, resourceUri, actorUri) { try { if (!resourceUri || !actorUri) throw new Error('no event or no actor to reconcile'); await this.actions.reconcile({ resourceUri, actorUri }, { parentCtx: ctx }); } catch (e) { this.logger.error(`Could not reconcile public read on ${resourceUri}: ${e.message}`); } }, // The one re-decision after a late write: a plain serialized reconcile WITHOUT `onLateSettle`, so // whatever happens to it (even a timeout with a late write of its own) it schedules nothing more. async redecideOnce(ctx, resourceUri, actorUri) { try { await this.serialize(resourceUri, signal => this.reconcileNow(ctx, resourceUri, actorUri, signal)); } catch (e) { this.logger.error(`Could not re-decide public read on ${resourceUri} after a late write: ${e.message}`); } }, /** The resource's ACL as the pod serves it to the app (`public-read-acl.js` `readAcl`). */ async readAcl(ctx, resourceUri, actorUri) { return readAcl(ctx, resourceUri, actorUri); }, /** * Makes `resourceUri` world-readable and records it as FESTIPOD's grant — only when its ACL was * read and shows no public read: a resource already world-readable (the owner's share, another * app's) is left as it is and never recorded, and when the ACL cannot be read nothing is recorded * or written (the next notification retries). The record is written BEFORE the grant, so a * failure in between never leaves an unrecorded Festipod grant behind (which `revoke` would then * never take back). `signal` (from the event's lock) is checked immediately before each write; * the one write past it is re-recording a grant that LANDED late (a fact, see the code). * Resolves `true` when it added the grant, `false` otherwise; throws on a refusal or an unreadable ACL. */ async grantNow(ctx, resourceUri, actorUri, signal, late = {}) { const aborted = () => !!(signal && signal.aborted); // A write that RETURNS once the time has run out may have landed after a newer decision read. const wrote = () => { if (aborted()) late.wrote = true; }; const acl = await this.readAcl(ctx, resourceUri, actorUri); if (!acl.authorizations) throw new Error(`the ACL of ${resourceUri} could not be read (HTTP ${acl.status}): nothing granted or recorded`); if (grantsPublicRead(acl.authorizations)) { this.logger.info(`${resourceUri} is already publicly readable`); return false; } const dataset = CONFIG.MAIN_DATASET; if (aborted()) return false; await recordGrant(ctx, { dataset, uri: resourceUri }); wrote(); if (aborted()) return false; const added = await ctx.call('pod-permissions.add', { uri: resourceUri, agentPredicate: 'acl:agentClass', agentUri: FOAF_AGENT, mode: 'acl:Read', actorUri }); if (!added) { if (!aborted()) await forgetGrant(ctx, { dataset, uri: resourceUri }).catch(() => {}); throw new Error(`The pod refused the public-read grant on ${resourceUri}`); } if (aborted()) { late.wrote = true; // The grant landed after the time ran out, and a newer decision may have run meanwhile and // forgotten the record (it saw no public read yet). A grant Festipod made must stay on record — // this writes a FACT, not a decision — so the re-decision that follows (`onLateSettle`) can // judge it against the current state and take it back if the event is no longer public. await recordGrant(ctx, { dataset, uri: resourceUri }); this.logger.warn(`Public read on ${resourceUri} landed after its time ran out: re-recorded, to be re-decided`); return true; } this.logger.info(`Granted public read on ${resourceUri}`); return true; }, /** * The reverse of `grantNow`, for a grant FESTIPOD made (recorded in the ledger) and only for it: * a public read Festipod did not add — the owner's, another app's, the pod's — is left untouched * and logged. Removes ONLY the `acl:agentClass foaf:Agent` triple of the resource's own `#Read` * rule, leaving every other OWN authorization (a `foaf:Agent` on another mode included) and the * inherited owner defaults in place, then forgets the record. When the ACL cannot be read, the * state is unknown: nothing changes and the record is KEPT, so a later notification retries. * Idempotent: with the own public-read rule already gone, it only forgets the record. A public * read INHERITED from the container cannot be removed this way: it is reported as a warning. Not * usable via `pod-permissions.remove` (see `public-read-acl.js`): it PUTs a reconstructed * own-rights graph, acting as the app actor (the same actor `grant` writes as, which holds * `acl:Control` on the resource). `signal` is checked immediately before each write. Resolves * `true` when it removed the rule, `false` otherwise; throws when the pod refuses the change. */ async revokeNow(ctx, resourceUri, actorUri, signal, late = {}) { const aborted = () => !!(signal && signal.aborted); const wrote = () => { if (aborted()) late.wrote = true; }; const dataset = CONFIG.MAIN_DATASET; const acl = await this.readAcl(ctx, resourceUri, actorUri); if (!(await isGrantedByFestipod(ctx, { dataset, uri: resourceUri }))) { if (acl.authorizations && grantsPublicRead(acl.authorizations)) { this.logger.info(`${resourceUri} is publicly readable, but not through a grant Festipod made: left untouched`); } return false; } if (!acl.authorizations) { this.logger.warn(`The ACL of ${resourceUri} could not be read (HTTP ${acl.status}): Festipod's grant kept, a later notification retries`); return false; } const inherited = hasInheritedPublicRead(acl.authorizations); if (!hasOwnPublicRead(acl.authorizations)) { if (aborted()) return false; await forgetGrant(ctx, { dataset, uri: resourceUri }); wrote(); if (inherited) { this.logger.warn(`${resourceUri} is publicly readable through a rule inherited from its container: not revocable here`); } else { this.logger.info(`${resourceUri} is not publicly readable: nothing to revoke`); } return false; } const document = revocationGraph(acl.authorizations, resourceUri, actorUri, aclUriOf(resourceUri)); if (aborted()) return false; const status = await putOwnRights(ctx, resourceUri, document); if (status !== 204 && status !== 200) { throw new Error(`The pod refused to revoke the public-read grant on ${resourceUri} (HTTP ${status})`); } if (aborted()) { late.wrote = true; // the PUT landed after the time ran out: to be re-decided return true; } await forgetGrant(ctx, { dataset, uri: resourceUri }); wrote(); this.logger.info(`Revoked public read on ${resourceUri}`); if (inherited) { this.logger.warn(`${resourceUri} stays publicly readable through a rule inherited from its container`); } return true; }, // Runs under the event's lock (see `reconcile`). The read here is the one that decides. Once // `signal` is aborted (the work timed out and the lock moved on) it writes nothing more; it // resolves `wroteLate: true` when a write it had sent returned after that (see `reconcile`). async reconcileNow(ctx, resourceUri, actorUri, signal) { if (!isInActorPod(resourceUri, actorUri)) { this.logger.warn(`Public read not reconciled on ${resourceUri}: not in the pod of ${actorUri}`); return undefined; } const { status, body } = await ctx.call('pod-resources.get', { resourceUri, actorUri }); if (status === 404 || status === 410 || (status === 200 && hasAsType(body, 'Tombstone'))) { this.logger.info(`${resourceUri} is gone: its ACL went with it`); return undefined; } if (status !== 200 || !body || uriOf(body) !== resourceUri) { this.logger.warn(`Public read not reconciled on ${resourceUri}: the pod does not return it (HTTP ${status})`); return undefined; } if (!hasAsType(body, 'Event')) { this.logger.warn(`Public read not reconciled on ${resourceUri}: not an as:Event`); return undefined; } const isPublic = hasPublicIntent(body); const targets = [resourceUri]; const collectionUri = attendeesCollectionOf(body); if (collectionUri && isInActorPod(collectionUri, actorUri)) targets.push(collectionUri); else if (collectionUri) this.logger.warn(`${resourceUri} names an attendees collection outside its pod: left alone`); if (!isPublic) this.logger.info(`${resourceUri} is not addressed to the Public collection: no public read`); const late = { wrote: false }; for (const uri of targets) { if (signal && signal.aborted) break; try { if (isPublic) await this.grantNow(ctx, uri, actorUri, signal, late); else await this.revokeNow(ctx, uri, actorUri, signal, late); } catch (e) { this.logger.error(`Could not ${isPublic ? 'grant' : 'revoke'} public read on ${uri}: ${e.message}`); } } return { isPublic, targets, wroteLate: late.wrote }; } } };