src/Controller/EuroOffice/EuroOfficeController.php line 86

Open in your IDE?
  1. <?php
  2. namespace App\Controller\EuroOffice;
  3. use App\Entity\Section;
  4. use App\Entity\User;
  5. use App\Service\EuroOffice\CallbackHandler;
  6. use App\Service\EuroOffice\DocumentStorage;
  7. use App\Service\EuroOffice\EditorConfigBuilder;
  8. use Firebase\JWT\JWT;
  9. use Firebase\JWT\Key;
  10. use Psr\Log\LoggerInterface;
  11. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  12. use Symfony\Component\HttpFoundation\JsonResponse;
  13. use Symfony\Component\HttpFoundation\Request;
  14. use Symfony\Component\HttpFoundation\Response;
  15. use Symfony\Component\Routing\Annotation\Route;
  16. use Symfony\Contracts\HttpClient\HttpClientInterface;
  17. class EuroOfficeController extends AbstractController
  18. {
  19.     public function __construct(
  20.         private readonly EditorConfigBuilder $config,
  21.         private readonly DocumentStorage $storage,
  22.         private readonly CallbackHandler $handler,
  23.         private readonly HttpClientInterface $httpClient,
  24.         private readonly LoggerInterface $logger,
  25.         private readonly string $jwtSecret,
  26.         private readonly string $publicUrl,
  27.         private readonly ?string $docserverInternalUrl null,
  28.     ) {}
  29.     /** Renvoie la config JSON pour DocsAPI.DocEditor (consommée par le JS workroom). */
  30.     #[Route(path'/euroffice/config/{uuid}'name'euroffice_config'methods: ['GET'], options: ['expose' => true])]
  31.     public function config(Section $sectionRequest $request): JsonResponse
  32.     {
  33.         /** @var User $user */
  34.         $user $this->getUser();
  35.         return new JsonResponse([
  36.             'config' => $this->config->buildForSection($section$user$request),
  37.             'documentServerUrl' => $this->publicUrl,
  38.         ]);
  39.     }
  40.     /** Page autonome pour test isolé Euro-Office (debug). */
  41.     #[Route(path'/euroffice/test/{uuid}'name'euroffice_test'methods: ['GET'])]
  42.     public function test(Section $sectionRequest $request): Response
  43.     {
  44.         /** @var User $user */
  45.         $user $this->getUser();
  46.         return $this->render('workroom/euroffice_test.html.twig', [
  47.             'config' => $this->config->buildForSection($section$user$request),
  48.             'documentServerUrl' => $this->publicUrl,
  49.         ]);
  50.     }
  51.     /** DocServer télécharge le DOCX ici (token court signé par Symfony). */
  52.     #[Route(path'/euroffice/file/{uuid}'name'euroffice_file'methods: ['GET'])]
  53.     public function download(string $uuidRequest $request): Response
  54.     {
  55.         $token = (string) $request->query->get('t');
  56.         if ($token === '') return new Response('Missing token'401);
  57.         try {
  58.             $payload = (array) JWT::decode($token, new Key($this->jwtSecret'HS256'));
  59.         } catch (\Throwable) { return new Response('Invalid token'401); }
  60.         if (($payload['sub'] ?? null) !== 'section-'.$uuid) {
  61.             return new Response('Token mismatch'403);
  62.         }
  63.         $section $this->getDoctrine()->getRepository(Section::class)->findOneBy(['uuid' => $uuid]);
  64.         if (!$section) return new Response('Not found'404);
  65.         if (($payload['key'] ?? null) !== $section->getDocxKey()) return new Response('Stale'410);
  66.         if (!$section->getDocxPath()) return new Response('No DOCX'404);
  67.         $bytes $this->storage->read($section->getDocxPath());
  68.         $r = new Response($bytes);
  69.         $r->headers->set('Content-Type''application/vnd.openxmlformats-officedocument.wordprocessingml.document');
  70.         $r->headers->set('Content-Disposition''attachment; filename="'.rawurlencode($section->getName()).'.docx"');
  71.         $r->headers->set('Content-Length', (string) strlen($bytes));
  72.         $r->headers->set('Cache-Control''no-store');
  73.         return $r;
  74.     }
  75.     /** DocServer pousse le save ici. */
  76.     #[Route(path'/euroffice/callback/{uuid}'name'euroffice_callback'methods: ['POST'])]
  77.     public function callback(Section $sectionRequest $request): JsonResponse
  78.     {
  79.         return new JsonResponse($this->handler->handle($section$request));
  80.     }
  81.     /**
  82.      * Force-save synchrone : le frontend l'appelle AVANT de détruire l'iframe
  83.      * (ex: changement de section, fermeture workroom). Symfony POST le
  84.      * CommandService de DocServer avec `c=forcesave` + JWT signé. DocServer
  85.      * fire alors un callback save vers nous, qui persiste le DOCX → on est
  86.      * sûr que les modifs sont sur disque avant le switch.
  87.      *
  88.      * Sans ça : DocServer attend ~10s de "no clients connected" avant de
  89.      * fire le callback → si l'utilisateur revient avant, il charge l'ancien.
  90.      *
  91.      * Body JSON : `{ "key": "<docKey>" }` (la clé renvoyée par /euroffice/config).
  92.      */
  93.     #[Route(path'/euroffice/forcesave/{uuid}'name'euroffice_forcesave'methods: ['POST'], options: ['expose' => true])]
  94.     public function forceSave(string $uuidRequest $request): JsonResponse
  95.     {
  96.         /** @var User|null $user */
  97.         $user $this->getUser();
  98.         if (!$user) return new JsonResponse(['error' => 'unauthorized'], 401);
  99.         $section $this->getDoctrine()->getRepository(Section::class)->findOneBy(['uuid' => $uuid]);
  100.         if (!$section) return new JsonResponse(['error' => 'not_found'], 404);
  101.         $body json_decode((string) $request->getContent(), true) ?: [];
  102.         $key = (string) ($body['key'] ?? '');
  103.         if ($key === '') return new JsonResponse(['error' => 'missing_key'], 400);
  104.         // Validation basique : la clé contient toujours l'UUID de section en
  105.         // préfixe (cf. EditorConfigBuilder). Anti-forcesave d'une clé d'une
  106.         // autre section / random.
  107.         if (!str_starts_with($key$section->getUuid())) {
  108.             return new JsonResponse(['error' => 'key_mismatch'], 400);
  109.         }
  110.         $internalBase rtrim((string) ($this->docserverInternalUrl ?? 'http://documentserver'), '/');
  111.         $payload = ['c' => 'forcesave''key' => $key];
  112.         // CommandService DocServer JWT_ENABLED : la signature du body doit
  113.         // être passée en `Authorization: Bearer <jwt>`. JWT contient `payload`.
  114.         $jwt JWT::encode(['payload' => $payload], $this->jwtSecret'HS256');
  115.         try {
  116.             $r $this->httpClient->request('POST'$internalBase.'/coauthoring/CommandService.ashx', [
  117.                 'json' => $payload,
  118.                 'headers' => ['Authorization' => 'Bearer '.$jwt],
  119.                 'timeout' => 8,
  120.                 'max_redirects' => 0,
  121.             ]);
  122.             $code $r->getStatusCode();
  123.             $data json_decode($r->getContent(false), true);
  124.             // DocServer renvoie `{error: 0}` si la commande est acceptée.
  125.             // 1 = no document with key, 6 = invalid token, etc.
  126.             return new JsonResponse([
  127.                 'ok' => $code 400 && (int) ($data['error'] ?? -1) === 0,
  128.                 'docserver' => $data,
  129.             ]);
  130.         } catch (\Throwable $e) {
  131.             $this->logger->warning('forcesave: erreur CommandService', [
  132.                 'section' => $uuid,
  133.                 'error' => $e->getMessage(),
  134.             ]);
  135.             return new JsonResponse(['ok' => false'error' => 'docserver_unreachable'], 502);
  136.         }
  137.     }
  138. }