Compare commits
7 Commits
57faa37e70
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| eacffd7755 | |||
| 3e9f5c3f60 | |||
| ec1d9bc1a4 | |||
| a93d48cada | |||
| 62064f053a | |||
| e168b6c826 | |||
| c0a0966557 |
@@ -35,22 +35,31 @@ config/ # server, database, plugins, middlewares, cron-tasks
|
||||
|
||||
## 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 |
|
||||
|---|---|
|
||||
| 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` |
|
||||
| Groupes | `group`, `group-membership` |
|
||||
| Événements | `event`, `event-relationship` |
|
||||
| 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` |
|
||||
| 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
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "harmony-back",
|
||||
"version": "0.13.19",
|
||||
"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,19 +8,33 @@ 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é
|
||||
// 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
|
||||
if (isExternalInvite) {
|
||||
@@ -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")
|
||||
|
||||
@@ -44,6 +44,28 @@ 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 },
|
||||
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Core } from "@strapi/strapi";
|
||||
import { checkEnvironment } from "./env-check";
|
||||
import { syncRolePermissions } from "./permissions-sync";
|
||||
|
||||
export default {
|
||||
@@ -26,6 +27,12 @@ export default {
|
||||
* run jobs, or perform some special logic.
|
||||
*/
|
||||
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);
|
||||
|
||||
@@ -58,16 +58,18 @@ const ROLE_PERMISSIONS: Record<string, string[]> = {
|
||||
"api::message.message.delete",
|
||||
"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)
|
||||
"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)
|
||||
|
||||
Reference in New Issue
Block a user