<?php
namespace App\Controller\EuroOffice;
use App\Entity\Section;
use App\Entity\User;
use App\Service\EuroOffice\CallbackHandler;
use App\Service\EuroOffice\DocumentStorage;
use App\Service\EuroOffice\EditorConfigBuilder;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Psr\Log\LoggerInterface;
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;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class EuroOfficeController extends AbstractController
{
public function __construct(
private readonly EditorConfigBuilder $config,
private readonly DocumentStorage $storage,
private readonly CallbackHandler $handler,
private readonly HttpClientInterface $httpClient,
private readonly LoggerInterface $logger,
private readonly string $jwtSecret,
private readonly string $publicUrl,
private readonly ?string $docserverInternalUrl = null,
) {}
/** Renvoie la config JSON pour DocsAPI.DocEditor (consommée par le JS workroom). */
#[Route(path: '/euroffice/config/{uuid}', name: 'euroffice_config', methods: ['GET'], options: ['expose' => true])]
public function config(Section $section, Request $request): JsonResponse
{
/** @var User $user */
$user = $this->getUser();
return new JsonResponse([
'config' => $this->config->buildForSection($section, $user, $request),
'documentServerUrl' => $this->publicUrl,
]);
}
/** Page autonome pour test isolé Euro-Office (debug). */
#[Route(path: '/euroffice/test/{uuid}', name: 'euroffice_test', methods: ['GET'])]
public function test(Section $section, Request $request): Response
{
/** @var User $user */
$user = $this->getUser();
return $this->render('workroom/euroffice_test.html.twig', [
'config' => $this->config->buildForSection($section, $user, $request),
'documentServerUrl' => $this->publicUrl,
]);
}
/** DocServer télécharge le DOCX ici (token court signé par Symfony). */
#[Route(path: '/euroffice/file/{uuid}', name: 'euroffice_file', methods: ['GET'])]
public function download(string $uuid, Request $request): Response
{
$token = (string) $request->query->get('t');
if ($token === '') return new Response('Missing token', 401);
try {
$payload = (array) JWT::decode($token, new Key($this->jwtSecret, 'HS256'));
} catch (\Throwable) { return new Response('Invalid token', 401); }
if (($payload['sub'] ?? null) !== 'section-'.$uuid) {
return new Response('Token mismatch', 403);
}
$section = $this->getDoctrine()->getRepository(Section::class)->findOneBy(['uuid' => $uuid]);
if (!$section) return new Response('Not found', 404);
if (($payload['key'] ?? null) !== $section->getDocxKey()) return new Response('Stale', 410);
if (!$section->getDocxPath()) return new Response('No DOCX', 404);
$bytes = $this->storage->read($section->getDocxPath());
$r = new Response($bytes);
$r->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
$r->headers->set('Content-Disposition', 'attachment; filename="'.rawurlencode($section->getName()).'.docx"');
$r->headers->set('Content-Length', (string) strlen($bytes));
$r->headers->set('Cache-Control', 'no-store');
return $r;
}
/** DocServer pousse le save ici. */
#[Route(path: '/euroffice/callback/{uuid}', name: 'euroffice_callback', methods: ['POST'])]
public function callback(Section $section, Request $request): JsonResponse
{
return new JsonResponse($this->handler->handle($section, $request));
}
/**
* Force-save synchrone : le frontend l'appelle AVANT de détruire l'iframe
* (ex: changement de section, fermeture workroom). Symfony POST le
* CommandService de DocServer avec `c=forcesave` + JWT signé. DocServer
* fire alors un callback save vers nous, qui persiste le DOCX → on est
* sûr que les modifs sont sur disque avant le switch.
*
* Sans ça : DocServer attend ~10s de "no clients connected" avant de
* fire le callback → si l'utilisateur revient avant, il charge l'ancien.
*
* Body JSON : `{ "key": "<docKey>" }` (la clé renvoyée par /euroffice/config).
*/
#[Route(path: '/euroffice/forcesave/{uuid}', name: 'euroffice_forcesave', methods: ['POST'], options: ['expose' => true])]
public function forceSave(string $uuid, Request $request): JsonResponse
{
/** @var User|null $user */
$user = $this->getUser();
if (!$user) return new JsonResponse(['error' => 'unauthorized'], 401);
$section = $this->getDoctrine()->getRepository(Section::class)->findOneBy(['uuid' => $uuid]);
if (!$section) return new JsonResponse(['error' => 'not_found'], 404);
$body = json_decode((string) $request->getContent(), true) ?: [];
$key = (string) ($body['key'] ?? '');
if ($key === '') return new JsonResponse(['error' => 'missing_key'], 400);
// Validation basique : la clé contient toujours l'UUID de section en
// préfixe (cf. EditorConfigBuilder). Anti-forcesave d'une clé d'une
// autre section / random.
if (!str_starts_with($key, $section->getUuid())) {
return new JsonResponse(['error' => 'key_mismatch'], 400);
}
$internalBase = rtrim((string) ($this->docserverInternalUrl ?? 'http://documentserver'), '/');
$payload = ['c' => 'forcesave', 'key' => $key];
// CommandService DocServer JWT_ENABLED : la signature du body doit
// être passée en `Authorization: Bearer <jwt>`. JWT contient `payload`.
$jwt = JWT::encode(['payload' => $payload], $this->jwtSecret, 'HS256');
try {
$r = $this->httpClient->request('POST', $internalBase.'/coauthoring/CommandService.ashx', [
'json' => $payload,
'headers' => ['Authorization' => 'Bearer '.$jwt],
'timeout' => 8,
'max_redirects' => 0,
]);
$code = $r->getStatusCode();
$data = json_decode($r->getContent(false), true);
// DocServer renvoie `{error: 0}` si la commande est acceptée.
// 1 = no document with key, 6 = invalid token, etc.
return new JsonResponse([
'ok' => $code < 400 && (int) ($data['error'] ?? -1) === 0,
'docserver' => $data,
]);
} catch (\Throwable $e) {
$this->logger->warning('forcesave: erreur CommandService', [
'section' => $uuid,
'error' => $e->getMessage(),
]);
return new JsonResponse(['ok' => false, 'error' => 'docserver_unreachable'], 502);
}
}
}