Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eacffd7755 | |||
| 3e9f5c3f60 | |||
| ec1d9bc1a4 | |||
| a93d48cada | |||
| 62064f053a | |||
| e168b6c826 | |||
| c0a0966557 | |||
| 57faa37e70 | |||
| 87842c7585 | |||
| 3da128278d | |||
| 883b827b0d | |||
| 5be8873ad5 | |||
| 5a8a7d9f66 | |||
| 126e9332ad | |||
| 8e672633dc | |||
| ca66bec45b | |||
| 51947d49e7 | |||
| 30c4c4ed3c | |||
| ca2604d0fb | |||
| 574d81ac54 | |||
| 6034c4f07b | |||
| 945ad5d095 | |||
| ce25ba2695 |
@@ -5,3 +5,4 @@ dist
|
||||
.cache
|
||||
.env
|
||||
build
|
||||
.git
|
||||
@@ -5,3 +5,28 @@ API_TOKEN_SALT=tobemodified
|
||||
ADMIN_JWT_SECRET=tobemodified
|
||||
TRANSFER_TOKEN_SALT=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
|
||||
|
||||
@@ -5,9 +5,9 @@ on: [push]
|
||||
env:
|
||||
DOCKER_IMAGE_NAME: harmony-back
|
||||
DOCKER_REGISTRY_URL: git.harmonylab.ovh
|
||||
DOCKER_REGISTRY_ORG: harmony
|
||||
DOCKER_REGISTRY_ORG: admin
|
||||
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
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -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.
|
||||
@@ -5,6 +5,12 @@ RUN apk update && apk add --no-cache \
|
||||
|
||||
ARG NODE_ENV=production
|
||||
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
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
Vendored
+21
-18
@@ -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 [
|
||||
"strapi::logger",
|
||||
"strapi::errors",
|
||||
//'strapi::security',
|
||||
{
|
||||
name: "strapi::security",
|
||||
config: {
|
||||
@@ -9,22 +21,8 @@ export default [
|
||||
useDefaults: true,
|
||||
directives: {
|
||||
"connect-src": ["'self'", "https:"],
|
||||
"img-src": [
|
||||
"'self'",
|
||||
"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",
|
||||
],
|
||||
"img-src": mediaSources,
|
||||
"media-src": mediaSources,
|
||||
upgradeInsecureRequests: null,
|
||||
},
|
||||
},
|
||||
@@ -33,7 +31,12 @@ export default [
|
||||
"strapi::cors",
|
||||
"strapi::poweredBy",
|
||||
"strapi::query",
|
||||
"strapi::body",
|
||||
{
|
||||
name: "strapi::body",
|
||||
config: {
|
||||
includeUnparsed: true, // INDISPENSABLE pour les webhooks
|
||||
},
|
||||
},
|
||||
"strapi::session",
|
||||
"strapi::favicon",
|
||||
"strapi::public",
|
||||
|
||||
Vendored
-42
@@ -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
@@ -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: {
|
||||
config: {
|
||||
provider: "nodemailer",
|
||||
providerOptions: {
|
||||
host: "smtp.zeptomail.eu",
|
||||
port: 587,
|
||||
host: env("SMTP_HOST", "smtp.zeptomail.eu"),
|
||||
port: env.int("SMTP_PORT", 587),
|
||||
auth: {
|
||||
user: "emailapikey",
|
||||
pass: "yA6KbHsJ4lrywWtTFUc+0pSC94lm/aE/2nzks3i2fpZ1LYXp3qE71RBvd4O4c2CLjdfT5a9UbIkVJoCwvIpbfpczPIBXJpTGTuv4P2uV48xh8ciEYNYjhJivALIWFqVOeBsnDyo4QfEjWA==",
|
||||
user: env("SMTP_USERNAME", "emailapikey"),
|
||||
pass: env("SMTP_PASSWORD"),
|
||||
},
|
||||
debug: true,
|
||||
logger: true,
|
||||
debug: env.bool("SMTP_DEBUG", false),
|
||||
logger: env.bool("SMTP_DEBUG", false),
|
||||
},
|
||||
settings: {
|
||||
defaultFrom: "ChoralSync <noreply@choralsync.com>",
|
||||
defaultReplyTo: "contact@choralsync.com",
|
||||
defaultFrom: env(
|
||||
"SMTP_DEFAULT_FROM",
|
||||
"ChoralSync <noreply@choralsync.com>",
|
||||
),
|
||||
defaultReplyTo: env("SMTP_DEFAULT_REPLY_TO", "contact@choralsync.com"),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -23,16 +37,22 @@ export default () => ({
|
||||
config: {
|
||||
provider: "aws-s3",
|
||||
providerOptions: {
|
||||
baseUrl: "https://container.harmonylab.ovh/harmony",
|
||||
credentials: {
|
||||
accessKeyId: "admin",
|
||||
secretAccessKey: "Apslxnap12bn23",
|
||||
},
|
||||
endpoint: "https://container.harmonylab.ovh",
|
||||
forcePathStyle: true,
|
||||
region: "eu-west-3",
|
||||
params: {
|
||||
Bucket: "harmony",
|
||||
// URL publique servant les médias (domaine personnalisé du bucket).
|
||||
baseUrl: env("R2_PUBLIC_URL"),
|
||||
// Le provider 5.x attend les options S3 sous `s3Options` ; à plat,
|
||||
// elles ne sont acceptées qu'au prix d'un avertissement de dépréciation
|
||||
// — et une surcharge partielle produit une config incohérente.
|
||||
s3Options: {
|
||||
credentials: {
|
||||
accessKeyId: env("R2_ACCESS_KEY_ID"),
|
||||
secretAccessKey: env("R2_SECRET_ACCESS_KEY"),
|
||||
},
|
||||
endpoint: env("R2_ENDPOINT"),
|
||||
region: env("R2_REGION", "auto"),
|
||||
forcePathStyle: true,
|
||||
params: {
|
||||
Bucket: env("R2_BUCKET"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "harmony-back",
|
||||
"version": "0.13.10",
|
||||
"version": "0.13.25",
|
||||
"private": true,
|
||||
"description": "A Strapi application",
|
||||
"scripts": {
|
||||
|
||||
@@ -2,6 +2,85 @@
|
||||
* 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 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 isInternalInvite = !!data.user;
|
||||
const isInternalInvite = !!data.user && !isSelfRequest;
|
||||
|
||||
let token = null;
|
||||
let targetEmail = null;
|
||||
|
||||
// 2. Configuration commune
|
||||
// On force le statut "en attente" peut importe le type d'invité
|
||||
ctx.request.body.data.state = "pending_user_approval";
|
||||
if (!data.role) {
|
||||
// 2. Statut selon le sens : c'est à l'autre partie de se prononcer.
|
||||
if (isSelfRequest) {
|
||||
ctx.request.body.data.state = "pending_admin_approval";
|
||||
// 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.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
|
||||
@@ -51,8 +65,10 @@ export default factories.createCoreController(
|
||||
// 4. Exécution de la création native par Strapi
|
||||
const response = await super.create(ctx);
|
||||
|
||||
// 5. Post-traitement : Envoi de l'email
|
||||
if (response && targetEmail) {
|
||||
// 5. Post-traitement : Envoi de l'email d'invitation.
|
||||
// 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 {
|
||||
const chorals = await strapi
|
||||
.documents("api::choral.choral")
|
||||
|
||||
@@ -8,6 +8,18 @@ export default factories.createCoreController(
|
||||
"api::choral.choral",
|
||||
({ strapi }) => ({
|
||||
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);
|
||||
|
||||
if (ctx.request.files.coverImage) {
|
||||
@@ -31,6 +43,35 @@ export default factories.createCoreController(
|
||||
|
||||
ctx.request.body = { data };
|
||||
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;
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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');
|
||||
@@ -18,7 +18,24 @@ export default factories.createCoreController(
|
||||
process.env.STRIPE_WEBHOOK_SECRET!,
|
||||
);
|
||||
} 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}`);
|
||||
}
|
||||
|
||||
@@ -34,37 +51,72 @@ export default factories.createCoreController(
|
||||
| "pro"
|
||||
| "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 {
|
||||
// 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
|
||||
await strapi.documents("api::order.order").create({
|
||||
data: {
|
||||
stripeId: session.id,
|
||||
amount: session.amount_total ? session.amount_total / 100 : 0,
|
||||
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({
|
||||
documentId: userId,
|
||||
data: {
|
||||
isPremium: true,
|
||||
subscriptionPlan: planType,
|
||||
maxChoirs: PLAN_MAX_CHOIRS[planType] ?? 0,
|
||||
stripeCustomerId: session.customer as string,
|
||||
subscriptionId: session.subscription as string,
|
||||
stripeSubscriptionId: session.subscription as string,
|
||||
trialStartedAt: null,
|
||||
},
|
||||
});
|
||||
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) {
|
||||
strapi.log.error(`❌ Erreur base de données : ${dbError.message}`);
|
||||
// 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
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"public",
|
||||
"contacts",
|
||||
"private"
|
||||
]
|
||||
],
|
||||
"default": "public"
|
||||
},
|
||||
"allowMessage": {
|
||||
"type": "boolean",
|
||||
|
||||
@@ -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:
|
||||
"Inter",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
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 qu’une é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>
|
||||
@@ -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
@@ -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": {
|
||||
"type": "json"
|
||||
},
|
||||
"stripeCustomerId": {
|
||||
"type": "string",
|
||||
"private": true
|
||||
},
|
||||
"stripeSubscriptionId": {
|
||||
"type": "string",
|
||||
"private": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use strict";
|
||||
const lod = require("lodash");
|
||||
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 bcrypt = require("bcryptjs");
|
||||
const fs = require("fs").promises;
|
||||
@@ -9,16 +9,60 @@ const path = require("path");
|
||||
|
||||
module.exports = (plugin) => {
|
||||
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 sanitizeUser = (user, ctx) => {
|
||||
const sanitizeUser = async (user, ctx) => {
|
||||
const { auth } = ctx.state;
|
||||
const userSchema = strapi.getModel("plugin::users-permissions.user");
|
||||
|
||||
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 attributes = strapi.getModel(USER_MODEL_UID).attributes;
|
||||
|
||||
@@ -26,7 +70,7 @@ module.exports = (plugin) => {
|
||||
if (attributes[key] && attributes[key].type === "password") {
|
||||
// Check if a custom encryption.rounds has been set on the password attribute
|
||||
const rounds = toNumber(
|
||||
getOr(10, "encryption.rounds", attributes[key])
|
||||
getOr(10, "encryption.rounds", attributes[key]),
|
||||
);
|
||||
values[key] = await bcrypt.hash(values[key], rounds);
|
||||
}
|
||||
@@ -78,7 +122,7 @@ module.exports = (plugin) => {
|
||||
to: user.email,
|
||||
subject: onCreateUser?.subject ?? "Confirme ton adresse e-mail",
|
||||
html,
|
||||
from: "ChoralSync <admin@harmonychoral.com>",
|
||||
from: "ChoralSync <contact@choralsync.com>",
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
@@ -111,7 +155,7 @@ module.exports = (plugin) => {
|
||||
query.access_token || query.code || query.oauth_token;
|
||||
|
||||
if (!accessToken) {
|
||||
throw new Error("No access_token.");
|
||||
throw new ApplicationError("No access_token.");
|
||||
}
|
||||
|
||||
const profile = await getProfile(provider, query);
|
||||
@@ -119,7 +163,7 @@ module.exports = (plugin) => {
|
||||
const email = lod.toLower(profile.email);
|
||||
|
||||
if (!email) {
|
||||
throw new Error("Email was not available.");
|
||||
throw new ApplicationError("Email was not available.");
|
||||
}
|
||||
|
||||
const users = await strapi.db
|
||||
@@ -142,7 +186,7 @@ module.exports = (plugin) => {
|
||||
}
|
||||
|
||||
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)) {
|
||||
@@ -150,7 +194,7 @@ module.exports = (plugin) => {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -174,8 +218,6 @@ module.exports = (plugin) => {
|
||||
};
|
||||
};
|
||||
|
||||
const originalMe = plugin.controllers.user.me;
|
||||
|
||||
plugin.controllers.user.me = async (ctx) => {
|
||||
const fullUser = await strapi.db
|
||||
.query("plugin::users-permissions.user")
|
||||
@@ -186,14 +228,14 @@ module.exports = (plugin) => {
|
||||
|
||||
if (fullUser.contacts && Array.isArray(fullUser.contacts)) {
|
||||
fullUser.contacts = fullUser.contacts.filter(
|
||||
(contact) => contact.state === "accepted"
|
||||
(contact) => contact.state === "accepted",
|
||||
);
|
||||
}
|
||||
|
||||
if (fullUser.post_ownerships && Array.isArray(fullUser.post_ownerships)) {
|
||||
fullUser.post_ownerships = fullUser.post_ownerships.filter(
|
||||
(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)
|
||||
) {
|
||||
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 = {
|
||||
...JSON.parse(JSON.stringify(fullUser)),
|
||||
stats: {
|
||||
contacts: contactsCount,
|
||||
groups: groupsCount,
|
||||
posts: postsCount,
|
||||
events: eventsCount,
|
||||
followers: followersCount,
|
||||
following: followingCount,
|
||||
},
|
||||
...safeUser,
|
||||
stats,
|
||||
};
|
||||
|
||||
return result;
|
||||
@@ -279,10 +287,6 @@ module.exports = (plugin) => {
|
||||
// 4️⃣ Ajoute un champ calculé
|
||||
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
|
||||
try {
|
||||
const populatedUser = await strapi.entityService.findOne(
|
||||
@@ -291,6 +295,7 @@ module.exports = (plugin) => {
|
||||
{
|
||||
populate: {
|
||||
post_ownerships: {
|
||||
filters: { contextType: "user", relation: "owner" },
|
||||
populate: {
|
||||
post: {
|
||||
populate: {
|
||||
@@ -300,6 +305,7 @@ module.exports = (plugin) => {
|
||||
},
|
||||
},
|
||||
contacts: {
|
||||
filters: { state: "accepted" },
|
||||
populate: {
|
||||
owner: {
|
||||
populate: {
|
||||
@@ -314,6 +320,7 @@ module.exports = (plugin) => {
|
||||
},
|
||||
},
|
||||
group_memberships: {
|
||||
filters: { role: { $in: ["member", "admin", "owner"] } },
|
||||
populate: {
|
||||
group: {
|
||||
populate: {
|
||||
@@ -324,29 +331,8 @@ module.exports = (plugin) => {
|
||||
},
|
||||
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
|
||||
.query("api::event-relationship.event-relationship")
|
||||
@@ -356,84 +342,28 @@ module.exports = (plugin) => {
|
||||
event: true,
|
||||
},
|
||||
});
|
||||
user.event_relationships = eventRelationships || [];
|
||||
const [
|
||||
contactsCount,
|
||||
groupsCount,
|
||||
postsCount,
|
||||
eventsCount,
|
||||
followersCount,
|
||||
followingCount,
|
||||
] = await Promise.all([
|
||||
strapi.db.query("api::contact.contact").count({
|
||||
where: {
|
||||
$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,
|
||||
|
||||
const stats = await getUserStats(user.id);
|
||||
|
||||
const sanitizedPopulatedUser = await sanitizeUser(populatedUser, ctx) as object;
|
||||
|
||||
// Fusionne les données originales avec les relations sécurisées
|
||||
user = {
|
||||
...user,
|
||||
...sanitizedPopulatedUser,
|
||||
event_relationships: eventRelationships || [],
|
||||
stats
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("Erreur populate relations user:", err);
|
||||
// fallback : retourne juste l'utilisateur original
|
||||
user.stats = await getUserStats(user.id);
|
||||
}
|
||||
ctx.body = user;
|
||||
return ctx;
|
||||
};
|
||||
|
||||
const uploadImage = async (ctx, label: string, username: string) => {
|
||||
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;
|
||||
};
|
||||
// Removed unused uploadImage function
|
||||
|
||||
plugin.services.providers = providers;
|
||||
|
||||
@@ -444,7 +374,14 @@ module.exports = (plugin) => {
|
||||
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, [
|
||||
"email",
|
||||
@@ -463,6 +400,7 @@ module.exports = (plugin) => {
|
||||
"languages",
|
||||
"parameter",
|
||||
"privacy",
|
||||
"avatar",
|
||||
]);
|
||||
|
||||
/*
|
||||
@@ -506,7 +444,7 @@ module.exports = (plugin) => {
|
||||
ctx.params = { id: user.id };
|
||||
|
||||
const keysExcludingParameterAndPrivacy = Object.keys(newData).filter(
|
||||
(key) => key !== "parameter" && key !== "privacy"
|
||||
(key) => key !== "parameter" && key !== "privacy",
|
||||
);
|
||||
|
||||
if (keysExcludingParameterAndPrivacy.length === 0) {
|
||||
@@ -520,9 +458,6 @@ module.exports = (plugin) => {
|
||||
return ctx;
|
||||
});
|
||||
} else {
|
||||
const avatarId = await uploadImage(ctx, "avatar", newData.username);
|
||||
if (avatarId != 0) ctx.request.body.avatar = avatarId;
|
||||
|
||||
return plugin.controllers.user.update(ctx);
|
||||
}
|
||||
};
|
||||
@@ -533,14 +468,12 @@ module.exports = (plugin) => {
|
||||
|
||||
await sendConfirmationEmail(result);
|
||||
|
||||
await strapi
|
||||
.service("api::notification.notification")
|
||||
?.addActivity({
|
||||
userId: result.id,
|
||||
activityMessage: `Bienvenue ${result.username}, Ton compte est maintenant activé.`,
|
||||
activityUser: result.username,
|
||||
activityType: "user",
|
||||
});
|
||||
await strapi.service("api::notification.notification")?.addActivity({
|
||||
userId: result.id,
|
||||
activityMessage: `Bienvenue ${result.username}, Ton compte est maintenant activé.`,
|
||||
activityUser: result.username,
|
||||
activityType: "user",
|
||||
});
|
||||
|
||||
// Appel du service de notification
|
||||
await strapi
|
||||
@@ -548,14 +481,18 @@ module.exports = (plugin) => {
|
||||
?.createNotification({
|
||||
title: "Bienvenue !",
|
||||
message: `Ton compte est maintenant activé.`,
|
||||
type: "success",
|
||||
type: "accountCreation",
|
||||
target_user: result.id,
|
||||
source: "system",
|
||||
payload: { email: result.email },
|
||||
payload: {
|
||||
email: result.email,
|
||||
url: "/app/user",
|
||||
description: "Penses à remplir ton profil",
|
||||
},
|
||||
});
|
||||
|
||||
strapi.log.info(
|
||||
`🔔 Notification envoyée pour l'utilisateur ${result.id}`
|
||||
`🔔 Notification envoyée pour l'utilisateur ${result.id}`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
+13
-1
@@ -1,4 +1,6 @@
|
||||
import type { Core } from "@strapi/strapi";
|
||||
import { checkEnvironment } from "./env-check";
|
||||
import { syncRolePermissions } from "./permissions-sync";
|
||||
|
||||
export default {
|
||||
|
||||
@@ -24,7 +26,17 @@ export default {
|
||||
* This gives you an opportunity to set up your data model,
|
||||
* 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({
|
||||
models: ["plugin::users-permissions.user"], // Applies only to users in users-permissions
|
||||
|
||||
|
||||
@@ -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).`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -1971,6 +1971,7 @@ export interface PluginUploadFile extends Struct.CollectionTypeSchema {
|
||||
Schema.Attribute.Private;
|
||||
mime: Schema.Attribute.String & Schema.Attribute.Required;
|
||||
name: Schema.Attribute.String & Schema.Attribute.Required;
|
||||
path: Schema.Attribute.String;
|
||||
previewUrl: Schema.Attribute.String;
|
||||
provider: Schema.Attribute.String & Schema.Attribute.Required;
|
||||
provider_metadata: Schema.Attribute.JSON;
|
||||
|
||||
Reference in New Issue
Block a user