From f25f3fa634568ccb3b2d849ce99be3fd9a5d07d7 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 3 Jun 2026 17:13:46 +0200 Subject: [PATCH] feat(share) : controllers status/browse/download du partage --- .../Share/ShareBrowseController.php | 78 +++++++++++++++++++ .../Share/ShareDownloadController.php | 74 ++++++++++++++++++ .../Share/ShareStatusController.php | 27 +++++++ .../Functional/Controller/ShareBrowseTest.php | 62 +++++++++++++++ 4 files changed, 241 insertions(+) create mode 100644 src/Controller/Share/ShareBrowseController.php create mode 100644 src/Controller/Share/ShareDownloadController.php create mode 100644 src/Controller/Share/ShareStatusController.php create mode 100644 tests/Functional/Controller/ShareBrowseTest.php diff --git a/src/Controller/Share/ShareBrowseController.php b/src/Controller/Share/ShareBrowseController.php new file mode 100644 index 0000000..1972f49 --- /dev/null +++ b/src/Controller/Share/ShareBrowseController.php @@ -0,0 +1,78 @@ +query->get('path', ''); + + try { + $path = $this->pathResolver->normalizeRelative($rawPath); + } catch (InvalidPathException) { + return new JsonResponse(['error' => 'Invalid path.'], 400); + } + + try { + $entries = $this->fileSource->dir($path); + } catch (ShareNotConfiguredException) { + return new JsonResponse(['error' => 'Share not configured.'], 409); + } catch (ShareConnectionException $e) { + return new JsonResponse(['error' => $e->getMessage()], 502); + } + + return new JsonResponse([ + 'path' => $path, + 'breadcrumb' => $this->breadcrumb($path), + 'entries' => array_map(static fn (FileEntry $e): array => [ + 'name' => $e->name, + 'path' => $e->path, + 'isDir' => $e->isDir, + 'size' => $e->size, + 'modifiedAt' => $e->modifiedAt, + 'mimeType' => $e->mimeType, + ], $entries), + ]); + } + + /** + * @return array + */ + private function breadcrumb(string $path): array + { + if ('' === $path) { + return []; + } + + $crumbs = []; + $acc = ''; + foreach (explode('/', $path) as $segment) { + $acc = '' === $acc ? $segment : $acc.'/'.$segment; + $crumbs[] = ['name' => $segment, 'path' => $acc]; + } + + return $crumbs; + } +} diff --git a/src/Controller/Share/ShareDownloadController.php b/src/Controller/Share/ShareDownloadController.php new file mode 100644 index 0000000..7f3dcd1 --- /dev/null +++ b/src/Controller/Share/ShareDownloadController.php @@ -0,0 +1,74 @@ +query->get('path', ''); + + try { + $path = $this->pathResolver->normalizeRelative($rawPath); + } catch (InvalidPathException) { + return new Response('Invalid path.', 400); + } + + if ('' === $path) { + throw new NotFoundHttpException('No file requested.'); + } + + try { + $stream = $this->fileSource->read($path); + } catch (ShareNotConfiguredException) { + return new Response('Share not configured.', 409); + } catch (ShareConnectionException $e) { + throw new NotFoundHttpException($e->getMessage()); + } + + $name = basename($path); + $extension = pathinfo($name, PATHINFO_EXTENSION); + $mime = MimeTypes::getDefault()->getMimeTypes($extension)[0] ?? 'application/octet-stream'; + + // SVG toujours en attachment (anti-XSS) ; sinon respecte le paramètre demandé. + $requested = 'attachment' === $request->query->get('disposition') ? 'attachment' : 'inline'; + $disposition = 'image/svg+xml' === $mime ? HeaderUtils::DISPOSITION_ATTACHMENT : $requested; + + $response = new StreamedResponse(function () use ($stream): void { + if (is_resource($stream)) { + fpassthru($stream); + fclose($stream); + } + }); + $response->headers->set('Content-Type', $mime); + $response->headers->set('Content-Disposition', HeaderUtils::makeDisposition($disposition, $name)); + + return $response; + } +} diff --git a/src/Controller/Share/ShareStatusController.php b/src/Controller/Share/ShareStatusController.php new file mode 100644 index 0000000..4c226cb --- /dev/null +++ b/src/Controller/Share/ShareStatusController.php @@ -0,0 +1,27 @@ +configRepository->findSingleton(); + + return new JsonResponse(['enabled' => null !== $config && $config->isUsable()]); + } +} diff --git a/tests/Functional/Controller/ShareBrowseTest.php b/tests/Functional/Controller/ShareBrowseTest.php new file mode 100644 index 0000000..d415f12 --- /dev/null +++ b/tests/Functional/Controller/ShareBrowseTest.php @@ -0,0 +1,62 @@ +request('GET', '/api/share/browse?path=/'); + + self::assertSame(401, $client->getResponse()->getStatusCode()); + } + + public function testBrowseRejectsPathTraversal(): void + { + $client = self::createClient(); + $this->login($client); + + $client->request('GET', '/api/share/browse?path='.urlencode('../etc')); + + self::assertSame(400, $client->getResponse()->getStatusCode()); + } + + public function testBrowseReturns409WhenNotConfigured(): void + { + $client = self::createClient(); + $this->login($client); + + $client->request('GET', '/api/share/browse?path='); + + self::assertSame(409, $client->getResponse()->getStatusCode()); + } + + public function testStatusReturnsDisabledByDefault(): void + { + $client = self::createClient(); + $this->login($client); + + $client->request('GET', '/api/share/status'); + + self::assertResponseIsSuccessful(); + $data = json_decode($client->getResponse()->getContent(), true); + self::assertFalse($data['enabled']); + } + + private function login(KernelBrowser $client): void + { + $em = self::getContainer()->get('doctrine.orm.entity_manager'); + $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); + $client->loginUser($user); + } +}