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>
331 lines
13 KiB
PHP
331 lines
13 KiB
PHP
<?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.
|
||
}
|
||
}
|
||
}
|