diff --git a/src/Controller/FileController.php b/src/Controller/FileController.php index ee1bc15..7c46bfb 100644 --- a/src/Controller/FileController.php +++ b/src/Controller/FileController.php @@ -6,19 +6,66 @@ use App\Service\FileService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; class FileController extends AbstractController { - #[Route('/user/file/{id}', name: 'app_files', methods: ['GET'])] - public function browse(int $id, Request $request, FileService $fileService): JsonResponse + private FileService $fileService; + + public function __construct(FileService $fileService) + { + $this->fileService = $fileService; + } + + #[Route('/user/file/{domain}/{id}', name: 'app_files', methods: ['GET'])] + public function browse(string $domain, int $id, Request $request): Response { $relativePath = $request->query->get('path', ''); try { - $files = $fileService->list($id, ''); + $files = $this->fileService->list($domain, (string) $id, $relativePath); - return $this->json(['files' => $files]); + return $this->render('file/browse.html.twig', [ + 'domain' => $domain, + 'id' => $id, + 'files' => $files, + 'path' => $relativePath, + 'editable' => true, + ]); + } catch (\Exception $e) { + $this->addFlash('danger', $e->getMessage()); + + return $this->redirectToRoute('app_files', [ + 'domain' => $domain, + 'id' => $id, + 'editable' => true, + ]); + } + } + + #[Route('/user/file/{domain}/{id}/uploadmodal', name: 'app_files_uploadmodal', methods: ['GET'])] + public function upload(string $domain, int $id, Request $request): Response + { + $relativePath = $request->query->get('path', ''); + + return new JsonResponse(['success' => true]); + } + + #[Route('/user/file/{domain}/{id}/delete', name: 'app_files_delete', methods: ['POST'])] + public function delete(string $domain, int $id, Request $request): JsonResponse + { + $data = json_decode($request->getContent(), true); + $relativePath = $data['path'] ?? null; + + if (!$relativePath) { + return $this->json(['error' => 'Chemin non fourni.'], 400); + } + + try { + $this->fileService->delete($domain, (string) $id, $relativePath); + + return $this->json(['success' => true]); } catch (\Exception $e) { return $this->json(['error' => $e->getMessage()], 400); } diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php index b110b4d..90de8b5 100644 --- a/src/Controller/ProjectController.php +++ b/src/Controller/ProjectController.php @@ -6,6 +6,7 @@ use App\Entity\Project; use App\Entity\User; use App\Form\ProjectType; use App\Repository\ProjectRepository; +use App\Service\FileService; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -15,6 +16,13 @@ use Symfony\Component\Routing\Attribute\Route; class ProjectController extends AbstractController { + private FileService $fileService; + + public function __construct(FileService $fileService) + { + $this->fileService = $fileService; + } + #[Route('/admin/project', name: 'app_admin_project')] public function list(ProjectRepository $projectRepository): Response { @@ -41,6 +49,7 @@ class ProjectController extends AbstractController if ($form->isSubmitted() && $form->isValid()) { $em->persist($project); $em->flush(); + $this->fileService->init('project', $project->getId()); return $this->redirectToRoute('app_admin_project'); } diff --git a/src/Entity/Project.php b/src/Entity/Project.php index 516384e..c3ef12e 100644 --- a/src/Entity/Project.php +++ b/src/Entity/Project.php @@ -8,7 +8,6 @@ use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: ProjectRepository::class)] -#[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_project_title', fields: ['title'])] class Project { #[ORM\Id] diff --git a/src/Service/FileService.php b/src/Service/FileService.php index 9dee7b7..5981246 100644 --- a/src/Service/FileService.php +++ b/src/Service/FileService.php @@ -2,32 +2,58 @@ namespace App\Service; +use Symfony\Component\Filesystem\Exception\IOExceptionInterface; +use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Finder\Finder; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\HttpKernel\KernelInterface; class FileService { private string $basePath; + private Filesystem $filesystem; - public function __construct() + public function __construct(KernelInterface $kernel) { - // Répertoire en dur dans le projet, pas dans /public - $this->basePath = realpath(__DIR__.'/../../uploads'); - if (!$this->basePath) { - throw new \RuntimeException('Répertoire /uploads introuvable.'); + $this->filesystem = new Filesystem(); + $projectDir = $kernel->getProjectDir(); // chemin racine du projet + $this->basePath = $projectDir.'/uploads'; + + if (!is_dir($this->basePath)) { + // On crée le dossier uploads s'il n'existe pas + try { + $this->filesystem->mkdir($this->basePath, 0775); + } catch (IOExceptionInterface $e) { + throw new \RuntimeException('Impossible de créer le dossier /uploads : '.$e->getMessage()); + } } } /** - * Liste les fichiers/dossiers pour un projet donné + * Initialise un répertoire pour une entité (ex: project/123) */ - public function list(string $projectId, string $relativePath = ''): array + public function init(string $domain, string $id): void { - $targetPath = $this->basePath.'/'.$projectId.'/'.ltrim($relativePath, '/'); + $entityPath = $this->getEntityPath($domain, $id); + if (!is_dir($entityPath)) { + try { + $this->filesystem->mkdir($entityPath, 0775); + } catch (IOExceptionInterface $e) { + throw new \RuntimeException(sprintf('Impossible de créer le répertoire pour %s/%s : %s', $domain, $id, $e->getMessage())); + } + } + } + + /** + * Liste les fichiers d’un répertoire lié à une entité (ex: project/123) + */ + public function list(string $domain, string $id, string $relativePath = ''): array + { + $targetPath = $this->getEntityPath($domain, $id).'/'.ltrim($relativePath, '/'); $realPath = realpath($targetPath); - // Sécurité : protection contre les accès hors du dossier projet - if (!$realPath || !str_starts_with($realPath, $this->basePath.'/'.$projectId)) { + $baseEntityPath = $this->getEntityPath($domain, $id); + if (!$realPath || !str_starts_with($realPath, $baseEntityPath)) { throw new NotFoundHttpException('Répertoire non autorisé ou inexistant.'); } @@ -39,10 +65,37 @@ class FileService $results[] = [ 'name' => $file->getFilename(), 'isDirectory' => $file->isDir(), - 'path' => ltrim(str_replace($this->basePath.'/'.$projectId, '', $file->getRealPath()), '/'), + 'path' => ltrim(str_replace($baseEntityPath, '', $file->getRealPath()), '/'), ]; } return $results; } + + /** + * Supprime un fichier ou dossier (de façon sécurisée) + */ + public function delete(string $domain, string $id, string $relativePath): void + { + $baseEntityPath = $this->getEntityPath($domain, $id); + $targetPath = realpath($baseEntityPath.'/'.ltrim($relativePath, '/')); + + if (!$targetPath || !str_starts_with($targetPath, $baseEntityPath)) { + throw new NotFoundHttpException('Fichier ou dossier non autorisé.'); + } + + try { + $this->filesystem->remove($targetPath); + } catch (IOExceptionInterface $e) { + throw new \RuntimeException('Erreur lors de la suppression : '.$e->getMessage()); + } + } + + /** + * Construit le chemin absolu d’un domaine/id + */ + private function getEntityPath(string $domain, string $id): string + { + return $this->basePath.'/'.$domain.'/'.$id; + } } diff --git a/templates/file/browse.html.twig b/templates/file/browse.html.twig new file mode 100644 index 0000000..ce350a2 --- /dev/null +++ b/templates/file/browse.html.twig @@ -0,0 +1,121 @@ +
Chemin : {{ path ?: '/' }}
+ + {% set parentPath = path|split('/')|slice(0, -1)|join('/') %} + +