Compare commits

..

23 Commits

Author SHA1 Message Date
admin eacffd7755 docs: record the decision to keep choral-permission and permissions-template
Build release Docker image / Build Docker Images (push) Successful in 21s
Inutilisés (aucune référence, 0 ligne) mais conservés sciemment le
27/07/2026 comme fondation d'un modèle de permissions plus riche.
Tableau des content-types mis à jour après la suppression de
conversation et direct-message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:59:49 +02:00
admin 3e9f5c3f60 0.13.25 : drop the dead legacy direct-message content-types
Build release Docker image / Build Docker Images (push) Successful in 8m5s
`conversation` et `direct-message` n'ont plus aucun appelant depuis la
suppression de choral-old (front 0.18.17) : aucune référence dans le
back, aucune dans le front, et 0 ligne en base.

- content-types supprimés
- leurs 4 permissions retirées de permissions-sync

⚠️ La synchro étant additive, les permissions déjà présentes en base ne
sont pas supprimées automatiquement : elles apparaîtront désormais dans
la liste des permissions non déclarées au démarrage, à décocher dans
l'admin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:55:09 +02:00
admin ec1d9bc1a4 0.13.24 : move the conversations endpoint off a colliding path
Build release Docker image / Build Docker Images (push) Successful in 8m22s
/chat-conversation-members/mine pouvait être capté par le routeur cœur
comme un findOne avec id = "mine", selon l'ordre d'enregistrement des
routes — et les deux répondent 403 sans jeton, ce qui rendait le
diagnostic impossible. L'endpoint devient /api/my-chat-conversations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:11:18 +02:00
admin a93d48cada 0.13.23 : return the caller's conversations without opening private messages
Build release Docker image / Build Docker Images (push) Successful in 7m47s
La messagerie affichait des conversations vides : ni nom, ni
interlocuteur, ni message. Le front peuplait `conversation` et ses
`messages` via l'API REST, dont le sanitizer retire toute relation vers
un content-type que le rôle ne peut pas lire — or ni
`chat-conversation.find` ni `chat-message.find` ne sont accordées.

Les accorder aurait suffi, mais aurait ouvert la lecture de TOUTES les
conversations et de TOUS les messages privés à n'importe quel compte
connecté. On ajoute donc une action dédiée :

- GET /api/chat-conversation-members/mine — conversations du seul
  appelant, avec interlocuteurs et messages, renvoyées via ctx.send
- mot de passe et jetons retirés des utilisateurs peuplés
- permission `mine` déclarée dans permissions-sync, avec la raison pour
  laquelle les deux `find` restent volontairement absentes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:33:49 +02:00
admin 62064f053a 0.13.22 : tell a join request apart from an invitation
Build release Docker image / Build Docker Images (push) Successful in 8m5s
Le controller forçait `state = "pending_user_approval"` quel que soit le
sens — commentaire à l'appui : « On force le statut en attente peu
importe le type d'invité ». Une candidature déposée par un utilisateur
ressortait donc en invitation, et il recevait un email « vous avez été
invité » pour une chorale qu'il venait lui-même de demander à rejoindre.

- une adhésion créée par un utilisateur pour lui-même est une
  candidature : `pending_admin_approval`, à valider par un administrateur
- rôle imposé à `member` dans ce cas : sans cela, une candidature pouvait
  se déclarer `owner` de la chorale visée
- aucun email d'invitation n'est envoyé pour une candidature
- l'invitation, elle, garde son comportement : `pending_user_approval`,
  token pour un email externe, notification pour un compte existant

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:02:01 +02:00
admin e168b6c826 0.13.21 : create the owner membership when a choral is created
Build release Docker image / Build Docker Images (push) Successful in 7m57s
Le créateur d'une chorale n'avait aucune adhésion : la chorale
n'apparaissait pas dans « Mes chorales » et son créateur n'en était pas
propriétaire — il n'avait donc aucun droit sur ce qu'il venait de créer.
Seule la recherche la montrait.

Le controller crée désormais une choral-membership `owner` / `active`
après la création, et journalise l'anomalie si l'id de la chorale n'est
pas exploitable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 11:51:36 +02:00
admin c0a0966557 0.13.20 : check environment variables at boot
Build release Docker image / Build Docker Images (push) Successful in 7m58s
Trois erreurs de configuration successives — clés R2 pointant sur
l'ancien MinIO, `wwhsec_` au lieu de `whsec_`, `k_test_` amputé de son
`s` — n'ont produit aucun message exploitable. Chacune se manifestait
très loin de sa cause : un upload qui échoue, un webhook rejeté.

src/env-check.ts vérifie au bootstrap la forme des variables sensibles
(Stripe, R2, SMTP) : présence, préfixe, longueur, et les altérations de
copier-coller (guillemets englobants, espaces parasites). Les trois
erreurs ci-dessus sont détectées.

Ne bloque pas le démarrage — une clé SMTP erronée ne doit pas empêcher
le site de servir — mais journalise en erreur, une ligne par anomalie.
Aucune valeur n'est journalisée, uniquement des longueurs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:23:51 +02:00
admin 57faa37e70 0.13.19 : tell apart the two causes of a Stripe signature failure
Build release Docker image / Build Docker Images (push) Successful in 8m27s
Un corps brut absent et un secret de signature erroné produisent des
messages Stripe proches, alors que les correctifs sont opposés. Le log
d'échec indique désormais si le corps brut est présent et sa taille, si
l'en-tête stripe-signature est là, et si STRIPE_WEBHOOK_SECRET est défini
(sa longueur, jamais sa valeur).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 00:39:07 +02:00
admin 87842c7585 0.13.18 : stop leaking password hash and tokens from /users/me
Le controller `me` renvoyait la ligne brute de `strapi.db.query`, sans
passer par le sanitizer utilisé par `findOne` : le hash du mot de passe,
`resetPasswordToken` et `confirmationToken` étaient envoyés au navigateur
à chaque appel — et /users/me est appelé sur presque toutes les pages.

Les trois champs sont retirés explicitement plutôt que via
`sanitizeUser` : le sanitizer de l'API de contenu retirerait aussi les
relations peuplées que le front consomme dans cette réponse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 00:33:05 +02:00
admin 3da128278d 0.13.17 : make the Stripe webhook say when it credits nothing
Un abonnement premium souscrit en test aboutissait chez Stripe sans
qu'aucune commande ni aucun droit n'apparaisse en base — et sans la
moindre trace côté serveur.

- `metadata.userId` absent faisait sortir le handler en silence avec un
  200 : c'est désormais journalisé en erreur, avec l'id de session Stripe
- l'utilisateur est résolu une fois par `documentId` (ce que le front
  envoie) : la relation `user` d'une commande attend un id numérique,
  elle recevait un documentId
- un utilisateur introuvable est journalisé au lieu d'échouer plus loin
- les types d'événements ignorés sont tracés, pour distinguer « Stripe
  n'appelle pas » de « Stripe appelle sans checkout.session.completed »

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 00:32:19 +02:00
admin 883b827b0d docs: warn about custom create controllers bypassing files.<attr>
Piège rencontré sur la publication d'annonce : ad/post/group/chat-message
lisent ctx.request.files.<attribut> directement. Envoyer files.<attribut>
n'échoue pas, les fichiers sont simplement ignorés.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 00:15:35 +02:00
admin 5be8873ad5 docs: note that REST-populated relations need find permission
Règle apprise en corrigeant les commentaires invisibles sur la page de
détail d'une publication (0.13.16) : le sanitizer de l'API REST retire
sans erreur les relations peuplées vers un content-type non lisible par
le rôle. Les controllers custom qui répondent via ctx.send() ne passent
pas par ce sanitizer et masquent le problème.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:57:34 +02:00
admin 5a8a7d9f66 0.13.16 : grant read permission on comments
Build release Docker image / Build Docker Images (push) Successful in 7m56s
Les commentaires n'apparaissaient pas sur la page de détail d'une
publication, alors qu'ils s'affichent dans le fil.

`comment` n'avait aucune permission déclarée. Le fil passe par le
controller custom `feed`, qui renvoie ses résultats via ctx.send() sans
sanitizer d'API ; la page de détail passe par l'API REST standard, dont
le sanitizer retire les relations peuplées vers un content-type que le
rôle n'a pas le droit de lire.

Lecture seule : l'écriture reste passée par `post.addComment`, qui
contrôle l'auteur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:36:34 +02:00
admin 126e9332ad 0.13.15 : default profileVisibility to public
`configuration.privacy.profileVisibility` n'avait pas de valeur par
défaut : un composant `privacy` créé sans choix explicite laissait le
champ nul, que la recherche d'utilisateurs traitait comme non-public.
L'écran de confidentialité du front affiche « public » dans ce cas —
le schéma s'aligne.

Ne rétroagit pas sur les utilisateurs existants, qui n'ont aucun
composant `privacy` : c'est le front (0.18.3) qui les traite comme
publics.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:15:08 +02:00
admin 8e672633dc 0.13.14 : fix media uploads, move plugin secrets to env vars
Build release Docker image / Build Docker Images (push) Successful in 7m54s
Tous les uploads échouaient depuis mai 2026 sur « Credential access key
has length 5, should be 32 ».

config/env/production/plugins.ts surchargeait l'upload avec l'ancien
MinIO auto-hébergé (accessKeyId "admin", 5 caractères, endpoint
container.harmonylab.ovh aujourd'hui injoignable). Strapi fusionnant les
surcharges d'environnement en profondeur, la config effective en prod
n'était ni l'une ni l'autre : endpoint R2 hérité de config/plugins.ts,
mais identifiants MinIO, le provider lisant s3Options.credentials en
priorité sur les options à plat.

- surcharge production supprimée : une seule config plugins, les
  différences d'environnement passent par les variables d'environnement
- upload en forme s3Options attendue par @strapi/provider-upload-aws-s3 5.x
  (à plat, le provider n'accepte plus qu'au prix d'une dépréciation)
- secrets R2 et SMTP sortis du fichier vers env(), .env.example à jour
- CSP de production : les médias étaient autorisés depuis
  container.harmonylab.ovh et 192.168.0.211:9000, jamais depuis le domaine
  R2 — l'hôte est désormais dérivé de R2_PUBLIC_URL
- la surcharge production des middlewares réduisait strapi::body à sa forme
  par défaut, perdant includeUnparsed nécessaire au webhook Stripe

⚠️ Déploiement : poser R2_* et SMTP_PASSWORD dans Dokploy AVANT de
déployer. Les clés historiques restent dans l'historique git : rotation
R2 et ZeptoMail à faire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 18:44:58 +02:00
admin ca66bec45b 0.13.13 : fix Stripe entitlement loop (maxChoirs never credited)
Build release Docker image / Build Docker Images (push) Successful in 7m32s
- webhook : crédite maxChoirs selon le plan (pro=1, premium=5), écrit
  uniquement des champs existants du schéma (isPremium supprimé,
  subscriptionId -> stripeSubscriptionId)
- schéma user : ajout stripeCustomerId / stripeSubscriptionId (private)
- controller choral.create : contrôle du crédit côté serveur + décrément
  après création

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 04:14:52 +02:00
admin 51947d49e7 0.13.12 : sync users-permissions roles at bootstrap
Build release Docker image / Build Docker Images (push) Failing after 2m2s
- src/permissions-sync.ts : source de vérité versionnée des permissions
  (85 authenticated / 9 public, issues de l'audit AUDIT_PERMISSIONS.md) ;
  synchro additive et idempotente au démarrage, extras logués jamais supprimés
- plugin::users-permissions.user.update volontairement non accordé (sécurité)
- CLAUDE.md : doc du repo (stack, content-types, règles sécurité)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 00:47:04 +02:00
admin 30c4c4ed3c 0.13.11 : change register template an improve in user extension
Build release Docker image / Build Docker Images (push) Successful in 8m20s
2026-05-05 13:55:21 +02:00
admin ca2604d0fb Fix dokploy url
Build release Docker image / Build Docker Images (push) Successful in 5m21s
2026-05-04 21:46:09 +02:00
admin 574d81ac54 New upload way
Build release Docker image / Build Docker Images (push) Failing after 8m10s
2026-05-04 18:46:41 +02:00
admin 6034c4f07b Use zeptomail in prod
Build release Docker image / Build Docker Images (push) Failing after 10m38s
2026-05-04 09:43:59 +02:00
admin 945ad5d095 Fix strapi url
Build release Docker image / Build Docker Images (push) Failing after 13m22s
2026-05-04 01:05:48 +02:00
admin ce25ba2695 Change registry org
Build release Docker image / Build Docker Images (push) Failing after 7m58s
2026-05-03 22:41:10 +02:00
32 changed files with 2101 additions and 385 deletions
+2 -1
View File
@@ -4,4 +4,5 @@ dist
.tmp .tmp
.cache .cache
.env .env
build build
.git
+25
View File
@@ -5,3 +5,28 @@ API_TOKEN_SALT=tobemodified
ADMIN_JWT_SECRET=tobemodified ADMIN_JWT_SECRET=tobemodified
TRANSFER_TOKEN_SALT=tobemodified TRANSFER_TOKEN_SALT=tobemodified
JWT_SECRET=tobemodified JWT_SECRET=tobemodified
# Base de données — sqlite par défaut en dev, postgres/mysql sinon
DATABASE_CLIENT=sqlite
# Stockage des médias (Cloudflare R2, compatible S3)
R2_ACCESS_KEY_ID=tobemodified
R2_SECRET_ACCESS_KEY=tobemodified
R2_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
R2_BUCKET=choralsync
R2_REGION=auto
# Domaine public servant les médias du bucket
R2_PUBLIC_URL=https://container.choralsync.com
# Email transactionnel (ZeptoMail via nodemailer)
SMTP_HOST=smtp.zeptomail.eu
SMTP_PORT=587
SMTP_USERNAME=emailapikey
SMTP_PASSWORD=tobemodified
SMTP_DEFAULT_FROM="ChoralSync <noreply@choralsync.com>"
SMTP_DEFAULT_REPLY_TO=contact@choralsync.com
SMTP_DEBUG=false
# Stripe
STRIPE_SECRET_KEY=tobemodified
STRIPE_WEBHOOK_SECRET=tobemodified
+2 -2
View File
@@ -5,9 +5,9 @@ on: [push]
env: env:
DOCKER_IMAGE_NAME: harmony-back DOCKER_IMAGE_NAME: harmony-back
DOCKER_REGISTRY_URL: git.harmonylab.ovh DOCKER_REGISTRY_URL: git.harmonylab.ovh
DOCKER_REGISTRY_ORG: harmony DOCKER_REGISTRY_ORG: admin
RELEASE_VERSION: 1.2.0 RELEASE_VERSION: 1.2.0
DOCKPLOY_WEBHOOK_URL: http://192.168.0.220:3000/api/deploy/HHWS7dR5rT-8vbXD6Zypv DOCKPLOY_WEBHOOK_URL: https://dokploy.harmonylab.ovh/api/deploy/fO2GDNPJAoNicWALwiBPa
DISCORD_WEBHOOK_URL: https://discord.com/api/webhooks/1433240422901088339/GAvL79ESBRabkB6rvxN2DsWI74KJ_Szgp9W2_PycPIY113rMCT_9LvIv-iTLCMD9W9qH DISCORD_WEBHOOK_URL: https://discord.com/api/webhooks/1433240422901088339/GAvL79ESBRabkB6rvxN2DsWI74KJ_Szgp9W2_PycPIY113rMCT_9LvIv-iTLCMD9W9qH
jobs: jobs:
+152
View File
@@ -0,0 +1,152 @@
# Backend ChoralSync — Strapi
> Racine du repo backend. Vue d'ensemble front + back : `../CLAUDE.md`.
## Stack
- Strapi **5.8.1** (⚠️ API v5 : `documentId`, réponses aplaties — pas de `attributes` imbriqués comme en v4)
- Node **18 à 22** (`engines` du package.json ; v22 utilisée en dev) — gestionnaire : **yarn**
- TypeScript
- Base de données : sélectionnée par `DATABASE_CLIENT`**SQLite** par défaut en dev (better-sqlite3), **PostgreSQL** ou MySQL supportés (voir `config/database.ts`)
- Plugins actifs :
- Users & Permissions (rôles `Public`, `Authenticated`)
- Documentation (`@strapi/plugin-documentation`)
- Email : nodemailer via **ZeptoMail** (SMTP)
- Upload : **aws-s3** vers **Cloudflare R2** (bucket `choralsync`, servi par `container.choralsync.com`)
- `strapi-v5-plugin-populate-deep` (defaultDepth 3)
- Autres : Stripe (paiements, content-type `order`), Puppeteer (génération), cron jobs dans `config/cron-tasks.ts` (ex. récupération d'actualités GNews), templates d'emails dans `src/email-templates/`
## Structure
```
src/
├── api/ # 34 content-types (un dossier chacun)
│ └── <name>/
│ ├── content-types/<name>/schema.json
│ ├── controllers/
│ ├── routes/
│ └── services/
├── components/ # composants Strapi partagés
├── email-templates/ # templates des emails transactionnels
├── extensions/ # overrides : documentation, upload, users-permissions
└── admin/ # customisation admin
config/ # server, database, plugins, middlewares, cron-tasks, api
```
## Content-types
Vue par domaine (32 au total, liste exhaustive dans `src/api/`) :
| Domaine | Content-types |
|---|---|
| Chorale | `choral`, `choral-membership`, `choral-permission` ⚠️, `permissions-template` ⚠️ |
| Social (offre gratuite) | `post`, `post-ownership`, `comment`, `activity`, `report` |
| Groupes | `group`, `group-membership` |
| Événements | `event`, `event-relationship` |
| Boards (kanban) | `board`, `board-list`, `board-card` |
| Messagerie — **deux systèmes distincts**, inventaire détaillé dans `../CLAUDE.md` | générale : `chat-conversation`, `chat-conversation-member`, `chat-message` · chorale : `channel`, `message` |
| Notifications | `notification`, `announcement`, `invite` |
| Paiement | `order` (Stripe — ⚠️ route custom `auth: false` dans `src/api/order/routes/order.ts`, webhook) |
| Contenu / divers | `page`, `legal-page`, `ad`, `contact`, `contact-meta`, `form-template`, `mails` |
<!-- Tableau détaillé (champs sensibles, accès par rôle) à compléter après export des permissions. -->
⚠️ `choral-permission` et `permissions-template` sont **inutilisés** : aucune
référence dans le back ni le front, 0 ligne en base. La gestion des permissions
passe en réalité par le composant `permissions` de `choral-membership`,
`permission_exceptions`, et `choral.available_roles` — c'est là que
`updatePermissionsTemplatesAction` écrit. **Décision du 27/07/2026 : on les
conserve**, comme fondation d'un modèle de permissions plus riche. Ne pas les
proposer à la suppression sans une nouvelle décision. Leurs permissions
`permissions-template.create/delete` restent accordées pour rien.
## Sécurité — règles NON NÉGOCIABLES
- Aucune route custom en `auth: false` sans justification écrite en commentaire
ET mention dans le CLAUDE.md parent. État actuel : 1 occurrence
(`src/api/order/routes/order.ts` — webhook Stripe).
- Tout controller custom qui lit/écrit des données utilisateur DOIT filtrer
par `ctx.state.user.id`. Jamais de `entityService.findMany` sans filtre
dans un contexte authentifié multi-utilisateurs.
- Toujours utiliser `sanitizeOutput` / les sanitizers Strapi avant de retourner
une entité depuis un controller custom (ne jamais renvoyer l'objet brut).
- `populate=*` interdit côté serveur comme côté client : populate explicite.
⚠️ Le plugin populate-deep (defaultDepth 3) est installé — ne pas en abuser,
il peut exposer des relations non prévues.
- Les permissions du plugin Users & Permissions sont versionnées dans
**`src/permissions-sync.ts`** (source de vérité) et synchronisées au
démarrage par le bootstrap (`src/index.ts`) — synchro ADDITIVE et
idempotente : les manquantes sont créées, les permissions en trop sont
seulement loguées (`[permissions-sync]`), jamais supprimées automatiquement.
Toute nouvelle route/action consommée par le front DOIT être ajoutée à ce
fichier (et jamais cochée uniquement dans l'admin, sinon elle sera signalée
comme non déclarée).
- ⚠️ **Les controllers `create` custom qui gèrent des fichiers n'utilisent PAS
la convention `files.<attribut>` de Strapi.** `ad`, `post`, `group` et
`chat-message` lisent directement `ctx.request.files.<attribut>` (donc un
champ multipart nommé `medias`, `media`… et non `files.medias`), uploadent
eux-mêmes puis rattachent les ids. Envoyer `files.<attribut>` à l'un d'eux
ne produit **aucune erreur** : l'entité est créée, les fichiers sont
ignorés en silence. Avant de brancher un formulaire avec upload, lire le
controller du content-type visé. Symptôme : « le contenu s'enregistre mais
pas l'image ».
- ⚠️ **Une relation peuplée via l'API REST exige `find` sur le content-type
cible.** Le sanitizer de l'API REST retire silencieusement les relations
peuplées vers un content-type que le rôle n'a pas le droit de lire — pas
d'erreur, juste un champ vide. C'est ce qui rendait les commentaires
invisibles sur la page de détail d'une publication (corrigé en 0.13.16),
alors qu'ils s'affichaient dans le fil : les controllers custom qui
renvoient via `ctx.send()` ne passent pas par ce sanitizer, et masquent
donc le problème. Symptôme à reconnaître : « ça marche dans le fil, pas
dans le détail ».
- Secrets uniquement en variables d'environnement. `.env` jamais commité ;
`.env.example` maintenu à jour. Depuis le 26/07/2026, `config/plugins.ts`
ne contient plus aucun secret (R2 et SMTP passent par `env()`).
⚠️ **Les clés historiques ont vécu en clair dans l'historique git** :
rotation R2 + ZeptoMail à faire, l'historique n'étant pas réécrit.
- ⚠️ **Pas de `config/env/<env>/plugins.ts`.** Strapi fusionne les surcharges
d'environnement en profondeur : une surcharge partielle de `providerOptions`
avait produit une config d'upload hybride (endpoint R2 de la base +
identifiants MinIO de la surcharge, prioritaires via `s3Options.credentials`)
et cassé tous les uploads de mai à juillet 2026. Les différences entre
environnements passent par les variables d'environnement, pas par un
second fichier. Même vigilance pour `config/env/production/middlewares.ts`,
qui doit répéter la config `strapi::body` (`includeUnparsed`) sous peine de
casser le webhook Stripe.
## Points d'audit récurrents
Checklist à dérouler lors d'une revue sécurité :
1. `grep -rn "auth: false" src/` — chaque occurrence doit être justifiée.
2. Rôle Public dans l'export des permissions : uniquement les find/findOne
réellement nécessaires au site public.
3. Controllers custom : filtrage par user, sanitization, gestion d'erreurs
(pas de stack trace renvoyée au client).
4. Lifecycles (`lifecycles.ts`) : pas de logique qui bypasse les permissions.
5. Champs privés : vérifier `"private": true` dans les schema.json pour les
champs sensibles (ils sont sinon exposés dans les réponses API).
6. Rate limiting / CORS dans `config/middlewares.ts` : origines explicites en prod.
7. API tokens : scope minimal, pas de token full-access utilisé par le front.
8. Webhook Stripe (`order`) : vérification de la signature
(`stripe.webhooks.constructEvent`).
## Commandes
```bash
yarn develop # dev + admin (strapi develop --debug)
yarn build
yarn strapi config:dump -f config/sync/permissions.json # export permissions
yarn strapi config:restore -f config/sync/permissions.json # import permissions
```
## Règles pour Claude
- Toute modification de schema.json = migration implicite : signaler l'impact
(données existantes, types front à mettre à jour dans `harmony-web/types/` et
`harmony-web/interfaces/`).
- Après modification d'une route/permission : mettre à jour le tableau des
content-types ici ET dans le CLAUDE.md parent.
- Ne jamais modifier directement la base de données.
- Strapi **5** : utiliser les `documentId` et le format de réponse v5
(pas de `attributes` imbriqués). Toujours vérifier package.json en cas de doute.
+6
View File
@@ -5,6 +5,12 @@ RUN apk update && apk add --no-cache \
ARG NODE_ENV=production ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV} ENV NODE_ENV=${NODE_ENV}
# On les transforme en variables d'environnement pour le processus de build
ENV NODE_ENV=${NODE_ENV}
ARG URL=https://back.harmonylab.ovh
ENV URL=${URL}
# Strapi v4/v5 utilise aussi souvent celle-ci pour l'admin
ENV STRAPI_ADMIN_BACKEND_URL=${URL}
WORKDIR /opt/app WORKDIR /opt/app
COPY package.json yarn.lock ./ COPY package.json yarn.lock ./
+21 -18
View File
@@ -1,7 +1,19 @@
/**
* Surcharge production : uniquement la politique CSP (les médias sont servis
* par un domaine externe). Le reste de la pile reprend `config/middlewares.ts`
* à l'identique — y compris `includeUnparsed`, indispensable au webhook
* Stripe, que la fusion d'environnement écraserait si on laissait ici un
* simple `"strapi::body"`.
*/
const mediaHost = process.env.R2_PUBLIC_URL
? new URL(process.env.R2_PUBLIC_URL).host
: "container.choralsync.com";
const mediaSources = ["'self'", "data:", "blob:", "market-assets.strapi.io", mediaHost];
export default [ export default [
"strapi::logger", "strapi::logger",
"strapi::errors", "strapi::errors",
//'strapi::security',
{ {
name: "strapi::security", name: "strapi::security",
config: { config: {
@@ -9,22 +21,8 @@ export default [
useDefaults: true, useDefaults: true,
directives: { directives: {
"connect-src": ["'self'", "https:"], "connect-src": ["'self'", "https:"],
"img-src": [ "img-src": mediaSources,
"'self'", "media-src": mediaSources,
"data:",
"blob:",
"market-assets.strapi.io",
"192.168.0.211:9000",
"container.harmonylab.ovh",
],
"media-src": [
"'self'",
"data:",
"blob:",
"market-assets.strapi.io",
"192.168.0.211:9000",
"container.harmonylab.ovh",
],
upgradeInsecureRequests: null, upgradeInsecureRequests: null,
}, },
}, },
@@ -33,7 +31,12 @@ export default [
"strapi::cors", "strapi::cors",
"strapi::poweredBy", "strapi::poweredBy",
"strapi::query", "strapi::query",
"strapi::body", {
name: "strapi::body",
config: {
includeUnparsed: true, // INDISPENSABLE pour les webhooks
},
},
"strapi::session", "strapi::session",
"strapi::favicon", "strapi::favicon",
"strapi::public", "strapi::public",
-42
View File
@@ -1,42 +0,0 @@
export default () => ({
email: {
config: {
provider: "nodemailer",
providerOptions: {
host: "mail.harmonychoral.com",
port: 465,
auth: {
user: "admin@harmonychoral.com",
pass: "Apslxnap12bn23",
},
// ... any custom nodemailer options
},
settings: {
defaultFrom: "admin@harmonychoral.com",
defaultReplyTo: "admin@harmonychoral.com",
},
},
},
upload: {
config: {
provider: "aws-s3",
providerOptions: {
//baseUrl: "http://192.168.0.211:9000/harmony",
baseUrl: "https://container.harmonylab.ovh/harmony",
s3Options: {
credentials: {
accessKeyId: "admin",
secretAccessKey: "Apslxnap12bn23",
},
//endpoint: "http://192.168.0.211:9000",
endpoint: "https://container.harmonylab.ovh",
region: "eu-west-3",
forcePathStyle: true,
params: {
Bucket: "harmony",
},
},
},
},
},
});
+39 -19
View File
@@ -1,20 +1,34 @@
export default () => ({ /**
* Configuration des plugins — source de vérité unique, tous environnements.
*
* ⚠️ Il n'existe volontairement PAS de `config/env/production/plugins.ts` :
* Strapi fusionne les surcharges d'environnement en profondeur, et cette
* fusion avait produit une configuration hybride (endpoint R2 de ce fichier +
* identifiants MinIO de la surcharge, lus en priorité par le provider via
* `s3Options.credentials`). Tous les uploads échouaient depuis mai 2026 sur
* « Credential access key has length 5, should be 32 ». Toute différence
* entre environnements passe désormais par les variables d'environnement.
*/
export default ({ env }) => ({
email: { email: {
config: { config: {
provider: "nodemailer", provider: "nodemailer",
providerOptions: { providerOptions: {
host: "smtp.zeptomail.eu", host: env("SMTP_HOST", "smtp.zeptomail.eu"),
port: 587, port: env.int("SMTP_PORT", 587),
auth: { auth: {
user: "emailapikey", user: env("SMTP_USERNAME", "emailapikey"),
pass: "yA6KbHsJ4lrywWtTFUc+0pSC94lm/aE/2nzks3i2fpZ1LYXp3qE71RBvd4O4c2CLjdfT5a9UbIkVJoCwvIpbfpczPIBXJpTGTuv4P2uV48xh8ciEYNYjhJivALIWFqVOeBsnDyo4QfEjWA==", pass: env("SMTP_PASSWORD"),
}, },
debug: true, debug: env.bool("SMTP_DEBUG", false),
logger: true, logger: env.bool("SMTP_DEBUG", false),
}, },
settings: { settings: {
defaultFrom: "ChoralSync <noreply@choralsync.com>", defaultFrom: env(
defaultReplyTo: "contact@choralsync.com", "SMTP_DEFAULT_FROM",
"ChoralSync <noreply@choralsync.com>",
),
defaultReplyTo: env("SMTP_DEFAULT_REPLY_TO", "contact@choralsync.com"),
}, },
}, },
}, },
@@ -23,16 +37,22 @@ export default () => ({
config: { config: {
provider: "aws-s3", provider: "aws-s3",
providerOptions: { providerOptions: {
baseUrl: "https://container.harmonylab.ovh/harmony", // URL publique servant les médias (domaine personnalisé du bucket).
credentials: { baseUrl: env("R2_PUBLIC_URL"),
accessKeyId: "admin", // Le provider 5.x attend les options S3 sous `s3Options` ; à plat,
secretAccessKey: "Apslxnap12bn23", // elles ne sont acceptées qu'au prix d'un avertissement de dépréciation
}, // — et une surcharge partielle produit une config incohérente.
endpoint: "https://container.harmonylab.ovh", s3Options: {
forcePathStyle: true, credentials: {
region: "eu-west-3", accessKeyId: env("R2_ACCESS_KEY_ID"),
params: { secretAccessKey: env("R2_SECRET_ACCESS_KEY"),
Bucket: "harmony", },
endpoint: env("R2_ENDPOINT"),
region: env("R2_REGION", "auto"),
forcePathStyle: true,
params: {
Bucket: env("R2_BUCKET"),
},
}, },
}, },
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "harmony-back", "name": "harmony-back",
"version": "0.13.10", "version": "0.13.25",
"private": true, "private": true,
"description": "A Strapi application", "description": "A Strapi application",
"scripts": { "scripts": {
@@ -2,6 +2,85 @@
* chat-conversation-member controller * chat-conversation-member controller
*/ */
import { factories } from '@strapi/strapi' import { factories } from "@strapi/strapi";
export default factories.createCoreController('api::chat-conversation-member.chat-conversation-member'); export default factories.createCoreController(
"api::chat-conversation-member.chat-conversation-member",
({ strapi }) => ({
/**
* Conversations de l'utilisateur courant, avec leurs interlocuteurs et
* leurs messages.
*
* Pourquoi une action dédiée plutôt que le `find` générique : le front
* peuplait `conversation` et ses `messages` à travers l'API REST, dont le
* sanitizer retire toute relation vers un content-type que le rôle n'a pas
* le droit de lire. Les conversations revenaient donc vides — ni nom, ni
* interlocuteur, ni message.
*
* Accorder `chat-conversation.find` et `chat-message.find` aurait suffi à
* lever le filtrage, mais aurait ouvert la lecture de **toutes** les
* conversations et de **tous** les messages privés à n'importe quel compte
* connecté. Cette action retourne les données via `ctx.send`, en les
* limitant à l'appelant.
*/
async mine(ctx) {
const userId = ctx.state.user?.id;
if (!userId) {
return ctx.unauthorized();
}
const members = await strapi.db
.query("api::chat-conversation-member.chat-conversation-member")
.findMany({
where: { user: { id: userId } },
populate: {
user: true,
conversation: {
populate: {
users: { populate: { avatar: true } },
messages: {
populate: {
sender: { populate: { avatar: true } },
media: true,
},
},
},
},
},
});
// Les entités portent le mot de passe et les jetons des utilisateurs
// peuplés : on ne renvoie jamais ces champs au navigateur.
const stripUser = (user: any) => {
if (!user) return user;
const {
password,
resetPasswordToken,
confirmationToken,
...safeUser
} = user;
return safeUser;
};
const sanitized = members.map((member: any) => ({
...member,
user: stripUser(member.user),
conversation: member.conversation
? {
...member.conversation,
users: (member.conversation.users ?? []).map(stripUser),
messages: (member.conversation.messages ?? []).map(
(message: any) => ({
...message,
sender: stripUser(message.sender),
})
),
}
: null,
}));
ctx.send(sanitized);
},
})
);
@@ -0,0 +1,16 @@
/**
* Routes personnalisées de chat-conversation-member.
*/
export default {
routes: [
{
method: "GET",
// Chemin distinct : sous /chat-conversation-members/:id, le routeur
// cœur capterait « mine » comme un identifiant (findOne), selon l'ordre
// d'enregistrement des routes.
path: "/my-chat-conversations",
handler: "chat-conversation-member.mine",
},
],
};
@@ -8,18 +8,32 @@ export default factories.createCoreController(
const { data } = ctx.request.body; const { data } = ctx.request.body;
const inviter = ctx.state.user; const inviter = ctx.state.user;
// 1. Déterminer le type d'invitation // 1. Déterminer le sens de la demande.
// Une adhésion peut naître dans deux sens opposés, que ce controller
// confondait : il forçait `pending_user_approval` et envoyait un
// email d'invitation dans tous les cas — y compris à quelqu'un qui
// venait lui-même de demander à rejoindre la chorale.
const isSelfRequest =
!!data.user && !!inviter?.id && String(data.user) === String(inviter.id);
const isExternalInvite = !!data.invite_email && !data.user; const isExternalInvite = !!data.invite_email && !data.user;
const isInternalInvite = !!data.user; const isInternalInvite = !!data.user && !isSelfRequest;
let token = null; let token = null;
let targetEmail = null; let targetEmail = null;
// 2. Configuration commune // 2. Statut selon le sens : c'est à l'autre partie de se prononcer.
// On force le statut "en attente" peut importe le type d'invité if (isSelfRequest) {
ctx.request.body.data.state = "pending_user_approval"; ctx.request.body.data.state = "pending_admin_approval";
if (!data.role) { // Rôle imposé : sans cela, une candidature pourrait se déclarer
// `owner` de la chorale qu'elle demande à rejoindre.
ctx.request.body.data.role = "member"; ctx.request.body.data.role = "member";
ctx.request.body.data.invite_token = null;
ctx.request.body.data.invite_email = null;
} else {
ctx.request.body.data.state = "pending_user_approval";
if (!data.role) {
ctx.request.body.data.role = "member";
}
} }
// 3. Traitement spécifique selon le cas // 3. Traitement spécifique selon le cas
@@ -51,8 +65,10 @@ export default factories.createCoreController(
// 4. Exécution de la création native par Strapi // 4. Exécution de la création native par Strapi
const response = await super.create(ctx); const response = await super.create(ctx);
// 5. Post-traitement : Envoi de l'email // 5. Post-traitement : Envoi de l'email d'invitation.
if (response && targetEmail) { // Jamais pour une candidature : `targetEmail` reste nul dans ce cas,
// on n'écrit pas « vous avez été invité » à qui vient de postuler.
if (response && targetEmail && !isSelfRequest) {
try { try {
const chorals = await strapi const chorals = await strapi
.documents("api::choral.choral") .documents("api::choral.choral")
+41
View File
@@ -8,6 +8,18 @@ export default factories.createCoreController(
"api::choral.choral", "api::choral.choral",
({ strapi }) => ({ ({ strapi }) => ({
async create(ctx) { async create(ctx) {
// Contrôle du crédit de création côté serveur (le front bloque aussi,
// mais seul ce contrôle fait foi).
const user = ctx.state.user;
if (!user) {
return ctx.unauthorized();
}
if (!user.maxChoirs || user.maxChoirs <= 0) {
return ctx.forbidden(
"Plus de crédit de création de chorale. Mettez à jour votre abonnement.",
);
}
const data = JSON.parse(ctx.request.body.data); const data = JSON.parse(ctx.request.body.data);
if (ctx.request.files.coverImage) { if (ctx.request.files.coverImage) {
@@ -31,6 +43,35 @@ export default factories.createCoreController(
ctx.request.body = { data }; ctx.request.body = { data };
const result = await super.create(ctx); const result = await super.create(ctx);
const choralId = (result as any)?.data?.id;
// Adhésion propriétaire : sans elle, le créateur n'apparaît pas dans
// « Mes chorales » et n'a aucun droit sur ce qu'il vient de créer.
if (choralId) {
await strapi.db.query("api::choral-membership.choral-membership").create({
data: {
user: user.id,
choral: choralId,
role: "owner",
state: "active",
},
});
strapi.log.info(
`✅ Chorale ${choralId} créée par l'utilisateur ${user.id}, adhésion propriétaire enregistrée`,
);
} else {
strapi.log.error(
`❌ Chorale créée sans id exploitable : aucune adhésion propriétaire pour l'utilisateur ${user.id}`,
);
}
// Décrémente le crédit de création une fois la chorale créée.
await strapi.db.query("plugin::users-permissions.user").update({
where: { id: user.id },
data: { maxChoirs: user.maxChoirs - 1 },
});
return result; return result;
}, },
}), }),
@@ -1,32 +0,0 @@
{
"kind": "collectionType",
"collectionName": "conversations",
"info": {
"singularName": "conversation",
"pluralName": "conversations",
"displayName": "Conversation",
"description": ""
},
"options": {
"draftAndPublish": false
},
"pluginOptions": {},
"attributes": {
"user1": {
"type": "relation",
"relation": "oneToOne",
"target": "plugin::users-permissions.user"
},
"user2": {
"type": "relation",
"relation": "oneToOne",
"target": "plugin::users-permissions.user"
},
"direct_messages": {
"type": "relation",
"relation": "oneToMany",
"target": "api::direct-message.direct-message",
"mappedBy": "conversation"
}
}
}
@@ -1,7 +0,0 @@
/**
* conversation controller
*/
import { factories } from '@strapi/strapi'
export default factories.createCoreController('api::conversation.conversation');
@@ -1,7 +0,0 @@
/**
* conversation router
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::conversation.conversation');
@@ -1,7 +0,0 @@
/**
* conversation service
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::conversation.conversation');
@@ -1,33 +0,0 @@
{
"kind": "collectionType",
"collectionName": "direct_messages",
"info": {
"singularName": "direct-message",
"pluralName": "direct-messages",
"displayName": "DirectMessage",
"description": ""
},
"options": {
"draftAndPublish": false
},
"pluginOptions": {},
"attributes": {
"content": {
"type": "string"
},
"fileUrl": {
"type": "string"
},
"author": {
"type": "relation",
"relation": "oneToOne",
"target": "plugin::users-permissions.user"
},
"conversation": {
"type": "relation",
"relation": "manyToOne",
"target": "api::conversation.conversation",
"inversedBy": "direct_messages"
}
}
}
@@ -1,7 +0,0 @@
/**
* direct-message controller
*/
import { factories } from '@strapi/strapi'
export default factories.createCoreController('api::direct-message.direct-message');
@@ -1,7 +0,0 @@
/**
* direct-message router
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::direct-message.direct-message');
@@ -1,7 +0,0 @@
/**
* direct-message service
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::direct-message.direct-message');
+59 -7
View File
@@ -18,7 +18,24 @@ export default factories.createCoreController(
process.env.STRIPE_WEBHOOK_SECRET!, process.env.STRIPE_WEBHOOK_SECRET!,
); );
} catch (err: any) { } catch (err: any) {
strapi.log.error(`❌ Erreur de signature Webhook: ${err.message}`); // Deux causes très différentes se ressemblent ici. Corps brut absent
// (middleware `strapi::body` sans `includeUnparsed`) : Stripe se
// plaint du payload. Corps brut présent mais signature invalide :
// c'est le secret qui ne correspond pas à l'endpoint appelant —
// chaque endpoint Stripe a le sien.
strapi.log.error(
`❌ Erreur de signature Webhook: ${err.message} ` +
`[corps brut: ${
typeof unparsedBody === "string" || Buffer.isBuffer(unparsedBody)
? `présent, ${unparsedBody.length} octets`
: `ABSENT (${typeof unparsedBody})`
}, en-tête stripe-signature: ${sig ? "présent" : "ABSENT"}, ` +
`STRIPE_WEBHOOK_SECRET: ${
process.env.STRIPE_WEBHOOK_SECRET
? `défini (${process.env.STRIPE_WEBHOOK_SECRET.length} car.)`
: "NON DÉFINI"
}]`,
);
return ctx.badRequest(`Webhook Error: ${err.message}`); return ctx.badRequest(`Webhook Error: ${err.message}`);
} }
@@ -34,37 +51,72 @@ export default factories.createCoreController(
| "pro" | "pro"
| "premium"; | "premium";
if (userId) { // Capacité de création de chorales par plan (cf. offres du front,
// UserSubscriptionTab : pro = "1 chorale", premium = "5 chorales").
const PLAN_MAX_CHOIRS: Record<string, number> = {
free: 0,
pro: 1,
premium: 5,
};
if (!userId) {
// Sans `userId` dans les métadonnées, l'ancien code sortait en
// silence avec un 200 : le paiement aboutissait chez Stripe sans
// qu'aucune commande ni aucun droit n'apparaisse, et sans trace.
strapi.log.error(
`❌ Webhook ${event.type} sans metadata.userId (session ${session.id}) : aucun droit crédité`,
);
} else {
try { try {
// Les métadonnées portent un `documentId` (cf. le front,
// UserSubscriptionTab). La relation `user` d'une commande attend
// un id numérique : on résout l'utilisateur une fois pour les deux.
const user = await strapi.db
.query("plugin::users-permissions.user")
.findOne({ where: { documentId: userId } });
if (!user) {
strapi.log.error(
`❌ Webhook : aucun utilisateur pour documentId ${userId} (session ${session.id})`,
);
ctx.send({ received: true });
return;
}
// Création de la commande // Création de la commande
await strapi.documents("api::order.order").create({ await strapi.documents("api::order.order").create({
data: { data: {
stripeId: session.id, stripeId: session.id,
amount: session.amount_total ? session.amount_total / 100 : 0, amount: session.amount_total ? session.amount_total / 100 : 0,
planType: planType, planType: planType,
user: userId, user: user.id,
}, },
}); });
// Mise à jour de l'utilisateur // Mise à jour de l'utilisateur : plan + crédit de création de
// chorales + identifiants Stripe (résiliation/portail client).
await strapi.documents("plugin::users-permissions.user").update({ await strapi.documents("plugin::users-permissions.user").update({
documentId: userId, documentId: userId,
data: { data: {
isPremium: true,
subscriptionPlan: planType, subscriptionPlan: planType,
maxChoirs: PLAN_MAX_CHOIRS[planType] ?? 0,
stripeCustomerId: session.customer as string, stripeCustomerId: session.customer as string,
subscriptionId: session.subscription as string, stripeSubscriptionId: session.subscription as string,
trialStartedAt: null, trialStartedAt: null,
}, },
}); });
strapi.log.info( strapi.log.info(
`✅ Droits mis à jour pour l'utilisateur ${userId}`, `✅ Droits mis à jour pour l'utilisateur ${userId} : plan ${planType}, maxChoirs ${PLAN_MAX_CHOIRS[planType] ?? 0}`,
); );
} catch (dbError) { } catch (dbError) {
strapi.log.error(`❌ Erreur base de données : ${dbError.message}`); strapi.log.error(`❌ Erreur base de données : ${dbError.message}`);
// On répond quand même 200 à Stripe pour éviter les retries infinis // On répond quand même 200 à Stripe pour éviter les retries infinis
} }
} }
} else {
// Permet de distinguer « Stripe n'appelle pas le webhook » de
// « Stripe appelle mais n'envoie pas checkout.session.completed ».
strapi.log.info(`️ Événement ${event.type} ignoré`);
} }
// INDISPENSABLE : Répondre 200 OK à Stripe pour TOUS les événements // INDISPENSABLE : Répondre 200 OK à Stripe pour TOUS les événements
+2 -1
View File
@@ -13,7 +13,8 @@
"public", "public",
"contacts", "contacts",
"private" "private"
] ],
"default": "public"
}, },
"allowMessage": { "allowMessage": {
"type": "boolean", "type": "boolean",
+222
View File
@@ -0,0 +1,222 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Confirmation d'e-mail - ChoralSync</title>
<!--[if mso]>
<style type="text/css">
body,
table,
td,
a {
font-family: Arial, Helvetica, sans-serif !important;
}
</style>
<![endif]-->
</head>
<body
style="
margin: 0;
padding: 0;
background-color: #f9fafb;
font-family:
&quot;Inter&quot;,
-apple-system,
BlinkMacSystemFont,
&quot;Segoe UI&quot;,
Roboto,
Helvetica,
Arial,
sans-serif;
"
>
<table
width="100%"
border="0"
cellspacing="0"
cellpadding="0"
style="background-color: #f9fafb; padding: 40px 20px"
>
<tr>
<td align="center">
<!-- Main Container -->
<table
width="100%"
border="0"
cellspacing="0"
cellpadding="0"
style="
max-width: 650px;
background-color: #ffffff;
border-radius: 16px;
border: 1px solid #e2e8f0;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
"
>
<!-- Header -->
<tr>
<td style="padding: 32px 32px 16px 32px">
<div
style="
font-size: 24px;
font-weight: 700;
color: #111827;
letter-spacing: -0.025em;
"
>
ChoralSync
</div>
<div
style="
font-size: 14px;
color: #6b7280;
font-weight: 500;
margin-top: 4px;
"
>
Bienvenue — plus quune étape
</div>
</td>
</tr>
<!-- Content -->
<tr>
<td style="padding: 0 32px 32px 32px">
<h1
style="
font-size: 24px;
font-weight: 700;
color: #111827;
margin-top: 32px;
margin-bottom: 16px;
"
>
Bonjour {{USER_NAME}},
</h1>
<p
style="
font-size: 18px;
line-height: 1.6;
color: #4b5563;
margin-bottom: 32px;
"
>
Merci de t'être inscrit. Clique sur le bouton ci-dessous pour
confirmer ton adresse e-mail et activer ton compte.
</p>
<!-- CTA Button -->
<table
border="0"
cellspacing="0"
cellpadding="0"
style="margin-bottom: 40px"
>
<tr>
<td
align="center"
bgcolor="#0b1c30"
style="border-radius: 12px"
>
<a
href="{{CONFIRM_URL}}"
target="_blank"
style="
display: inline-block;
padding: 16px 32px;
font-size: 18px;
font-weight: 600;
color: #ffffff;
text-decoration: none;
border-radius: 12px;
"
>
Confirmer mon e-mail →
</a>
</td>
</tr>
</table>
<!-- Fallback Link -->
<div style="margin-top: 32px">
<p
style="font-size: 14px; color: #6b7280; margin-bottom: 12px"
>
Si le bouton ne fonctionne pas, copie-colle ce lien dans ton
navigateur :
</p>
<div
style="
background-color: #f1f5f9;
border-radius: 12px;
padding: 16px;
border: 1px solid #e2e8f0;
"
>
<a
href="{{CONFIRM_URL}}"
style="
color: #0ea5e9;
font-size: 14px;
text-decoration: underline;
word-break: break-all;
font-weight: 500;
"
>
{{CONFIRM_URL}}
</a>
</div>
</div>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding: 24px 32px; border-top: 1px solid #f1f5f9">
<p style="font-size: 14px; color: #9ca3af; margin: 0 0 8px 0">
Si tu n'es pas à l'origine de cette inscription, ignore cet
e-mail.
</p>
<p style="font-size: 14px; color: #9ca3af; margin: 0">
© ChoralSync 2026 •
<a
href="https://www.choralsync.com"
style="color: #9ca3af; text-decoration: underline"
>www.choralsync.com</a
>
</p>
</td>
</tr>
</table>
<!-- External Footer -->
<table
width="100%"
border="0"
cellspacing="0"
cellpadding="0"
style="max-width: 650px; margin-top: 32px"
>
<tr>
<td align="center" style="font-size: 14px; color: #9ca3af">
Besoin d'aide ? Réponds directement à cet e-mail ou consulte
notre
<a
href="#"
style="
color: #0ea5e9;
font-weight: 500;
text-decoration: underline;
"
>centre d'aide</a
>.
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+164
View File
@@ -0,0 +1,164 @@
import type { Core } from "@strapi/strapi";
/**
* Contrôle des variables d'environnement au démarrage.
*
* Motivation : trois erreurs de configuration successives (clés R2 pointant
* sur l'ancien MinIO, `wwhsec_` au lieu de `whsec_`, `k_test_` amputé de son
* `s`) n'ont produit aucun message exploitable. Chacune s'est manifestée très
* loin de sa cause — un upload qui échoue, un webhook rejeté — et a coûté des
* heures de diagnostic.
*
* Ce module ne vérifie que ce qui est vérifiable sans appel réseau : présence,
* préfixe, longueur, et les altérations de copier-coller (guillemets
* englobants, espaces parasites). Il ne valide évidemment pas qu'une clé est
* la *bonne* — seulement qu'elle a la forme attendue.
*
* Il ne bloque pas le démarrage : une clé SMTP erronée ne doit pas empêcher
* le site de servir. Il journalise en erreur, ce qui suffit à orienter le
* diagnostic dès le boot.
*
* ⚠️ Aucune valeur n'est journalisée, jamais — uniquement des longueurs.
*/
type EnvRule = {
name: string;
/** Préfixe attendu, le cas échéant. */
prefix?: string;
/** Longueur exacte attendue. */
length?: number;
/** Longueur minimale attendue. */
minLength?: number;
/** Contexte affiché dans le message, pour orienter la correction. */
usedFor: string;
};
const ENV_RULES: EnvRule[] = [
// --- Paiement
{
name: "STRIPE_SECRET_KEY",
prefix: "sk_",
minLength: 20,
usedFor: "appels à l'API Stripe",
},
{
name: "STRIPE_WEBHOOK_SECRET",
prefix: "whsec_",
minLength: 30,
usedFor: "vérification de signature des webhooks Stripe",
},
// --- Stockage des médias (Cloudflare R2)
{
name: "R2_ACCESS_KEY_ID",
length: 32,
usedFor: "upload des médias",
},
{
name: "R2_SECRET_ACCESS_KEY",
length: 64,
usedFor: "upload des médias",
},
{
name: "R2_ENDPOINT",
prefix: "https://",
usedFor: "upload des médias",
},
{
name: "R2_BUCKET",
minLength: 1,
usedFor: "upload des médias",
},
{
name: "R2_PUBLIC_URL",
prefix: "https://",
usedFor: "URLs publiques des médias",
},
// --- Email transactionnel
{
name: "SMTP_PASSWORD",
minLength: 10,
usedFor: "envoi des emails transactionnels",
},
];
/** Guillemets englobants ou espaces parasites laissés par un copier-coller. */
const findCopyPasteDamage = (raw: string): string | null => {
if (raw !== raw.trim()) {
return "espace ou retour à la ligne en début ou fin de valeur";
}
const quoted =
(raw.startsWith('"') && raw.endsWith('"')) ||
(raw.startsWith("'") && raw.endsWith("'"));
if (quoted && raw.length > 1) {
return "valeur entourée de guillemets (ils font partie de la valeur)";
}
return null;
};
const checkRule = (rule: EnvRule): string[] => {
const raw = process.env[rule.name];
if (raw === undefined || raw === "") {
return [`${rule.name} n'est pas définie — ${rule.usedFor}`];
}
const problems: string[] = [];
const damage = findCopyPasteDamage(raw);
if (damage) {
problems.push(`${rule.name} : ${damage}`);
}
const value = raw.trim();
if (rule.prefix && !value.startsWith(rule.prefix)) {
problems.push(
`${rule.name} ne commence pas par « ${rule.prefix} » — ${rule.usedFor}`,
);
}
if (rule.length !== undefined && value.length !== rule.length) {
problems.push(
`${rule.name} fait ${value.length} caractères, ${rule.length} attendus — ${rule.usedFor}`,
);
}
if (rule.minLength !== undefined && value.length < rule.minLength) {
problems.push(
`${rule.name} fait ${value.length} caractères, au moins ${rule.minLength} attendus — ${rule.usedFor}`,
);
}
return problems;
};
/**
* Vérifie la forme des variables d'environnement et journalise les anomalies.
* @returns le nombre d'anomalies détectées
*/
export const checkEnvironment = (strapi: Core.Strapi): number => {
const problems = ENV_RULES.flatMap(checkRule);
if (problems.length === 0) {
strapi.log.info(
`[env-check] ${ENV_RULES.length} variables vérifiées, aucune anomalie de forme`,
);
return 0;
}
strapi.log.error(
`[env-check] ${problems.length} anomalie(s) détectée(s) dans les variables d'environnement :`,
);
for (const problem of problems) {
strapi.log.error(`[env-check] ✗ ${problem}`);
}
strapi.log.error(
"[env-check] Ces valeurs viennent de l'environnement de déploiement " +
"(Dokploy en production, .env en local). Une variable modifiée n'est " +
"relue qu'au redémarrage du processus.",
);
return problems.length;
};
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
module.exports = (plugin) => {
// 1. LE SECRET : On ajoute le champ 'path' au schéma officiel de Strapi.
// Strapi va ainsi créer la colonne en BDD et arrêter de supprimer notre variable.
plugin.contentTypes.file.schema.attributes.path = {
type: "string",
configurable: false,
};
const originalServiceFactory = plugin.services.upload;
plugin.services.upload = ({ strapi }) => {
const service = originalServiceFactory({ strapi });
return {
...service,
// 2. Surcharge de l'Uploader
async upload(fileData, usage) {
const ctx = strapi.requestContext.get();
// On récupère le custom path envoyé par ton client Next.js
const customPath = ctx?.request?.body?.path;
if (customPath) {
// Pour que le provider S3 l'utilise tout de suite (R2)
fileData.path = customPath;
// Pour que Strapi l'enregistre dans notre nouvelle colonne BDD
if (!fileData.fileInfo) fileData.fileInfo = {};
fileData.fileInfo.path = customPath;
}
// Le service original va maintenant sauvegarder le path en BDD !
return service.upload(fileData, usage);
},
// Note : Plus besoin de surcharger `remove` (l'équivalent de delete) !
// Comme la colonne 'path' est maintenant en BDD, le service original
// récupère l'info et la passe au provider S3 qui supprime le bon dossier.
};
};
return plugin;
};
@@ -267,6 +267,14 @@
}, },
"smartTags": { "smartTags": {
"type": "json" "type": "json"
},
"stripeCustomerId": {
"type": "string",
"private": true
},
"stripeSubscriptionId": {
"type": "string",
"private": true
} }
} }
} }
+111 -174
View File
@@ -1,7 +1,7 @@
"use strict"; "use strict";
const lod = require("lodash"); const lod = require("lodash");
const utils = require("@strapi/utils"); const utils = require("@strapi/utils");
const { concat, compact, isArray, toNumber, getOr } = require("lodash/fp"); const { toNumber, getOr } = require("lodash/fp");
const cryptoLib = require("crypto"); const cryptoLib = require("crypto");
const bcrypt = require("bcryptjs"); const bcrypt = require("bcryptjs");
const fs = require("fs").promises; const fs = require("fs").promises;
@@ -9,16 +9,60 @@ const path = require("path");
module.exports = (plugin) => { module.exports = (plugin) => {
const rawProviders = plugin.services.providers({ strapi }); const rawProviders = plugin.services.providers({ strapi });
const { ApplicationError, ValidationError, ForbiddenError } = utils.errors; const { ApplicationError, ValidationError } = utils.errors;
const USER_MODEL_UID = "plugin::users-permissions.user"; const USER_MODEL_UID = "plugin::users-permissions.user";
const sanitizeUser = (user, ctx) => { const sanitizeUser = async (user, ctx) => {
const { auth } = ctx.state; const { auth } = ctx.state;
const userSchema = strapi.getModel("plugin::users-permissions.user"); const userSchema = strapi.getModel("plugin::users-permissions.user");
return strapi.contentAPI.sanitize.output(user, userSchema, { auth }); return strapi.contentAPI.sanitize.output(user, userSchema, { auth });
}; };
const getUserStats = async (userId) => {
const [
contactsCount,
groupsCount,
postsCount,
eventsCount,
followersCount,
followingCount,
] = await Promise.all([
strapi.db.query("api::contact.contact").count({
where: {
$or: [{ owner: userId }, { user: userId }],
},
}),
strapi.db.query("api::group-membership.group-membership").count({
where: {
user: userId,
role: { $in: ["owner", "member", "admin"] },
},
}),
strapi.db.query("api::post-ownership.post-ownership").count({
where: { author: userId },
}),
strapi.db.query("api::event-relationship.event-relationship").count({
where: { author: userId },
}),
strapi.db.query("api::contact.contact").count({
where: { user: userId, state: "follow" },
}),
strapi.db.query("api::contact.contact").count({
where: { owner: userId, state: "follow" },
}),
]);
return {
contacts: contactsCount,
groups: groupsCount,
posts: postsCount,
events: eventsCount,
followers: followersCount,
following: followingCount,
};
};
const ensureHashedPasswords = async (values) => { const ensureHashedPasswords = async (values) => {
const attributes = strapi.getModel(USER_MODEL_UID).attributes; const attributes = strapi.getModel(USER_MODEL_UID).attributes;
@@ -26,7 +70,7 @@ module.exports = (plugin) => {
if (attributes[key] && attributes[key].type === "password") { if (attributes[key] && attributes[key].type === "password") {
// Check if a custom encryption.rounds has been set on the password attribute // Check if a custom encryption.rounds has been set on the password attribute
const rounds = toNumber( const rounds = toNumber(
getOr(10, "encryption.rounds", attributes[key]) getOr(10, "encryption.rounds", attributes[key]),
); );
values[key] = await bcrypt.hash(values[key], rounds); values[key] = await bcrypt.hash(values[key], rounds);
} }
@@ -78,7 +122,7 @@ module.exports = (plugin) => {
to: user.email, to: user.email,
subject: onCreateUser?.subject ?? "Confirme ton adresse e-mail", subject: onCreateUser?.subject ?? "Confirme ton adresse e-mail",
html, html,
from: "ChoralSync <admin@harmonychoral.com>", from: "ChoralSync <contact@choralsync.com>",
}); });
return { ok: true }; return { ok: true };
@@ -111,7 +155,7 @@ module.exports = (plugin) => {
query.access_token || query.code || query.oauth_token; query.access_token || query.code || query.oauth_token;
if (!accessToken) { if (!accessToken) {
throw new Error("No access_token."); throw new ApplicationError("No access_token.");
} }
const profile = await getProfile(provider, query); const profile = await getProfile(provider, query);
@@ -119,7 +163,7 @@ module.exports = (plugin) => {
const email = lod.toLower(profile.email); const email = lod.toLower(profile.email);
if (!email) { if (!email) {
throw new Error("Email was not available."); throw new ApplicationError("Email was not available.");
} }
const users = await strapi.db const users = await strapi.db
@@ -142,7 +186,7 @@ module.exports = (plugin) => {
} }
if (lod.isEmpty(user) && !advancedSettings.allow_register) { if (lod.isEmpty(user) && !advancedSettings.allow_register) {
throw new Error("Register action is actually not available."); throw new ApplicationError("Register action is actually not available.");
} }
if (!lod.isEmpty(user)) { if (!lod.isEmpty(user)) {
@@ -150,7 +194,7 @@ module.exports = (plugin) => {
} }
if (users.length && advancedSettings.unique_email) { if (users.length && advancedSettings.unique_email) {
throw new Error("Email is already taken."); throw new ApplicationError("Email is already taken.");
} }
const defaultRole = await strapi.db const defaultRole = await strapi.db
@@ -174,8 +218,6 @@ module.exports = (plugin) => {
}; };
}; };
const originalMe = plugin.controllers.user.me;
plugin.controllers.user.me = async (ctx) => { plugin.controllers.user.me = async (ctx) => {
const fullUser = await strapi.db const fullUser = await strapi.db
.query("plugin::users-permissions.user") .query("plugin::users-permissions.user")
@@ -186,14 +228,14 @@ module.exports = (plugin) => {
if (fullUser.contacts && Array.isArray(fullUser.contacts)) { if (fullUser.contacts && Array.isArray(fullUser.contacts)) {
fullUser.contacts = fullUser.contacts.filter( fullUser.contacts = fullUser.contacts.filter(
(contact) => contact.state === "accepted" (contact) => contact.state === "accepted",
); );
} }
if (fullUser.post_ownerships && Array.isArray(fullUser.post_ownerships)) { if (fullUser.post_ownerships && Array.isArray(fullUser.post_ownerships)) {
fullUser.post_ownerships = fullUser.post_ownerships.filter( fullUser.post_ownerships = fullUser.post_ownerships.filter(
(ownership) => (ownership) =>
ownership.contextType === "user" && ownership.relation === "owner" ownership.contextType === "user" && ownership.relation === "owner",
); );
} }
@@ -202,62 +244,28 @@ module.exports = (plugin) => {
Array.isArray(fullUser.group_memberships) Array.isArray(fullUser.group_memberships)
) { ) {
fullUser.group_memberships = fullUser.group_memberships.filter( fullUser.group_memberships = fullUser.group_memberships.filter(
(membership) => ["member", "admin", "owner"].includes(membership.role) (membership) => ["member", "admin", "owner"].includes(membership.role),
); );
} }
const user = ctx.state.user; const stats = await getUserStats(ctx.state.user.id);
const userId = user.id; // `strapi.db.query` renvoie la ligne brute : sans cette omission, /users/me
// exposait au navigateur le hash du mot de passe et les jetons de
// réinitialisation et de confirmation.
// On retire ces champs explicitement plutôt que de passer par
// `sanitizeUser` : le sanitizer de l'API de contenu retirerait aussi les
// relations peuplées que le front consomme ici.
const {
password: _password,
resetPasswordToken: _resetPasswordToken,
confirmationToken: _confirmationToken,
...safeUser
} = JSON.parse(JSON.stringify(fullUser));
const [
contactsCount,
groupsCount,
postsCount,
eventsCount,
followersCount,
followingCount,
] = await Promise.all([
strapi.db.query("api::contact.contact").count({
where: {
$or: [{ owner: userId }, { user: userId }],
},
}),
strapi.db.query("api::group-membership.group-membership").count({
where: {
user: userId,
role: { $in: ["owner", "member", "admin"] },
},
}),
strapi.db
.query("api::post-ownership.post-ownership")
.count({ where: { author: userId } }),
strapi.db
.query("api::event-relationship.event-relationship")
.count({ where: { author: userId } }),
strapi.db.query("api::contact.contact").count({
where: {
user: userId,
state: "follow",
},
}),
strapi.db.query("api::contact.contact").count({
where: {
owner: userId,
state: "follow",
},
}),
]);
const result = { const result = {
...JSON.parse(JSON.stringify(fullUser)), ...safeUser,
stats: { stats,
contacts: contactsCount,
groups: groupsCount,
posts: postsCount,
events: eventsCount,
followers: followersCount,
following: followingCount,
},
}; };
return result; return result;
@@ -279,10 +287,6 @@ module.exports = (plugin) => {
// 4️⃣ Ajoute un champ calculé // 4️⃣ Ajoute un champ calculé
user.profileCompleted = Boolean(user.username && user.surname); user.profileCompleted = Boolean(user.username && user.surname);
// 3️⃣ Supprime les champs sensibles
const sensitive = ["password", "resetPasswordToken", "confirmationToken"];
sensitive.forEach((key) => delete user[key]); //post_ownerships
// 5️⃣ Refetch avec relations peuplées // 5️⃣ Refetch avec relations peuplées
try { try {
const populatedUser = await strapi.entityService.findOne( const populatedUser = await strapi.entityService.findOne(
@@ -291,6 +295,7 @@ module.exports = (plugin) => {
{ {
populate: { populate: {
post_ownerships: { post_ownerships: {
filters: { contextType: "user", relation: "owner" },
populate: { populate: {
post: { post: {
populate: { populate: {
@@ -300,6 +305,7 @@ module.exports = (plugin) => {
}, },
}, },
contacts: { contacts: {
filters: { state: "accepted" },
populate: { populate: {
owner: { owner: {
populate: { populate: {
@@ -314,6 +320,7 @@ module.exports = (plugin) => {
}, },
}, },
group_memberships: { group_memberships: {
filters: { role: { $in: ["member", "admin", "owner"] } },
populate: { populate: {
group: { group: {
populate: { populate: {
@@ -324,29 +331,8 @@ module.exports = (plugin) => {
}, },
activities: true, activities: true,
}, },
} },
); );
// Fusionne les données originales (permissions/serialization) avec les relations
user = { ...user, ...populatedUser };
if (user.contacts && Array.isArray(user.contacts)) {
user.contacts = user.contacts.filter(
(contact) => contact.state === "accepted"
);
}
if (user.post_ownerships && Array.isArray(user.post_ownerships)) {
user.post_ownerships = user.post_ownerships.filter(
(ownership) =>
ownership.contextType === "user" && ownership.relation === "owner"
);
}
if (user.group_memberships && Array.isArray(user.group_memberships)) {
user.group_memberships = user.group_memberships.filter((membership) =>
["member", "admin", "owner"].includes(membership.role)
);
}
const eventRelationships = await strapi.db const eventRelationships = await strapi.db
.query("api::event-relationship.event-relationship") .query("api::event-relationship.event-relationship")
@@ -356,84 +342,28 @@ module.exports = (plugin) => {
event: true, event: true,
}, },
}); });
user.event_relationships = eventRelationships || [];
const [ const stats = await getUserStats(user.id);
contactsCount,
groupsCount, const sanitizedPopulatedUser = await sanitizeUser(populatedUser, ctx) as object;
postsCount,
eventsCount, // Fusionne les données originales avec les relations sécurisées
followersCount, user = {
followingCount, ...user,
] = await Promise.all([ ...sanitizedPopulatedUser,
strapi.db.query("api::contact.contact").count({ event_relationships: eventRelationships || [],
where: { stats
$or: [{ owner: user.id }, { user: user.id }],
},
}),
strapi.db.query("api::group-membership.group-membership").count({
where: {
user: user.id,
role: { $in: ["owner", "member", "admin"] },
},
}),
strapi.db
.query("api::post-ownership.post-ownership")
.count({ where: { author: user.id } }),
strapi.db
.query("api::event-relationship.event-relationship")
.count({ where: { author: user.id } }),
strapi.db.query("api::contact.contact").count({
where: {
user: user.id,
state: "follow",
},
}),
strapi.db.query("api::contact.contact").count({
where: {
owner: user.id,
state: "follow",
},
}),
]);
user.stats = {
contacts: contactsCount,
groups: groupsCount,
posts: postsCount,
events: eventsCount,
followers: followersCount,
following: followingCount,
}; };
} catch (err) { } catch (err) {
console.error("Erreur populate relations user:", err); console.error("Erreur populate relations user:", err);
// fallback : retourne juste l'utilisateur original // fallback : retourne juste l'utilisateur original
user.stats = await getUserStats(user.id);
} }
ctx.body = user; ctx.body = user;
return ctx; return ctx;
}; };
const uploadImage = async (ctx, label: string, username: string) => { // Removed unused uploadImage function
const key = `${label}Image`;
if (ctx.request.files[key]) {
const files = Array.isArray(ctx.request.files[key])
? ctx.request.files[key][0]
: ctx.request.files[key];
const extension = files.originalFilename.match(/\.[0-9a-z]+$/i);
const payload = {
fileInfo: {
caption: "undefined",
alternativeText: username || "",
name: `${username}_avatar${extension}`,
},
};
const asset = await strapi.services["plugin::upload.upload"].upload({
data: payload,
files,
});
return asset[0].id;
}
return 0;
};
plugin.services.providers = providers; plugin.services.providers = providers;
@@ -444,7 +374,14 @@ module.exports = (plugin) => {
return ctx.unauthorized(); return ctx.unauthorized();
} }
const data = JSON.parse(ctx.request.body.data); let data = ctx.request.body.data || ctx.request.body;
if (typeof data === "string") {
try {
data = JSON.parse(data);
} catch (err) {
throw new ValidationError("Invalid JSON format in request body data");
}
}
const newData = lod.pick(data, [ const newData = lod.pick(data, [
"email", "email",
@@ -463,6 +400,7 @@ module.exports = (plugin) => {
"languages", "languages",
"parameter", "parameter",
"privacy", "privacy",
"avatar",
]); ]);
/* /*
@@ -506,7 +444,7 @@ module.exports = (plugin) => {
ctx.params = { id: user.id }; ctx.params = { id: user.id };
const keysExcludingParameterAndPrivacy = Object.keys(newData).filter( const keysExcludingParameterAndPrivacy = Object.keys(newData).filter(
(key) => key !== "parameter" && key !== "privacy" (key) => key !== "parameter" && key !== "privacy",
); );
if (keysExcludingParameterAndPrivacy.length === 0) { if (keysExcludingParameterAndPrivacy.length === 0) {
@@ -520,9 +458,6 @@ module.exports = (plugin) => {
return ctx; return ctx;
}); });
} else { } else {
const avatarId = await uploadImage(ctx, "avatar", newData.username);
if (avatarId != 0) ctx.request.body.avatar = avatarId;
return plugin.controllers.user.update(ctx); return plugin.controllers.user.update(ctx);
} }
}; };
@@ -533,14 +468,12 @@ module.exports = (plugin) => {
await sendConfirmationEmail(result); await sendConfirmationEmail(result);
await strapi await strapi.service("api::notification.notification")?.addActivity({
.service("api::notification.notification") userId: result.id,
?.addActivity({ activityMessage: `Bienvenue ${result.username}, Ton compte est maintenant activé.`,
userId: result.id, activityUser: result.username,
activityMessage: `Bienvenue ${result.username}, Ton compte est maintenant activé.`, activityType: "user",
activityUser: result.username, });
activityType: "user",
});
// Appel du service de notification // Appel du service de notification
await strapi await strapi
@@ -548,14 +481,18 @@ module.exports = (plugin) => {
?.createNotification({ ?.createNotification({
title: "Bienvenue !", title: "Bienvenue !",
message: `Ton compte est maintenant activé.`, message: `Ton compte est maintenant activé.`,
type: "success", type: "accountCreation",
target_user: result.id, target_user: result.id,
source: "system", source: "system",
payload: { email: result.email }, payload: {
email: result.email,
url: "/app/user",
description: "Penses à remplir ton profil",
},
}); });
strapi.log.info( strapi.log.info(
`🔔 Notification envoyée pour l'utilisateur ${result.id}` `🔔 Notification envoyée pour l'utilisateur ${result.id}`,
); );
}, },
}; };
+13 -1
View File
@@ -1,4 +1,6 @@
import type { Core } from "@strapi/strapi"; import type { Core } from "@strapi/strapi";
import { checkEnvironment } from "./env-check";
import { syncRolePermissions } from "./permissions-sync";
export default { export default {
@@ -24,7 +26,17 @@ export default {
* This gives you an opportunity to set up your data model, * This gives you an opportunity to set up your data model,
* run jobs, or perform some special logic. * run jobs, or perform some special logic.
*/ */
bootstrap({ strapi }: { strapi: Core.Strapi }) { async bootstrap({ strapi }: { strapi: Core.Strapi }) {
// Contrôle de forme des variables d'environnement (préfixes, longueurs,
// guillemets parasites). Ne bloque pas le démarrage, mais signale au boot
// les erreurs de configuration qui, sinon, ne se manifestent que très
// loin de leur cause — cf. src/env-check.ts.
checkEnvironment(strapi);
// Synchronise les permissions Users & Permissions (source de vérité
// versionnée dans src/permissions-sync.ts — additif, idempotent).
await syncRolePermissions(strapi);
strapi.db.lifecycles.subscribe({ strapi.db.lifecycles.subscribe({
models: ["plugin::users-permissions.user"], // Applies only to users in users-permissions models: ["plugin::users-permissions.user"], // Applies only to users in users-permissions
+211
View File
@@ -0,0 +1,211 @@
import type { Core } from "@strapi/strapi";
/**
* Source de vérité VERSIONNÉE des permissions Users & Permissions.
*
* Synchronisée au démarrage (voir bootstrap dans src/index.ts) : toute
* permission listée ici et absente en base est créée pour le rôle.
* La synchro est ADDITIVE : elle n'en supprime jamais — les permissions en
* trop sont seulement signalées dans les logs, la suppression reste un geste
* manuel (admin ou SQL).
*
* Liste établie par audit du front (voir AUDIT_PERMISSIONS.md à la racine du
* projet parent, 24/07/2026) : chaque action correspond à un appel réel de
* harmony-web. Toute modification ici doit être répercutée dans cet audit et
* dans les CLAUDE.md.
*/
const ROLE_PERMISSIONS: Record<string, string[]> = {
authenticated: [
// --- Marketplace (annonces) — ⚠️ update/delete : ownership à verrouiller côté controller
"api::ad.ad.find",
"api::ad.ad.create",
"api::ad.ad.update",
"api::ad.ad.delete",
// --- Chorale
"api::choral.choral.find",
"api::choral.choral.create",
"api::choral.choral.update",
"api::choral-membership.choral-membership.find",
"api::choral-membership.choral-membership.create",
"api::choral-membership.choral-membership.update",
"api::announcement.announcement.find",
"api::permissions-template.permissions-template.create",
"api::permissions-template.permissions-template.delete",
"api::form-template.form-template.find",
"api::form-template.form-template.create",
// --- Boards (kanban)
"api::board.board.find",
"api::board.board.create",
"api::board.board.update",
"api::board.board.delete",
"api::board-list.board-list.find",
"api::board-list.board-list.create",
"api::board-list.board-list.update",
"api::board-list.board-list.delete",
"api::board-card.board-card.create",
"api::board-card.board-card.update",
"api::board-card.board-card.delete",
// --- Chat de chorale (salons + messages)
"api::channel.channel.create",
"api::channel.channel.update",
"api::channel.channel.delete",
"api::message.message.find",
"api::message.message.create",
"api::message.message.update",
"api::message.message.delete",
"api::message.message.addReaction",
// --- Messagerie (nouveau système chat-conversation)
"api::chat-conversation.chat-conversation.create",
"api::chat-conversation-member.chat-conversation-member.find",
"api::chat-conversation-member.chat-conversation-member.update",
// Conversations de l'appelant, avec interlocuteurs et messages.
// ⚠️ VOLONTAIREMENT ABSENTES : chat-conversation.find et
// chat-message.find. Elles lèveraient le filtrage du sanitizer sur les
// relations peuplées, mais ouvriraient la lecture de toutes les
// conversations et de tous les messages privés à n'importe quel compte
// connecté. L'action `mine` retourne les mêmes données, limitées à
// l'utilisateur courant.
"api::chat-conversation-member.chat-conversation-member.mine",
"api::chat-message.chat-message.create",
// --- Social (posts)
"api::post.post.find",
"api::post.post.create",
"api::post.post.update",
"api::post.post.delete",
"api::post.post.feed",
"api::post.post.savePost",
"api::post.post.removeSavedPost",
"api::post.post.hidePost",
"api::post.post.removeHiddenPost",
"api::post.post.likePost",
"api::post.post.addComment",
"api::post.post.link",
"api::post-ownership.post-ownership.find",
// Lecture seule : sans `find`, le sanitizer de l'API REST retire les
// commentaires des réponses où ils sont peuplés (page de détail d'une
// publication). L'écriture reste passée par `post.addComment`, qui
// contrôle l'auteur — pas de create/update/delete ici.
"api::comment.comment.find",
"api::comment.comment.findOne",
"api::report.report.create",
// --- Contacts / amis
"api::contact.contact.find",
"api::contact.contact.create",
"api::contact.contact.update",
"api::contact.contact.delete",
"api::contact.contact.activities",
"api::contact.contact.suggestions",
// --- Événements
"api::event.event.find",
"api::event.event.create",
"api::event.event.update",
"api::event.event.delete",
"api::event.event.feed",
"api::event.event.applyEvent",
"api::event.event.unapplyEvent",
"api::event-relationship.event-relationship.find",
// --- Groupes
"api::group.group.find",
"api::group.group.create",
"api::group.group.update",
"api::group.group.delete",
"api::group.group.invite",
"api::group-membership.group-membership.find",
"api::group-membership.group-membership.create",
"api::group-membership.group-membership.update",
"api::group-membership.group-membership.delete",
// --- Invitations chorale
"api::invite.invite.find",
"api::invite.invite.create",
"api::invite.invite.update",
"api::invite.invite.delete",
// --- Notifications
"api::notification.notification.find",
"api::notification.notification.update",
"api::notification.notification.delete",
// --- Contenu / commandes
"api::page.page.find",
"api::order.order.find",
"api::order.order.findOne",
// --- Plugins
"plugin::upload.content-api.upload",
"plugin::users-permissions.auth.changePassword",
"plugin::users-permissions.user.me",
"plugin::users-permissions.user.find",
"plugin::users-permissions.user.findOne",
"plugin::users-permissions.user.updateMe",
// ⚠️ VOLONTAIREMENT ABSENT : plugin::users-permissions.user.update
// (permettrait à n'importe quel utilisateur de modifier n'importe quel
// compte). Le front doit passer par updateMe — voir AUDIT_PERMISSIONS.md.
],
public: [
"plugin::users-permissions.auth.callback",
"plugin::users-permissions.auth.connect",
"plugin::users-permissions.auth.register",
"plugin::users-permissions.auth.emailConfirmation",
"plugin::users-permissions.auth.sendEmailConfirmation",
"plugin::users-permissions.auth.forgotPassword",
"plugin::users-permissions.auth.resetPassword",
// Pages CMS publiques (landing / pages légales)
"api::page.page.find",
"api::legal-page.legal-page.find",
],
};
/**
* Crée les permissions manquantes pour chaque rôle (idempotent, additif).
* À appeler depuis bootstrap().
*/
export async function syncRolePermissions(strapi: Core.Strapi): Promise<void> {
for (const [roleType, wantedActions] of Object.entries(ROLE_PERMISSIONS)) {
const role = await strapi.db
.query("plugin::users-permissions.role")
.findOne({ where: { type: roleType } });
if (!role) {
strapi.log.warn(`[permissions-sync] Rôle "${roleType}" introuvable, ignoré.`);
continue;
}
const existing = await strapi.db
.query("plugin::users-permissions.permission")
.findMany({ where: { role: role.id }, select: ["action"] });
const existingActions = new Set(existing.map((p: { action: string }) => p.action));
const missing = wantedActions.filter((a) => !existingActions.has(a));
for (const action of missing) {
await strapi.db
.query("plugin::users-permissions.permission")
.create({ data: { action, role: role.id } });
}
const extra = [...existingActions].filter((a) => !wantedActions.includes(a));
if (missing.length > 0) {
strapi.log.info(
`[permissions-sync] ${roleType}: ${missing.length} permission(s) créée(s) → ${missing.join(", ")}`,
);
}
if (extra.length > 0) {
strapi.log.warn(
`[permissions-sync] ${roleType}: ${extra.length} permission(s) en base non déclarée(s) ici (non supprimées) → ${extra.join(", ")}`,
);
}
if (missing.length === 0 && extra.length === 0) {
strapi.log.info(`[permissions-sync] ${roleType}: à jour (${wantedActions.length} permissions).`);
}
}
}
+1
View File
@@ -1971,6 +1971,7 @@ export interface PluginUploadFile extends Struct.CollectionTypeSchema {
Schema.Attribute.Private; Schema.Attribute.Private;
mime: Schema.Attribute.String & Schema.Attribute.Required; mime: Schema.Attribute.String & Schema.Attribute.Required;
name: Schema.Attribute.String & Schema.Attribute.Required; name: Schema.Attribute.String & Schema.Attribute.Required;
path: Schema.Attribute.String;
previewUrl: Schema.Attribute.String; previewUrl: Schema.Attribute.String;
provider: Schema.Attribute.String & Schema.Attribute.Required; provider: Schema.Attribute.String & Schema.Attribute.Required;
provider_metadata: Schema.Attribute.JSON; provider_metadata: Schema.Attribute.JSON;