diff --git a/docs/superpowers/plans/2026-06-03-explorateur-partage-windows.md b/docs/superpowers/plans/2026-06-03-explorateur-partage-windows.md new file mode 100644 index 0000000..48753af --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-explorateur-partage-windows.md @@ -0,0 +1,2223 @@ +# Explorateur de partage Windows + viewer — Plan d'implémentation + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Donner accès depuis Lesstime à un partage de fichiers Windows (SMB) avec un explorateur façon Drive/SharePoint, un viewer de documents, et une config admin activable + testable. + +**Architecture:** Connexion SMB en PHP via `icewind/smb` (config chiffrée en base, calquée sur Zimbra). Une interface `FileSource` (lister/lire) isole la source. Deux endpoints live (`browse`, `download`) + un endpoint `status`. Front : explorateur maison (Nuxt) + viewer réutilisant le pattern `TaskDocumentPreview`, PDF rendu via PDF.js. Le lien « Documents » n'apparaît que si la fonctionnalité est activée. + +**Tech Stack:** Symfony 8 / API Platform 4 / Doctrine / PostgreSQL, `icewind/smb`, `smbclient` (paquet Debian), Nuxt 4 / Vue 3 / Pinia, `vue-pdf-embed`. + +**Spec de référence :** `docs/superpowers/specs/2026-06-03-explorateur-partage-windows-design.md` + +**Conventions projet rappel :** `declare(strict_types=1)` partout ; commits `() : ` (espaces autour du `:`) ; controllers sous `/api/` avec `priority: 1` ; 4 espaces d'indentation côté front ; ne jamais modifier `@malio/layer-ui`. + +--- + +## Structure des fichiers + +**Backend (créés) :** +- `src/Entity/ShareConfiguration.php` — entité de config singleton (host, partage, basePath, domaine, login, mot de passe chiffré, enabled). +- `src/Repository/ShareConfigurationRepository.php` — `findSingleton()`. +- `src/ApiResource/ShareSettings.php` — DTO Get/Put `/api/settings/share`. +- `src/ApiResource/ShareTestConnection.php` — DTO Post `/api/settings/share/test`. +- `src/State/ShareSettingsProvider.php` / `ShareSettingsProcessor.php`. +- `src/State/ShareTestConnectionProvider.php`. +- `src/Service/Share/FileSource.php` — interface. +- `src/Service/Share/SmbFileSource.php` — implémentation SMB. +- `src/Service/Share/SharePathResolver.php` — normalisation + anti path-traversal (unité testée). +- `src/Service/Share/FileEntry.php` — value object d'une entrée (fichier/dossier). +- `src/Service/Share/ShareTestResult.php` — résultat du test de connexion. +- `src/Service/Share/Exception/InvalidPathException.php`, `ShareNotConfiguredException.php`, `ShareConnectionException.php`. +- `src/Controller/Share/ShareBrowseController.php`, `ShareDownloadController.php`, `ShareStatusController.php`. +- `migrations/VersionYYYYMMDDHHMMSS.php` — table `share_configuration` (générée). + +**Backend (modifiés) :** +- `composer.json` — dépendance `icewind/smb`. +- `infra/dev/Dockerfile` + `infra/prod/Dockerfile` — paquet `smbclient`. +- `config/services.yaml` — alias `FileSource` → `SmbFileSource`. + +**Frontend (créés) :** +- `frontend/services/dto/share.ts` — types `FileEntry`, `ShareBrowseResult`, `ShareSettings(Write)`, `ShareTestResult`, `ShareStatus`. +- `frontend/services/share.ts` — `browse`, `getDownloadUrl`, `getStatus`. +- `frontend/services/share-settings.ts` — `getSettings`, `saveSettings`, `testConnection`. +- `frontend/composables/useShareStatus.ts` — état « activé » mis en cache. +- `frontend/components/admin/AdminShareTab.vue` — onglet config. +- `frontend/components/share/SharedFilePreview.vue` — viewer. +- `frontend/pages/documents.vue` — explorateur. + +**Frontend (modifiés) :** +- `frontend/package.json` — dépendance `vue-pdf-embed`. +- `frontend/pages/admin.vue` — onglet « Partage ». +- `frontend/layouts/default.vue` — lien « Documents » conditionnel. +- `frontend/i18n/locales/fr.json` (+ autres locales) — clés `sharedFiles.*`, `adminShare.*`. + +--- + +## Task 1: Dépendances (composer + Docker) + +**Files:** +- Modify: `composer.json` +- Modify: `infra/dev/Dockerfile` (~ligne 9, liste `apt-get install`) +- Modify: `infra/prod/Dockerfile` (stage `production`, ~ligne 41) + +- [ ] **Step 1: Ajouter `smbclient` au Dockerfile dev** + +Dans `infra/dev/Dockerfile`, ajouter `smbclient` à la liste de paquets de la commande `RUN apt-get update && apt-get install -y \` (~ligne 9), par exemple en ajoutant une ligne ` smbclient \` parmi les autres paquets. + +- [ ] **Step 2: Ajouter `smbclient` au Dockerfile prod (stage production uniquement)** + +Dans `infra/prod/Dockerfile`, repérer le stage `FROM php:8.4-fpm AS production` (~ligne 39) et sa commande `RUN apt-get update && apt-get install -y \` (~ligne 41). Ajouter `smbclient \` à cette liste. **Ne pas** toucher au stage `backend-build`. + +- [ ] **Step 3: Rebuild de l'image dev et vérifier `smbclient`** + +Run : +```bash +docker compose -f infra/dev/docker-compose.yml build php # ou: make build si défini +docker exec php-lesstime-fpm which smbclient +``` +Expected : un chemin (`/usr/bin/smbclient`). + +> Si la commande `make`/compose diffère, adapter ; l'objectif est que `which smbclient` réponde dans le conteneur `php-lesstime-fpm`. + +- [ ] **Step 4: Installer `icewind/smb` via Composer (dans le conteneur)** + +Run : +```bash +docker exec -u www-data php-lesstime-fpm composer require icewind/smb +``` +Expected : `composer.json` et `composer.lock` mis à jour, dossier `vendor/icewind/smb` présent. + +- [ ] **Step 5: Commit** + +```bash +git add composer.json composer.lock infra/dev/Dockerfile infra/prod/Dockerfile +git commit -m "build(share) : ajout icewind/smb et paquet smbclient (dev + prod)" +``` + +> **Rappel déploiement (à ne pas oublier en prod)** : l'image prod `lesstime-app` devra être rebuildée et redéployée pour embarquer `smbclient`, et la migration de la Task 3 jouée en prod. + +--- + +## Task 2: Entité `ShareConfiguration` + repository + migration + +**Files:** +- Create: `src/Entity/ShareConfiguration.php` +- Create: `src/Repository/ShareConfigurationRepository.php` +- Create: `migrations/VersionYYYYMMDDHHMMSS.php` (générée) + +- [ ] **Step 1: Créer le repository** + +```php +createQueryBuilder('s') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult() + ; + } +} +``` + +- [ ] **Step 2: Créer l'entité** + +```php +id; + } + + public function getHost(): ?string + { + return $this->host; + } + + public function setHost(?string $host): static + { + $this->host = $host; + + return $this; + } + + public function getShareName(): ?string + { + return $this->shareName; + } + + public function setShareName(?string $shareName): static + { + $this->shareName = $shareName; + + return $this; + } + + public function getBasePath(): ?string + { + return $this->basePath; + } + + public function setBasePath(?string $basePath): static + { + $this->basePath = $basePath; + + return $this; + } + + public function getDomain(): ?string + { + return $this->domain; + } + + public function setDomain(?string $domain): static + { + $this->domain = $domain; + + return $this; + } + + public function getUsername(): ?string + { + return $this->username; + } + + public function setUsername(?string $username): static + { + $this->username = $username; + + return $this; + } + + public function getEncryptedPassword(): ?string + { + return $this->encryptedPassword; + } + + public function setEncryptedPassword(?string $encryptedPassword): static + { + $this->encryptedPassword = $encryptedPassword; + + return $this; + } + + public function isEnabled(): bool + { + return $this->enabled; + } + + public function setEnabled(bool $enabled): static + { + $this->enabled = $enabled; + + return $this; + } + + public function hasPassword(): bool + { + return null !== $this->encryptedPassword; + } + + public function isUsable(): bool + { + return $this->enabled + && null !== $this->host && '' !== $this->host + && null !== $this->shareName && '' !== $this->shareName; + } +} +``` + +- [ ] **Step 3: Générer la migration** + +Run : +```bash +docker exec -u www-data php-lesstime-fpm php bin/console make:migration --no-interaction +``` +Expected : un fichier `migrations/VersionYYYYMMDDHHMMSS.php` créé, contenant un `CREATE TABLE share_configuration` (colonnes en minuscules : `host`, `share_name`, `base_path`, `domain`, `username`, `encrypted_password`, `enabled`). + +- [ ] **Step 4: Jouer la migration** + +Run : +```bash +docker exec -u www-data php-lesstime-fpm php bin/console doctrine:migrations:migrate --no-interaction +``` +Expected : `migrated` sans erreur. Vérifier : `docker exec php-lesstime-fpm php bin/console dbal:run-sql "SELECT * FROM share_configuration"` → 0 ligne, pas d'erreur de table. + +- [ ] **Step 5: Commit** + +```bash +git add src/Entity/ShareConfiguration.php src/Repository/ShareConfigurationRepository.php migrations/ +git commit -m "feat(share) : entité ShareConfiguration + migration" +``` + +--- + +## Task 3: Résolution de chemin (anti path-traversal) — unité testée + +**Files:** +- Create: `src/Service/Share/Exception/InvalidPathException.php` +- Create: `src/Service/Share/SharePathResolver.php` +- Test: `tests/Unit/Service/SharePathResolverTest.php` + +- [ ] **Step 1: Écrire le test (qui échoue)** + +```php +resolver = new SharePathResolver(); + } + + public function testNormalizeRelativeKeepsSimplePath(): void + { + self::assertSame('a/b', $this->resolver->normalizeRelative('a/b')); + } + + public function testNormalizeRelativeStripsDotsAndSlashes(): void + { + self::assertSame('a/b', $this->resolver->normalizeRelative('/a/./b/')); + } + + public function testNormalizeRelativeConvertsBackslashes(): void + { + self::assertSame('a/b', $this->resolver->normalizeRelative('a\\b')); + } + + public function testNormalizeRelativeRejectsParentTraversal(): void + { + $this->expectException(InvalidPathException::class); + $this->resolver->normalizeRelative('a/../b'); + } + + public function testNormalizeRelativeRejectsLeadingParent(): void + { + $this->expectException(InvalidPathException::class); + $this->resolver->normalizeRelative('../etc/passwd'); + } + + public function testFullPathJoinsBaseAndRelative(): void + { + self::assertSame('/Projets/a/b', $this->resolver->fullPath('/Projets', 'a/b')); + } + + public function testFullPathWithEmptyBaseAndEmptyRelativeIsRoot(): void + { + self::assertSame('/', $this->resolver->fullPath('', '')); + } + + public function testFullPathTrimsBaseSlashes(): void + { + self::assertSame('/Projets/a', $this->resolver->fullPath('/Projets/', 'a')); + } +} +``` + +- [ ] **Step 2: Lancer le test → échec attendu** + +Run : +```bash +docker exec -u www-data php-lesstime-fpm vendor/bin/phpunit tests/Unit/Service/SharePathResolverTest.php +``` +Expected : FAIL (classes `SharePathResolver` / `InvalidPathException` introuvables). + +- [ ] **Step 3: Créer l'exception** + +```php +normalizeRelative($relativePath); + + $parts = array_values(array_filter([$base, $relative], static fn (string $p): bool => '' !== $p)); + + return '/'.implode('/', $parts); + } +} +``` + +- [ ] **Step 5: Lancer le test → succès attendu** + +Run : +```bash +docker exec -u www-data php-lesstime-fpm vendor/bin/phpunit tests/Unit/Service/SharePathResolverTest.php +``` +Expected : PASS (8 tests). + +- [ ] **Step 6: Commit** + +```bash +git add src/Service/Share/SharePathResolver.php src/Service/Share/Exception/InvalidPathException.php tests/Unit/Service/SharePathResolverTest.php +git commit -m "feat(share) : résolution de chemin SMB anti path-traversal" +``` + +--- + +## Task 4: `FileSource` + `SmbFileSource` + value objects + +**Files:** +- Create: `src/Service/Share/FileEntry.php` +- Create: `src/Service/Share/ShareTestResult.php` +- Create: `src/Service/Share/Exception/ShareNotConfiguredException.php` +- Create: `src/Service/Share/Exception/ShareConnectionException.php` +- Create: `src/Service/Share/FileSource.php` +- Create: `src/Service/Share/SmbFileSource.php` +- Modify: `config/services.yaml` + +- [ ] **Step 1: Value object `FileEntry`** + +```php +requireUsableConfig(); + $share = $this->connect($config); + $full = $this->pathResolver->fullPath((string) $config->getBasePath(), $relativePath); + + try { + $infos = $share->dir($full); + } catch (Throwable $e) { + throw new ShareConnectionException($e->getMessage(), 0, $e); + } + + $entries = array_map(fn (IFileInfo $i): FileEntry => $this->toEntry($i, $relativePath), $infos); + + usort($entries, static function (FileEntry $a, FileEntry $b): int { + if ($a->isDir !== $b->isDir) { + return $a->isDir ? -1 : 1; + } + + return strcasecmp($a->name, $b->name); + }); + + return $entries; + } + + public function read(string $relativePath) + { + $config = $this->requireUsableConfig(); + $share = $this->connect($config); + $full = $this->pathResolver->fullPath((string) $config->getBasePath(), $relativePath); + + try { + return $share->read($full); + } catch (Throwable $e) { + throw new ShareConnectionException($e->getMessage(), 0, $e); + } + } + + public function test(): ShareTestResult + { + try { + $config = $this->requireUsableConfig(); + $share = $this->connect($config); + $share->dir($this->pathResolver->fullPath((string) $config->getBasePath(), '')); + + return new ShareTestResult(true); + } catch (ShareNotConfiguredException $e) { + return new ShareTestResult(false, 'Configuration incomplète ou désactivée.'); + } catch (Throwable $e) { + return new ShareTestResult(false, $e->getMessage()); + } + } + + private function requireUsableConfig(): ShareConfiguration + { + $config = $this->configRepository->findSingleton(); + + if (null === $config || !$config->isUsable()) { + throw new ShareNotConfiguredException('Share is not configured or disabled.'); + } + + return $config; + } + + private function connect(ShareConfiguration $config): IShare + { + $password = null !== $config->getEncryptedPassword() + ? $this->tokenEncryptor->decrypt($config->getEncryptedPassword()) + : ''; + + $auth = new BasicAuth( + (string) $config->getUsername(), + $config->getDomain() ?: 'WORKGROUP', + $password, + ); + $server = (new ServerFactory())->createServer((string) $config->getHost(), $auth); + + try { + return $server->getShare((string) $config->getShareName()); + } catch (Throwable $e) { + throw new ShareConnectionException($e->getMessage(), 0, $e); + } + } + + private function toEntry(IFileInfo $info, string $parentRelative): FileEntry + { + $parent = '' === $parentRelative ? '' : rtrim($parentRelative, '/').'/'; + $path = $parent.$info->getName(); + $isDir = $info->isDirectory(); + + $mime = 'application/octet-stream'; + if (!$isDir) { + $guessed = MimeTypes::getDefault()->getMimeTypes(pathinfo($info->getName(), PATHINFO_EXTENSION)); + $mime = $guessed[0] ?? 'application/octet-stream'; + } + + return new FileEntry( + name: $info->getName(), + path: $path, + isDir: $isDir, + size: $isDir ? 0 : $info->getSize(), + modifiedAt: $info->getMTime(), + mimeType: $mime, + ); + } +} +``` + +- [ ] **Step 6: Alias `FileSource` dans `services.yaml`** + +Dans `config/services.yaml`, sous la clé `services:`, ajouter (après le bloc `_defaults` / `App\:` existant) : +```yaml + App\Service\Share\FileSource: '@App\Service\Share\SmbFileSource' +``` + +- [ ] **Step 7: Vérifier que le conteneur compile** + +Run : +```bash +docker exec -u www-data php-lesstime-fpm php bin/console cache:clear +docker exec -u www-data php-lesstime-fpm php bin/console debug:container App\\Service\\Share\\SmbFileSource +``` +Expected : pas d'erreur, le service est listé. + +- [ ] **Step 8: Commit** + +```bash +git add src/Service/Share/ config/services.yaml +git commit -m "feat(share) : source de fichiers SMB (FileSource + SmbFileSource)" +``` + +--- + +## Task 5: Config admin — `ShareSettings` (Get/Put) + provider/processor + +**Files:** +- Create: `src/ApiResource/ShareSettings.php` +- Create: `src/State/ShareSettingsProvider.php` +- Create: `src/State/ShareSettingsProcessor.php` +- Test: `tests/Functional/Controller/ShareSettingsTest.php` + +- [ ] **Step 1: DTO `ShareSettings`** + +```php + ['share_settings:read']], + provider: ShareSettingsProvider::class, + security: "is_granted('ROLE_ADMIN')", + ), + new Put( + uriTemplate: '/settings/share', + denormalizationContext: ['groups' => ['share_settings:write']], + normalizationContext: ['groups' => ['share_settings:read']], + provider: ShareSettingsProvider::class, + processor: ShareSettingsProcessor::class, + security: "is_granted('ROLE_ADMIN')", + ), + ], +)] +final class ShareSettings +{ + #[Groups(['share_settings:read', 'share_settings:write'])] + public ?string $host = null; + + #[Groups(['share_settings:read', 'share_settings:write'])] + public ?string $shareName = null; + + #[Groups(['share_settings:read', 'share_settings:write'])] + public ?string $basePath = null; + + #[Groups(['share_settings:read', 'share_settings:write'])] + public ?string $domain = null; + + #[Groups(['share_settings:read', 'share_settings:write'])] + public ?string $username = null; + + #[Groups(['share_settings:write'])] + public ?string $password = null; + + #[Groups(['share_settings:read', 'share_settings:write'])] + public bool $enabled = false; + + #[Groups(['share_settings:read'])] + public bool $hasPassword = false; +} +``` + +- [ ] **Step 2: Provider** + +```php +configRepository->findSingleton(); + $dto = new ShareSettings(); + + if (null !== $config) { + $dto->host = $config->getHost(); + $dto->shareName = $config->getShareName(); + $dto->basePath = $config->getBasePath(); + $dto->domain = $config->getDomain(); + $dto->username = $config->getUsername(); + $dto->enabled = $config->isEnabled(); + $dto->hasPassword = $config->hasPassword(); + } + + return $dto; + } +} +``` + +- [ ] **Step 3: Processor** + +```php +configRepository->findSingleton() ?? new ShareConfiguration(); + + $config->setHost($data->host); + $config->setShareName($data->shareName); + $config->setBasePath($data->basePath); + $config->setDomain($data->domain); + $config->setUsername($data->username); + $config->setEnabled($data->enabled); + + if (null !== $data->password && '' !== $data->password) { + $config->setEncryptedPassword($this->tokenEncryptor->encrypt($data->password)); + } + + $this->em->persist($config); + $this->em->flush(); + + $result = new ShareSettings(); + $result->host = $config->getHost(); + $result->shareName = $config->getShareName(); + $result->basePath = $config->getBasePath(); + $result->domain = $config->getDomain(); + $result->username = $config->getUsername(); + $result->enabled = $config->isEnabled(); + $result->hasPassword = $config->hasPassword(); + + return $result; + } +} +``` + +- [ ] **Step 4: Test fonctionnel (sécurité + non-exposition du mot de passe)** + +```php +request('GET', '/api/settings/share'); + + self::assertSame(401, $client->getResponse()->getStatusCode()); + } + + public function testAdminCanReadSettingsWithoutPasswordLeak(): void + { + $client = static::createClient(); + $this->loginAsAdmin($client); + + $client->request('GET', '/api/settings/share'); + + self::assertResponseIsSuccessful(); + $data = json_decode($client->getResponse()->getContent(), true); + self::assertArrayHasKey('hasPassword', $data); + self::assertArrayNotHasKey('password', $data); + self::assertArrayNotHasKey('encryptedPassword', $data); + } + + private function loginAsAdmin(\Symfony\Bundle\FrameworkBundle\KernelBrowser $client): void + { + $client->request('POST', '/login_check', server: [ + 'CONTENT_TYPE' => 'application/json', + ], content: json_encode(['username' => 'admin', 'password' => 'admin'])); + } +} +``` + +> Si l'authentification de test des autres `tests/Functional/Controller` utilise un helper différent (regarder un test existant, ex. un test Gitea/Zimbra s'il existe, sinon le pattern de login JWT), aligner `loginAsAdmin` dessus. + +- [ ] **Step 5: Lancer les tests** + +Run : +```bash +docker exec -u www-data php-lesstime-fpm vendor/bin/phpunit tests/Functional/Controller/ShareSettingsTest.php +``` +Expected : PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/ApiResource/ShareSettings.php src/State/ShareSettingsProvider.php src/State/ShareSettingsProcessor.php tests/Functional/Controller/ShareSettingsTest.php +git commit -m "feat(share) : endpoints de configuration admin (GET/PUT settings/share)" +``` + +--- + +## Task 6: Test de connexion — `ShareTestConnection` (Post) + +**Files:** +- Create: `src/ApiResource/ShareTestConnection.php` +- Create: `src/State/ShareTestConnectionProvider.php` + +- [ ] **Step 1: DTO `ShareTestConnection`** + +```php + ['share_test:read']], + provider: ShareTestConnectionProvider::class, + processor: ShareTestConnectionProvider::class, + security: "is_granted('ROLE_ADMIN')", + ), + ], +)] +final class ShareTestConnection +{ + #[Groups(['share_test:read'])] + public bool $success = false; + + #[Groups(['share_test:read'])] + public ?string $message = null; +} +``` + +- [ ] **Step 2: Provider/Processor** + +```php +fileSource->test(); + + $dto = new ShareTestConnection(); + $dto->success = $result->success; + $dto->message = $result->message; + + return $dto; + } +} +``` + +- [ ] **Step 3: Vérifier la route** + +Run : +```bash +docker exec -u www-data php-lesstime-fpm php bin/console debug:router | grep "settings/share" +``` +Expected : voir `/api/settings/share` (GET, PUT) et `/api/settings/share/test` (POST). + +- [ ] **Step 4: Commit** + +```bash +git add src/ApiResource/ShareTestConnection.php src/State/ShareTestConnectionProvider.php +git commit -m "feat(share) : endpoint test de connexion (POST settings/share/test)" +``` + +--- + +## Task 7: Controllers de navigation — `status`, `browse`, `download` + +**Files:** +- Create: `src/Controller/Share/ShareStatusController.php` +- Create: `src/Controller/Share/ShareBrowseController.php` +- Create: `src/Controller/Share/ShareDownloadController.php` +- Test: `tests/Functional/Controller/ShareBrowseTest.php` + +- [ ] **Step 1: `ShareStatusController`** + +```php +configRepository->findSingleton(); + + return new JsonResponse(['enabled' => null !== $config && $config->isUsable()]); + } +} +``` + +- [ ] **Step 2: `ShareBrowseController`** + +```php +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; + } +} +``` + +- [ ] **Step 3: `ShareDownloadController`** + +```php +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; + } +} +``` + +- [ ] **Step 4: Test fonctionnel (auth + path-traversal + config absente)** + +```php +request('GET', '/api/share/browse?path=/'); + + self::assertSame(401, $client->getResponse()->getStatusCode()); + } + + public function testBrowseRejectsPathTraversal(): void + { + $client = static::createClient(); + $this->login($client); + + $client->request('GET', '/api/share/browse?path='.urlencode('../etc')); + + self::assertSame(400, $client->getResponse()->getStatusCode()); + } + + public function testBrowseReturns409WhenNotConfigured(): void + { + $client = static::createClient(); + $this->login($client); + + $client->request('GET', '/api/share/browse?path='); + + self::assertSame(409, $client->getResponse()->getStatusCode()); + } + + public function testStatusReturnsDisabledByDefault(): void + { + $client = static::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 + { + $client->request('POST', '/login_check', server: [ + 'CONTENT_TYPE' => 'application/json', + ], content: json_encode(['username' => 'admin', 'password' => 'admin'])); + } +} +``` + +> `testBrowseRejectsPathTraversal` valide le chemin **avant** toute connexion SMB (donc passe sans serveur réel). `testBrowseReturns409WhenNotConfigured` suppose une base de test sans `share_configuration` activée (fixtures par défaut). Si les fixtures venaient à créer une config activée, adapter l'assertion. + +- [ ] **Step 5: Lancer les tests** + +Run : +```bash +docker exec -u www-data php-lesstime-fpm vendor/bin/phpunit tests/Functional/Controller/ShareBrowseTest.php +``` +Expected : PASS (4 tests). + +- [ ] **Step 6: Lancer toute la suite back (non-régression)** + +Run : +```bash +docker exec -u www-data php-lesstime-fpm vendor/bin/phpunit +``` +Expected : suite verte (mêmes notices qu'avant, pas de nouvel échec). + +- [ ] **Step 7: php-cs-fixer + commit** + +Run : +```bash +make php-cs-fixer-allow-risky +git add src/Controller/Share/ tests/Functional/Controller/ShareBrowseTest.php +git commit -m "feat(share) : controllers status/browse/download du partage" +``` + +--- + +## Task 8: Front — dépendance PDF + services + DTO + +**Files:** +- Modify: `frontend/package.json` +- Create: `frontend/services/dto/share.ts` +- Create: `frontend/services/share.ts` +- Create: `frontend/services/share-settings.ts` + +- [ ] **Step 1: Installer `vue-pdf-embed`** + +Run (dans le conteneur ou via make dev-nuxt env) : +```bash +docker exec -i php-lesstime-fpm sh -lc 'cd frontend && yarn add vue-pdf-embed' +``` +Expected : `frontend/package.json` + lockfile mis à jour, paquet présent dans `frontend/node_modules/vue-pdf-embed`. + +> Si l'install Node se fait hors conteneur (selon ton workflow `make dev-nuxt`), lancer `yarn add vue-pdf-embed` depuis `frontend/` sur l'hôte. L'important : le paquet est dans `frontend/package.json`. + +- [ ] **Step 2: DTO `share.ts`** + +```typescript +export type FileEntry = { + name: string + path: string + isDir: boolean + size: number + modifiedAt: number | null + mimeType: string +} + +export type Breadcrumb = { + name: string + path: string +} + +export type ShareBrowseResult = { + path: string + breadcrumb: Breadcrumb[] + entries: FileEntry[] +} + +export type ShareStatus = { + enabled: boolean +} + +export type ShareSettings = { + host: string | null + shareName: string | null + basePath: string | null + domain: string | null + username: string | null + enabled: boolean + hasPassword: boolean +} + +export type ShareSettingsWrite = { + host: string | null + shareName: string | null + basePath: string | null + domain: string | null + username: string | null + password?: string | null + enabled: boolean +} + +export type ShareTestResult = { + success: boolean + message: string | null +} +``` + +- [ ] **Step 3: Service `share.ts`** + +```typescript +import type { ShareBrowseResult, ShareStatus } from './dto/share' + +export function useShareService() { + const api = useApi() + const config = useRuntimeConfig() + + async function browse(path: string): Promise { + const query = path ? `?path=${encodeURIComponent(path)}` : '' + return api.get(`/share/browse${query}`) + } + + async function getStatus(): Promise { + return api.get('/share/status') + } + + function getDownloadUrl(path: string, disposition: 'inline' | 'attachment' = 'inline'): string { + const base = config.public.apiBase ?? '/api' + return `${base}/share/download?path=${encodeURIComponent(path)}&disposition=${disposition}` + } + + return { browse, getStatus, getDownloadUrl } +} +``` + +> Vérifier le nom réel de la base API publique : regarder comment `useTaskDocumentService().getDownloadUrl` construit son URL dans `frontend/services/task-documents.ts` et **réutiliser exactement la même base** (clé runtimeConfig ou helper). Aligner `getDownloadUrl` dessus plutôt que d'inventer `config.public.apiBase`. + +- [ ] **Step 4: Service `share-settings.ts`** + +```typescript +import type { ShareSettings, ShareSettingsWrite, ShareTestResult } from './dto/share' + +export function useShareSettingsService() { + const api = useApi() + + async function getSettings(): Promise { + return api.get('/settings/share') + } + + async function saveSettings(payload: ShareSettingsWrite): Promise { + return api.put('/settings/share', payload as Record, { + toastSuccessKey: 'adminShare.saved', + }) + } + + async function testConnection(): Promise { + return api.post('/settings/share/test', {}) + } + + return { getSettings, saveSettings, testConnection } +} +``` + +- [ ] **Step 5: Commit** + +```bash +git add frontend/package.json frontend/yarn.lock frontend/services/dto/share.ts frontend/services/share.ts frontend/services/share-settings.ts +git commit -m "feat(share) : services et DTO front (browse, settings, status) + dépendance pdf" +``` + +--- + +## Task 9: Front — onglet admin `AdminShareTab` + +**Files:** +- Create: `frontend/components/admin/AdminShareTab.vue` +- Modify: `frontend/pages/admin.vue` + +- [ ] **Step 1: Composant `AdminShareTab.vue`** + +```vue + + + +``` + +- [ ] **Step 2: Câbler l'onglet dans `admin.vue`** + +Dans `frontend/pages/admin.vue` : +1. Ajouter dans le template, après la ligne `` (ligne 32) : +```vue + +``` +2. Ajouter dans le tableau `tabs` (après `{ key: 'zimbra', label: 'Zimbra' },`) : +```typescript + { key: 'share', label: 'Partage' }, +``` + +- [ ] **Step 3: Vérifier visuellement** + +Run : `make dev-nuxt` puis ouvrir `http://localhost:3002/admin`, onglet « Partage ». +Expected : le formulaire s'affiche, « Enregistrer » persiste, « Tester » renvoie un message (échec attendu tant qu'aucun vrai serveur n'est configuré). + +- [ ] **Step 4: Commit** + +```bash +git add frontend/components/admin/AdminShareTab.vue frontend/pages/admin.vue +git commit -m "feat(share) : onglet admin de configuration du partage" +``` + +--- + +## Task 10: Front — composable de visibilité + lien sidebar + +**Files:** +- Create: `frontend/composables/useShareStatus.ts` +- Modify: `frontend/layouts/default.vue` + +- [ ] **Step 1: Composable `useShareStatus`** + +```typescript +import { useShareService } from '~/services/share' + +const enabled = ref(null) + +export function useShareStatus() { + const { getStatus } = useShareService() + + async function refresh() { + try { + const status = await getStatus() + enabled.value = status.enabled + } catch { + enabled.value = false + } + } + + async function ensureLoaded() { + if (enabled.value === null) { + await refresh() + } + } + + return { enabled, refresh, ensureLoaded } +} +``` + +> Le `ref` est déclaré au niveau module → état partagé/mis en cache pour toute l'app (pattern singleton, comme certains composables existants). Vérifier qu'aucun composable du projet n'impose un autre pattern (ex. `useState` de Nuxt) ; si oui, utiliser `useState('shareEnabled', () => null)` à la place. + +- [ ] **Step 2: Lien « Documents » conditionnel dans `default.vue`** + +Dans `frontend/layouts/default.vue`, template — après le bloc « Suivi de temps » (le `SidebarLink to="/time-tracking"`, ~ligne 102) et avant le bloc mail, ajouter : +```vue + +``` + +Dans le ` + + +``` + +> Pour le chargement du texte, aligner sur la façon dont `TaskDocumentPreview.vue` récupère son contenu (`getContent`) si un helper équivalent existe ; sinon le `$fetch` ci-dessus avec `credentials: 'include'` convient. Vérifier que `vue-pdf-embed` s'importe correctement en SPA Nuxt (si erreur SSR/worker, importer en `defineAsyncComponent` ou configurer le worker pdf.js — voir doc context7 `vue-pdf-embed`). + +- [ ] **Step 2: Commit** + +```bash +git add frontend/components/share/SharedFilePreview.vue +git commit -m "feat(share) : viewer de documents du partage (image/pdf/texte)" +``` + +--- + +## Task 12: Front — page explorateur `documents.vue` + +**Files:** +- Create: `frontend/pages/documents.vue` + +- [ ] **Step 1: Page explorateur** + +```vue + + + +``` + +> Garde-fou : si `enabled === false`, on redirige vers l'accueil (défense en profondeur en plus du `409` backend). Adapter `formatDate`/`iconForMime` aux helpers existants dans `frontend/utils/` s'il y en a (éviter la duplication). + +- [ ] **Step 2: Build front (vérif compilation)** + +Run : +```bash +docker exec -i php-lesstime-fpm sh -lc 'cd frontend && yarn build' +``` +Expected : build OK, pas d'erreur TypeScript. + +- [ ] **Step 3: Vérif manuelle bout-en-bout** + +Avec une config SMB valide saisie en admin (host, shareName, identifiants, enabled) + un vrai partage accessible : +- le lien « Documents » apparaît ; +- `/documents` liste le contenu, on navigue dans les dossiers via le fil d'Ariane ; +- un clic sur un PDF l'ouvre dans le viewer (PDF.js), une image s'affiche, un fichier inconnu propose le téléchargement ; +- le filtre restreint le dossier courant. + +> Sans serveur SMB de test sous la main, vérifier au minimum que `/documents` affiche l'état d'erreur proprement (502/409) et ne plante pas. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/pages/documents.vue +git commit -m "feat(share) : page explorateur de fichiers du partage" +``` + +--- + +## Task 13: i18n + +**Files:** +- Modify: `frontend/i18n/locales/fr.json` (+ toute autre locale présente, ex. `en.json`) + +- [ ] **Step 1: Ajouter les clés** + +Repérer la structure JSON existante (ex. bloc `zimbra`) et ajouter deux blocs cohérents. Valeurs FR : + +```json +"sharedFiles": { + "title": "Documents", + "root": "Racine", + "empty": "Ce dossier est vide.", + "filterPlaceholder": "Filtrer ce dossier…", + "download": "Télécharger", + "colName": "Nom", + "colSize": "Taille", + "colModified": "Modifié le", + "sidebar": { "title": "Documents" } +}, +"adminShare": { + "title": "Partage réseau (SMB)", + "host": "Serveur", + "hostPlaceholder": "ex. WIN-SRV ou 192.168.1.10", + "shareName": "Nom du partage", + "shareNamePlaceholder": "ex. Documents", + "basePath": "Sous-dossier racine (optionnel)", + "basePathPlaceholder": "ex. /Projets", + "domain": "Domaine / groupe de travail", + "domainPlaceholder": "WORKGROUP", + "username": "Identifiant", + "usernamePlaceholder": "ex. lesstime", + "password": "Mot de passe", + "passwordConfigured": "Un mot de passe est déjà enregistré.", + "enabled": "Activer l'accès au partage", + "save": "Enregistrer", + "saved": "Configuration enregistrée.", + "testConnection": "Tester la connexion", + "testSuccess": "Connexion réussie.", + "testFailed": "Échec de la connexion." +} +``` + +Pour chaque autre locale présente (ex. `en.json`), ajouter les mêmes clés traduites. + +- [ ] **Step 2: Vérifier qu'aucune clé n'est manquante** + +Run : `make dev-nuxt`, parcourir `/admin` (onglet Partage) et `/documents`. +Expected : aucun libellé brut `sharedFiles.*` / `adminShare.*` affiché. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/i18n/locales/ +git commit -m "feat(share) : traductions explorateur et config partage" +``` + +--- + +## Task 14: Vérification finale & non-régression + +- [ ] **Step 1: Suite back complète** + +Run : `docker exec -u www-data php-lesstime-fpm vendor/bin/phpunit` +Expected : verte (pas de nouvel échec vs baseline). + +- [ ] **Step 2: php-cs-fixer** + +Run : `make php-cs-fixer-allow-risky` +Expected : aucune violation restante (commiter si des fichiers sont retouchés). + +- [ ] **Step 3: Build front** + +Run : `docker exec -i php-lesstime-fpm sh -lc 'cd frontend && yarn build'` +Expected : build OK. + +- [ ] **Step 4: Récap manuel** + +Vérifier la checklist de la spec §6 (erreurs) : SMB injoignable → message ; config désactivée → pas de lien + 409 ; path-traversal → 400 ; fichier absent → 404. + +- [ ] **Step 5: (Optionnel) Préparer la PR** + +```bash +git push -u origin feat/share-explorer +``` +Puis ouvrir la MR vers `develop` (sans mention d'IA dans la description). + +--- + +## Notes de déploiement prod (à exécuter le moment venu) + +1. Rebuild de l'image prod `lesstime-app` **avec `smbclient`** (Task 1) — sinon `icewind/smb` échoue en prod. +2. Jouer la migration `share_configuration` en prod (`doctrine:migrations:migrate`). +3. Saisir la config SMB en admin prod et activer. +4. Vérifier que `ENCRYPTION_KEY` est bien défini en prod (réutilisé pour chiffrer le mot de passe SMB — déjà requis par l'intégration Zimbra). +```