Files
KantSites/src/Controller/SettingsController.php
T
kantarciandClaude Fable 5.2 ead47cc924 Keep old pages readable, detect stale settings writes, make backups honest
- Own CSS is parsed on write only. A page whose stored CSS no longer
  passes a stricter rule answers 200 and opens in the editor; that one
  block loses its CSS. A document read several times in one request is
  validated once.
- Settings and theme refuse a write from an outdated form.
- A backup fails when a media file does not arrive completely and
  leaves no half directory behind; rotation counts intact backups only.
- The archive update takes its backup behind the maintenance page.
- A package import puts all media in place before removing the old
  ones, reports honestly when that fails and keeps a maintenance state
  that was already on.
- Restoring replaces the SQLite file in one move and points at pending
  migrations.
- Six irreversible actions ask first, in a dialog of the admin's own.
- Ctrl+S in the editor takes the field being typed in along; Delete
  no longer removes a block while a dialog is open.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 11:41:05 +01:00

252 lines
12 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
namespace App\Controller;
use App\Content\Consent;
use App\Content\DemoContent;
use App\Content\Languages;
use App\Content\NotFoundPage;
use App\Content\PageRepository;
use App\Content\PostRepository;
use App\Content\RedirectRepository;
use App\Content\SiteSettings;
use App\Content\SocialProfiles;
use App\Content\Theme;
use App\Domain\InvalidPage;
use App\Domain\StaleWrite;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class SettingsController extends AbstractController
{
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private Theme $theme, private NotFoundPage $notFoundPage, private Languages $languages, private PostRepository $posts)
{
}
#[Route('/%kant.admin_path%/settings', name: 'admin_settings', methods: ['GET'])]
public function index(Request $request): Response
{
// Alles hier wirkt sofort auf die Website: 404-Seite und Weiterleitungen sind Sache des
// Veroeffentlichers, die Unternehmensdaten samt Einwilligung und Spamschutz die des
// Administrators - der Bildschirm blendet den Teil aus, den die Rolle nicht aendern darf.
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
$settings = $this->settings->load();
$choosable = [];
// Datenschutz und Impressum werden per Suche gewählt; das Feld zeigt Titel und Adresse der
// gespeicherten Seite. Eine Adresse ohne Seite bleibt sichtbar, statt still zu verschwinden.
$legal = ['privacyPath' => null, 'imprintPath' => null];
foreach (array_keys($legal) as $key) {
if ('' !== $settings[$key]) {
$legal[$key] = ['path' => $settings[$key], 'title' => $settings[$key]];
}
}
foreach ($this->pages->listForEditor() as $page) {
foreach ($legal as $key => $selection) {
if (null !== $selection && $selection['path'] === $page['path']) {
$legal[$key]['title'] = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR)['title'] ?? $page['path'];
}
}
// Only a published page can serve as the root; a draft would answer every visit with 404.
if ('page' === $page['type'] && null !== $page['published_revision_id']) {
$document = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR);
$choosable[] = ['path' => $page['path'], 'title' => $document['title'] ?? $page['path']];
}
}
return $this->render('admin/settings.html.twig', [
'settings' => $settings,
'settingsStamp' => $this->settings->stamp(),
'businessTypes' => SiteSettings::BUSINESS_TYPES,
'legalSelection' => $legal,
'notFoundSelection' => $this->notFoundPage->selected(),
'redirects' => $this->redirects->all($request->query->getInt('seite', 1)),
'choosableHome' => $choosable,
'consentGroups' => Consent::GROUPS,
'missingLegal' => $this->settings->missingLegalPages(),
'consentGroupHelp' => Consent::GROUP_HELP,
// Fuer die Farbfelder: dieselbe Palette, die auch der Editor anbietet.
'palette' => $this->theme->load()['colors'],
'socialNetworks' => SocialProfiles::NETWORKS,
'weekdays' => SiteSettings::DAYS,
'socialPlaceholders' => SocialProfiles::PLACEHOLDERS,
'mainLanguage' => $this->languages->describe($this->languages->main()),
'extraLanguages' => $this->languages->extra(),
'terms' => [] === $this->languages->extra() ? [] : $this->posts->allTerms(),
'languageCatalog' => array_map(fn (string $code): array => $this->languages->describe($code), array_values(array_diff(array_keys(Languages::CATALOG), [$this->languages->main()]))),
]);
}
/**
* Die weiteren Sprachen (ADR 111). Eine Sprache dazunehmen ändert an der Website nichts,
* solange keine Seite übersetzt und veröffentlicht ist; eine Sprache wegnehmen nimmt ihre
* Seiten vom Netz, lässt die Übersetzungen aber in den Seiten liegen.
*/
/** Kategorien, Schlagwörter und Einwilligungshinweis in den weiteren Sprachen (ADR 111). */
#[Route('/%kant.admin_path%/settings/sprachen/texte', name: 'admin_language_texts_save', methods: ['POST'])]
public function saveLanguageTexts(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
$all = $request->request->all();
$this->settings->saveLanguageTexts(is_array($all['termNames'] ?? null) ? $all['termNames'] : [], is_array($all['consentTexts'] ?? null) ? $all['consentTexts'] : []);
$this->addFlash('success', 'Die Übersetzungen wurden gespeichert.');
return $this->redirectToRoute('admin_settings', ['_fragment' => 'sprachen']);
}
#[Route('/%kant.admin_path%/settings/sprachen', name: 'admin_languages_save', methods: ['POST'])]
public function saveLanguages(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
try {
$this->languages->save(array_values(array_map('strval', (array) ($request->request->all()['languages'] ?? []))));
} catch (InvalidPage $e) {
$this->addFlash('error', $e->getMessage());
return $this->redirectToRoute('admin_settings', ['_fragment' => 'sprachen']);
}
$this->addFlash('success', [] === $this->languages->extra() ? 'Die Website ist einsprachig.' : 'Die Sprachen wurden gespeichert. Übersetzt wird im Editor jeder Seite über den Sprachumschalter oben.');
return $this->redirectToRoute('admin_settings', ['_fragment' => 'sprachen']);
}
#[Route('/%kant.admin_path%/settings/company', name: 'admin_settings_save', methods: ['POST'])]
public function save(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
try {
// Ohne Kennung (ein Formular von vor dem Update) wird nicht geprüft, sondern gespeichert.
$this->settings->save($request->request->all(), $request->request->getString('expected') ?: null);
} catch (InvalidPage|StaleWrite $e) {
return $this->back($e->getMessage());
}
return $this->back('Die Unternehmensdaten wurden gespeichert.', 'success');
}
#[Route('/%kant.admin_path%/settings/not-found', name: 'admin_not_found_save', methods: ['POST'])]
public function saveNotFound(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
try {
$expectedId = $request->request->getString('expectedId');
if ('create' === $request->request->getString('action')) {
$id = $this->notFoundPage->create($expectedId);
$this->addFlash('success', 'Die 404-Vorlage wurde als Entwurf angelegt und zugewiesen. Nach dem Veröffentlichen ersetzt sie die Standard-404.');
return $this->redirectToRoute('admin_editor', ['id' => $id]);
}
$id = $request->request->getString('notFoundPageId');
$this->settings->assignNotFoundPage($id, $expectedId);
$selection = $this->notFoundPage->selected();
$message = '' === $id ? 'Die Standard-404 im Website-Design ist wieder aktiv.'
: ($selection['published'] ? 'Die 404-Seite wurde zugewiesen.' : 'Die Seite wurde zugewiesen. Bis zu ihrer Veröffentlichung erscheint die Standard-404.');
$this->addFlash('success', $message);
} catch (InvalidPage|StaleWrite $error) {
$this->addFlash('error', $error->getMessage());
}
return $this->redirectToRoute('admin_settings', ['_fragment' => 'not-found']);
}
#[Route('/%kant.admin_path%/settings/redirects', name: 'admin_redirect_save', methods: ['POST'])]
public function saveRedirect(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
$remove = $request->request->getString('remove');
if ('' !== $remove) {
$this->redirects->delete($remove);
return $this->back('Die Weiterleitung wurde entfernt.', 'success');
}
try {
$this->redirects->save(
$request->request->getString('from'),
$request->request->getString('to'),
$request->request->getInt('status', 301),
);
} catch (InvalidPage $e) {
return $this->back($e->getMessage());
}
return $this->back('Die Weiterleitung wurde gespeichert.', 'success');
}
/**
* Creates the privacy statement and the imprint, with a skeleton whose gaps are marked.
*
* The pages are mandatory, the text is not something a CMS can write: what gets created is the
* structure with every required item named and marked as still to be filled in.
*/
#[Route('/%kant.admin_path%/settings/rechtliches', name: 'admin_legal_pages', methods: ['POST'])]
public function legalPages(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
$settings = $this->settings->load();
$created = [];
try {
foreach (DemoContent::LEGAL as $slug => $label) {
$key = 'datenschutz' === $slug ? 'privacyPath' : 'imprintPath';
// An existing page is never overwritten - it may already carry the real text.
$existing = null !== $this->pages->findPublished($slug) || $this->pageExists($slug);
if (!$existing) {
$id = $this->pages->createPage($slug, null, 'page', DemoContent::legalPage($slug));
$page = $this->pages->findForEditor($id);
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
$created[] = $label;
}
$settings[$key] = $slug;
}
$this->settings->save([...$settings, 'consentGroups' => $settings['consentGroups'], 'sameAs' => implode('
', $settings['sameAs'])]);
} catch (InvalidPage $e) {
return $this->back($e->getMessage());
}
return $this->back(
[] === $created
? 'Die Seiten waren schon vorhanden und sind jetzt verknüpft. Der Inhalt bleibt unverändert.'
: sprintf('%s angelegt und verknüpft. Beide Seiten sind veröffentlicht und müssen noch ausgefüllt werden – die Pflichtangaben sind darin markiert.', implode(' und ', $created)),
'success',
);
}
/** Also finds a page that exists but is not published yet. */
private function pageExists(string $path): bool
{
foreach ($this->pages->listForEditor() as $page) {
if ($path === $page['path']) {
return true;
}
}
return false;
}
private function back(string $message, string $type = 'error'): Response
{
$this->addFlash($type, $message);
return $this->redirectToRoute('admin_settings');
}
}