Fassung 1.6.0: der Aufgabenplan wird veroeffentlicht
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s

Wiederkehrende Aufgaben ohne Cronjob (ADR 073): Sicherung, Statistik kuerzen,
geplante Veroeffentlichung, Updatepruefung und Aufraeumen laufen als Plan in
der Tabelle scheduled_task - eine Zeile je Aufgabe mit Faelligkeit, Sperre und
letztem Ergebnis. Auf geteiltem Hosting gibt es oft keinen Cronjob; der Plan
haengt stattdessen an Anfragen und an einem Takt aus dem geoeffneten Backend.

Diese Arbeit entstand in einer parallel laufenden Sitzung und lag bis hierher
unveroeffentlicht im Arbeitsverzeichnis: die Releases 1.5.0 bis 1.5.5 wurden
bewusst aus dem jeweiligen Tag gebaut und enthielten sie deshalb nicht.

Vor der Veroeffentlichung geprueft, nicht bloss eingepackt:

- phpunit 452/452, npm test 28/28, npm run test:editor vollstaendig.
- Die Migration 20260914000100 legt nur die Tabelle scheduled_task an und nimmt
  sie beim Zurueckrollen wieder weg. Sie fasst keine bestehenden Daten an.
- Seit einer Stunde keine Schreibzugriffe der anderen Sitzung mehr.

Dabei zwei Fehler behoben:

- config/services.yaml verwendet !tagged_iterator. Das ist gueltiges Symfony,
  aber "lint:yaml config" kennt eigene Tags nur mit --parse-tags und gab 1
  zurueck - der CI-Lauf waere daran gescheitert. Flag ergaenzt.
- AdminHeadersSubscriber.php (aus 1.5.3) entsprach nicht dem Stil von
  php-cs-fixer; composer lint gab 8 zurueck. Behoben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-14 08:30:13 +02:00
1 parent d1ac9d760b
commit 05ea703655
35 files changed
+2229 -13

No files matched your search

+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Controller;
use App\EventSubscriber\TaskWorkerSubscriber;
use App\Ops\Scheduler;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* Der Cronjob für Hostings, die nur eine URL aufrufen können.
*
* Viele Tarife bieten keinen Shell-Cron, aber einen „URL-Cronjob": der Hoster ruft alle paar
* Minuten eine Adresse auf. Das genügt – und ist gegenüber dem Besuchs-Auslöser der bessere Weg,
* weil er auch dann läuft, wenn niemand auf der Website ist.
*
* Drei Entscheidungen:
*
* 1. **Der Schlüssel steht in der Adresse**, weil ein URL-Cronjob nichts anderes mitschicken
* kann als eine Adresse. Er ist aus dem Anwendungsgeheimnis abgeleitet, 40 Zeichen lang und
* auf jeder Installation ein anderer.
* 2. **Ein falscher Schlüssel bekommt 404**, nicht 403. Wer probiert, soll nicht erfahren, dass
* es hier überhaupt etwas gibt.
* 3. **Die Antwort ist Text**, nicht HTML: sie landet im Protokoll des Hosters, und dort ist
* eine Zeile je Aufgabe das Nützlichste, was ankommen kann.
*/
final class CronController extends AbstractController
{
public function __construct(private Scheduler $scheduler, private string $secret)
{
}
#[Route('/cms-cron/{token}', name: 'cms_cron', requirements: ['token' => '[a-f0-9]{40}'], methods: ['GET', 'POST'])]
public function run(Request $request, string $token): Response
{
if (!hash_equals(Scheduler::cronToken($this->secret), $token)) {
throw $this->createNotFoundException();
}
$request->attributes->set(TaskWorkerSubscriber::HANDLED, true);
// Hier wartet kein Mensch: ein Aufruf darf alles abarbeiten, was ansteht.
@ignore_user_abort(true);
@set_time_limit(0);
$began = microtime(true);
$results = $this->scheduler->runDue('cron', 20, 240);
$lines = [sprintf('%s UTC', gmdate('Y-m-d H:i:s'))];
foreach ($results as $result) {
$lines[] = sprintf('%-14s %-7s %6d ms %s', $result['name'], $result['status'], $result['ms'], $result['message']);
}
$lines[] = sprintf('%d Aufgabe(n) in %.1f s.', count($results), microtime(true) - $began);
return new Response(implode("\n", $lines)."\n", Response::HTTP_OK, [
'Content-Type' => 'text/plain; charset=UTF-8',
'Cache-Control' => 'no-store',
'X-Robots-Tag' => 'noindex, nofollow',
]);
}
}
+1 -1
View File
@@ -222,7 +222,7 @@ final class EditorController extends AbstractController
return $this->back('' === trim($request->request->getString('publishAt'))
? 'Die geplante Veröffentlichung wurde entfernt.'
: 'Die Veröffentlichung ist geplant. Sie erfolgt, sobald cms:publish-due läuft.', 'success');
: 'Die Veröffentlichung ist geplant. Sie erfolgt beim ersten Aufruf der Website nach diesem Zeitpunkt – siehe „Aufgaben“.', 'success');
}
#[Route('/pages/{id}/restore', name: 'admin_restore_page', methods: ['POST'])]
+147
View File
@@ -0,0 +1,147 @@
<?php
namespace App\Controller;
use App\EventSubscriber\TaskWorkerSubscriber;
use App\Ops\Scheduler;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* Der Bildschirm „Aufgaben“ und der Takt des geöffneten Backends.
*
* Zwei verschiedene Dinge in einer Datei, weil sie dasselbe Thema haben: hier sieht der Betreiber,
* was der Plan vorhat und was zuletzt passiert ist – und hier klopft die geöffnete Verwaltung im
* Minutentakt an, damit auf einer Website ohne Besucher trotzdem etwas läuft.
*/
final class TaskController extends AbstractController
{
public function __construct(private Scheduler $scheduler, private string $secret)
{
}
#[Route('/%kant.admin_path%/aufgaben', name: 'admin_tasks', methods: ['GET'])]
public function index(): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$state = $this->scheduler->fastState();
return $this->render('admin/tasks.html.twig', [
'tasks' => $this->scheduler->overview(),
'traffic' => $state['traffic'],
'admin' => $state['admin'],
'next' => $state['next'],
'now' => gmdate('Y-m-d H:i:s'),
'protocol' => $this->scheduler->protocol(30),
// Der Schlüssel für einen URL-Cronjob. Er steht in keiner Datenbank: er wird aus dem
// Anwendungsgeheimnis abgeleitet, ist damit auf jedem Server ein anderer und ändert
// sich, wenn das Geheimnis gewechselt wird.
'cronUrl' => $this->generateUrl('cms_cron', ['token' => Scheduler::cronToken($this->secret)], UrlGeneratorInterface::ABSOLUTE_URL),
]);
}
#[Route('/%kant.admin_path%/aufgaben/ausfuehren', name: 'admin_tasks_run', methods: ['POST'])]
public function run(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
if (!$this->isCsrfTokenValid('tasks', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
@set_time_limit(0);
$result = $this->scheduler->runNow($request->request->getString('name'));
if (null === $result) {
return $this->back('Diese Aufgabe gibt es nicht oder sie läuft gerade schon.');
}
$this->addFlash('ok' === $result['status'] ? 'success' : 'error', sprintf(
'%s: %s (%d ms)',
$result['label'],
$result['message'],
$result['ms'],
));
return $this->redirectToRoute('admin_tasks');
}
/**
* Abstand, Ein/Aus je Aufgabe und die beiden Auslöser, die in Anfragen stecken.
*
* Ein Formular für alles, weil es eine Entscheidung ist: wie dieser Server seine Aufgaben
* abarbeitet. Zwei getrennte Formulare hätten zwei Speicherknöpfe und dieselbe Wirkung.
*/
#[Route('/%kant.admin_path%/aufgaben/einstellen', name: 'admin_tasks_configure', methods: ['POST'])]
public function configure(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
if (!$this->isCsrfTokenValid('tasks', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
$this->scheduler->setWorkers(
$request->request->getBoolean('traffic'),
$request->request->getBoolean('admin'),
);
$enabled = $request->request->all('enabled');
foreach ($request->request->all('interval') as $name => $minutes) {
$name = (string) $name;
$this->scheduler->setInterval($name, max(1, (int) $minutes) * 60);
$this->scheduler->enable($name, isset($enabled[$name]));
}
$this->addFlash('success', 'Der Plan ist gespeichert.');
return $this->redirectToRoute('admin_tasks');
}
/** Eine Sperre, die stehen geblieben ist, weil das Hosting den Prozess mitten im Lauf beendet hat. */
#[Route('/%kant.admin_path%/aufgaben/freigeben', name: 'admin_tasks_release', methods: ['POST'])]
public function release(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
if (!$this->isCsrfTokenValid('tasks', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
$this->scheduler->release($request->request->getString('name'));
$this->addFlash('success', 'Die Sperre ist aufgehoben. Die Aufgabe darf wieder starten.');
return $this->redirectToRoute('admin_tasks');
}
/**
* Der Takt der geöffneten Verwaltung.
*
* Damit läuft der Plan auch auf einer Website, die gerade niemand besucht – solange jemand im
* Backend arbeitet. Das Skript fragt nur, während der Reiter sichtbar ist; ein im Hintergrund
* vergessener Reiter soll keine Sitzung endlos offen halten.
*
* Die Grenzen sind hier großzügiger als beim Besuch: hier wartet niemand auf eine Seite.
*/
#[Route('/%kant.admin_path%/aufgaben/takt', name: 'admin_tasks_tick', methods: ['POST'])]
public function tick(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
if (!$this->isCsrfTokenValid('tasks-tick', $request->request->getString('_csrf_token'))) {
return new JsonResponse(['error' => 'token'], Response::HTTP_FORBIDDEN);
}
$request->attributes->set(TaskWorkerSubscriber::HANDLED, true);
$state = $this->scheduler->fastState();
if (!$state['admin']) {
return new JsonResponse(['off' => true, 'ran' => []]);
}
@set_time_limit(120);
$ran = $this->scheduler->runDue('verwaltung', 3, 45);
return new JsonResponse([
'ran' => $ran,
'next' => $this->scheduler->fastState()['next'],
]);
}
private function back(string $error): Response
{
$this->addFlash('error', $error);
return $this->redirectToRoute('admin_tasks');
}
}