svg
This commit is contained in:
@ -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);
|
||||
}
|
||||
|
@ -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');
|
||||
}
|
||||
|
@ -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]
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
121
templates/file/browse.html.twig
Normal file
121
templates/file/browse.html.twig
Normal file
@ -0,0 +1,121 @@
|
||||
<div id="file-browser-{{ domain }}-{{ id|e('html_attr') }}"
|
||||
class="file-browser"
|
||||
data-domain="{{ domain }}"
|
||||
data-id="{{ id }}"
|
||||
data-base-path="{{ path('app_files', { domain: domain, id: id }) }}"
|
||||
data-current-path="{{ path }}">
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">Fichiers</div>
|
||||
<div class="card-body">
|
||||
|
||||
{% if editable %}
|
||||
<div class="mb-3">
|
||||
<form method="post" enctype="multipart/form-data" class="d-flex gap-2 align-items-center" id="upload-form-{{ domain }}-{{ id }}">
|
||||
<input type="file" name="file" required>
|
||||
<input type="hidden" name="path" value="{{ path }}">
|
||||
<button type="submit" class="btn btn-sm btn-primary">Uploader</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p><strong>Chemin :</strong> {{ path ?: '/' }}</p>
|
||||
|
||||
{% set parentPath = path|split('/')|slice(0, -1)|join('/') %}
|
||||
|
||||
<ul class="list-unstyled">
|
||||
{% if path %}
|
||||
<li><a href="#" class="file-nav" data-path="{{ parentPath }}">⬅️ ..</a></li>
|
||||
{% endif %}
|
||||
|
||||
{% for file in files %}
|
||||
<li>
|
||||
{% if file.isDirectory %}
|
||||
📁 <a href="#" class="file-nav" data-path="{{ path ? path ~ '/' ~ file.name : file.name }}">{{ file.name }}/</a>
|
||||
{% if editable %}
|
||||
<button class="btn btn-sm btn-danger btn-delete ms-2" data-path="{{ file.path }}">🗑️</button>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
📄 {{ file.name }}
|
||||
{% if editable %}
|
||||
<button class="btn btn-sm btn-danger btn-delete ms-2" data-path="{{ file.path }}">🗑️</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</li>
|
||||
{% else %}
|
||||
<li><em>Dossier vide</em></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
function initFileBrowser(container) {
|
||||
if (!container) return;
|
||||
|
||||
container.addEventListener('click', function (e) {
|
||||
// Navigation
|
||||
if (e.target.classList.contains('file-nav')) {
|
||||
e.preventDefault();
|
||||
const path = e.target.dataset.path;
|
||||
refreshContainer(container, path);
|
||||
}
|
||||
|
||||
// Suppression
|
||||
if (e.target.classList.contains('btn-delete')) {
|
||||
e.preventDefault();
|
||||
if (!confirm('Supprimer ce fichier ?')) return;
|
||||
|
||||
const pathToDelete = e.target.dataset.path;
|
||||
const currentPath = container.dataset.currentPath || '';
|
||||
|
||||
fetch('/user/file/' + container.dataset.domain + '/' + container.dataset.id + '/delete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: JSON.stringify({ path: pathToDelete })
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Rafraîchit après suppression
|
||||
refreshContainer(container, currentPath);
|
||||
} else {
|
||||
alert('Erreur : ' + data.error);
|
||||
}
|
||||
})
|
||||
.catch(err => alert('Erreur lors de la suppression : ' + err.message));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function refreshContainer(oldContainer, path) {
|
||||
const domain = oldContainer.dataset.domain;
|
||||
const id = oldContainer.dataset.id;
|
||||
const base = oldContainer.dataset.basePath;
|
||||
|
||||
fetch(base + '?path=' + encodeURIComponent(path))
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const newContainer = doc.getElementById(oldContainer.id);
|
||||
if (newContainer) {
|
||||
oldContainer.replaceWith(newContainer);
|
||||
newContainer.dataset.currentPath = path;
|
||||
initFileBrowser(newContainer); // re-binde sur le nouveau DOM
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelectorAll('.file-browser').forEach(initFileBrowser);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
</div>
|
@ -35,7 +35,7 @@
|
||||
</div>
|
||||
|
||||
{% if mode=="update" %}
|
||||
{{ render(path("app_files",{id:project.id}))}}
|
||||
{{ render(path("app_files",{domain:'project',id:project.id})) }}
|
||||
{% endif %}
|
||||
{{ form_end(form) }}
|
||||
|
||||
|
Reference in New Issue
Block a user