getConfig(); return ($c['SSR_PURGE_ENDPOINT'] ?? '') !== '' && ($c['SSR_PURGE_SECRET'] ?? '') !== ''; } public function getHealthEndpoint(): string { return $this->getConfig()['SSR_HEALTH_ENDPOINT'] ?? ''; } /** * Comma-separated recipient list for vitec:ssr-health transition and * failover alerts (SSR_ALERT_EMAIL); empty string = alerting off. */ public function getAlertEmail(): string { return trim($this->getConfig()['SSR_ALERT_EMAIL'] ?? ''); } /** * Consecutive DOWN samples after which vitec:ssr-health fails over to * the SPA (SSR_FAILOVER_AFTER, default 2 = ~10 minutes at the 5-minute * cron cadence). Never below 1. */ public function getFailoverAfter(): int { return max(1, (int)($this->getConfig()['SSR_FAILOVER_AFTER'] ?? 2)); } /** * Normalizes, dedupes and sends the paths; logs the outcome. * Never throws - editors' saves must not fail on a purge problem. * * @param string[] $paths page paths, or ['*'] for everything * @return array{success:bool, httpCode:int, message:string, paths:string[]} */ public function purge(array $paths, string $trigger): array { $paths = $this->normalize($paths); if ($paths === []) { return ['success' => true, 'httpCode' => 0, 'message' => 'nothing to purge', 'paths' => []]; } if (!$this->isConfigured()) { $result = ['success' => false, 'httpCode' => 0, 'message' => 'not configured (var/vitec-ssr-purge.env missing)', 'paths' => $paths]; $this->log($trigger, $result, 0); return $result; } $c = $this->getConfig(); $start = microtime(true); try { $response = $this->requestFactory->request($c['SSR_PURGE_ENDPOINT'], 'POST', [ 'headers' => [ 'X-Purge-Secret' => $c['SSR_PURGE_SECRET'], 'Content-Type' => 'application/json', ], 'body' => json_encode(['paths' => $paths], JSON_THROW_ON_ERROR), 'timeout' => 4, 'connect_timeout' => 2, 'http_errors' => false, ]); $code = $response->getStatusCode(); $body = substr((string)$response->getBody(), 0, 200); $result = [ 'success' => $code === 200, 'httpCode' => $code, 'message' => $body, 'paths' => $paths, ]; } catch (\Throwable $e) { $result = ['success' => false, 'httpCode' => 0, 'message' => substr($e->getMessage(), 0, 200), 'paths' => $paths]; } $this->log($trigger, $result, (int)round((microtime(true) - $start) * 1000)); return $result; } public function purgeQuietly(array $paths, string $trigger): void { try { $this->purge($paths, $trigger); } catch (\Throwable) { // Logged inside purge(); never disturb the caller. } } /** * Path rules from 0055-BE Q3 / 0054-FE section 3: path only, query * stripped, no trailing slash (root stays '/'), '*' wins over all. * * @param string[] $paths * @return string[] */ public function normalize(array $paths): array { $out = []; foreach ($paths as $p) { $p = trim((string)$p); if ($p === '') { continue; } if ($p === '*') { return ['*']; } $p = parse_url($p, PHP_URL_PATH) ?: '/'; if ($p !== '/' ) { $p = '/' . trim($p, '/'); } $out[$p] = true; } return array_keys($out); } private function getConfig(): array { if ($this->config !== null) { return $this->config; } $this->config = []; $file = self::getConfigPath(); if (is_readable($file)) { foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) { if (str_starts_with(ltrim($line), '#') || !str_contains($line, '=')) { continue; } [$k, $v] = explode('=', $line, 2); $this->config[trim($k)] = trim($v); } } return $this->config; } private function log(string $trigger, array $result, int $durationMs): void { try { $this->connectionPool->getConnectionForTable('tx_vitec_ssrpurgelog')->insert( 'tx_vitec_ssrpurgelog', [ 'crdate' => time(), 'trigger_source' => substr($trigger, 0, 100), 'paths' => implode(', ', $result['paths']), 'success' => $result['success'] ? 1 : 0, 'http_code' => $result['httpCode'], 'message' => substr($result['message'], 0, 255), 'duration_ms' => $durationMs, ] ); } catch (\Throwable) { // Table missing (before schema update) must not break anything. } } }