Compare commits

...

14 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
20 changed files with 421 additions and 132 deletions
+30 -3
View File
@@ -35,22 +35,31 @@ config/ # server, database, plugins, middlewares, cron-tasks
## Content-types ## Content-types
Vue par domaine (34 au total, liste exhaustive dans `src/api/`) : Vue par domaine (32 au total, liste exhaustive dans `src/api/`) :
| Domaine | Content-types | | Domaine | Content-types |
|---|---| |---|---|
| Chorale | `choral`, `choral-membership`, `choral-permission`, `permissions-template` | | Chorale | `choral`, `choral-membership`, `choral-permission` ⚠️, `permissions-template` ⚠️ |
| Social (offre gratuite) | `post`, `post-ownership`, `comment`, `activity`, `report` | | Social (offre gratuite) | `post`, `post-ownership`, `comment`, `activity`, `report` |
| Groupes | `group`, `group-membership` | | Groupes | `group`, `group-membership` |
| Événements | `event`, `event-relationship` | | Événements | `event`, `event-relationship` |
| Boards (kanban) | `board`, `board-list`, `board-card` | | Boards (kanban) | `board`, `board-list`, `board-card` |
| Messagerie | `channel`, `chat-conversation`, `chat-conversation-member`, `chat-message`, `conversation`, `direct-message`, `message` | | 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` | | Notifications | `notification`, `announcement`, `invite` |
| Paiement | `order` (Stripe — ⚠️ route custom `auth: false` dans `src/api/order/routes/order.ts`, webhook) | | 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` | | 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. --> <!-- 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 ## Sécurité — règles NON NÉGOCIABLES
- Aucune route custom en `auth: false` sans justification écrite en commentaire - Aucune route custom en `auth: false` sans justification écrite en commentaire
@@ -72,6 +81,24 @@ Vue par domaine (34 au total, liste exhaustive dans `src/api/`) :
Toute nouvelle route/action consommée par le front DOIT être ajoutée à ce 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 fichier (et jamais cochée uniquement dans l'admin, sinon elle sera signalée
comme non déclaré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é ; - Secrets uniquement en variables d'environnement. `.env` jamais commité ;
`.env.example` maintenu à jour. Depuis le 26/07/2026, `config/plugins.ts` `.env.example` maintenu à jour. Depuis le 26/07/2026, `config/plugins.ts`
ne contient plus aucun secret (R2 et SMTP passent par `env()`). ne contient plus aucun secret (R2 et SMTP passent par `env()`).
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "harmony-back", "name": "harmony-back",
"version": "0.13.14", "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,19 +8,33 @@ 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_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"; ctx.request.body.data.state = "pending_user_approval";
if (!data.role) { if (!data.role) {
ctx.request.body.data.role = "member"; ctx.request.body.data.role = "member";
} }
}
// 3. Traitement spécifique selon le cas // 3. Traitement spécifique selon le cas
if (isExternalInvite) { if (isExternalInvite) {
@@ -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")
+22
View File
@@ -44,6 +44,28 @@ 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. // Décrémente le crédit de création une fois la chorale créée.
await strapi.db.query("plugin::users-permissions.user").update({ await strapi.db.query("plugin::users-permissions.user").update({
where: { id: user.id }, where: { id: user.id },
@@ -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');
+46 -3
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}`);
} }
@@ -42,15 +59,37 @@ export default factories.createCoreController(
premium: 5, premium: 5,
}; };
if (userId) { 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,
}, },
}); });
@@ -74,6 +113,10 @@ export default factories.createCoreController(
// 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",
+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;
};
@@ -250,8 +250,21 @@ module.exports = (plugin) => {
const stats = await getUserStats(ctx.state.user.id); const stats = await getUserStats(ctx.state.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 result = { const result = {
...JSON.parse(JSON.stringify(fullUser)), ...safeUser,
stats, stats,
}; };
+7
View File
@@ -1,4 +1,5 @@
import type { Core } from "@strapi/strapi"; import type { Core } from "@strapi/strapi";
import { checkEnvironment } from "./env-check";
import { syncRolePermissions } from "./permissions-sync"; import { syncRolePermissions } from "./permissions-sync";
export default { export default {
@@ -26,6 +27,12 @@ export default {
* run jobs, or perform some special logic. * run jobs, or perform some special logic.
*/ */
async 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é // Synchronise les permissions Users & Permissions (source de vérité
// versionnée dans src/permissions-sync.ts — additif, idempotent). // versionnée dans src/permissions-sync.ts — additif, idempotent).
await syncRolePermissions(strapi); await syncRolePermissions(strapi);
+14 -6
View File
@@ -58,16 +58,18 @@ const ROLE_PERMISSIONS: Record<string, string[]> = {
"api::message.message.delete", "api::message.message.delete",
"api::message.message.addReaction", "api::message.message.addReaction",
// --- Messagerie directe (ancien système)
"api::conversation.conversation.find",
"api::conversation.conversation.create",
"api::direct-message.direct-message.find",
"api::direct-message.direct-message.create",
// --- Messagerie (nouveau système chat-conversation) // --- Messagerie (nouveau système chat-conversation)
"api::chat-conversation.chat-conversation.create", "api::chat-conversation.chat-conversation.create",
"api::chat-conversation-member.chat-conversation-member.find", "api::chat-conversation-member.chat-conversation-member.find",
"api::chat-conversation-member.chat-conversation-member.update", "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", "api::chat-message.chat-message.create",
// --- Social (posts) // --- Social (posts)
@@ -84,6 +86,12 @@ const ROLE_PERMISSIONS: Record<string, string[]> = {
"api::post.post.addComment", "api::post.post.addComment",
"api::post.post.link", "api::post.post.link",
"api::post-ownership.post-ownership.find", "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", "api::report.report.create",
// --- Contacts / amis // --- Contacts / amis