Commit f8e6378a authored by Kourser's avatar Kourser
Browse files

Images : joindre une image aux réseaux sociaux, à la messagerie et au chat

Un seul magasin, trois références. `ExerciseImage` plutôt qu'un réemploi de
`Document` : une capture lâchée dans un fil de chat n'est pas une pièce de
l'espace documentaire, et les deux n'ont ni le même cycle de vie ni la même
audience. Le stockage physique, lui, reste partagé (`common/uploads.ts`).

Le corps d'un message n'est plus obligatoire quand une image est jointe — c'est
l'usage courant, notamment sur Instagram. La règle « du texte OU une image » ne
peut pas vivre dans les DTO (`@ValidateIf` désactive TOUS les validateurs d'une
propriété, on ne peut donc pas retirer le seul `@MinLength`) : elle est portée
par `assertHasContent`, appelé là où tous les chemins d'écriture convergent,
soit un point par canal.

Sécurité :

- SVG exclu de la liste blanche, contrairement aux logos : un SVG peut porter du
  script, et ces images-ci sont téléversées par des JOUEURS. Le format n'apporte
  rien pour une capture ou une photo.
- `assertBelongs` avant toute référence : sans ce contrôle, un identifiant deviné
  permettrait de rattacher l'image d'un autre exercice.
- L'identifiant est tiré au hasard sur 32 octets, et non généré en cuid. La route
  de lecture est servie sans session — une balise `<img>` ne peut pas émettre
  l'en-tête « voir comme » — donc l'URL tient lieu de capacité et doit être
  imprévisible, ce qu'un cuid (compteur + empreinte machine) n'est pas. Faire de
  cette clé l'identifiant lui-même évite en prime une jointure à ne pas oublier
  dans chaque lecture.

Limite connue, déjà signalée pour les documents : une image téléversée puis
abandonnée avant l'envoi reste orpheline en base et sur le disque. Elle est
emportée par la suppression de l'exercice et par la purge de rétention, mais
aucune purge intermédiaire n'existe.

Non vérifié : aucune chaîne Node dans cet environnement, donc rien n'a été
compilé, typé ni testé. Contrôles faits à la main (équilibre des accolades,
absence de doublon de clé i18n, correspondance classes CSS / TSX, symboles
importés tous utilisés, props obligatoires présentes sur les 7 usages du
composant).

Co-Authored-By: default avatarClaude (RCA) <noreply@anthropic.com>
parent a0c05f5d
Loading
Loading
Loading
Loading
+26 −0
Original line number Diff line number Diff line
@@ -44,6 +44,32 @@ d'un autre tenant. Cet invariant est couvert par des tests unitaires.
  compromis est assumé pour un logo d'entreprise derrière un identifiant non devinable ; cette
  route ne doit pas être élargie à du contenu d'exercice.

## Images jointes (réseaux sociaux, messagerie, chat)

Joueurs et animateurs peuvent joindre une image à une publication, un email ou un message
de chat. Les garde-fous :

- **Liste blanche de types** : PNG, JPEG, WebP, GIF, plafonnés à 5 Mo. Le **SVG est exclu**,
  contrairement aux logos d'organisation : un SVG peut porter du script, et ces images-ci sont
  téléversées par des joueurs, pas par un administrateur. Le format n'apporte rien pour une
  capture d'écran ou une photo.
- Le nom de fichier d'origine n'est **jamais** réutilisé sur le disque (stockage partagé avec
  les documents, `apps/api/src/common/uploads.ts`).
- **Appartenance vérifiée** : avant qu'un contenu ne référence une image, le serveur contrôle
  qu'elle appartient bien à l'exercice courant (`ImagesService.assertBelongs`). Sans cela, un
  identifiant deviné permettrait de rattacher l'image d'un autre exercice.
- `GET /api/images/:id` est **sans garde**, comme le logo : une balise `<img>` ne peut pas
  émettre l'en-tête `x-play-session` de l'observation « voir comme ». L'URL tient donc lieu de
  capacité — et c'est pourquoi l'identifiant est tiré au hasard sur **32 octets** plutôt que
  généré en `cuid`, qui est partiellement prévisible (compteur et empreinte machine).
- **Rien ne sort de l'exercice** : les images sont servies depuis le stockage local, jamais
  depuis un service tiers.
- **RGPD** : une image téléversée puis abandonnée avant l'envoi reste orpheline en base et sur
  le disque. Elle est emportée par la suppression de l'exercice et par la purge de rétention,
  mais aucune purge intermédiaire n'existe à ce jour. Rappeler aux joueurs que le contenu
  d'un exercice doit rester **fictif** : une capture d'écran réelle peut contenir des données
  personnelles.

## Observation d'un espace joueur (« voir comme »)

Un animateur peut ouvrir l'espace d'un participant pour y voir ce que ce dernier voit.
+38 −0
Original line number Diff line number Diff line
-- Images utilisables dans une publication, un message ou un chat.
-- Modele distinct de Document : une image lachee dans un fil n'est pas une piece
-- de l'espace documentaire. Le stockage physique reste partage.

-- CreateTable
CREATE TABLE "ExerciseImage" (
    -- Identifiant aleatoire fourni par l'application : il se retrouve dans l'URL
    -- de lecture, servie sans session, et y tient lieu de capacite.
    "id" TEXT NOT NULL,
    "tenantId" TEXT NOT NULL,
    "exerciseId" TEXT NOT NULL,
    "path" TEXT NOT NULL,
    "mimeType" TEXT NOT NULL,
    "fileName" TEXT NOT NULL,
    "authorParticipantId" TEXT,
    "authorUserId" TEXT,
    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

    CONSTRAINT "ExerciseImage_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "ExerciseImage_tenantId_idx" ON "ExerciseImage"("tenantId");
CREATE INDEX "ExerciseImage_exerciseId_idx" ON "ExerciseImage"("exerciseId");

-- AddForeignKey
ALTER TABLE "ExerciseImage" ADD CONSTRAINT "ExerciseImage_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ExerciseImage" ADD CONSTRAINT "ExerciseImage_exerciseId_fkey" FOREIGN KEY ("exerciseId") REFERENCES "Exercise"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AlterTable : reference depuis les trois porteurs de contenu.
-- SET NULL et non CASCADE : supprimer une image ne doit pas emporter le message.
ALTER TABLE "SocialPost" ADD COLUMN "imageId" TEXT;
ALTER TABLE "ChatMessage" ADD COLUMN "imageId" TEXT;
ALTER TABLE "Message" ADD COLUMN "imageId" TEXT;

ALTER TABLE "SocialPost" ADD CONSTRAINT "SocialPost_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "ExerciseImage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "ChatMessage" ADD CONSTRAINT "ChatMessage_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "ExerciseImage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "Message" ADD CONSTRAINT "Message_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "ExerciseImage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+42 −0
Original line number Diff line number Diff line
@@ -35,6 +35,7 @@ model Tenant {
  socialPosts       SocialPost[]
  newsArticles      NewsArticle[]
  documents         Document[]
  exerciseImages    ExerciseImage[]
  observerNotes     ObserverNote[]
  events            EventLog[]
  kits              Kit[]
@@ -132,6 +133,7 @@ model Exercise {
  socialPosts  SocialPost[]
  newsArticles  NewsArticle[]
  documents     Document[]
  images        ExerciseImage[]
  observerNotes ObserverNote[]
  events        EventLog[]
  comchecks       Comcheck[]
@@ -280,6 +282,9 @@ model Message {
  injectId            String? // lien vers l'inject d'origine, le cas echeant
  inReplyToId         String? // fil de discussion (reponse/transfert)
  attachments         Json? // pieces jointes : [{ documentId, name }]
  /// Image jointe au corps (distincte des pieces jointes issues de la GED).
  imageId             String?
  image               ExerciseImage? @relation(fields: [imageId], references: [id], onDelete: SetNull)
  sentAtExerciseSec   Int
  recipients          MessageRecipient[]
  createdAt           DateTime           @default(now())
@@ -401,6 +406,8 @@ model ChatMessage {
  authorParticipantId String?
  authorLabel         String
  body                String
  imageId             String?
  image               ExerciseImage? @relation(fields: [imageId], references: [id], onDelete: SetNull)
  atExerciseSec       Int
  createdAt           DateTime @default(now())

@@ -437,6 +444,8 @@ model SocialPost {
  /// Joueur auteur de la publication ; null = publiee par la cellule d'animation.
  /// Renseigne meme sous le compte officiel, pour savoir qui a engage l'organisation.
  authorParticipantId String?
  imageId             String?
  image               ExerciseImage? @relation(fields: [imageId], references: [id], onDelete: SetNull)
  atExerciseSec Int
  createdAt     DateTime      @default(now())

@@ -473,6 +482,39 @@ model NewsArticle {
  @@index([exerciseId, createdAt])
}

/// Une image televersee, utilisable dans une publication, un message ou un chat.
///
/// Modele distinct de Document a dessein : une image lachee dans un fil de chat
/// n'est pas une piece de l'espace documentaire que l'animation entretient. Le
/// stockage physique est le meme (common/uploads.ts).
model ExerciseImage {
  /// Identifiant ALEATOIRE (32 octets), fourni par l'application, et non un cuid.
  /// La route de lecture est servie SANS session : cet identifiant se retrouve
  /// dans l'URL et y tient lieu de capacite, il doit donc etre imprevisible —
  /// un cuid ne l'est pas (compteur + empreinte machine).
  id         String   @id
  tenantId   String
  tenant     Tenant   @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  exerciseId String
  exercise   Exercise @relation(fields: [exerciseId], references: [id], onDelete: Cascade)
  /// Cle de fichier prefixee (cf. UPLOAD_PREFIX).
  path       String
  mimeType   String
  fileName   String
  /// Auteur : joueur, ou membre de l'animation. Les deux peuvent televerser.
  authorParticipantId String?
  authorUserId        String?

  socialPosts  SocialPost[]
  chatMessages ChatMessage[]
  messages     Message[]

  createdAt DateTime @default(now())

  @@index([tenantId])
  @@index([exerciseId])
}

/// Qui voit un document : les joueurs, ou la seule cellule d'animation.
enum DocumentAudience {
  PLAYERS
+1 −1
Original line number Diff line number Diff line
@@ -34,7 +34,7 @@ export class ChatController {
    @Param('id') id: string,
    @Body() dto: PostMessageDto,
  ) {
    return this.chat.postByPlayer(player, id, dto.body);
    return this.chat.postByPlayer(player, id, dto.body, dto.imageId);
  }

  @Post('dm')
+32 −7
Original line number Diff line number Diff line
@@ -4,6 +4,7 @@ import { RealtimeGateway } from './realtime.gateway';
import { computeElapsedSeconds } from './clock';
import { RT } from './engine.types';
import type { PlayerContext } from '../auth/auth.types';
import { assertHasContent, ImagesService, imageUrl } from './images.service';

export interface ChatMessageView {
  id: string;
@@ -11,6 +12,8 @@ export interface ChatMessageView {
  authorLabel: string;
  authorParticipantId: string | null;
  body: string;
  /** Image jointe, ou null. */
  imageUrl: string | null;
  atExerciseSec: number;
  createdAt: string;
}
@@ -26,6 +29,7 @@ export class ChatService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly rt: RealtimeGateway,
    private readonly images: ImagesService,
  ) {}

  private async exerciseSeconds(tenantId: string, exerciseId: string): Promise<number> {
@@ -149,6 +153,7 @@ export class ChatService {
    authorLabel: string;
    authorParticipantId: string | null;
    body: string;
    imageId: string | null;
    atExerciseSec: number;
    createdAt: Date;
  }): ChatMessageView {
@@ -158,6 +163,7 @@ export class ChatService {
      authorLabel: m.authorLabel,
      authorParticipantId: m.authorParticipantId,
      body: m.body,
      imageUrl: m.imageId ? imageUrl(m.imageId) : null,
      atExerciseSec: m.atExerciseSec,
      createdAt: m.createdAt.toISOString(),
    };
@@ -169,7 +175,10 @@ export class ChatService {
    channelId: string,
    body: string,
    author: { label: string; participantId?: string },
    imageId?: string | null,
  ): Promise<ChatMessageView> {
    assertHasContent(body, imageId);
    if (imageId) await this.images.assertBelongs(tenantId, exerciseId, imageId);
    const channel = await this.prisma.forTenant(tenantId).channel.findFirst({
      where: { id: channelId, exerciseId },
      include: { members: true },
@@ -185,6 +194,7 @@ export class ChatService {
        authorParticipantId: author.participantId ?? null,
        authorLabel: author.label,
        body,
        imageId: imageId ?? null,
        atExerciseSec: atSec,
      },
    });
@@ -201,8 +211,14 @@ export class ChatService {
    return view;
  }

  async postByStaff(tenantId: string, exerciseId: string, channelId: string, body: string) {
    return this.post(tenantId, exerciseId, channelId, body, { label: 'Animation' });
  async postByStaff(
    tenantId: string,
    exerciseId: string,
    channelId: string,
    body: string,
    imageId?: string | null,
  ) {
    return this.post(tenantId, exerciseId, channelId, body, { label: 'Animation' }, imageId);
  }

  /** Post depuis un inject (label = expediteur simule, defaut Animation). */
@@ -216,16 +232,25 @@ export class ChatService {
    return this.post(tenantId, exerciseId, channelId, body, { label });
  }

  async postByPlayer(player: PlayerContext, channelId: string, body: string) {
  async postByPlayer(
    player: PlayerContext,
    channelId: string,
    body: string,
    imageId?: string | null,
  ) {
    await this.assertPlayerAccess(player, channelId);
    const p = await this.prisma.forTenant(player.tenantId).participant.findFirst({
      where: { id: player.participantId },
      include: { character: { select: { name: true } } },
    });
    if (!p) throw new NotFoundException();
    return this.post(player.tenantId, player.exerciseId, channelId, body, {
      label: p.displayName ?? p.character.name,
      participantId: p.id,
    });
    return this.post(
      player.tenantId,
      player.exerciseId,
      channelId,
      body,
      { label: p.displayName ?? p.character.name, participantId: p.id },
      imageId,
    );
  }
}
Loading