Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import './js/move-modal.js';
import './js/folder-children.js';
import './js/rename.js';
import './js/delete-folder-modal.js';
import './js/delete-file-modal.js';
import './js/pdf-viewer-modal.js';
import './js/media-viewer-modal.js';
import { initUploadModal } from './js/upload-modal.js';
Expand Down
41 changes: 41 additions & 0 deletions assets/js/delete-file-modal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Modal } from './modal.js';

/**
* Ouvre la modale de suppression de fichier (#246).
*
* L'option « conserver dans mes albums » n'est affichée que si le fichier
* a un Media rattaché à au moins un album (inAlbum) — sinon confirmation
* simple, jamais de confirm() natif.
*
* @param {string} fileId UUID du fichier à supprimer
* @param {string} fileName Nom affiché dans la modale
* @param {string} folderId Dossier courant (pour la redirection post-suppression)
* @param {boolean} inAlbum Le Media associé appartient à au moins un album
*/
window.openDeleteFileModal = function (fileId, fileName, folderId, inAlbum) {
const form = document.getElementById('delete-file-form');
const nameEl = document.getElementById('delete-file-name');
const folderInput = document.getElementById('delete-file-folder-input');
const albumOption = document.getElementById('delete-file-album-option');
const simpleConfirm = document.getElementById('delete-file-simple-confirm');

if (!form || !nameEl) return;

form.action = `/files/${fileId}/delete`;
nameEl.textContent = fileName || 'ce fichier';
folderInput.value = folderId || '';

albumOption.classList.toggle('hidden', !inAlbum);
simpleConfirm.classList.toggle('hidden', !!inAlbum);

Modal.open('delete-file-modal');
};

window.submitDeleteFile = function (keepInAlbums) {
const form = document.getElementById('delete-file-form');
const input = document.getElementById('delete-file-keep-in-albums-input');
if (!form || !input) return;

input.value = keepInAlbums ? '1' : '0';
form.submit();
};
63 changes: 63 additions & 0 deletions assets/tests/delete-file-modal.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { jest, describe, test, expect, beforeEach } from '@jest/globals';

jest.unstable_mockModule('../js/modal.js', () => ({
Modal: {
open: jest.fn(),
close: jest.fn(),
},
}));

const { Modal } = await import('../js/modal.js');
await import('../js/delete-file-modal.js');

describe('delete-file-modal behavior', () => {
beforeEach(() => {
jest.clearAllMocks();

document.body.innerHTML = `
<form id="delete-file-form" method="POST" action="">
<input type="hidden" name="keep_in_albums" id="delete-file-keep-in-albums-input" value="0">
<input type="hidden" name="folder_id" id="delete-file-folder-input" value="">
<span id="delete-file-name"></span>
</form>
<div id="delete-file-album-option" class="hidden"></div>
<div id="delete-file-simple-confirm" class="hidden"></div>
`;

const form = document.getElementById('delete-file-form');
form.submit = jest.fn();
});

test('opens modal and shows the album option when file is in an album', () => {
window.openDeleteFileModal('file-1', 'vacances.jpg', 'folder-1', true);

expect(Modal.open).toHaveBeenCalledWith('delete-file-modal');
expect(document.getElementById('delete-file-album-option').classList.contains('hidden')).toBe(false);
expect(document.getElementById('delete-file-simple-confirm').classList.contains('hidden')).toBe(true);
});

test('opens modal with simple confirmation only when file is not in any album', () => {
window.openDeleteFileModal('file-2', 'doc.pdf', 'folder-1', false);

expect(Modal.open).toHaveBeenCalledWith('delete-file-modal');
expect(document.getElementById('delete-file-album-option').classList.contains('hidden')).toBe(true);
expect(document.getElementById('delete-file-simple-confirm').classList.contains('hidden')).toBe(false);
});

test('submitDeleteFile sets keep_in_albums flag and submits the form', () => {
window.openDeleteFileModal('file-1', 'vacances.jpg', 'folder-1', true);
window.submitDeleteFile(true);

const form = document.getElementById('delete-file-form');
expect(document.getElementById('delete-file-keep-in-albums-input').value).toBe('1');
expect(form.action).toContain('file-1');
expect(form.submit).toHaveBeenCalled();
});

test('submitDeleteFile without keep sets keep_in_albums to 0', () => {
window.openDeleteFileModal('file-1', 'vacances.jpg', 'folder-1', true);
window.submitDeleteFile(false);

expect(document.getElementById('delete-file-keep-in-albums-input').value).toBe('0');
});
});
42 changes: 42 additions & 0 deletions migrations/Version20260723114759.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260723114759 extends AbstractMigration
{
public function getDescription(): string
{
return '#246 — Media::$file devient nullable (détachement) ; ajout de Media::$owner pour ne plus dépendre de File::$owner comme source d\'autorité.';
}

public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE medias DROP FOREIGN KEY `FK_12D2AF8193CB796C`');
$this->addSql('ALTER TABLE medias ADD owner_id BINARY(16) DEFAULT NULL, CHANGE file_id file_id BINARY(16) DEFAULT NULL');
// Backfill : owner_id hérite du propriétaire du File tant qu'il existe encore (aucun Media n'est détaché à ce stade).
$this->addSql('UPDATE medias m INNER JOIN files f ON m.file_id = f.id SET m.owner_id = f.owner_id');
$this->addSql('ALTER TABLE medias CHANGE owner_id owner_id BINARY(16) NOT NULL');
$this->addSql('ALTER TABLE medias ADD CONSTRAINT FK_12D2AF8193CB796C FOREIGN KEY (file_id) REFERENCES files (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE medias ADD CONSTRAINT FK_12D2AF817E3C61F9 FOREIGN KEY (owner_id) REFERENCES users (id)');
$this->addSql('CREATE INDEX IDX_12D2AF817E3C61F9 ON medias (owner_id)');
}

public function down(Schema $schema): void
{
// Irréversible pour les Media détachés (file_id NULL) : ils perdent
// leur seule ancre vers un File au moment de repasser en NOT NULL.
$this->addSql('ALTER TABLE medias DROP FOREIGN KEY FK_12D2AF8193CB796C');
$this->addSql('ALTER TABLE medias DROP FOREIGN KEY FK_12D2AF817E3C61F9');
$this->addSql('DROP INDEX IDX_12D2AF817E3C61F9 ON medias');
$this->addSql('ALTER TABLE medias DROP owner_id, CHANGE file_id file_id BINARY(16) NOT NULL');
$this->addSql('ALTER TABLE medias ADD CONSTRAINT `FK_12D2AF8193CB796C` FOREIGN KEY (file_id) REFERENCES files (id) ON DELETE CASCADE');
}
}
3 changes: 2 additions & 1 deletion src/ApiResource/MediaOutput.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ final class MediaOutput
public function __construct(
public readonly string $id,
public readonly string $mediaType,
public readonly string $fileId,
/** null si le Media est détaché de son File source (#246) */
public readonly ?string $fileId,
public readonly ?int $width,
public readonly ?int $height,
public readonly ?string $takenAt,
Expand Down
9 changes: 8 additions & 1 deletion src/Controller/Api/MediaThumbnailController.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,14 @@ public function __invoke(string $id): StreamedResponse

/** @var User $user */
$user = $this->getUser();
if (!$this->resourceAccessChecker->canRead($user, Share::RESOURCE_FILE, $media->getFile()->getId(), $media->getFile()->getOwner())) {
// Un Media détaché (#246) n'a plus de File : sa vignette doit rester
// consultable par son owner (c'est tout l'objet du détachement), mais
// le partage par fichier n'a alors plus de sens.
$file = $media->getFile();
$canRead = $file !== null
? $this->resourceAccessChecker->canRead($user, Share::RESOURCE_FILE, $file->getId(), $file->getOwner())
: $media->isOwnedBy($user);
if (!$canRead) {
throw $this->createAccessDeniedException('Vous ne pouvez pas voir ce média.');
}

Expand Down
17 changes: 16 additions & 1 deletion src/Controller/Web/ExplorerController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace App\Controller\Web;

use App\Entity\User;
use App\Interface\AlbumRepositoryInterface;
use App\Repository\FileRepository;
use App\Repository\FolderRepository;
use App\Repository\MediaRepository;
Expand All @@ -26,6 +27,7 @@ public function __construct(
private readonly FolderRepository $folderRepository,
private readonly FileRepository $fileRepository,
private readonly MediaRepository $mediaRepository,
private readonly AlbumRepositoryInterface $albumRepository,
) {}

#[Route('/explorer', name: 'app_explorer')]
Expand Down Expand Up @@ -60,10 +62,22 @@ public function index(Request $request): Response
// aucun chemin vers la vignette. Une relation inverse lazy provoquerait un
// N+1 sur chaque carte de la grille.
$mediasByFileId = [];
foreach ($this->mediaRepository->findBy(['file' => $files]) as $media) {
$mediasInFolder = $this->mediaRepository->findBy(['file' => $files]);
foreach ($mediasInFolder as $media) {
$mediasByFileId[$media->getFile()->getId()->toRfc4122()] = $media;
}

// Fichiers dont le Media est dans au moins un album (#246) : détermine
// si la modale de suppression propose l'option "conserver dans mes albums".
$mediaIds = array_map(static fn ($media) => $media->getId(), $mediasInFolder);
$albumNamesByMediaId = $this->albumRepository->findAlbumNamesByMediaIds($mediaIds);
$filesInAlbum = [];
foreach ($mediasByFileId as $fileId => $media) {
if (isset($albumNamesByMediaId[$media->getId()->toRfc4122()])) {
$filesInAlbum[] = $fileId;
}
}

// Construit le chemin complet (ancêtres) pour la breadcrumb
$breadcrumbFolders = [];
$ancestor = $currentFolder;
Expand All @@ -88,6 +102,7 @@ public function index(Request $request): Response
'folders' => $folders,
'files' => $files,
'mediasByFileId' => $mediasByFileId,
'filesInAlbum' => $filesInAlbum,
'folderCount' => count($folders),
'fileCount' => count($files),
'sidebarTree' => $this->folderRepository->findAllAsTree($user, $currentFolder),
Expand Down
36 changes: 34 additions & 2 deletions src/Controller/Web/FileWebController.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@

use App\Entity\File;
use App\Entity\Share;
use App\Interface\MediaDeletionServiceInterface;
use App\Interface\MediaDetachServiceInterface;
use App\Interface\MediaProcessorInterface;
use App\Interface\StorageServiceInterface;
use App\Repository\FileRepository;
use App\Repository\MediaRepository;
use App\Interface\DefaultFolderServiceInterface;
use App\Interface\SharedResourceCleanerInterface;
use App\Security\GuestRestrictionChecker;
Expand Down Expand Up @@ -51,6 +54,9 @@ public function __construct(
private readonly PendingMediaProcessingCollector $pendingMediaProcessingCollector,
private readonly MediaProcessorInterface $mediaProcessor,
private readonly PdfSignatureDetector $pdfSignatureDetector,
private readonly MediaRepository $mediaRepository,
private readonly MediaDetachServiceInterface $mediaDetachService,
private readonly MediaDeletionServiceInterface $mediaDeletionService,
) {}

#[Route('/files/{id}/download', name: 'app_file_download', methods: ['GET'])]
Expand Down Expand Up @@ -206,17 +212,43 @@ public function delete(string $id, Request $request): Response
}

$folderId = $request->request->get('folder_id');
$keepInAlbums = (bool) $request->request->get('keep_in_albums', '0');
$media = $this->mediaRepository->findByFile($file);

if ($media !== null && $keepInAlbums) {
try {
$this->mediaDetachService->detachAndDeleteFile($media);
} catch (\Throwable) {
$this->addFlash('error', "Erreur lors de la suppression du fichier « {$file->getOriginalName()} ».");

return $this->redirect($folderId ? '/explorer?folder=' . $folderId : '/explorer');
}

$this->addFlash('success', "Fichier « {$file->getOriginalName()} » supprimé, conservé dans vos albums.");

return $this->redirect($folderId ? '/explorer?folder=' . $folderId : '/explorer');
}

try {
$this->storage->delete($file->getPath());
if ($media !== null) {
// Media::$file est désormais onDelete: SET NULL (#246, plus de
// CASCADE) : la suppression complète doit retirer le Media
// explicitement, sinon il devient orphelin (file_id NULL) sans
// que l'utilisateur ait choisi de le conserver.
$this->mediaDeletionService->delete($media);
} else {
$this->storage->delete($file->getPath());
}
} catch (\Throwable) {
$this->addFlash('error', "Erreur lors de la suppression du fichier « {$file->getOriginalName()} ».");

return $this->redirect($folderId ? '/explorer?folder=' . $folderId : '/explorer');
}

$this->sharedResourceCleaner->deleteByResource(Share::RESOURCE_FILE, $file->getId());
$this->em->remove($file);
if ($media === null) {
$this->em->remove($file);
}
$this->em->flush();

$this->addFlash('success', "Fichier « {$file->getOriginalName()} » supprimé.");
Expand Down
5 changes: 5 additions & 0 deletions src/Controller/Web/MediaGalleryController.php
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,11 @@ public function full(string $id): Response
}

$file = $media->getFile();
if ($file === null) {
// Media détaché (#246) : le fichier source a été supprimé
// volontairement, seule la vignette reste disponible.
throw $this->createNotFoundException("Fichier d'origine supprimé, seule la vignette reste disponible.");
}
$absolutePath = $this->storageService->getAbsolutePath($file->getPath());

if (!file_exists($absolutePath)) {
Expand Down
12 changes: 10 additions & 2 deletions src/Controller/Web/MediaRenameController.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
Expand Down Expand Up @@ -41,9 +42,16 @@ public function __invoke(string $id, Request $request): JsonResponse
throw $this->createNotFoundException('Média introuvable.');
}

$file = $media->getFile();
if ($file === null) {
// Media détaché (#246) : plus de File à renommer. Le bouton est
// masqué côté template, mais l'endpoint doit rester sûr.
throw new BadRequestHttpException('Impossible de renommer : le fichier source a été supprimé.');
}

$newName = (string) $request->request->get('name', '');
$this->fileActionService->rename($media->getFile(), $newName);
$this->fileActionService->rename($file, $newName);

return $this->json(['name' => $media->getFile()->getOriginalName()]);
return $this->json(['name' => $file->getOriginalName()]);
}
}
5 changes: 4 additions & 1 deletion src/Controller/Web/PublicShareController.php
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,10 @@ private function mediaInScopeOrFail(string $mediaId, \App\Entity\ShareLink $link
$media = $this->mediaRepository->findById(Uuid::fromString($mediaId))
?? throw new NotFoundHttpException();

if (!$this->sharedFileScopeChecker->isInScope($media->getFile(), $link->getResourceType(), $link->getResourceId(), $linkResource)) {
// Un Media détaché (#246) n'a plus de File : il ne peut plus être
// dans le périmètre d'un partage par fichier/dossier/album.
$file = $media->getFile();
if ($file === null || !$this->sharedFileScopeChecker->isInScope($file, $link->getResourceType(), $link->getResourceId(), $linkResource)) {
throw new AccessDeniedHttpException('Ce média ne fait pas partie du partage.');
}

Expand Down
Loading