<?php
namespace App\Service\EuroOffice;
use App\Entity\Section;
use App\Entity\SectionRevision;
use App\Entity\User;
use App\Message\IndexSectionMessage;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* Gère les callbacks DocumentServer (status 0..7).
* Doc : https://api.onlyoffice.com/editors/callback
*/
class CallbackHandler
{
/**
* Whitelist d'hôtes autorisés pour le téléchargement du DOCX retourné par
* DocServer dans le payload callback (champ `url`). Sans whitelist, le
* payload contrôlé par l'attaquant permettait un SSRF intra-Docker
* (http://qdrant:6333, http://mysql, http://169.254.169.254/...).
*
* Construite depuis EURO_OFFICE_PUBLIC_URL + EURO_OFFICE_CALLBACK_HOST
* (les 2 URLs déjà connues du DocServer pour notre stack).
*
* @var string[]
*/
private readonly array $allowedHosts;
/**
* Hostname (sans port) à utiliser quand on télécharge le DOCX depuis le
* callback. DocServer met l'URL publique browser-side (ex: localhost:8083)
* dans le payload mais Symfony ne peut pas joindre cet host depuis le
* réseau Docker → on réécrit vers ce hostname interne.
*/
private readonly ?string $internalDocserverHost;
private readonly ?int $internalDocserverPort;
private readonly ?string $internalDocserverScheme;
private readonly ?string $publicDocserverHost;
public function __construct(
private readonly EntityManagerInterface $em,
private readonly DocumentStorage $storage,
private readonly DocxAnalyzer $analyzer,
private readonly HttpClientInterface $http,
private readonly UserRepository $users,
private readonly LoggerInterface $logger,
private readonly CacheInterface $cache,
private readonly MessageBusInterface $bus,
private readonly string $jwtSecret,
?string $euroOfficePublicUrl = null,
?string $docserverInternalUrl = null,
// Permet d'ajouter d'autres hôtes (déploiements multi-DocServer)
?string $extraAllowedHostsCsv = null,
) {
$hosts = [];
foreach ([$euroOfficePublicUrl, $docserverInternalUrl] as $url) {
$host = $url ? $this->extractHost($url) : null;
if ($host) $hosts[] = $host;
}
foreach (explode(',', (string) ($extraAllowedHostsCsv ?? '')) as $extra) {
$extra = trim($extra);
if ($extra !== '') $hosts[] = strtolower($extra);
}
// Fallback dev : le hostname Docker `documentserver` qu'on retrouve dans
// EURO_OFFICE_INTERNAL_URL par défaut. On l'ajoute pour ne pas bloquer
// un setup Docker standard sans configuration explicite.
$hosts[] = 'documentserver';
$this->allowedHosts = array_values(array_unique($hosts));
// Pré-parse l'URL interne pour la réécriture (host + port + scheme)
$internalParts = $docserverInternalUrl ? parse_url($docserverInternalUrl) : null;
$this->internalDocserverHost = $internalParts['host'] ?? 'documentserver';
$this->internalDocserverPort = isset($internalParts['port']) ? (int) $internalParts['port'] : null;
$this->internalDocserverScheme = $internalParts['scheme'] ?? 'http';
$publicParts = $euroOfficePublicUrl ? parse_url($euroOfficePublicUrl) : null;
$this->publicDocserverHost = $publicParts['host'] ?? null;
}
/** @return array{error:int, message?:string} */
public function handle(Section $section, Request $request): array
{
$payload = $this->verifyAndDecode($request);
if ($payload === null) {
return ['error' => 1, 'message' => 'invalid jwt'];
}
$status = (int) ($payload['status'] ?? 0);
switch ($status) {
case 1: return ['error' => 0]; // editing
case 2: // ready to save (last user closed)
case 6: // force save (autosave)
$url = $payload['url'] ?? null;
if (!$url) return ['error' => 1, 'message' => 'no url'];
if (!$this->isAllowedDocserverUrl($url)) {
$this->logger->warning('Callback rejeté : URL hors whitelist', [
'section' => $section->getUuid(),
'url' => $url,
'allowedHosts' => $this->allowedHosts,
]);
return ['error' => 1, 'message' => 'url not allowed'];
}
// **Anti-replay** : un attaquant qui a intercepté un callback
// légitime (MITM intra-Docker, leak via logs) pourrait le
// rejouer pour écraser le DOCX avec une URL contrôlée. On
// calcule un hash unique du payload + URL et on le marque
// comme "consommé" en cache pendant 1h. Replay = rejet.
if ($this->isReplay($section, $url, $payload)) {
$this->logger->warning('Callback replay détecté → rejet', [
'section' => $section->getUuid(),
'url' => $url,
]);
return ['error' => 1, 'message' => 'replay detected'];
}
$this->saveDocument($section, $url, $payload, $status === 2);
return ['error' => 0];
case 3:
case 7:
$this->logger->error('Save error reçu', ['section' => $section->getUuid(), 'payload' => $payload]);
return ['error' => 0];
default: return ['error' => 0];
}
}
/**
* Vérifie le JWT du callback. En PROD, le secret DOIT être présent et un
* token DOIT être fourni — pas de fallback "JWT désactivé en dev" qui
* laissait passer n'importe quel POST anonyme. En dev (APP_ENV=dev) on
* tolère l'absence de token uniquement si JWT_ENABLED est explicitement
* désactivé côté DocServer (configuration locale).
*/
private function verifyAndDecode(Request $request): ?array
{
$body = json_decode($request->getContent(), true) ?? [];
if (isset($body['token'])) {
try {
return (array) JWT::decode($body['token'], new Key($this->jwtSecret, 'HS256'));
} catch (\Throwable $e) {
$this->logger->warning('Callback JWT invalide (body)', ['error' => $e->getMessage()]);
return null;
}
}
$h = $request->headers->get('Authorization');
if ($h && str_starts_with($h, 'Bearer ')) {
try {
$decoded = (array) JWT::decode(substr($h, 7), new Key($this->jwtSecret, 'HS256'));
return array_merge($body, (array) ($decoded['payload'] ?? []));
} catch (\Throwable $e) {
$this->logger->warning('Callback JWT invalide (header)', ['error' => $e->getMessage()]);
return null;
}
}
// Pas de token → refus en prod, tolérance en dev (DocServer JWT_ENABLED=false)
$appEnv = $_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? 'prod';
if ($appEnv === 'prod' || $appEnv === 'staging') {
$this->logger->warning('Callback sans JWT refusé (env='.$appEnv.')');
return null;
}
return $body;
}
private function saveDocument(Section $section, string $url, array $payload, bool $finalSave): void
{
// L'URL fournie par DocServer est l'URL publique (browser-side), ex:
// http://localhost:8083/cache/files/... — Symfony, depuis son container,
// ne peut pas joindre `localhost:8083`. On réécrit vers le hostname
// Docker interne avant le download.
$fetchUrl = $this->rewriteToInternalUrl($url);
// verify_peer=true (défaut). En intra-Docker on parle souvent en HTTP donc le drapeau
// est moot, mais s'il y a TLS on veut une vraie vérif (les attaquants en MITM réseau
// pouvaient remplacer le contenu téléchargé via cert auto-signé).
$bytes = $this->http->request('GET', $fetchUrl, [
'timeout' => 60,
'max_redirects' => 0, // pas de suivi de redirect (sinon bypass de la whitelist)
])->getContent();
if ($finalSave && $section->getDocxPath() && $this->storage->exists($section->getDocxPath())) {
$rev = new SectionRevision();
$rev->setSection($section);
$rev->setName('Auto — '.(new \DateTimeImmutable())->format('Y-m-d H:i:s'));
$rev->setText('');
if (isset($payload['users'][0])) {
$u = $this->users->findOneBy(['uuid' => $payload['users'][0]]);
if ($u instanceof User) $rev->setUser($u);
}
$old = $this->storage->read($section->getDocxPath());
$this->storage->writeRevision(sprintf('section-%s-%d.docx', $section->getUuid(), time()), $old);
$this->em->persist($rev);
}
$this->storage->write($section->getDocxPath(), $bytes);
$analysis = $this->analyzer->analyze($bytes);
$section->setCountWords($analysis['words']);
$section->setCountCharacters($analysis['characters']);
$section->setText($analysis['text']); // garde le texte plein dans Section.text pour ELS
$section->setLastSavedAt(new \DateTimeImmutable());
$section->regenerateDocxKey();
$this->em->flush();
// Dispatch async re-index RAG. Best-effort : si Redis down, on log
// mais on ne casse pas le save. Reindex manuel via app:rag:reindex
// possible plus tard.
try {
$this->bus->dispatch(new IndexSectionMessage($section->getId()));
} catch (\Throwable $e) {
$this->logger->warning('Section RAG re-index dispatch failed', [
'sectionUuid' => $section->getUuid(),
'error' => $e->getMessage(),
]);
}
}
/**
* Vérifie que l'URL fournie par le callback :
* - utilise un schéma http/https (pas file://, gopher://, etc.)
* - pointe sur un host whitelisté
* - n'est pas un IP privée non whitelistée (anti-SSRF)
*/
private function isAllowedDocserverUrl(string $url): bool
{
$parts = parse_url($url);
if (!$parts || empty($parts['host']) || empty($parts['scheme'])) return false;
$scheme = strtolower($parts['scheme']);
if (!in_array($scheme, ['http', 'https'], true)) return false;
$host = strtolower($parts['host']);
if (in_array($host, $this->allowedHosts, true)) return true;
// Bloque les hôtes pointant sur des plages privées (sauf si explicitement
// whitelistés au-dessus). Couvre 127.0.0.0/8, 169.254.0.0/16 (link-local
// / AWS metadata), 10/8, 172.16/12, 192.168/16.
$ip = filter_var($host, FILTER_VALIDATE_IP) ? $host : @gethostbyname($host);
if ($ip && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
return false;
}
return false;
}
/**
* Si l'URL contient l'hostname public du DocServer (browser-side, ex
* `localhost:8083`), réécrit vers l'hostname Docker interne (ex
* `documentserver`). Sinon → URL retournée telle-quelle (cas multi-DocServer
* où le host est déjà interne).
*
* Ne touche QUE le host + port. Path/query/fragment préservés (le md5/expires
* dans la query string sont signés par DocServer et doivent passer intacts).
*/
private function rewriteToInternalUrl(string $url): string
{
$parts = parse_url($url);
if (!$parts || empty($parts['host'])) return $url;
$currentHost = strtolower($parts['host']);
// Réécrit uniquement si on touche bien le host public (sinon laissé tel
// quel : multi-DocServer staging/prod).
if ($this->publicDocserverHost === null || $currentHost !== $this->publicDocserverHost) {
return $url;
}
if ($this->internalDocserverHost === null) return $url;
$rebuilt = ($this->internalDocserverScheme ?? 'http') . '://' . $this->internalDocserverHost;
if ($this->internalDocserverPort !== null) {
$rebuilt .= ':' . $this->internalDocserverPort;
}
$rebuilt .= $parts['path'] ?? '';
if (!empty($parts['query'])) $rebuilt .= '?' . $parts['query'];
if (!empty($parts['fragment'])) $rebuilt .= '#' . $parts['fragment'];
return $rebuilt;
}
private function extractHost(string $url): ?string
{
$url = trim($url);
if ($url === '') return null;
$parts = parse_url($url);
return !empty($parts['host']) ? strtolower($parts['host']) : null;
}
/**
* Détecte un replay sur le callback : on store le hash (section + url +
* iat du JWT + docx key) en cache 1h. Si la même clé revient → on était
* déjà passé → replay → reject.
*
* Le pattern Symfony Cache `get($key, callable)` retourne la valeur
* stockée (1er hit invoque la callback, suivants la skip). On stocke un
* `ts` au 1er passage ; si `now - ts > 2s` au retour, c'est qu'on avait
* déjà cette clé → replay.
*/
private function isReplay(Section $section, string $url, array $payload): bool
{
// Clé unique par couple (section, url, iat). `iat` change à chaque
// save légitime → 2 saves successifs ne sont jamais marqués comme
// replay l'un de l'autre.
$signature = hash('sha256', implode('|', [
$section->getUuid(),
$url,
(string) ($payload['iat'] ?? ''),
(string) ($payload['key'] ?? ''),
]));
$cacheKey = 'docserver_callback_replay.'.$signature;
try {
$existing = $this->cache->get($cacheKey, function (ItemInterface $item) {
$item->expiresAfter(3600); // 1h
return ['ts' => time()];
});
$ts = is_array($existing) ? (int) ($existing['ts'] ?? 0) : 0;
// Marge de 2s : si on revient sur la même clé après >2s, c'est
// qu'elle a été enregistrée par un appel précédent → replay.
return $ts > 0 && (time() - $ts) > 2;
} catch (\Throwable $e) {
// Cache down → fail-open (mieux que bloquer tous les saves)
$this->logger->warning('Callback replay check : cache error', ['error' => $e->getMessage()]);
return false;
}
}
}