Ticket 0068-FE: ProductFeatureJsonRenderer returned json_encode(null), which headless passes on as the STRING null (it only decodes object/array-looking strings) and crashed the front end. It now returns an empty string and the TypoScript field sets ifEmptyReturnNull = 1, giving a real JSON null. No other renderer used the pattern. Ticket 0066-FE: SsrPurgeService::warm() posts to the node /__warm endpoint (derived from SSR_PURGE_ENDPOINT, same secret), exposed as a Warm cache button with confirmation in the VITEC SSR module, a Cache warming progress row fed by /__health, and the CLI vitec:ssr-warm [paths]. Warm runs are deliberately not written to the purge log. Verified: two-path run 2/2 done, affected page delivers productfeature null. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
171 lines
6.6 KiB
PHP
171 lines
6.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Evomedien\Vitec\Controller\Backend;
|
|
|
|
use Evomedien\Vitec\Service\SsrPurgeService;
|
|
use Psr\Http\Message\ResponseInterface;
|
|
use Psr\Http\Message\ServerRequestInterface;
|
|
use TYPO3\CMS\Backend\Attribute\AsController;
|
|
use TYPO3\CMS\Backend\Routing\UriBuilder;
|
|
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
|
|
use TYPO3\CMS\Core\Core\Environment;
|
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
|
use TYPO3\CMS\Core\Http\JsonResponse;
|
|
use TYPO3\CMS\Core\Http\RedirectResponse;
|
|
use TYPO3\CMS\Core\Http\RequestFactory;
|
|
use TYPO3\CMS\Core\Messaging\FlashMessage;
|
|
use TYPO3\CMS\Core\Site\SiteFinder;
|
|
use TYPO3\CMS\Core\Messaging\FlashMessageService;
|
|
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
|
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
|
|
|
/**
|
|
* Backend module "VITEC SSR": live state of the SSR node, manual purge
|
|
* (single paths or everything) and the purge/health protocol.
|
|
*/
|
|
#[AsController]
|
|
final class SsrController
|
|
{
|
|
public function __construct(
|
|
private readonly ModuleTemplateFactory $moduleTemplateFactory,
|
|
private readonly SsrPurgeService $purgeService,
|
|
private readonly RequestFactory $requestFactory,
|
|
private readonly ConnectionPool $connectionPool,
|
|
private readonly FlashMessageService $flashMessageService,
|
|
private readonly UriBuilder $uriBuilder,
|
|
) {}
|
|
|
|
public function indexAction(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$view = $this->moduleTemplateFactory->create($request);
|
|
$view->setTitle('VITEC SSR');
|
|
|
|
$health = null;
|
|
$healthError = '';
|
|
$endpoint = $this->purgeService->getHealthEndpoint();
|
|
if ($endpoint === '') {
|
|
$healthError = 'var/vitec-ssr-purge.env fehlt oder ist unvollständig.';
|
|
} else {
|
|
$start = microtime(true);
|
|
try {
|
|
$response = $this->requestFactory->request($endpoint, 'GET', [
|
|
'timeout' => 3, 'connect_timeout' => 2, 'http_errors' => false,
|
|
]);
|
|
if ($response->getStatusCode() === 200) {
|
|
$health = json_decode((string)$response->getBody(), true) ?: null;
|
|
if ($health !== null) {
|
|
$health['responseMs'] = (int)round((microtime(true) - $start) * 1000);
|
|
}
|
|
} else {
|
|
$healthError = 'HTTP ' . $response->getStatusCode();
|
|
}
|
|
} catch (\Throwable $e) {
|
|
$healthError = $e->getMessage();
|
|
}
|
|
}
|
|
|
|
$view->assignMultiple([
|
|
'flagOn' => file_exists(Environment::getVarPath() . '/vitec-ssr-on.flag'),
|
|
'configured' => $this->purgeService->isConfigured(),
|
|
'health' => $health,
|
|
'healthError' => $healthError,
|
|
'purgeLog' => $this->rows('tx_vitec_ssrpurgelog', 30),
|
|
'healthLog' => $this->rows('tx_vitec_ssrhealthlog', 10),
|
|
]);
|
|
return $view->renderResponse('Ssr/Index');
|
|
}
|
|
|
|
public function purgeAction(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$body = (array)$request->getParsedBody();
|
|
$mode = (string)($body['mode'] ?? 'paths');
|
|
$user = (string)($GLOBALS['BE_USER']->user['username'] ?? '?');
|
|
|
|
if ($mode === 'all') {
|
|
$paths = ['*'];
|
|
} else {
|
|
$paths = preg_split('/[\s,]+/', (string)($body['paths'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
|
}
|
|
|
|
if ($paths === []) {
|
|
$this->flash('No paths given.', 'Purge', false);
|
|
} else {
|
|
$result = $this->purgeService->purge($paths, 'module:' . $user);
|
|
$this->flash(
|
|
sprintf('HTTP %d — %s', $result['httpCode'], $result['message']),
|
|
$result['success'] ? 'Purge successful' : 'Purge failed',
|
|
$result['success']
|
|
);
|
|
}
|
|
return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecssr'), 303);
|
|
}
|
|
|
|
/**
|
|
* "Warm cache" button: asks the node to render every page of the
|
|
* sitemap in the background (0066-FE). Returns at once; progress is
|
|
* the "Cache warming" row of the status table.
|
|
*/
|
|
public function warmAction(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$result = $this->purgeService->warm();
|
|
$this->flash(
|
|
$result['message'],
|
|
$result['success'] ? 'Cache warming' : 'Cache warming failed',
|
|
$result['success']
|
|
);
|
|
return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecssr'), 303);
|
|
}
|
|
|
|
/**
|
|
* Ajax route vitec_ssr_purge_page: purge exactly one page, called by
|
|
* the page tree context menu item (ssr-context-menu.js).
|
|
*/
|
|
public function purgePage(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$body = (array)($request->getParsedBody() ?? []);
|
|
$pageUid = (int)($body['pageUid'] ?? ($request->getQueryParams()['pageUid'] ?? 0));
|
|
if ($pageUid <= 0) {
|
|
return new JsonResponse(['success' => false, 'message' => 'No page uid given'], 400);
|
|
}
|
|
try {
|
|
$site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pageUid);
|
|
$path = $site->getRouter()->generateUri($pageUid)->getPath() ?: '/';
|
|
} catch (\Throwable) {
|
|
return new JsonResponse(['success' => false, 'message' => 'Page has no routable URL (folder?)']);
|
|
}
|
|
$user = (string)($GLOBALS['BE_USER']->user['username'] ?? '?');
|
|
$result = $this->purgeService->purge([$path], 'contextmenu:' . $user);
|
|
return new JsonResponse([
|
|
'success' => $result['success'],
|
|
'path' => $path,
|
|
'message' => 'HTTP ' . $result['httpCode'] . ' - ' . $result['message'],
|
|
]);
|
|
}
|
|
|
|
private function rows(string $table, int $limit): array
|
|
{
|
|
try {
|
|
$qb = $this->connectionPool->getQueryBuilderForTable($table);
|
|
return $qb->select('*')->from($table)
|
|
->orderBy('uid', 'DESC')->setMaxResults($limit)
|
|
->executeQuery()->fetchAllAssociative();
|
|
} catch (\Throwable) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private function flash(string $message, string $title, bool $ok): void
|
|
{
|
|
$flash = GeneralUtility::makeInstance(
|
|
FlashMessage::class,
|
|
$message,
|
|
$title,
|
|
$ok ? ContextualFeedbackSeverity::OK : ContextualFeedbackSeverity::ERROR,
|
|
true
|
|
);
|
|
$this->flashMessageService->getMessageQueueByIdentifier()->addMessage($flash);
|
|
}
|
|
}
|