Files
VITEC-website/packages/vitec/Classes/Service/SsrPurgeService.php
T
orascheandClaude Opus 5.5 90cb2faaf9 SSR alert mail recipients maintainable in the site settings
New site setting vitec.ssr.alertEmails (Site Management, Settings, VITEC; one address per entry). vitec:ssr-health reads the recipients from there, invalid entries are skipped and logged, SSR_ALERT_EMAIL in the env file stays as fallback. The cockpit shows the recipients in the live band. Pre-filled with the address that was in the env file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 11:13:37 +01:00

331 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Service;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Http\RequestFactory;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Talks to the SSR node's POST /__purge endpoint (contract 0054-FE) and
* keeps a log of every attempt in tx_vitec_ssrpurgelog.
*
* Endpoint + secret live OUTSIDE the repository in var/vitec-ssr-purge.env
* (KEY=VALUE lines: SSR_PURGE_ENDPOINT, SSR_PURGE_SECRET, SSR_HEALTH_ENDPOINT,
* SSR_ALERT_EMAIL, SSR_FAILOVER_AFTER)
* - same pattern as the routing flag file, nothing secret in git.
*/
final class SsrPurgeService
{
private ?array $config = null;
public function __construct(
private readonly RequestFactory $requestFactory,
private readonly ConnectionPool $connectionPool,
private readonly SiteFinder $siteFinder,
) {}
public static function getConfigPath(): string
{
return Environment::getVarPath() . '/vitec-ssr-purge.env';
}
public function isConfigured(): bool
{
$c = $this->getConfig();
return ($c['SSR_PURGE_ENDPOINT'] ?? '') !== '' && ($c['SSR_PURGE_SECRET'] ?? '') !== '';
}
public function getHealthEndpoint(): string
{
return $this->getConfig()['SSR_HEALTH_ENDPOINT'] ?? '';
}
/**
* The /__stats sibling of the health endpoint (cumulative hit/miss/
* error counters, in the node since release 20261109-211544). Derived
* from SSR_HEALTH_ENDPOINT; SSR_STATS_ENDPOINT overrides explicitly.
*/
public function getStatsEndpoint(): string
{
$explicit = $this->getConfig()['SSR_STATS_ENDPOINT'] ?? '';
if ($explicit !== '') {
return $explicit;
}
$health = $this->getHealthEndpoint();
return $health === '' ? '' : str_replace('/__health', '/__stats', $health);
}
/**
* 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'] ?? '');
}
/**
* Who gets the vitec:ssr-health alert mails. Maintained by editors in
* Site Management › Settings › VITEC (vitec.ssr.alertEmails, one
* address per entry); the first site with at least one valid address
* wins. Without such a list, SSR_ALERT_EMAIL from the env file is the
* fallback. Invalid entries are skipped and reported back.
*
* @return array{recipients: list<string>, source: string, invalid: list<string>}
*/
public function getAlertRecipients(): array
{
$invalid = [];
try {
foreach ($this->siteFinder->getAllSites() as $site) {
$list = $site->getSettings()->get('vitec.ssr.alertEmails', []);
if (is_string($list)) {
$list = GeneralUtility::trimExplode(',', $list, true);
}
$valid = [];
foreach ((array)$list as $address) {
$address = trim((string)$address);
if ($address === '') {
continue;
}
if (GeneralUtility::validEmail($address)) {
$valid[] = $address;
} else {
$invalid[] = $address;
}
}
if ($valid !== []) {
return ['recipients' => array_values(array_unique($valid)), 'source' => 'site settings', 'invalid' => $invalid];
}
}
} catch (\Throwable) {
// No site configuration readable (e.g. broken YAML): use the env fallback.
}
$env = array_values(array_filter(
GeneralUtility::trimExplode(',', $this->getAlertEmail(), true),
static fn(string $a): bool => GeneralUtility::validEmail($a)
));
return ['recipients' => $env, 'source' => $env !== [] ? 'var/vitec-ssr-purge.env' : '', 'invalid' => $invalid];
}
/**
* 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));
}
/**
* SSR_CACHE_MB of the node (/etc/vitec-ssr.env on the VPS) as the
* module knows it — the node does not report its limit in /__health.
* Optional SSR_CACHE_LIMIT_MB in var/vitec-ssr-purge.env; 0 = unknown.
*/
public function getCacheLimitMb(): int
{
return max(0, (int)($this->getConfig()['SSR_CACHE_LIMIT_MB'] ?? 0));
}
/**
* POST /__warm on the node (contract 0066-FE), derived from
* SSR_PURGE_ENDPOINT; SSR_WARM_ENDPOINT overrides explicitly.
*/
public function getWarmEndpoint(): string
{
$explicit = $this->getConfig()['SSR_WARM_ENDPOINT'] ?? '';
if ($explicit !== '') {
return $explicit;
}
$purge = $this->getConfig()['SSR_PURGE_ENDPOINT'] ?? '';
return $purge === '' ? '' : str_replace('/__purge', '/__warm', $purge);
}
/**
* Starts a background warm run on the node: no paths (or '*') = every
* page of sitemap.xml, otherwise exactly the given paths. The node
* answers at once and renders in the background; progress shows up as
* "warming" in /__health and /__stats. Not written to the purge log -
* a warm run evicts nothing, and the log feeds the purge statistics.
*
* @param string[] $paths
* @return array{success:bool, httpCode:int, message:string, data:array}
*/
public function warm(array $paths = []): array
{
if (!$this->isConfigured()) {
return ['success' => false, 'httpCode' => 0, 'message' => 'not configured (var/vitec-ssr-purge.env missing)', 'data' => []];
}
$paths = $this->normalize($paths);
$payload = ($paths === [] || $paths === ['*']) ? ['source' => 'sitemap'] : ['paths' => $paths];
try {
$response = $this->requestFactory->request($this->getWarmEndpoint(), 'POST', [
'headers' => [
'X-Purge-Secret' => $this->getConfig()['SSR_PURGE_SECRET'],
'Content-Type' => 'application/json',
],
'body' => json_encode($payload, JSON_THROW_ON_ERROR),
// The node reads the sitemap (index + sub-sitemaps) before
// it answers; the pages themselves are not awaited.
'timeout' => 30,
'connect_timeout' => 3,
'http_errors' => false,
]);
$code = $response->getStatusCode();
$body = (string)$response->getBody();
$data = json_decode($body, true);
$data = is_array($data) ? $data : [];
if ($code === 200 && !empty($data['started'])) {
$message = sprintf(
'Warm run started: %d pages queued (source: %s, %d at a time)',
(int)($data['queued'] ?? 0),
(string)($data['source'] ?? '?'),
(int)($data['concurrency'] ?? 0)
);
return ['success' => true, 'httpCode' => $code, 'message' => $message, 'data' => $data];
}
if ($code === 200 && !empty($data['running'])) {
$w = (array)($data['warming'] ?? []);
$message = sprintf(
'A warm run is already active: %d of %d pages done',
(int)($w['done'] ?? 0) + (int)($w['skipped'] ?? 0) + (int)($w['failed'] ?? 0),
(int)($w['queued'] ?? 0)
);
return ['success' => true, 'httpCode' => $code, 'message' => $message, 'data' => $data];
}
$message = 'HTTP ' . $code . ' - ' . (string)($data['error'] ?? substr($body, 0, 200));
return ['success' => false, 'httpCode' => $code, 'message' => $message, 'data' => $data];
} catch (\Throwable $e) {
return ['success' => false, 'httpCode' => 0, 'message' => substr($e->getMessage(), 0, 200), 'data' => []];
}
}
/**
* 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.
}
}
}