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
+14
View File
@@ -0,0 +1,14 @@
SEMAPPS_APP_NAME=Festipod
SEMAPPS_APP_DESCRIPTION=Points de rencontre sur les événements publics
SEMAPPS_APP_LANG=en,fr
SEMAPPS_HOME_URL=http://localhost:3003
SEMAPPS_FRONT_URL=http://localhost:5000
SEMAPPS_SHAPE_REPOSITORY_URL=https://shapes.activitypods.org/
SEMAPPS_PORT=3003
SEMAPPS_SPARQL_ENDPOINT=http://localhost:3030/
SEMAPPS_MAIN_DATASET=settings
SEMAPPS_JENA_USER=admin
SEMAPPS_JENA_PASSWORD=admin
SEMAPPS_REDIS_CACHE_URL=redis://localhost:6390/3
SEMAPPS_QUEUE_SERVICE_URL=redis://localhost:6390/4
SEMAPPS_AUTH_ACCOUNTS_DATASET_NAME=settings
+25
View File
@@ -0,0 +1,25 @@
FROM node:22-slim
# Install pnpm and build tools required for native modules (sharp, bcrypt).
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
python3 \
make \
g++ \
&& rm -rf /var/lib/apt/lists/* \
&& corepack enable && corepack prepare pnpm@10.26.0 --activate
WORKDIR /app
# Install dependencies first to leverage Docker layer caching.
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod
# Copy application code.
COPY config ./config
COPY moleculer.config.js ./
COPY services ./services
EXPOSE 3003
CMD ["pnpm", "start"]
+18
View File
@@ -0,0 +1,18 @@
require('dotenv-flow').config();
module.exports = {
APP_NAME: process.env.SEMAPPS_APP_NAME,
APP_DESCRIPTION: process.env.SEMAPPS_APP_DESCRIPTION,
APP_LANG: process.env.SEMAPPS_APP_LANG,
HOME_URL: process.env.SEMAPPS_HOME_URL,
FRONT_URL: process.env.SEMAPPS_FRONT_URL,
SHAPE_REPOSITORY_URL: process.env.SEMAPPS_SHAPE_REPOSITORY_URL,
PORT: process.env.SEMAPPS_PORT,
SPARQL_ENDPOINT: process.env.SEMAPPS_SPARQL_ENDPOINT,
MAIN_DATASET: process.env.SEMAPPS_MAIN_DATASET,
JENA_USER: process.env.SEMAPPS_JENA_USER,
JENA_PASSWORD: process.env.SEMAPPS_JENA_PASSWORD,
REDIS_CACHE_URL: process.env.SEMAPPS_REDIS_CACHE_URL,
QUEUE_SERVICE_URL: process.env.SEMAPPS_QUEUE_SERVICE_URL,
AUTH_ACCOUNTS_DATASET_NAME: process.env.SEMAPPS_AUTH_ACCOUNTS_DATASET_NAME
};
+62
View File
@@ -0,0 +1,62 @@
const { CacherMiddleware } = require('@semapps/webacl');
const CONFIG = require('./config/config');
Error.stackTraceLimit = Infinity;
const cacherConfig = CONFIG.REDIS_CACHE_URL
? {
type: 'Redis',
options: {
prefix: 'action',
ttl: 2592000,
redis: CONFIG.REDIS_CACHE_URL
}
}
: undefined;
const SkipOrphanBlankNodesCleanupMiddleware = () => ({
name: 'SkipOrphanBlankNodesCleanupMiddleware',
localAction: (next, action) => {
if (action.name === 'triplestore.deleteOrphanBlankNodes') {
return async () => {};
}
return next;
}
});
// ActivityPods app services mark internal calls with isSystemCall, but the SDK
// triplestore layer needs an explicit webId='system' to bypass pod-side WAC.
const SystemCallWebIdMiddleware = () => ({
name: 'SystemCallWebIdMiddleware',
localAction: (next, action) => {
return async ctx => {
if (ctx.meta?.isSystemCall && !ctx.params?.webId) {
ctx.params.webId = 'system';
}
return next(ctx);
};
}
});
module.exports = {
middlewares: [
SkipOrphanBlankNodesCleanupMiddleware(),
SystemCallWebIdMiddleware(),
CacherMiddleware(cacherConfig)
],
logger: {
type: 'Console',
options: {
formatter: 'short',
level: 'info'
}
},
retryPolicy: {
enabled: true,
retries: 0,
delay: 100,
maxDelay: 30000,
factor: 2,
check: err => err && !!err.retryable
}
};
+6 -1
View File
@@ -10,19 +10,24 @@
"@rdfjs/data-model": "2.1.1",
"@semapps/activitypub": "1.2.7",
"@semapps/auth": "1.2.7",
"@semapps/crypto": "1.2.7",
"@semapps/core": "1.2.7",
"@semapps/crypto": "1.2.7",
"@semapps/importer": "1.2.7",
"@semapps/ldp": "1.2.7",
"@semapps/mime-types": "1.2.7",
"@semapps/nodeinfo": "1.2.7",
"@semapps/ontologies": "1.2.7",
"@semapps/solid": "1.2.7",
"@semapps/sparql-endpoint": "1.2.7",
"@semapps/triplestore": "1.2.7",
"@semapps/webacl": "1.2.7",
"dotenv-flow": "^3.2.0",
"ioredis": "^4.17.3",
"moleculer": "^0.14.19",
"moleculer-bull": "0.2.8",
"moleculer-cron": "0.0.4",
"moleculer-repl": "^0.6.6",
"sharp": "0.31.3",
"url-join": "^4.0.1"
},
"engines": {
+15
View File
@@ -32,15 +32,27 @@ importers:
'@semapps/ldp':
specifier: 1.2.7
version: 1.2.7(debug@4.4.3)(moleculer@0.14.36(debug@4.4.3)(ioredis@4.31.0))
'@semapps/mime-types':
specifier: 1.2.7
version: 1.2.7(moleculer@0.14.36(debug@4.4.3)(ioredis@4.31.0))
'@semapps/nodeinfo':
specifier: 1.2.7
version: 1.2.7(moleculer@0.14.36(debug@4.4.3)(ioredis@4.31.0))
'@semapps/ontologies':
specifier: 1.2.7
version: 1.2.7(moleculer@0.14.36(debug@4.4.3)(ioredis@4.31.0))
'@semapps/solid':
specifier: 1.2.7
version: 1.2.7(debug@4.4.3)(moleculer@0.14.36(debug@4.4.3)(ioredis@4.31.0))
'@semapps/sparql-endpoint':
specifier: 1.2.7
version: 1.2.7(moleculer@0.14.36(debug@4.4.3)(ioredis@4.31.0))
'@semapps/triplestore':
specifier: 1.2.7
version: 1.2.7(moleculer@0.14.36(debug@4.4.3)(ioredis@4.31.0))
'@semapps/webacl':
specifier: 1.2.7
version: 1.2.7(debug@4.4.3)(moleculer@0.14.36(debug@4.4.3)(ioredis@4.31.0))
dotenv-flow:
specifier: ^3.2.0
version: 3.3.0
@@ -59,6 +71,9 @@ importers:
moleculer-repl:
specifier: ^0.6.6
version: 0.6.6
sharp:
specifier: 0.31.3
version: 0.31.3
url-join:
specifier: ^4.0.1
version: 4.0.1
+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
});
}
}
}
};