Fassung 1.6.0: der Aufgabenplan wird veroeffentlicht
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:
1 parent
d1ac9d760b
commit
05ea703655
35 files changed
+2229
-13
No files matched your search
@@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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'])]
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user