From a7166f95e6c42ea52ddff4ae82c61215887611b5 Mon Sep 17 00:00:00 2001 From: srosset81 Date: Mon, 1 Apr 2024 17:28:10 +0200 Subject: [PATCH 01/20] Initial work --- .../mixins/controlled-collection.js | 123 ++++++++++++++---- .../activitypub/subservices/collection.js | 56 ++++---- .../packages/webacl/middlewares/webacl.js | 8 +- 3 files changed, 128 insertions(+), 59 deletions(-) diff --git a/src/middleware/packages/activitypub/mixins/controlled-collection.js b/src/middleware/packages/activitypub/mixins/controlled-collection.js index 7994701f8..76a611678 100644 --- a/src/middleware/packages/activitypub/mixins/controlled-collection.js +++ b/src/middleware/packages/activitypub/mixins/controlled-collection.js @@ -1,48 +1,115 @@ -const { Errors: E } = require('moleculer-web'); +const urlJoin = require('url-join'); +const { quad, namedNode } = require('@rdfjs/data-model'); +const { arrayOf } = require('@semapps/ldp'); module.exports = { settings: { - path: null, + automaticCreation: true, // If false, the action createAndAttachCollection will need to be called manually + path: null, // If not defined, the collection will be created in the root directory attachToTypes: [], attachPredicate: null, ordered: false, itemsPerPage: null, dereferenceItems: false, sort: { predicate: 'as:published', order: 'DESC' }, - permissions: null, - controlledActions: {} + permissions: null }, - dependencies: ['activitypub.registry'], async started() { - await this.broker.call('activitypub.registry.register', { - path: this.settings.path, - name: this.name, - attachToTypes: this.settings.attachToTypes, - attachPredicate: this.settings.attachPredicate, - ordered: this.settings.ordered, - itemsPerPage: this.settings.itemsPerPage, - dereferenceItems: this.settings.dereferenceItems, - sort: this.settings.sort, - permissions: this.settings.permissions, - controlledActions: { - get: `${this.name}.get`, - post: `${this.name}.post`, - ...this.settings.controlledActions - } - }); + this.collectionsInCreation = []; }, actions: { - get(ctx) { - return ctx.call('activitypub.collection.get', ctx.params); + async createAndAttachCollection(ctx) { + const { objectUri, webId } = ctx.params; + const collectionUri = urlJoin(objectUri, this.settings.path); + + const exists = await ctx.call('activitypub.collection.exist', { collectionUri }); + if (!exists && !this.collectionsInCreation.includes(collectionUri)) { + // Prevent race conditions by keeping the collections being created in memory + this.collectionsInCreation.push(collectionUri); + + // Create the collection + await ctx.call('activitypub.collection.create', { + collectionUri, + config: { + ordered: this.settings.ordered, + summary: this.settings.summary, + itemsPerPage: this.settings.itemsPerPage, + dereferenceItems: this.settings.dereferenceItems, + sortPredicate: this.settings.sort.predicate, + sortOrder: this.settings.sort.order + }, + permissions: this.settings.permissions, // Used by WebACL middleware if it exists + webId + }); + + // Attach it to the object + await ctx.call( + 'ldp.resource.patch', + { + resourceUri: objectUri, + triplesToAdd: [ + quad(namedNode(objectUri), namedNode(this.settings.attachPredicate), namedNode(collectionUri)) + ], + webId: 'system' + }, + { + meta: { + skipObjectsWatcher: true // We don't want to trigger an Update + } + } + ); + + // Now the collection has been created, we can remove it (this way we don't use too much memory) + this.collectionsInCreation = this.collectionsInCreation.filter(c => c !== collectionUri); + } }, - post() { - throw new E.ForbiddenError(); + async deleteCollection(ctx) { + const { objectUri } = ctx.params; + const collectionUri = urlJoin(objectUri, this.settings.path); + + const exists = await ctx.call('activitypub.collection.exist', { collectionUri, webId: 'system' }); + if (exists) { + // Delete the collection + await ctx.call('activitypub.collection.remove', { collectionUri, webId: 'system' }); + } } }, methods: { - async getCollectionUri(webId) { - // TODO make this work - return this.broker.call('activitypub.registry.getUri', { path: this.settings.path, webId }); + matchType(resource) { + return arrayOf(resource.type || resource['@type']).some(type => + arrayOf(this.settings.attachToTypes).includes(type) + ); + } + }, + events: { + async 'ldp.resource.created'(ctx) { + const { resourceUri, newData, webId } = ctx.params; + if (this.matchType(newData)) { + await this.actions.createAndAttachCollection({ objectUri: resourceUri, webId }, { parentCtx: ctx }); + } + }, + async 'ldp.resource.updated'(ctx) { + const { resourceUri, newData, oldData, webId } = ctx.params; + if (this.matchType(newData) && !this.matchType(oldData)) { + await this.actions.createAndAttachCollection({ objectUri: resourceUri, webId }, { parentCtx: ctx }); + } + }, + async 'ldp.resource.patched'(ctx) { + const { resourceUri, triplesAdded, webId } = ctx.params; + for (const triple of triplesAdded) { + if ( + triple.predicate.value === 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type' && + this.matchType({ type: triple.object.value }) + ) { + await this.actions.createAndAttachCollection({ objectUri: resourceUri, webId }, { parentCtx: ctx }); + } + } + }, + async 'ldp.resource.deleted'(ctx) { + const { resourceUri, oldData } = ctx.params; + if (this.matchType(oldData)) { + await this.actions.deleteCollection({ objectUri: resourceUri }, { parentCtx: ctx }); + } } } }; diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/collection.js b/src/middleware/packages/activitypub/services/activitypub/subservices/collection.js index 559e5b43f..bb40cde38 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/collection.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/collection.js @@ -16,13 +16,17 @@ const CollectionService = { */ async create(ctx) { const { collectionUri, config } = ctx.params; - const { ordered, summary } = config || {}; + const { ordered, summary, dereferenceItems, itemsPerPage, sortPredicate, sortOrder } = config || {}; await ctx.call('triplestore.insert', { resource: { '@context': 'https://www.w3.org/ns/activitystreams', id: collectionUri, type: ordered ? ['Collection', 'OrderedCollection'] : 'Collection', - summary + summary, + dereferenceItems, + itemsPerPage, + sortPredicate, + sortOrder }, contentType: MIME_TYPES.JSON, webId: 'system' @@ -149,45 +153,45 @@ const CollectionService = { /* * Returns a JSON-LD formatted collection stored in the triple store * @param collectionUri The full URI of the collection - * @param dereferenceItems Should we dereference the items in the collection ? * @param page Page number. If none are defined, display the collection. - * @param itemsPerPage Number of items to show per page * @param jsonContext JSON-LD context to format the whole result - * @param sort Object with `predicate` and `order` properties to sort ordered collections */ async get(ctx) { const { collectionUri, page, jsonContext } = ctx.params; const webId = ctx.params.webId || ctx.meta.webId || 'anon'; - const { dereferenceItems, itemsPerPage, sort } = { - ...(await ctx.call('activitypub.registry.getByUri', { collectionUri })), - ...ctx.params - }; const collection = await ctx.call('triplestore.query', { query: ` PREFIX as: CONSTRUCT { - <${collectionUri}> a as:Collection, ?collectionType . - <${collectionUri}> as:summary ?summary . + <${collectionUri}> + a as:Collection, ?collectionType ; + as:summary ?summary ; + as:dereferenceItems ?dereferenceItems ; + as:itemsPerPage ?itemsPerPage ; + as:sortPredicate ?sortPredicate ; + as:sortOrder ?sortOrder . } WHERE { <${collectionUri}> a as:Collection, ?collectionType . OPTIONAL { <${collectionUri}> as:summary ?summary . } + OPTIONAL { <${collectionUri}> as:dereferenceItems ?dereferenceItems . } + OPTIONAL { <${collectionUri}> as:itemsPerPage ?itemsPerPage . } + OPTIONAL { <${collectionUri}> as:sortPredicate ?sortPredicate . } + OPTIONAL { <${collectionUri}> as:sortOrder ?sortOrder . } } `, accept: MIME_TYPES.JSON, webId }); + const ordered = this.isOrderedCollection(collection); + // No persisted collection found if (!collection['@id']) { throw new MoleculerError('Collection Not found', 404, 'NOT_FOUND'); } - if (this.isOrderedCollection(collection) && !sort) { - throw new Error('A sort parameter must be provided for ordered collections'); - } - // Caution: we must do a select query, because construct queries cannot be sorted const result = await ctx.call('triplestore.query', { query: ` @@ -197,22 +201,26 @@ const CollectionService = { <${collectionUri}> a as:Collection . OPTIONAL { <${collectionUri}> as:items ?itemUri . - ${sort ? `OPTIONAL { ?itemUri ${sort.predicate} ?order . }` : ''} + ${ordered ? `OPTIONAL { ?itemUri ${collection.sortPredicate} ?order . }` : ''} } } - ${sort ? `ORDER BY ${sort.order}( ?order )` : ''} + ${ordered ? `ORDER BY ${collection.sortOrder === 'DescOrder' ? 'DESC' : 'ASC'}( ?order )` : ''} `, accept: MIME_TYPES.JSON, webId }); const allItems = result.filter(node => node.itemUri).map(node => node.itemUri.value); - const numPages = !itemsPerPage ? 1 : allItems.length > 0 ? Math.ceil(allItems.length / itemsPerPage) : 0; + const numPages = !collection.itemsPerPage + ? 1 + : allItems.length > 0 + ? Math.ceil(allItems.length / collection.itemsPerPage) + : 0; let returnData = null; if (page > 1 && page > numPages) { throw new MoleculerError('Collection Not found', 404, 'NOT_FOUND'); - } else if ((itemsPerPage && !page) || (page === 1 && allItems.length === 0)) { + } else if ((collection.itemsPerPage && !page) || (page === 1 && allItems.length === 0)) { // Pagination is enabled but no page is selected, return the collection // OR the first page is selected but there is no item, return an empty page returnData = { @@ -230,12 +238,12 @@ const CollectionService = { const itemsProp = this.isOrderedCollection(collection) ? 'orderedItems' : 'items'; // If pagination is enabled, return a slice of the items - if (itemsPerPage) { - const start = (page - 1) * itemsPerPage; - selectedItemsUris = allItems.slice(start, start + itemsPerPage); + if (collection.itemsPerPage) { + const start = (page - 1) * collection.itemsPerPage; + selectedItemsUris = allItems.slice(start, start + collection.itemsPerPage); } - if (dereferenceItems) { + if (collection.dereferenceItems) { for (const itemUri of selectedItemsUris) { try { selectedItems.push( @@ -262,7 +270,7 @@ const CollectionService = { selectedItems = selectedItemsUris; } - if (itemsPerPage) { + if (collection.itemsPerPage) { returnData = { '@context': 'https://www.w3.org/ns/activitystreams', id: `${collectionUri}?page=${page}`, diff --git a/src/middleware/packages/webacl/middlewares/webacl.js b/src/middleware/packages/webacl/middlewares/webacl.js index ed10a7f3c..f30a2202a 100644 --- a/src/middleware/packages/webacl/middlewares/webacl.js +++ b/src/middleware/packages/webacl/middlewares/webacl.js @@ -176,13 +176,7 @@ const WebAclMiddleware = ({ baseUrl, podProvider = false, graphName = 'http://se } case 'activitypub.collection.create': { - // On start, collection options are passed as parameters because the registry is not up yet - if (!ctx.params.options) { - ctx.params.options = await ctx.call('activitypub.registry.getByUri', { - collectionUri: ctx.params.collectionUri - }); - } - const rights = ctx.params.options?.permissions || defaultCollectionRights; + const rights = ctx.params.permissions || defaultCollectionRights; // We must add the permissions before inserting the collection await ctx.call('webacl.resource.addRights', { From b6b5030f6a9d54854ca50bcb6c60e4c61bbef498 Mon Sep 17 00:00:00 2001 From: srosset81 Date: Tue, 2 Apr 2024 18:08:20 +0200 Subject: [PATCH 02/20] Working tests --- .../mixins/controlled-collection.js | 123 +++------ .../activitypub/subservices/activity.js | 2 +- .../activitypub/subservices/collection.js | 236 +++++++++--------- .../activitypub/subservices/follow.js | 12 +- .../services/activitypub/subservices/inbox.js | 4 +- .../services/activitypub/subservices/like.js | 8 +- .../activitypub/subservices/outbox.js | 6 +- .../activitypub/subservices/registry.js | 35 ++- .../services/activitypub/subservices/reply.js | 4 +- .../packages/auth/services/auth.local.js | 12 +- .../services/context/actions/getLocal.js | 20 +- .../ldp/mixins/controlled-container.js | 3 + .../packages/ldp/routes/getCatchAllRoute.js | 2 + .../packages/ldp/services/api/actions/get.js | 3 +- src/middleware/packages/middlewares/index.js | 8 + .../ontologies/ontologies/core/semapps.json | 10 +- .../packages/webacl/middlewares/webacl.js | 22 +- .../tests/activitypub/collection.test.js | 170 +++++++++---- .../tests/activitypub/initialize.js | 12 +- 19 files changed, 349 insertions(+), 343 deletions(-) diff --git a/src/middleware/packages/activitypub/mixins/controlled-collection.js b/src/middleware/packages/activitypub/mixins/controlled-collection.js index 76a611678..7994701f8 100644 --- a/src/middleware/packages/activitypub/mixins/controlled-collection.js +++ b/src/middleware/packages/activitypub/mixins/controlled-collection.js @@ -1,115 +1,48 @@ -const urlJoin = require('url-join'); -const { quad, namedNode } = require('@rdfjs/data-model'); -const { arrayOf } = require('@semapps/ldp'); +const { Errors: E } = require('moleculer-web'); module.exports = { settings: { - automaticCreation: true, // If false, the action createAndAttachCollection will need to be called manually - path: null, // If not defined, the collection will be created in the root directory + path: null, attachToTypes: [], attachPredicate: null, ordered: false, itemsPerPage: null, dereferenceItems: false, sort: { predicate: 'as:published', order: 'DESC' }, - permissions: null + permissions: null, + controlledActions: {} }, + dependencies: ['activitypub.registry'], async started() { - this.collectionsInCreation = []; + await this.broker.call('activitypub.registry.register', { + path: this.settings.path, + name: this.name, + attachToTypes: this.settings.attachToTypes, + attachPredicate: this.settings.attachPredicate, + ordered: this.settings.ordered, + itemsPerPage: this.settings.itemsPerPage, + dereferenceItems: this.settings.dereferenceItems, + sort: this.settings.sort, + permissions: this.settings.permissions, + controlledActions: { + get: `${this.name}.get`, + post: `${this.name}.post`, + ...this.settings.controlledActions + } + }); }, actions: { - async createAndAttachCollection(ctx) { - const { objectUri, webId } = ctx.params; - const collectionUri = urlJoin(objectUri, this.settings.path); - - const exists = await ctx.call('activitypub.collection.exist', { collectionUri }); - if (!exists && !this.collectionsInCreation.includes(collectionUri)) { - // Prevent race conditions by keeping the collections being created in memory - this.collectionsInCreation.push(collectionUri); - - // Create the collection - await ctx.call('activitypub.collection.create', { - collectionUri, - config: { - ordered: this.settings.ordered, - summary: this.settings.summary, - itemsPerPage: this.settings.itemsPerPage, - dereferenceItems: this.settings.dereferenceItems, - sortPredicate: this.settings.sort.predicate, - sortOrder: this.settings.sort.order - }, - permissions: this.settings.permissions, // Used by WebACL middleware if it exists - webId - }); - - // Attach it to the object - await ctx.call( - 'ldp.resource.patch', - { - resourceUri: objectUri, - triplesToAdd: [ - quad(namedNode(objectUri), namedNode(this.settings.attachPredicate), namedNode(collectionUri)) - ], - webId: 'system' - }, - { - meta: { - skipObjectsWatcher: true // We don't want to trigger an Update - } - } - ); - - // Now the collection has been created, we can remove it (this way we don't use too much memory) - this.collectionsInCreation = this.collectionsInCreation.filter(c => c !== collectionUri); - } + get(ctx) { + return ctx.call('activitypub.collection.get', ctx.params); }, - async deleteCollection(ctx) { - const { objectUri } = ctx.params; - const collectionUri = urlJoin(objectUri, this.settings.path); - - const exists = await ctx.call('activitypub.collection.exist', { collectionUri, webId: 'system' }); - if (exists) { - // Delete the collection - await ctx.call('activitypub.collection.remove', { collectionUri, webId: 'system' }); - } + post() { + throw new E.ForbiddenError(); } }, methods: { - matchType(resource) { - return arrayOf(resource.type || resource['@type']).some(type => - arrayOf(this.settings.attachToTypes).includes(type) - ); - } - }, - events: { - async 'ldp.resource.created'(ctx) { - const { resourceUri, newData, webId } = ctx.params; - if (this.matchType(newData)) { - await this.actions.createAndAttachCollection({ objectUri: resourceUri, webId }, { parentCtx: ctx }); - } - }, - async 'ldp.resource.updated'(ctx) { - const { resourceUri, newData, oldData, webId } = ctx.params; - if (this.matchType(newData) && !this.matchType(oldData)) { - await this.actions.createAndAttachCollection({ objectUri: resourceUri, webId }, { parentCtx: ctx }); - } - }, - async 'ldp.resource.patched'(ctx) { - const { resourceUri, triplesAdded, webId } = ctx.params; - for (const triple of triplesAdded) { - if ( - triple.predicate.value === 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type' && - this.matchType({ type: triple.object.value }) - ) { - await this.actions.createAndAttachCollection({ objectUri: resourceUri, webId }, { parentCtx: ctx }); - } - } - }, - async 'ldp.resource.deleted'(ctx) { - const { resourceUri, oldData } = ctx.params; - if (this.matchType(oldData)) { - await this.actions.deleteCollection({ objectUri: resourceUri }, { parentCtx: ctx }); - } + async getCollectionUri(webId) { + // TODO make this work + return this.broker.call('activitypub.registry.getUri', { path: this.settings.path, webId }); } } }; diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/activity.js b/src/middleware/packages/activitypub/services/activitypub/subservices/activity.js index f83b6cb59..13859f9a2 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/activity.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/activity.js @@ -50,7 +50,7 @@ const ActivityService = { // TODO Fetch remote followers list ? if (recipient.startsWith(this.settings.baseUri)) { const collection = await ctx.call('activitypub.collection.get', { - collectionUri: recipient, + resourceUri: recipient, webId: activity.actor }); if (collection && collection.items) output.push(...arrayOf(collection.items)); diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/collection.js b/src/middleware/packages/activitypub/services/activitypub/subservices/collection.js index bb40cde38..a5b069329 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/collection.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/collection.js @@ -1,55 +1,70 @@ const { MoleculerError } = require('moleculer').Errors; +const { ControlledContainerMixin, arrayOf } = require('@semapps/ldp'); const { MIME_TYPES } = require('@semapps/mime-types'); +const { Errors: E } = require('moleculer-web'); const CollectionService = { name: 'activitypub.collection', + mixins: [ControlledContainerMixin], settings: { - podProvider: false + podProvider: false, + // ControlledContainerMixin settings + path: '/as/collection', + acceptedTypes: ['Collection', 'OrderedCollection'], + accept: MIME_TYPES.JSON, + permissions: {}, + newResourcesPermissions: webId => { + switch (webId) { + case 'anon': + case 'system': + return { + anon: { + read: true + } + }; + + default: + return { + user: { + uri: webId, + read: true, + write: true, + control: true + } + }; + } + }, + excludeFromMirror: true }, dependencies: ['triplestore', 'ldp.resource'], actions: { - /* - * Create a persisted collection - * @param collectionUri The full URI of the collection - * @param config.ordered If true, an OrderedCollection will be created - * @param config.summary An optional description of the collection - */ - async create(ctx) { - const { collectionUri, config } = ctx.params; - const { ordered, summary, dereferenceItems, itemsPerPage, sortPredicate, sortOrder } = config || {}; - await ctx.call('triplestore.insert', { - resource: { - '@context': 'https://www.w3.org/ns/activitystreams', - id: collectionUri, - type: ordered ? ['Collection', 'OrderedCollection'] : 'Collection', - summary, - dereferenceItems, - itemsPerPage, - sortPredicate, - sortOrder - }, - contentType: MIME_TYPES.JSON, - webId: 'system' - }); + put() { + throw new E.ForbiddenError(); }, - /* - * Checks if the collection exists - * @param collectionUri The full URI of the collection - * @return true if the collection exists - */ - async exist(ctx) { - const { collectionUri } = ctx.params; - return await ctx.call('triplestore.query', { - query: ` - PREFIX as: - ASK - WHERE { - <${collectionUri}> a as:Collection . - } - `, - accept: MIME_TYPES.JSON, - webId: 'system' - }); + patch() { + // Handle PATCH + }, + async post(ctx) { + if (!ctx.params.containerUri) { + ctx.params.containerUri = await this.actions.getContainerUri({ webId: ctx.params.webId }, { parentCtx: ctx }); + } + + const ordered = arrayOf(ctx.params.resource.type).includes('OrderedCollection'); + + // TODO Use ShEx to check collection validity + if (!ordered && (ctx.params.resource['semapps:sortPredicate'] || ctx.params.resource['semapps:sortOrder'])) { + throw new Error(`Non-ordered collections cannot include semapps:sortPredicate or semapps:sortOrder predicates`); + } + + // Set default values + if (!ctx.params.resource['semapps:dereferenceItems']) ctx.params.resource['semapps:dereferenceItems'] = false; + if (ordered) { + if (!ctx.params.resource['semapps:sortPredicate']) + ctx.params.resource['semapps:sortPredicate'] = 'as:published'; + if (!ctx.params.resource['semapps:sortOrder']) ctx.params.resource['semapps:sortOrder'] = 'semapps:DescOrder'; + } + + return await ctx.call('ldp.container.post', ctx.params); }, /* * Checks if the collection is empty @@ -99,17 +114,17 @@ const CollectionService = { * @param collectionUri The full URI of the collection * @param item The resource to add to the collection */ - async attach(ctx) { + async add(ctx) { let { collectionUri, item, itemUri } = ctx.params; if (!itemUri && item) itemUri = typeof item === 'object' ? item.id || item['@id'] : item; - if (!itemUri) throw new Error('No valid item URI provided for activitypub.collection.attach'); + if (!itemUri) throw new Error('No valid item URI provided for activitypub.collection.add'); // TODO also check external resources // const resourceExist = await ctx.call('ldp.resource.exist', { resourceUri: itemUri }); // if (!resourceExist) throw new Error('Cannot attach a non-existing resource !') // TODO check why thrown error is lost and process is stopped - const collectionExist = await ctx.call('activitypub.collection.exist', { collectionUri }); + const collectionExist = await ctx.call('activitypub.collection.exist', { resourceUri: collectionUri }); if (!collectionExist) throw new Error(`Cannot attach to a non-existing collection: ${collectionUri} (dataset: ${ctx.meta.dataset})`); @@ -128,12 +143,12 @@ const CollectionService = { * @param collectionUri The full URI of the collection * @param item The resource to remove from the collection */ - async detach(ctx) { + async remove(ctx) { let { collectionUri, item, itemUri } = ctx.params; if (!itemUri && item) itemUri = typeof item === 'object' ? item.id || item['@id'] : item; - if (!itemUri) throw new Error('No valid item URI provided for activitypub.collection.detach'); + if (!itemUri) throw new Error('No valid item URI provided for activitypub.collection.remove'); - const collectionExist = await ctx.call('activitypub.collection.exist', { collectionUri }); + const collectionExist = await ctx.call('activitypub.collection.exist', { resourceUri: collectionUri }); if (!collectionExist) throw new Error(`Cannot detach from a non-existing collection: ${collectionUri}`); await ctx.call('triplestore.update', { @@ -150,48 +165,49 @@ const CollectionService = { itemUri }); }, - /* - * Returns a JSON-LD formatted collection stored in the triple store - * @param collectionUri The full URI of the collection - * @param page Page number. If none are defined, display the collection. - * @param jsonContext JSON-LD context to format the whole result - */ async get(ctx) { - const { collectionUri, page, jsonContext } = ctx.params; + const { resourceUri: collectionUri, jsonContext } = ctx.params; + const page = ctx.params.page || ctx.meta.queryString?.page; const webId = ctx.params.webId || ctx.meta.webId || 'anon'; + const localContext = await ctx.call('jsonld.context.get'); - const collection = await ctx.call('triplestore.query', { + const results = await ctx.call('triplestore.query', { query: ` PREFIX as: - CONSTRUCT { - <${collectionUri}> - a as:Collection, ?collectionType ; - as:summary ?summary ; - as:dereferenceItems ?dereferenceItems ; - as:itemsPerPage ?itemsPerPage ; - as:sortPredicate ?sortPredicate ; - as:sortOrder ?sortOrder . - } + PREFIX semapps: + SELECT ?ordered ?summary ?dereferenceItems ?itemsPerPage ?sortPredicate ?sortOrder WHERE { - <${collectionUri}> a as:Collection, ?collectionType . + BIND (EXISTS{<${collectionUri}> a } AS ?ordered) OPTIONAL { <${collectionUri}> as:summary ?summary . } - OPTIONAL { <${collectionUri}> as:dereferenceItems ?dereferenceItems . } - OPTIONAL { <${collectionUri}> as:itemsPerPage ?itemsPerPage . } - OPTIONAL { <${collectionUri}> as:sortPredicate ?sortPredicate . } - OPTIONAL { <${collectionUri}> as:sortOrder ?sortOrder . } + OPTIONAL { <${collectionUri}> semapps:dereferenceItems ?dereferenceItems . } + OPTIONAL { <${collectionUri}> semapps:itemsPerPage ?itemsPerPage . } + OPTIONAL { <${collectionUri}> semapps:sortPredicate ?sortPredicate . } + OPTIONAL { <${collectionUri}> semapps:sortOrder ?sortOrder . } } `, accept: MIME_TYPES.JSON, webId }); - const ordered = this.isOrderedCollection(collection); - - // No persisted collection found - if (!collection['@id']) { + if (!results.length === 0) { throw new MoleculerError('Collection Not found', 404, 'NOT_FOUND'); } + const options = Object.fromEntries( + Object.entries(results[0]).map(([key, val]) => [ + key, + val.datatype?.value === 'http://www.w3.org/2001/XMLSchema#boolean' ? val.value === 'true' : val.value + ]) + ); + + const collectionOptions = { + summary: options.summary, + 'semapps:dereferenceItems': options.dereferenceItems, + 'semapps:itemsPerPage': options.itemsPerPage, + 'semapps:sortPredicate': options.sortPredicate, + 'semapps:sortOrder': options.sortOrder + }; + // Caution: we must do a select query, because construct queries cannot be sorted const result = await ctx.call('triplestore.query', { query: ` @@ -201,33 +217,37 @@ const CollectionService = { <${collectionUri}> a as:Collection . OPTIONAL { <${collectionUri}> as:items ?itemUri . - ${ordered ? `OPTIONAL { ?itemUri ${collection.sortPredicate} ?order . }` : ''} + ${options.ordered ? `OPTIONAL { ?itemUri <${options.sortPredicate}> ?order . }` : ''} } } - ${ordered ? `ORDER BY ${collection.sortOrder === 'DescOrder' ? 'DESC' : 'ASC'}( ?order )` : ''} + ${ + options.ordered + ? `ORDER BY ${options.sortOrder === 'http://semapps.org/ns/core#DescOrder' ? 'DESC' : 'ASC'}( ?order )` + : '' + } `, accept: MIME_TYPES.JSON, webId }); const allItems = result.filter(node => node.itemUri).map(node => node.itemUri.value); - const numPages = !collection.itemsPerPage + const numPages = !options.itemsPerPage ? 1 : allItems.length > 0 - ? Math.ceil(allItems.length / collection.itemsPerPage) + ? Math.ceil(allItems.length / options.itemsPerPage) : 0; let returnData = null; if (page > 1 && page > numPages) { throw new MoleculerError('Collection Not found', 404, 'NOT_FOUND'); - } else if ((collection.itemsPerPage && !page) || (page === 1 && allItems.length === 0)) { + } else if ((options.itemsPerPage && !page) || (page === 1 && allItems.length === 0)) { // Pagination is enabled but no page is selected, return the collection // OR the first page is selected but there is no item, return an empty page returnData = { - '@context': 'https://www.w3.org/ns/activitystreams', + '@context': localContext, id: collectionUri, - type: this.isOrderedCollection(collection) ? 'OrderedCollection' : 'Collection', - summary: collection.summary, + type: options.ordered ? 'OrderedCollection' : 'Collection', + ...collectionOptions, first: numPages > 0 ? `${collectionUri}?page=1` : undefined, last: numPages > 0 ? `${collectionUri}?page=${numPages}` : undefined, totalItems: allItems ? allItems.length : 0 @@ -235,15 +255,15 @@ const CollectionService = { } else { let selectedItemsUris = allItems; let selectedItems = []; - const itemsProp = this.isOrderedCollection(collection) ? 'orderedItems' : 'items'; + const itemsProp = options.ordered ? 'orderedItems' : 'items'; // If pagination is enabled, return a slice of the items - if (collection.itemsPerPage) { - const start = (page - 1) * collection.itemsPerPage; - selectedItemsUris = allItems.slice(start, start + collection.itemsPerPage); + if (options.itemsPerPage) { + const start = (page - 1) * options.itemsPerPage; + selectedItemsUris = allItems.slice(start, start + options.itemsPerPage); } - if (collection.dereferenceItems) { + if (options.dereferenceItems) { for (const itemUri of selectedItemsUris) { try { selectedItems.push( @@ -257,7 +277,7 @@ const CollectionService = { } catch (e) { if (e.code === 404 || e.code === 403) { // Ignore resource if it is not found - this.logger.warn(`Resource not found with URI: ${itemUri}`); + this.logger.warn(`Item not found with URI: ${itemUri}`); } else { throw e; } @@ -270,11 +290,11 @@ const CollectionService = { selectedItems = selectedItemsUris; } - if (collection.itemsPerPage) { + if (options.itemsPerPage) { returnData = { - '@context': 'https://www.w3.org/ns/activitystreams', + '@context': localContext, id: `${collectionUri}?page=${page}`, - type: this.isOrderedCollection(collection) ? 'OrderedCollectionPage' : 'CollectionPage', + type: options.ordered ? 'OrderedCollectionPage' : 'CollectionPage', partOf: collectionUri, prev: page > 1 ? `${collectionUri}?page=${parseInt(page) - 1}` : undefined, next: page < numPages ? `${collectionUri}?page=${parseInt(page) + 1}` : undefined, @@ -284,10 +304,10 @@ const CollectionService = { } else { // No pagination, return the collection returnData = { - '@context': 'https://www.w3.org/ns/activitystreams', + '@context': localContext, id: collectionUri, - type: this.isOrderedCollection(collection) ? 'OrderedCollection' : 'Collection', - summary: collection.summary, + type: options.ordered ? 'OrderedCollection' : 'Collection', + ...collectionOptions, [itemsProp]: selectedItems, totalItems: allItems ? allItems.length : 0 }; @@ -296,7 +316,7 @@ const CollectionService = { return await ctx.call('jsonld.parser.compact', { input: returnData, - context: jsonContext || (await ctx.call('jsonld.context.get')) + context: jsonContext || localContext }); }, /* @@ -325,22 +345,6 @@ const CollectionService = { * The items are not deleted, for this call the clear action. * @param collectionUri The full URI of the collection */ - async remove(ctx) { - const collectionUri = ctx.params.collectionUri.replace(/\/+$/, ''); - await ctx.call('triplestore.update', { - query: ` - PREFIX as: - DELETE { - ?s1 ?p1 ?o1 . - } - WHERE { - FILTER(?s1 IN (<${collectionUri}>, <${`${collectionUri}/`}>)) . - ?s1 ?p1 ?o1 . - } - `, - webId: 'system' - }); - }, async getOwner(ctx) { const { collectionUri, collectionKey } = ctx.params; @@ -366,7 +370,7 @@ const CollectionService = { hooks: { before: { '*'(ctx) { - // If we have a pod provider, guess the dataset from the container URI + // If we have a pod provider, guess the dataset from the collection URI if (this.settings.podProvider && ctx.params.collectionUri) { const collectionPath = new URL(ctx.params.collectionUri).pathname; const parts = collectionPath.split('/'); @@ -376,14 +380,6 @@ const CollectionService = { } } } - }, - methods: { - isOrderedCollection(collection) { - return ( - collection['@type'] === 'as:OrderedCollection' || - (Array.isArray(collection['@type']) && collection['@type'].includes('as:OrderedCollection')) - ); - } } }; diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/follow.js b/src/middleware/packages/activitypub/services/activitypub/subservices/follow.js index bf66cb6d6..4302579ef 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/follow.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/follow.js @@ -38,7 +38,7 @@ const FollowService = { if (this.isLocalActor(following)) { const actor = await ctx.call('activitypub.actor.get', { actorUri: following }); if (actor.followers) { - await ctx.call('activitypub.collection.attach', { + await ctx.call('activitypub.collection.add', { collectionUri: actor.followers, item: follower }); @@ -49,7 +49,7 @@ const FollowService = { if (this.isLocalActor(follower)) { const actor = await ctx.call('activitypub.actor.get', { actorUri: follower }); if (actor.following) { - await ctx.call('activitypub.collection.attach', { + await ctx.call('activitypub.collection.add', { collectionUri: actor.following, item: following }); @@ -64,7 +64,7 @@ const FollowService = { if (this.isLocalActor(following)) { const actor = await ctx.call('activitypub.actor.get', { actorUri: following }); if (actor.followers) { - await ctx.call('activitypub.collection.detach', { + await ctx.call('activitypub.collection.remove', { collectionUri: actor.followers, item: follower }); @@ -75,7 +75,7 @@ const FollowService = { if (this.isLocalActor(follower)) { const actor = await ctx.call('activitypub.actor.get', { actorUri: follower }); if (actor.following) { - await ctx.call('activitypub.collection.detach', { + await ctx.call('activitypub.collection.remove', { collectionUri: actor.following, item: following }); @@ -100,14 +100,14 @@ const FollowService = { const { collectionUri } = ctx.params; return await ctx.call('activitypub.collection.get', { - collectionUri + resourceUri: collectionUri }); }, async listFollowing(ctx) { const { collectionUri } = ctx.params; return await ctx.call('activitypub.collection.get', { - collectionUri + resourceUri: collectionUri }); } }, diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/inbox.js b/src/middleware/packages/activitypub/services/activitypub/subservices/inbox.js index 4ee4901d8..a370906dd 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/inbox.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/inbox.js @@ -37,7 +37,7 @@ const InboxService = { // Remember inbox owner (used by WebACL middleware) const actorUri = await ctx.call('activitypub.collection.getOwner', { collectionUri, collectionKey: 'inbox' }); - const collectionExists = await ctx.call('activitypub.collection.exist', { collectionUri }); + const collectionExists = await ctx.call('activitypub.collection.exist', { resourceUri: collectionUri }); if (!collectionExists) { ctx.meta.$statusCode = 404; return; @@ -84,7 +84,7 @@ const InboxService = { }); // Attach the activity to the inbox - await ctx.call('activitypub.collection.attach', { + await ctx.call('activitypub.collection.add', { collectionUri, item: activity }); diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/like.js b/src/middleware/packages/activitypub/services/activitypub/subservices/like.js index 14c6d407b..a3353a4f1 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/like.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/like.js @@ -42,7 +42,7 @@ const LikeService = { // If a liked collection is attached to the actor, attach the object if (actor.liked) { - await ctx.call('activitypub.collection.attach', { + await ctx.call('activitypub.collection.add', { collectionUri: actor.liked, item: objectUri }); @@ -50,7 +50,7 @@ const LikeService = { // If a likes collection is attached to the object, attach the actor if (object.likes) { - await ctx.call('activitypub.collection.attach', { + await ctx.call('activitypub.collection.add', { collectionUri: object.likes, item: actorUri }); @@ -66,7 +66,7 @@ const LikeService = { // If a liked collection is attached to the actor, detach the object if (actor.liked) { - await ctx.call('activitypub.collection.detach', { + await ctx.call('activitypub.collection.remove', { collectionUri: actor.liked, item: objectUri }); @@ -74,7 +74,7 @@ const LikeService = { // If a likes collection is attached to the object, detach the actor if (object.likes) { - await ctx.call('activitypub.collection.detach', { + await ctx.call('activitypub.collection.remove', { collectionUri: object.likes, item: actorUri }); diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/outbox.js b/src/middleware/packages/activitypub/services/activitypub/subservices/outbox.js index 78aceb753..118b08149 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/outbox.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/outbox.js @@ -26,7 +26,7 @@ const OutboxService = { async post(ctx) { let { collectionUri, ...activity } = ctx.params; - const collectionExists = await ctx.call('activitypub.collection.exist', { collectionUri }); + const collectionExists = await ctx.call('activitypub.collection.exist', { resourceUri: collectionUri }); if (!collectionExists) { throw new MoleculerError(`Collection not found:${collectionUri}`, 404, 'NOT_FOUND'); } @@ -79,7 +79,7 @@ const OutboxService = { activity = await ctx.call('activitypub.activity.get', { resourceUri: activityUri, webId: 'system' }); // Attach the newly-created activity to the outbox - await ctx.call('activitypub.collection.attach', { + await ctx.call('activitypub.collection.add', { collectionUri, item: activity }); @@ -166,7 +166,7 @@ const OutboxService = { // Attach activity to the inbox of the recipient await this.broker.call( - 'activitypub.collection.attach', + 'activitypub.collection.add', { collectionUri: recipientInbox, item: activity diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/registry.js b/src/middleware/packages/activitypub/services/activitypub/subservices/registry.js index 9d002da6d..4e8599ee8 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/registry.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/registry.js @@ -1,7 +1,7 @@ const urlJoin = require('url-join'); const { quad, namedNode } = require('@rdfjs/data-model'); const { MIME_TYPES } = require('@semapps/mime-types'); -const { defaultToArray } = require('../../../utils'); +const { defaultToArray, getSlugFromUri } = require('../../../utils'); const { ACTOR_TYPES, FULL_ACTOR_TYPES, AS_PREFIX } = require('../../../constants'); const RegistryService = { @@ -78,19 +78,28 @@ const RegistryService = { }, async createAndAttachCollection(ctx) { const { objectUri, collection, webId } = ctx.params; - const collectionUri = urlJoin(objectUri, collection.path); + const { path, attachPredicate, ordered, summary, dereferenceItems, itemsPerPage, sortPredicate, sortOrder } = + collection || {}; + const collectionUri = urlJoin(objectUri, path); - const exists = await ctx.call('activitypub.collection.exist', { collectionUri }); + const exists = await ctx.call('activitypub.collection.exist', { resourceUri: collectionUri }); if (!exists && !this.collectionsInCreation.includes(collectionUri)) { // Prevent race conditions by keeping the collections being created in memory this.collectionsInCreation.push(collectionUri); // Create the collection - await ctx.call('activitypub.collection.create', { - collectionUri, - config: { ordered: collection?.ordered, summary: collection?.summary }, - options: collection, // Used by WebACL middleware if it exists - webId + await ctx.call('activitypub.collection.post', { + resource: { + type: ordered ? ['Collection', 'OrderedCollection'] : 'Collection', + summary, + 'semapps:dereferenceItems': dereferenceItems, + 'semapps:itemsPerPage': itemsPerPage, + 'semapps:sortPredicate': sortPredicate, + 'semapps:sortOrder': sortOrder + }, + contentType: MIME_TYPES.JSON, + slug: path ? getSlugFromUri(objectUri) + path : undefined, + webId: 'system' }); // Attach it to the object @@ -98,8 +107,8 @@ const RegistryService = { 'ldp.resource.patch', { resourceUri: objectUri, - triplesToAdd: [quad(namedNode(objectUri), namedNode(collection.attachPredicate), namedNode(collectionUri))], - webId: 'system' + triplesToAdd: [quad(namedNode(objectUri), namedNode(attachPredicate), namedNode(collectionUri))], + webId }, { meta: { @@ -114,12 +123,12 @@ const RegistryService = { }, async deleteCollection(ctx) { const { objectUri, collection } = ctx.params; - const collectionUri = urlJoin(objectUri, collection.path); + const resourceUri = urlJoin(objectUri, collection.path); - const exists = await ctx.call('activitypub.collection.exist', { collectionUri, webId: 'system' }); + const exists = await ctx.call('activitypub.collection.exist', { resourceUri, webId: 'system' }); if (exists) { // Delete the collection - await ctx.call('activitypub.collection.remove', { collectionUri, webId: 'system' }); + await ctx.call('activitypub.collection.delete', { resourceUri, webId: 'system' }); } }, async createAndAttachMissingCollections(ctx) { diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/reply.js b/src/middleware/packages/activitypub/services/activitypub/subservices/reply.js index 92f4f2e90..d99c294b7 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/reply.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/reply.js @@ -28,14 +28,14 @@ const ReplyService = { const object = await ctx.call('activitypub.object.get', { objectUri }); - await ctx.call('activitypub.collection.attach', { collectionUri: object.replies, item: replyUri }); + await ctx.call('activitypub.collection.add', { collectionUri: object.replies, item: replyUri }); }, async removeReply(ctx) { const { objectUri, replyUri } = ctx.params; const object = await ctx.call('activitypub.object.get', { objectUri }); - await ctx.call('activitypub.collection.detach', { collectionUri: object.replies, item: replyUri }); + await ctx.call('activitypub.collection.remove', { collectionUri: object.replies, item: replyUri }); } }, activities: { diff --git a/src/middleware/packages/auth/services/auth.local.js b/src/middleware/packages/auth/services/auth.local.js index 17473c67c..8bd9fd366 100644 --- a/src/middleware/packages/auth/services/auth.local.js +++ b/src/middleware/packages/auth/services/auth.local.js @@ -32,11 +32,13 @@ const AuthLocalService = { this.passportId = 'local'; - await this.broker.createService(AuthMailService, { - settings: { - ...mail - } - }); + if (mail !== false) { + await this.broker.createService(AuthMailService, { + settings: { + ...mail + } + }); + } }, actions: { async signup(ctx) { diff --git a/src/middleware/packages/jsonld/services/context/actions/getLocal.js b/src/middleware/packages/jsonld/services/context/actions/getLocal.js index a2745f70d..80b39b943 100644 --- a/src/middleware/packages/jsonld/services/context/actions/getLocal.js +++ b/src/middleware/packages/jsonld/services/context/actions/getLocal.js @@ -1,5 +1,3 @@ -const { isObject } = require('../../../utils'); - module.exports = { visibility: 'public', cache: true, @@ -10,20 +8,16 @@ module.exports = { for (const ontology of ontologies) { // Do not include in local contexts URIs we want to preserve explicitely - if (ontology.preserveContextUri !== true) { - if (!ontology.jsonldContext) { - // If no context is defined for the ontology, simply add its prefix - ontology.jsonldContext = { [ontology.prefix]: ontology.namespace }; - } else if (isObject(ontology.jsonldContext)) { - // If the context is an object, ensure the prefix is included - ontology.jsonldContext[ontology.prefix] = ontology.namespace; - } - - context = [].concat(context, ontology.jsonldContext); + if (ontology.jsonldContext && ontology.preserveContextUri !== true) { + context = [].concat(ontology.jsonldContext, context); } } - context = await ctx.call('jsonld.context.parse', { context }); + const prefixes = Object.fromEntries(ontologies.map(ont => [ont.prefix, ont.namespace])); + + context = await ctx.call('jsonld.context.parse', { + context: [...context, prefixes] + }); return { '@context': context diff --git a/src/middleware/packages/ldp/mixins/controlled-container.js b/src/middleware/packages/ldp/mixins/controlled-container.js index 0a6381618..8e2efb875 100644 --- a/src/middleware/packages/ldp/mixins/controlled-container.js +++ b/src/middleware/packages/ldp/mixins/controlled-container.js @@ -83,6 +83,9 @@ module.exports = { delete(ctx) { return ctx.call('ldp.resource.delete', ctx.params); }, + exist(ctx) { + return ctx.call('ldp.resource.exist', ctx.params); + }, getContainerUri(ctx) { return ctx.call('ldp.registry.getUri', { path: this.settings.path, webId: ctx.params?.webId || ctx.meta?.webId }); }, diff --git a/src/middleware/packages/ldp/routes/getCatchAllRoute.js b/src/middleware/packages/ldp/routes/getCatchAllRoute.js index d9d1361ff..0d52925e1 100644 --- a/src/middleware/packages/ldp/routes/getCatchAllRoute.js +++ b/src/middleware/packages/ldp/routes/getCatchAllRoute.js @@ -1,4 +1,5 @@ const { + parseQueryString, parseHeader, parseSparql, negotiateContentType, @@ -11,6 +12,7 @@ const { function getCatchAllRoute(podProvider) { const middlewares = [ + parseQueryString, parseHeader, negotiateContentType, negotiateAccept, diff --git a/src/middleware/packages/ldp/services/api/actions/get.js b/src/middleware/packages/ldp/services/api/actions/get.js index 23995276f..801c48f10 100644 --- a/src/middleware/packages/ldp/services/api/actions/get.js +++ b/src/middleware/packages/ldp/services/api/actions/get.js @@ -43,8 +43,7 @@ module.exports = async function get(ctx) { res = await ctx.call( controlledActions?.get || 'activitypub.collection.get', cleanUndefined({ - collectionUri: uri, - page: ctx.params.page, + resourceUri: uri, jsonContext: parseJson(ctx.meta.headers?.jsonldcontext) }) ); diff --git a/src/middleware/packages/middlewares/index.js b/src/middleware/packages/middlewares/index.js index c01554d02..5abe83732 100644 --- a/src/middleware/packages/middlewares/index.js +++ b/src/middleware/packages/middlewares/index.js @@ -3,6 +3,13 @@ const { negotiateTypeMime, MIME_TYPES } = require('@semapps/mime-types'); const Busboy = require('busboy'); const streams = require('memory-streams'); +// Put query string in meta so that services may use them independently +// Set here https://github.com/moleculerjs/moleculer-web/blob/c6ec80056a64ea15c57d6e2b946ce978d673ae92/src/index.js#L151-L161 +const parseQueryString = async (req, res, next) => { + req.$ctx.meta.queryString = req.query; + next(); +}; + const parseHeader = async (req, res, next) => { req.$ctx.meta.headers = req.headers ? { ...req.headers } : {}; // Also remember original headers (needed for HTTP signatures verification and files type negociation) @@ -187,6 +194,7 @@ const saveDatasetMeta = (req, res, next) => { }; module.exports = { + parseQueryString, parseHeader, parseSparql, negotiateContentType, diff --git a/src/middleware/packages/ontologies/ontologies/core/semapps.json b/src/middleware/packages/ontologies/ontologies/core/semapps.json index 06b0e7280..9c51bb4cf 100644 --- a/src/middleware/packages/ontologies/ontologies/core/semapps.json +++ b/src/middleware/packages/ontologies/ontologies/core/semapps.json @@ -1,4 +1,12 @@ { "prefix": "semapps", - "namespace": "http://semapps.org/ns/core#" + "namespace": "http://semapps.org/ns/core#", + "jsonldContext": { + "semapps:sortPredicate": { + "@type": "@id" + }, + "semapps:sortOrder": { + "@type": "@id" + } + } } diff --git a/src/middleware/packages/webacl/middlewares/webacl.js b/src/middleware/packages/webacl/middlewares/webacl.js index f30a2202a..d79ceb0c6 100644 --- a/src/middleware/packages/webacl/middlewares/webacl.js +++ b/src/middleware/packages/webacl/middlewares/webacl.js @@ -1,12 +1,11 @@ const { throw403 } = require('@semapps/middlewares'); const { arrayOf } = require('@semapps/ldp'); const { isRemoteUri, getSlugFromUri } = require('../utils'); -const { defaultContainerRights, defaultCollectionRights } = require('../defaultRights'); +const { defaultContainerRights } = require('../defaultRights'); const modifyActions = [ 'ldp.resource.create', 'ldp.container.create', - 'activitypub.collection.create', 'activitypub.activity.create', 'activitypub.activity.attach', 'webid.create', @@ -175,18 +174,6 @@ const WebAclMiddleware = ({ baseUrl, podProvider = false, graphName = 'http://se break; } - case 'activitypub.collection.create': { - const rights = ctx.params.permissions || defaultCollectionRights; - - // We must add the permissions before inserting the collection - await ctx.call('webacl.resource.addRights', { - resourceUri: ctx.params.collectionUri, - newRights: typeof rights === 'function' ? rights(webId) : rights, - webId: 'system' - }); - break; - } - default: break; } @@ -213,13 +200,6 @@ const WebAclMiddleware = ({ baseUrl, podProvider = false, graphName = 'http://se { meta: { webId: 'system' } } ); break; - case 'activitypub.collection.create': - await ctx.call( - 'webacl.resource.deleteAllRights', - { resourceUri: ctx.params.collectionUri }, - { meta: { webId: 'system' } } - ); - break; default: break; } diff --git a/src/middleware/tests/activitypub/collection.test.js b/src/middleware/tests/activitypub/collection.test.js index 85dbb91a0..c8577cfe7 100644 --- a/src/middleware/tests/activitypub/collection.test.js +++ b/src/middleware/tests/activitypub/collection.test.js @@ -5,8 +5,6 @@ const CONFIG = require('../config'); jest.setTimeout(50000); -const collectionUri = `${CONFIG.HOME_URL}my-collection`; -const orderedCollectionUri = `${CONFIG.HOME_URL}my-ordered-collection`; let broker; beforeAll(async () => { @@ -18,6 +16,8 @@ afterAll(async () => { describe('Handle collections', () => { const items = []; + let collectionUri; + let orderedCollectionUri; test('Create ressources', async () => { for (let i = 0; i < 10; i++) { @@ -39,33 +39,35 @@ describe('Handle collections', () => { }); test('Create collection', async () => { - await broker.call('activitypub.collection.create', { - collectionUri, - config: { - ordered: false, + collectionUri = await broker.call('activitypub.collection.post', { + resource: { + type: 'Collection', summary: 'My non-ordered collection' - } + }, + contentType: MIME_TYPES.JSON, + webId: 'system' }); const collectionExist = await broker.call('activitypub.collection.exist', { - collectionUri + resourceUri: collectionUri }); expect(collectionExist).toBeTruthy(); - const collection = await broker.call('activitypub.collection.get', { collectionUri }); + const collection = await broker.call('activitypub.collection.get', { resourceUri: collectionUri }); expect(collection).toMatchObject({ id: collectionUri, type: 'Collection', summary: 'My non-ordered collection', + 'semapps:dereferenceItems': false, totalItems: 0 }); }); test('Get collection with custom jsonContext', async () => { const collection = await broker.call('activitypub.collection.get', { - collectionUri, + resourceUri: collectionUri, jsonContext: { as: 'https://www.w3.org/ns/activitystreams#' } }); @@ -73,6 +75,7 @@ describe('Handle collections', () => { '@id': collectionUri, '@type': 'as:Collection', 'as:summary': 'My non-ordered collection', + 'http://semapps.org/ns/core#dereferenceItems': false, 'as:totalItems': expect.objectContaining({ '@value': 0 }) @@ -80,41 +83,45 @@ describe('Handle collections', () => { }); test('Create ordered collection', async () => { - await broker.call('activitypub.collection.create', { - collectionUri: orderedCollectionUri, - config: { - ordered: true, - summary: 'My ordered collection' - } + orderedCollectionUri = await broker.call('activitypub.collection.post', { + resource: { + type: ['Collection', 'OrderedCollection'], + summary: 'My ordered collection', + 'semapps:dereferenceItems': false + }, + contentType: MIME_TYPES.JSON, + webId: 'system' }); const collectionExist = await broker.call('activitypub.collection.exist', { - collectionUri: orderedCollectionUri + resourceUri: orderedCollectionUri }); expect(collectionExist).toBeTruthy(); const collection = await broker.call('activitypub.collection.get', { - collectionUri: orderedCollectionUri, - sort: { predicate: 'as:published', order: 'DESC' } + resourceUri: orderedCollectionUri }); expect(collection).toMatchObject({ id: orderedCollectionUri, type: 'OrderedCollection', summary: 'My ordered collection', + 'semapps:dereferenceItems': false, + 'semapps:sortPredicate': 'as:published', + 'semapps:sortOrder': 'semapps:DescOrder', totalItems: 0 }); }); - test('Attach item to collection', async () => { - await broker.call('activitypub.collection.attach', { + test('Add item to collection', async () => { + await broker.call('activitypub.collection.add', { collectionUri, item: items[0] }); let collection = await broker.call('activitypub.collection.get', { - collectionUri + resourceUri: collectionUri }); expect(collection).toMatchObject({ @@ -124,16 +131,32 @@ describe('Handle collections', () => { items: items[0], totalItems: 1 }); + }); - collection = await broker.call('activitypub.collection.get', { - collectionUri, - dereferenceItems: true + test('Get collection with dereference items', async () => { + const collectionWithDereferenceUri = await broker.call('activitypub.collection.post', { + resource: { + type: 'Collection', + summary: 'My non-ordered collection with dereferenceItems: true', + 'semapps:dereferenceItems': true + }, + contentType: MIME_TYPES.JSON, + webId: 'system' + }); + + await broker.call('activitypub.collection.add', { + collectionUri: collectionWithDereferenceUri, + item: items[0] + }); + + const collection = await broker.call('activitypub.collection.get', { + resourceUri: collectionWithDereferenceUri }); expect(collection).toMatchObject({ - id: collectionUri, + id: collectionWithDereferenceUri, type: 'Collection', - summary: 'My non-ordered collection', + summary: 'My non-ordered collection with dereferenceItems: true', items: { id: items[0], type: 'Note', @@ -144,14 +167,14 @@ describe('Handle collections', () => { }); }); - test('Detach item from collection', async () => { - await broker.call('activitypub.collection.detach', { + test('Remove item from collection', async () => { + await broker.call('activitypub.collection.remove', { collectionUri, item: items[0] }); const collection = await broker.call('activitypub.collection.get', { - collectionUri + resourceUri: collectionUri }); expect(collection).toMatchObject({ @@ -162,30 +185,29 @@ describe('Handle collections', () => { }); }); - test('Handle order', async () => { - await broker.call('activitypub.collection.attach', { + test('Items are sorted in descending order (default)', async () => { + await broker.call('activitypub.collection.add', { collectionUri: orderedCollectionUri, item: items[4] }); - await broker.call('activitypub.collection.attach', { + await broker.call('activitypub.collection.add', { collectionUri: orderedCollectionUri, item: items[0] }); - await broker.call('activitypub.collection.attach', { + await broker.call('activitypub.collection.add', { collectionUri: orderedCollectionUri, item: items[2] }); - await broker.call('activitypub.collection.attach', { + await broker.call('activitypub.collection.add', { collectionUri: orderedCollectionUri, item: items[6] }); let collection = await broker.call('activitypub.collection.get', { - collectionUri: orderedCollectionUri, - sort: { predicate: 'as:published', order: 'DESC' } + resourceUri: orderedCollectionUri }); expect(collection).toMatchObject({ @@ -193,50 +215,90 @@ describe('Handle collections', () => { orderedItems: [items[6], items[4], items[2], items[0]], totalItems: 4 }); + }); - collection = await broker.call('activitypub.collection.get', { - collectionUri: orderedCollectionUri, - sort: { predicate: 'as:published', order: 'ASC' } + test('Items are sorted in ascending order', async () => { + const ascOrderedCollectionUri = await broker.call('activitypub.collection.post', { + resource: { + type: ['Collection', 'OrderedCollection'], + summary: 'My asc-ordered collection', + 'semapps:sortPredicate': 'as:published', + 'semapps:sortOrder': 'semapps:AscOrder' + }, + contentType: MIME_TYPES.JSON, + webId: 'system' + }); + + await broker.call('activitypub.collection.add', { + collectionUri: ascOrderedCollectionUri, + item: items[4] + }); + + await broker.call('activitypub.collection.add', { + collectionUri: ascOrderedCollectionUri, + item: items[0] + }); + + await broker.call('activitypub.collection.add', { + collectionUri: ascOrderedCollectionUri, + item: items[2] + }); + + await broker.call('activitypub.collection.add', { + collectionUri: ascOrderedCollectionUri, + item: items[6] + }); + + const collection = await broker.call('activitypub.collection.get', { + resourceUri: ascOrderedCollectionUri }); expect(collection).toMatchObject({ - id: orderedCollectionUri, + id: ascOrderedCollectionUri, orderedItems: [items[0], items[2], items[4], items[6]], totalItems: 4 }); }); - test('Handle pagination', async () => { + test('Paginated collection', async () => { + const paginatedCollectionUri = await broker.call('activitypub.collection.post', { + resource: { + type: 'Collection', + summary: 'My paginated collection', + 'semapps:itemsPerPage': 4 + }, + contentType: MIME_TYPES.JSON, + webId: 'system' + }); + for (let i = 0; i < 10; i++) { - await broker.call('activitypub.collection.attach', { - collectionUri, + await broker.call('activitypub.collection.add', { + collectionUri: paginatedCollectionUri, item: items[i] }); } let collection = await broker.call('activitypub.collection.get', { - collectionUri, - itemsPerPage: 4 + resourceUri: paginatedCollectionUri }); expect(collection).toMatchObject({ - id: collectionUri, - first: `${collectionUri}?page=1`, - last: `${collectionUri}?page=3`, + id: paginatedCollectionUri, + first: `${paginatedCollectionUri}?page=1`, + last: `${paginatedCollectionUri}?page=3`, totalItems: 10 }); collection = await broker.call('activitypub.collection.get', { - collectionUri, - itemsPerPage: 4, + resourceUri: paginatedCollectionUri, page: 1 }); expect(collection).toMatchObject({ - id: `${collectionUri}?page=1`, + id: `${paginatedCollectionUri}?page=1`, type: 'CollectionPage', - partOf: collectionUri, - next: `${collectionUri}?page=2`, + partOf: paginatedCollectionUri, + next: `${paginatedCollectionUri}?page=2`, totalItems: 10 }); expect(collection.items).toHaveLength(4); diff --git a/src/middleware/tests/activitypub/initialize.js b/src/middleware/tests/activitypub/initialize.js index 52b188711..7f5db3338 100644 --- a/src/middleware/tests/activitypub/initialize.js +++ b/src/middleware/tests/activitypub/initialize.js @@ -52,7 +52,8 @@ const initialize = async (port, mainDataset, accountsDataset) => { settings: { baseUrl, jwtPath: path.resolve(__dirname, './jwt'), - accountsDataset + accountsDataset, + mail: false } }); @@ -92,6 +93,15 @@ const initialize = async (port, mainDataset, accountsDataset) => { } } }); + await broker.call('webacl.resource.addRights', { + webId: 'system', + resourceUri: urlJoin(baseUrl, 'as/collection'), + additionalRights: { + anon: { + write: true + } + } + }); return broker; }; From b70848b364ea046f0ae1cdc2555a219db989f91c Mon Sep 17 00:00:00 2001 From: srosset81 Date: Wed, 3 Apr 2024 10:16:48 +0200 Subject: [PATCH 03/20] Collection API tests --- .../packages/activitypub/package.json | 1 + .../activitypub/subservices/collection.js | 66 ++++++- src/middleware/packages/activitypub/utils.js | 18 +- src/middleware/tests/.eslintrc.json | 2 +- .../tests/activitypub/collection-api.test.js | 178 ++++++++++++++++++ .../tests/activitypub/collection.test.js | 2 +- src/middleware/yarn.lock | 158 ++++++++++++++-- 7 files changed, 394 insertions(+), 31 deletions(-) create mode 100644 src/middleware/tests/activitypub/collection-api.test.js diff --git a/src/middleware/packages/activitypub/package.json b/src/middleware/packages/activitypub/package.json index 690e420b1..9d681f213 100644 --- a/src/middleware/packages/activitypub/package.json +++ b/src/middleware/packages/activitypub/package.json @@ -18,6 +18,7 @@ "moleculer-db": "^0.8.16", "moleculer-web": "^0.10.0-beta1", "node-fetch": "^2.6.6", + "sparqljs": "^3.5.2", "url-join": "^4.0.1" }, "publishConfig": { diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/collection.js b/src/middleware/packages/activitypub/services/activitypub/subservices/collection.js index a5b069329..a2ce12733 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/collection.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/collection.js @@ -2,6 +2,10 @@ const { MoleculerError } = require('moleculer').Errors; const { ControlledContainerMixin, arrayOf } = require('@semapps/ldp'); const { MIME_TYPES } = require('@semapps/mime-types'); const { Errors: E } = require('moleculer-web'); +const SparqlParser = require('sparqljs').Parser; +const { getValueFromDataType } = require('../../../utils'); + +const parser = new SparqlParser(); const CollectionService = { name: 'activitypub.collection', @@ -19,7 +23,8 @@ const CollectionService = { case 'system': return { anon: { - read: true + read: true, + write: true } }; @@ -41,8 +46,54 @@ const CollectionService = { put() { throw new E.ForbiddenError(); }, - patch() { - // Handle PATCH + async patch(ctx) { + const { resourceUri: collectionUri, sparqlUpdate } = ctx.params; + const webId = ctx.params.webId || ctx.meta.webId || 'anon'; + + const collectionExist = await ctx.call('activitypub.collection.exist', { resourceUri: collectionUri, webId }); + if (!collectionExist) { + throw new MoleculerError( + `Cannot update content of non-existing collection ${collectionUri}`, + 400, + 'BAD_REQUEST' + ); + } + + try { + const parsedQuery = parser.parse(sparqlUpdate); + + if (parsedQuery.type !== 'update') + throw new MoleculerError('Invalid SPARQL. Must be an Update', 400, 'BAD_REQUEST'); + + const updates = { insert: [], delete: [] }; + parsedQuery.updates.forEach(p => updates[p.updateType].push(p[p.updateType][0])); + + for (const inserts of updates.insert) { + for (const triple of inserts.triples) { + if ( + triple.subject.value === collectionUri && + triple.predicate.value === 'https://www.w3.org/ns/activitystreams#items' + ) { + const itemUri = triple.object.value; + await ctx.call('activitypub.collection.add', { collectionUri, itemUri }); + } + } + } + + for (const deletes of updates.delete) { + for (const triple of deletes.triples) { + if ( + triple.subject.value === collectionUri && + triple.predicate.value === 'https://www.w3.org/ns/activitystreams#items' + ) { + const itemUri = triple.object.value; + await ctx.call('activitypub.collection.remove', { collectionUri, itemUri }); + } + } + } + } catch (e) { + throw new MoleculerError(`Invalid sparql-update content`, 400, 'BAD_REQUEST'); + } }, async post(ctx) { if (!ctx.params.containerUri) { @@ -77,9 +128,9 @@ const CollectionService = { const res = await ctx.call('triplestore.query', { query: ` PREFIX as: - SELECT ( Count(?follower) as ?count ) + SELECT ( Count(?items) as ?count ) WHERE { - <${collectionUri}> as:items ?follower . + <${collectionUri}> as:items ?items . } `, accept: MIME_TYPES.JSON, @@ -194,10 +245,7 @@ const CollectionService = { } const options = Object.fromEntries( - Object.entries(results[0]).map(([key, val]) => [ - key, - val.datatype?.value === 'http://www.w3.org/2001/XMLSchema#boolean' ? val.value === 'true' : val.value - ]) + Object.entries(results[0]).map(([key, result]) => [key, getValueFromDataType(result)]) ); const collectionOptions = { diff --git a/src/middleware/packages/activitypub/utils.js b/src/middleware/packages/activitypub/utils.js index 07f98544c..099b934d8 100644 --- a/src/middleware/packages/activitypub/utils.js +++ b/src/middleware/packages/activitypub/utils.js @@ -93,11 +93,24 @@ const waitForResource = async (delayMs, fieldNames, maxTries, callback) => { } await delay(delayMs); } - throw new Error('Waiting for resource failed. No results after ' + maxTries + ' tries'); + throw new Error(`Waiting for resource failed. No results after ${maxTries} tries`); }; const objectDepth = o => (Object(o) === o ? 1 + Math.max(-1, ...Object.values(o).map(objectDepth)) : 0); +const getValueFromDataType = result => { + switch (result.datatype?.value) { + case 'http://www.w3.org/2001/XMLSchema#boolean': + return result.value === 'true'; + + case 'http://www.w3.org/2001/XMLSchema#integer': + return parseInt(result.value, 10); + + default: + return result.value; + } +}; + module.exports = { objectCurrentToId, objectIdToCurrent, @@ -108,5 +121,6 @@ module.exports = { delay, arrayOf, waitForResource, - objectDepth + objectDepth, + getValueFromDataType }; diff --git a/src/middleware/tests/.eslintrc.json b/src/middleware/tests/.eslintrc.json index 930157333..d1e0b023e 100644 --- a/src/middleware/tests/.eslintrc.json +++ b/src/middleware/tests/.eslintrc.json @@ -1,4 +1,4 @@ { "extends": "../.eslintrc.json", - "rules": { "node/no-extraneous-require": "off" } + "rules": { "node/no-extraneous-require": "off", "no-plusplus": "off", "global-require": "off" } } diff --git a/src/middleware/tests/activitypub/collection-api.test.js b/src/middleware/tests/activitypub/collection-api.test.js new file mode 100644 index 000000000..926a08161 --- /dev/null +++ b/src/middleware/tests/activitypub/collection-api.test.js @@ -0,0 +1,178 @@ +const urlJoin = require('url-join'); +const fetch = require('node-fetch'); +const { MIME_TYPES } = require('@semapps/mime-types'); +const { fetchServer } = require('../utils'); +const initialize = require('./initialize'); +const CONFIG = require('../config'); + +jest.setTimeout(50000); + +let broker; + +beforeAll(async () => { + broker = await initialize(3000, 'testData', 'settings'); +}); +afterAll(async () => { + if (broker) await broker.stop(); +}); + +describe('Collections API', () => { + const items = []; + const collectionsContainersUri = urlJoin(CONFIG.HOME_URL, 'as/collection'); + let collectionUri; + let localContext; + + test('Create ressources', async () => { + for (let i = 0; i < 10; i++) { + items.push( + await broker.call('ldp.container.post', { + containerUri: urlJoin(CONFIG.HOME_URL, 'as/object'), + resource: { + '@context': 'https://www.w3.org/ns/activitystreams', + '@type': 'Note', + name: `Note #${i}`, + content: `Contenu de ma note #${i}`, + published: `2021-01-0${i}T00:00:00.000Z` + }, + contentType: MIME_TYPES.JSON + }) + ); + } + expect(items).toHaveLength(10); + }); + + test('Create collection', async () => { + localContext = await broker.call('jsonld.context.get'); + + const { headers } = await fetchServer(collectionsContainersUri, { + method: 'POST', + body: { + '@context': localContext, + type: 'Collection', + summary: 'My non-ordered collection' + } + }); + + collectionUri = headers.get('Location'); + + expect(collectionUri).not.toBeNull(); + + await expect(fetchServer(collectionUri)).resolves.toMatchObject({ + json: { + id: collectionUri, + type: 'Collection', + summary: 'My non-ordered collection', + 'semapps:dereferenceItems': false, + totalItems: 0 + } + }); + }); + + test('Add item to collection', async () => { + await fetchServer(collectionUri, { + method: 'PATCH', + headers: new fetch.Headers({ + 'Content-Type': 'application/sparql-update' + }), + body: ` + PREFIX as: + INSERT DATA { <${collectionUri}> as:items <${items[0]}> . }; + ` + }); + + await expect(fetchServer(collectionUri)).resolves.toMatchObject({ + json: { + id: collectionUri, + type: 'Collection', + summary: 'My non-ordered collection', + 'semapps:dereferenceItems': false, + items: items[0], + totalItems: 1 + } + }); + }); + + test('Remove item from collection', async () => { + await fetchServer(collectionUri, { + method: 'PATCH', + headers: new fetch.Headers({ + 'Content-Type': 'application/sparql-update' + }), + body: ` + PREFIX as: + DELETE DATA { <${collectionUri}> as:items <${items[0]}> . }; + ` + }); + + await expect(fetchServer(collectionUri)).resolves.toMatchObject({ + json: { + id: collectionUri, + type: 'Collection', + summary: 'My non-ordered collection', + 'semapps:dereferenceItems': false, + totalItems: 0 + } + }); + }); + + test('Paginated collection', async () => { + const { headers } = await fetchServer(collectionsContainersUri, { + method: 'POST', + body: { + '@context': localContext, + type: 'Collection', + summary: 'My paginated collection', + 'semapps:itemsPerPage': 4 + } + }); + + const paginatedCollectionUri = headers.get('Location'); + + // Add all items to the collection + await fetchServer(paginatedCollectionUri, { + method: 'PATCH', + headers: new fetch.Headers({ + 'Content-Type': 'application/sparql-update' + }), + body: ` + PREFIX as: + INSERT DATA { <${paginatedCollectionUri}> as:items ${items.map(item => `<${item}>`).join(', ')} . }; + ` + }); + + await expect(fetchServer(paginatedCollectionUri)).resolves.toMatchObject({ + json: { + id: paginatedCollectionUri, + type: 'Collection', + summary: 'My paginated collection', + 'semapps:dereferenceItems': false, + 'semapps:itemsPerPage': 4, + first: `${paginatedCollectionUri}?page=1`, + last: `${paginatedCollectionUri}?page=3`, + totalItems: 10 + } + }); + + await expect(fetchServer(`${paginatedCollectionUri}?page=1`)).resolves.toMatchObject({ + json: { + id: `${paginatedCollectionUri}?page=1`, + type: 'CollectionPage', + partOf: paginatedCollectionUri, + next: `${paginatedCollectionUri}?page=2`, + items: expect.arrayContaining([items[0], items[1], items[2], items[3]]), + totalItems: 10 + } + }); + + await expect(fetchServer(`${paginatedCollectionUri}?page=3`)).resolves.toMatchObject({ + json: { + id: `${paginatedCollectionUri}?page=3`, + type: 'CollectionPage', + partOf: paginatedCollectionUri, + prev: `${paginatedCollectionUri}?page=2`, + items: expect.arrayContaining([items[8], items[9]]), + totalItems: 10 + } + }); + }); +}); diff --git a/src/middleware/tests/activitypub/collection.test.js b/src/middleware/tests/activitypub/collection.test.js index c8577cfe7..3ea3a676b 100644 --- a/src/middleware/tests/activitypub/collection.test.js +++ b/src/middleware/tests/activitypub/collection.test.js @@ -14,7 +14,7 @@ afterAll(async () => { if (broker) await broker.stop(); }); -describe('Handle collections', () => { +describe('Collections', () => { const items = []; let collectionUri; let orderedCollectionUri; diff --git a/src/middleware/yarn.lock b/src/middleware/yarn.lock index 6bd27b05d..88871d94d 100644 --- a/src/middleware/yarn.lock +++ b/src/middleware/yarn.lock @@ -2516,6 +2516,17 @@ call-bind@^1.0.0, call-bind@^1.0.2, call-bind@^1.0.4, call-bind@^1.0.5: get-intrinsic "^1.2.1" set-function-length "^1.1.1" +call-bind@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" + integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + set-function-length "^1.2.1" + callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -2844,7 +2855,7 @@ compare-func@^2.0.0: array-ify "^1.0.0" dot-prop "^5.1.0" -component-emitter@^1.2.1: +component-emitter@^1.2.0, component-emitter@^1.2.1: version "1.3.1" resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.1.tgz#ef1d5796f7d93f135ee6fb684340b26403c97d17" integrity sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ== @@ -2999,6 +3010,11 @@ cookie@0.5.0: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== +cookiejar@^2.1.0: + version "2.1.4" + resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.4.tgz#ee669c1fea2cf42dc31585469d193fef0d65771b" + integrity sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw== + copy-descriptor@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" @@ -3198,7 +3214,7 @@ debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, d dependencies: ms "2.1.2" -debug@^3.2.7: +debug@^3.1.0, debug@^3.2.7: version "3.2.7" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== @@ -3299,6 +3315,15 @@ define-data-property@^1.0.1, define-data-property@^1.1.1: gopd "^1.0.1" has-property-descriptors "^1.0.0" +define-data-property@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" + integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + gopd "^1.0.1" + define-lazy-prop@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f" @@ -3781,6 +3806,18 @@ es-array-method-boxes-properly@^1.0.0: resolved "https://registry.yarnpkg.com/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz#873f3e84418de4ee19c5be752990b2e44718d09e" integrity sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA== +es-define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845" + integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ== + dependencies: + get-intrinsic "^1.2.4" + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + es-set-tostringtag@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.0.2.tgz#11f7cc9f63376930a5f20be4915834f4bc74f9c9" @@ -4304,7 +4341,7 @@ extend-shallow@^3.0.0, extend-shallow@^3.0.2: assign-symbols "^1.0.0" is-extendable "^1.0.1" -extend@~3.0.2: +extend@^3.0.0, extend@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== @@ -4527,6 +4564,15 @@ forever-agent@~0.6.1: resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" integrity sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw== +form-data@^2.3.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.5.1.tgz#f2cbec57b5e59e23716e128fe44d4e5dd23895f4" + integrity sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.6" + mime-types "^2.1.12" + form-data@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" @@ -4545,6 +4591,11 @@ form-data@~2.3.2: combined-stream "^1.0.6" mime-types "^2.1.12" +formidable@^1.2.0: + version "1.2.6" + resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.2.6.tgz#d2a51d60162bbc9b4a055d8457a7c75315d1a168" + integrity sha512-KcpbcpuLNOwrEjnbpMC0gS+X8ciDoZE1kkqzat4a8vrprf+s9pKNQ/QIwWfbfs4ltgmFl3MD177SNTkve3BwGQ== + forwarded@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" @@ -4691,6 +4742,17 @@ get-intrinsic@^1.0.2, get-intrinsic@^1.1.1, get-intrinsic@^1.1.3, get-intrinsic@ has-symbols "^1.0.3" hasown "^2.0.0" +get-intrinsic@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd" + integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + has-proto "^1.0.1" + has-symbols "^1.0.3" + hasown "^2.0.0" + get-pkg-repo@^4.2.1: version "4.2.1" resolved "https://registry.yarnpkg.com/get-pkg-repo/-/get-pkg-repo-4.2.1.tgz#75973e1c8050c73f48190c52047c4cee3acbf385" @@ -5010,6 +5072,13 @@ has-property-descriptors@^1.0.0: dependencies: get-intrinsic "^1.2.2" +has-property-descriptors@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" + integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== + dependencies: + es-define-property "^1.0.0" + has-proto@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.1.tgz#1885c1305538958aff469fef37937c22795408e0" @@ -7228,7 +7297,7 @@ merge2@^1.3.0, merge2@^1.4.1: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== -methods@~1.1.2: +methods@^1.1.1, methods@^1.1.2, methods@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== @@ -7282,7 +7351,7 @@ mime-types@^2.1.12, mime-types@^2.1.35, mime-types@~2.1.19, mime-types@~2.1.24, dependencies: mime-db "1.52.0" -mime@1.6.0: +mime@1.6.0, mime@^1.4.1: version "1.6.0" resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== @@ -8971,6 +9040,13 @@ qs@^6.11.0: dependencies: side-channel "^1.0.4" +qs@^6.5.1: + version "6.12.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.12.0.tgz#edd40c3b823995946a8a0b1f208669c7a200db77" + integrity sha512-trVZiI6RMOkO476zLGaBIzszOdFPnCCXHPG9kn0yuS1uz6xdVxPfZdB3vUig9pxPFDM9BRAgz/YUIVQ1/vuiUg== + dependencies: + side-channel "^1.0.6" + qs@~6.5.2: version "6.5.3" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.3.tgz#3aeeffc91967ef6e35c0e488ef46fb296ab76aad" @@ -9270,6 +9346,19 @@ readable-stream@1.1: isarray "0.0.1" string_decoder "~0.10.x" +readable-stream@^2.3.5, readable-stream@~2.3.6: + version "2.3.8" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" + integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + readable-stream@^3.0.0, readable-stream@^3.0.2, readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: version "3.6.2" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" @@ -9300,19 +9389,6 @@ readable-stream@~1.0.2: isarray "0.0.1" string_decoder "~0.10.x" -readable-stream@~2.3.6: - version "2.3.8" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" - integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.3" - isarray "~1.0.0" - process-nextick-args "~2.0.0" - safe-buffer "~5.1.1" - string_decoder "~1.1.1" - util-deprecate "~1.0.1" - readable-web-to-node-stream@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz#5d52bb5df7b54861fd48d015e93a2cb87b3ee0bb" @@ -9742,6 +9818,18 @@ set-function-length@^1.1.1: gopd "^1.0.1" has-property-descriptors "^1.0.0" +set-function-length@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" + integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + set-function-name@^2.0.0, set-function-name@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.1.tgz#12ce38b7954310b9f61faa12701620a0c882793a" @@ -9830,6 +9918,16 @@ side-channel@^1.0.4: get-intrinsic "^1.0.2" object-inspect "^1.9.0" +side-channel@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" + integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== + dependencies: + call-bind "^1.0.7" + es-errors "^1.3.0" + get-intrinsic "^1.2.4" + object-inspect "^1.13.1" + signal-exit@3.0.7, signal-exit@^3.0.0, signal-exit@^3.0.2, signal-exit@^3.0.3, signal-exit@^3.0.7: version "3.0.7" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" @@ -10383,6 +10481,30 @@ strong-log-transformer@2.1.0, strong-log-transformer@^2.1.0: minimist "^1.2.0" through "^2.3.4" +superagent@^3.8.3: + version "3.8.3" + resolved "https://registry.yarnpkg.com/superagent/-/superagent-3.8.3.tgz#460ea0dbdb7d5b11bc4f78deba565f86a178e128" + integrity sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA== + dependencies: + component-emitter "^1.2.0" + cookiejar "^2.1.0" + debug "^3.1.0" + extend "^3.0.0" + form-data "^2.3.1" + formidable "^1.2.0" + methods "^1.1.1" + mime "^1.4.1" + qs "^6.5.1" + readable-stream "^2.3.5" + +supertest@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/supertest/-/supertest-4.0.2.tgz#c2234dbdd6dc79b6f15b99c8d6577b90e4ce3f36" + integrity sha512-1BAbvrOZsGA3YTCWqbmh14L0YEq0EGICX/nBnfkfVJn7SrxQV1I3pMYjSzG9y/7ZU2V9dWqyqk2POwxlb09duQ== + dependencies: + methods "^1.1.2" + superagent "^3.8.3" + supports-color@^5.3.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" From 63932e9fa18a1fdaa5a9720b5745923cc47ff7e4 Mon Sep 17 00:00:00 2001 From: srosset81 Date: Wed, 3 Apr 2024 11:13:51 +0200 Subject: [PATCH 04/20] All tests working --- .../activitypub/subservices/registry.js | 34 ++++++++++++------- .../ldp/services/container/actions/post.js | 5 ++- .../tests/activitypub/follow.test.js | 4 +-- .../tests/activitypub/inbox.test.js | 12 +++---- .../tests/activitypub/outbox.test.js | 12 +++---- .../tests/interop/mirror-protected.test.js | 4 +-- 6 files changed, 41 insertions(+), 30 deletions(-) diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/registry.js b/src/middleware/packages/activitypub/services/activitypub/subservices/registry.js index 4e8599ee8..ff44a6804 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/registry.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/registry.js @@ -1,7 +1,7 @@ const urlJoin = require('url-join'); const { quad, namedNode } = require('@rdfjs/data-model'); const { MIME_TYPES } = require('@semapps/mime-types'); -const { defaultToArray, getSlugFromUri } = require('../../../utils'); +const { defaultToArray } = require('../../../utils'); const { ACTOR_TYPES, FULL_ACTOR_TYPES, AS_PREFIX } = require('../../../constants'); const RegistryService = { @@ -88,19 +88,27 @@ const RegistryService = { this.collectionsInCreation.push(collectionUri); // Create the collection - await ctx.call('activitypub.collection.post', { - resource: { - type: ordered ? ['Collection', 'OrderedCollection'] : 'Collection', - summary, - 'semapps:dereferenceItems': dereferenceItems, - 'semapps:itemsPerPage': itemsPerPage, - 'semapps:sortPredicate': sortPredicate, - 'semapps:sortOrder': sortOrder + await ctx.call( + 'activitypub.collection.post', + { + resource: { + type: ordered ? ['Collection', 'OrderedCollection'] : 'Collection', + summary, + 'semapps:dereferenceItems': dereferenceItems, + 'semapps:itemsPerPage': itemsPerPage, + 'semapps:sortPredicate': sortPredicate, + 'semapps:sortOrder': sortOrder + }, + contentType: MIME_TYPES.JSON, + webId: 'system' }, - contentType: MIME_TYPES.JSON, - slug: path ? getSlugFromUri(objectUri) + path : undefined, - webId: 'system' - }); + { + meta: { + // Bypass the automatic URI generation + forcedResourceUri: path ? urlJoin(objectUri, path) : undefined + } + } + ); // Attach it to the object await ctx.call( diff --git a/src/middleware/packages/ldp/services/container/actions/post.js b/src/middleware/packages/ldp/services/container/actions/post.js index f1160aac1..809abfc71 100644 --- a/src/middleware/packages/ldp/services/container/actions/post.js +++ b/src/middleware/packages/ldp/services/container/actions/post.js @@ -55,7 +55,10 @@ module.exports = { } } - const resourceUri = await ctx.call('ldp.resource.generateId', { containerUri, slug, isContainer }); + // The forcedResourceUri meta allows Moleculer service to bypass URI generation + // It is used by ActivityStreams collections to provide URIs like {actorUri}/inbox + const resourceUri = + ctx.meta.forcedResourceUri || (await ctx.call('ldp.resource.generateId', { containerUri, slug, isContainer })); const containerExist = await ctx.call('ldp.container.exist', { containerUri, webId }); if (!containerExist) { diff --git a/src/middleware/tests/activitypub/follow.test.js b/src/middleware/tests/activitypub/follow.test.js index 4cca4c2d3..60d4f222e 100644 --- a/src/middleware/tests/activitypub/follow.test.js +++ b/src/middleware/tests/activitypub/follow.test.js @@ -64,7 +64,7 @@ describe.each(['single-server', 'multi-server'])('In mode %s, posting to followe await waitForExpect(async () => { const inbox = await bob.call('activitypub.collection.get', { - collectionUri: bob.inbox, + resourceUri: bob.inbox, page: 1, webId: bob.id }); @@ -99,7 +99,7 @@ describe.each(['single-server', 'multi-server'])('In mode %s, posting to followe await waitForExpect(async () => { const inbox = await bob.call('activitypub.collection.get', { - collectionUri: bob.inbox, + resourceUri: bob.inbox, page: 1, webId: bob.id }); diff --git a/src/middleware/tests/activitypub/inbox.test.js b/src/middleware/tests/activitypub/inbox.test.js index 2513b6588..ae683b4ce 100644 --- a/src/middleware/tests/activitypub/inbox.test.js +++ b/src/middleware/tests/activitypub/inbox.test.js @@ -62,7 +62,7 @@ describe('Permissions are correctly set on inbox', () => { // Get inbox as recipient await waitForExpect(async () => { const outbox = await broker.call('activitypub.collection.get', { - collectionUri: simon.inbox, + resourceUri: simon.inbox, page: 1, webId: simon.id }); @@ -80,7 +80,7 @@ describe('Permissions are correctly set on inbox', () => { // Get inbox as emitter await waitForExpect(async () => { const outbox = await broker.call('activitypub.collection.get', { - collectionUri: simon.inbox, + resourceUri: simon.inbox, page: 1, webId: sebastien.id }); @@ -98,7 +98,7 @@ describe('Permissions are correctly set on inbox', () => { // Get inbox as anonymous await waitForExpect(async () => { const outbox = await broker.call('activitypub.collection.get', { - collectionUri: sebastien.inbox, + resourceUri: sebastien.inbox, page: 1, webId: 'anon' }); @@ -118,7 +118,7 @@ describe('Permissions are correctly set on inbox', () => { // Get inbox as recipient await waitForExpect(async () => { const outbox = await broker.call('activitypub.collection.get', { - collectionUri: simon.inbox, + resourceUri: simon.inbox, page: 1, webId: simon.id }); @@ -136,7 +136,7 @@ describe('Permissions are correctly set on inbox', () => { // Get inbox as emitter await waitForExpect(async () => { const outbox = await broker.call('activitypub.collection.get', { - collectionUri: simon.inbox, + resourceUri: simon.inbox, page: 1, webId: sebastien.id }); @@ -154,7 +154,7 @@ describe('Permissions are correctly set on inbox', () => { // Get inbox as anonymous await waitForExpect(async () => { const outbox = await broker.call('activitypub.collection.get', { - collectionUri: simon.inbox, + resourceUri: simon.inbox, page: 1, webId: 'anon' }); diff --git a/src/middleware/tests/activitypub/outbox.test.js b/src/middleware/tests/activitypub/outbox.test.js index ac8ea2d30..c4a113647 100644 --- a/src/middleware/tests/activitypub/outbox.test.js +++ b/src/middleware/tests/activitypub/outbox.test.js @@ -61,7 +61,7 @@ describe('Permissions are correctly set on outbox', () => { // Get outbox as self await waitForExpect(async () => { const outbox = await broker.call('activitypub.collection.get', { - collectionUri: sebastien.outbox, + resourceUri: sebastien.outbox, page: 1, webId: sebastien.id }); @@ -79,7 +79,7 @@ describe('Permissions are correctly set on outbox', () => { // Get outbox as anonymous await waitForExpect(async () => { const outbox = await broker.call('activitypub.collection.get', { - collectionUri: sebastien.outbox, + resourceUri: sebastien.outbox, page: 1, webId: 'anon' }); @@ -99,7 +99,7 @@ describe('Permissions are correctly set on outbox', () => { // Get outbox as friend await waitForExpect(async () => { const outbox = await broker.call('activitypub.collection.get', { - collectionUri: sebastien.outbox, + resourceUri: sebastien.outbox, page: 1, webId: simon.id }); @@ -117,7 +117,7 @@ describe('Permissions are correctly set on outbox', () => { // Get outbox as anonymous await waitForExpect(async () => { const outbox = await broker.call('activitypub.collection.get', { - collectionUri: sebastien.outbox, + resourceUri: sebastien.outbox, page: 1, webId: 'anon' }); @@ -137,7 +137,7 @@ describe('Permissions are correctly set on outbox', () => { // Get outbox as friend await waitForExpect(async () => { const outbox = await broker.call('activitypub.collection.get', { - collectionUri: sebastien.outbox, + resourceUri: sebastien.outbox, page: 1, webId: simon.id }); @@ -155,7 +155,7 @@ describe('Permissions are correctly set on outbox', () => { // Get outbox as anonymous await waitForExpect(async () => { const outbox = await broker.call('activitypub.collection.get', { - collectionUri: sebastien.outbox, + resourceUri: sebastien.outbox, page: 1, webId: 'anon' }); diff --git a/src/middleware/tests/interop/mirror-protected.test.js b/src/middleware/tests/interop/mirror-protected.test.js index 7b1cef320..5f501e1ba 100644 --- a/src/middleware/tests/interop/mirror-protected.test.js +++ b/src/middleware/tests/interop/mirror-protected.test.js @@ -78,7 +78,7 @@ describe('Resource on server1 is shared with user on server2', () => { await waitForExpect(async () => { const inbox = await server1.call('activitypub.collection.get', { - collectionUri: `${relay1}/outbox`, + resourceUri: `${relay1}/outbox`, page: 1, webId: relay1 }); @@ -107,7 +107,7 @@ describe('Resource on server1 is shared with user on server2', () => { await waitForExpect(async () => { const inbox = await server1.call('activitypub.collection.get', { - collectionUri: `${relay1}/outbox`, + resourceUri: `${relay1}/outbox`, page: 1, webId: relay1 }); From b0d1b9e0c6eb95002c0ee00b42a9d6ec3ebb3e12 Mon Sep 17 00:00:00 2001 From: srosset81 Date: Wed, 3 Apr 2024 18:22:16 +0200 Subject: [PATCH 05/20] Working tests after adding boes routes --- .../packages/activitypub/containers.js | 6 +- .../activitypub/services/activitypub/index.js | 8 ++ .../activitypub/subservices/activity.js | 4 +- .../services/activitypub/subservices/api.js | 107 ++++++++++++++++++ .../activitypub/subservices/collection.js | 5 +- .../services/activitypub/subservices/inbox.js | 17 ++- .../activitypub/subservices/outbox.js | 24 ++-- .../packages/jsonld/services/parser/index.js | 14 ++- .../packages/ldp/routes/getCatchAllRoute.js | 4 +- .../packages/ldp/services/api/actions/post.js | 81 +++++-------- src/middleware/packages/middlewares/index.js | 17 +-- .../packages/pod/routes/getPodsRoute.js | 4 +- .../tests/activitypub/follow.test.js | 2 +- .../tests/activitypub/initialize.js | 2 +- 14 files changed, 194 insertions(+), 101 deletions(-) create mode 100644 src/middleware/packages/activitypub/services/activitypub/subservices/api.js diff --git a/src/middleware/packages/activitypub/containers.js b/src/middleware/packages/activitypub/containers.js index b71afceba..8c14c6a7b 100644 --- a/src/middleware/packages/activitypub/containers.js +++ b/src/middleware/packages/activitypub/containers.js @@ -1,12 +1,12 @@ -const { ACTOR_TYPES, OBJECT_TYPES } = require('./constants'); +const { FULL_ACTOR_TYPES, FULL_OBJECT_TYPES } = require('./constants'); module.exports = [ { path: '/as/actor', - acceptedTypes: Object.values(ACTOR_TYPES) + acceptedTypes: Object.values(FULL_ACTOR_TYPES) }, { path: '/as/object', - acceptedTypes: Object.values(OBJECT_TYPES) + acceptedTypes: Object.values(FULL_OBJECT_TYPES) } ]; diff --git a/src/middleware/packages/activitypub/services/activitypub/index.js b/src/middleware/packages/activitypub/services/activitypub/index.js index 343de1938..51824d627 100644 --- a/src/middleware/packages/activitypub/services/activitypub/index.js +++ b/src/middleware/packages/activitypub/services/activitypub/index.js @@ -3,6 +3,7 @@ const { as, sec } = require('@semapps/ontologies'); const ActorService = require('./subservices/actor'); const ActivitiesWatcherService = require('./subservices/activities-watcher'); const ActivityService = require('./subservices/activity'); +const ApiService = require('./subservices/api'); const CollectionService = require('./subservices/collection'); const FollowService = require('./subservices/follow'); const InboxService = require('./subservices/inbox'); @@ -60,6 +61,13 @@ const ActivityPubService = { } }); + this.broker.createService(ApiService, { + settings: { + baseUri, + podProvider + } + }); + this.broker.createService(ObjectService, { settings: { baseUri, diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/activity.js b/src/middleware/packages/activitypub/services/activitypub/subservices/activity.js index 13859f9a2..d7d28e804 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/activity.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/activity.js @@ -2,7 +2,7 @@ const { ControlledContainerMixin } = require('@semapps/ldp'); const { MIME_TYPES } = require('@semapps/mime-types'); const { Errors: E } = require('moleculer-web'); const { objectCurrentToId, objectIdToCurrent, arrayOf } = require('../../../utils'); -const { PUBLIC_URI, ACTIVITY_TYPES } = require('../../../constants'); +const { PUBLIC_URI, FULL_ACTIVITY_TYPES } = require('../../../constants'); const ActivityService = { name: 'activitypub.activity', @@ -10,7 +10,7 @@ const ActivityService = { settings: { baseUri: null, path: '/as/activity', - acceptedTypes: Object.values(ACTIVITY_TYPES), + acceptedTypes: Object.values(FULL_ACTIVITY_TYPES), accept: MIME_TYPES.JSON, permissions: {}, newResourcesPermissions: {}, diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/api.js b/src/middleware/packages/activitypub/services/activitypub/subservices/api.js new file mode 100644 index 000000000..dcbe3551a --- /dev/null +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/api.js @@ -0,0 +1,107 @@ +const urlJoin = require('url-join'); +const { arrayOf } = require('@semapps/ldp'); +const { + parseUrl, + parseHeader, + parseSparql, + negotiateContentType, + negotiateAccept, + parseJson, + parseTurtle, + parseFile, + saveDatasetMeta +} = require('@semapps/middlewares'); +const { FULL_ACTOR_TYPES } = require('../../../constants'); + +const ApiService = { + name: 'activitypub.api', + settings: { + baseUri: null, + podProvider: false + }, + dependencies: ['api', 'ldp.registry'], + async started() { + if (this.settings.podProvider) { + await this.broker.call('api.addRoute', { route: this.getBoxesRoute('/:username([^/.][^/]+)') }); + } else { + // If some actor containers are already registered, add the corresponding API routes + const registeredContainers = await this.broker.call('ldp.registry.list'); + for (const container of Object.values(registeredContainers)) { + if (arrayOf(container.acceptedTypes).some(type => Object.values(FULL_ACTOR_TYPES).includes(type))) { + await this.broker.call('api.addRoute', { route: this.getBoxesRoute(container.fullPath) }); + } + } + } + }, + actions: { + async inbox(ctx) { + const { actorSlug, ...activity } = ctx.params; + const { requestUrl } = ctx.meta; + + await ctx.call('activitypub.inbox.post', { + collectionUri: urlJoin(this.settings.baseUri, requestUrl), + ...activity + }); + + ctx.meta.$statusCode = 202; + }, + async outbox(ctx) { + let { actorSlug, ...activity } = ctx.params; + const { requestUrl } = ctx.meta; + + activity = await ctx.call('activitypub.outbox.post', { + collectionUri: urlJoin(this.settings.baseUri, requestUrl), + ...activity + }); + + ctx.meta.$responseHeaders = { + Location: activity.id || activity['@id'], + 'Content-Length': 0 + }; + ctx.meta.$statusCode = 201; + } + }, + events: { + async 'ldp.registry.registered'(ctx) { + // TODO ensure that no events of this kind are sent before the service start, or routes may be missing + const { container } = ctx.params; + if ( + !this.settings.podProvider && + arrayOf(container.acceptedTypes).some(type => Object.values(FULL_ACTOR_TYPES).includes(type)) + ) { + await ctx.call('api.addRoute', { route: this.getBoxesRoute(container.fullPath) }); + } + } + }, + methods: { + getBoxesRoute(actorsPath) { + const middlewares = [ + parseUrl, + parseHeader, + negotiateContentType, + negotiateAccept, + parseSparql, + parseJson, + parseTurtle, + parseFile, + saveDatasetMeta + ]; + + return { + name: this.settings.podProvider ? 'boxes' : `boxes${actorsPath}`, + path: actorsPath, + // Disable the body parsers so that we can parse the body ourselves + // (Moleculer-web doesn't handle non-JSON bodies, so we must do it) + bodyParsers: false, + authorization: false, + authentication: true, + aliases: { + 'POST /:actorSlug/inbox': [...middlewares, 'activitypub.api.inbox'], + 'POST /:actorSlug/outbox': [...middlewares, 'activitypub.api.outbox'] + } + }; + } + } +}; + +module.exports = ApiService; diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/collection.js b/src/middleware/packages/activitypub/services/activitypub/subservices/collection.js index a2ce12733..cd9e35ce4 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/collection.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/collection.js @@ -14,7 +14,10 @@ const CollectionService = { podProvider: false, // ControlledContainerMixin settings path: '/as/collection', - acceptedTypes: ['Collection', 'OrderedCollection'], + acceptedTypes: [ + 'https://www.w3.org/ns/activitystreams#Collection', + 'https://www.w3.org/ns/activitystreams#OrderedCollection' + ], accept: MIME_TYPES.JSON, permissions: {}, newResourcesPermissions: webId => { diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/inbox.js b/src/middleware/packages/activitypub/services/activitypub/subservices/inbox.js index a370906dd..92b4e8e57 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/inbox.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/inbox.js @@ -1,5 +1,5 @@ const { MIME_TYPES } = require('@semapps/mime-types'); -const { MoleculerError } = require('moleculer').Errors; +const { Errors: E } = require('moleculer-web'); const { objectIdToCurrent, collectionPermissionsWithAnonRead } = require('../../../utils'); const ControlledCollectionMixin = require('../../../mixins/controlled-collection'); const { ACTOR_TYPES } = require('../../../constants'); @@ -24,11 +24,14 @@ const InboxService = { async post(ctx) { const { collectionUri, ...activity } = ctx.params; + if (!collectionUri || !collectionUri.startsWith('http')) { + throw new Error(`The collectionUri ${collectionUri} is not a valid URL`); + } + // Ensure the actor in the activity is the same as the posting actor // (When posting, the webId is the one of the poster) if (activity.actor !== ctx.meta.webId) { - ctx.meta.$statusMessage = 'Activity actor is not the same as the posting actor'; - ctx.meta.$statusCode = 401; + throw new E.UnAuthorizedError('INVALID_ACTOR', 'Activity actor is not the same as the posting actor'); } // We want the next operations to be done by the system @@ -39,8 +42,7 @@ const InboxService = { const collectionExists = await ctx.call('activitypub.collection.exist', { resourceUri: collectionUri }); if (!collectionExists) { - ctx.meta.$statusCode = 404; - return; + throw new E.NotFoundError(); } if (!ctx.meta.skipSignatureValidation) { @@ -59,8 +61,7 @@ const InboxService = { }); if (!validDigest || !validSignature) { - ctx.meta.$statusCode = 401; - return; + throw new E.UnAuthorizedError('INVALID_SIGNATURE'); } } @@ -107,8 +108,6 @@ const InboxService = { }, { meta: { webId: null, dataset: null } } ); - - ctx.meta.$statusCode = 202; }, async getByDates(ctx) { const { collectionUri, fromDate, toDate } = ctx.params; diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/outbox.js b/src/middleware/packages/activitypub/services/activitypub/subservices/outbox.js index 118b08149..6b2923684 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/outbox.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/outbox.js @@ -1,5 +1,5 @@ const fetch = require('node-fetch'); -const { MoleculerError } = require('moleculer').Errors; +const { Errors: E } = require('moleculer-web'); const { MIME_TYPES } = require('@semapps/mime-types'); const ControlledCollectionMixin = require('../../../mixins/controlled-collection'); const { collectionPermissionsWithAnonRead, getSlugFromUri, objectIdToCurrent } = require('../../../utils'); @@ -28,18 +28,19 @@ const OutboxService = { const collectionExists = await ctx.call('activitypub.collection.exist', { resourceUri: collectionUri }); if (!collectionExists) { - throw new MoleculerError(`Collection not found:${collectionUri}`, 404, 'NOT_FOUND'); + throw E.NotFoundError(); } const actorUri = await ctx.call('activitypub.collection.getOwner', { collectionUri, collectionKey: 'outbox' }); - if (!actorUri) throw new MoleculerError('The collection is not a valid ActivityPub outbox', 400); + if (!actorUri) { + throw new E.BadRequestError('INVALID_COLLECTION', 'The collection is not a valid ActivityPub outbox'); + } // Ensure logged user is posting to his own outbox if (ctx.meta.webId && ctx.meta.webId !== 'system' && actorUri !== ctx.meta.webId) { - throw new MoleculerError( - `Forbidden to post to the outbox ${collectionUri} (webId ${ctx.meta.webId})`, - 403, - 'FORBIDDEN' + throw new E.UnAuthorizedError( + 'UNAUTHORIZED', + `Forbidden to post to the outbox ${collectionUri} (webId ${ctx.meta.webId})` ); } @@ -125,15 +126,6 @@ const OutboxService = { } } - // TODO identify API calls so that we only set these headers if necessary - // (They can enter into conflict with an usage of ctx.meta.$location) - ctx.meta.$responseHeaders = { - Location: activityUri, - 'Content-Length': 0 - }; - - ctx.meta.$statusCode = 201; - return activity; } }, diff --git a/src/middleware/packages/jsonld/services/parser/index.js b/src/middleware/packages/jsonld/services/parser/index.js index 012589fc9..96cfc4dd7 100644 --- a/src/middleware/packages/jsonld/services/parser/index.js +++ b/src/middleware/packages/jsonld/services/parser/index.js @@ -3,6 +3,8 @@ const { JsonLdParser } = require('jsonld-streaming-parser'); const streamifyString = require('streamify-string'); const { arrayOf, isURL } = require('../../utils'); +const delay = t => new Promise(resolve => setTimeout(resolve, t)); + module.exports = { name: 'jsonld.parser', dependencies: ['jsonld.document-loader'], @@ -79,7 +81,17 @@ module.exports = { if (!context) context = await ctx.call('jsonld.context.get'); const result = await this.actions.expand({ input: { '@context': context, '@type': types } }, { parentCtx: ctx }); - return result?.[0]?.['@type']; + + const expandedTypes = result?.[0]?.['@type']; + + if (!arrayOf(expandedTypes).every(type => isURL(type))) { + throw new Error(` + Could not expand all types (${expandedTypes.join(', ')}). + Is an ontology missing or not registered yet on the local context ? + `); + } + + return expandedTypes; } } }; diff --git a/src/middleware/packages/ldp/routes/getCatchAllRoute.js b/src/middleware/packages/ldp/routes/getCatchAllRoute.js index 0d52925e1..32f021e27 100644 --- a/src/middleware/packages/ldp/routes/getCatchAllRoute.js +++ b/src/middleware/packages/ldp/routes/getCatchAllRoute.js @@ -1,5 +1,5 @@ const { - parseQueryString, + parseUrl, parseHeader, parseSparql, negotiateContentType, @@ -12,7 +12,7 @@ const { function getCatchAllRoute(podProvider) { const middlewares = [ - parseQueryString, + parseUrl, parseHeader, negotiateContentType, negotiateAccept, diff --git a/src/middleware/packages/ldp/services/api/actions/post.js b/src/middleware/packages/ldp/services/api/actions/post.js index e706aa6f1..fb72a1f9d 100644 --- a/src/middleware/packages/ldp/services/api/actions/post.js +++ b/src/middleware/packages/ldp/services/api/actions/post.js @@ -7,61 +7,40 @@ module.exports = async function post(ctx) { try { const { username, slugParts, ...resource } = ctx.params; - const uri = this.getUriFromSlugParts(slugParts, username); - const types = await ctx.call('ldp.resource.getTypes', { resourceUri: uri }); + const containerUri = this.getUriFromSlugParts(slugParts, username); - if (types.includes('http://www.w3.org/ns/ldp#Container')) { - /* - * LDP CONTAINER - */ - let resourceUri; - const { controlledActions } = await ctx.call('ldp.registry.getByUri', { containerUri: uri }); - if (ctx.meta.parser !== 'file') { - resourceUri = await ctx.call(controlledActions.post || 'ldp.container.post', { - containerUri: uri, - slug: ctx.meta.headers.slug, - resource, - contentType: ctx.meta.headers['content-type'] - }); - } else { - if (ctx.params.files.length > 1) { - throw new MoleculerError(`Multiple file upload not supported`, 400, 'BAD_REQUEST'); - } - - const extension = mime.extension(ctx.params.files[0].mimetype); - const slug = extension ? `${uuidv4()}.${extension}}` : uuidv4(); - - resourceUri = await ctx.call(controlledActions.post || 'ldp.container.post', { - containerUri: uri, - slug, - file: ctx.params.files[0], - contentType: MIME_TYPES.JSON - }); + let resourceUri; + const { controlledActions } = await ctx.call('ldp.registry.getByUri', { containerUri }); + if (ctx.meta.parser !== 'file') { + resourceUri = await ctx.call(controlledActions.post || 'ldp.container.post', { + containerUri, + slug: ctx.meta.headers.slug, + resource, + contentType: ctx.meta.headers['content-type'] + }); + } else { + if (ctx.params.files.length > 1) { + throw new MoleculerError(`Multiple file upload not supported`, 400, 'BAD_REQUEST'); } - ctx.meta.$responseHeaders = { - Location: resourceUri, - Link: '; rel="type"', - 'Content-Length': 0 - }; - // We need to set this also here (in addition to above) or we get a Moleculer warning - ctx.meta.$location = resourceUri; - ctx.meta.$statusCode = 201; - } else if (types.includes('https://www.w3.org/ns/activitystreams#Collection')) { - /* - * AS COLLECTION - */ - const { controlledActions } = await ctx.call('activitypub.registry.getByUri', { collectionUri: uri }); - if (controlledActions.post) { - await ctx.call(controlledActions.post, { - collectionUri: uri, - ...resource - }); - } else { - // The collection endpoint is not available for POSTing - ctx.meta.$statusCode = 404; - } + const extension = mime.extension(ctx.params.files[0].mimetype); + const slug = extension ? `${uuidv4()}.${extension}}` : uuidv4(); + + resourceUri = await ctx.call(controlledActions.post || 'ldp.container.post', { + containerUri, + slug, + file: ctx.params.files[0], + contentType: MIME_TYPES.JSON + }); } + ctx.meta.$responseHeaders = { + Location: resourceUri, + Link: '; rel="type"', + 'Content-Length': 0 + }; + // We need to set this also here (in addition to above) or we get a Moleculer warning + ctx.meta.$location = resourceUri; + ctx.meta.$statusCode = 201; } catch (e) { if (e.code !== 404 && e.code !== 403) console.error(e); ctx.meta.$statusCode = e.code || 500; diff --git a/src/middleware/packages/middlewares/index.js b/src/middleware/packages/middlewares/index.js index 5abe83732..df53dec8f 100644 --- a/src/middleware/packages/middlewares/index.js +++ b/src/middleware/packages/middlewares/index.js @@ -3,9 +3,10 @@ const { negotiateTypeMime, MIME_TYPES } = require('@semapps/mime-types'); const Busboy = require('busboy'); const streams = require('memory-streams'); -// Put query string in meta so that services may use them independently +// Put requested URL and query string in meta so that services may use them independently // Set here https://github.com/moleculerjs/moleculer-web/blob/c6ec80056a64ea15c57d6e2b946ce978d673ae92/src/index.js#L151-L161 -const parseQueryString = async (req, res, next) => { +const parseUrl = async (req, res, next) => { + req.$ctx.meta.requestUrl = req.parsedUrl; req.$ctx.meta.queryString = req.query; next(); }; @@ -179,22 +180,13 @@ const parseFile = (req, res, next) => { } }; -const addContainerUriMiddleware = containerUri => (req, res, next) => { - if (containerUri.includes('/:username([^/.][^/]+)')) { - req.$params.containerUri = containerUri.replace(':username([^/.][^/]+)', req.$params.username).replace(/\/$/, ''); - } else { - req.$params.containerUri = containerUri; - } - next(); -}; - const saveDatasetMeta = (req, res, next) => { req.$ctx.meta.dataset = req.$params.username; next(); }; module.exports = { - parseQueryString, + parseUrl, parseHeader, parseSparql, negotiateContentType, @@ -202,7 +194,6 @@ module.exports = { parseJson, parseTurtle, parseFile, - addContainerUriMiddleware, saveDatasetMeta, throw403, throw500 diff --git a/src/middleware/packages/pod/routes/getPodsRoute.js b/src/middleware/packages/pod/routes/getPodsRoute.js index 660202425..cfaa2dad0 100644 --- a/src/middleware/packages/pod/routes/getPodsRoute.js +++ b/src/middleware/packages/pod/routes/getPodsRoute.js @@ -1,4 +1,5 @@ const { + parseUrl, parseHeader, parseSparql, negotiateContentType, @@ -24,6 +25,7 @@ const transformRouteParamsToSlugParts = (req, res, next) => { function getPodsRoute() { const middlewares = [ + parseUrl, parseHeader, negotiateContentType, negotiateAccept, @@ -47,7 +49,7 @@ function getPodsRoute() { 'GET /': [...middlewares, 'ldp.api.get'], 'HEAD /': [transformRouteParamsToSlugParts, 'ldp.api.head'], 'GET /:collection': [...middlewares, 'ldp.api.get'], - 'POST /:collection': [...middlewares, 'ldp.api.post'] + 'PATCH /:collection': [...middlewares, 'ldp.api.patch'] }, // Handle this route after other routes. Requires a modification of the ApiGateway. // See https://github.com/moleculerjs/moleculer-web/issues/335 diff --git a/src/middleware/tests/activitypub/follow.test.js b/src/middleware/tests/activitypub/follow.test.js index 60d4f222e..823055f5e 100644 --- a/src/middleware/tests/activitypub/follow.test.js +++ b/src/middleware/tests/activitypub/follow.test.js @@ -53,7 +53,7 @@ describe.each(['single-server', 'multi-server'])('In mode %s, posting to followe actor: bob.id, type: ACTIVITY_TYPES.FOLLOW, object: alice.id, - to: [alice.id, `${bob.id}/followers`] + to: alice.id }); await waitForExpect(async () => { diff --git a/src/middleware/tests/activitypub/initialize.js b/src/middleware/tests/activitypub/initialize.js index 7f5db3338..739374d4f 100644 --- a/src/middleware/tests/activitypub/initialize.js +++ b/src/middleware/tests/activitypub/initialize.js @@ -57,7 +57,7 @@ const initialize = async (port, mainDataset, accountsDataset) => { } }); - broker.createService(WebIdService, { + await broker.createService(WebIdService, { settings: { usersContainer: urlJoin(baseUrl, 'as/actor') } From 6ea73890cd505827f3ac233bf91318f235850181 Mon Sep 17 00:00:00 2001 From: srosset81 Date: Thu, 4 Apr 2024 10:55:08 +0200 Subject: [PATCH 06/20] Fix more tests --- src/middleware/packages/core/service.js | 4 ++-- .../packages/jsonld/services/context/actions/getLocal.js | 8 +++++--- src/middleware/packages/pod/service.js | 4 ++-- src/middleware/tests/interop/initialize.js | 6 +++--- src/middleware/tests/ontologies/ontologies.test.js | 2 +- 5 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/middleware/packages/core/service.js b/src/middleware/packages/core/service.js index 54aa7b7ba..4a5d8582a 100644 --- a/src/middleware/packages/core/service.js +++ b/src/middleware/packages/core/service.js @@ -1,7 +1,7 @@ const path = require('path'); const ApiGatewayService = require('moleculer-web'); const { Errors: E } = require('moleculer-web'); -const { ActivityPubService } = require('@semapps/activitypub'); +const { ActivityPubService, FULL_ACTOR_TYPES } = require('@semapps/activitypub'); const { JsonLdService } = require('@semapps/jsonld'); const { LdpService, DocumentTaggerMixin } = require('@semapps/ldp'); const { OntologiesService } = require('@semapps/ontologies'); @@ -14,7 +14,7 @@ const { WebfingerService } = require('@semapps/webfinger'); const botsContainer = { path: '/as/application', - acceptedTypes: ['Application'], + acceptedTypes: [FULL_ACTOR_TYPES.APPLICATION], readOnly: true }; diff --git a/src/middleware/packages/jsonld/services/context/actions/getLocal.js b/src/middleware/packages/jsonld/services/context/actions/getLocal.js index 80b39b943..ecce05e97 100644 --- a/src/middleware/packages/jsonld/services/context/actions/getLocal.js +++ b/src/middleware/packages/jsonld/services/context/actions/getLocal.js @@ -4,11 +4,13 @@ module.exports = { async handler(ctx) { let context = []; - const ontologies = await ctx.call('ontologies.list'); + let ontologies = await ctx.call('ontologies.list'); + + // Do not include ontologies which want to preserve their context URI + ontologies = ontologies.filter(ont => ont.preserveContextUri !== true); for (const ontology of ontologies) { - // Do not include in local contexts URIs we want to preserve explicitely - if (ontology.jsonldContext && ontology.preserveContextUri !== true) { + if (ontology.jsonldContext) { context = [].concat(ontology.jsonldContext, context); } } diff --git a/src/middleware/packages/pod/service.js b/src/middleware/packages/pod/service.js index 9e122f116..4b7ea53f9 100644 --- a/src/middleware/packages/pod/service.js +++ b/src/middleware/packages/pod/service.js @@ -1,5 +1,5 @@ const urlJoin = require('url-join'); -const { ACTOR_TYPES } = require('@semapps/activitypub'); +const { FULL_ACTOR_TYPES } = require('@semapps/activitypub'); const { getSlugFromUri } = require('@semapps/ldp'); const getPodsRoute = require('./routes/getPodsRoute'); @@ -16,7 +16,7 @@ module.exports = { name: 'pods', path: '/', podsContainer: true, - acceptedTypes: [ACTOR_TYPES.PERSON], + acceptedTypes: [FULL_ACTOR_TYPES.PERSON], excludeFromMirror: true, controlledActions: { get: 'pod.getActor' diff --git a/src/middleware/tests/interop/initialize.js b/src/middleware/tests/interop/initialize.js index c2bdd9fac..ff97f3678 100644 --- a/src/middleware/tests/interop/initialize.js +++ b/src/middleware/tests/interop/initialize.js @@ -2,7 +2,7 @@ const fse = require('fs-extra'); const path = require('path'); const urlJoin = require('url-join'); const { ServiceBroker } = require('moleculer'); -const { ACTOR_TYPES, RelayService } = require('@semapps/activitypub'); +const { FULL_ACTOR_TYPES, RelayService } = require('@semapps/activitypub'); const { AuthLocalService } = require('@semapps/auth'); const { CoreService } = require('@semapps/core'); const { InferenceService } = require('@semapps/inference'); @@ -26,12 +26,12 @@ const containers = [ }, { path: '/as/actor', - acceptedTypes: [ACTOR_TYPES.PERSON], + acceptedTypes: [FULL_ACTOR_TYPES.PERSON], excludeFromMirror: true }, { path: '/as/application', - acceptedTypes: [ACTOR_TYPES.APPLICATION], + acceptedTypes: [FULL_ACTOR_TYPES.APPLICATION], excludeFromMirror: true } ]; diff --git a/src/middleware/tests/ontologies/ontologies.test.js b/src/middleware/tests/ontologies/ontologies.test.js index 20f09c8a5..998b06e59 100644 --- a/src/middleware/tests/ontologies/ontologies.test.js +++ b/src/middleware/tests/ontologies/ontologies.test.js @@ -101,7 +101,7 @@ describe.each([true])('Register ontologies with persistRegistry %s', persistRegi ont2: 'https://www.w3.org/ns/ontology2#', ont3: 'https://www.w3.org/ns/ontology3#', friend: { - '@id': 'https://www.w3.org/ns/ontology3#friend', + '@id': 'ont3:friend', '@type': '@id', '@protected': true } From f27f668d69e784106819bf2a93f3f7a2cebffb93 Mon Sep 17 00:00:00 2001 From: srosset81 Date: Thu, 4 Apr 2024 12:07:49 +0200 Subject: [PATCH 07/20] Clean up ActivitypubRegistryService --- src/middleware/packages/activitypub/index.js | 1 - .../mixins/controlled-collection.js | 48 -------- .../activitypub/services/activitypub/index.js | 1 - .../services/activitypub/subservices/inbox.js | 24 ++-- .../activitypub/subservices/outbox.js | 27 ++--- .../activitypub/subservices/registry.js | 104 +++--------------- .../packages/ldp/services/api/actions/get.js | 15 --- src/middleware/packages/pod/package.json | 1 + website/docs/middleware/activitypub/index.md | 1 - website/docs/middleware/ldp/index.md | 14 +-- 10 files changed, 49 insertions(+), 187 deletions(-) delete mode 100644 src/middleware/packages/activitypub/mixins/controlled-collection.js diff --git a/src/middleware/packages/activitypub/index.js b/src/middleware/packages/activitypub/index.js index 83237d33d..0080be9bd 100644 --- a/src/middleware/packages/activitypub/index.js +++ b/src/middleware/packages/activitypub/index.js @@ -6,7 +6,6 @@ module.exports = { RelayService: require('./services/relay'), // Mixins BotMixin: require('./mixins/bot'), - ControlledCollectionMixin: require('./mixins/controlled-collection'), ActivitiesHandlerMixin: require('./mixins/activities-handler'), // Misc. matchActivity: require('./utils/matchActivity'), diff --git a/src/middleware/packages/activitypub/mixins/controlled-collection.js b/src/middleware/packages/activitypub/mixins/controlled-collection.js deleted file mode 100644 index 7994701f8..000000000 --- a/src/middleware/packages/activitypub/mixins/controlled-collection.js +++ /dev/null @@ -1,48 +0,0 @@ -const { Errors: E } = require('moleculer-web'); - -module.exports = { - settings: { - path: null, - attachToTypes: [], - attachPredicate: null, - ordered: false, - itemsPerPage: null, - dereferenceItems: false, - sort: { predicate: 'as:published', order: 'DESC' }, - permissions: null, - controlledActions: {} - }, - dependencies: ['activitypub.registry'], - async started() { - await this.broker.call('activitypub.registry.register', { - path: this.settings.path, - name: this.name, - attachToTypes: this.settings.attachToTypes, - attachPredicate: this.settings.attachPredicate, - ordered: this.settings.ordered, - itemsPerPage: this.settings.itemsPerPage, - dereferenceItems: this.settings.dereferenceItems, - sort: this.settings.sort, - permissions: this.settings.permissions, - controlledActions: { - get: `${this.name}.get`, - post: `${this.name}.post`, - ...this.settings.controlledActions - } - }); - }, - actions: { - get(ctx) { - return ctx.call('activitypub.collection.get', ctx.params); - }, - post() { - throw new E.ForbiddenError(); - } - }, - methods: { - async getCollectionUri(webId) { - // TODO make this work - return this.broker.call('activitypub.registry.getUri', { path: this.settings.path, webId }); - } - } -}; diff --git a/src/middleware/packages/activitypub/services/activitypub/index.js b/src/middleware/packages/activitypub/services/activitypub/index.js index 51824d627..08c8c224f 100644 --- a/src/middleware/packages/activitypub/services/activitypub/index.js +++ b/src/middleware/packages/activitypub/services/activitypub/index.js @@ -91,7 +91,6 @@ const ActivityPubService = { this.broker.createService(InboxService, { settings: { - baseUri, podProvider } }); diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/inbox.js b/src/middleware/packages/activitypub/services/activitypub/subservices/inbox.js index 92b4e8e57..e8e66bcb7 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/inbox.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/inbox.js @@ -1,25 +1,27 @@ const { MIME_TYPES } = require('@semapps/mime-types'); const { Errors: E } = require('moleculer-web'); const { objectIdToCurrent, collectionPermissionsWithAnonRead } = require('../../../utils'); -const ControlledCollectionMixin = require('../../../mixins/controlled-collection'); const { ACTOR_TYPES } = require('../../../constants'); /** @type {import('moleculer').ServiceSchema} */ const InboxService = { name: 'activitypub.inbox', - mixins: [ControlledCollectionMixin], settings: { - path: '/inbox', - attachToTypes: Object.values(ACTOR_TYPES), - attachPredicate: 'http://www.w3.org/ns/ldp#inbox', - ordered: true, - itemsPerPage: 10, - dereferenceItems: true, - sort: { predicate: 'as:published', order: 'DESC' }, - permissions: collectionPermissionsWithAnonRead, podProvider: false }, - dependencies: ['activitypub.collection', 'triplestore'], + dependencies: ['activitypub.collection', 'activitypub.registry'], + async started() { + await this.broker.call('activitypub.registry.register', { + path: '/inbox', + attachToTypes: Object.values(ACTOR_TYPES), + attachPredicate: 'http://www.w3.org/ns/ldp#inbox', + ordered: true, + itemsPerPage: 10, + dereferenceItems: true, + sort: { predicate: 'as:published', order: 'DESC' }, + permissions: collectionPermissionsWithAnonRead + }); + }, actions: { async post(ctx) { const { collectionUri, ...activity } = ctx.params; diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/outbox.js b/src/middleware/packages/activitypub/services/activitypub/subservices/outbox.js index 6b2923684..8872f227d 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/outbox.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/outbox.js @@ -1,27 +1,28 @@ const fetch = require('node-fetch'); const { Errors: E } = require('moleculer-web'); const { MIME_TYPES } = require('@semapps/mime-types'); -const ControlledCollectionMixin = require('../../../mixins/controlled-collection'); const { collectionPermissionsWithAnonRead, getSlugFromUri, objectIdToCurrent } = require('../../../utils'); const { ACTOR_TYPES } = require('../../../constants'); const OutboxService = { name: 'activitypub.outbox', - mixins: [ControlledCollectionMixin], settings: { baseUri: null, - podProvider: false, - // ControlledCollectionMixin settings - path: '/outbox', - attachToTypes: Object.values(ACTOR_TYPES), - attachPredicate: 'https://www.w3.org/ns/activitystreams#outbox', - ordered: true, - itemsPerPage: 10, - dereferenceItems: true, - sort: { predicate: 'as:published', order: 'DESC' }, - permissions: collectionPermissionsWithAnonRead + podProvider: false + }, + dependencies: ['activitypub.object', 'activitypub.collection', 'activitypub.registry'], + async started() { + await this.broker.call('activitypub.registry.register', { + path: '/outbox', + attachToTypes: Object.values(ACTOR_TYPES), + attachPredicate: 'https://www.w3.org/ns/activitystreams#outbox', + ordered: true, + itemsPerPage: 10, + dereferenceItems: true, + sort: { predicate: 'as:published', order: 'DESC' }, + permissions: collectionPermissionsWithAnonRead + }); }, - dependencies: ['activitypub.object', 'activitypub.collection'], actions: { async post(ctx) { let { collectionUri, ...activity } = ctx.params; diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/registry.js b/src/middleware/packages/activitypub/services/activitypub/subservices/registry.js index ff44a6804..e0a69fc1b 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/registry.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/registry.js @@ -21,17 +21,7 @@ const RegistryService = { dependencies: ['triplestore', 'ldp'], async started() { this.registeredCollections = []; - this.registeredContainers = {}; this.collectionsInCreation = []; - - this.registeredContainers['*'] = await this.broker.call('ldp.registry.list'); - - if (this.settings.podProvider) { - await this.broker.waitForServices(['pod']); - for (let dataset of await this.broker.call('pod.list')) { - this.registeredContainers[dataset] = await this.broker.call('ldp.registry.list', { dataset }); - } - } }, actions: { async register(ctx) { @@ -47,35 +37,6 @@ const RegistryService = { list() { return this.registeredCollections; }, - listLocalContainers() { - return this.registeredContainers; - }, - async getByUri(ctx) { - const { collectionUri } = ctx.params; - - if (!collectionUri) { - throw new Error('The param collectionUri must be provided to activitypub.registry.getByUri'); - } - - // TODO put in cache - const result = await ctx.call('triplestore.query', { - query: ` - SELECT ?predicate - WHERE { - ?resource ?predicate <${collectionUri}> . - } - `, - accept: MIME_TYPES.JSON, - webId: 'system' - }); - - return { - ...this.settings.defaultCollectionOptions, - ...this.registeredCollections.find(collection => - result.some(node => collection.attachPredicate === node.predicate.value) - ) - }; - }, async createAndAttachCollection(ctx) { const { objectUri, collection, webId } = ctx.params; const { path, attachPredicate, ordered, summary, dereferenceItems, itemsPerPage, sortPredicate, sortOrder } = @@ -146,38 +107,25 @@ const RegistryService = { const datasets = this.settings.podProvider ? await this.broker.call('pod.list') : ['*']; for (let dataset of datasets) { // Find all containers where we want to attach this collection - const containers = this.getContainersByType(collection.attachToTypes, dataset); - if (containers) { - // Go through each container - for (const container of Object.values(containers)) { - const containerUri = urlJoin(this.settings.baseUri, container.fullPath); - this.logger.info(`Looking for resources in container ${containerUri}`); - const resources = await ctx.call('ldp.container.getUris', { containerUri }); - for (const resourceUri of resources) { - await this.actions.createAndAttachCollection( - { - objectUri: resourceUri, - collection, - webId: 'system' - }, - { parentCtx: ctx } - ); - } + const containers = await ctx.call('ldp.registry.getByType', { type: collection.attachToTypes, dataset }); + for (const container of Object.values(containers)) { + const containerUri = urlJoin(this.settings.baseUri, container.fullPath); + this.logger.info(`Looking for resources in container ${containerUri}`); + const resources = await ctx.call('ldp.container.getUris', { containerUri }); + for (const resourceUri of resources) { + await this.actions.createAndAttachCollection( + { + objectUri: resourceUri, + collection, + webId: 'system' + }, + { parentCtx: ctx } + ); } } } } } - // async getUri(ctx) { - // const { path, webId } = ctx.params; - // - // if (this.settings.podProvider) { - // const account = await ctx.call('auth.account.findByWebId', { webId }); - // return urlJoin(account.podUri, path); - // } else { - // return urlJoin(this.settings.baseUrl, path); - // } - // } }, methods: { // Get the collections attached to the given type @@ -195,22 +143,6 @@ const RegistryService = { ) : []; }, - // Get the containers with resources of the given type - // Same action as ldp.registry.getByType, but search through locally registered containers to avoid race conditions - getContainersByType(types, dataset) { - const containers = - !dataset || dataset === '*' - ? this.registeredContainers['*'] - : { ...this.registeredContainers['*'], ...this.registeredContainers[dataset] }; - - return Object.values(containers).filter(container => - defaultToArray(types).some(type => - Array.isArray(container.acceptedTypes) - ? container.acceptedTypes.includes(type) - : container.acceptedTypes === type - ) - ); - }, isActor(types) { return defaultToArray(types).some(type => [...Object.values(ACTOR_TYPES), ...Object.values(FULL_ACTOR_TYPES)].includes(type) @@ -291,14 +223,6 @@ const RegistryService = { { parentCtx: ctx } ); } - }, - async 'ldp.registry.registered'(ctx) { - const { container, dataset } = ctx.params; - - // Register the container locally - // Avoid race conditions, if this event is called while the register action is still running - if (!this.registeredContainers[dataset || '*']) this.registeredContainers[dataset || '*'] = {}; - this.registeredContainers[dataset || '*'][container.name] = container; } } }; diff --git a/src/middleware/packages/ldp/services/api/actions/get.js b/src/middleware/packages/ldp/services/api/actions/get.js index 801c48f10..b16f463b7 100644 --- a/src/middleware/packages/ldp/services/api/actions/get.js +++ b/src/middleware/packages/ldp/services/api/actions/get.js @@ -33,21 +33,6 @@ module.exports = async function get(ctx) { ctx.meta.$responseType = ctx.meta.$responseType || accept; if (ctx.meta.$responseType === 'application/ld+json') ctx.meta.$responseType = `application/ld+json; profile="https://www.w3.org/ns/activitystreams"`; - } else if (types.includes('https://www.w3.org/ns/activitystreams#Collection')) { - /* - * AS COLLECTION - */ - - const { controlledActions } = await ctx.call('activitypub.registry.getByUri', { collectionUri: uri }); - - res = await ctx.call( - controlledActions?.get || 'activitypub.collection.get', - cleanUndefined({ - resourceUri: uri, - jsonContext: parseJson(ctx.meta.headers?.jsonldcontext) - }) - ); - ctx.meta.$responseType = `application/ld+json; profile="https://www.w3.org/ns/activitystreams"`; } else { /* * LDP RESOURCE diff --git a/src/middleware/packages/pod/package.json b/src/middleware/packages/pod/package.json index e2e81da98..bd4cf5b78 100644 --- a/src/middleware/packages/pod/package.json +++ b/src/middleware/packages/pod/package.json @@ -6,6 +6,7 @@ "author": "Virtual Assembly", "dependencies": { "@semapps/activitypub": "0.6.2", + "@semapps/ldp": "0.6.2", "@semapps/middlewares": "0.6.2", "url-join": "^4.0.1" }, diff --git a/website/docs/middleware/activitypub/index.md b/website/docs/middleware/activitypub/index.md index 31c94a13a..c6e286206 100644 --- a/website/docs/middleware/activitypub/index.md +++ b/website/docs/middleware/activitypub/index.md @@ -38,7 +38,6 @@ This service allows you to create an ActivityPub server with data stored in a tr ## Mixins - [ActivitiesHandlerMixin](activities-handler.md) -- ControlledCollectionMixin - BotMixin ## Install diff --git a/website/docs/middleware/ldp/index.md b/website/docs/middleware/ldp/index.md index 59706054c..b6e631934 100644 --- a/website/docs/middleware/ldp/index.md +++ b/website/docs/middleware/ldp/index.md @@ -92,13 +92,13 @@ The following options can be set for each container, or they can be set in the ` These catch-all routes are automatically added to the `ApiGateway` service. -| Method | LDP resource | LDP container | ActivityStreams collection | -| ---------- | --------------------- | ---------------------- | ----------------------------------------- | -| `GET *` | `ldp.resource.get` | `ldp.container.get` | `activitypub.collection.get` | -| `POST *` | `ldp.resource.post` | `ldp.container.post` | (If defined by ControlledCollectionMixin) | -| `PUT *` | `ldp.resource.put` | - | - | -| `PATCH *` | `ldp.resource.patch` | `ldp.container.patch` | - | -| `DELETE *` | `ldp.resource.delete` | `ldp.container.delete` | - | +| Method | LDP resource | LDP container | +| -------- | --------------------- | ---------------------- | +| `GET` | `ldp.resource.get` | `ldp.container.get` | +| `POST` | `ldp.resource.post` | `ldp.container.post` | +| `PUT` | `ldp.resource.put` | - | +| `PATCH` | `ldp.resource.patch` | `ldp.container.patch` | +| `DELETE` | `ldp.resource.delete` | `ldp.container.delete` | > Note: If the `readOnly` container option is set (see above), only `GET` routes are added. From 563a240b2d8b3761ff02e07fd81d54167bd6b1d6 Mon Sep 17 00:00:00 2001 From: srosset81 Date: Fri, 5 Apr 2024 11:02:53 +0200 Subject: [PATCH 08/20] Refactor reply service --- .../activitypub/subservices/collection.js | 6 +- .../services/activitypub/subservices/inbox.js | 5 + .../activitypub/subservices/registry.js | 14 +- .../services/activitypub/subservices/reply.js | 83 ++++++---- .../packages/webacl/middlewares/webacl.js | 28 +++- src/middleware/packages/webacl/package.json | 1 + .../tests/activitypub/initialize.js | 2 +- .../tests/activitypub/message.test.js | 150 ++++++++++++++++++ src/middleware/tests/package.json | 2 +- 9 files changed, 244 insertions(+), 47 deletions(-) create mode 100644 src/middleware/tests/activitypub/message.test.js diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/collection.js b/src/middleware/packages/activitypub/services/activitypub/subservices/collection.js index cd9e35ce4..8300ed71f 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/collection.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/collection.js @@ -321,7 +321,7 @@ const CollectionService = { await ctx.call('ldp.resource.get', { resourceUri: itemUri, accept: MIME_TYPES.JSON, - jsonContext: 'https://www.w3.org/ns/activitystreams', + jsonContext, webId }) ); @@ -365,10 +365,12 @@ const CollectionService = { } } - return await ctx.call('jsonld.parser.compact', { + const test = await ctx.call('jsonld.parser.compact', { input: returnData, context: jsonContext || localContext }); + + return test; }, /* * Empty the collection, deleting all items it contains. diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/inbox.js b/src/middleware/packages/activitypub/services/activitypub/subservices/inbox.js index e8e66bcb7..0e8f91ac4 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/inbox.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/inbox.js @@ -1,4 +1,5 @@ const { MIME_TYPES } = require('@semapps/mime-types'); +const { getDatasetFromUri } = require('@semapps/ldp'); const { Errors: E } = require('moleculer-web'); const { objectIdToCurrent, collectionPermissionsWithAnonRead } = require('../../../utils'); const { ACTOR_TYPES } = require('../../../constants'); @@ -26,6 +27,10 @@ const InboxService = { async post(ctx) { const { collectionUri, ...activity } = ctx.params; + if (this.settings.podProvider) { + ctx.meta.dataset = getDatasetFromUri(collectionUri); + } + if (!collectionUri || !collectionUri.startsWith('http')) { throw new Error(`The collectionUri ${collectionUri} is not a valid URL`); } diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/registry.js b/src/middleware/packages/activitypub/services/activitypub/subservices/registry.js index e0a69fc1b..f15203e44 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/registry.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/registry.js @@ -8,15 +8,7 @@ const RegistryService = { name: 'activitypub.registry', settings: { baseUri: null, - podProvider: false, - defaultCollectionOptions: { - attachToTypes: [], - attachPredicate: null, - ordered: false, - itemsPerPage: null, - dereferenceItems: false, - sort: { predicate: 'as:published', order: 'DESC' } - } + podProvider: false }, dependencies: ['triplestore', 'ldp'], async started() { @@ -31,7 +23,7 @@ const RegistryService = { // Ignore undefined options Object.keys(options).forEach(key => (options[key] === undefined || options[key] === null) && delete options[key]); - // Save the collection locally + // Persist the collection in memory this.registeredCollections.push({ path, name, attachToTypes, ...options }); }, list() { @@ -89,6 +81,8 @@ const RegistryService = { // Now the collection has been created, we can remove it (this way we don't use too much memory) this.collectionsInCreation = this.collectionsInCreation.filter(c => c !== collectionUri); } + + return collectionUri; }, async deleteCollection(ctx) { const { objectUri, collection } = ctx.params; diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/reply.js b/src/middleware/packages/activitypub/services/activitypub/subservices/reply.js index d99c294b7..d7f4f2f4d 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/reply.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/reply.js @@ -8,91 +8,110 @@ const ReplyService = { mixins: [ActivitiesHandlerMixin], settings: { baseUri: null, - attachToObjectTypes: null - }, - dependencies: ['activitypub.outbox', 'activitypub.collection'], - async started() { - const { attachToObjectTypes } = this.settings; - await this.broker.call('activitypub.registry.register', { + collectionOptions: { path: '/replies', - attachToTypes: attachToObjectTypes, attachPredicate: 'https://www.w3.org/ns/activitystreams#replies', ordered: false, dereferenceItems: true, permissions: collectionPermissionsWithAnonRead - }); + } }, + dependencies: ['activitypub.outbox', 'activitypub.collection'], actions: { async addReply(ctx) { const { objectUri, replyUri } = ctx.params; - const object = await ctx.call('activitypub.object.get', { objectUri }); + // Create the /replies collection and attach it to the object, unless it already exists + const collectionUri = await ctx.call('activitypub.registry.createAndAttachCollection', { + objectUri, + collection: this.settings.collectionOptions, + webId: 'system' + }); - await ctx.call('activitypub.collection.add', { collectionUri: object.replies, item: replyUri }); + await ctx.call('activitypub.collection.add', { collectionUri, item: replyUri }); }, async removeReply(ctx) { const { objectUri, replyUri } = ctx.params; const object = await ctx.call('activitypub.object.get', { objectUri }); - await ctx.call('activitypub.collection.remove', { collectionUri: object.replies, item: replyUri }); + // Remove the reply only if a /replies collection was attached to the object + if (object.replies) { + await ctx.call('activitypub.collection.remove', { collectionUri: object.replies, item: replyUri }); + } } }, activities: { postReply: { - match(ctx, activity) { - return matchActivity( + async match(ctx, activity) { + const dereferencedActivity = await matchActivity( ctx, { type: ACTIVITY_TYPES.CREATE, object: { - type: OBJECT_TYPES.NOTE, - inReplyTo: { - type: this.settings.attachToObjectTypes - } + type: OBJECT_TYPES.NOTE } }, activity ); + // We have a match only if there is a inReplyTo predicate to the object + if (dereferencedActivity && dereferencedActivity.object.inReplyTo) { + return dereferencedActivity; + } else { + return false; + } }, async onEmit(ctx, activity) { - if (this.isLocalObject(activity.object.inReplyTo.id)) { + if (this.isLocalObject(activity.object.inReplyTo)) { await this.actions.addReply( - { objectUri: activity.object.inReplyTo.id, replyUri: activity.object.id }, + { objectUri: activity.object.inReplyTo, replyUri: activity.object.id }, { parentCtx: ctx } ); } }, async onReceive(ctx, activity) { - if (this.isLocalObject(activity.object.inReplyTo.id)) { + if (this.isLocalObject(activity.object.inReplyTo)) { await this.actions.addReply( - { objectUri: activity.object.inReplyTo.id, replyUri: activity.object.id }, + { objectUri: activity.object.inReplyTo, replyUri: activity.object.id }, { parentCtx: ctx } ); } } }, deleteReply: { - match(ctx, activity) { - return matchActivity( + async match(ctx, activity) { + const dereferencedActivity = await matchActivity( ctx, { type: ACTIVITY_TYPES.DELETE, object: { - type: OBJECT_TYPES.NOTE, - inReplyTo: { - type: this.settings.attachToObjectTypes - } + type: OBJECT_TYPES.NOTE } }, activity ); + // We have a match only if there is a inReplyTo predicate to the object + if (dereferencedActivity && dereferencedActivity.object.inReplyTo) { + return dereferencedActivity; + } else { + return false; + } }, - async onEmit(ctx, activity, emitterUri) { - await this.actions.removeReply( - { objectUri: activity.object.inReplyTo.id, replyUri: activity.object.id }, - { parentCtx: ctx } - ); + async onEmit(ctx, activity) { + if (this.isLocalObject(activity.object.inReplyTo)) { + await this.actions.removeReply( + { objectUri: activity.object.inReplyTo, replyUri: activity.object.id }, + { parentCtx: ctx } + ); + } + }, + async onReceive(ctx, activity) { + if (this.isLocalObject(activity.object.inReplyTo)) { + await this.actions.removeReply( + { objectUri: activity.object.inReplyTo, replyUri: activity.object.id }, + { parentCtx: ctx } + ); + } } } }, diff --git a/src/middleware/packages/webacl/middlewares/webacl.js b/src/middleware/packages/webacl/middlewares/webacl.js index d79ceb0c6..b5e71dfb7 100644 --- a/src/middleware/packages/webacl/middlewares/webacl.js +++ b/src/middleware/packages/webacl/middlewares/webacl.js @@ -1,5 +1,6 @@ const { throw403 } = require('@semapps/middlewares'); -const { arrayOf } = require('@semapps/ldp'); +const { arrayOf, hasType } = require('@semapps/ldp'); +const { ACTIVITY_TYPES } = require('@semapps/activitypub'); const { isRemoteUri, getSlugFromUri } = require('../utils'); const { defaultContainerRights } = require('../defaultRights'); @@ -281,6 +282,19 @@ const WebAclMiddleware = ({ baseUrl, podProvider = false, graphName = 'http://se }, webId: 'system' }); + // If this is a Create activity, also give rights to the object + if (action.name === 'activitypub.activity.create' && hasType(activity, ACTIVITY_TYPES.CREATE)) { + await ctx.call('webacl.resource.addRights', { + resourceUri: typeof activity.object === 'string' ? activity.object : activity.object.id, + additionalRights: { + user: { + uri: recipient, + read: true + } + }, + webId: 'system' + }); + } } // If activity is public, give anonymous read right @@ -294,6 +308,18 @@ const WebAclMiddleware = ({ baseUrl, podProvider = false, graphName = 'http://se }, webId: 'system' }); + // If this is a Create activity, also give rights to the object + if (action.name === 'activitypub.activity.create' && hasType(activity, ACTIVITY_TYPES.CREATE)) { + await ctx.call('webacl.resource.addRights', { + resourceUri: typeof activity.object === 'string' ? activity.object : activity.object.id, + additionalRights: { + anon: { + read: true + } + }, + webId: 'system' + }); + } } break; } diff --git a/src/middleware/packages/webacl/package.json b/src/middleware/packages/webacl/package.json index 3e8a0900c..f18270c08 100644 --- a/src/middleware/packages/webacl/package.json +++ b/src/middleware/packages/webacl/package.json @@ -16,6 +16,7 @@ "url": "https://github.com/assemblee-virtuelle/semapps/issues" }, "dependencies": { + "@semapps/activitypub": "0.6.2", "@semapps/ldp": "0.6.2", "@semapps/middlewares": "0.6.2", "@semapps/mime-types": "0.6.2", diff --git a/src/middleware/tests/activitypub/initialize.js b/src/middleware/tests/activitypub/initialize.js index 739374d4f..f82172ac9 100644 --- a/src/middleware/tests/activitypub/initialize.js +++ b/src/middleware/tests/activitypub/initialize.js @@ -21,7 +21,7 @@ const initialize = async (port, mainDataset, accountsDataset) => { logger: { type: 'Console', options: { - level: 'error' + level: 'warn' } } }); diff --git a/src/middleware/tests/activitypub/message.test.js b/src/middleware/tests/activitypub/message.test.js new file mode 100644 index 000000000..6101b2074 --- /dev/null +++ b/src/middleware/tests/activitypub/message.test.js @@ -0,0 +1,150 @@ +const waitForExpect = require('wait-for-expect'); +const { OBJECT_TYPES, ACTIVITY_TYPES } = require('@semapps/activitypub'); +const { MIME_TYPES } = require('@semapps/mime-types'); +const initialize = require('./initialize'); + +jest.setTimeout(50000); + +const NUM_USERS = 2; + +describe.each(['single-server', 'multi-server'])('In mode %s, exchange messages', mode => { + let broker; + const actors = []; + let alice; + let bob; + let aliceMessageUri; + let bobMessageUri; + + beforeAll(async () => { + if (mode === 'single-server') { + broker = await initialize(3000, 'testData', 'settings'); + } else { + broker = []; + } + + for (let i = 1; i <= NUM_USERS; i++) { + if (mode === 'multi-server') { + broker[i] = await initialize(3000 + i, `testData${i}`, `settings${i}`); + } else { + broker[i] = broker; + } + const { webId } = await broker[i].call('auth.signup', require(`./data/actor${i}.json`)); + actors[i] = await broker[i].call('activitypub.actor.awaitCreateComplete', { actorUri: webId }); + actors[i].call = (actionName, params, options = {}) => + broker[i].call(actionName, params, { ...options, meta: { ...options.meta, webId } }); + } + + alice = actors[1]; + bob = actors[2]; + }); + + afterAll(async () => { + if (mode === 'multi-server') { + for (let i = 1; i <= NUM_USERS; i++) { + await broker[i].stop(); + } + } else { + await broker.stop(); + } + }); + + test('Alice send message to Bob', async () => { + const createActivity = await alice.call('activitypub.outbox.post', { + collectionUri: alice.outbox, + '@context': 'https://www.w3.org/ns/activitystreams', + type: OBJECT_TYPES.NOTE, + attributedTo: alice.id, + content: 'Hello Bob, how are you doing ?', + to: bob.id + }); + + aliceMessageUri = createActivity.object.id; + + // Check the object has been created + const message = await alice.call('ldp.resource.get', { + resourceUri: aliceMessageUri, + accept: MIME_TYPES.JSON + }); + expect(message).toMatchObject({ + type: OBJECT_TYPES.NOTE, + attributedTo: alice.id, + content: 'Hello Bob, how are you doing ?' + }); + + // Ensure the /replies collection has not been created yet + expect(message.replies).not.toBeDefined(); + }); + + test('Bob replies to Alice and his message appears in the /replies collection', async () => { + const createActivity = await bob.call('activitypub.outbox.post', { + collectionUri: bob.outbox, + '@context': 'https://www.w3.org/ns/activitystreams', + type: OBJECT_TYPES.NOTE, + attributedTo: bob.id, + content: "I'm fine, what about you ?", + inReplyTo: aliceMessageUri, + to: alice.id + }); + + bobMessageUri = createActivity.object.id; + + await waitForExpect(async () => { + await expect( + alice.call('ldp.resource.get', { + resourceUri: aliceMessageUri, + accept: MIME_TYPES.JSON + }) + ).resolves.toMatchObject({ + replies: `${aliceMessageUri}/replies` + }); + }); + + await waitForExpect(async () => { + await expect( + alice.call('activitypub.collection.get', { + resourceUri: `${aliceMessageUri}/replies`, + accept: MIME_TYPES.JSON + }) + ).resolves.toMatchObject({ + type: 'Collection', + items: { + id: bobMessageUri, + type: OBJECT_TYPES.NOTE, + attributedTo: bob.id, + content: "I'm fine, what about you ?" + }, + totalItems: 1 + }); + }); + }); + + test('Bob deletes his message to Alice', async () => { + await bob.call('activitypub.outbox.post', { + collectionUri: bob.outbox, + '@context': 'https://www.w3.org/ns/activitystreams', + type: ACTIVITY_TYPES.DELETE, + object: bobMessageUri, + to: alice.id + }); + + await waitForExpect(async () => { + await expect( + alice.call('ldp.resource.get', { + resourceUri: bobMessageUri, + accept: MIME_TYPES.JSON + }) + ).rejects.toThrow(); + }); + + await waitForExpect(async () => { + await expect( + alice.call('activitypub.collection.get', { + resourceUri: `${aliceMessageUri}/replies`, + accept: MIME_TYPES.JSON + }) + ).resolves.toMatchObject({ + totalItems: 0 + }); + }); + }); +}); diff --git a/src/middleware/tests/package.json b/src/middleware/tests/package.json index f66702972..1ac7206b5 100644 --- a/src/middleware/tests/package.json +++ b/src/middleware/tests/package.json @@ -3,7 +3,7 @@ "private": true, "description": "SemApps integration tests", "scripts": { - "test": "jest --detectOpenHandles --verbose true --silent false" + "test": "jest --detectOpenHandles --verbose true --silent false activitypub/message" }, "author": "SemApps Team", "license": "Apache-2.0", From 53505c49abb1154726c249099a9e440fa1b8d090 Mon Sep 17 00:00:00 2001 From: srosset81 Date: Fri, 5 Apr 2024 15:57:54 +0200 Subject: [PATCH 09/20] ReplyService refactoring --- .../activitypub/services/activitypub/index.js | 12 ++- .../activitypub/subservices/object.js | 74 +++++++++---------- .../services/activitypub/subservices/reply.js | 54 +++++++------- src/middleware/packages/ldp/package.json | 1 + .../ldp/services/remote/actions/getNetwork.js | 6 +- .../ldp/services/remote/actions/store.js | 7 ++ .../packages/webacl/middlewares/webacl.js | 14 ++++ .../tests/activitypub/message.test.js | 6 +- .../tests/activitypub/object.test.js | 6 +- src/middleware/tests/package.json | 2 +- 10 files changed, 102 insertions(+), 80 deletions(-) diff --git a/src/middleware/packages/activitypub/services/activitypub/index.js b/src/middleware/packages/activitypub/services/activitypub/index.js index 08c8c224f..9a7e2f5f1 100644 --- a/src/middleware/packages/activitypub/services/activitypub/index.js +++ b/src/middleware/packages/activitypub/services/activitypub/index.js @@ -20,6 +20,7 @@ const ActivityPubService = { baseUri: null, podProvider: false, activitiesPath: '/as/activity', + activateTombstones: true, selectActorData: null, queueServiceUrl: null, like: { @@ -28,14 +29,11 @@ const ActivityPubService = { }, follow: { attachToActorTypes: null - }, - reply: { - attachToObjectTypes: null } }, dependencies: ['api', 'ontologies'], created() { - const { baseUri, podProvider, activitiesPath, selectActorData, queueServiceUrl, reply, like, follow } = + const { baseUri, podProvider, activitiesPath, selectActorData, queueServiceUrl, activateTombstones, like, follow } = this.settings; this.broker.createService(ActivitiesWatcherService); @@ -71,7 +69,8 @@ const ActivityPubService = { this.broker.createService(ObjectService, { settings: { baseUri, - podProvider + podProvider, + activateTombstones } }); @@ -105,8 +104,7 @@ const ActivityPubService = { this.broker.createService(ReplyService, { settings: { - baseUri, - attachToObjectTypes: reply.attachToObjectTypes || Object.values(OBJECT_TYPES) + baseUri } }); diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/object.js b/src/middleware/packages/activitypub/services/activitypub/subservices/object.js index ac5f4df2a..80c9777f6 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/object.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/object.js @@ -1,12 +1,13 @@ const { MIME_TYPES } = require('@semapps/mime-types'); const { getSlugFromUri } = require('@semapps/ldp'); -const { OBJECT_TYPES, ACTIVITY_TYPES } = require('../../../constants'); +const { OBJECT_TYPES, ACTIVITY_TYPES, ACTOR_TYPES } = require('../../../constants'); const ObjectService = { name: 'activitypub.object', settings: { baseUri: null, - podProvider: false + podProvider: false, + activateTombstones: true }, dependencies: ['ldp.resource'], actions: { @@ -121,44 +122,39 @@ const ObjectService = { } return activity; + }, + async createTombstone(ctx) { + const { resourceUri, formerType } = ctx.params; + const expandedFormerTypes = await ctx.call('jsonld.parser.expandTypes', { types: formerType }); + + // Insert directly the Tombstone in the triple store to avoid resource creation side-effects + await ctx.call('triplestore.insert', { + resource: { + '@id': resourceUri, + '@type': 'https://www.w3.org/ns/activitystreams#Tombstone', + 'https://www.w3.org/ns/activitystreams#formerType': expandedFormerTypes.map(type => ({ '@id': type })), + 'https://www.w3.org/ns/activitystreams#deleted': { + '@value': new Date().toISOString(), + '@type': 'http://www.w3.org/2001/XMLSchema#dateTime' + } + }, + contentType: MIME_TYPES.JSON, + webId: 'system' + }); + } + }, + events: { + async 'ldp.resource.deleted'(ctx) { + if (this.settings.activateTombstones) { + const { resourceUri, oldData } = ctx.params; + const formerType = oldData.type || oldData['@type']; + + // Do not create Tombstones for actors or collections + if (![...Object.values(ACTOR_TYPES), 'Collection', 'OrderedCollection'].includes(formerType)) { + await this.actions.createTombstone({ resourceUri, formerType }, { parentCtx: ctx }); + } + } } - // TODO handle Tombstones, also when we post directly through the LDP protocol ? - // async create(ctx) { - // // If there is already a tombstone in the desired URI, - // // remove it first to avoid automatic incrementation of the slug - // if (ctx.params.slug) { - // const desiredUri = urlJoin(this.settings.containerUri, ctx.params.slug); - // let object; - // try { - // object = await this.getById(desiredUri); - // } catch (e) { - // // Do nothing if object is not found - // } - // if (object && object.type === OBJECT_TYPES.TOMBSTONE) { - // await this._remove(ctx, { id: desiredUri }); - // } - // } - // return await this._create(ctx, ctx.params); - // } - // }, - // events: { - // async 'ldp.resource.deleted'(ctx) { - // const { resourceUri } = ctx.params; - // const containerUri = getContainerFromUri(resourceUri); - // const slug = getSlugFromUri(resourceUri); - // - // if (this.settings.objectsContainers.includes(containerUri)) { - // await ctx.call('ldp.container.post', { - // containerUri, - // slug, - // resource: { - // '@context': this.settings.context, - // type: OBJECT_TYPES.TOMBSTONE, - // deleted: new Date().toISOString() - // } - // }); - // } - // } }, methods: { isLocal(uri) { diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/reply.js b/src/middleware/packages/activitypub/services/activitypub/subservices/reply.js index d7f4f2f4d..5fef09d80 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/reply.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/reply.js @@ -39,6 +39,23 @@ const ReplyService = { if (object.replies) { await ctx.call('activitypub.collection.remove', { collectionUri: object.replies, item: replyUri }); } + }, + async removeFromAllRepliesCollections(ctx) { + const { objectUri } = ctx.params; + + await ctx.call('triplestore.update', { + query: ` + PREFIX as: + DELETE { + ?collection as:items <${objectUri}> . + } + WHERE { + ?collection as:items <${objectUri}> . + ?collection a as:Collection . + ?object as:replies ?collection . + } + ` + }); } }, activities: { @@ -78,40 +95,19 @@ const ReplyService = { } } }, - deleteReply: { - async match(ctx, activity) { - const dereferencedActivity = await matchActivity( - ctx, - { - type: ACTIVITY_TYPES.DELETE, - object: { - type: OBJECT_TYPES.NOTE - } - }, - activity - ); - // We have a match only if there is a inReplyTo predicate to the object - if (dereferencedActivity && dereferencedActivity.object.inReplyTo) { - return dereferencedActivity; - } else { - return false; + deleteNote: { + match: { + type: ACTIVITY_TYPES.DELETE, + object: { + type: OBJECT_TYPES.TOMBSTONE, + formerType: 'as:Note' // JSON-LD doesn't remove prefixes for subjects } }, async onEmit(ctx, activity) { - if (this.isLocalObject(activity.object.inReplyTo)) { - await this.actions.removeReply( - { objectUri: activity.object.inReplyTo, replyUri: activity.object.id }, - { parentCtx: ctx } - ); - } + await this.actions.removeFromAllRepliesCollections({ objectUri: activity.object.id }, { parentCtx: ctx }); }, async onReceive(ctx, activity) { - if (this.isLocalObject(activity.object.inReplyTo)) { - await this.actions.removeReply( - { objectUri: activity.object.inReplyTo, replyUri: activity.object.id }, - { parentCtx: ctx } - ); - } + await this.actions.removeFromAllRepliesCollections({ objectUri: activity.object.id }, { parentCtx: ctx }); } } }, diff --git a/src/middleware/packages/ldp/package.json b/src/middleware/packages/ldp/package.json index c6abe41aa..621e32a77 100644 --- a/src/middleware/packages/ldp/package.json +++ b/src/middleware/packages/ldp/package.json @@ -16,6 +16,7 @@ "moleculer": "^0.14.17", "moleculer-db": "^0.8.16", "moleculer-schedule": "^0.2.3", + "moleculer-web": "^0.10.0-beta1", "node-fetch": "^2.6.6", "path-to-regexp": "^6.2.0", "rdf-parse": "^1.7.0", diff --git a/src/middleware/packages/ldp/services/remote/actions/getNetwork.js b/src/middleware/packages/ldp/services/remote/actions/getNetwork.js index 81b7630b9..2127604d2 100644 --- a/src/middleware/packages/ldp/services/remote/actions/getNetwork.js +++ b/src/middleware/packages/ldp/services/remote/actions/getNetwork.js @@ -37,9 +37,11 @@ module.exports = { if (response.ok) { if (accept === MIME_TYPES.JSON) { return await response.json(); + } else { + return await response.text(); } - return await response.text(); + } else { + throw new MoleculerError(response.statusText, response.status); } - throw new MoleculerError(response.statusText, response.status); } }; diff --git a/src/middleware/packages/ldp/services/remote/actions/store.js b/src/middleware/packages/ldp/services/remote/actions/store.js index d2e2d83d0..53f926fc2 100644 --- a/src/middleware/packages/ldp/services/remote/actions/store.js +++ b/src/middleware/packages/ldp/services/remote/actions/store.js @@ -1,4 +1,6 @@ const { MIME_TYPES } = require('@semapps/mime-types'); +const { Errors: E } = require('moleculer-web'); +const { hasType } = require('../../../utils'); module.exports = { visibility: 'public', @@ -26,6 +28,11 @@ module.exports = { resource = await this.actions.getNetwork({ resourceUri, webId }, { parentCtx: ctx }); } + // Do not store Tombstone (throw 404 error) + if (hasType(resource, 'Tombstone')) { + throw new E.NotFoundError(); + } + if (!resourceUri) { resourceUri = resource.id || resource['@id']; } diff --git a/src/middleware/packages/webacl/middlewares/webacl.js b/src/middleware/packages/webacl/middlewares/webacl.js index b5e71dfb7..ca618b1a5 100644 --- a/src/middleware/packages/webacl/middlewares/webacl.js +++ b/src/middleware/packages/webacl/middlewares/webacl.js @@ -9,6 +9,7 @@ const modifyActions = [ 'ldp.container.create', 'activitypub.activity.create', 'activitypub.activity.attach', + 'activitypub.object.createTombstone', 'webid.create', 'ldp.remote.store', 'ldp.remote.delete', @@ -231,6 +232,19 @@ const WebAclMiddleware = ({ baseUrl, podProvider = false, graphName = 'http://se await addRightsToNewUser(ctx, actionReturnValue); break; + case 'activitypub.object.createTombstone': + // Tombstones should be public + await ctx.call('webacl.resource.addRights', { + resourceUri: ctx.params.resourceUri, + additionalRights: { + anon: { + read: true + } + }, + webId: 'system' + }); + break; + case 'ldp.remote.store': { const resourceUri = ctx.params.resourceUri || ctx.params.resource.id || ctx.params.resource['@id']; // When a remote resource is stored in the default graph, give read permission to the logged user diff --git a/src/middleware/tests/activitypub/message.test.js b/src/middleware/tests/activitypub/message.test.js index 6101b2074..7e583c2a1 100644 --- a/src/middleware/tests/activitypub/message.test.js +++ b/src/middleware/tests/activitypub/message.test.js @@ -133,7 +133,11 @@ describe.each(['single-server', 'multi-server'])('In mode %s, exchange messages' resourceUri: bobMessageUri, accept: MIME_TYPES.JSON }) - ).rejects.toThrow(); + ).resolves.toMatchObject({ + type: OBJECT_TYPES.TOMBSTONE, + formerType: 'as:Note', + deleted: expect.anything() + }); }); await waitForExpect(async () => { diff --git a/src/middleware/tests/activitypub/object.test.js b/src/middleware/tests/activitypub/object.test.js index eb7b5cd30..f807820f2 100644 --- a/src/middleware/tests/activitypub/object.test.js +++ b/src/middleware/tests/activitypub/object.test.js @@ -127,7 +127,11 @@ describe('Create/Update/Delete objects', () => { resourceUri: objectUri, accept: MIME_TYPES.JSON }) - ).rejects.toThrow(`Cannot get permissions of non-existing container or resource ${objectUri}`); + ).resolves.toMatchObject({ + type: OBJECT_TYPES.TOMBSTONE, + formerType: 'as:Article', + deleted: expect.anything() + }); }); }); }); diff --git a/src/middleware/tests/package.json b/src/middleware/tests/package.json index 1ac7206b5..f66702972 100644 --- a/src/middleware/tests/package.json +++ b/src/middleware/tests/package.json @@ -3,7 +3,7 @@ "private": true, "description": "SemApps integration tests", "scripts": { - "test": "jest --detectOpenHandles --verbose true --silent false activitypub/message" + "test": "jest --detectOpenHandles --verbose true --silent false" }, "author": "SemApps Team", "license": "Apache-2.0", From 1b52d73d8701c090e09085f4f621740f1df78c05 Mon Sep 17 00:00:00 2001 From: srosset81 Date: Fri, 5 Apr 2024 16:46:32 +0200 Subject: [PATCH 10/20] Refactor LikeService --- .../activitypub/services/activitypub/index.js | 4 +- .../services/activitypub/subservices/like.js | 127 +++++++-------- src/middleware/tests/activitypub/like.test.js | 150 ++++++++++++++++++ website/docs/middleware/activitypub/index.md | 21 +-- 4 files changed, 224 insertions(+), 78 deletions(-) create mode 100644 src/middleware/tests/activitypub/like.test.js diff --git a/src/middleware/packages/activitypub/services/activitypub/index.js b/src/middleware/packages/activitypub/services/activitypub/index.js index 9a7e2f5f1..bf962f9c4 100644 --- a/src/middleware/packages/activitypub/services/activitypub/index.js +++ b/src/middleware/packages/activitypub/services/activitypub/index.js @@ -12,7 +12,7 @@ const ObjectService = require('./subservices/object'); const OutboxService = require('./subservices/outbox'); const RegistryService = require('./subservices/registry'); const ReplyService = require('./subservices/reply'); -const { OBJECT_TYPES, ACTOR_TYPES } = require('../../constants'); +const { ACTOR_TYPES } = require('../../constants'); const ActivityPubService = { name: 'activitypub', @@ -24,7 +24,6 @@ const ActivityPubService = { selectActorData: null, queueServiceUrl: null, like: { - attachToObjectTypes: null, attachToActorTypes: null }, follow: { @@ -97,7 +96,6 @@ const ActivityPubService = { this.broker.createService(LikeService, { settings: { baseUri, - attachToObjectTypes: like.attachToObjectTypes || Object.values(OBJECT_TYPES), attachToActorTypes: like.attachToActorTypes || Object.values(ACTOR_TYPES) } }); diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/like.js b/src/middleware/packages/activitypub/services/activitypub/subservices/like.js index a3353a4f1..9f7761ff3 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/like.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/like.js @@ -1,7 +1,6 @@ const ActivitiesHandlerMixin = require('../../../mixins/activities-handler'); const { ACTIVITY_TYPES } = require('../../../constants'); const { collectionPermissionsWithAnonRead } = require('../../../utils'); -const matchActivity = require('../../../utils/matchActivity'); const LikeService = { name: 'activitypub.like', @@ -9,11 +8,17 @@ const LikeService = { settings: { baseUri: null, attachToActorTypes: null, - attachToObjectTypes: null + likesCollectionOptions: { + path: '/likes', + attachPredicate: 'https://www.w3.org/ns/activitystreams#likes', + ordered: false, + dereferenceItems: false, + permissions: collectionPermissionsWithAnonRead + } }, dependencies: ['activitypub.outbox', 'activitypub.collection'], async started() { - const { attachToActorTypes, attachToObjectTypes } = this.settings; + const { attachToActorTypes } = this.settings; await this.broker.call('activitypub.registry.register', { path: '/liked', @@ -23,35 +28,33 @@ const LikeService = { dereferenceItems: false, permissions: collectionPermissionsWithAnonRead }); - - await this.broker.call('activitypub.registry.register', { - path: '/likes', - attachToTypes: attachToObjectTypes, - attachPredicate: 'https://www.w3.org/ns/activitystreams#likes', - ordered: false, - dereferenceItems: false, - permissions: collectionPermissionsWithAnonRead - }); }, actions: { async addLike(ctx) { const { actorUri, objectUri } = ctx.params; - const actor = await ctx.call('activitypub.actor.get', { actorUri }); - const object = await ctx.call('activitypub.object.get', { objectUri, actorUri }); + if (this.isLocal(actorUri)) { + const actor = await ctx.call('activitypub.actor.get', { actorUri }); - // If a liked collection is attached to the actor, attach the object - if (actor.liked) { - await ctx.call('activitypub.collection.add', { - collectionUri: actor.liked, - item: objectUri - }); + // If a liked collection is attached to the actor, attach the object + if (actor.liked) { + await ctx.call('activitypub.collection.add', { + collectionUri: actor.liked, + item: objectUri + }); + } } - // If a likes collection is attached to the object, attach the actor - if (object.likes) { + if (this.isLocal(objectUri)) { + // Create the /likes collection and attach it to the object, unless it already exists + const likesCollectionUri = await ctx.call('activitypub.registry.createAndAttachCollection', { + objectUri, + collection: this.settings.likesCollectionOptions, + webId: 'system' + }); + await ctx.call('activitypub.collection.add', { - collectionUri: object.likes, + collectionUri: likesCollectionUri, item: actorUri }); } @@ -61,23 +64,27 @@ const LikeService = { async removeLike(ctx) { const { actorUri, objectUri } = ctx.params; - const actor = await ctx.call('activitypub.actor.get', { actorUri }); - const object = await ctx.call('activitypub.object.get', { objectUri, actorUri }); + if (this.isLocal(actorUri)) { + const actor = await ctx.call('activitypub.actor.get', { actorUri }); - // If a liked collection is attached to the actor, detach the object - if (actor.liked) { - await ctx.call('activitypub.collection.remove', { - collectionUri: actor.liked, - item: objectUri - }); + // If a liked collection is attached to the actor, detach the object + if (actor.liked) { + await ctx.call('activitypub.collection.remove', { + collectionUri: actor.liked, + item: objectUri + }); + } } - // If a likes collection is attached to the object, detach the actor - if (object.likes) { - await ctx.call('activitypub.collection.remove', { - collectionUri: object.likes, - item: actorUri - }); + if (this.isLocal(objectUri)) { + const object = await ctx.call('activitypub.object.get', { objectUri, actorUri }); + // If a likes collection is attached to the object, detach the actor + if (object.likes) { + await ctx.call('activitypub.collection.remove', { + collectionUri: object.likes, + item: actorUri + }); + } } ctx.emit('activitypub.like.removed', { actorUri, objectUri }, { meta: { webId: null, dataset: null } }); @@ -85,45 +92,41 @@ const LikeService = { }, activities: { likeObject: { - match(ctx, activity) { - return matchActivity( - ctx, - { - type: ACTIVITY_TYPES.LIKE, - object: { - type: this.settings.attachToObjectTypes - } - }, - activity - ); + match: { + type: ACTIVITY_TYPES.LIKE }, async onEmit(ctx, activity) { await this.actions.addLike({ actorUri: activity.actor, objectUri: activity.object }, { parentCtx: ctx }); + }, + async onReceive(ctx, activity) { + await this.actions.addLike({ actorUri: activity.actor, objectUri: activity.object }, { parentCtx: ctx }); } }, unlikeObject: { - match(ctx, activity) { - return matchActivity( - ctx, - { - type: ACTIVITY_TYPES.UNDO, - object: { - type: ACTIVITY_TYPES.LIKE, - object: { - type: this.settings.attachToObjectTypes - } - } - }, - activity - ); + match: { + type: ACTIVITY_TYPES.UNDO, + object: { + type: ACTIVITY_TYPES.LIKE + } }, async onEmit(ctx, activity) { await this.actions.removeLike( { actorUri: activity.actor, objectUri: activity.object.object }, { parentCtx: ctx } ); + }, + async onReceive(ctx, activity) { + await this.actions.removeLike( + { actorUri: activity.actor, objectUri: activity.object.object }, + { parentCtx: ctx } + ); } } + }, + methods: { + isLocal(uri) { + return uri.startsWith(this.settings.baseUri); + } } }; diff --git a/src/middleware/tests/activitypub/like.test.js b/src/middleware/tests/activitypub/like.test.js new file mode 100644 index 000000000..602348e1f --- /dev/null +++ b/src/middleware/tests/activitypub/like.test.js @@ -0,0 +1,150 @@ +const waitForExpect = require('wait-for-expect'); +const { OBJECT_TYPES, ACTIVITY_TYPES, PUBLIC_URI } = require('@semapps/activitypub'); +const { MIME_TYPES } = require('@semapps/mime-types'); +const initialize = require('./initialize'); + +jest.setTimeout(50000); + +const NUM_USERS = 2; + +describe.each(['single-server', 'multi-server'])('In mode %s, exchange likes', mode => { + let broker; + const actors = []; + let alice; + let bob; + let aliceMessageUri; + let bobMessageUri; + + beforeAll(async () => { + if (mode === 'single-server') { + broker = await initialize(3000, 'testData', 'settings'); + } else { + broker = []; + } + + for (let i = 1; i <= NUM_USERS; i++) { + if (mode === 'multi-server') { + broker[i] = await initialize(3000 + i, `testData${i}`, `settings${i}`); + } else { + broker[i] = broker; + } + const { webId } = await broker[i].call('auth.signup', require(`./data/actor${i}.json`)); + actors[i] = await broker[i].call('activitypub.actor.awaitCreateComplete', { actorUri: webId }); + actors[i].call = (actionName, params, options = {}) => + broker[i].call(actionName, params, { ...options, meta: { ...options.meta, webId } }); + } + + alice = actors[1]; + bob = actors[2]; + }); + + afterAll(async () => { + if (mode === 'multi-server') { + for (let i = 1; i <= NUM_USERS; i++) { + await broker[i].stop(); + } + } else { + await broker.stop(); + } + }); + + test('Bob likes Alice message', async () => { + const createActivity = await alice.call('activitypub.outbox.post', { + collectionUri: alice.outbox, + '@context': 'https://www.w3.org/ns/activitystreams', + type: OBJECT_TYPES.NOTE, + attributedTo: alice.id, + content: 'Hello world', + to: [bob.id, PUBLIC_URI] + }); + + aliceMessageUri = createActivity.object.id; + + await bob.call('activitypub.outbox.post', { + collectionUri: bob.outbox, + '@context': 'https://www.w3.org/ns/activitystreams', + type: ACTIVITY_TYPES.LIKE, + object: aliceMessageUri, + to: alice.id + }); + + // Ensure the /likes collection has been created + await waitForExpect(async () => { + await expect( + alice.call('ldp.resource.get', { + resourceUri: aliceMessageUri, + accept: MIME_TYPES.JSON + }) + ).resolves.toMatchObject({ + likes: `${aliceMessageUri}/likes` + }); + }); + + // Ensure Bob has been added to the /likes collection + await waitForExpect(async () => { + await expect( + alice.call('activitypub.collection.get', { + resourceUri: `${aliceMessageUri}/likes`, + accept: MIME_TYPES.JSON + }) + ).resolves.toMatchObject({ + type: 'Collection', + items: bob.id, + totalItems: 1 + }); + }); + + // Ensure the note has been added to Bob's /liked collection + await waitForExpect(async () => { + await expect( + bob.call('activitypub.collection.get', { + resourceUri: `${bob.id}/liked`, + accept: MIME_TYPES.JSON + }) + ).resolves.toMatchObject({ + type: 'Collection', + items: aliceMessageUri, + totalItems: 1 + }); + }); + }); + + test('Bob undo his like', async () => { + await bob.call('activitypub.outbox.post', { + collectionUri: bob.outbox, + '@context': 'https://www.w3.org/ns/activitystreams', + type: ACTIVITY_TYPES.UNDO, + object: { + type: ACTIVITY_TYPES.LIKE, + object: aliceMessageUri + }, + to: alice.id + }); + + // Ensure Bob has been removed from the /likes collection + await waitForExpect(async () => { + await expect( + alice.call('activitypub.collection.get', { + resourceUri: `${aliceMessageUri}/likes`, + accept: MIME_TYPES.JSON + }) + ).resolves.toMatchObject({ + type: 'Collection', + totalItems: 0 + }); + }); + + // Ensure the note has been added to Bob's /liked collection + await waitForExpect(async () => { + await expect( + bob.call('activitypub.collection.get', { + resourceUri: `${bob.id}/liked`, + accept: MIME_TYPES.JSON + }) + ).resolves.toMatchObject({ + type: 'Collection', + totalItems: 0 + }); + }); + }); +}); diff --git a/website/docs/middleware/activitypub/index.md b/website/docs/middleware/activitypub/index.md index c6e286206..a9b0e575b 100644 --- a/website/docs/middleware/activitypub/index.md +++ b/website/docs/middleware/activitypub/index.md @@ -57,14 +57,10 @@ module.exports = { baseUri: 'http://localhost:3000/', queueServiceUrl: null, like: { - attachToObjectTypes: null, attachToActorTypes: null }, follow: { attachToActorTypes: null - }, - reply: { - attachToObjectTypes: null } } }; @@ -120,15 +116,14 @@ Additionally, the ActivityPub services will append all the ActivityPub-specific ## Settings -| Property | Type | Default | Description | -| ---------------------------- | ---------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| `baseUri` | `String` | **required** | Base URI of your web server | -| `selectActorData` | `Function` | | Receives the data provided on signup (as JSON-LD), and must return the properties (with full URI) to be appended to the actor profile (see above). | -| `queueServiceUrl` | `String` | | Redis connection string. If set, the [Bull](https://github.com/OptimalBits/bull) task manager will be used to handle federation POSTs. | -| `like.attachToObjectTypes` | `Array` | All AS objects | The ActivityStreams objects which will be attached a `likes` collection | -| `like.attachToActorsTypes` | `Array` | All AS actors | The ActivityStreams actors which will be attached a `liked` collection | -| `follow.attachToActorsTypes` | `Array` | All AS actors | The ActivityStreams actors which will be attached a `followers` and `following` collections | -| `reply.attachToObjectTypes` | `Array` | All AS objects | The ActivityStreams objects which will be attached a `replies` collection | +| Property | Type | Default | Description | +| ---------------------------- | ---------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `baseUri` | `String` | **required** | Base URI of your web server | +| `selectActorData` | `Function` | | Receives the data provided on signup (as JSON-LD), and must return the properties (with full URI) to be appended to the actor profile (see above). | +| `queueServiceUrl` | `String` | | Redis connection string. If set, the [Bull](https://github.com/OptimalBits/bull) task manager will be used to handle federation POSTs. | +| `activateTombestones` | `Boolean` | true | If true, all deleted resources will be replaced with a [Tombstone](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tombstone) | +| `like.attachToActorsTypes` | `Array` | All AS actors | The ActivityStreams actors which will be attached a `liked` collection | +| `follow.attachToActorsTypes` | `Array` | All AS actors | The ActivityStreams actors which will be attached a `followers` and `following` collections | ## Events From 1cb800e08c674a836bd4eb4b3ce3309fad47f575 Mon Sep 17 00:00:00 2001 From: srosset81 Date: Fri, 5 Apr 2024 17:14:41 +0200 Subject: [PATCH 11/20] Update ActivityPub service docs --- website/docs/middleware/activitypub/index.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/website/docs/middleware/activitypub/index.md b/website/docs/middleware/activitypub/index.md index a9b0e575b..304df0b6a 100644 --- a/website/docs/middleware/activitypub/index.md +++ b/website/docs/middleware/activitypub/index.md @@ -8,7 +8,7 @@ This service allows you to create an ActivityPub server with data stored in a tr - Store activities, actors and objects in the triple store - Allow to create actors when new [WebIDs](../webid.md) are created -- Side effects are supported for `Create`, `Update`, `Delete`, `Follow`, `Like` +- Side effects are supported for `Create`, `Update`, `Delete`, `Follow`, `Like` activities, as well replies ## Dependencies @@ -20,20 +20,22 @@ This service allows you to create an ActivityPub server with data stored in a tr ## Sub-services - ActivityService +- ActivitiesWatcherService - ActorService +- ApiService - CollectionService - FollowService - InboxService - LikeService - ObjectService - OutboxService -- RelayService -- ReplyService - RegistryService +- ReplyService ## Other services - [ActivityMappingService](activity-mapping.md) +- RelayService ## Mixins @@ -56,6 +58,7 @@ module.exports = { settings: { baseUri: 'http://localhost:3000/', queueServiceUrl: null, + activateTombestones: true, like: { attachToActorTypes: null }, From 3882731e6a9af2b4fbc08e19945f5d79ff2c6daa Mon Sep 17 00:00:00 2001 From: srosset81 Date: Tue, 9 Apr 2024 16:13:15 +0200 Subject: [PATCH 12/20] Fixes fixes fixes --- .../activitypub/mixins/activities-handler.js | 2 + .../services/activitypub/subservices/api.js | 18 ++++--- .../activitypub/subservices/collection.js | 5 ++ .../services/activitypub/subservices/inbox.js | 18 +++++-- .../services/activitypub/subservices/like.js | 3 +- .../activitypub/subservices/object.js | 52 ++++++++++++++----- .../activitypub/subservices/registry.js | 11 ++-- src/middleware/packages/ldp/service.js | 4 ++ .../ldp/services/resource/actions/put.js | 6 ++- src/middleware/packages/ldp/utils.js | 17 +++++- .../packages/webacl/defaultRights.js | 25 +-------- .../packages/webacl/middlewares/webacl.js | 35 +++++-------- src/middleware/tests/ldp/container.test.js | 6 +-- 13 files changed, 116 insertions(+), 86 deletions(-) diff --git a/src/middleware/packages/activitypub/mixins/activities-handler.js b/src/middleware/packages/activitypub/mixins/activities-handler.js index 70d244542..aed01ef48 100644 --- a/src/middleware/packages/activitypub/mixins/activities-handler.js +++ b/src/middleware/packages/activitypub/mixins/activities-handler.js @@ -29,6 +29,8 @@ const ActivitiesHandlerMixin = { return; } + ctx.meta.webId = actorUri; + if (boxType === 'inbox' && activityHandler.onReceive) { await activityHandler.onReceive.bind(this)(ctx, dereferencedActivity, actorUri); } else if (boxType === 'outbox' && activityHandler.onEmit) { diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/api.js b/src/middleware/packages/activitypub/services/activitypub/subservices/api.js index dcbe3551a..67fa311d9 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/api.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/api.js @@ -19,16 +19,19 @@ const ApiService = { baseUri: null, podProvider: false }, - dependencies: ['api', 'ldp.registry'], + dependencies: ['api', 'ldp', 'ldp.registry'], async started() { + const resourcesWithContainerPath = await this.broker.call('ldp.getSetting', { key: 'resourcesWithContainerPath' }); if (this.settings.podProvider) { await this.broker.call('api.addRoute', { route: this.getBoxesRoute('/:username([^/.][^/]+)') }); + } else if (!resourcesWithContainerPath) { + await this.broker.call('api.addRoute', { route: this.getBoxesRoute(`/:actorSlug`) }); } else { // If some actor containers are already registered, add the corresponding API routes const registeredContainers = await this.broker.call('ldp.registry.list'); for (const container of Object.values(registeredContainers)) { if (arrayOf(container.acceptedTypes).some(type => Object.values(FULL_ACTOR_TYPES).includes(type))) { - await this.broker.call('api.addRoute', { route: this.getBoxesRoute(container.fullPath) }); + await this.broker.call('api.addRoute', { route: this.getBoxesRoute(`${container.fullPath}/:actorSlug`) }); } } } @@ -63,13 +66,16 @@ const ApiService = { }, events: { async 'ldp.registry.registered'(ctx) { - // TODO ensure that no events of this kind are sent before the service start, or routes may be missing const { container } = ctx.params; + const resourcesWithContainerPath = await this.broker.call('ldp.getSetting', { + key: 'resourcesWithContainerPath' + }); if ( !this.settings.podProvider && + resourcesWithContainerPath && arrayOf(container.acceptedTypes).some(type => Object.values(FULL_ACTOR_TYPES).includes(type)) ) { - await ctx.call('api.addRoute', { route: this.getBoxesRoute(container.fullPath) }); + await ctx.call('api.addRoute', { route: this.getBoxesRoute(`${container.fullPath}/:actorSlug`) }); } } }, @@ -96,8 +102,8 @@ const ApiService = { authorization: false, authentication: true, aliases: { - 'POST /:actorSlug/inbox': [...middlewares, 'activitypub.api.inbox'], - 'POST /:actorSlug/outbox': [...middlewares, 'activitypub.api.outbox'] + 'POST /inbox': [...middlewares, 'activitypub.api.inbox'], + 'POST /outbox': [...middlewares, 'activitypub.api.outbox'] } }; } diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/collection.js b/src/middleware/packages/activitypub/services/activitypub/subservices/collection.js index 8300ed71f..8a596238e 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/collection.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/collection.js @@ -33,6 +33,9 @@ const CollectionService = { default: return { + anon: { + read: true + }, user: { uri: webId, read: true, @@ -103,6 +106,8 @@ const CollectionService = { ctx.params.containerUri = await this.actions.getContainerUri({ webId: ctx.params.webId }, { parentCtx: ctx }); } + await this.actions.waitForContainerCreation({ containerUri: ctx.params.containerUri }); + const ordered = arrayOf(ctx.params.resource.type).includes('OrderedCollection'); // TODO Use ShEx to check collection validity diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/inbox.js b/src/middleware/packages/activitypub/services/activitypub/subservices/inbox.js index 0e8f91ac4..33ac0f7fc 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/inbox.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/inbox.js @@ -42,12 +42,16 @@ const InboxService = { } // We want the next operations to be done by the system + // TODO check if we can avoid this, as this is a bad practice ctx.meta.webId = 'system'; // Remember inbox owner (used by WebACL middleware) const actorUri = await ctx.call('activitypub.collection.getOwner', { collectionUri, collectionKey: 'inbox' }); - const collectionExists = await ctx.call('activitypub.collection.exist', { resourceUri: collectionUri }); + const collectionExists = await ctx.call('activitypub.collection.exist', { + resourceUri: collectionUri, + webId: 'system' + }); if (!collectionExists) { throw new E.NotFoundError(); } @@ -100,10 +104,14 @@ const InboxService = { // If the activity cannot be retrieved, pass the full object // This will be used in particular for Solid notifications // which will send the full activity to the listeners - await ctx.emit('activitypub.collection.added', { - collectionUri, - item: activity - }); + ctx.emit( + 'activitypub.collection.added', + { + collectionUri, + item: activity + }, + { meta: { webId: null, dataset: null } } + ); } ctx.emit( diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/like.js b/src/middleware/packages/activitypub/services/activitypub/subservices/like.js index 9f7761ff3..5c9355ae0 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/like.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/like.js @@ -49,8 +49,7 @@ const LikeService = { // Create the /likes collection and attach it to the object, unless it already exists const likesCollectionUri = await ctx.call('activitypub.registry.createAndAttachCollection', { objectUri, - collection: this.settings.likesCollectionOptions, - webId: 'system' + collection: this.settings.likesCollectionOptions }); await ctx.call('activitypub.collection.add', { diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/object.js b/src/middleware/packages/activitypub/services/activitypub/subservices/object.js index 80c9777f6..0a3a92dc3 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/object.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/object.js @@ -64,12 +64,20 @@ const ObjectService = { const containerUri = await ctx.call('ldp.registry.getUri', { path: container.path, webId: actorUri }); - objectUri = await ctx.call(container.controlledActions?.post || 'ldp.container.post', { - containerUri, - resource: activity.object, - contentType: MIME_TYPES.JSON, - webId: actorUri - }); + objectUri = await ctx.call( + container.controlledActions?.post || 'ldp.container.post', + { + containerUri, + resource: activity.object, + contentType: MIME_TYPES.JSON, + webId: actorUri + }, + { + meta: { + skipObjectsWatcher: true // We don't want to trigger another Create action + } + } + ); break; } @@ -81,11 +89,19 @@ const ObjectService = { const { controlledActions } = await ctx.call('ldp.registry.getByUri', { resourceUri: objectUri }); - await ctx.call(controlledActions?.put || 'ldp.resource.put', { - resource: activity.object, - contentType: MIME_TYPES.JSON, - webId: actorUri - }); + await ctx.call( + controlledActions?.put || 'ldp.resource.put', + { + resource: activity.object, + contentType: MIME_TYPES.JSON, + webId: actorUri + }, + { + meta: { + skipObjectsWatcher: true // We don't want to trigger another Create action + } + } + ); break; } @@ -97,7 +113,15 @@ const ObjectService = { if (await ctx.call('ldp.resource.exist', { resourceUri, webId: actorUri })) { const { controlledActions } = await ctx.call('ldp.registry.getByUri', { resourceUri }); - await ctx.call(controlledActions?.delete || 'ldp.resource.delete', { resourceUri, webId: actorUri }); + await ctx.call( + controlledActions?.delete || 'ldp.resource.delete', + { resourceUri, webId: actorUri }, + { + meta: { + skipObjectsWatcher: true // We don't want to trigger another Create action + } + } + ); } } else { this.logger.warn('Cannot delete object as it is undefined'); @@ -146,12 +170,12 @@ const ObjectService = { events: { async 'ldp.resource.deleted'(ctx) { if (this.settings.activateTombstones) { - const { resourceUri, oldData } = ctx.params; + const { resourceUri, oldData, dataset } = ctx.params; const formerType = oldData.type || oldData['@type']; // Do not create Tombstones for actors or collections if (![...Object.values(ACTOR_TYPES), 'Collection', 'OrderedCollection'].includes(formerType)) { - await this.actions.createTombstone({ resourceUri, formerType }, { parentCtx: ctx }); + await this.actions.createTombstone({ resourceUri, formerType }, { meta: { dataset }, parentCtx: ctx }); } } } diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/registry.js b/src/middleware/packages/activitypub/services/activitypub/subservices/registry.js index f15203e44..40a62a237 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/registry.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/registry.js @@ -3,6 +3,7 @@ const { quad, namedNode } = require('@rdfjs/data-model'); const { MIME_TYPES } = require('@semapps/mime-types'); const { defaultToArray } = require('../../../utils'); const { ACTOR_TYPES, FULL_ACTOR_TYPES, AS_PREFIX } = require('../../../constants'); +const { getWebIdFromUri } = require('@semapps/ldp'); const RegistryService = { name: 'activitypub.registry', @@ -30,7 +31,7 @@ const RegistryService = { return this.registeredCollections; }, async createAndAttachCollection(ctx) { - const { objectUri, collection, webId } = ctx.params; + const { objectUri, collection } = ctx.params; const { path, attachPredicate, ordered, summary, dereferenceItems, itemsPerPage, sortPredicate, sortOrder } = collection || {}; const collectionUri = urlJoin(objectUri, path); @@ -53,12 +54,12 @@ const RegistryService = { 'semapps:sortOrder': sortOrder }, contentType: MIME_TYPES.JSON, - webId: 'system' + webId: this.settings.podProvider ? getWebIdFromUri(objectUri) : 'system' }, { meta: { // Bypass the automatic URI generation - forcedResourceUri: path ? urlJoin(objectUri, path) : undefined + forcedResourceUri: path ? collectionUri : undefined } } ); @@ -69,7 +70,7 @@ const RegistryService = { { resourceUri: objectUri, triplesToAdd: [quad(namedNode(objectUri), namedNode(attachPredicate), namedNode(collectionUri))], - webId + webId: 'system' }, { meta: { @@ -98,7 +99,7 @@ const RegistryService = { for (const collection of this.registeredCollections) { this.logger.info(`Looking for containers with types: ${JSON.stringify(collection.attachToTypes)}`); - const datasets = this.settings.podProvider ? await this.broker.call('pod.list') : ['*']; + const datasets = this.settings.podProvider ? await this.broker.call('pod.list') : [undefined]; for (let dataset of datasets) { // Find all containers where we want to attach this collection const containers = await ctx.call('ldp.registry.getByType', { type: collection.attachToTypes, dataset }); diff --git a/src/middleware/packages/ldp/service.js b/src/middleware/packages/ldp/service.js index 63e905f1c..0105ffff4 100644 --- a/src/middleware/packages/ldp/service.js +++ b/src/middleware/packages/ldp/service.js @@ -95,6 +95,10 @@ module.exports = { actions: { getBaseUrl() { return this.settings.baseUrl; + }, + getSetting(ctx) { + const { key } = ctx.params; + return this.settings[key]; } } }; diff --git a/src/middleware/packages/ldp/services/resource/actions/put.js b/src/middleware/packages/ldp/services/resource/actions/put.js index dcaef3a92..ec7fbff1f 100644 --- a/src/middleware/packages/ldp/services/resource/actions/put.js +++ b/src/middleware/packages/ldp/services/resource/actions/put.js @@ -28,7 +28,11 @@ module.exports = { const resourceUri = resource.id || resource['@id']; if (this.isRemoteUri(resourceUri, ctx.meta.dataset)) - throw new MoleculerError('Remote resources cannot be modified', 403, 'FORBIDDEN'); + throw new MoleculerError( + `Remote resource ${resourceUri} cannot be modified (dataset: ${ctx.meta.dataset})`, + 403, + 'FORBIDDEN' + ); // Save the current data, to be able to send it through the event // If the resource does not exist, it will throw a 404 error diff --git a/src/middleware/packages/ldp/utils.js b/src/middleware/packages/ldp/utils.js index 5a6309c7e..af24a1b66 100644 --- a/src/middleware/packages/ldp/utils.js +++ b/src/middleware/packages/ldp/utils.js @@ -82,7 +82,7 @@ const getPathFromUri = uri => { } }; -// Transforms "http://localhost:3000/dataset/data" to "dataset" +// Transforms "http://localhost:3000/alice/data" to "alice" const getDatasetFromUri = uri => { const path = getPathFromUri(uri); if (path) { @@ -93,6 +93,20 @@ const getDatasetFromUri = uri => { } }; +// Transforms "http://localhost:3000/alice/data" to "http://localhost:3000/alice" +const getWebIdFromUri = uri => { + const path = getPathFromUri(uri); + if (path) { + const parts = path.split('/'); + if (parts.length > 1) { + const urlObject = new URL(uri); + return `${urlObject.origin}/${parts[1]}`; + } + } else { + throw new Error(`${uri} is not a valid URL`); + } +}; + const hasType = (resource, type) => { const resourceType = resource.type || resource['@type']; return Array.isArray(resourceType) ? resourceType.includes(type) : resourceType === type; @@ -161,6 +175,7 @@ module.exports = { getParentContainerUri, getParentContainerPath, getDatasetFromUri, + getWebIdFromUri, hasType, isContainer, defaultToArray, diff --git a/src/middleware/packages/webacl/defaultRights.js b/src/middleware/packages/webacl/defaultRights.js index 02f00149d..95836d67d 100644 --- a/src/middleware/packages/webacl/defaultRights.js +++ b/src/middleware/packages/webacl/defaultRights.js @@ -31,29 +31,6 @@ const defaultContainerRights = webId => { } }; -const defaultCollectionRights = webId => { - switch (webId) { - case 'anon': - case 'system': - return { - anon: { - read: true - } - }; - - default: - return { - user: { - uri: webId, - read: true, - write: true, - control: true - } - }; - } -}; - module.exports = { - defaultContainerRights, - defaultCollectionRights + defaultContainerRights }; diff --git a/src/middleware/packages/webacl/middlewares/webacl.js b/src/middleware/packages/webacl/middlewares/webacl.js index ca618b1a5..b4fc5ce7b 100644 --- a/src/middleware/packages/webacl/middlewares/webacl.js +++ b/src/middleware/packages/webacl/middlewares/webacl.js @@ -189,18 +189,12 @@ const WebAclMiddleware = ({ baseUrl, podProvider = false, graphName = 'http://se // Remove the permissions which were added just before switch (action.name) { case 'ldp.resource.create': - await ctx.call( - 'webacl.resource.deleteAllRights', - { resourceUri: ctx.params.resource['@id'] || ctx.params.resource.id }, - { meta: { webId: 'system' } } - ); + await ctx.call('webacl.resource.deleteAllRights', { + resourceUri: ctx.params.resource['@id'] || ctx.params.resource.id + }); break; case 'ldp.container.create': - await ctx.call( - 'webacl.resource.deleteAllRights', - { resourceUri: ctx.params.containerUri }, - { meta: { webId: 'system' } } - ); + await ctx.call('webacl.resource.deleteAllRights', { resourceUri: ctx.params.containerUri }); break; default: break; @@ -213,19 +207,11 @@ const WebAclMiddleware = ({ baseUrl, podProvider = false, graphName = 'http://se */ switch (action.name) { case 'ldp.resource.delete': - await ctx.call( - 'webacl.resource.deleteAllRights', - { resourceUri: ctx.params.resourceUri }, - { meta: { webId: 'system' } } - ); + await ctx.call('webacl.resource.deleteAllRights', { resourceUri: ctx.params.resourceUri }); break; case 'ldp.remote.delete': - await ctx.call( - 'webacl.resource.deleteAllRights', - { resourceUri: ctx.params.resourceUri }, - { meta: { webId: 'system' } } - ); + await ctx.call('webacl.resource.deleteAllRights', { resourceUri: ctx.params.resourceUri }); break; case 'webid.create': @@ -278,7 +264,8 @@ const WebAclMiddleware = ({ baseUrl, podProvider = false, graphName = 'http://se const recipients = await ctx.call('activitypub.activity.getRecipients', { activity }); // When a new activity is created, ensure the emitter has read rights also - if (action.name === 'activitypub.activity.create') { + // Don't do that on podProvider config, because the Pod owner already has all rights + if (action.name === 'activitypub.activity.create' && !podProvider) { if (!recipients.includes(activity.actor)) recipients.push(activity.actor); } @@ -296,7 +283,8 @@ const WebAclMiddleware = ({ baseUrl, podProvider = false, graphName = 'http://se }, webId: 'system' }); - // If this is a Create activity, also give rights to the object + + // If this is a Create activity, also give rights to the created object if (action.name === 'activitypub.activity.create' && hasType(activity, ACTIVITY_TYPES.CREATE)) { await ctx.call('webacl.resource.addRights', { resourceUri: typeof activity.object === 'string' ? activity.object : activity.object.id, @@ -322,7 +310,8 @@ const WebAclMiddleware = ({ baseUrl, podProvider = false, graphName = 'http://se }, webId: 'system' }); - // If this is a Create activity, also give rights to the object + + // If this is a Create activity, also give rights to the created object if (action.name === 'activitypub.activity.create' && hasType(activity, ACTIVITY_TYPES.CREATE)) { await ctx.call('webacl.resource.addRights', { resourceUri: typeof activity.object === 'string' ? activity.object : activity.object.id, diff --git a/src/middleware/tests/ldp/container.test.js b/src/middleware/tests/ldp/container.test.js index 723fba098..3c39f5ac3 100644 --- a/src/middleware/tests/ldp/container.test.js +++ b/src/middleware/tests/ldp/container.test.js @@ -27,11 +27,7 @@ describe('LDP container tests', () => { false ); - await broker.call( - 'ldp.container.create', - { containerUri: `${CONFIG.HOME_URL}objects` }, - { meta: { webId: 'system' } } - ); + await broker.call('ldp.container.create', { containerUri: `${CONFIG.HOME_URL}objects`, webId: 'system' }); await expect(broker.call('ldp.container.exist', { containerUri: `${CONFIG.HOME_URL}objects` })).resolves.toBe(true); From cfa60e5212b1303be21edadfdae2e78fe221044a Mon Sep 17 00:00:00 2001 From: srosset81 Date: Wed, 10 Apr 2024 11:58:05 +0200 Subject: [PATCH 13/20] Fix registry --- .../activitypub/subservices/registry.js | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/registry.js b/src/middleware/packages/activitypub/services/activitypub/subservices/registry.js index 40a62a237..6233d4fe5 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/registry.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/registry.js @@ -189,21 +189,23 @@ const RegistryService = { }, async 'ldp.resource.patched'(ctx) { const { resourceUri, triplesAdded, webId } = ctx.params; - for (const triple of triplesAdded) { - if (triple.predicate.value === 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type') { - const collections = this.getCollectionsByType(triple.object.value); - for (const collection of collections) { - if (this.isActor(triple.object.value)) { - // If the resource is an actor, use the resource URI as the webId - await this.actions.createAndAttachCollection( - { objectUri: resourceUri, collection, webId: resourceUri }, - { parentCtx: ctx } - ); - } else { - await this.actions.createAndAttachCollection( - { objectUri: resourceUri, collection, webId }, - { parentCtx: ctx } - ); + if (triplesAdded) { + for (const triple of triplesAdded) { + if (triple.predicate.value === 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type') { + const collections = this.getCollectionsByType(triple.object.value); + for (const collection of collections) { + if (this.isActor(triple.object.value)) { + // If the resource is an actor, use the resource URI as the webId + await this.actions.createAndAttachCollection( + { objectUri: resourceUri, collection, webId: resourceUri }, + { parentCtx: ctx } + ); + } else { + await this.actions.createAndAttachCollection( + { objectUri: resourceUri, collection, webId }, + { parentCtx: ctx } + ); + } } } } From a1fccf2a4febe08fcf697249fa2aecb62d3a4b64 Mon Sep 17 00:00:00 2001 From: srosset81 Date: Mon, 15 Apr 2024 15:19:30 +0200 Subject: [PATCH 14/20] Use default context for expandPredicate and throw error if expanding didn't work --- .../packages/jsonld/services/parser/index.js | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/middleware/packages/jsonld/services/parser/index.js b/src/middleware/packages/jsonld/services/parser/index.js index 96cfc4dd7..ee0fc50f4 100644 --- a/src/middleware/packages/jsonld/services/parser/index.js +++ b/src/middleware/packages/jsonld/services/parser/index.js @@ -66,10 +66,25 @@ module.exports = { }, // TODO move to ontologies service ?? async expandPredicate(ctx) { - const { predicate, context } = ctx.params; + let { predicate, context } = ctx.params; + if (isURL(predicate)) return predicate; + + // If no context is provided, use default context + if (!context) context = await ctx.call('jsonld.context.get'); + const result = await this.actions.expand({ input: { '@context': context, [predicate]: '' } }, { parentCtx: ctx }); - return Object.keys(result[0])[0]; + + const expandedPredicate = Object.keys(result[0])?.[0]; + + if (!isURL(expandedPredicate)) { + throw new Error(` + Could not expand predicate (${expandedPredicate}). + Is an ontology missing or not registered yet on the local context ? + `); + } + + return expandedPredicate; }, async expandTypes(ctx) { let { types, context } = ctx.params; From 9390aa062faaaae9cc130bef43ca927627aa86c0 Mon Sep 17 00:00:00 2001 From: srosset81 Date: Mon, 15 Apr 2024 17:45:16 +0200 Subject: [PATCH 15/20] Param check on expandPredicate and expandTypes --- src/middleware/packages/jsonld/services/parser/index.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/middleware/packages/jsonld/services/parser/index.js b/src/middleware/packages/jsonld/services/parser/index.js index ee0fc50f4..39dd6d0cf 100644 --- a/src/middleware/packages/jsonld/services/parser/index.js +++ b/src/middleware/packages/jsonld/services/parser/index.js @@ -3,8 +3,6 @@ const { JsonLdParser } = require('jsonld-streaming-parser'); const streamifyString = require('streamify-string'); const { arrayOf, isURL } = require('../../utils'); -const delay = t => new Promise(resolve => setTimeout(resolve, t)); - module.exports = { name: 'jsonld.parser', dependencies: ['jsonld.document-loader'], @@ -68,6 +66,8 @@ module.exports = { async expandPredicate(ctx) { let { predicate, context } = ctx.params; + if (!predicate) throw new Error('No predicate param provided to expandPredicate action'); + if (isURL(predicate)) return predicate; // If no context is provided, use default context @@ -89,6 +89,8 @@ module.exports = { async expandTypes(ctx) { let { types, context } = ctx.params; + if (!types) throw new Error('No types param provided to expandTypes action'); + // If types are already full URIs, return them immediately if (arrayOf(types).every(type => isURL(type))) return types; From aff8f6838f9803ed518d7cd5efb57877e911a1d1 Mon Sep 17 00:00:00 2001 From: srosset81 Date: Tue, 16 Apr 2024 20:43:22 +0200 Subject: [PATCH 16/20] ObjectsWatcherMiddleware: register excludedContainers instead of watchedContainers --- .../sync/middlewares/objects-watcher.js | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/middleware/packages/sync/middlewares/objects-watcher.js b/src/middleware/packages/sync/middlewares/objects-watcher.js index 2c8431364..36a9145b5 100644 --- a/src/middleware/packages/sync/middlewares/objects-watcher.js +++ b/src/middleware/packages/sync/middlewares/objects-watcher.js @@ -15,7 +15,7 @@ const handledActions = [ const ObjectsWatcherMiddleware = (config = {}) => { const { baseUrl, podProvider = false, postWithoutRecipients = false } = config; let relayActor; - let watchedContainers = []; + let excludedContainersPathRegex = []; let initialized = false; let cacherActivated = false; @@ -53,9 +53,9 @@ const ObjectsWatcherMiddleware = (config = {}) => { return recipients; }; - const isWatched = containersUris => { + const isExcluded = containersUris => { return containersUris.some(uri => - watchedContainers.some(container => container.pathRegex.test(new URL(uri).pathname)) + excludedContainersPathRegex.some(pathRegex => pathRegex.test(new URL(uri).pathname)) ); }; @@ -94,7 +94,11 @@ const ObjectsWatcherMiddleware = (config = {}) => { } const containers = await broker.call('ldp.registry.list'); - watchedContainers = Object.values(containers).filter(c => !c.excludeFromMirror); + for (const container of Object.values(containers)) { + if (container.excludeFromMirror === true && !excludedContainersPathRegex.includes(container.pathRegex)) { + excludedContainersPathRegex.push(container.pathRegex); + } + } initialized = true; cacherActivated = !!broker.cacher; @@ -156,7 +160,7 @@ const ObjectsWatcherMiddleware = (config = {}) => { const containers = containerUri ? [containerUri] : await ctx.call('ldp.resource.getContainers', { resourceUri }); - if (!isWatched(containers)) return await next(ctx); + if (isExcluded(containers)) return await next(ctx); /* * BEFORE HOOKS @@ -342,7 +346,9 @@ const ObjectsWatcherMiddleware = (config = {}) => { if (event.name === 'ldp.registry.registered') { return async ctx => { const { container } = ctx.params; - if (!container.excludeFromMirror) watchedContainers.push(container); + if (container.excludeFromMirror === true && !excludedContainersPathRegex.includes(container.pathRegex)) { + excludedContainersPathRegex.push(container.pathRegex); + } return next(ctx); }; } From 3a27bc709d3d4870bf4a7603f1a2f92c0f89a31e Mon Sep 17 00:00:00 2001 From: srosset81 Date: Wed, 17 Apr 2024 18:03:59 +0200 Subject: [PATCH 17/20] Migration tool --- .../activitypub/services/activitypub/index.js | 19 +++-- .../{registry.js => collections-registry.js} | 72 +++++++++++++++++-- .../activitypub/subservices/follow.js | 36 ++++++---- .../services/activitypub/subservices/inbox.js | 21 ++++-- .../services/activitypub/subservices/like.js | 29 +++++--- .../activitypub/subservices/outbox.js | 21 ++++-- .../services/activitypub/subservices/reply.js | 7 +- website/docs/middleware/activitypub/index.md | 24 +++---- 8 files changed, 163 insertions(+), 66 deletions(-) rename src/middleware/packages/activitypub/services/activitypub/subservices/{registry.js => collections-registry.js} (73%) diff --git a/src/middleware/packages/activitypub/services/activitypub/index.js b/src/middleware/packages/activitypub/services/activitypub/index.js index bf962f9c4..dd80826e1 100644 --- a/src/middleware/packages/activitypub/services/activitypub/index.js +++ b/src/middleware/packages/activitypub/services/activitypub/index.js @@ -10,7 +10,7 @@ const InboxService = require('./subservices/inbox'); const LikeService = require('./subservices/like'); const ObjectService = require('./subservices/object'); const OutboxService = require('./subservices/outbox'); -const RegistryService = require('./subservices/registry'); +const CollectionsRegistryService = require('./subservices/collections-registry'); const ReplyService = require('./subservices/reply'); const { ACTOR_TYPES } = require('../../constants'); @@ -43,7 +43,7 @@ const ActivityPubService = { } }); - this.broker.createService(RegistryService, { + this.broker.createService(CollectionsRegistryService, { settings: { baseUri, podProvider @@ -82,8 +82,7 @@ const ActivityPubService = { this.broker.createService(FollowService, { settings: { - baseUri, - attachToActorTypes: follow.attachToActorTypes || Object.values(ACTOR_TYPES) + baseUri } }); @@ -95,8 +94,7 @@ const ActivityPubService = { this.broker.createService(LikeService, { settings: { - baseUri, - attachToActorTypes: like.attachToActorTypes || Object.values(ACTOR_TYPES) + baseUri } }); @@ -123,6 +121,15 @@ const ActivityPubService = { ...sec, overwrite: true }); + }, + actions: { + async updateCollectionsOptions(ctx) { + await ctx.call('activitypub.follow.updateCollectionsOptions'); + await ctx.call('activitypub.inbox.updateCollectionsOptions'); + await ctx.call('activitypub.outbox.updateCollectionsOptions'); + await ctx.call('activitypub.like.updateCollectionsOptions'); + await ctx.call('activitypub.reply.updateCollectionsOptions'); + } } }; diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/registry.js b/src/middleware/packages/activitypub/services/activitypub/subservices/collections-registry.js similarity index 73% rename from src/middleware/packages/activitypub/services/activitypub/subservices/registry.js rename to src/middleware/packages/activitypub/services/activitypub/subservices/collections-registry.js index 6233d4fe5..b0abcb849 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/registry.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/collections-registry.js @@ -1,12 +1,12 @@ const urlJoin = require('url-join'); const { quad, namedNode } = require('@rdfjs/data-model'); const { MIME_TYPES } = require('@semapps/mime-types'); +const { getWebIdFromUri } = require('@semapps/ldp'); const { defaultToArray } = require('../../../utils'); const { ACTOR_TYPES, FULL_ACTOR_TYPES, AS_PREFIX } = require('../../../constants'); -const { getWebIdFromUri } = require('@semapps/ldp'); -const RegistryService = { - name: 'activitypub.registry', +const CollectionsRegistryService = { + name: 'activitypub.collections-registry', settings: { baseUri: null, podProvider: false @@ -120,6 +120,70 @@ const RegistryService = { } } } + }, + async updateCollectionsOptions(ctx) { + const { collection } = ctx.params; + let { attachPredicate, ordered, summary, dereferenceItems, itemsPerPage, sortPredicate, sortOrder } = + collection || {}; + + attachPredicate = await ctx.call('jsonld.parser.expandPredicate', { predicate: attachPredicate }); + sortPredicate = sortPredicate && (await ctx.call('jsonld.parser.expandPredicate', { predicate: sortPredicate })); + sortOrder = sortOrder && (await ctx.call('jsonld.parser.expandPredicate', { predicate: sortOrder })); + + const datasets = this.settings.podProvider ? await this.broker.call('pod.list') : [undefined]; + + for (let dataset of datasets) { + this.logger.info(`Getting all collections in dataset ${dataset} attached with predicate ${attachPredicate}...`); + + const results = await ctx.call('triplestore.query', { + query: ` + SELECT ?collectionUri + WHERE { + ?objectUri <${attachPredicate}> ?collectionUri + } + `, + accept: MIME_TYPES.JSON, + webId: 'system', + dataset + }); + + for (const collectionUri of results.map(r => r.collectionUri.value)) { + this.logger.info(`Updating options of ${collectionUri}...`); + await ctx.call('triplestore.update', { + query: ` + PREFIX as: + PREFIX semapps: + DELETE { + <${collectionUri}> + a ?type ; + as:summary ?summary ; + semapps:dereferenceItems ?dereferenceItems ; + semapps:itemsPerPage ?itemsPerPage ; + semapps:sortPredicate ?sortPredicate ; + semapps:sortOrder ?sortOrder . + } + INSERT { + <${collectionUri}> a ${ordered ? 'as:OrderedCollection, as:Collection' : 'as:Collection'} . + ${summary ? `<${collectionUri}> as:summary "${summary}" .` : ''} + <${collectionUri}> semapps:dereferenceItems ${dereferenceItems} . + ${itemsPerPage ? `<${collectionUri}> semapps:itemsPerPage ${itemsPerPage} .` : ''} + ${sortPredicate ? `<${collectionUri}> semapps:sortPredicate <${sortPredicate}> .` : ''} + ${sortOrder ? `<${collectionUri}> semapps:sortOrder <${sortOrder}> .` : ''} + } + WHERE { + <${collectionUri}> a ?type + OPTIONAL { <${collectionUri}> as:summary ?summary . } + OPTIONAL { <${collectionUri}> semapps:dereferenceItems ?dereferenceItems . } + OPTIONAL { <${collectionUri}> semapps:itemsPerPage ?itemsPerPage . } + OPTIONAL { <${collectionUri}> semapps:sortPredicate ?sortPredicate . } + OPTIONAL { <${collectionUri}> semapps:sortOrder ?sortOrder . } + } + `, + webId: 'system', + dataset + }); + } + } } }, methods: { @@ -224,4 +288,4 @@ const RegistryService = { } }; -module.exports = RegistryService; +module.exports = CollectionsRegistryService; diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/follow.js b/src/middleware/packages/activitypub/services/activitypub/subservices/follow.js index 4302579ef..2e57986ba 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/follow.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/follow.js @@ -1,5 +1,5 @@ const ActivitiesHandlerMixin = require('../../../mixins/activities-handler'); -const { ACTIVITY_TYPES } = require('../../../constants'); +const { ACTIVITY_TYPES, ACTOR_TYPES } = require('../../../constants'); const { collectionPermissionsWithAnonRead } = require('../../../utils'); const FollowService = { @@ -7,29 +7,29 @@ const FollowService = { mixins: [ActivitiesHandlerMixin], settings: { baseUri: null, - attachToActorTypes: null - }, - dependencies: ['activitypub.outbox', 'activitypub.collection'], - async started() { - const { attachToActorTypes } = this.settings; - - await this.broker.call('activitypub.registry.register', { + followersCollectionOptions: { path: '/followers', - attachToTypes: attachToActorTypes, + attachToTypes: Object.values(ACTOR_TYPES), attachPredicate: 'https://www.w3.org/ns/activitystreams#followers', ordered: false, + summary: 'Followers list', dereferenceItems: false, permissions: collectionPermissionsWithAnonRead - }); - - await this.broker.call('activitypub.registry.register', { + }, + followingCollectionOptions: { path: '/following', - attachToTypes: attachToActorTypes, + attachToTypes: Object.values(ACTOR_TYPES), attachPredicate: 'https://www.w3.org/ns/activitystreams#following', ordered: false, + summary: 'Following list', dereferenceItems: false, permissions: collectionPermissionsWithAnonRead - }); + } + }, + dependencies: ['activitypub.outbox', 'activitypub.collection'], + async started() { + await this.broker.call('activitypub.collections-registry.register', this.settings.followersCollectionOptions); + await this.broker.call('activitypub.collections-registry.register', this.settings.followingCollectionOptions); }, actions: { async addFollower(ctx) { @@ -109,6 +109,14 @@ const FollowService = { return await ctx.call('activitypub.collection.get', { resourceUri: collectionUri }); + }, + async updateCollectionsOptions(ctx) { + await ctx.call('activitypub.collections-registry.updateCollectionsOptions', { + collection: this.settings.followersCollectionOptions + }); + await ctx.call('activitypub.collections-registry.updateCollectionsOptions', { + collection: this.settings.followingCollectionOptions + }); } }, activities: { diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/inbox.js b/src/middleware/packages/activitypub/services/activitypub/subservices/inbox.js index 33ac0f7fc..6ad8cfc2e 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/inbox.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/inbox.js @@ -8,20 +8,22 @@ const { ACTOR_TYPES } = require('../../../constants'); const InboxService = { name: 'activitypub.inbox', settings: { - podProvider: false - }, - dependencies: ['activitypub.collection', 'activitypub.registry'], - async started() { - await this.broker.call('activitypub.registry.register', { + podProvider: false, + collectionOptions: { path: '/inbox', attachToTypes: Object.values(ACTOR_TYPES), attachPredicate: 'http://www.w3.org/ns/ldp#inbox', ordered: true, itemsPerPage: 10, dereferenceItems: true, - sort: { predicate: 'as:published', order: 'DESC' }, + sortPredicate: 'as:published', + sortOrder: 'semapps:DescOrder', permissions: collectionPermissionsWithAnonRead - }); + } + }, + dependencies: ['activitypub.collection', 'activitypub.collections-registry'], + async started() { + await this.broker.call('activitypub.collections-registry.register', this.settings.collectionsOptions); }, actions: { async post(ctx) { @@ -156,6 +158,11 @@ const InboxService = { } return activities; + }, + async updateCollectionsOptions(ctx) { + await ctx.call('activitypub.collections-registry.updateCollectionsOptions', { + collection: this.settings.collectionOptions + }); } } }; diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/like.js b/src/middleware/packages/activitypub/services/activitypub/subservices/like.js index 5c9355ae0..836307823 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/like.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/like.js @@ -1,5 +1,5 @@ const ActivitiesHandlerMixin = require('../../../mixins/activities-handler'); -const { ACTIVITY_TYPES } = require('../../../constants'); +const { ACTIVITY_TYPES, ACTOR_TYPES } = require('../../../constants'); const { collectionPermissionsWithAnonRead } = require('../../../utils'); const LikeService = { @@ -14,20 +14,19 @@ const LikeService = { ordered: false, dereferenceItems: false, permissions: collectionPermissionsWithAnonRead - } - }, - dependencies: ['activitypub.outbox', 'activitypub.collection'], - async started() { - const { attachToActorTypes } = this.settings; - - await this.broker.call('activitypub.registry.register', { + }, + likedCollectionOptions: { path: '/liked', - attachToTypes: attachToActorTypes, + attachToTypes: Object.values(ACTOR_TYPES), attachPredicate: 'https://www.w3.org/ns/activitystreams#liked', ordered: false, dereferenceItems: false, permissions: collectionPermissionsWithAnonRead - }); + } + }, + dependencies: ['activitypub.outbox', 'activitypub.collection'], + async started() { + await this.broker.call('activitypub.collections-registry.register', this.settings.likedCollectionOptions); }, actions: { async addLike(ctx) { @@ -47,7 +46,7 @@ const LikeService = { if (this.isLocal(objectUri)) { // Create the /likes collection and attach it to the object, unless it already exists - const likesCollectionUri = await ctx.call('activitypub.registry.createAndAttachCollection', { + const likesCollectionUri = await ctx.call('activitypub.collections-registry.createAndAttachCollection', { objectUri, collection: this.settings.likesCollectionOptions }); @@ -87,6 +86,14 @@ const LikeService = { } ctx.emit('activitypub.like.removed', { actorUri, objectUri }, { meta: { webId: null, dataset: null } }); + }, + async updateCollectionsOptions(ctx) { + await ctx.call('activitypub.collections-registry.updateCollectionsOptions', { + collection: this.settings.likesCollectionOptions + }); + await ctx.call('activitypub.collections-registry.updateCollectionsOptions', { + collection: this.settings.likedCollectionOptions + }); } }, activities: { diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/outbox.js b/src/middleware/packages/activitypub/services/activitypub/subservices/outbox.js index 8872f227d..4d76c420b 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/outbox.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/outbox.js @@ -8,20 +8,22 @@ const OutboxService = { name: 'activitypub.outbox', settings: { baseUri: null, - podProvider: false - }, - dependencies: ['activitypub.object', 'activitypub.collection', 'activitypub.registry'], - async started() { - await this.broker.call('activitypub.registry.register', { + podProvider: false, + collectionOptions: { path: '/outbox', attachToTypes: Object.values(ACTOR_TYPES), attachPredicate: 'https://www.w3.org/ns/activitystreams#outbox', ordered: true, itemsPerPage: 10, dereferenceItems: true, - sort: { predicate: 'as:published', order: 'DESC' }, + sortPredicate: 'as:published', + sortOrder: 'semapps:DescOrder', permissions: collectionPermissionsWithAnonRead - }); + } + }, + dependencies: ['activitypub.object', 'activitypub.collection', 'activitypub.collections-registry'], + async started() { + await this.broker.call('activitypub.collections-registry.register', this.settings.collectionOptions); }, actions: { async post(ctx) { @@ -128,6 +130,11 @@ const OutboxService = { } return activity; + }, + async updateCollectionsOptions(ctx) { + await ctx.call('activitypub.collections-registry.updateCollectionsOptions', { + collection: this.settings.collectionOptions + }); } }, methods: { diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/reply.js b/src/middleware/packages/activitypub/services/activitypub/subservices/reply.js index 5fef09d80..0ad505938 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/reply.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/reply.js @@ -22,7 +22,7 @@ const ReplyService = { const { objectUri, replyUri } = ctx.params; // Create the /replies collection and attach it to the object, unless it already exists - const collectionUri = await ctx.call('activitypub.registry.createAndAttachCollection', { + const collectionUri = await ctx.call('activitypub.collections-registry.createAndAttachCollection', { objectUri, collection: this.settings.collectionOptions, webId: 'system' @@ -56,6 +56,11 @@ const ReplyService = { } ` }); + }, + async updateCollectionsOptions(ctx) { + await ctx.call('activitypub.collections-registry.updateCollectionsOptions', { + collection: this.settings.collectionOptions + }); } }, activities: { diff --git a/website/docs/middleware/activitypub/index.md b/website/docs/middleware/activitypub/index.md index 304df0b6a..63ce5d8e3 100644 --- a/website/docs/middleware/activitypub/index.md +++ b/website/docs/middleware/activitypub/index.md @@ -24,12 +24,12 @@ This service allows you to create an ActivityPub server with data stored in a tr - ActorService - ApiService - CollectionService +- CollectionsRegistryService - FollowService - InboxService - LikeService - ObjectService - OutboxService -- RegistryService - ReplyService ## Other services @@ -58,13 +58,7 @@ module.exports = { settings: { baseUri: 'http://localhost:3000/', queueServiceUrl: null, - activateTombestones: true, - like: { - attachToActorTypes: null - }, - follow: { - attachToActorTypes: null - } + activateTombestones: true } }; ``` @@ -119,14 +113,12 @@ Additionally, the ActivityPub services will append all the ActivityPub-specific ## Settings -| Property | Type | Default | Description | -| ---------------------------- | ---------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| `baseUri` | `String` | **required** | Base URI of your web server | -| `selectActorData` | `Function` | | Receives the data provided on signup (as JSON-LD), and must return the properties (with full URI) to be appended to the actor profile (see above). | -| `queueServiceUrl` | `String` | | Redis connection string. If set, the [Bull](https://github.com/OptimalBits/bull) task manager will be used to handle federation POSTs. | -| `activateTombestones` | `Boolean` | true | If true, all deleted resources will be replaced with a [Tombstone](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tombstone) | -| `like.attachToActorsTypes` | `Array` | All AS actors | The ActivityStreams actors which will be attached a `liked` collection | -| `follow.attachToActorsTypes` | `Array` | All AS actors | The ActivityStreams actors which will be attached a `followers` and `following` collections | +| Property | Type | Default | Description | +| --------------------- | ---------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `baseUri` | `String` | **required** | Base URI of your web server | +| `selectActorData` | `Function` | | Receives the data provided on signup (as JSON-LD), and must return the properties (with full URI) to be appended to the actor profile (see above). | +| `queueServiceUrl` | `String` | | Redis connection string. If set, the [Bull](https://github.com/OptimalBits/bull) task manager will be used to handle federation POSTs. | +| `activateTombestones` | `Boolean` | true | If true, all deleted resources will be replaced with a [Tombstone](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tombstone) | ## Events From 1ed7455a325188ba67b4c3b5ad757ae58f2cb094 Mon Sep 17 00:00:00 2001 From: srosset81 Date: Thu, 18 Apr 2024 09:30:56 +0200 Subject: [PATCH 18/20] Improve migration tools --- src/middleware/packages/activitypub/index.js | 1 + .../activitypub/services/activitypub/index.js | 9 ------ .../activitypub/services/migration.js | 32 +++++++++++++++++++ 3 files changed, 33 insertions(+), 9 deletions(-) create mode 100644 src/middleware/packages/activitypub/services/migration.js diff --git a/src/middleware/packages/activitypub/index.js b/src/middleware/packages/activitypub/index.js index 0080be9bd..82a95cc2e 100644 --- a/src/middleware/packages/activitypub/index.js +++ b/src/middleware/packages/activitypub/index.js @@ -2,6 +2,7 @@ const constants = require('./constants'); module.exports = { ActivityPubService: require('./services/activitypub'), + ActivityPubMigrationService: require('./services/migration'), ActivityMappingService: require('./services/activity-mapping'), RelayService: require('./services/relay'), // Mixins diff --git a/src/middleware/packages/activitypub/services/activitypub/index.js b/src/middleware/packages/activitypub/services/activitypub/index.js index dd80826e1..8f36db3b2 100644 --- a/src/middleware/packages/activitypub/services/activitypub/index.js +++ b/src/middleware/packages/activitypub/services/activitypub/index.js @@ -121,15 +121,6 @@ const ActivityPubService = { ...sec, overwrite: true }); - }, - actions: { - async updateCollectionsOptions(ctx) { - await ctx.call('activitypub.follow.updateCollectionsOptions'); - await ctx.call('activitypub.inbox.updateCollectionsOptions'); - await ctx.call('activitypub.outbox.updateCollectionsOptions'); - await ctx.call('activitypub.like.updateCollectionsOptions'); - await ctx.call('activitypub.reply.updateCollectionsOptions'); - } } }; diff --git a/src/middleware/packages/activitypub/services/migration.js b/src/middleware/packages/activitypub/services/migration.js new file mode 100644 index 000000000..a1dda30a8 --- /dev/null +++ b/src/middleware/packages/activitypub/services/migration.js @@ -0,0 +1,32 @@ +module.exports = { + name: 'activitypub.migration', + actions: { + async updateCollectionsOptions(ctx) { + await ctx.call('activitypub.follow.updateCollectionsOptions'); + await ctx.call('activitypub.inbox.updateCollectionsOptions'); + await ctx.call('activitypub.outbox.updateCollectionsOptions'); + await ctx.call('activitypub.like.updateCollectionsOptions'); + await ctx.call('activitypub.reply.updateCollectionsOptions'); + }, + // This shouldn't be used in Pod provider config + async addCollectionsToContainer(ctx) { + const collectionsContainerUri = await ctx.call('activitypub.collection.getContainerUri'); + + this.logger.info(`Attaching all collections to ${collectionsContainerUri}`); + + await ctx.call('triplestore.update', { + query: ` + PREFIX as: + PREFIX ldp: + INSERT { + <${collectionsContainerUri}> ldp:contains ?collectionUri + } + WHERE { + ?collectionUri a as:Collection + } + `, + webId: 'system' + }); + } + } +}; From 56266603f9749921cc965b3bea0e772f9a166421 Mon Sep 17 00:00:00 2001 From: srosset81 Date: Thu, 18 Apr 2024 11:36:48 +0200 Subject: [PATCH 19/20] Fixes --- .../activitypub/services/activitypub/subservices/follow.js | 2 -- .../activitypub/services/activitypub/subservices/inbox.js | 2 +- src/middleware/packages/inference/service.js | 5 ++++- src/middleware/packages/inference/subservices/remote.js | 3 +++ .../packages/ldp/services/resource/actions/patch.js | 7 ++++++- src/middleware/tests/interop/remote-inference.test.js | 2 +- 6 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/follow.js b/src/middleware/packages/activitypub/services/activitypub/subservices/follow.js index 2e57986ba..d95491793 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/follow.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/follow.js @@ -12,7 +12,6 @@ const FollowService = { attachToTypes: Object.values(ACTOR_TYPES), attachPredicate: 'https://www.w3.org/ns/activitystreams#followers', ordered: false, - summary: 'Followers list', dereferenceItems: false, permissions: collectionPermissionsWithAnonRead }, @@ -21,7 +20,6 @@ const FollowService = { attachToTypes: Object.values(ACTOR_TYPES), attachPredicate: 'https://www.w3.org/ns/activitystreams#following', ordered: false, - summary: 'Following list', dereferenceItems: false, permissions: collectionPermissionsWithAnonRead } diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/inbox.js b/src/middleware/packages/activitypub/services/activitypub/subservices/inbox.js index 6ad8cfc2e..2fc71a652 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/inbox.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/inbox.js @@ -23,7 +23,7 @@ const InboxService = { }, dependencies: ['activitypub.collection', 'activitypub.collections-registry'], async started() { - await this.broker.call('activitypub.collections-registry.register', this.settings.collectionsOptions); + await this.broker.call('activitypub.collections-registry.register', this.settings.collectionOptions); }, actions: { async post(ctx) { diff --git a/src/middleware/packages/inference/service.js b/src/middleware/packages/inference/service.js index 4a3ae0bee..bf54ebf02 100644 --- a/src/middleware/packages/inference/service.js +++ b/src/middleware/packages/inference/service.js @@ -258,7 +258,10 @@ module.exports = { } }, async 'ldp.resource.patched'(ctx) { - const { triplesAdded, triplesRemoved } = ctx.params; + const { triplesAdded, triplesRemoved, skipInferenceCheck } = ctx.params; + + // If the patch is done following a remote inference offer + if (skipInferenceCheck) return; const triplesToAdd = this.generateInverseTriples(triplesAdded); const triplesToRemove = this.generateInverseTriples(triplesRemoved); diff --git a/src/middleware/packages/inference/subservices/remote.js b/src/middleware/packages/inference/subservices/remote.js index ff8ac3bf2..ca3288733 100644 --- a/src/middleware/packages/inference/subservices/remote.js +++ b/src/middleware/packages/inference/subservices/remote.js @@ -154,6 +154,9 @@ module.exports = { resourceUri: relationship.subject, triplesToAdd: activity.object.type === ACTIVITY_TYPES.ADD ? triples : [], triplesToRemove: activity.object.type === ACTIVITY_TYPES.REMOVE ? triples : [], + // We want this operation to be ignored by the InferenceService + // Otherwise it will send back an offer to add the inverse relationship + skipInferenceCheck: true, webId: 'system' }); diff --git a/src/middleware/packages/ldp/services/resource/actions/patch.js b/src/middleware/packages/ldp/services/resource/actions/patch.js index b844feb1c..a1274608d 100644 --- a/src/middleware/packages/ldp/services/resource/actions/patch.js +++ b/src/middleware/packages/ldp/services/resource/actions/patch.js @@ -38,13 +38,17 @@ module.exports = { type: 'array', optional: true }, + skipInferenceCheck: { + type: 'boolean', + optional: true + }, webId: { type: 'string', optional: true } }, async handler(ctx) { - let { resourceUri, sparqlUpdate, triplesToAdd, triplesToRemove, webId } = ctx.params; + let { resourceUri, sparqlUpdate, triplesToAdd, triplesToRemove, skipInferenceCheck, webId } = ctx.params; webId = webId || ctx.meta.webId || 'anon'; if (this.isRemoteUri(resourceUri, ctx.meta.dataset)) @@ -116,6 +120,7 @@ module.exports = { resourceUri, triplesAdded: triplesToAdd, triplesRemoved: triplesToRemove, + skipInferenceCheck, webId, dataset: ctx.meta.dataset }; diff --git a/src/middleware/tests/interop/remote-inference.test.js b/src/middleware/tests/interop/remote-inference.test.js index 6c91d0f05..ac941eb49 100644 --- a/src/middleware/tests/interop/remote-inference.test.js +++ b/src/middleware/tests/interop/remote-inference.test.js @@ -3,7 +3,7 @@ const waitForExpect = require('wait-for-expect'); const { MIME_TYPES } = require('@semapps/mime-types'); const initialize = require('./initialize'); -jest.setTimeout(70000); +jest.setTimeout(100000); let server1; let server2; From b93f5d8c9f8c55e0e8a9d46748eb7fd56be7a10e Mon Sep 17 00:00:00 2001 From: srosset81 Date: Thu, 18 Apr 2024 16:58:24 +0200 Subject: [PATCH 20/20] Ultimate fixes --- .../activitypub/subservices/activity.js | 1 + .../activitypub/subservices/collection.js | 1 + .../activitypub/subservices/object.js | 14 +++- .../packages/auth/services/capabilities.js | 1 + .../ldp/mixins/controlled-container.js | 4 +- .../ldp/services/registry/actions/register.js | 1 + .../ldp/services/resource/actions/delete.js | 5 +- .../ldp/services/resource/actions/exist.js | 9 ++- src/middleware/packages/pod/service.js | 1 + .../sync/middlewares/objects-watcher.js | 1 + website/docs/middleware/activitypub/index.md | 12 +-- website/docs/middleware/ldp/index.md | 21 ++--- website/docs/middleware/ldp/resource.md | 78 ++++++++++++------- 13 files changed, 95 insertions(+), 54 deletions(-) diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/activity.js b/src/middleware/packages/activitypub/services/activitypub/subservices/activity.js index d7d28e804..4a4274209 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/activity.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/activity.js @@ -16,6 +16,7 @@ const ActivityService = { newResourcesPermissions: {}, readOnly: true, excludeFromMirror: true, + activateTombstones: false, controlledActions: { // Activities shouldn't be handled manually patch: 'activitypub.activity.forbidden', diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/collection.js b/src/middleware/packages/activitypub/services/activitypub/subservices/collection.js index 8a596238e..a9cb2d186 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/collection.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/collection.js @@ -19,6 +19,7 @@ const CollectionService = { 'https://www.w3.org/ns/activitystreams#OrderedCollection' ], accept: MIME_TYPES.JSON, + activateTombstones: false, permissions: {}, newResourcesPermissions: webId => { switch (webId) { diff --git a/src/middleware/packages/activitypub/services/activitypub/subservices/object.js b/src/middleware/packages/activitypub/services/activitypub/subservices/object.js index 0a3a92dc3..837dd71cf 100644 --- a/src/middleware/packages/activitypub/services/activitypub/subservices/object.js +++ b/src/middleware/packages/activitypub/services/activitypub/subservices/object.js @@ -169,12 +169,18 @@ const ObjectService = { }, events: { async 'ldp.resource.deleted'(ctx) { + // Check if tombstones are globally activated if (this.settings.activateTombstones) { - const { resourceUri, oldData, dataset } = ctx.params; - const formerType = oldData.type || oldData['@type']; + const { resourceUri, containersUris, oldData, dataset } = ctx.params; - // Do not create Tombstones for actors or collections - if (![...Object.values(ACTOR_TYPES), 'Collection', 'OrderedCollection'].includes(formerType)) { + // Check if tombstones are activated for this specific container + const containerOptions = await ctx.call('ldp.registry.getByUri', { + containerUri: containersUris[0], + dataset + }); + + if (containerOptions.activateTombstones !== false) { + const formerType = oldData.type || oldData['@type']; await this.actions.createTombstone({ resourceUri, formerType }, { meta: { dataset }, parentCtx: ctx }); } } diff --git a/src/middleware/packages/auth/services/capabilities.js b/src/middleware/packages/auth/services/capabilities.js index d6359ee23..4dfc187ff 100644 --- a/src/middleware/packages/auth/services/capabilities.js +++ b/src/middleware/packages/auth/services/capabilities.js @@ -13,6 +13,7 @@ const CapabilitiesService = { settings: { path: CAPABILITIES_ROUTE, excludeFromMirror: true, + activateTombstones: false, permissions: {}, newResourcesPermissions: {} }, diff --git a/src/middleware/packages/ldp/mixins/controlled-container.js b/src/middleware/packages/ldp/mixins/controlled-container.js index 8e2efb875..0483d54f8 100644 --- a/src/middleware/packages/ldp/mixins/controlled-container.js +++ b/src/middleware/packages/ldp/mixins/controlled-container.js @@ -10,7 +10,8 @@ module.exports = { newResourcesPermissions: null, controlledActions: {}, readOnly: false, - excludeFromMirror: false + excludeFromMirror: false, + activateTombstones: true }, dependencies: ['ldp'], async started() { @@ -21,6 +22,7 @@ module.exports = { accept: this.settings.accept, permissions: this.settings.permissions, excludeFromMirror: this.settings.excludeFromMirror, + activateTombstones: this.settings.activateTombstones, newResourcesPermissions: this.settings.newResourcesPermissions, controlledActions: { post: `${this.name}.post`, diff --git a/src/middleware/packages/ldp/services/registry/actions/register.js b/src/middleware/packages/ldp/services/registry/actions/register.js index 1bcf52bdd..62a8f7579 100644 --- a/src/middleware/packages/ldp/services/registry/actions/register.js +++ b/src/middleware/packages/ldp/services/registry/actions/register.js @@ -12,6 +12,7 @@ module.exports = { acceptedTypes: { type: 'multi', rules: [{ type: 'array' }, { type: 'string' }], optional: true }, permissions: { type: 'object', optional: true }, excludeFromMirror: { type: 'boolean', optional: true }, + activateTombstones: { type: 'boolean', default: true }, newResourcesPermissions: { type: 'multi', rules: [{ type: 'object' }, { type: 'function' }], optional: true }, controlledActions: { type: 'object', optional: true }, readOnly: { type: 'boolean', optional: true }, diff --git a/src/middleware/packages/ldp/services/resource/actions/delete.js b/src/middleware/packages/ldp/services/resource/actions/delete.js index c06e7eb07..1398e5212 100644 --- a/src/middleware/packages/ldp/services/resource/actions/delete.js +++ b/src/middleware/packages/ldp/services/resource/actions/delete.js @@ -43,8 +43,8 @@ module.exports = { }); // We must detach the resource from the containers after deletion, otherwise the permissions may fail - const containers = await ctx.call('ldp.resource.getContainers', { resourceUri }); - for (const containerUri of containers) { + const containersUris = await ctx.call('ldp.resource.getContainers', { resourceUri }); + for (const containerUri of containersUris) { await ctx.call('ldp.container.detach', { containerUri, resourceUri, webId: 'system' }); } @@ -58,6 +58,7 @@ module.exports = { const returnValues = { resourceUri, + containersUris, oldData, webId, dataset: ctx.meta.dataset diff --git a/src/middleware/packages/ldp/services/resource/actions/exist.js b/src/middleware/packages/ldp/services/resource/actions/exist.js index 69dff8dd5..a8fbeae92 100644 --- a/src/middleware/packages/ldp/services/resource/actions/exist.js +++ b/src/middleware/packages/ldp/services/resource/actions/exist.js @@ -4,10 +4,11 @@ module.exports = { visibility: 'public', params: { resourceUri: { type: 'string' }, + acceptTombstones: { type: 'boolean', default: true }, webId: { type: 'string', optional: true } }, async handler(ctx) { - const { resourceUri } = ctx.params; + const { resourceUri, acceptTombstones } = ctx.params; const webId = ctx.params.webId || ctx.meta.webId || 'anon'; let exist = await ctx.call('triplestore.tripleExist', { @@ -24,6 +25,12 @@ module.exports = { }); } + // If resource exists but we don't want tombstones, check the resource type + if (exist && !acceptTombstones) { + const types = await this.actions.getTypes({ resourceUri }, { parentCtx: ctx }); + if (types.includes('https://www.w3.org/ns/activitystreams#Tombstone')) return false; + } + return exist; } }; diff --git a/src/middleware/packages/pod/service.js b/src/middleware/packages/pod/service.js index 4b7ea53f9..77edc63cb 100644 --- a/src/middleware/packages/pod/service.js +++ b/src/middleware/packages/pod/service.js @@ -18,6 +18,7 @@ module.exports = { podsContainer: true, acceptedTypes: [FULL_ACTOR_TYPES.PERSON], excludeFromMirror: true, + activateTombstones: false, controlledActions: { get: 'pod.getActor' } diff --git a/src/middleware/packages/sync/middlewares/objects-watcher.js b/src/middleware/packages/sync/middlewares/objects-watcher.js index 36a9145b5..57bf61442 100644 --- a/src/middleware/packages/sync/middlewares/objects-watcher.js +++ b/src/middleware/packages/sync/middlewares/objects-watcher.js @@ -190,6 +190,7 @@ const ObjectsWatcherMiddleware = (config = {}) => { }); const resourceExist = await ctx.call('ldp.resource.exist', { resourceUri: ctx.params.resourceUri, + acceptTombstones: false, // Ignore Tombstones webId: 'system' }); if (containerExist || resourceExist) { diff --git a/website/docs/middleware/activitypub/index.md b/website/docs/middleware/activitypub/index.md index 63ce5d8e3..ab238e5cc 100644 --- a/website/docs/middleware/activitypub/index.md +++ b/website/docs/middleware/activitypub/index.md @@ -113,12 +113,12 @@ Additionally, the ActivityPub services will append all the ActivityPub-specific ## Settings -| Property | Type | Default | Description | -| --------------------- | ---------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| `baseUri` | `String` | **required** | Base URI of your web server | -| `selectActorData` | `Function` | | Receives the data provided on signup (as JSON-LD), and must return the properties (with full URI) to be appended to the actor profile (see above). | -| `queueServiceUrl` | `String` | | Redis connection string. If set, the [Bull](https://github.com/OptimalBits/bull) task manager will be used to handle federation POSTs. | -| `activateTombestones` | `Boolean` | true | If true, all deleted resources will be replaced with a [Tombstone](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tombstone) | +| Property | Type | Default | Description | +| --------------------- | ---------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `baseUri` | `String` | **required** | Base URI of your web server | +| `selectActorData` | `Function` | | Receives the data provided on signup (as JSON-LD), and must return the properties (with full URI) to be appended to the actor profile (see above). | +| `queueServiceUrl` | `String` | | Redis connection string. If set, the [Bull](https://github.com/OptimalBits/bull) task manager will be used to handle federation POSTs. | +| `activateTombestones` | `Boolean` | true | If true, all deleted resources will be replaced with a [Tombstone](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tombstone), except for containers which have disabled this | ## Events diff --git a/website/docs/middleware/ldp/index.md b/website/docs/middleware/ldp/index.md index b6e631934..ee0ac6fd2 100644 --- a/website/docs/middleware/ldp/index.md +++ b/website/docs/middleware/ldp/index.md @@ -77,16 +77,17 @@ module.exports = { The following options can be set for each container, or they can be set in the `defaultContainerOptions` settings. -| Property | Type | Default | Description | -| ------------------------- | ---------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `accept` | `String` | "text/turtle" | Type to return (`application/ld+json`, `text/turtle` or `application/n-triples`) | -| `acceptedTypes` | `Array` | | RDF classes accepted in this container. This is not enforced but used by some services to identify containers. | -| `excludeFromMirror` | `Boolean` | false | If true, other servers will not be able to [mirror](../sync/mirror) this container. | -| `permissions` | `Object` or `Function` | | If the WebACL service is activated, permissions of the container itself | -| `newResourcesPermissions` | `Object` or `Function` | | If the WebACL service is activated, permissions for new resources. [See the docs here](../webacl/index.md#default-permissions-for-new-resources) | -| `readOnly` | `Boolean` | false | Do not set `POST`, `PATCH`, `PUT` and `DELETE` routes for the container and its resources | -| `preferredView` | `String` | | A part of the final URL for redirecting to the preferred view of the resource (see below). | -| `controlledActions` | `Object` | | Use custom actions instead of the LDP ones (post, list, get, create, put, patch, delete). Used by the [ControlledContainerMixin](controlled-container) | +| Property | Type | Default | Description | +| ------------------------- | ---------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `accept` | `String` | "text/turtle" | Type to return (`application/ld+json`, `text/turtle` or `application/n-triples`) | +| `acceptedTypes` | `Array` | | RDF classes accepted in this container. This is not enforced but used by some services to identify containers. | +| `excludeFromMirror` | `Boolean` | false | If true, other servers will not be able to [mirror](../sync/mirror) this container. | +| `activateTombstones` | `Boolean` | true | If true, and if the ActivityPubService setting is also true, [Tombstones](https://www.w3.org/TR/activitypub/#delete-activity-outbox) will replace deleted resources. | +| `permissions` | `Object` or `Function` | | If the WebACL service is activated, permissions of the container itself | +| `newResourcesPermissions` | `Object` or `Function` | | If the WebACL service is activated, permissions for new resources. [See the docs here](../webacl/index.md#default-permissions-for-new-resources) | +| `readOnly` | `Boolean` | false | Do not set `POST`, `PATCH`, `PUT` and `DELETE` routes for the container and its resources | +| `preferredView` | `String` | | A part of the final URL for redirecting to the preferred view of the resource (see below). | +| `controlledActions` | `Object` | | Use custom actions instead of the LDP ones (post, list, get, create, put, patch, delete). Used by the [ControlledContainerMixin](controlled-container) | ## API routes diff --git a/website/docs/middleware/ldp/resource.md b/website/docs/middleware/ldp/resource.md index 1b2522f34..f69201bf9 100644 --- a/website/docs/middleware/ldp/resource.md +++ b/website/docs/middleware/ldp/resource.md @@ -4,81 +4,90 @@ title: LdpResourceService This service is automatically created by the [LdpService](index) - ## Actions The following service actions are available: - ### `create` + - This action is called internally by `ldp.container.post` - If called directly, the full URI must be provided in the `@id` of the `resource` object ##### Parameters + | Property | Type | Default | Description | -|---------------|----------|---------------------|---------------------------------------------------------------------------------------------| +| ------------- | -------- | ------------------- | ------------------------------------------------------------------------------------------- | | `resource` | `Object` | **required** | Resource to create (with an ID) | | `contentType` | `String` | **required** | Type of provided resource (`application/ld+json`, `text/turtle` or `application/n-triples`) | | `webId` | `String` | Logged user's webId | User doing the action | ##### Return values + | Property | Type | Description | -|---------------|----------|-----------------------------| +| ------------- | -------- | --------------------------- | | `resourceUri` | `String` | URI of the created resource | | `newData` | `Object` | New value of the resource | | `webId` | `String` | User who did the action | - ### `delete` + - Delete the whole resource and detach it from its container ##### Parameters + | Property | Type | Default | Description | -|---------------|----------|---------------------|---------------------------| +| ------------- | -------- | ------------------- | ------------------------- | | `resourceUri` | `String` | **required** | URI of resource to delete | | `webId` | `string` | Logged user's webId | User doing the action | ##### Return values + | Property | Type | Description | -|---------------|----------|-----------------------------| +| ------------- | -------- | --------------------------- | | `resourceUri` | `String` | URI of the deleted resource | | `oldData` | `Object` | Old value of the resource | | `webId` | `String` | User who did the action | - ### `exist` + - Check if a resource exist ##### Parameters -| Property | Type | Default | Description | -|---------------|----------|---------------------|--------------------------------| -| `resourceUri` | `String` | **required** | URI of the resource to check | -| `webId` | `String` | Logged user's webId | User doing the action | + +| Property | Type | Default | Description | +| ------------------ | --------- | ------------------- | -------------------------------------------------------------- | +| `resourceUri` | `String` | **required** | URI of the resource to check | +| `acceptTombstones` | `Boolean` | true | If false, calling this action on a Tombstone will return false | +| `webId` | `String` | Logged user's webId | User doing the action | ##### Return values -`Boolean` +`Boolean` ### `generateId` + - Finds an unique ID for a resource ##### Parameters + | Property | Type | Default | Description | -|----------------|----------|--------------|---------------------------------------------------| +| -------------- | -------- | ------------ | ------------------------------------------------- | | `containerUri` | `String` | **required** | URI of the container where to create the resource | | `slug` | `String` | | Preferred slug (will be "slugified") | ##### Return values -Full URI available +Full URI available ### `get` + - Get a resource by its URI -Accept triples, turtle or JSON-LD (see `@semapps/mime-types` package) ##### Parameters + | Property | Type | Default | Description | -|---------------|----------|---------------------|----------------------------------------------------------------------------------| +| ------------- | -------- | ------------------- | -------------------------------------------------------------------------------- | | `resourceUri` | `String` | **required** | URI of the resource | | `accept` | `string` | **required** | Type to return (`application/ld+json`, `text/turtle` or `application/n-triples`) | | `webId` | `string` | Logged user's webId | User doing the action | @@ -86,10 +95,11 @@ Full URI available You can also pass any parameter defined in the [container options](./index.md#container-options). ##### Return -Triples, Turtle or JSON-LD depending on `accept` type. +Triples, Turtle or JSON-LD depending on `accept` type. ### `patch` + - Partial update of an existing resource. Allow to add and/or remove tripled. - Accept either a SPARQL Update (with INSERT DATA and/or DELETE DATA) or an array of triples conforming with the [RDF.js data model](https://github.com/rdfjs-base/data-model) - You can add blank nodes but not remove them (this is a limitation of the SPARQL specifications for DELETE DATA) @@ -112,8 +122,9 @@ DELETE DATA { ``` ##### Parameters + | Property | Type | Default | Description | -|-------------------|----------|---------------------|----------------------------------------------------------------------------------------------------| +| ----------------- | -------- | ------------------- | -------------------------------------------------------------------------------------------------- | | `resourceUri` | `String` | **required** | URI of resource to update | | `sparqlUpdate` | `String` | | SPARQL query with INSERT DATA and/or DELETE DATA operations | | `triplesToAdd` | `Array` | | Array of triples conforming with the [RDF.js data model](https://github.com/rdfjs-base/data-model) | @@ -121,79 +132,86 @@ DELETE DATA { | `webId` | `String` | Logged user's webId | User doing the action | ##### Return values + | Property | Type | Description | -|-------------------|----------|----------------------------------------------------------------------------------------------------| +| ----------------- | -------- | -------------------------------------------------------------------------------------------------- | | `resourceUri` | `String` | URI of the updated resource | | `triplesToAdd` | `Array` | Array of triples conforming with the [RDF.js data model](https://github.com/rdfjs-base/data-model) | | `triplesToRemove` | `Array` | Array of triples conforming with the [RDF.js data model](https://github.com/rdfjs-base/data-model) | | `webId` | `String` | User who did the action | - ### `put` + - Full update of an existing resource - If some predicates existed but are not provided, they will be deleted. - Content-type can be triples, turtle or JSON-LD (see `@semapps/mime-types` package) ##### Parameters + | Property | Type | Default | Description | -|---------------|----------|---------------------|------------------------------------------------------------------------------------| +| ------------- | -------- | ------------------- | ---------------------------------------------------------------------------------- | | `resource` | `Object` | **required** | Resource to update | | `contentType` | `string` | **required** | Type of resource (`application/ld+json`, `text/turtle` or `application/n-triples`) | | `webId` | `string` | Logged user's webId | User doing the action | ##### Return values + | Property | Type | Description | -|---------------|----------|-----------------------------| +| ------------- | -------- | --------------------------- | | `resourceUri` | `String` | URI of the updated resource | | `newData` | `Object` | New value of the resource | | `oldData` | `Object` | Old value of the resource | | `webId` | `String` | User who did the action | - ## Events The following events are emitted. ### `ldp.resource.created` + Sent after a resource is created. ##### Payload + | Property | Type | Description | -|---------------|----------|-----------------------------| +| ------------- | -------- | --------------------------- | | `resourceUri` | `String` | URI of the created resource | | `newData` | `Object` | New value of the resource | | `webId` | `String` | User who did the action | - ### `ldp.resource.deleted` + Sent after a resource is deleted. ##### Payload + | Property | Type | Description | -|---------------|----------|-----------------------------| +| ------------- | -------- | --------------------------- | | `resourceUri` | `String` | URI of the deleted resource | | `oldData` | `Object` | Old value of the resource | | `webId` | `String` | User who did the action | - ### `ldp.resource.patched` + Sent after a resource is patched ##### Payload + | Property | Type | Description | -|------------------|----------|----------------------------------------------------------------------------------------------------| +| ---------------- | -------- | -------------------------------------------------------------------------------------------------- | | `resourceUri` | `String` | URI of the updated resource | | `triplesAdded` | `Array` | Array of triples conforming with the [RDF.js data model](https://github.com/rdfjs-base/data-model) | | `triplesRemoved` | `Array` | Array of triples conforming with the [RDF.js data model](https://github.com/rdfjs-base/data-model) | | `webId` | `String` | User who did the action | - ### `ldp.resource.updated` + Sent after a resource is updated (through PUT) ##### Payload + | Property | Type | Description | -|---------------|----------|-----------------------------| +| ------------- | -------- | --------------------------- | | `resourceUri` | `String` | URI of the updated resource | | `newData` | `Object` | New value of the resource | | `oldData` | `Object` | Old value of the resource |