T04.f: backend Festipod ActivityPods + dockerisation

- Ajoute la configuration et les services backend (app, actors, registration,
  attendees, contacts, catalog, core auth/nodeinfo/proxy/notification).
- Corrige le démarrage : désactive resourcesWithContainerPath pour éviter le
  conflit entre l'URI de l'acteur applicatif (/as/actor/app) et la route /app.
- Corrige l'URL du dépôt de shapes : https://shapes.activitypods.org/.
- Ajoute backend/Dockerfile et docker-compose.yml racine (frontend + backend).
- Met à jour .env.example racine avec les variables SEMAPPS_* et VITE_BACKEND_*.
- Gates verts : bunx tsc --noEmit (0 erreur) et bun test (50 pass).
This commit is contained in:
Sylvain Duchesne
2026-10-09 13:51:02 +02:00
parent e27cfa4e0a
commit e17398d86d
18 changed files with 536 additions and 1 deletions
+51
View File
@@ -0,0 +1,51 @@
const urlJoin = require('url-join');
const { AppService } = require('@activitypods/app');
const CONFIG = require('../config/config');
module.exports = {
mixins: [AppService],
settings: {
baseUrl: CONFIG.HOME_URL,
app: {
name: CONFIG.APP_NAME,
description: CONFIG.APP_DESCRIPTION,
thumbnail: urlJoin(CONFIG.FRONT_URL, 'logo.svg'),
frontUrl: CONFIG.FRONT_URL,
supportedLocales: CONFIG.APP_LANG
},
oidc: {
clientUri: CONFIG.FRONT_URL,
redirectUris: urlJoin(CONFIG.FRONT_URL, 'login'),
postLogoutRedirectUris: urlJoin(CONFIG.FRONT_URL, 'login?logout=true'),
tosUri: null
},
accessNeeds: {
required: [
{
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/as/Event'),
accessMode: ['acl:Read', 'acl:Write', 'acl:Control'],
preferredScope: 'interop:AllFromRegistry'
},
{
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/as/Profile'),
accessMode: ['acl:Read', 'acl:Write'],
preferredScope: 'interop:AllFromRegistry'
},
{
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/vcard/Group'),
accessMode: 'acl:Read',
preferredScope: 'interop:AllFromRegistry'
},
'apods:ReadInbox',
'apods:ReadOutbox',
'apods:PostOutbox',
'apods:QuerySparqlEndpoint',
'apods:CreateWacGroup',
'apods:CreateCollection',
'apods:UpdateWebId'
],
optional: []
},
queueServiceUrl: CONFIG.QUEUE_SERVICE_URL
}
};
+17
View File
@@ -0,0 +1,17 @@
const urlJoin = require('url-join');
const { PodCollectionsHandlerMixin } = require('@activitypods/app');
const CONFIG = require('../config/config');
module.exports = {
name: 'attendees',
mixins: [PodCollectionsHandlerMixin],
settings: {
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/as/Event'),
attachPredicate: 'http://activitypods.org/ns/core#attendees',
collectionOptions: {
ordered: false,
summary: 'Meeting point attendees'
},
createWacGroup: true
}
};
+41
View File
@@ -0,0 +1,41 @@
const urlJoin = require('url-join');
const { PodResourcesHandlerMixin } = require('@activitypods/app');
const CONFIG = require('../config/config');
module.exports = {
name: 'catalog',
mixins: [PodResourcesHandlerMixin],
settings: {
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/as/Event')
},
actions: {
discover: {
async handler() {
return {
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Collection',
summary: 'Public Festipod events and meeting points',
totalItems: this.index ? Object.keys(this.index).length : 0,
items: this.index ? Object.values(this.index) : []
};
}
}
},
methods: {
async onCreate(ctx, resource) {
if (!this.index) this.index = {};
this.index[resource.id || resource['@id']] = resource;
this.logger.info(`Catalog indexed new event ${resource.id || resource['@id']}`);
},
async onUpdate(ctx, resource) {
if (!this.index) this.index = {};
this.index[resource.id || resource['@id']] = resource;
this.logger.info(`Catalog updated event ${resource.id || resource['@id']}`);
},
async onDelete(ctx, resourceUri) {
if (!this.index) return;
delete this.index[resourceUri];
this.logger.info(`Catalog removed event ${resourceUri}`);
}
}
};
+17
View File
@@ -0,0 +1,17 @@
const urlJoin = require('url-join');
const { PodCollectionsHandlerMixin } = require('@activitypods/app');
const CONFIG = require('../config/config');
module.exports = {
name: 'contacts',
mixins: [PodCollectionsHandlerMixin],
settings: {
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/as/Profile'),
attachPredicate: 'http://activitypods.org/ns/core#contacts',
collectionOptions: {
ordered: false,
summary: 'Festipod contacts'
},
createWacGroup: true
}
};
@@ -0,0 +1,8 @@
const { AuthAccountService } = require('@semapps/auth');
const { TripleStoreAdapter } = require('@semapps/triplestore');
const CONFIG = require('../../config/config');
module.exports = {
mixins: [AuthAccountService],
adapter: new TripleStoreAdapter({ type: 'AuthAccount', dataset: CONFIG.AUTH_ACCOUNTS_DATASET_NAME })
};
+53
View File
@@ -0,0 +1,53 @@
const path = require('path');
const { CoreService } = require('@semapps/core');
const { apods, notify, interop, oidc, skos } = require('@semapps/ontologies');
const CONFIG = require('../../config/config');
module.exports = {
mixins: [CoreService],
settings: {
baseUrl: CONFIG.HOME_URL,
baseDir: path.resolve(__dirname, '../..'),
triplestore: {
url: CONFIG.SPARQL_ENDPOINT,
user: CONFIG.JENA_USER,
password: CONFIG.JENA_PASSWORD,
mainDataset: CONFIG.MAIN_DATASET
},
ontologies: [apods, notify, interop, oidc, skos],
activitypub: {
queueServiceUrl: CONFIG.QUEUE_SERVICE_URL
},
api: {
port: CONFIG.PORT,
routes: [
{
path: '/app',
aliases: {
'GET /': 'app.get'
},
mappingPolicy: 'restrict',
authentication: false,
authorization: false,
bodyParsers: {
json: true
}
},
{
path: '/catalog',
aliases: {
'GET /': 'catalog.discover'
},
mappingPolicy: 'restrict',
authentication: false,
authorization: false,
bodyParsers: {
json: true
}
}
]
},
webacl: false,
void: false
}
};
+30
View File
@@ -0,0 +1,30 @@
const urlJoin = require('url-join');
const { NodeinfoService } = require('@semapps/nodeinfo');
const CONFIG = require('../../config/config');
module.exports = {
mixins: [NodeinfoService],
settings: {
baseUrl: CONFIG.HOME_URL,
software: {
name: 'festipod'
},
protocols: ['activitypub'],
metadata: {
frontend_url: CONFIG.FRONT_URL,
login_url: CONFIG.FRONT_URL && urlJoin(CONFIG.FRONT_URL, 'login'),
logout_url: CONFIG.FRONT_URL && urlJoin(CONFIG.FRONT_URL, 'login?logout=true')
}
},
actions: {
async getUsersCount(ctx) {
const containerUri = await ctx.call('app-registrations.getContainerUri');
const registrationsUris = await ctx.call('ldp.container.getUris', { containerUri });
return {
total: registrationsUris.length,
activeHalfYear: registrationsUris.length,
activeMonth: registrationsUris.length
};
}
}
};
@@ -0,0 +1,11 @@
const { TripleStoreAdapter } = require('@semapps/triplestore');
const { NotificationsListenerService } = require('@semapps/solid');
const CONFIG = require('../../config/config');
module.exports = {
mixins: [NotificationsListenerService],
adapter: new TripleStoreAdapter({ type: 'WebhookChannelListener', dataset: CONFIG.AUTH_ACCOUNTS_DATASET_NAME }),
settings: {
baseUrl: CONFIG.HOME_URL
}
};
+3
View File
@@ -0,0 +1,3 @@
const { ProxyService } = require('@semapps/crypto');
module.exports = ProxyService;
+97
View File
@@ -0,0 +1,97 @@
const { ACTIVITY_TYPES, OBJECT_TYPES } = require('@semapps/activitypub');
const { PodActivitiesHandlerMixin } = require('@activitypods/app');
const { arrayOf } = require('@semapps/ldp');
module.exports = {
name: 'registration',
mixins: [PodActivitiesHandlerMixin],
async started() {
this.logger.info('Registration watcher subscribed');
},
activities: {
join: {
match: {
type: ACTIVITY_TYPES.JOIN,
object: {
type: OBJECT_TYPES.EVENT
}
},
async onEmit(ctx, activity, actorUri) {
const event = activity.object;
const organizerUri = event['dc:creator'];
if (!organizerUri) return;
const { body: actor } = await ctx.call('pod-resources.get', { resourceUri: actorUri, actorUri });
if (!actor || !actor['apods:contacts']) return;
const contactsGroupUri = await ctx.call('pod-wac-groups.getUriFromCollectionUri', {
collectionUri: actor['apods:contacts']
});
await ctx.call('pod-wac-groups.addMember', {
groupUri: contactsGroupUri,
memberUri: organizerUri,
actorUri
});
},
async onReceive(ctx, activity, actorUri) {
const event = activity.object;
const attendeesCollectionUri = event['apods:attendees'];
if (!attendeesCollectionUri) {
this.logger.warn(`No attendees collection on ${event.id}, skipping Join`);
return;
}
const attendees = await ctx.call('pod-collections.getItems', {
collectionUri: attendeesCollectionUri,
actorUri
});
if (arrayOf(attendees).includes(activity.actor)) {
this.logger.info(`${activity.actor} is already attending ${event.id}`);
return;
}
await ctx.call('attendees.add', {
collectionUri: attendeesCollectionUri,
itemUri: activity.actor,
actorUri
});
}
},
leave: {
match: {
type: ACTIVITY_TYPES.LEAVE,
object: {
type: OBJECT_TYPES.EVENT
}
},
async onReceive(ctx, activity, actorUri) {
const event = activity.object;
const attendeesCollectionUri = event['apods:attendees'];
if (!attendeesCollectionUri) {
this.logger.warn(`No attendees collection on ${event.id}, skipping Leave`);
return;
}
const attendees = await ctx.call('pod-collections.getItems', {
collectionUri: attendeesCollectionUri,
actorUri
});
if (!arrayOf(attendees).includes(activity.actor)) {
this.logger.info(`${activity.actor} is not attending ${event.id}, skipping Leave`);
return;
}
await ctx.call('attendees.remove', {
collectionUri: attendeesCollectionUri,
itemUri: activity.actor,
actorUri
});
}
}
}
};