Compare commits

...
41 Commits
Author SHA1 Message Date
kantarciandClaude Opus 5.5 ef7cb02cbd Release 1.35.1
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:48:43 +02:00
kantarciandClaude Opus 5.5 7eaa3f6efa Tidy the mail and system screens and let the mail log be cleared
Text in admin cards had no size of its own, so 16px browser default sat
next to 13px notes; cards now use 13px throughout while fields stay at
16px for iOS. The mail page gets option cards, a 2x2 SMTP grid, a styled
save button in the card foot and a log with status pills and collapsed
server messages, plus "Protokoll leeren". The package card uses the same
sections and a styled file picker. The E-Mail-Versand subitem only shows
inside System and sits beside it in the horizontal strip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:48:43 +02:00
kantarciandClaude Opus 5.5 daaeb52588 Note the 1.35.0 release in the status
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:08:27 +02:00
kantarciandClaude Opus 5.5 6b1293cdb8 Release 1.35.0
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:07:27 +02:00
kantarciandClaude Opus 5.5 b982cb8944 Give mail delivery its own admin page and move the origin probe off /.well-known
The mail page under System tries SMTP, PHP mail() and both sendmail
modes one by one or all at once, and saves the chosen way to .env
without ever showing the password. IONOS answers everything under
/.well-known/ itself with 403, so the address and HTTPS checks never
reached the installation; the probe now lives at /kantsites-origin
(ADR 122).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:07:27 +02:00
kantarciandClaude Opus 5.5 2497b8cb69 Note the 1.34.0 release in the status
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:30:39 +02:00
kantarciandClaude Opus 5.5 f5b9ae03e3 Release 1.34.0
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:29:36 +02:00
kantarciandClaude Opus 5.5 d313156c73 Send a test mail from the admin and log why delivery failed
Form submissions marked as not sent gave no reason: the transport
exception was swallowed. Every send now goes through MailCheck, which
logs the server's answer and a hint in German to var/log/mail.log
(server lines only, no credentials). The system screen shows the mail
route, sends a test mail to any address and lists recent attempts
(ADR 121).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:29:36 +02:00
kantarciandClaude Opus 5.5 090436233d Release 1.33.0
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:22:56 +02:00
kantarciandClaude Opus 5.5 8b212d5cc9 Force HTTPS from the admin, with a probe before and after
System > HTTPS and cms:https switch the redirect lines in public/.htaccess
only when the site answers over HTTPS, and take them back on a loop. HSTS
is a separate second switch. The redirect block moves before the front
controller rules, where it never reached pages, and .well-known is no
longer denied, so the origin probe works on Apache. The Git updater keeps
switched-on lines like the archive update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:22:56 +02:00
kantarciandClaude Opus 5.5 daad5a1740 Release 1.32.1
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:50:23 +02:00
kantarciandClaude Opus 5.5 dd5bc16413 Close the collapsible menu after choosing a link
Same-page anchors kept the details menu open. scrolled.js closes it on
any link click and loads only where a collapsible menu holds such links.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:50:23 +02:00
kantarciandClaude Opus 5.5 bb9d7c6b45 Note the 1.32.0 release in the status
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:32:24 +02:00
kantarciandClaude Opus 5.5 be4a4f23d8 Release 1.32.0
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:31:07 +02:00
kantarciandClaude Opus 5.5 32e42f973b Move site content between installations as a package
System now exports a content package and imports one, by upload or from
var/pakete. Pages with their revisions, areas, menus, media, forms,
redirects, terms, translations, design and settings are replaced;
submissions, statistics, tasks, plugin state and service keys stay, and
accounts only come along when that is chosen explicitly. A backup comes
first, the site is in maintenance meanwhile, and every file and document
in the package is checked before anything is written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:31:06 +02:00
kantarciandClaude Opus 5.5 a57b829a15 Note the 1.31.0 release in the status
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:07:28 +02:00
kantarciandClaude Opus 5.5 e465ec7437 Release 1.31.0
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:06:15 +02:00
kantarciandClaude Opus 5.5 9e82918a5e Close the open menu on a click beside it and add social icons
The open burger menu now closes when someone clicks elsewhere, without a
script: an invisible layer that belongs to the menu button covers the
page while the menu is open. Facebook, Instagram and WhatsApp join the
icon set, so menu buttons can carry them. The stroke attributes of every
icon move into the stylesheet, which saves about 4 KB per page.

Hüfner gets a right-aligned menu with the quote button, Facebook and
Instagram as menu buttons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:06:15 +02:00
kantarciandClaude Opus 5.5 3c5c50f499 Note the 1.30.0 release in the status
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:40:22 +02:00
kantarciandClaude Opus 5.5 c9c797e592 Release 1.30.0
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:38:26 +02:00
kantarciandClaude Opus 5.5 ad7c333215 Harden KantSites after the security review
- The installer only opens with a per-installation key that install.php
  creates and shows; the key is deleted with the first account.
- Lock files keep .env, the database, backups and logs out of reach when a
  domain points at the project folder instead of public/.
- Linked sections render by their own schema version, so an old section
  cannot carry unfiltered HTML into a newer page.
- Editors no longer move, trash or change areas and post data of published
  pages; a scheduled publication goes live with the revision that was
  scheduled (migration 20261012000100).
- Form stamps are bound to their form, IPv6 senders count per /64, and a
  flood per form or overall stops storing with a visible message.
- The consent redirect rejects control characters, update targets must look
  like 1.2.3, and own CSS cannot reach outside its block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:38:25 +02:00
kantarciandClaude Opus 5.5 14e7a3b310 Open Hüfner's outside links in a new tab and crop the tree for the favicon
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:03:03 +02:00
kantarciandClaude Opus 5.5 69f7977ecf Note the 1.29.0 release in the status
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:57:31 +02:00
kantarciandClaude Opus 5.5 6f6e5d876c Release 1.29.0
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:56:16 +02:00
kantarciandClaude Opus 5.5 6fa303f901 Save consent without reloading the page
consent.js posts the consent form in the background, the server answers
204 with the cookie, and the script swaps in the embeds and the legal line
from a fresh copy of the page. Embed hosts are in frame-src from the start,
the content address still only after consent. Without JavaScript the form
keeps working with a reload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:56:16 +02:00
kantarciandClaude Opus 5.5 6390c00876 Rework the Hüfner site after the customer meeting
Tree green from the logo everywhere, the 100 emoji inline wherever 100
appears, downloads for the exemption certificates and the PQ certificate,
the image video behind consent, Google reviews, a 48-hour quote promise,
WhatsApp, two job cards and a page for the alpacas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:56:16 +02:00
kantarciandClaude Opus 5.5 6ee05f4c70 Remove the duplicated 1.28.1 status entry
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:35:11 +02:00
kantarciandClaude Opus 5.5 1b6cbb2247 Note the 1.28.1 release in the status
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:34:58 +02:00
kantarciandClaude Opus 5.5 b298b7469f Release 1.28.1
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:33:31 +02:00
kantarciandClaude Opus 5.5 760efc3297 Stop the rewrite rule from looping on IONOS
IONOS answered every rewritten address with a blank Apache 500: the
!-f condition did not recognise the already rewritten index.php, so the
rule fired again until Apache gave up. RewriteBase and an explicit stop
for index.php, as in WordPress, fix it (ADR 114).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:33:31 +02:00
kantarciandClaude Opus 5.5 28070298aa Note the 1.28.0 release in the status
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:46:20 +02:00
kantarciandClaude Opus 5.5 436c66ecd3 Release 1.28.0
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:43:12 +02:00
kantarciandClaude Opus 5.5 a6a334fe68 Show a maintenance page instead of a blank 500 and update in one click
Emergency layer logs fatal errors to var/log/notfall.log and answers 503.
The system page checks for a new release on load, shows its notes, runs
backup, download, switch, migration and health check in one flow with live
progress, and switches back automatically when the new version fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:33:53 +02:00
kantarciandClaude Opus 5.5 339a48c810 Note the 1.27.1 release in the status
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:37:45 +02:00
kantarciandClaude Opus 5.5 f2b29afe22 Release 1.27.1
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:36:25 +02:00
kantarciandClaude Opus 5.5 47292c7266 Finish an archive update in one go and translate Kantarci Reisen into Turkish
The request that swaps the files now ends with a plain page instead of running on through the
framework with old code in memory and new code on disk - that is what answered with a 500 after
a successful update. The page calls the second step itself with a one-time key: migrations,
clean-up, maintenance off, back to the system screen (ADR 112).

Turkish is complete in the fixed template texts and form messages. Two language switches on one
page get distinct landmark names. Kantarci Reisen gets a Turkish version of every page, the
header, footer, menu and enquiry form, with a DE · TR switch; the reset script applies it.
The DM Sans 600 files are needed after all and are committed with fonts.css.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:36:25 +02:00
kantarciandClaude Opus 5.5 8aee38bde8 Note the removed unused DM Sans files in the status
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:04:57 +02:00
kantarciandClaude Opus 5.5 91ebfde829 Note the 1.27.0 release in the status
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:03:12 +02:00
kantarciandClaude Opus 5.5 ad86e4874b Release 1.27.0
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:00:54 +02:00
kantarciandClaude Opus 5.5 e15b2517b1 Translate pages, areas, menus, forms and posts field by field instead of rebuilding them
A site can have further languages (Settings → Languages). A page is built once; each language
adds only its texts, keyed by the stable block IDs, plus its own address under /en/… (ADR 111,
document schema v27, migration 20261009000100 for page_translation).

- Editor: DE | EN switch, a list of all texts with original, state (missing, translated,
  original changed) and filter; structure stays with the main language. Undo, drafts,
  publishing and revisions cover all languages at once.
- Website: /en and translated slugs, lang, canonical, hreflang with x-default, og:locale,
  sitemap; links to translated pages follow the language; a language switch block.
- Header, footer and linked sections translate the same way; menu entries carry labels per
  language; fixed template texts come from a small dictionary.
- Forms: labels, help, options, button and success message per language; messages and the
  step script speak the language, the submission and the email stay in the main language.
- Posts, archives, category names, consent text and the 404 page follow the language.
- Single-language sites see none of it and never touch the new table.

The forms administration becomes an overview with one row per form and its own edit page;
fields are one line each with details collapsed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:56:52 +02:00
kantarciandClaude Opus 5.5 c7705b7083 Add the Kantarci Reisen build scripts and local launch configurations
Seed, reset and asset scripts for the tenth installation, plus dev and prod launch entries
for it and for the multilingual test copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:56:44 +02:00
127 changed files with 10249 additions and 604 deletions

No files matched your search

+40
View File
@@ -168,6 +168,46 @@
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/huefner.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/huefner-media'; $env:SITE_ORIGIN='http://127.0.0.1:8103'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8103 -t public bin/dev-router.php"
],
"port": 8103
},
{
"name": "kantarci-reisen",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-reisen.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-reisen-media'; $env:SITE_ORIGIN='http://127.0.0.1:8104'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8104 -t public bin/dev-router.php"
],
"port": 8104
},
{
"name": "kantarci-reisen-prod",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-reisen.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-reisen-media'; $env:SITE_ORIGIN='http://127.0.0.1:8105'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8105 -t public bin/dev-router.php"
],
"port": 8105
},
{
"name": "sprachen-test",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/lang/huefner-lang.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/huefner-media'; $env:SITE_ORIGIN='http://127.0.0.1:8111'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8111 -t public bin/dev-router.php"
],
"port": 8111
},
{
"name": "sprachen-test-prod",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/lang/huefner-lang.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/huefner-media'; $env:SITE_ORIGIN='http://127.0.0.1:8112'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8112 -t public bin/dev-router.php"
],
"port": 8112
}
]
}
+24
View File
@@ -0,0 +1,24 @@
# Schutz fuer den Fall, dass die Domain auf den Projektordner zeigt statt auf public/ (ADR 116).
#
# Bewusst kein "alles sperren": Apache wendet diese Datei auch auf public/ an, und dort soll alles
# wie gewohnt laufen. Gesperrt werden nur Dateien, die es in public/ nicht gibt oder die
# public/.htaccess ohnehin sperrt. Die Ordner var/, vendor/, config/, src/ usw. tragen eine eigene
# Sperrdatei.
<FilesMatch "^\.|\.(env|db|sqlite|sqlite3|lock|md|yaml|yml|dist|log|key)$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
</FilesMatch>
<FilesMatch "^(composer\.json|VERSION|cli-config\.php|migrations\.php)$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
</FilesMatch>
+6 -4
View File
@@ -186,7 +186,9 @@ Danach `http://127.0.0.1:8080/` öffnen. Der Routerskript-Parameter ist nicht op
`bin/setup.php` erzeugt ein zufälliges Secret und `var/`. Die Konfiguration steht ausschließlich in der nicht versionierten `.env`; vorhandene Einstellungen und Secrets bleiben erhalten. Die Datenbank liegt in `var/cms.db`. Wiederholtes Seeding erhält existierende Seiten einschließlich ihrer Bearbeitungen. Standardzugangsdaten gibt es nicht.
**Installation ohne Konsole** läuft über `/install`: Systemprüfung, Datenbank, Migration, Website, erstes Konto. Der Installer antwortet nur, solange keine Installation existiert – sobald ein Konto angelegt ist, liefert jede Adresse darunter 404, und die Sperre hängt am Zustand der Datenbank, nicht an einer Datei. Solange nichts installiert ist, führt jede Adresse dorthin. Davor genügt eine einzelne hochgeladene Datei: `install.php` aus einem Release lädt das Archiv von einer fest eingetragenen Adresse, vergleicht die SHA-256, entpackt es und löscht sich selbst. Ohne passende Prüfsumme wird nichts entpackt. Editor-Endpunkte benötigen Anmeldung, Berechtigung und bei POST einen CSRF-Token.
**Installation ohne Konsole** läuft über `/install`: Systemprüfung, Datenbank, Migration, Website, erstes Konto. Der Installer antwortet nur, solange keine Installation existiert – sobald ein Konto angelegt ist, liefert jede Adresse darunter 404, und die Sperre hängt am Zustand der Datenbank, nicht an einer Datei. Solange nichts installiert ist, führt jede Adresse dorthin. Davor genügt eine einzelne hochgeladene Datei: `install.php` aus einem Release lädt das Archiv von einer fest eingetragenen Adresse, vergleicht die SHA-256, entpackt es und löscht sich selbst. Ohne passende Prüfsumme wird nichts entpackt. **Seit 1.30.0 öffnet sich der Installer nur mit dem Installationsschlüssel** (ADR 116): `install.php` legt ihn an und zeigt ihn mit einem fertigen Verweis auf seiner Abschlussseite; wer per FTP entpackt, findet ihn in `var/install.key`. Mit dem ersten Konto wird er gelöscht. Das Paket bringt Sperrdateien mit (`.htaccess` im Projektstamm und in `bin/`, `config/`, `migrations/`, `plugins/`, `src/`, `templates/`, `vendor/`; `var/.htaccess` legt die Website selbst an), damit auch bei einer Domain, die versehentlich auf den Projektordner zeigt, weder `.env` noch Datenbank oder Protokolle abrufbar sind. Editor-Endpunkte benötigen Anmeldung, Berechtigung und bei POST einen CSRF-Token.
**Inhalte zwischen Installationen übertragen** (seit 1.32.0, ADR 118): System → „Inhalte übertragen“ exportiert ein Inhaltspaket (Zip) und importiert eines – per Upload oder aus `var/pakete/`. Ersetzt werden Seiten, Bereiche, Menüs, Medien, Formulare, Weiterleitungen, Kategorien, Übersetzungen, Gestaltung und Einstellungen; Anfragen, Statistik und Turnstile-Zugangsdaten bleiben, Konten nur mit eigenem Häkchen. Vorher wird gesichert. Auf der Konsole: `php bin/console cms:paket:export` und `php bin/console cms:paket:import <datei>`.
## Eigene 404-Seite (ab 1.8.0)
@@ -290,7 +292,7 @@ Unter Linux/macOS `npm` und `npx` ohne `.cmd`; in CI `npx playwright install --w
Die Verwaltung liegt unter `/admin`. `php bin/console cms:admin:path meine-verwaltung` legt sie auf eine andere Adresse: der Wert landet in `.env`, gilt für Routen, Firewall und Schutzkopfzeilen, und wird in der `robots.txt` bewusst nicht genannt. Ohne Argument zeigt der Befehl die aktuelle Adresse.
Die öffentliche Adresse der Website steht als `SITE_ORIGIN` in `.env` – mit oder ohne `www`, so wie sie in Canonical, Sitemap, Feed und strukturierten Daten stehen soll. `php bin/console cms:site:origin https://www.meine-firma.de` prüft und schreibt sie; ohne Argument zeigt der Befehl die aktuelle. Dasselbe geht in der Verwaltung unter „System“, mit einem Link auf die andere Schreibweise. Dort wird vorher geprüft, ob die Website unter der neuen Adresse antwortet. Die jeweils andere Schreibweise (`meine-firma.de` ↔ `www.meine-firma.de`) leitet die Anwendung selbst mit 301 auf die eingetragene um, mit Pfad und Abfrage, auch für die Verwaltung. Ein ganz anderer Host – die Vorschauadresse des Hosters, eine IP-Adresse, `localhost` – bleibt erreichbar, damit eine falsch eingetragene Adresse niemanden aussperrt; der Bildschirm „System“ zeigt dann, unter welcher Adresse die Verwaltung gerade läuft und welche eingetragen ist. HTTPS erzwingt die Anwendung nicht; dafür bleiben die drei Zeilen in `public/.htaccess`.
Die öffentliche Adresse der Website steht als `SITE_ORIGIN` in `.env` – mit oder ohne `www`, so wie sie in Canonical, Sitemap, Feed und strukturierten Daten stehen soll. `php bin/console cms:site:origin https://www.meine-firma.de` prüft und schreibt sie; ohne Argument zeigt der Befehl die aktuelle. Dasselbe geht in der Verwaltung unter „System“, mit einem Link auf die andere Schreibweise. Dort wird vorher geprüft, ob die Website unter der neuen Adresse antwortet. Die jeweils andere Schreibweise (`meine-firma.de` ↔ `www.meine-firma.de`) leitet die Anwendung selbst mit 301 auf die eingetragene um, mit Pfad und Abfrage, auch für die Verwaltung. Ein ganz anderer Host – die Vorschauadresse des Hosters, eine IP-Adresse, `localhost` – bleibt erreichbar, damit eine falsch eingetragene Adresse niemanden aussperrt; der Bildschirm „System“ zeigt dann, unter welcher Adresse die Verwaltung gerade läuft und welche eingetragen ist. HTTPS erzwingt die Anwendung nicht selbst; unter „System“ → „HTTPS“ schaltet „HTTPS erzwingen“ die drei Zeilen in `public/.htaccess` ein – nur wenn die Adresse vorher über HTTPS hier ankommt, mit Probe danach und Rücknahme bei einer Schleife. HSTS ist ein eigener Schalter. Konsole: `php bin/console cms:https` (Stand), `cms:https an|aus|hsts-an|hsts-aus` (ADR 120).
`composer format` formatiert PHP. `composer build` wärmt den Symfony-Produktionscache; öffentliche CSS-Dateien sind direkt auslieferbar, es ist kein Frontend-Bundler nötig. Bei Änderungen an `public/assets/design-tokens.json` einmal `npm.cmd run build:design` ausführen und die erzeugte CSS-Datei mitliefern; Node ist auf dem Hosting nicht erforderlich. Nach Änderungen an Produktionskonfiguration/Templates `php bin/console cache:clear --env=prod --no-debug` ausführen. Falls lokale Tests alte Templates verwenden, vor PHPUnit auch `php bin/console cache:clear --env=test --no-debug` ausführen.
@@ -341,7 +343,7 @@ Serverkonfiguration über die nicht öffentliche `.env`; echte Server-Umgebungsv
**Composer wird auf dem Hosting nicht vorausgesetzt.** Ein Release wird auf einem Rechner gebaut, der Composer hat: `php scripts/build-release.php` legt `var/releases/kantsites-<Fassung>.zip` samt `.sha256` an. Das Archiv enthält die Anwendung mit fertigem `vendor/` ohne Entwicklungspakete, aber keine Tests, keine Entwicklungsskripte und kein `var/`. Eine `.env` aus der unvertraulichen `.env.example` liegt für die Erstinstallation bei. Der Webinstaller schreibt die eigenen Werte in `.env`, migriert und legt das erste Benutzerkonto an. Beim Update die eigene `.env` erhalten und niemals mit der Vorlage aus dem Archiv überschreiben. Node wird dort ebenfalls nicht gebraucht. Keine Installation ins öffentlich erreichbare Projektstammverzeichnis.
`web.kant-it.de` und das Drews-Hosting laufen mit Releasearchiven dieses Codes; die Domainumschaltung von kant-it.de, ein Restore-Test auf dem Hosting und der Formularversand über einen echten Mailserver stehen aus (`docs/LIVEGANG.md`). Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Migration `20260912001300` ergänzt zwei Spalten an `page` für Kopf- und Fußbereich je Seite (zwei ADD COLUMN). Migration `20260914000100` legt die Tabelle des Aufgabenplans an (neue Tabelle, kein Eingriff in Bestehendes); ohne sie läuft der Plan nicht, meldet das aber, statt eine Seite zu zerstören. Migration `20260912001200` legt die Zählertabelle der Statistik an (neue Tabelle, kein Eingriff in Bestehendes); `php bin/console cms:stats:prune` und `php bin/console cms:backup` erledigt jetzt der eingebaute Aufgabenplan (Abschnitt „Wiederkehrende Aufgaben“); ohne Cronjob genügen Besuche und das geöffnete Backend – die Sicherung schreibt nach `var/backups/`, rotiert selbst und enthält Datenbank und Medien, aber weder `.env` noch `vendor/`. Zurückspielen: `php bin/console cms:restore <name>`. Der Code liegt auf `https://git.kant-it.de/kantarci/KantSites.git`; Fassungen sind Tags der Form `v1.0.1`, und die laufende Fassung steht in `VERSION`. Ein Update zieht `php bin/console cms:update` (mit `--check`, `--dry-run`, `--to=v1.2.0`) oder der Bildschirm „System“ in der Verwaltung; `--maintenance=an|aus` schaltet den Wartungszustand von Hand. Ändert ein Update die Abhängigkeiten (`composer.lock`) und ist Composer auf dem Server nicht aufrufbar, bricht der Updater ab und verweist auf das Releasearchiv; Updates ohne Abhängigkeitsänderung laufen auch ohne Composer durch. Ablauf und Grenzen stehen in `docs/UPDATE.md`. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt.
`web.kant-it.de` und das Drews-Hosting laufen mit Releasearchiven dieses Codes; die Domainumschaltung von kant-it.de, ein Restore-Test auf dem Hosting und der Formularversand über einen echten Mailserver stehen aus (`docs/LIVEGANG.md`). Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Migration `20260912001300` ergänzt zwei Spalten an `page` für Kopf- und Fußbereich je Seite (zwei ADD COLUMN). Migration `20260914000100` legt die Tabelle des Aufgabenplans an (neue Tabelle, kein Eingriff in Bestehendes); ohne sie läuft der Plan nicht, meldet das aber, statt eine Seite zu zerstören. Migration `20261009000100` legt die Tabelle `page_translation` für die Mehrsprachigkeit an (neue Tabelle, kein Eingriff in Bestehendes; Sprachen unter Einstellungen → Sprachen, ADR 111). Migration `20260912001200` legt die Zählertabelle der Statistik an (neue Tabelle, kein Eingriff in Bestehendes); `php bin/console cms:stats:prune` und `php bin/console cms:backup` erledigt jetzt der eingebaute Aufgabenplan (Abschnitt „Wiederkehrende Aufgaben“); ohne Cronjob genügen Besuche und das geöffnete Backend – die Sicherung schreibt nach `var/backups/`, rotiert selbst und enthält Datenbank und Medien, aber weder `.env` noch `vendor/`. Zurückspielen: `php bin/console cms:restore <name>`. Der Code liegt auf `https://git.kant-it.de/kantarci/KantSites.git`; Fassungen sind Tags der Form `v1.0.1`, und die laufende Fassung steht in `VERSION`. Ein Update zieht `php bin/console cms:update` (mit `--check`, `--dry-run`, `--to=v1.2.0`) oder der Bildschirm „System“ in der Verwaltung; `--maintenance=an|aus` schaltet den Wartungszustand von Hand. Ändert ein Update die Abhängigkeiten (`composer.lock`) und ist Composer auf dem Server nicht aufrufbar, bricht der Updater ab und verweist auf das Releasearchiv; Updates ohne Abhängigkeitsänderung laufen auch ohne Composer durch. Ablauf und Grenzen stehen in `docs/UPDATE.md`. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt.
## Verwaltung ab 1.7.0
@@ -397,7 +399,7 @@ Die Seite „Kantarci Markt – Neueröffnung“ (Wilhelm-Drapp-Straße, Baden-B
### Neunte lokale Referenzwebsite: Hüfner Gartenbau
Die Website der Garten- und Landschaftsbau Hüfner GmbH (Iffezheim) liegt in `var/huefner.db` mit Medien in `var/huefner-media`; die Startkonfiguration `huefner` in `.claude/launch.json` nutzt Port 8102, `huefner-prod` denselben Bestand im Produktionsmodus auf Port 8103 zum Messen. `sh scripts/reset-huefner.sh [Entwurfsordner]` kopiert die Fotos aus dem gelieferten Entwurf nach `var/huefner-assets` und schneidet den Baum aus dem Logo als Seitensymbol (`scripts/build-huefner-assets.php`; ohne Argument nimmt es die schon vorbereiteten Bilder), wirft die Installation weg, baut mit `scripts/seed-huefner.php` Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404, Menü, Einstellungen und das Formular `anfrage` auf und holt Barlow und Barlow Condensed auf den eigenen Server. Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8103 CMS_PATHS=/,/impressum,/datenschutz npm run audit`. Die bewussten Abweichungen vom Entwurf stehen im Kopf des Skripts.
Die Website der Garten- und Landschaftsbau Hüfner GmbH (Iffezheim) liegt in `var/huefner.db` mit Medien in `var/huefner-media`; die Startkonfiguration `huefner` in `.claude/launch.json` nutzt Port 8102, `huefner-prod` denselben Bestand im Produktionsmodus auf Port 8103 zum Messen. `sh scripts/reset-huefner.sh [Entwurfsordner Unterlagenordner]` kopiert die Fotos aus dem gelieferten Entwurf und Umuts Unterlagen (Alpaka-Fotos, PQ-Logo, Galabau-Signet, PQ-Bescheinigung und Freistellungsbescheinigungen als PDF, unter festen Namen) nach `var/huefner-assets` und schneidet den Baum aus dem Logo als Seitensymbol (`scripts/build-huefner-assets.php`; ohne Argumente nimmt es die schon vorbereiteten Dateien), wirft die Installation weg, baut mit `scripts/seed-huefner.php` Gestaltung, Kopf- und Fußbereich, Startseite, Alpakaseite, Impressum, Datenschutz, 404, Menü, Einstellungen und das Formular `anfrage` auf und holt Barlow und Barlow Condensed auf den eigenen Server und schreibt zuletzt das Inhaltspaket `var/huefner-paket.zip` für die Live-Seite (dort unter System importieren, Konten bleiben). Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8103 CMS_PATHS=/,/alpakas CMS_AUDIT_DIR=reports/lighthouse-huefner-termin node scripts/audit.mjs`. Die Startseite liegt knapp unter dem HTML-Budget von 150 KB aus `test:browser`: wiederkehrende Bausteine tragen ihre Gestaltung deshalb am Behälter. Die bewussten Abweichungen vom Entwurf stehen im Kopf des Skripts.
Zusätzliche Prüfung mit den lokalen Kant-IT-Inhalten: `node scripts/admin-quality-check.mjs` (PHP über `PHP_BINARY` wählbar). Das Skript benötigt `var/kantit.db` und `var/kantit-media`, erstellt eine eigene Datenbankkopie und ein zufälliges Testkonto darin; es ändert die Pilotdatenbank nicht. Screenshots und Bericht landen in `reports/admin-quality/`.
+1 -1
View File
@@ -1 +1 @@
1.26.0
1.35.1
+9
View File
@@ -0,0 +1,9 @@
# Dieser Ordner gehoert nicht ins Netz (ADR 116). Zeigt die Domain richtig auf public/, wird diese
# Datei nie gelesen; zeigt sie versehentlich auf den Projektordner, sperrt sie hier alles.
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
+9
View File
@@ -0,0 +1,9 @@
# Dieser Ordner gehoert nicht ins Netz (ADR 116). Zeigt die Domain richtig auf public/, wird diese
# Datei nie gelesen; zeigt sie versehentlich auf den Projektordner, sperrt sie hier alles.
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
+1
View File
@@ -2,3 +2,4 @@ framework:
mailer:
# Tests never send real mail; the transport collects messages in memory instead.
dsn: 'null://null'
+9
View File
@@ -0,0 +1,9 @@
# Dieser Ordner gehoert nicht ins Netz (ADR 116). Zeigt die Domain richtig auf public/, wird diese
# Datei nie gelesen; zeigt sie versehentlich auf den Projektordner, sperrt sie hier alles.
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
+24
View File
@@ -0,0 +1,24 @@
# Schutz fuer den Fall, dass die Domain auf den Projektordner zeigt statt auf public/ (ADR 116).
#
# Bewusst kein "alles sperren": Apache wendet diese Datei auch auf public/ an, und dort soll alles
# wie gewohnt laufen. Gesperrt werden nur Dateien, die es in public/ nicht gibt oder die
# public/.htaccess ohnehin sperrt. Die Ordner var/, vendor/, config/, src/ usw. tragen eine eigene
# Sperrdatei.
<FilesMatch "^\.|\.(env|db|sqlite|sqlite3|lock|md|yaml|yml|dist|log|key)$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
</FilesMatch>
<FilesMatch "^(composer\.json|VERSION|cli-config\.php|migrations\.php)$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
</FilesMatch>
+38
View File
@@ -10,6 +10,10 @@ parameters:
# das Projekt schreiben, und eine Installation mit schreibgeschuetztem public/ ebenfalls.
kant.fonts_dir: '%kernel.project_dir%/public/assets/fonts'
kant.asset_dir: '%kernel.project_dir%/public/assets'
kant.mail_log: '%kernel.project_dir%/var/log/mail.log'
kant.mailer_dsn: 'sendmail://default'
# Die Einstellung, die die Verwaltung anzeigt und ausprobiert; Tests setzen sie fest.
kant.mail_dsn: '%env(default:kant.mailer_dsn:MAILER_DSN)%'
services:
_defaults:
@@ -59,10 +63,19 @@ services:
App\Content\StyleBundle:
arguments:
$assetDirectory: '%env(default:kant.asset_dir:ASSET_DIRECTORY)%'
# Versand und Protokoll (ADR 121). MAIL_LOG nur fuer Tests, damit sie nicht ins echte var/log
# schreiben.
App\Ops\MailCheck:
arguments:
$dsn: '%kant.mail_dsn%'
$logFile: '%env(default:kant.mail_log:MAIL_LOG)%'
App\Controller\FormController:
arguments:
$secret: '%kernel.secret%'
$siteOrigin: '%env(SITE_ORIGIN)%'
App\EventSubscriber\ErrorLogSubscriber:
arguments:
$debug: '%kernel.debug%'
App\Controller\SeoController:
arguments:
$siteOrigin: '%env(SITE_ORIGIN)%'
@@ -70,6 +83,10 @@ services:
App\Command\HealthCommand:
arguments:
$projectDir: '%kernel.project_dir%'
App\Controller\MailController:
arguments:
$projectDir: '%kernel.project_dir%'
$siteOrigin: '%env(SITE_ORIGIN)%'
App\Controller\SystemController:
arguments:
$adminPath: '%kant.admin_path%'
@@ -87,6 +104,14 @@ services:
arguments:
$secret: '%kernel.secret%'
$download: null
# HTTPS erzwingen und HSTS (ADR 120); die Probe ueber http kommt im Test als Closure herein.
App\Ops\HttpsRedirect:
arguments:
$projectDir: '%kernel.project_dir%'
$httpProbe: null
App\Command\HttpsCommand:
arguments:
$siteOrigin: '%env(SITE_ORIGIN)%'
App\Command\SiteOriginCommand:
arguments:
$projectDir: '%kernel.project_dir%'
@@ -115,6 +140,10 @@ services:
$root: '%kernel.project_dir%'
$databaseUrl: '%env(projectdir:DATABASE_URL)%'
$mediaDirectory: '%env(projectdir:MEDIA_DIRECTORY)%'
App\Ops\SitePackage:
arguments:
$root: '%kernel.project_dir%'
$mediaDirectory: '%env(projectdir:MEDIA_DIRECTORY)%'
# Der Aufgabenplan. Die Aufgaben kommen ueber ihr Etikett herein, damit diese Stelle nicht
# jedes Mal mitwaechst.
App\Ops\Scheduler:
@@ -151,3 +180,12 @@ services:
App\Twig\PluginExtension:
arguments:
$loader: '@twig.loader.native_filesystem'
# Tests: das Versandprotokoll gehoert nicht nach var/log der echten Installation, und die
# Verwaltung soll einen festen SMTP-Weg als eingestellt sehen. Verschickt wird trotzdem ueber
# null://null (config/packages/test/mailer.yaml), weil derselbe Weg den Transport des
# Frameworks nimmt. Hier und nicht in config/packages/test, weil diese Datei danach geladen wird.
when@test:
parameters:
kant.mail_log: '%kernel.cache_dir%/mail.log'
kant.mail_dsn: 'smtp://cms.test:587'
+166 -1
View File
@@ -1,6 +1,171 @@
# Architekturentscheidungen
Stand: 15.09.2026. Arbeitstitel KantSites; Produktname offen.
Stand: 12.10.2026. Arbeitstitel KantSites; Produktname offen.
## ADR 122 – E-Mail-Versand als eigene Seite, Probe nicht mehr unter /.well-known (12.10.2026)
Anlass: huefner-gartenbau.de bei IONOS. Mit 1.34.0 zeigte die Testmail, dass `sendmail -bs` dort abbricht und `native://default` (PHP `mail()`) geht, solange der Absender zu einer Domain des Vertrags gehört. Umut: „Können wir die Einstellungsmethoden ins Backend verlagern? Dass man dort alle Methoden probiert und auch SMTP setzt?“ – und „bau es in ein Untermenü ein, nicht System vollstopfen“. Gleichzeitig scheiterte „HTTPS erzwingen“ mit „antwortet mit 403“, obwohl HTTPS lief.
**E-Mail-Versand.** Eigene Seite `/admin/system/e-mail` (`MailController`), im Menü als Unterpunkt von „System“; die Karte auf der Systemseite ist weg. Wege: SMTP mit Postfach (Server, Port, Benutzer, Passwort), PHP `mail()`, `sendmail -t`, `sendmail -bs`. „Diesen Weg testen“ schickt eine Testmail über den gewählten Weg, ohne ihn zu speichern; „Alle Wege testen“ probiert alle Wege des Servers und – wenn eingetragen – SMTP nacheinander; „Diesen Weg speichern“ schreibt `MAILER_DSN` über `Environment::write` in `.env` (nur wenn sie dort steht, nicht aus der Server-Umgebung). `MailCheck::smtpDsn` baut die DSN mit URL-kodiertem Benutzer und Passwort, Port 465 wird `smtps`. Das Passwort steht nie im Formular: leer gelassen gilt das gespeicherte, solange Server und Benutzer gleich bleiben. Derselbe Weg wie eingestellt nutzt den Transport des Frameworks, ein anderer wird mit `Transport::fromDsn` gebaut. Hinweise neu: fremder Absender bei Server-Versand („Sender address is not allowed“) und bei sendmail-Fehlern zuerst `mail()`.
**Probe.** `OriginCheck::PATH` ist jetzt `/kantsites-origin` statt `/.well-known/kantsites-origin`. IONOS beantwortet alles unter `/.well-known/` selbst mit 403 (nachgemessen mit und ohne www, http und https); die Anfrage erreicht die Installation nie, deshalb konnten „Adresse speichern“ und „HTTPS erzwingen“ dort nicht gelingen. Die Ausnahme für `.well-known` in `public/.htaccess` bleibt für Zertifikatsanbieter.
**Folgen.** Die Tests setzen die angezeigte Einstellung über `when@test` in `config/services.yaml` (`kant.mail_dsn`, `kant.mail_log`); in `config/packages/test/` wurden die Parameter von `services.yaml` überschrieben, so dass die Tests von 1.34.0 in das lokale `var/log/mail.log` schrieben (nicht Teil eines Releases). Keine Migration.
## ADR 121 – E-Mail-Versand mit Protokoll und Testmail (12.10.2026)
Anlass: Auf huefner-gartenbau.de (IONOS) standen zwei Anfragen als „Nicht versendet“ in der Verwaltung, und nirgends stand, warum: `FormController` fing die `TransportException` und verwarf sie. Umut: „Bau mal in den Backend einen Knopf ein, Testmail an Mailadresse, wo man einträgt, und wo Logs angezeigt werden, woran es liegt.“
**Entscheidung.** Jeder Versand geht durch `App\Ops\MailCheck`: Formular und Testmail. Die Klasse versendet über den Transport aus `MAILER_DSN`, wirft nie und schreibt je Versuch eine JSON-Zeile nach `var/log/mail.log` (per FTP lesbar, ab 256 KB nach `.1` rotiert): Zeit, Anlass, Empfänger, Absender, Ergebnis, Meldung des Servers, ein Hinweis auf Deutsch für die häufigen Fälle (Anmeldung abgelehnt, Server nicht erreichbar, TLS, sendmail nicht nutzbar, Absender abgelehnt, ungültige DSN, `null://`) und die Antworten des Servers. Aus dem SMTP-Gespräch bleiben nur die Zeilen des Servers (`< `), denn die Zeilen des Clients enthalten bei AUTH LOGIN Benutzer und Passwort in Base64. Die Besucheradresse (Antwortadresse) steht nicht im Protokoll.
**Systemseite.** Neue Karte „E-Mail-Versand“: Versandweg ohne Passwort (Art, Server, Benutzer, Quelle der Einstellung), Feld für eine beliebige Empfängeradresse und einen Absender (vorbelegt mit dem SMTP-Benutzer, wenn er eine Adresse ist), danach Ergebnis, Hinweis und Serverantworten, darunter die letzten zehn Versuche. Nur Administratoren, mit CSRF-Token.
**Folgen.** Der Transport wird träge gebaut (`lazy`), damit eine ungültige `MAILER_DSN` den Versand scheitern lässt und im Protokoll steht, statt die Systemseite selbst. Die Einstellung bleibt in `.env`; ein Feld zum Ändern der DSN in der Verwaltung gibt es bewusst noch nicht (Passwort im Formular). Tests schreiben ihr Protokoll in den Cache (`kant.mail_log`, überschreibbar mit `MAIL_LOG`). Keine Migration; der Grund steht im Protokoll, nicht an der Einsendung.
## ADR 120 – HTTPS erzwingen aus der Verwaltung (12.10.2026)
Anlass: Umut: „HTTPS wird noch nicht erzwungen, wie machen wir das allgemein?“ Bisher hieß das: per FTP die drei auskommentierten Zeilen in `public/.htaccess` einschalten. Die Anwendung selbst erzwingt bewusst nichts (ADR zur Adresse: hinter einem Proxy sieht PHP oft nur `http`, ohne vertrauenswürdige Proxys wäre das eine Schleife).
**Zwei Fehler, beim Bau gefunden und auf einem echten Apache (XAMPP 2.4) nachgestellt.**
1. Seit 1.28.1 stand der HTTPS-Block **hinter** `RewriteRule ^index\.php$ - [L]` und der Regel für `index.php`. Nach dem Umschreiben auf `index.php` beginnt die Runde neu und endet an der ersten Regel, die Umleitung kam für Seiten nie an die Reihe: Mit eingeschalteten Zeilen antworteten `/` und `/leistungen` mit 200, nur echte Dateien (`/x.css`) mit 301. Der Block steht jetzt direkt nach `RewriteBase`; nachgemessen: alle Pfade 301, mit `X-Forwarded-Proto: https` 200 (keine Schleife hinter einem Proxy).
2. `FilesMatch "^\."` sperrte auch `/.well-known/…`: Apache prüft den ersten nicht vorhandenen Pfadteil gegen die Regel. Die Probe `/.well-known/kantsites-origin` antwortete deshalb unter Apache mit 403 – auch die Prüfung bei „Adresse speichern“ konnte dort nie gelingen. Ausgenommen ist jetzt genau `.well-known` (`^\.(?!well-known$)`); `.env`, `.htaccess`, `.htpasswd` bleiben 403 (nachgemessen).
**Entscheidung.** `App\Ops\HttpsRedirect` schaltet die vorhandenen Zeilen ein und aus, unter System („HTTPS“) und als `cms:https [an|aus|hsts-an|hsts-aus]`:
- Einschalten nur mit `SITE_ORIGIN` auf `https://` und nur, wenn die Installation dort vorher mit gültigem Zertifikat ankommt (`OriginCheck`, dieselbe Kennungsprobe wie beim Adresswechsel).
- Danach dieselbe Probe noch einmal. Kommt sie nicht mehr an, ist es eine Schleife (der Server meldet HTTPS nicht und setzt keinen Proxy-Kopf): Die Datei wird sofort zurückgeschrieben.
- Dann `http://<Adresse>` ohne Weiterleitungen folgen: 301/308 auf genau die https-Adresse ist der Erfolg. Antwortet http anders, liest der Server die Regel nicht (mod_rewrite, AllowOverride) – die Zeilen schaden nicht, die Meldung verweist auf die Weiterleitung des Hosters. Erreicht der Server sich selbst nicht über http, sagt die Meldung das statt zu raten.
- HSTS ist ein eigener zweiter Schalter, nur bei eingeschalteter Umleitung und funktionierendem HTTPS, mit Warnung: Ein Browser hält den Kopf ein Jahr. „Umleitung ausschalten“ nimmt HSTS mit.
- Darf PHP die Datei nicht schreiben oder fehlen die Zeilen, zeigt System, was von Hand zu tun ist.
**Updates.** Die Zusammenführung, die eingeschaltete Zeilen in die neue `.htaccess` mitnimmt, steht jetzt in `HttpsRedirect::keepEnabled()` und gilt für beide Wege: das Archiv-Update wie bisher, und neu der Git-Updater, der die Datei vorher mit `checkout --force` überschrieb. Eine geänderte `public/.htaccess` zählt im Git-Weg nicht mehr als lokale Änderung, die das Update verweigert. Weil die Zeilen nach Inhalt und nicht nach Position gefunden werden, landet eine von Hand eingeschaltete Umleitung beim Update an der neuen, richtigen Stelle.
**Folgen.** Keine Migration. Installationen, die die Zeilen schon von Hand eingeschaltet haben, leiteten bisher nur Dateien um; mit dem Update auf 1.33.0 leiten sie alles um.
## ADR 119 – Das Menü schließt nach einer Wahl (12.10.2026)
Anlass: Umut: „Wenn man Links zu Ankern, also #kontakt, im Menü klickt, dann schließt sich das Menü nicht beim Mobilmenü. Also immer schließen, wenn man ausgewählt hat, egal was.“
**Ursache.** Das aufklappbare Menü ist ein `details` (ADR 117: öffnet ohne Skript). Ein Sprungziel auf derselben Seite lädt nicht neu, also bleibt `details` offen und liegt über dem Ziel. CSS kann ein `details` bei einem Linkklick nicht schließen.
**Entscheidung.** `scrolled.js`, das kleine Skript des Kopfbereichs, schließt das Menü beim Klick auf einen Link darin (`initMenus`). Es kommt nur dorthin, wo es gebraucht wird: wenn ein aufklappbares Menü (Burger oder waagerecht mit Zusammenklappen) einen Link auf ein Sprungziel **dieser** Seite trägt (`#…` oder der eigene Pfad mit `#…`). Ein Link auf eine andere Seite braucht nichts: Die Seite lädt neu, und weil öffentliche Seiten `no-store` senden, stellt „Zurück“ kein offenes Menü aus dem Seiten-Cache her (im Browser geprüft). So bleibt jede Seite ohne solche Sprünge bei `script-src 'none'`, und auf Seiten, die `scrolled.js` ohnehin laden (Nach-oben-Knopf, Kopf mit Scrollfarben), kommt kein Skript dazu.
**Geprüft:** Im Browser auf Hüfner (iPhone): „Kontakt“, „Angebot anfragen“ und „Leistungen“ schließen das Menü und springen; „Alpakas“ und „Zurück“ zeigen es geschlossen. Die bestehenden Tests, die `script-src 'none'` auf Seiten mit aufklappbarem Menü ohne Sprungziele verlangen, bleiben unverändert grün.
## ADR 118 – Inhaltspakete: Export und Import ohne die Konten (12.10.2026)
Anlass: Umut: „Können wir sowas wie einen Importer einbauen, weil sich nach jeder Anpassung sonst das Passwort ändert? Wo man auswählen muss, ob Passwort/Benutzer überschrieben werden sollen. Gerne auch als Plugin.“ Bisher wurde eine lokal gebaute Website als ganze SQLite-Datei hochgeladen – samt Konten (neues Passwort nach jedem Neuaufbau) und ohne das, was nur live entstanden war (Anfragen, Statistik).
**Entscheidung: im Kern, nicht als Plugin.** Plugins führen bewusst keinen eigenen Servercode aus (ADR zum Pluginsystem; serverseitiger Code nur mit Isolation). Ein Import schreibt Datenbank und Medienordner – das gehört in den Kern, unter System, nur für Administratoren.
**Paket.** Zip mit `paket.json` (Art, Format 1, Fassung, Website, Konten ja/nein, Zahlen, SHA-256 von `inhalt.json`), `inhalt.json` (Zeilen je Tabelle) und `medien/` (Bildvarianten, PDFs, erzeugte Seitensymbole). JSON statt SQLite-Datei: Ein Paket geht von SQLite nach MySQL und zurück, und jede Zeile wird vor dem Schreiben geprüft. Inhalt: `page`, `revision`, `partial`, `menu`, `media`, `document`, `form`, `redirect`, `term`, `page_term`, `page_translation` und die Website (Name, Sprache, Gestaltung, Einstellungen). **Nicht** darin: Anfragen, Statistik, Aufgaben, Plugin-Stand, Migrationsliste und die Zugangsdaten zu Diensten (`turnstile`, `turnstileSiteKey`, `turnstileSecret`). Konten nur mit „Konten mitnehmen“ beim Export.
**Import (`App\Ops\SitePackage::import`).** Prüfen vor Schreiben: nur bekannte Einträge im Zip (keine Pfade nach oben, keine anderen Dateitypen, Größengrenzen gegen Zip-Bomben), Prüfsumme von `inhalt.json`, nur bekannte Tabellen und Spaltennamen, jedes Seiten- und Bereichsdokument durch `DocumentValidator`, Paket nicht aus einer neueren Fassung. Dann Sicherung (`vor Import`), Wartungszustand, Inhalte in einer Transaktion ersetzen (nur Spalten, die es hier gibt – ein älteres Paket passt so in ein neueres Schema), Website-Einstellungen übernehmen außer den Dienst-Zugangsdaten, Anfragen über die Formularadresse dem neuen Formular zuordnen, Medienordner ersetzen. **Konten nur mit eigenem Häkchen** („Konten und Passwörter aus dem Paket übernehmen“); ohne bleiben Benutzer und Passwörter der Website. Ein Häkchen bei einem Paket ohne Konten bricht ab, ohne etwas zu ändern.
**Oberfläche.** System → „Inhalte übertragen“: Export als Download (Datei wird nach dem Senden gelöscht), Import per Upload oder aus `var/pakete/` (für Pakete über der Upload-Grenze des Hostings, die angezeigt wird), Bestätigungshäkchen, Ergebnisseite mit den Schritten und einem Hinweis, falls Schriften der neuen Gestaltung auf dem Server fehlen. Konsole: `cms:paket:export [--mit-benutzern] [--ziel=…]`, `cms:paket:import <datei> [--mit-benutzern] [--ja]`.
**Folgen.** Das Zurückspielen einer Sicherung bleibt Konsolensache; der Import ist der Weg für Inhalte aus einer anderen Installation, mit eigener Sicherung davor. Reset-Skripte können ein Paket schreiben (zuerst `reset-huefner.sh` → `var/huefner-paket.zip`). Keine Migration.
## ADR 117 – Menü schließt beim Klick daneben, Netzwerk-Symbole, schlankere Symbole (12.10.2026)
Anlass: Umut: „Bei Mobilmenü, wenn es aufgeklappt ist, schließt es nicht, wenn man irgendwo anders hinklickt.“ Und für Hüfner: Facebook, Instagram und „Angebot anfragen“ als Menü-Buttons statt eigener Knöpfe im Kopf.
**Entscheidungen.**
1. **Klick daneben schließt – ohne Skript.** Das Menü bleibt ein `details` (es soll auf jeder Seite ohne Skript aufgehen). Solange es offen ist, legt `summary::before` eine unsichtbare, feste Fläche über die Seite (`z-index:29`); sie gehört zum Knopf, ein Klick darauf schließt also. Die Menüfläche liegt darüber (`z-index:30`; außerhalb des Kopfbereichs per `:where()` mit niedriger Spezifität, damit die Platzierung im Kopf gewinnt). Der erste Klick daneben schließt nur und löst nichts darunter aus – wie bei jedem aufgeklappten Menü. Escape schließt weiterhin nicht (das bräuchte ein Skript). Grenze: Hat ein Vorfahr des Menüs `transform`/`filter`, deckt die feste Fläche nur diesen Vorfahren ab.
2. **Facebook, Instagram, WhatsApp im Symbolsatz.** Dieselben Strichformen wie `social-icon.html.twig`, als Pfad in `icon.html.twig`, `icons.js` (Reiter „Soziale Netzwerke“) und `DocumentValidator::ICONS`. Damit sind sie an Menü-Buttons, Buttons, Symbolkarten und Symbolen wählbar. Keine Schemaänderung; ein Dokument mit diesen Symbolen braucht aber einen Kern ab 1.31.0.
3. **Strichattribute ins Stylesheet.** Jedes Symbol trug `fill`, `stroke`, `stroke-width`, `stroke-linecap` und `stroke-linejoin` als Attribute, rund 105 Zeichen, bei etwa 40 Symbolen je Seite gut 4 KB HTML. Jetzt steht das einmal als `svg.block-icon{…}` in `site.css` und `admin.css`; `width`/`height` bleiben am Element, damit nichts ohne Stylesheet riesig wird. Anlass war die Hüfner-Startseite, die mit den Menü-Buttons das HTML-Budget von 150 KB riss.
**Geprüft:** Eigene Browserprüfung auf iPad (quer) und iPhone: Menü öffnet, Menülinks liegen über der Fläche, Klick daneben schließt, kein waagerechtes Scrollen. `test:menu-buttons` (Menü-Buttons, axe), `test:editor` (Verwaltung mit Symbolen, axe), `npm test` (Symbolsatz in Twig, JS und Validator gleich). `test:responsive-navigation` scheitert im Editorteil am Klick auf den Strukturbaum – ebenso ohne diese Änderungen (Gegenprobe mit zurückgesetzten Dateien), also vorbestehend.
## ADR 116 – Härtung nach dem Sicherheitscheck (12.10.2026)
Anlass: Umut: „Kannst du mal einen Sicherheitscheck für KantSites machen? Das Ding muss bombensicher sein.“ Fünf getrennte Prüfungen (Anmeldung/Rollen, öffentliche Eingaben, XSS/Ausgabe, Uploads/Dateien, Installer/Updater/Betrieb) plus ein Blick von außen auf die Live-Seiten. Keine ausnutzbare Lücke ohne Anmeldung bei korrekt eingerichteter Installation; behoben wird, was übrig blieb.
**Entscheidungen.**
1. **Installationsschlüssel.** Der Installer war bis zum ersten Konto für jeden offen – wer eine frisch entpackte Installation zuerst aufrief, konnte sie übernehmen (und vorher über den MySQL-Host fremde Rechner abtasten). Jetzt öffnet sich kein Schritt ohne einen 128-Bit-Schlüssel in `var/install.key`. `install.php` legt ihn an und zeigt ihn nur auf seiner Abschlussseite mit fertigem Verweis (`/install/schluessel?schluessel=…`); wer per FTP entpackt, liest die Datei. Im Browser bleibt ein Cookie `kant_install` mit einem HMAC über den Schlüssel (HttpOnly, SameSite=Strict, Pfad `/install`), nie der Schlüssel selbst. Mit dem ersten Konto wird die Datei gelöscht; das Anlegen des ersten Kontos läuft in einer Transaktion. Restrisiko: Wer `install.php` vor dem Betreiber aufruft, sieht den Schlüssel – das Fenster ist die Zeit zwischen Hochladen und Aufrufen.
2. **Sperrdateien.** Zeigte die Domain auf den Projektordner statt auf `public/`, waren `.env`, Datenbank, Sicherungen und Protokolle abrufbar; die Sperren standen nur in `public/.htaccess`. Jetzt sperren `.htaccess`-Dateien `bin/`, `config/`, `migrations/`, `plugins/`, `src/`, `templates/`, `vendor/` (Buildskript) und `var/` (legt die Website selbst an, `App\Ops\Protection`), dazu im Projektstamm gezielt Dotfiles, `env|db|sqlite|lock|md|yaml|yml|dist|log|key` und `composer.json`, `VERSION`, `cli-config.php`, `migrations.php`. Bewusst **kein** pauschales „alles sperren“ im Projektstamm: Apache wendet die Datei auch auf `public/` an, ein Update hätte jede Seite mit 403 lahmlegen können. Eine eigene `.htaccess` des Betreibers im Projektstamm bleibt unberührt; fehlende Dateien trägt die Website beim ersten Aufruf nach (auch nach einem Update von einer Fassung, deren Updater sie nicht kannte). Vorlagen: `config/schutz/`.
3. **Sektionen nach ihrem eigenen Schema-Stand.** Eine verknüpfte Sektion wurde mit den Ausgabeflags der Seite gerendert. Eine Sektion mit Schema unter v12 (deren Text nicht gefiltert wird) gab so in einer neueren Seite ungefiltertes HTML aus – ein Publisher konnte `<meta refresh>` oder fremde Links setzen, was sonst nur der Admin darf. Jetzt rechnet die Vorlage mit `section.schemaVersion`; neue Sektionen beginnen im aktuellen Schema.
4. **Redakteure ändern nichts Veröffentlichtes.** Adresse verschieben, Papierkorb (jeweils samt Unterseiten), Kopf/Fuß wählen und Beitragsdaten (robots, SEO-Titel, Datum …) wirkten sofort öffentlich und brauchten nur ROLE_EDITOR. Jetzt verlangen sie ROLE_PUBLISHER, sobald die Seite (bzw. eine Unterseite) veröffentlicht ist; Entwürfe bleiben frei.
5. **Geplante Veröffentlichung mit fester Fassung.** Zum Termin ging der neueste Entwurf online, auch einer, den ein Redakteur nach dem Planen gespeichert hatte. Jetzt speichert `schedule()` die damalige Fassung in `page.publish_revision_id` (Migration `20261012000100`, eine Spalte); `publishDue()` veröffentlicht genau sie. Planungen ohne Fassung (aus älteren Versionen) veröffentlichen weiterhin den Entwurf.
6. **Formulare.** Das Zeitsiegel ist an das Formular und den Zweck gebunden (`FormRepository::stamp`). Die Grenze je Absender zählt bei IPv6 das /64-Netz. Neu: höchstens 30 gespeicherte Anfragen je Formular und 120 insgesamt je Stunde; darüber wird nichts gespeichert und nichts verschickt – sichtbar mit Hinweis aufs Telefon statt still, damit kein echter Besucher glaubt, seine Anfrage sei angekommen.
7. **Kleinere Härtungen.** Einwilligungs-Rücksprung ohne Steuerzeichen und Backslash (Tabulator machte aus `/<Tab>/fremd` eine offene Weiterleitung). Update-Ziel nur in der Form `1.2.3` bzw. `v1.2.3`, `git checkout` mit `--`. Eigenes CSS: keine Geschwister des Blocks selbst (`& ~`, `& +`), Anführungszeichen paarweise, keine Kommentarzeichen in Werten; bereits gespeichertes CSS, das das verletzt, entfällt beim Ausgeben still, statt die Seite zu brechen.
**Nicht umgesetzt, mit Grund.** Signierte Releases (Umut: später); dann gehört ein Offline-Schlüssel dazu. Antwortzeit beim Login für unbekannte Benutzer, Trusted Proxies, Turnstile-Hostname, `%PDF-` ab Byte 0, archivierte PDFs, Statistik-Obergrenzen, MySQL-Passwort in der Prozessliste: niedrig, offen in STATUS.
**Folgen.** Neuinstallationen brauchen den Schlüssel; die Doku sagt, wo er steht. Bestehende Installationen merken nur die Sperrdateien (keine Wirkung bei korrekter Einrichtung) und die engeren Rollen. Migration läuft mit dem Updater. Ausgeliefert mit 1.30.0.
## ADR 115 – Einwilligung ohne Neuladen (12.10.2026)
Anlass: Umut auf der Hüfner-Seite: „dieses Einwilligen, da lädt die Seite komplett neu. Bekommen wir das auch ohne hin?“ Hinweis und Knopf am eingebetteten Inhalt waren reine Formulare (ADR zur Einwilligung: kein Skript), der Server setzte das Cookie und leitete zurück.
**Entscheidung.** Das Formular bleibt der Weg, ein kleines Skript `public/assets/consent.js` verbessert ihn. Es schickt dasselbe Formular im Hintergrund an `/einwilligung` mit der Kopfzeile `X-Kant-Consent: 1`; der Server antwortet dann mit 204 und dem Cookie statt mit der Weiterleitung (Herkunftsprüfung unverändert). Wurde etwas erlaubt, holt das Skript die Seite noch einmal und übernimmt daraus nur die eingebetteten Inhalte und die Zeile „Rechtliches“ (dort steht jetzt „Einwilligung ändern“); dann verschwindet der Hinweis. Der Fokus geht an den eingesetzten Rahmen bzw. an `#main`. Schlägt etwas fehl, sendet das Skript das Formular normal ab. Die Adresse eines Inhalts steht weiterhin erst nach der Einwilligung im HTML – kein `data-src` im Platzhalter.
**Was sich dafür ändert.** (1) Die Sicherheitsrichtlinie nennt die Hosts aller eingebetteten Inhalte der Seite in `frame-src`, auch solange die Einwilligung fehlt: eine Richtlinie steht beim Laden fest, ein nachträglich eingesetzter Rahmen bräuchte sonst ein Neuladen. Die Erlaubnis lädt nichts. (2) Seiten mit offener Frage (Hinweis offen oder ein Inhalt wartet) senden `script-src 'self'` und laden `consent.js`; nach der Entscheidung ohne wartende Inhalte wieder `script-src 'none'`. (3) Das Skriptbudget von `test:browser` (3 je Seite) zählt `consent.js` nicht mit, weil es nur bis zur Entscheidung da ist.
**Folgen.** Ohne JavaScript bleibt alles wie bisher. Andere Vorlagen als `PageController` (falls sie Einbettungen zeigen) laden das Skript nicht und bleiben beim Neuladen. Ausgeliefert mit 1.29.0.
## ADR 114 – Die Umschreibregel fängt index.php selbst ab (12.10.2026)
Anlass: huefner-gartenbau.de bei IONOS (PHP 8.4.26) antwortete auf `/`, `/install` und `/assets/` mit einem weißen 500er von Apache. Die Notfallschicht (ADR 113) griff nicht, weil PHP gar nicht erreicht wurde. `/index.php/install` lieferte dagegen 200, auch mit der `.htaccess` – die Datei wurde also gelesen, der Fehler lag in der Umschreibregel. Vermutete Ursache: `RewriteCond %{REQUEST_FILENAME} !-f` erkennt dort die bereits umgeschriebene `index.php` nicht als Datei, die Regel greift erneut, Apache bricht nach zehn internen Umleitungen ab.
**Entscheidung.** `public/.htaccess` setzt `RewriteBase /` und beendet die Umschreibung für `index.php` selbst (`RewriteRule ^index\.php$ - [L]`), wie die `.htaccess` von WordPress. Auf dem Hosting von Umut mit beiden Zeilen bestätigt; welche der beiden allein genügt hätte, ist nicht geprüft. `CmsTest` sichert die Zeilen ab.
**Folgen.** `RewriteBase /` setzt voraus, dass die Domain auf `public/` zeigt – das war schon Bedingung, eine Installation in einem Unterordner einer Domain ist nicht vorgesehen. Bestehende Installationen bekommen die Zeilen mit dem nächsten Archivupdate, weil der Updater die `.htaccess` des Releases übernimmt (nur eingeschaltete HTTPS-/HSTS-Zeilen werden erhalten). `Options -Indexes -MultiViews` verträgt IONOS: auf huefner-gartenbau.de wieder eingeschaltet, die Seite lädt weiter. Ausgeliefert mit 1.28.1.
## ADR 113 – Kein leerer 500er mehr, Update mit einem Klick nach reiseSoft (09.10.2026)
Anlass: Auf kantarci-reisen.de wurde die Datenbank per FTP ersetzt, ohne vorher den Code zu aktualisieren – danach antwortete jede Adresse mit einer leeren 500-Seite, und nirgends stand, warum. Umut: „Ich meine, dass es nicht Fehler 500 einfach so gibt.“ Und zum Updater: suchen, dann einspielen, dann migrieren soll *ein* Vorgang sein, den man beim Aufruf der Systemseite sieht – „mach es wie bei reiseSoft“.
**Notfallschicht.** `src/Ops/Notfall.php` hängt an nichts außer PHP und wird in `public/index.php` vor dem Bootstrap geladen. Sie fängt Ausnahmen, die bis zum Front-Controller durchschlagen, und schwere Fehler (Shutdown-Handler: Parse-, Compile-, Speicherfehler), schreibt eine Zeile mit Zeit, Adresse, Fehler und bis zu drei Ursachen nach `var/log/notfall.log` (ab 1 MB nach `.1` rotiert) und gibt eine schlichte Wartungsseite mit 503 und `Retry-After: 300` aus. Damit ist die Datei per FTP lesbar, auch wenn Symfony gar nicht startet – das ist auf geteiltem Hosting oft der einzige Zugang. 503 statt 500, damit Suchmaschinen „gleich wieder da“ verstehen. Einzelheiten stehen nur im Protokoll, nie auf der Seite.
**Fehler innerhalb des Frameworks.** `ErrorLogSubscriber` hört auf `kernel.exception` mit Priorität −64: nach der Sicherheitsschicht (sonst wird „Zugriff verweigert“ zur Wartungsseite statt zur Anmeldung – so schlug `CmsTest` an), vor der Fehlerseite des Frameworks (−128). Er übergeht HTTP-Fehler unter 500 sowie Anmelde-, Zugriffs- und Abmeldeausnahmen, protokolliert den Rest und setzt außerhalb des Debugmodus dieselbe Wartungsseite. Im Debugmodus bleibt die ausführliche Seite.
**Update mit einem Klick.** Vorbild ist die Systemseite von reiseSoft: beim Öffnen fragt die Seite selbst nach einer neuen Fassung (`GET /admin/system/update/pruefen`) und zeigt Name, Datum und Hinweise des Release-Eintrags. Ein Knopf, ein Bestätigungsdialog, dann läuft alles durch: `POST …/update/starten` sichert, lädt und prüft das Archiv, schaltet um und endet mit reinem PHP (ADR 112); die Seite ruft danach `POST …/update/abschliessen` mit dem Einmalschlüssel auf – eine neue Anfrage mit dem neuen Code – und der migriert, prüft und schaltet die Wartung aus. Währenddessen fragt sie alle 1,5 s `GET …/update/status` ab und zeigt fünf Schritte: Daten sichern, Fassung laden und prüfen, Umschalten, Datenbank anpassen, Funktion prüfen. Der Fortschritt steht in `var/update/status.json` (Phase, Meldung, von/auf, `bei` = Schritt eines Fehlers; nach 30 Minuten ohne Änderung gilt ein Lauf als abgebrochen).
**Automatisch zurück.** Die Prüfung ist eine Unteranfrage auf `/` mit dem neuen Code; erwartet wird ein Status unter 400, wenn eine Startseite veröffentlicht ist. Scheitern Migration oder Prüfung, kommt die alte Fassung zurück: Code aus `var/update/<lauf>/alt`, überschriebene Dateien in `public/` aus `alt-public` (neu hinzugekommene werden entfernt, Liste in `public.json`), Datenbank aus der Sicherung des Laufs (`Backup::restore`). Erst danach geht die Wartungsseite aus; der Status heißt `zurueckgerollt`, die Seite markiert den gescheiterten Schritt.
**Folgen.** Die Endpunkte verlangen die Administratorrolle, schreibende zusätzlich CSRF (Kopfzeile `X-CSRF-Token`) bzw. den Einmalschlüssel. Ohne JavaScript bleiben die bisherigen Formulare unter „Weitere Möglichkeiten“. Wie ADR 112 wirkt der neue Ablauf erst bei Updates, die *von* einer Fassung mit diesem Code ausgehen; die Notfallschicht wirkt sofort nach dem Einspielen. Geprüft Ende zu Ende mit einer Installation aus dem Releasearchiv: Update 1.27.1 → Testfassung in etwa 8 s mit einem Klick; eine absichtlich kaputte Testfassung wurde vollständig zurückgeschaltet (VERSION, Vorlagen, Datenbank, Wartung aus, Startseite 200).
## ADR 112 – Das Archivupdate läuft in einem Zug durch (09.10.2026)
Umut nach dem Update von kantarci-reisen.de auf 1.27.0: „Update durchgelaufen, direkt danach kam 500, dann bin ich zurück und hab migrieren können.“ Und: „Können wir den Updater nicht besser machen, dass das alles automatisch abläuft, einfach wie bei WordPress?“
**Befund.** Lokal mit dem Releasearchiv 1.26.0 und der Reisen-Datenbank vor der Migration nachgestellt: Alle Schritte bis „Dateien sind ersetzt“ laufen sauber, dann stirbt dieselbe Anfrage mit 500. Der PHP-Prozess hat den alten Code geladen, auf der Platte liegt der neue; was danach noch nachgeladen wird (Antwort-Listener, Sitzung, Vorlagen), passt nicht mehr zum Geladenen. ADR 071 hatte das gesehen und die Migrationen deshalb in eine zweite Anfrage gelegt – aber die erste Anfrage lief nach dem Tausch trotzdem durch das ganze Framework weiter.
**Entscheidung.** Die Anfrage, die tauscht, gibt danach nur noch eine schlichte Seite mit reinem PHP aus (`SystemController::sendAndStop`: Kopfzeilen, Text, `exit`) – kein Framework-Code mehr. Die Seite ruft per `<meta http-equiv="refresh">` sofort `GET /admin/system/abschliessen?token=…` auf. Das ist eine neue Anfrage mit dem neuen Code: Migrationen, Aufräumen, Wartungszustand aus, Weiterleitung zur Systemseite mit „Update auf x.y.z abgeschlossen“. Ein Klick, keine Zwischenseite zum Bedienen, kein 500er.
**Sicherheit.** Der Einmalschlüssel (32 Hexzeichen) steht in `var/update/offen.json` neben Fassung und Zeitpunkt; der Abschluss verlangt ihn und die Administratorrolle (`hash_equals`). Er ersetzt das CSRF-Token, das die Zwischenseite nicht mehr erzeugen kann. Neu laden findet nichts mehr offen und meldet „bereits abgeschlossen“. Der bisherige Knopf „Migrationen ausführen“ bleibt als Rückweg, falls die Weiterleitung unterdrückt wird; daneben steht ein Link.
**Folge.** Wirkt ab Updates, die von einer Fassung mit diesem Code ausgehen: von 1.27.0 auf 1.27.1 läuft noch der alte Weg (einmal 500, dann „Migrationen ausführen“), ab 1.27.1 der neue. Geprüft mit einer echten Installation aus dem Archiv, die auf ein lokal gebautes 1.27.1 aktualisiert (Downloader für die Probe auf lokale Dateien umgestellt): Zwischenseite mit 200, Abschluss 302, Fassung 1.27.1, Wartungszustand aus.
## ADR 111 – Mehrsprachigkeit als Übersetzungsebene, nicht als zweite Seite (Dokumentschema v27, 09.10.2026)
Umut: „Ich füge weitere Sprache hinzu und man wählt aus und muss dann zu jedem Feld die 2. Sprache hinzufügen. Ich möchte nicht für die 2. Sprache die komplette Seite neu bauen.“ Danach: „`/en/`-Präfix und übersetzte Slugs.“ Das Datenmodell hatte seit ADR 003 eine Spalte `locale` an jeder Seite – also das WPML-Prinzip „eine Sprache = eine eigene Seite“. Genau das wollte Umut nicht.
**Entscheidung.** Eine Seite wird einmal gebaut. Je weitere Sprache trägt ihr Dokument unter `translations.<sprache>` nur Texte, adressiert über die stabile Block-ID: `{slug?, title?, description?, seoTitle?, blocks: {<id>: {<eigenschaft>: text}}, source: {<id>.<eigenschaft>: fnv1a32}}`. Welche Eigenschaften übersetzbar sind, steht einmal in `Translations::FIELDS` und gespiegelt in `translate.js` (ein Test vergleicht beide): Texte von Überschrift, Text, Button, Bild (Alt, Unterschrift), Symbolkarte, Kennzahl, Zitat, Aufruf-Band, Kontakt (Anschrift), Akkordeon, Tab, Rechtszeile, Beitragsnavigation, Symbol, Fortschritt, Externer Inhalt. Aufbau, Gestaltung, Bilder und Links gibt es nur einmal.
**Warum im Dokument.** So gehören Übersetzungen zu jeder Fassung: Entwurf, Veröffentlichen, Wiederherstellen und Undo im Editor gelten für alle Sprachen zugleich, ohne zweiten Revisions- und Veröffentlichungsweg. Veraltete Schreibzugriffe erkennt dieselbe Versionsprüfung. Ein gelöschter Block nimmt beim Speichern seine Übersetzungen mit (`Translations::normalise`). Folge: Veröffentlichen gilt für alle Sprachen einer Seite gemeinsam; eine Sprache einzeln zurückhalten geht nur, indem man ihre Texte leert.
**Ausgeliefert wird eine Sprache durch Überlagerung.** `Translations::apply` legt die Texte auf eine Kopie des Dokuments und entfernt `translations`; danach laufen dieselbe Prüfung (`DocumentValidator`, auch je Sprache beim Speichern) und dieselben Vorlagen wie für die Hauptsprache. Kein Renderer muss Sprachen kennen, und eine Übersetzung kann keine Länge, keine Auszeichnungsregel und keine H1-Regel umgehen; formatierter Text geht durch denselben `RichText`-Filter. Ein leeres Feld zeigt den Text der Hauptsprache. Ein dekoratives Bild bekommt auch übersetzt keinen Alternativtext.
**Adressen.** `/en` ist die Startseite der Sprache, `/en/<pfad>` eine übersetzte Seite. Der Pfad besteht aus den übersetzten Adressen der Seite und ihrer Vorfahren; fehlt einem Vorfahren die Übersetzung, steht seine Adresse der Hauptsprache dort. Die Tabelle `page_translation (page_id, locale, slug, path)` (Migration `20261009000100`, neue Tabelle, kein Eingriff in `page`) spiegelt genau den veröffentlichten Stand: Zeilen entstehen und verschwinden beim Veröffentlichen, beim Verschieben einer Seite werden alle Pfade neu berechnet. Doppelte Adressen werden beim Speichern des Entwurfs abgelehnt; eine geänderte veröffentlichte Adresse bekommt eine 301-Weiterleitung (`en/alt` → `en/neu`). Eine Wurzelseite darf nicht wie eine aktive Sprache heißen, und eine Sprache lässt sich nicht einschalten, solange eine Seite ihre Adresse belegt.
**Links.** Auf einer übersetzten Seite zeigen Links auf Seiten, die es in dieser Sprache gibt, auf deren Übersetzung – Buttons, Menüs, Fließtext und Brotkrumen gleichermaßen. Umgesetzt als eine Ersetzung über die fertige Ausgabe (`PageController::localiseLinks`, nur `<a href="/…">`, nur Pfade aus der Zuordnung der Sprache). Links der Sprachwahl tragen `hreflang` und bleiben unberührt.
**SEO.** `<html lang>`, `og:locale` samt `og:locale:alternate`, Canonical auf die übersetzte Adresse, `hreflang` je veröffentlichter Sprache und `x-default` auf die Hauptsprache, die übersetzten Adressen in der Sitemap. Eine Seite ohne Übersetzung bekommt keine Alternativen. Ohne eigenen übersetzten Suchtitel nimmt die Seite den übersetzten Titel, nie den Suchtitel der Hauptsprache.
**Einsprachig bleibt alles wie vorher.** Sprachen stehen unter Einstellungen → Sprachen (`settings.languages`, nur Administratoren). Ohne weitere Sprache gibt es keinen Umschalter, keine Felder, keine `/en`-Route, kein `hreflang`. Wird eine Sprache abgewählt, verschwinden ihre Seiten von der Website; die Übersetzungen bleiben im Dokument.
**Editor.** Sprachumschalter DE | EN neben der Vorschaugröße. In einer weiteren Sprache zeigt die Seitenleiste die Liste aller Texte in Seitenreihenfolge, je mit Original, Feld und Zustand (fehlt / übersetzt / „Original geändert“, erkannt am FNV-1a-Vermerk), Filter, „Original übernehmen“ und „Übersetzung passt“. Dazu Adresse (mit Vorschlag aus dem übersetzten Titel) und Suchtitel. Die Vorschau zeigt die Seite in der Sprache, ein Klick springt zu den Texten des Blocks. Alles, was den Aufbau ändert (Werkzeugkasten, Struktur, Blockaktionen, Einfügepunkte, Ziehen, Spaltenbreiten, Doppelklick-Bearbeitung, Kontextmenü), ist in dieser Ansicht aus.
**Baustein „Sprachwahl“ (`language`, Schema v27).** Zeigt dieselbe Seite in jeder Sprache, in der sie veröffentlicht ist, als Kürzel oder Name; auf einer nur einsprachigen Seite nichts. Kürzel tragen den Sprachnamen für Vorlesesoftware, jeder Link `lang` und `hreflang`, die aktuelle Sprache `aria-current`.
**Stufe 2: Bereiche, Menüs, feste Texte (09.10.2026).** Kopf, Fuß und verknüpfte Sektionen tragen dieselbe Übersetzungsebene im Dokument und werden im selben Editor mit dem Umschalter übersetzt – ohne Adresse und Seitentitel. Da Bereiche keinen Entwurf haben, gilt eine gespeicherte Übersetzung sofort. Ausgeliefert werden sie wie Seiten: in einer weiteren Sprache überlagert, in der Hauptsprache ohne `translations`; ebenso in der Vorschau von Seite und Bereich. Menüeinträge haben keine stabile ID, deshalb trägt der Eintrag selbst seine Beschriftungen: `labels: {en: "Services"}` (je höchstens 80 Zeichen, bekannte Sprache, leer = Hauptsprache). Sie wandern so beim Umsortieren mit. Die Menüverwaltung zeigt je weitere Sprache ein Feld „Beschriftung Englisch“. Feste Texte der öffentlichen Vorlagen (Sprunglink, Menü-Knopf, Namen der Navigationen, Brotkrumen-Start, Rechtslinks, Einwilligung, Beitragslisten, „Nach oben“) gehen durch den Filter `kant_t` mit einem Wörterbuch in `LanguageExtension::UI` – Englisch vollständig, Französisch und Türkisch teilweise, sonst Englisch als Rückfall. Die Sprache der Anfrage steht im Anfrageattribut `kant_locale`.
**Stufe 3: Formulare, Beiträge, Einwilligung, 404 (09.10.2026).** Formulare tragen ihre Übersetzungen in den Einstellungen des Formulars (`settings.translations.<sprache>`: Erfolgsmeldung, Button, je Feld Beschriftung, Hilfetext, Einheit und Optionen als Zuordnung Original → Anzeige); gepflegt am Feld in der Formularverwaltung. Abgeschickt werden weiter die Optionen der Hauptsprache, damit Bedingungen, Prüfung, gespeicherte Einsendungen und die E-Mail an den Empfänger unverändert bleiben. Ein verstecktes Feld `language` lässt die Prüfung ihre Meldungen in der Sprache geben (`FormRepository::MESSAGES`) und führt nach dem Absenden auf die übersetzte Seite zurück; das Schrittskript bekommt seine Texte als `data-texts`. Beiträge sind Seiten und werden wie Seiten übersetzt; Listen, Nachbarn und Archive zeigen übersetzte Titel und Auszüge, nicht übersetzte Beiträge in der Hauptsprache. Archive antworten unter `/en/beitraege`, `/en/kategorie/…`, `/en/schlagwort/…` (Präfixe bleiben deutsch). Namen von Kategorien und Schlagwörtern sowie Überschrift und Text des Einwilligungshinweises stehen je Sprache in den Einstellungen (`termNames`, `consentTexts`, Einstellungen → Sprachen). Eine 404 unter `/en/…` antwortet auf Englisch: die eigene 404-Seite mit ihrer Übersetzung, die mitgelieferte über das Wörterbuch.
**Grenzen.** Feed, Einwilligungsseite `/einwilligung`, Datumsformat und die Präfixe der Archivadressen bleiben in der Hauptsprache. Eigenes HTML und Plugin-Bausteine bleiben unübersetzt. Bilder sind bewusst nicht je Sprache austauschbar (Umut, 09.10.2026). Die Kennzeichnung einzelner nicht übersetzter Texte mit `lang="de"` fehlt.
**Rückweg.** Ältere Kerne lehnen Dokumente mit Schema v27 ab (wie bei jeder Schemaerhöhung); Installationen zuerst aktualisieren. Die Migration lässt sich zurücknehmen (`down` löscht nur `page_translation`); die Übersetzungen bleiben dann im Dokument, sind aber nicht mehr erreichbar.
## ADR 110 – Menüeinträge als Button (07.10.2026)
+1 -1
View File
@@ -20,7 +20,7 @@ Gemessen wurde im Produktionsmodus auf dem PHP-Entwicklungsserver, der dieselben
## Nicht geprüft – muss vor dem Umschalten passieren
1. **E-Mail-Versand.** Nie ausgeführt, in keiner Umgebung. `MAILER_DSN=sendmail://default` funktioniert auf den meisten PHP-Hostings; verlangt der Anbieter SMTP, gehört dort eine Zeile der Form `smtp://benutzer:passwort@mailserver:587` hinein. **Nach dem Hochladen eine echte Anfrage über das Kontaktformular abschicken und prüfen, ob sie ankommt** – auch im Spam-Ordner. Die Anfrage wird vor dem Versand gespeichert; kommt keine Mail an, steht sie trotzdem unter „Formulare“ in der Verwaltung.
2. **HTTPS.** Zertifikat einrichten, dann in `public/.htaccess` die drei Zeilen der HTTPS-Umleitung einschalten. **Erst wenn das eine Weile läuft**, die HSTS-Zeile aktivieren – sie lässt sich in einem Browser ein halbes Jahr lang nicht zurücknehmen.
2. **HTTPS.** Zertifikat einrichten, dann unter System → „HTTPS“ auf „HTTPS erzwingen“ (prüft vorher und nachher, ADR 120; von Hand: die drei Zeilen in `public/.htaccess` einschalten). **Erst wenn das eine Weile läuft**, „HSTS einschalten“ – HSTS lässt sich in einem Browser ein Jahr lang nicht zurücknehmen.
3. **www oder nicht.** Das alte kant-it.de ist kanonisch mit `www`. Die Entscheidung steht an einer Stelle: `SITE_ORIGIN` in `.env` (`php bin/console cms:site:origin https://www.kant-it.de`). Die jeweils andere Schreibweise leitet die Anwendung selbst mit 301 dorthin um; in `.htaccess` gibt es dafür keine Regel mehr. Der Bildschirm „System“ zeigt die eingetragene Adresse und warnt, wenn die Verwaltung gerade unter einer anderen läuft.
4. **Datenbank.** Geprüft ist ausschließlich SQLite. Für MySQL ist alles vorbereitet, aber nichts erprobt – wer MySQL nimmt, sollte danach Seiten anlegen, veröffentlichen, Bilder hochladen und ein Formular absenden.
5. **Wiederkehrende Aufgaben.** Sicherung, Statistik und geplante Veröffentlichung laufen nach dem Hochladen von selbst – angestoßen von Seitenaufrufen und vom geöffneten Backend (Verwaltung → Aufgaben). **Das genügt, ist aber nicht minutengenau:** ohne Besuch passiert nichts. Bietet der Hoster einen Cronjob, gehört `php bin/console cms:tasks:run` dorthin (jede Minute); bietet er nur einen URL-Cronjob, die Adresse vom Bildschirm „Aufgaben“ (alle fünf Minuten). Danach dort den Auslöser „Besuche der Website“ abschalten. **Nach dem Umzug einmal nachsehen**, ob in der Spalte „Zuletzt“ bei der Sicherung ein `ok` steht – ohne Sicherung gibt es keinen Rückweg.
+246
View File
@@ -1,5 +1,251 @@
# Projektstatus
## Nach 1.35.0: E-Mail-Versand und Systemseite aufgeräumt, Protokoll leeren (13.10.2026)
**Anlass:** Umut: E-Mail-Seite und „Inhalte übertragen“ sehen uneinheitlich aus, Unterpunkt „E-Mail-Versand“ steht immer offen, „Logs leeren“ fehlt.
**Erledigt:** Fließtext in Karten hat jetzt durchgehend 13 px (vorher Browser-16 px neben 13 px), Felder bleiben 16 px. Neue gemeinsame Bausteine in `admin.css`: `.card-section` (Abschnitt mit Trennlinie), `.button-row`, `.form-foot`, `.option`/`.option-list` (Auswahl als Karten), `.pill-error`, Dateiauswahl im Knopfstil. E-Mail-Seite: Wege als Karten mit Erklärung, SMTP 2×2, Testmail-Abschnitt, „Speichern“ als Hauptknopf im Kartenfuß (war ungestylt). Protokoll mit Zustandsplakette, Hinweis vorn, Servermeldung zugeklappt (`admin/_mail_entry.html.twig`). „Protokoll leeren“ (`POST /admin/system/e-mail/protokoll`, löscht `mail.log` und `mail.log.1`). Unterpunkt nur im Bereich System sichtbar; in der waagrechten Leiste (761–1000 px) neben „System“.
**Geprüft:** `SystemScreenTest` erweitert (Leeren nur mit Token, danach leer, Editor 403, Unterpunkt auf Einstellungen nicht sichtbar). PHPUnit vollständig 682 grün, php-cs-fixer sauber, `lint:twig`, `test:admin`, `test:editor` grün. Im Browser (Hüfner lokal) bei 1240, 900 und 375 px: E-Mail-Seite, Systemseite, Einstellungen; kein waagrechtes Scrollen am Handy.
**Offen:** nicht veröffentlicht (kein Tag/Release).
## Fassung 1.35.0: E-Mail-Versand als eigene Seite, HTTPS-Probe bei IONOS (12.10.2026)
**Anlass:** huefner-gartenbau.de: mit `native://default` geht der Versand, Umut will die Einstellung samt SMTP in der Verwaltung, als Unterpunkt statt auf „System“. „HTTPS erzwingen“ meldete 403, obwohl HTTPS lief.
**Erledigt (ADR 122):** Seite `/admin/system/e-mail` unter „System“: Weg wählen (SMTP, mail(), sendmail -t, sendmail -bs), testen ohne Speichern, alle Wege nacheinander testen, speichern nach `.env`; Passwort nie im Formular. Karte von der Systemseite entfernt. Probe der Adresse von `/.well-known/kantsites-origin` nach `/kantsites-origin`, weil IONOS `/.well-known/` selbst mit 403 beantwortet. Test-Parameter über `when@test`.
**Geprüft:** `MailCheckTest` (DSN aus Feldern samt Sonderzeichen, Vorbelegung ohne Passwort, Wege ausprobieren über die Fabrik, Hinweis bei fremdem Absender), `SystemScreenTest` (Menüpunkt, Seite, Testen, fehlendes Passwort, Speichern nur aus `.env`, nur Administratoren), `HttpsRedirectTest`. PHPUnit vollständig 682 grün (die bekannte Warnung aus `UpdaterTest`), php-cs-fixer sauber; `var/log/mail.log` entsteht durch Tests nicht mehr. Im Browser auf der Wegwerfkopie: „Alle Wege testen“ zeigt je Weg Ergebnis und Hinweis, SMTP-Felder verschwinden bei anderen Wegen, Handybreite ohne waagrechtes Scrollen, Unterpunkt im Menü markiert. Live auf IONOS ist die neue Probe noch nicht geprüft; dass `/.well-known/` dort 403 liefert und `/index.php/...` durchkommt, ist nachgemessen.
**Veröffentlicht:** Tag `v1.35.0`, Release-Eintrag mit Archiv (3634 Dateien, 5,3 MB, gebaut aus `var/release-source-1.35.0`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt. Keine Migration.
**Offen:** Hüfner auf 1.35.0 aktualisieren, „HTTPS erzwingen“ erneut drücken, SMTP über das zentrale kant-it.de-Postfach einrichten und testen.
## Fassung 1.34.0: Testmail und Versandprotokoll in der Verwaltung (12.10.2026)
**Anlass:** huefner-gartenbau.de: Anfragen „Nicht versendet“, Ursache unbekannt (Ausnahme wurde verschluckt; vermutlich `sendmail://default` bei IONOS).
**Erledigt (ADR 121):** `App\Ops\MailCheck` für Formular und Testmail, Protokoll `var/log/mail.log` mit Meldung, deutschem Hinweis und Serverantworten (ohne Client-Zeilen, also ohne Zugangsdaten). Systemseite: Karte „E-Mail-Versand“ mit Versandweg, Testmail an frei wählbare Adresse samt Absender, letzte zehn Versuche.
**Geprüft:** `MailCheckTest` (Hinweise, Zugangsdaten bleiben draußen, Versandweg ohne Passwort), `SystemScreenTest` (Testmail, ungültige Adresse, Token, nur Administratoren); PHPUnit vollständig 678 grün (eine bekannte Warnung aus `UpdaterTest`, unabhängig davon), php-cs-fixer sauber. Im Browser auf einer Wegwerfkopie der Hüfner-Datenbank mit unerreichbarem SMTP-Server: Fehlermeldung, Hinweis „nicht erreichbar“ und Eintrag unter „Letzte Versuche“ erscheinen. Ein echter Versand über einen echten Mailserver wurde nicht ausgeführt.
**Veröffentlicht:** Tag `v1.34.0`, Release-Eintrag mit Archiv (3632 Dateien, 5,3 MB, gebaut aus `var/release-source-1.34.0`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt. Keine Migration.
**Offen:** Hüfner auf 1.34.0 aktualisieren, `MAILER_DSN` auf SMTP umstellen (zentrales Absenderpostfach bei kant-it.de geplant), dann Testmail.
## Fassung 1.33.0: HTTPS erzwingen aus der Verwaltung (12.10.2026)
**Anlass:** Umut: HTTPS wird noch nicht erzwungen – allgemein lösen.
**Änderung (ADR 120):** System → „HTTPS“ mit „HTTPS erzwingen“, danach „HSTS einschalten“ und „Umleitung ausschalten“; Konsole `cms:https`. Eingeschaltet wird nur nach erfolgreicher Probe über HTTPS, nach dem Einschalten wird erneut geprüft und bei einer Schleife sofort zurückgenommen; die http-Antwort wird gemeldet. Mitbehoben: Der HTTPS-Block in `public/.htaccess` stand hinter der Regel für `index.php` und leitete Seiten nie um (nur Dateien), und `/.well-known/kantsites-origin` antwortete unter Apache mit 403, womit auch „Adresse speichern“ dort scheiterte. Der Git-Updater behält eingeschaltete Zeilen jetzt wie das Archiv-Update.
**Geprüft:** `tests/HttpsRedirectTest.php` (8 Tests: Probe davor/danach, Schleife zurückgenommen, Server ignoriert Regel, HSTS nur nach Umleitung, Update behält, Git-Updater zählt die Datei nicht als Änderung). Auf Apache 2.4 (XAMPP) mit der alten und neuen `.htaccess`: alt `/` und `/leistungen` 200, neu 301, mit `X-Forwarded-Proto: https` 200; `.well-known`-Probe 200, `.env`/`.htaccess`/`.htpasswd` 403; die http-Probe der Klasse erkennt 301, 200 und „keine Verbindung“. Im Browser (Hüfner lokal): Anzeige gesperrt ohne https-Adresse, eingeschalteter Zustand mit HSTS-Hinweis, „Umleitung ausschalten“ stellt die Datei byte-gleich wieder her. Nicht geprüft: der Erfolgsweg gegen ein echtes Zertifikat (lokal kein HTTPS) – das zeigt sich beim ersten Einschalten live.
**Live Hüfner:** nach dem Update auf 1.33.0 unter System → HTTPS „HTTPS erzwingen“ drücken.
## Fassung 1.32.1: Menü schließt nach einer Wahl (12.10.2026)
**Anlass:** Umut: Ein Link auf ein Sprungziel (#kontakt) im Mobilmenü ließ das Menü offen.
**Änderung (ADR 119):** `scrolled.js` schließt das aufgeklappte Menü beim Klick auf einen Link darin. Geladen wird es zusätzlich nur, wenn ein aufklappbares Menü Sprungziele derselben Seite enthält; Seiten ohne solche Links bleiben ohne Skript (Links auf andere Seiten laden neu, `no-store` verhindert ein offenes Menü nach „Zurück“).
**Geprüft:** PHPUnit 665 Tests grün, `npm test` 41 grün; im Browser auf Hüfner (iPhone): „Kontakt“, „Angebot anfragen“, „Leistungen“ schließen das Menü und springen. Bekannt und vorbestehend: `test:responsive-navigation` scheitert am Klick im Editor-Strukturbaum.
**Live Hüfner (12.10.2026):** Umut hat die Datenbank hochgeladen, ohne Medienordner – Bilder antworteten mit 404 (Kennungen vom letzten Neuaufbau). Lösung: `var/huefner-paket.zip` unter System → „Inhalte übertragen“ importieren; bringt die passenden Medien mit.
## Fassung 1.32.0: Inhaltspakete – Export und Import ohne die Konten (12.10.2026)
**Anlass:** Umut: Nach jedem Hochladen einer neu aufgebauten Datenbank galt live ein anderes Passwort; gewünscht ist ein Importer mit der Wahl, ob Benutzer/Passwort überschrieben werden („gerne auch als Plugin“).
**Neu im Kern (ADR 118):** System → „Inhalte übertragen“: Paket exportieren (Download, Konten nur mit Häkchen) und importieren (Upload oder aus `var/pakete/`, Upload-Grenze des Hostings angezeigt). Ersetzt werden Seiten mit Fassungen, Bereiche, Menüs, Medien/PDFs, Formulare, Weiterleitungen, Kategorien, Übersetzungen, Gestaltung und Einstellungen; Anfragen (neu dem Formular zugeordnet), Statistik, Aufgaben, Plugin-Stand und Turnstile-Zugangsdaten bleiben; Konten nur mit eigenem Häkchen. Vorher Sicherung, währenddessen Wartung, jede Datei und jedes Dokument geprüft. Konsole: `cms:paket:export`, `cms:paket:import`. Im Kern statt als Plugin, weil Plugins keinen Servercode ausführen. `reset-huefner.sh` schreibt zuletzt `var/huefner-paket.zip`.
**Geprüft:** PHPUnit vollständig 665 grün (neu `SitePackageTest`: Import ersetzt Inhalte und behält Konten, Anfragen, Dienst-Zugangsdaten; Konten nur mit beiden Häkchen; neuere Fassung abgelehnt; fünf manipulierte Pakete ändern nichts – fremde Datei mit `../`, Skript als Medium, veränderter Inhalt, fremde Tabelle, ungültiges Dokument). `npm test` 41, `lint:twig`, php-cs-fixer. Durchlauf auf der Konsole: Hüfner exportiert und in eine veränderte Kopie importiert (Passwort, Anfrage, Turnstile blieben, Alpakaseite kam zurück, Medienordner ersetzt). Oberfläche in der lokalen Hüfner-Verwaltung: zu großer Upload mit Hinweis abgelehnt, „Konten übernehmen“ bei Paket ohne Konten abgebrochen, Import aus `var/pakete/` abgeschlossen, danach weiter angemeldet. `test:editor` grün inklusive axe der Systemseite – ein erster Lauf parallel zu PHPUnit scheiterte, der Wiederholungslauf allein bestand.
**Veröffentlicht:** Tag `v1.32.0`, Release-Eintrag mit Archiv (3629 Dateien, 5,3 MB, gebaut aus `var/release-source-1.32.0`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt. Keine Migration.
**Nächster Schritt:** huefner-gartenbau.de auf 1.32.0 aktualisieren, dann `var/huefner-paket.zip` unter System → „Inhalte übertragen“ importieren (ohne Konten-Häkchen).
## Fassung 1.31.0: Menü schließt beim Klick daneben, Netzwerk-Symbole; Hüfner-Kopf (12.10.2026)
**Anlass:** Umut: aufgeklapptes Mobilmenü schließt nicht beim Klick daneben; bei Hüfner wirkt der Kopf auf dem iPad verloren (Menü-Knopf mittig), und Facebook, Instagram und „Angebot anfragen“ sollen ins Menü.
**Neu im Kern (ADR 117):** Klick neben das aufgeklappte Menü schließt es – reines CSS (unsichtbare Fläche am Menü-Knopf), kein Skript. Facebook, Instagram und WhatsApp im Symbolsatz (Menü-Buttons, Buttons, Symbole). Die Strichattribute der Symbole stehen jetzt als `svg.block-icon` in `site.css`/`admin.css` statt an jedem Symbol (rund 4 KB weniger HTML je Seite).
**Hüfner (nur Inhalt):** Kopf aus Logo und rechtsbündigem Menü; „Angebot anfragen“, Facebook und Instagram als Menü-Buttons. Desktop zeigt in der Zeile nur „Angebot anfragen“ (F/I stehen in der Leiste darüber), im aufgeklappten Menü stehen alle drei, die Symbole nebeneinander. Menü-Knopf auf iPad und Telefon rechts; Abstand und Schrift schrumpfen knapp über 1024 px mit, damit die Zeile nicht umbricht. Upload-Paket neu (`var/huefner-upload.zip`, braucht 1.31.0).
**Geprüft:** PHPUnit vollständig 657 grün; `npm test` 41; `lint:twig`; `test:menu-buttons` und `test:editor` grün inklusive axe; `test:browser` auf Hüfner grün (HTML Startseite rund 148 KB). Eigene Browserprüfung auf iPad quer und iPhone: öffnen, Menülinks bedienbar, Klick daneben schließt, kein waagerechtes Scrollen; Kopf bei 810, 1025, 1080, 1100, 1280 und 1366 px im Bild. **Veröffentlicht:** Tag `v1.31.0`, Release-Eintrag mit Archiv (3624 Dateien, 5,3 MB, gebaut aus `var/release-source-1.31.0`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt.
**Nächster Schritt:** huefner-gartenbau.de auf 1.31.0 aktualisieren, dann `var/huefner-upload.zip` einspielen (Reihenfolge wichtig: die Datenbank nutzt die neuen Symbole).
**Bekannt:** `test:responsive-navigation` scheitert im Editorteil am Klick auf den Strukturbaum – auch ohne diese Änderungen (Gegenprobe), vorbestehend.
## Fassung 1.30.0: Härtung nach dem Sicherheitscheck (12.10.2026)
**Anlass:** Umut: „Sicherheitscheck für KantSites … das Ding muss bombensicher sein.“ Prüfung in fünf Bereichen plus Blick von außen auf huefner-gartenbau.de und kant-it.de; Ergebnis im Chat vom 12.10.2026, Umsetzung „1.30.0, Signatur später“.
**Behoben (ADR 116):** Installationsschlüssel für den Installer (`var/install.key`, von `install.php` angelegt und angezeigt); Sperrdateien im Projektstamm und in allen internen Ordnern (`var/` legt die Website selbst an); verknüpfte Sektionen nach ihrem eigenen Schema-Stand gerendert (kein ungefiltertes HTML durch alte Sektionen); Redakteure ändern Adresse, Papierkorb, Bereiche und Beitragsdaten veröffentlichter Seiten nicht mehr; geplante Veröffentlichung mit fester Fassung (Migration `20261012000100`); Formular-Siegel je Formular, IPv6 je /64, Obergrenze je Formular und gesamt mit sichtbarem Hinweis; offene Weiterleitung per Tabulator beim Einwilligen; Update-Ziel nur `1.2.3`; eigenes CSS bleibt im Block.
**Geprüft:** PHPUnit vollständig 657 Tests: im Gesamtlauf 656 grün, ein Fehlschlag (`ArchiveUpdateTest`: die neue `.htaccess` im Projektstamm stand weder in der Ersetzungsliste noch in den bewussten Ausnahmen – jetzt als Ausnahme eingetragen, weil sie dem Betreiber gehören kann), danach die betroffenen Dateien (ArchiveUpdate, Cms, Installation, InstallFlow, SingleFileInstaller, Translation; 436 Tests) erneut grün. Neu getestet: Installer nur mit Schlüssel (falscher Schlüssel, gefälschtes Cookie, Verweis aus install.php, Löschen nach dem ersten Konto), Sperrdateien, Sektion mit altem Schema, Redakteur vs. veröffentlichte Seite, geplante Fassung, Formular-Siegel je Formular, IPv6-/64, Flut-Grenze mit Hinweis, Einwilligungs-Rücksprung mit Tabulator/Umbruch/Backslash, CSS-Geschwister/Kommentare. Gegenprobe: der Sektionstest schlägt ohne den Fix an. `npm test` 41, `lint:twig`, php-cs-fixer; `test:browser` auf Hüfner grün; `test:editor` grün inklusive axe; im Browser ein echtes Absenden des Hüfner-Formulars mit neuem Siegel (Testeinsendung danach gelöscht). Hüfner-Upload-Paket mit migrierter Datenbank (`20261012000100`) neu gebaut.
**Veröffentlicht:** Tag `v1.30.0`, Release-Eintrag mit Archiv (5,5 MB, gebaut aus `var/release-source-1.30.0`, enthält alle Sperrdateien inkl. `vendor/.htaccess`), `.sha256` und `install.php` (mit Installationsschlüssel); Prüfsumme nach dem Herunterladen bestätigt. Eine Migration (`20261012000100`), läuft mit dem Updater.
**Nächster Schritt:** Installationen auf 1.30.0 aktualisieren (huefner-gartenbau.de, kant-it.de, weitere); bei Hüfner danach das Upload-Paket einspielen und die HTTPS-Zeilen einschalten.
**Offen (niedrig):** signierte Releases; Login-Antwortzeit für unbekannte Benutzer; Trusted-Proxy-Einstellung für Hosting hinter CDN; Turnstile-Hostname; `%PDF-` ab Byte 0; archivierte PDFs bleiben abrufbar; Statistik ohne Obergrenze für erfundene Verweise; MySQL-Passwort in der Prozessliste bei Sicherungen; Theme-Vorlagen ohne Versionsprüfung. **Live:** huefner-gartenbau.de antwortet auch über `http://` ohne Umleitung und ohne HSTS – die HTTPS-Zeilen in `public/.htaccess` einschalten.
## Fassung 1.29.0: Einwilligung ohne Neuladen (12.10.2026)
**Anlass:** Umut auf der Hüfner-Seite: Beim Einwilligen ins YouTube-Video lädt die Seite komplett neu.
**Neu im Kern (ADR 115):** `public/assets/consent.js` schickt Hinweis und Knopf am eingebetteten Inhalt im Hintergrund ab (`X-Kant-Consent: 1` → 204 mit Cookie statt Weiterleitung), holt die Seite neu und tauscht nur Einbettungen und „Rechtliches“ aus; Fokus an den Rahmen bzw. an den Seitenanfang. Ohne JavaScript und bei Fehlern bleibt das Formular mit Neuladen. `frame-src` nennt die Hosts aller Einbettungen der Seite auch vor der Einwilligung (die Adresse des Inhalts weiterhin erst danach im HTML). Das Skript lädt nur, solange eine Einwilligung offen ist; `test:browser` zählt es nicht zum Budget von drei Skripten.
**Geprüft:** PHPUnit vollständig 644 grün (neu: 204-Antwort mit Cookie, kein Cookie von fremder Herkunft, Weiterleitung ohne Kopfzeile; angepasst: Skript bei offener Frage, `script-src 'none'` nach der Entscheidung, `frame-src` vor der Einwilligung), `npm test` 41, `lint:twig`, php-cs-fixer. Im Browser auf der Hüfner-Kopie: Knopf am Video, „Nur notwendige“ und „Alle akzeptieren“ ohne Neuladen, Fokus aufs Video, ohne JavaScript weiterhin mit Neuladen, keine Konsolenfehler. `test:browser` grün, Lighthouse Produktion Startseite mobil 96, Desktop 98, sonst 100. `test:editor` nicht gelaufen (Editor unverändert, Skript dort nicht eingebunden).
**Veröffentlicht:** Tag `v1.29.0`, Release-Eintrag mit Archiv (3611 Dateien, 5,3 MB, gebaut aus `var/release-source-1.29.0`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt. Keine Migration.
**Nächster Schritt:** Installationen mit Einwilligungshinweis (huefner-gartenbau.de, Immobilien DEEG) auf 1.29.0 aktualisieren.
## Fassung 1.28.1: weißer 500er bei IONOS durch die Umschreibregel (12.10.2026)
**Anlass:** huefner-gartenbau.de bei IONOS zeigte nach der Einrichtung auf jeder Adresse einen weißen 500er.
**Ursache und Änderung (ADR 114):** PHP 8.4.26 lief (`test.php`), `/index.php/install` lieferte 200, nur umgeschriebene Adressen scheiterten – Umschreibschleife in `public/.htaccess`. Neu im Kern: `RewriteBase /` und `RewriteRule ^index\.php$ - [L]` vor der `!-f`-Regel; Test in `CmsTest::testEveryPublicAnswerCarriesItsProtectiveHeaders`.
**Geprüft:** Umut hat beide Zeilen auf IONOS eingetragen, danach lädt `/install`; auch mit eingeschalteter `Options`-Zeile. PHPUnit vollständig: 643 Tests grün, eine Warnung aus `UpdaterTest` (fehlende `.env` im Testordner, unabhängig von dieser Änderung); php-cs-fixer sauber.
**Veröffentlicht:** Tag `v1.28.1`, Release-Eintrag mit Archiv (3610 Dateien, 5,3 MB, gebaut aus `var/release-source-1.28.1`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt. Keine Migration.
**Offen:** Der Installer auf huefner-gartenbau.de war bis zum Abschluss der Einrichtung öffentlich erreichbar.
**Nächster Schritt:** Bestehende IONOS-Installationen auf 1.28.1 aktualisieren.
## Hüfner: Änderungen aus dem Kundentermin (12.10.2026)
**Anlass:** Umuts handschriftliche Notizen vom Hüfner-Termin plus Antworten auf Rückfragen. Nur Inhalt und Seed-Skript, kein Kerncode.
**Erledigt (`scripts/seed-huefner.php`, `build-huefner-assets.php`, `reset-huefner.sh`):** Alle Grüntöne auf das Baumgrün des Logos (#067F30) gedreht, „Premium“ und Hervorhebungen in Baumgrün, kleine Schrift auf Dunkel weiß statt hellgrün (Kontrast). Das „💯“ als Umuts Emoji-Bild überall, wo „100“ steht, im Text (`<mark>100</mark>`: die Zahl bleibt lesbar für Vorleser und KI, sichtbar ist das Bild in Schriftgröße, geschütztes Leerzeichen vor „%“); Quelle `100-emoji.png` in den Unterlagen. „Miteinander. 💯 %.“-Abzeichen jetzt dunkel statt rot. Freistellungsbescheinigungen (§ 13b UStG, § 48b EStG) als PDF bei „Planung & Angebot“ und im Fuß, PQ-Bescheinigung unter „Über uns“ und im Fuß, Galabau-Signet und PQ-Logo. Imagevideo (YouTube, youtube-nocookie) als „Externer Inhalt“ – dafür jetzt Einwilligungshinweis (Ecke) und angepasste Datenschutzerklärung. Google-Bewertungen unter „100 % Qualität. 100 % Zufriedenheit.“ mit 4,9 / über 150 als Beleg, drei echte Rezensionen (gekürzt, Namen abgekürzt), Knopf zum Eintrag. 48-h-Angebotsgarantie in Ablauf und Kontakt, „Leistung“ aus dem Formular, Chips verlinken zum Formular, WhatsApp (0176 30387990) in Kontakt und Karriere, 25 Jahre / 7.000+ Projekte, „Bagger bis 15 t“ raus. Karriere: Facharbeiter und LKW-Fahrer C/CE (wie Instagram) als zwei gleich hohe, aufklappbare Karten (details/summary, SVG-Zeichnungen), „Ein Anruf genügt!“. Neue Seite `/alpakas` mit Umuts Text, Ergänzungen, Galerie und Plakat; „Alpakas“ im Menü, „Ablauf“ aus dem Menü raus. `browser-check.mjs`: PDFs zählen beim „erster interner Link“-Klick nicht mit.
**Geprüft:** `test:browser` auf 8102 für `/`, `/alpakas`, `/datenschutz`, `/impressum` grün (HTML Startseite 148.905 B von 150.000 – knapp). Lighthouse Produktion (`reports/lighthouse-huefner-termin`): `/` mobil 96, Desktop 99; `/alpakas` mobil 95, Desktop 100; Barrierefreiheit, Best Practices, SEO, Agentic Browsing überall 100. Im Browser: Video lädt erst nach Einwilligung, Stellenkarten klappen auf, Telefonbreite ohne waagrechtes Scrollen.
**Bekannt / offen:** Die Freistellungsbescheinigung § 13b gilt nur bis 31.12.2026 – dann neue Datei in die Unterlagen und neu aufbauen. Die Alpaka-Videos (mp4) sind nicht eingebunden (keine Videoablage im Kern). Offen von Umut: Logos der Sponsoren, „Über uns – mehr lesen“, ggf. Stellenbilder aus ChatGPT statt der SVG-Zeichnungen, genauer Verbandsname. Die Sprachtest-Kopie (`var/browser-tests/lang`) teilt `var/huefner-media` und hat nach dem Neuaufbau keine passenden Bilder mehr. Sicherung vorher: `var/backups/huefner-vor-termin-1210/`. Nebenbefund Kern: `CustomCode::parse` lehnt `url('…')` mit Anführungszeichen immer ab (optionales Anführungszeichen im regulären Ausdruck backtrackt).
**Nächster Schritt:** Umuts Durchsicht; fehlende Punkte nachliefern lassen.
## Fassung 1.28.0: Notfallseite, Fehlerprotokoll und Update mit einem Klick (09.10.2026)
**Anlass:** kantarci-reisen.de zeigt nach einer per FTP ersetzten Datenbank auf jeder Adresse eine leere 500-Seite. Umut: „Fang mit 1 und 2 an und verbessere den Update-Prozess … mach es wie bei reiseSoft.“
**Erledigt (ADR 113):** (1) Wartungsseite mit 503 statt leerem 500er, auch wenn Symfony nicht startet; (2) Fehlerprotokoll `var/log/notfall.log`, per FTP lesbar; (3) Update-Karte auf der Systemseite: sucht beim Öffnen selbst, zeigt die Release-Hinweise, ein Klick mit Bestätigung, Fortschritt in fünf Schritten live, Migration im selben Vorgang, bei Fehler automatisch zurück auf die alte Fassung samt Datenbank.
**Geprüft:** PHPUnit vollständig 643 grün (neu: `NotfallTest`, Fortschrittsdatei und vollständiges Zurückschalten in `ArchiveUpdateTest`, Endpunkte nur für Administratoren in `SystemScreenTest`), php-cs-fixer. Im Browser mit einer Installation aus dem Releasearchiv 1.27.1: erfolgreiches Update in einem Klick, Zurückschalten bei einer kaputten Testfassung.
**Veröffentlicht:** Tag `v1.28.0`, Release-Eintrag mit Archiv (3610 Dateien, 5,3 MB, gebaut aus `var/release-source-1.28.0`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt. Keine Migration.
**Bekannt:** Der neue Ablauf greift erst bei Updates, die von 1.28.0 ausgehen. kantarci-reisen.de ist noch offline (Ursache unbekannt, Datenbank lokal mit 1.27.0 lauffähig).
**Nächster Schritt:** kantarci-reisen.de wiederherstellen (Notfallschicht hochladen → Protokoll lesen, oder Sicherung zurück) und aktualisieren.
## Fassung 1.27.1: Update in einem Zug, Kantarci Reisen auf Türkisch (09.10.2026)
**Anlass:** Umut: Update auf kantarci-reisen.de gab direkt danach einen 500er, nach „zurück“ ließ sich migrieren. „Können wir den Updater besser machen, dass alles automatisch abläuft, wie bei WordPress?“ Und: „Die DB hier komplett mit Übersetzung auf Türkisch fertig machen.“
**Updater (ADR 112):** Ein Klick spielt ein, die Anfrage endet nach dem Dateitausch mit einer schlichten Seite, die den Abschluss (Migrationen, Wartung aus) selbst aufruft – kein 500er, kein zweiter Knopf. Wirkt ab Updates, die von 1.27.1 ausgehen; 1.27.0 → 1.27.1 läuft noch den alten Weg.
**Türkisch im Kern vervollständigt:** feste Vorlagentexte und Formularmeldungen komplett auf Türkisch (vorher Teile auf Englisch). Zwei Sprachwahlen auf einer Seite heißen jetzt verschieden („Sprache“ im Kopf, „Sprache wählen“ sonst) – axe meldete sonst `landmark-unique`.
**Kantarci Reisen (nur Inhalt):** `scripts/translate-kantarci-reisen.php` schaltet Türkisch ein und übersetzt Startseite (67 Texte), Künye (`/tr/kunye`), Gizlilik (`/tr/gizlilik`), 404, Kopf, Fuß, Menü und das Anfrageformular; Anrede „siz“, Rechtstexte mit dem Hinweis, dass die deutsche Fassung gilt. Sprachwahl DE · TR im Kopf (auf dem Telefon ausgeblendet, kein Platz) und „Deutsch · Türkçe“ im Fuß. `reset-kantarci-reisen.sh` ruft das Skript am Ende auf. Ausgeführt auf `var/kantarci-reisen.db` (Sicherung `var/backups/vor-1.27.0/kantarci-reisen-vor-tr.db`).
**Korrigiert:** Die DM-Sans-600-Dateien von heute Nachmittag waren doch nötig – `FontLibrary::missing()` verlangt je Stärke eine Datei, sonst gibt die Website ihre eigenen Schriften ganz auf. Neu geholt (`cms:fonts:fetch`) und diesmal mit `fonts.css` committet. Live auf kantarci-reisen.de waren sie nie weg.
**Geprüft:** PHPUnit vollständig 637 grün (neu: Abschluss per Einmalschlüssel, Rolle, falscher Schlüssel, Neuladen), `npm test`, `lint:twig`, php-cs-fixer; Update-Ende-zu-Ende in einer Archivinstallation; `test:browser` auf Reisen (8104) grün; Lighthouse Produktion (`reports/lighthouse-kantarci-reisen-tr`): `/tr`, `/tr/gizlilik`, `/` überall 100, nur Leistung mobil 99 auf `/tr` und `/`. Telefonbreite ohne waagrechtes Scrollen.
**Veröffentlicht:** Tag `v1.27.1`, Release-Eintrag mit Archiv (3607 Dateien, 5,3 MB, gebaut aus `var/release-source-1.27.1`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt. Die lokalen Installationen laufen aus diesem Repository und haben 1.27.1 (keine Migration).
**Für den Livegang der türkischen Fassung:** erst kantarci-reisen.de auf 1.27.1 aktualisieren (sonst fehlen Teile der festen türkischen Texte), dann `var/kantarci-reisen.db` hochladen. Achtung: die hochgeladene Datei ersetzt die Datenbank dort – Einsendungen und Änderungen, die es nur live gibt, gehen dabei verloren.
## Fassung 1.27.0: Mehrsprachigkeit und übersichtlichere Formulare (09.10.2026)
**Inhalt:** Mehrsprachigkeit in drei Stufen (ADR 111, Schema v27, Migration `20261009000100`) und die neue Formularverwaltung – Einzelheiten in den drei Einträgen darunter.
**Geprüft vor dem Release:** PHPUnit vollständig auf dem Release-Stand 636 grün, `npm test` 41, `lint:twig`, php-cs-fixer, `test:admin --admin-only` und `test:editor` (mit axe) grün. Lighthouse im Produktionsmodus auf der Hüfner-Testkopie mit Englisch (`reports/lighthouse-sprachen`): `/en` mobil 97/100/100/100/100, Desktop 99/100/100/100/100; `/` mobil 96, Desktop 99, sonst 100.
**Veröffentlicht:** Tag `v1.27.0`, Release-Eintrag auf git.kant-it.de mit Archiv (3605 Dateien, 5,2 MB, gebaut aus `var/release-source-1.27.0`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt.
**Aufgeräumt:** Zwei nicht eingebundene DM-Sans-600-Dateien aus der Reisen-Installation gelöscht (die variable 400er-Datei deckt 400–700 ab), die nur umsortierte `fonts.css` auf den committeten Stand zurückgesetzt.
**Nächster Schritt:** Installationen auf 1.27.0 aktualisieren (Migration läuft über den Updater mit). Wer eine zweite Sprache will: Einstellungen → Sprachen, Sprachwahl in den Kopfbereich.
## Formularverwaltung übersichtlicher (09.10.2026)
**Anlass:** Umut: Formulare „sehr unübersichtlich … kompakter, nicht so wuchtig und so voll“; bei mehreren Formularen erst eine Übersicht ohne Vorschau, dann in die Seite rein.
**Neu:** `/admin/forms` ist eine Übersicht – je Formular eine Zeile mit Feldern, Empfänger, Adresse, übersetzten Sprachen und Zahl der Einsendungen, darunter die Einsendungen. Bearbeitet wird auf `/admin/forms/<adresse>` (neu: `/admin/forms/neu`), nach dem Speichern bleibt man dort. Im Editor ist jedes Feld eine Zeile (Beschriftung, Typ, Pflicht), alles Weitere unter „Details“ mit einer Kurzinfo darunter (technischer Name, Optionen, Hilfetext, Bedingung, „EN ✓“). Zustellung, Aussehen und je Sprache die Übersetzung sind zuklappbar mit Zusammenfassung; Erklärtexte gekürzt. Ohne JavaScript bleiben alle Felder sichtbar und bedienbar. Beim Testformular der Hüfner-Kopie: Seitenlänge von 4.913 px auf 1.274 px.
**Robuster:** Ohne weitere Sprache fasst der Kern die Tabelle `page_translation` nicht an – Speichern und Veröffentlichen funktionieren so auch, wenn nach einem Update die Migration noch aussteht (gefunden über die Pilot-Fixture, die ohne Migrationen kopiert).
**Geprüft:** PHPUnit vollständig 636 grün (vor dem Umbau), danach die betroffenen 59 (Editor-Sicherheit, Formulare, Übersetzung) grün; `npm test`, `lint:twig`, php-cs-fixer; `test:admin --admin-only` und `test:editor` grün inklusive axe auf allen Verwaltungsseiten und im Formulareditor; im Browser Übersicht, Bearbeiten, Speichern, Neu, 404 für unbekannte Adresse, Telefonbreite ohne waagrechtes Scrollen.
**Bekannt:** `test:admin` ohne `--admin-only` scheitert im Slider-Teil der Pilotseite – ebenso auf dem unveränderten Stand 1.26.0 (eigens in einem separaten Arbeitsverzeichnis geprüft), also kein Folgefehler dieser Änderungen.
## Mehrsprachigkeit, Stufe 3: Formulare, Beiträge, Einwilligung, 404 (09.10.2026)
**Neu (ADR 111, keine Migration):** Formulare je Sprache übersetzbar (in der Formularverwaltung am Feld: Beschriftung, Hilfetext, Optionen, Einheit; am Formular Button und Erfolgsmeldung), Fehlermeldungen und Schrittnavigation in der Sprache, Rückweg nach dem Absenden auf die übersetzte Seite; Einsendungen und E-Mail bleiben deutsch. Beitragslisten und Archive (`/en/beitraege`, `/en/kategorie/…`) mit übersetzten Titeln; Kategorie-/Schlagwortnamen und Einwilligungstext unter Einstellungen → Sprachen. 404 unter `/en/…` auf Englisch.
**Geprüft:** PHPUnit vollständig **636 grün** (neu: Formular in beiden Sprachen inkl. Absenden/Fehler/Weiterleitung; Beiträge, Archive, Kategorienamen, Einwilligung, 404), `npm test` 41, `lint:twig`, php-cs-fixer. Browser (Testkopie Hüfner): Formularübersetzung gespeichert, `/en` zeigt „Your name (EN) * (required)“ und „Send“, `/` unverändert. **Nicht geprüft:** Formularversand per E-Mail, Lighthouse/axe auf `/en`.
**Bekannt:** Feed, `/einwilligung`, Datumsformat und Archiv-Präfixe bleiben deutsch.
## Mehrsprachigkeit, Stufe 2: Kopf, Fuß, Menüs, feste Texte (09.10.2026)
**Anlass:** Umut: „Bilder lassen wir, mach weiter.“ Bilder bleiben für alle Sprachen gleich.
**Neu (ADR 111, keine weitere Migration):**
- **Kopf, Fuß und Sektionen** übersetzen: im Bereichs-Editor derselbe Umschalter DE | EN, die Liste zeigt nur die Texte (keine Adresse, kein Seitentitel). Gespeichert gilt sofort.
- **Menüs:** je Eintrag ein Feld „Beschriftung Englisch“ (leer = deutsch). Auf `/en/…` zeigen die Einträge ihre englische Beschriftung und führen auf die englischen Seiten.
- **Feste Texte** wie „Zum Inhalt springen“, „Menü“, „Hauptnavigation“, „Impressum/Datenschutz“ in der Rechtszeile, Einwilligungsknöpfe, „Weiterlesen“, „Nach oben“ erscheinen auf englischen Seiten englisch (Französisch/Türkisch teilweise, sonst Englisch).
**Geprüft:** PHPUnit vollständig **634 grün**, darunter neu ein Test für Kopfbereich, Menü und feste Texte in beiden Sprachen; `npm test` 41 grün; `lint:twig`; php-cs-fixer. Im Browser (Testkopie Hüfner, Port 8111): Menü mit „Services“/„References“ gespeichert, `/en` zeigt sie mit Zielen `/en#…`, „Skip to content“, „Main navigation“; Kopfbereich im Editor auf Englisch, „Request a quote“ in Vorschau und auf `/en`, `/` weiter „Angebot anfragen“. **Nicht geprüft:** Lighthouse/axe auf `/en`.
**Bekannt:** Formulare, Beiträge/Archive, Einwilligungstext aus den Einstellungen und die 404-Seite noch einsprachig. Nach einem Kern-Update cached die App-Vorschau alte Editor-Skripte (gleiche Fassungsnummer) – erst nach Neuladen der Assets sieht man Änderungen.
**Nächster Schritt:** Stufe 3 – Formulare (Felder, Meldungen, Bestätigungsmail) und Beiträge mit Archiven übersetzbar machen; danach Lighthouse/axe einer `/en`-Seite.
## Mehrsprachigkeit, Stufe 1: Seiten übersetzen statt neu bauen (09.10.2026)
**Anlass:** Umut: weitere Sprache hinzufügen und zu jedem Feld die 2. Sprache eintragen, ohne die Seite für die 2. Sprache neu zu bauen; Adressen mit `/en/`-Präfix und übersetzten Slugs.
**Neu (Kern, Schema v27, Migration `20261009000100`, ADR 111):**
- **Einstellungen → Sprachen:** Hauptsprache bleibt Deutsch, weitere Sprachen per Häkchen (10 zur Wahl, höchstens 5). Ohne Häkchen ist nirgends etwas zu sehen.
- **Editor:** Umschalter DE | EN oben. In EN zeigt die Seitenleiste alle Texte der Seite mit Original, Zustand (fehlt / übersetzt / Original geändert), Filter, „Original übernehmen“, „Übersetzung passt“; dazu Adresse und Suchtitel. Vorschau in der Sprache, Klick springt zum Text. Aufbau und Gestaltung sind in EN gesperrt. Undo, Speichern und Veröffentlichen wie gewohnt – für alle Sprachen gemeinsam.
- **Website:** `/en` und `/en/<übersetzte-adresse>`, `lang="en"`, Canonical, `hreflang` + `x-default`, `og:locale`, Sitemap. Links auf übersetzte Seiten führen zur Übersetzung. Nicht übersetzte Seiten: 404 unter `/en/…`. Neuer Baustein **„Sprachwahl“** (Kürzel oder Namen).
**Geprüft:** PHPUnit vollständig (633, ein Fehler durch die Änderung gefunden und behoben, danach die betroffenen 426 Tests erneut grün), neu `tests/TranslationTest.php` (Überlagerung, Prüfung wie Hauptsprache, Adressen beim Veröffentlichen/Verschieben mit Weiterleitung, Kollisionen, HTTP mit hreflang/Links/Sitemap/404). `npm test` 41 grün, neu `tests/translate.test.mjs` (Fingerabdruck gleich PHP, Feldliste gleich Server, Undo). `lint:twig`, php-cs-fixer. Im Browser auf einer Kopie der Hüfner-Datenbank (`var/browser-tests/lang/`, Startkonfiguration `sprachen-test`, Port 8111): Sprache eingeschaltet, Startseite übersetzt, Undo/Redo, Speichern, Veröffentlichen, `/en` mit englischem Titel und `hreflang`, `/en/impressum` 404, Sitemap mit `/en`. **Nicht geprüft:** Lighthouse und axe einer `/en`-Seite und des Bausteins „Sprachwahl“, Tastaturfokus beim Klick in die Vorschau (das Browserfenster der Prüfung hatte keinen Fokus).
**Bekannt (Stufe 2):** Kopf/Fuß, Menübeschriftungen, Formulare, Beiträge/Archive, Einwilligung, 404 und feste Vorlagentexte bleiben auf englischen Seiten deutsch. Eigenes HTML und Plugin-Bausteine unübersetzbar, Bilder nicht je Sprache. Ändert der Server beim Speichern formatierten Text der Hauptsprache, können Übersetzungen fälschlich als „Original geändert“ erscheinen. Dokumente mit Übersetzungen oder Sprachwahl brauchen einen Kern mit Schema v27.
**Nächster Schritt:** Stufe 2 – Menüs und Kopf-/Fußbereiche übersetzen, danach Formulare und Beiträge. Vorher mit Umut klären, ob Bilder je Sprache austauschbar sein sollen.
## Zehnte Installation „Kantarci Reisen“ (08.10.2026)
**Anlass:** Umut: neue Website nach dem Mockup `Downloads/Kantarci-Reisen-Komplettpaket.zip`, DB und Medienordner nach gleichem Muster, Inhalte dürfen individualisiert werden. Inhaber I. Kantarci, Ooser Hauptstraße 51, 76532 Baden-Baden, info@kantarci-reisen.de. Für reiseSoft ein echter Screenshot aus `Downloads/reiseSoft`, Plattform unter reisesoft.kantarci-reisen.de.
**Neu (nur Inhalt, kein Kerncode):** Datenbank `var/kantarci-reisen.db`, Medien `var/kantarci-reisen-media`, Bildquellen `var/kantarci-reisen-assets`, Startkonfigurationen `kantarci-reisen` (Port 8104, dev) und `kantarci-reisen-prod` (8105). `sh scripts/reset-kantarci-reisen.sh <Ordner Kantarci-Reisen> <Screenshot.png>` schneidet Logo und Seitensymbol zu (`build-kantarci-reisen-assets.php`), baut mit `seed-kantarci-reisen.php` alles neu und holt DM Sans auf den eigenen Server. Ohne Argumente nimmt es die vorbereiteten Bilder.
- **Startseite** wie das Mockup: geteilter Hero (Petrol/Küstenfoto), drei Angebote als ganz klickbare Karten (ohne Nummern, auf Umuts Wunsch), „Gute Reisen beginnen mit einem Gespräch“ mit Anschrift, neu „So läuft’s“ in drei Schritten, Anfrage auf Petrol mit CMS-Formular `reiseanfrage` (Versand an info@kantarci-reisen.de, dazu Telefon, Reisezeitraum, Personen), reiseSoft mit echtem Screenshot im Browserrahmen und Knopf zur Plattform. Dazu Impressum (Reisevermittlung), Datenschutz, 404, klebender Kopf, Einblendungen.
- **„Reise anfragen“** ist ein Menü-Button (ADR 110) mit Pfeil, auf dem Telefon neben dem Menü-Knopf. Eigenes Menü-CSS darf `.menu-list a` kein Polster geben, ohne es dem Button zurückzugeben (`.menu-list .menu-button`).
- **Screenshot reiseSoft:** gebaute Fassung von reiseSoft lokal (Port 4199) mit leerem Datenordner im Scratchpad gestartet, Ersteinrichtung mit Testzugang, `scripts/demodaten.mjs` (60 fiktive Vorgänge), Übersicht in 1440×900 @2x aufgenommen. Keine echten Kundendaten.
**Geprüft:** `test:browser` auf 8104 grün (HTML 66 KB). Lighthouse auf 8105 (Produktion): `/` mobil 98/100/100/100/100, Desktop 100 überall; `/impressum` überall 100 (`reports/lighthouse-kantarci-reisen`). Ganzseitige Screenshots 1440 und 390 px ohne Konsolenfehler und ohne waagrechtes Scrollen. Nicht geprüft: Formularversand per E-Mail.
**Nachtrag (08.10.2026):** Telefon 0170 2945935 (auch WhatsApp) von Umut; eine USt-IdNr. gibt es nicht. WhatsApp als Anfrageweg aus dem Baustein „Soziale Profile“ (Netzwerk in den Einstellungen, Link mit vorausgefülltem Text): Knopf unter „Über uns“ und neben dem Formular, Link im Fuß, runde Schaltfläche fest unten links (`position:fixed` im eigenen CSS). Datenschutz mit eigenem Punkt zu WhatsApp. „Inhaber“ steht nicht mehr im Fuß, nur im Impressum. Danach erneut `test:browser` grün (HTML 72 KB) und Lighthouse `/` mobil 98, sonst 100; `/datenschutz` überall 100.
**Bekannt:** Öffnungszeiten und Hoster sind unbekannt und stehen nirgends (Datenschutz nennt den Hoster allgemein). **Die Domain kantarci-reisen.de existiert am 08.10.2026 im DNS nicht** (NXDOMAIN bei Fritz.box, 1.1.1.1 und 8.8.8.8) – damit kommen weder Mails an info@kantarci-reisen.de an noch ist reisesoft.kantarci-reisen.de erreichbar. Vor dem Livegang klären. Das Reisefoto ist laut Paket KI-generiert, so im Bildnachweis. Grenzen des Kerns beim Aufbau: Textblöcke geben `<span>` nicht aus, eigenes CSS erlaubt keine Anführungszeichen in Werten, kein `calc()` in `maxWidth`, kein `>*` und keine `?` in Attributselektoren.
**Nächster Schritt:** Inhalte mit Umut abstimmen, dann Livegang nach `docs/LIVEGANG.md`.
## Fassung 1.26.0: Menüeinträge als Button (07.10.2026)
**Anlass:** Umut: Buttons im Menü – individuell wie im Editor, mit Symbol und Farbe, auf dem Telefon im aufgeklappten Menü oder neben dem Menü-Knopf.
+9
View File
@@ -0,0 +1,9 @@
# Dieser Ordner gehoert nicht ins Netz (ADR 116). Zeigt die Domain richtig auf public/, wird diese
# Datei nie gelesen; zeigt sie versehentlich auf den Projektordner, sperrt sie hier alles.
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20261009000100 extends AbstractMigration
{
public function getDescription(): string
{
return 'Mehrsprachigkeit: die öffentlichen Adressen der übersetzten Seiten (ADR 111).';
}
public function isTransactional(): bool
{
return false;
}
public function up(Schema $schema): void
{
// Die Texte einer Übersetzung stehen im Dokument der Seite und laufen so mit Entwurf,
// Veröffentlichung und Fassungen mit. Hier steht nur, was die Website zum Auffinden
// braucht: unter welcher Adresse eine Seite in welcher Sprache veröffentlicht ist. Die
// Zeilen entstehen beim Veröffentlichen und spiegeln genau den öffentlichen Stand.
// Eine neue Tabelle, keine Änderung an `page`: auf SQLite baut jede Indexänderung an einer
// bestehenden Tabelle sie neu auf (siehe ADR 010).
$table = $schema->createTable('page_translation');
$table->addColumn('page_id', 'string', ['length' => 32]);
$table->addColumn('website_id', 'string', ['length' => 32]);
$table->addColumn('locale', 'string', ['length' => 12]);
$table->addColumn('slug', 'string', ['length' => 80]);
$table->addColumn('path', 'string', ['length' => 400]);
$table->setPrimaryKey(['page_id', 'locale']);
$table->addUniqueIndex(['website_id', 'locale', 'path'], 'uniq_page_translation_path');
}
public function down(Schema $schema): void
{
// Die Übersetzungen selbst bleiben in den Dokumenten; ohne diese Tabelle sind sie nur
// nicht mehr öffentlich erreichbar.
$schema->dropTable('page_translation');
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20261012000100 extends AbstractMigration
{
public function getDescription(): string
{
return 'Geplante Veröffentlichung: welche Fassung zum Termin online geht (ADR 116).';
}
public function isTransactional(): bool
{
return false;
}
public function up(Schema $schema): void
{
// Bisher ging zum Termin der jeweils neueste Entwurf online - auch einer, den nach dem
// Planen jemand ohne Recht zum Veröffentlichen gespeichert hatte. Jetzt merkt sich die
// Planung die Fassung, die beim Planen vorlag. Eine Spalte, kein Tabellenneubau.
$schema->getTable('page')->addColumn('publish_revision_id', 'string', ['length' => 32, 'notnull' => false, 'default' => null]);
}
public function down(Schema $schema): void
{
$schema->getTable('page')->dropColumn('publish_revision_id');
}
}
+9
View File
@@ -0,0 +1,9 @@
# Dieser Ordner gehoert nicht ins Netz (ADR 116). Zeigt die Domain richtig auf public/, wird diese
# Datei nie gelesen; zeigt sie versehentlich auf den Projektordner, sperrt sie hier alles.
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
+25 -7
View File
@@ -2,18 +2,32 @@ DirectoryIndex index.php
Options -Indexes -MultiViews
# Alles, was keine Datei ist, beantwortet PHP.
#
# Die Zeile fuer index.php selbst ist kein Zierrat: Bei IONOS erkennt !-f die bereits
# umgeschriebene index.php nicht als Datei, die Regel greift wieder und wieder, und Apache bricht
# nach zehn Runden mit einem weissen 500er ab (huefner-gartenbau.de, 12.10.2026). Dieselbe
# Absicherung steht in der .htaccess von WordPress.
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
RewriteBase /
# HTTPS erzwingen. Auskommentiert, weil diese Datei auch in Installationen ohne Zertifikat
# liegt und eine Endlosschleife schlimmer waere als fehlendes HTTPS. Vor dem Livegang mit
# Zertifikat: die drei Zeilen einschalten - und erst danach die HSTS-Zeile weiter unten,
# denn HSTS laesst sich in einem Browser ein halbes Jahr lang nicht zuruecknehmen.
# liegt und eine Endlosschleife schlimmer waere als fehlendes HTTPS. Einschalten am besten in
# der Verwaltung unter System -> "HTTPS erzwingen": dort wird vorher und nachher geprueft und
# bei einer Schleife zurueckgenommen (ADR 120). Von Hand: die drei Zeilen einschalten - und erst
# danach die HSTS-Zeile weiter unten, denn HSTS laesst sich in einem Browser ein Jahr lang nicht
# zuruecknehmen.
#
# Die Zeilen stehen vor den Regeln fuer index.php, nicht dahinter: Nach dem Umschreiben auf
# index.php beginnt die Runde neu, und "^index\.php$ - [L]" beendet sie, bevor eine spaetere
# Regel an die Reihe kommt. Weiter unten griff die Umleitung nur fuer echte Dateien.
#RewriteCond %{HTTPS} !=on
#RewriteCond %{HTTP:X-Forwarded-Proto} !=https
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
# Eine Domain, eine Adresse - mit oder ohne www. Das erledigt die Anwendung selbst nach der
# Einstellung SITE_ORIGIN in .env: die jeweils andere Schreibweise antwortet mit 301 dorthin.
# Hier braucht es dafuer keine Regel; eine zweite Stelle wuerde nur auseinanderlaufen.
@@ -58,13 +72,17 @@ RewriteRule ^ index.php [L]
# ist fuer einen Besucher wertlos und fuer einen Scanner eine Einkaufsliste.
Header always unset X-Powered-By
Header always unset X-AspNet-Version
# Erst einschalten, wenn HTTPS steht und bleiben soll (siehe oben).
# Erst einschalten, wenn HTTPS steht und bleiben soll (siehe oben; System -> HSTS).
#Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
</IfModule>
# Nichts ausliefern, was nur intern gebraucht wird - falls jemand das Paket versehentlich
# vollstaendig in den oeffentlichen Ordner legt statt nur public/.
<FilesMatch "^\.|\.(env|db|sqlite|lock|md|yaml|yml|dist|log)$">
# .well-known ausgenommen: Zertifikatsanbieter (acme-challenge) brauchen den Ordner, und Apache
# prueft den ersten nicht vorhandenen Pfadteil gegen diese Regel. Die Probe der Verwaltung
# (Adresse speichern, HTTPS erzwingen) liegt nicht mehr dort, sondern unter /kantsites-origin:
# IONOS beantwortet /.well-known/ selbst mit 403 (ADR 122).
<FilesMatch "^\.(?!well-known$)|\.(env|db|sqlite|lock|md|yaml|yml|dist|log)$">
Require all denied
</FilesMatch>
# Eine Ausnahme mit Grund: der Editor liest die Liste der Gestaltungswerte als JSON.
+160 -8
View File
@@ -1,3 +1,5 @@
/* Der Strich aller Symbole aus icon.html.twig, wie auf der Website (site.css). */
svg.block-icon{fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}
/* Administration: shared foundation and the management screens.
The editor has its own stylesheet (editor.css) that only the editor and the media page load. */
@@ -74,6 +76,9 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
der Zeile das Häkchen um - in der Menüverwaltung klappten so die Button-Einstellungen zu. */
.check-label { display: flex; align-items: center; gap: 8px; font-weight: 500; width: fit-content; max-width: 100% }
.check-label input { width: 16px; min-height: 16px }
/* Eine Gruppe von Häkchen nebeneinander, die auf schmalen Bildschirmen umbricht (Sprachen). */
.check-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px 20px; border: 0; padding: 0; margin: 0 0 12px; min-width: 0 }
.check-grid legend { font-weight: 600; margin-bottom: 8px; padding: 0 }
/* A grid of fields that becomes one column when there is no room. */
.fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 0 24px }
.fields > div > label:first-child, .field > label:first-child { margin-top: 14px }
@@ -288,7 +293,24 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
}
.card-head h2, .card-head h3 { margin: 0 }
.card-head .muted { font-size: 12px }
.card-body { padding: 8px 22px 24px; min-width: 0 }
.card-body { padding: 8px 22px 24px; min-width: 0; font-size: 13px; line-height: 1.6 }
/* Fließtext in Karten ist 13 px wie Hinweise und Fakten. Felder bleiben bei 16 px - darunter
vergrößert iOS beim Antippen die ganze Seite. :where() hält die Regel schwächer als jede Klasse. */
:where(.card-body) :where(input:not([type=checkbox], [type=radio]), select, textarea) { font-size: 16px }
.card-body h3 { font-size: 14px; margin: 26px 0 4px }
.card-body h3:first-child { margin-top: 14px }
/* Abschnitte einer Karte: Trennlinie, Überschrift, gleicher Abstand - als fieldset oder form. */
.card-section { border: 0; border-top: 1px solid var(--line); margin: 22px 0 0; padding: 18px 0 0; min-width: 0 }
.card-section > legend { float: left; width: 100%; font-weight: 650; font-size: 14px; margin: 0 0 12px; padding: 0 }
.card-section > legend + * { clear: both }
.card-section > h3, .card-body .card-section > h3:first-child { font-size: 14px; font-weight: 650; margin: 0 0 12px }
.card-section .check-label { margin: 14px 0 0 }
.card-section > h3 + .check-label, .card-section > h3 + input + .check-label { margin-top: 0 }
.card-section label.field-first { margin-top: 0 }
/* Dateiauswahl im Stil der übrigen Knöpfe statt des grauen Browserknopfs. */
input[type=file] { padding: 6px; cursor: pointer; max-width: 720px }
input[type=file]::file-selector-button { margin-right: 12px; padding: 7px 14px; border: 1px solid var(--line); border-radius: 6px; background: #f3f5f1; color: var(--ink); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer }
input[type=file]:hover::file-selector-button { background: #e8ede6 }
.card-body code { overflow-wrap: anywhere }
/* Das Kleingedruckte einer Karte: zugeklappt, damit auf einem Handy der Knopf oben bleibt. */
.hint { margin: 12px 0 0 }
@@ -301,6 +323,22 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
.card-body > p:first-child { margin-top: 14px }
.card-body .primary, .card-body .secondary { margin-top: 22px }
.card-foot { padding: 16px 22px; border-top: 1px solid var(--line); background: #fafbf8; border-radius: 0 0 12px 12px }
/* Der Fuss eines Formulars in einer Karte: Hauptknopf links, Erklärung daneben. */
.form-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px; font-size: 12px }
.form-foot .primary { margin: 0 }
/* Knöpfe nebeneinander, gleicher Abstand, ohne den Abstand nach oben der Einzelknöpfe. */
.button-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px }
.card-body .button-row .primary, .card-body .button-row .secondary { margin: 0 }
/* Auswahl als Karten: ganze Fläche klickbar, Titel und eine Zeile Erklärung. */
.option-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 8px; max-width: 720px }
.option { display: flex; align-items: flex-start; gap: 10px; margin: 0; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--paper); font-weight: 400; cursor: pointer }
.option:hover { border-color: #a9b8af }
.option:has(input:checked) { border-color: var(--accent); background: #f4f8f1; box-shadow: inset 0 0 0 1px var(--accent) }
.option input { width: 16px; min-height: 16px; margin: 2px 0 0; flex: none; accent-color: var(--accent) }
.option > span { display: grid; gap: 2px; min-width: 0 }
.option strong { font-size: 13px; font-weight: 600 }
.option > span > span { font-size: 12px; color: var(--muted); line-height: 1.45 }
.check-label input, .option input { accent-color: var(--accent) }
/* A card that opens: used for everything secondary, so a screen starts calm. */
details.card > summary {
@@ -371,6 +409,7 @@ details.card > summary:hover { background: #fafbf8 }
.pill-live { background: #e7f0d6; color: #2c5340 }
.pill-draft { background: #fff2d9; color: #6f4e18 }
.pill-off { background: #f1f2f0; color: #5a635e }
.pill-error { background: #fff0eb; color: var(--danger) }
/* The per-row settings. One disclosure that opens in place, instead of six competing popovers
that clip each other at the edge of the card. */
@@ -588,6 +627,10 @@ details.card > summary:hover { background: #fafbf8 }
.nav-label { display: none }
.nav-list { display: flex; gap: 4px }
.nav-item { white-space: nowrap; padding: 8px 12px }
/* In der Leiste stehen Unterpunkte direkt neben ihrem Hauptpunkt, nicht in einer eigenen Zeile. */
.nav-list > li:has(.nav-sub) { display: flex; gap: 4px }
.nav-sub { display: flex; margin: 0 }
.nav-sub .nav-item { padding: 8px 12px }
.shell-foot { margin: 0 0 0 auto; padding: 0 0 0 12px; border-top: 0; border-left: 1px solid #2a3d35; display: flex; align-items: center; gap: 10px }
.shell-account { padding: 3px; gap: 2px }
.shell-user { padding: 3px }
@@ -692,6 +735,8 @@ details.card > summary:hover { background: #fafbf8 }
.origin-row { display: flex; flex-wrap: wrap; gap: 8px }
.origin-row input { flex: 1 1 240px; width: auto; box-sizing: border-box; min-height: 40px; height: 40px }
.card-body .origin-row .secondary { margin: 0 }
.https-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px }
.card-body .https-actions .secondary { margin: 0 }
.origin-form .check-label { margin-top: 10px }
.link-button { border: 0; background: none; padding: 0; min-height: 0; font: inherit; color: var(--accent); text-decoration: underline; cursor: pointer }
.check-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 10px; font-size: 13px; line-height: 1.55 }
@@ -711,6 +756,39 @@ details.card > summary:hover { background: #fafbf8 }
.check-bad { color: var(--danger) }
.log-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 6px }
.log-list code { font-size: 11px; overflow-wrap: anywhere }
.mail-result { margin-top: 16px }
.mail-result p { margin: 0 0 6px }
.mail-result p:last-child { margin-bottom: 0 }
.mail-result pre { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 11px; margin: 6px 0 0 }
.mail-result-head { margin: 0 0 10px }
/* Unterpunkte im Menü: eingerückt auf die Höhe des Textes, ohne Symbol. */
.nav-sub { list-style: none; margin: 2px 0 4px; padding: 0; display: grid; gap: 2px }
.nav-sub .nav-item { padding: 7px 10px 7px 39px; font-size: 12.5px; color: var(--shell-muted) }
.nav-sub .nav-item:hover { color: var(--shell-ink) }
.nav-sub .nav-item[aria-current=page] { color: #fff; background: #21362d }
/* E-Mail-Versand: der eingestellte Weg als Zeile, darunter Abschnitte mit Trennlinie. */
.mail-current { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; margin-top: 14px; padding: 12px 14px; border-radius: 10px; background: #f6f8f4 }
.mail-current-source { flex-basis: 100%; font-size: 12px }
.mail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 0 14px; max-width: 720px }
.mail-grid label { margin: 0 0 7px }
.mail-grid > div { margin-bottom: 4px }
/* Protokoll: Zustand links, daneben was, wann und - bei Fehlern - warum. */
.mail-log { list-style: none; margin: 14px 0 0; padding: 0 }
.mail-entry { display: grid; grid-template-columns: 112px minmax(0, 1fr); gap: 4px 14px; align-items: start; padding: 12px 0; border-top: 1px solid var(--line) }
.mail-entry:first-child { border-top: 0; padding-top: 4px }
.mail-entry .pill { justify-self: start; margin-top: 1px }
.mail-entry-text p { margin: 0 }
.mail-entry-line { overflow-wrap: anywhere }
.mail-entry-time { font-size: 12px; color: var(--muted) }
.mail-entry-hint { margin-top: 6px !important; color: var(--ink) }
.mail-entry-detail { margin-top: 6px }
.mail-entry-detail summary { font-size: 12px; font-weight: 600; color: var(--muted); cursor: pointer; width: fit-content }
.mail-entry-detail pre { margin: 6px 0 0; padding: 10px 12px; border-radius: 8px; background: #f6f8f4; font-size: 11px; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere }
@media (max-width: 560px) {
.mail-entry { grid-template-columns: minmax(0, 1fr) }
}
/* Ohne SMTP brauchen die Felder niemand; wo :has() fehlt, bleiben sie einfach sichtbar. */
.mail-form:has(input[name=weg]:checked:not([value=smtp])) .smtp-fields { display: none }
/* ---- the installer: six screens on an otherwise empty server ---------------------------- */
@@ -822,6 +900,9 @@ details.card > summary:hover { background: #fafbf8 }
.nav-list { display: grid; gap: 2px }
/* Vierundvierzig Pixel: die Flaeche, die ein Daumen zuverlaessig trifft. */
.nav-item { min-height: 44px; display: flex; align-items: center; gap: 12px; padding: 10px 14px; white-space: normal }
.nav-list > li:has(.nav-sub) { display: block }
.nav-sub { display: grid; margin: 2px 0 4px }
.nav-sub .nav-item { padding: 10px 14px 10px 44px }
.shell-foot { margin-left: auto; border-left: 0; padding-left: 0 }
.shell-version { display: none }
}
@@ -864,12 +945,42 @@ details.card > summary:hover { background: #fafbf8 }
.card-body .form-fields-head .secondary, .card-body .list-actions .secondary { margin: 0; min-height: 36px; padding: 6px 12px; font-size: 12px }
.form-fields-head .secondary .nav-icon, .list-actions .secondary .nav-icon { width: 15px; height: 15px; color: var(--accent) }
.field-list { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 10px }
.field-card { position: relative; padding: 2px 16px 16px; border: 1px solid var(--line); border-radius: 11px; background: #fafbf8 }
.field-card { position: relative; padding: 8px 10px; border: 1px solid var(--line); border-radius: 10px; background: #fafbf8 }
.field-card.is-open { padding-bottom: 14px; background: #fff; box-shadow: 0 1px 6px #203b3414 }
.field-card-new { border-style: dashed; background: var(--paper) }
.field-card-head { display: grid; grid-template-columns: 26px minmax(0, 1fr) 190px 38px; gap: 0 12px; align-items: end }
.field-card-index { display: grid; place-items: center; width: 26px; height: 26px; margin-bottom: 8px; border-radius: 50%; background: #e4ebe2; color: var(--accent); font-size: 12px; font-weight: 700 }
.field-card-head { display: grid; grid-template-columns: 24px minmax(0, 1fr) 170px auto auto 34px; gap: 0 10px; align-items: center }
.field-card-head input, .field-card-head select { min-height: 36px }
.field-card-index { display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; background: #e4ebe2; color: var(--accent); font-size: 12px; font-weight: 700 }
.field-card label { margin-top: 12px }
.field-card-body { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0 16px; padding-left: 38px }
.field-card-head .field-card-required { margin: 0; font-size: 13px; white-space: nowrap }
.field-toggle { display: inline-flex; align-items: center; gap: 4px; min-height: 34px; padding: 0 8px; border: 1px solid transparent; border-radius: 8px; background: transparent; color: var(--accent); font-size: 12px; font-weight: 650; white-space: nowrap }
.field-toggle:hover { background: #eef3ec }
.field-toggle .form-chevron { transition: transform .15s }
.field-card.is-open .field-toggle .form-chevron { transform: rotate(180deg) }
.field-card-meta { margin: 4px 0 0 34px; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
.field-card-body { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0 16px; padding-left: 34px }
.field-card-body[hidden] { display: none }
.field-translation { grid-column: 1 / -1; margin-top: 12px; padding-top: 8px; border-top: 1px dashed var(--line) }
.field-translation summary { cursor: pointer; font-size: 13px; font-weight: 650 }
/* Ein Formular je zuklappbarer Karte, Abschnitte darin ebenfalls zuklappbar. */
.form-card > .form-card-summary { display: flex; align-items: center; gap: 12px; padding: 16px 20px; cursor: pointer; list-style: none }
details.form-card > summary.form-card-summary::before, details.form-card[open] > summary.form-card-summary::before { content: none }
.form-card > .form-card-summary::-webkit-details-marker, .form-section > summary::-webkit-details-marker { display: none }
.form-card-name { font-size: 16px; font-weight: 700 }
.form-card > .form-card-summary { cursor: default; border-bottom: 1px solid var(--line) }
.form-list-name { margin: 0; font-size: 15px; font-weight: 650 }
.form-list-name a { text-decoration: none }
.form-list-name a:hover { text-decoration: underline }
.form-list .row { align-items: center }
.back-to-list { display: inline-block; margin-top: 4px }
.form-card-facts { flex: 1; min-width: 0; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
.form-chevron { flex: none; color: #52665a; transition: transform .15s }
.form-card[open] > .form-card-summary { border-bottom: 1px solid var(--line) }
.form-card[open] > .form-card-summary > .form-chevron, .form-section[open] > summary > .form-chevron { transform: rotate(180deg) }
.form-section { border-bottom: 1px solid var(--line); padding-bottom: 12px }
.form-section > summary { display: flex; align-items: center; gap: 10px; padding: 14px 0 2px; cursor: pointer; list-style: none }
.form-section > summary .form-part { margin: 0 }
.form-section-hint { flex: 1; min-width: 0; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
.field-card-body .field-help { margin-top: 5px }
.field-card-flags { align-self: end; padding-bottom: 11px }
.field-card-flags .check-label { margin: 12px 0 0 }
@@ -916,11 +1027,16 @@ details.card > summary:hover { background: #fafbf8 }
.form-look .color-control { margin-top: 0 }
@media (max-width: 1100px) { .form-editor { grid-template-columns: 1fr } }
@media (max-width: 700px) {
.field-card { padding-top: 8px }
.field-card-head { grid-template-columns: 26px minmax(0, 1fr) }
.field-card-title, .field-card-type { grid-column: 2 }
.field-card-head { grid-template-columns: 24px minmax(0, 1fr) auto; gap: 8px }
.field-card-title { grid-column: 2 / 4; padding-right: 36px }
.field-card-type { grid-column: 2 }
.field-card-head .field-card-required { grid-column: 3 }
.field-toggle { grid-column: 2 }
.field-card-head .field-remove { position: absolute; top: 8px; right: 8px }
.field-card-meta { margin-left: 0 }
.field-card-body { padding-left: 0 }
.form-card-facts { white-space: normal }
.form-card > .form-card-summary { flex-wrap: wrap; padding: 14px 16px }
}
/* ---- design: palette cards, ready-made palettes, font cards, typography preview -------- */
@@ -1006,3 +1122,39 @@ details.card > summary:hover { background: #fafbf8 }
.menu-entry > .menu-drag-handle { position: absolute; left: 14px; top: 10px; z-index: 1 }
.menu-entry summary { padding-left: 58px; min-height: 60px }
@media (max-width: 560px) { .menu-entry > .menu-drag-handle { left: 8px; top: 8px } .menu-entry summary { padding-left: 50px; min-height: 56px } }
/* Texte der Website in weiteren Sprachen (Einstellungen → Sprachen, ADR 111). */
.language-texts { margin-top: 24px; padding-top: 16px; border-top: 1px solid #dfe5e1 }
.language-texts h3 { margin: 0 0 6px; font-size: 15px }
.language-text-group { display: grid; gap: 6px; border: 1px solid #dfe5e1; border-radius: 8px; padding: 12px 16px; margin: 12px 0; min-width: 0 }
.language-text-group legend { font-weight: 600; padding: 0 4px }
/* ---- Update mit einem Klick (ADR 113), nach dem Vorbild von reiseSoft --------------------- */
.update-wait, .update-aktuell { display: flex; align-items: center; gap: 10px; margin: 0 0 12px }
.update-spinner { width: 16px; height: 16px; flex: none; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: update-dreh .8s linear infinite }
@keyframes update-dreh { to { transform: rotate(360deg) } }
@media (prefers-reduced-motion: reduce) { .update-spinner { animation-duration: 2.4s } }
.update-neu { border: 1px solid #b9d7c2; background: #f1f8f2; border-radius: 10px; padding: 16px 18px; margin: 0 0 14px; display: grid; gap: 6px; justify-items: start }
.update-neu h3 { margin: 0; font-size: 17px }
.update-neu .muted { margin: 0 }
.update-neu-label { margin: 0; font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: #1f6b45 }
.update-notizen { max-height: 220px; overflow: auto; width: 100%; font-size: 13px; line-height: 1.55; color: #33413a; background: #fff; border: 1px solid #dfe5e1; border-radius: 8px; padding: 10px 12px; margin: 6px 0 10px }
.update-lauf { background: #1f2d27; color: #e9efe9; border-radius: 12px; padding: 18px 20px; margin: 0 0 14px; display: grid; gap: 12px; justify-items: start }
.update-lauf-kopf { display: flex; align-items: center; gap: 10px; margin: 0; font-weight: 650 }
.update-schritte { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; width: 100% }
.update-schritt { display: flex; align-items: center; gap: 10px; font-size: 14px }
.update-marke { display: grid; place-items: center; width: 22px; height: 22px; flex: none; border-radius: 50%; border: 2px solid #5e6f66; font-size: 12px; font-weight: 700 }
.update-schritt.is-erledigt .update-marke { background: #3fa46a; border-color: #3fa46a; color: #fff }
.update-schritt.is-aktiv .update-marke { border-color: #f2c14e; box-shadow: 0 0 0 4px #f2c14e33; animation: update-puls 1.2s ease-in-out infinite }
.update-schritt.is-offen { color: #8f9e96 }
.update-schritt.is-fehler .update-marke { background: #d4553f; border-color: #d4553f; color: #fff }
@keyframes update-puls { 50% { box-shadow: 0 0 0 8px #f2c14e11 } }
@media (prefers-reduced-motion: reduce) { .update-schritt.is-aktiv .update-marke { animation: none } }
.update-meldung { margin: 0; font-size: 13px; color: #c9d4cd }
.update-meldung.is-ok { color: #9fdcb5 }
.update-meldung.is-fehler { color: #ffb4a6 }
.update-lauf .secondary { background: transparent; color: #fff; border-color: #ffffff66 }
.update-letztes { margin: 0 0 12px }
.update-manual { margin-top: 6px }
.update-dialog { max-width: 460px; border: 0; border-radius: 12px; padding: 24px 26px; box-shadow: 0 18px 48px #0003 }
.update-dialog::backdrop { background: #1f2d2766 }
.update-dialog h2 { margin: 0 0 10px; font-size: 18px }
.update-dialog .dialog-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px }
+91
View File
@@ -0,0 +1,91 @@
// Einwilligung ohne Neuladen (ADR 115).
//
// Ohne dieses Skript schicken der Hinweis und der Knopf am eingebetteten Inhalt ein Formular ab,
// der Server setzt das Cookie und leitet auf dieselbe Seite zurück - die Seite lädt neu. Mit dem
// Skript geht dasselbe Formular im Hintergrund an denselben Endpunkt; die Kopfzeile
// X-Kant-Consent sagt dem Server, dass keine Weiterleitung nötig ist. Danach holt das Skript die
// Seite noch einmal und übernimmt daraus nur, was sich durch die Entscheidung ändert: die
// eingebetteten Inhalte und die Zeile „Rechtliches“. Die Adresse eines Inhalts steht so weiterhin
// erst nach der Einwilligung im Dokument.
//
// Geht dabei etwas schief, schickt das Skript das Formular ganz normal ab: lieber einmal neu
// laden als eine Entscheidung, die nicht ankommt.
(() => {
const forms = document.querySelectorAll('.consent-bar form, .embed-blocked form');
if (forms.length === 0) {
return;
}
const replaceFrom = async () => {
const response = await fetch(location.href, { credentials: 'same-origin', headers: { Accept: 'text/html' } });
if (!response.ok) {
throw new Error('Seite nicht geladen');
}
const fresh = new DOMParser().parseFromString(await response.text(), 'text/html');
for (const blocked of document.querySelectorAll('.embed-blocked[id]')) {
const next = fresh.getElementById(blocked.id);
if (next && !next.classList.contains('embed-blocked')) {
blocked.replaceWith(document.importNode(next, true));
}
}
for (const legal of document.querySelectorAll('.block-legal[id]')) {
const next = fresh.getElementById(legal.id);
if (next) {
legal.replaceWith(document.importNode(next, true));
}
}
};
for (const form of forms) {
form.addEventListener('submit', async (event) => {
event.preventDefault();
const button = event.submitter;
const body = new FormData(form, button);
const placeholder = form.closest('.embed-blocked');
for (const control of form.querySelectorAll('button')) {
control.disabled = true;
}
try {
const saved = await fetch(form.action, { method: 'POST', body, credentials: 'same-origin', headers: { 'X-Kant-Consent': '1' } });
if (!saved.ok) {
throw new Error('Entscheidung nicht gespeichert');
}
// Wer etwas erlaubt hat, bekommt die Inhalte jetzt; wer abgelehnt hat, braucht keinen
// zweiten Abruf - es ändert sich nichts außer dem Hinweis.
if (body.get('choice') !== 'none' && document.querySelector('.embed-blocked')) {
await replaceFrom();
}
document.querySelector('.consent-bar')?.remove();
if (placeholder) {
// Der Knopf ist mit dem Platzhalter verschwunden; der Fokus geht an den Inhalt, der an
// seiner Stelle steht.
const frame = document.getElementById(placeholder.id)?.querySelector('iframe');
frame?.focus();
} else {
// Entschieden im Hinweis: der Fokus beginnt wieder am Seitenanfang, wie nach dem
// Neuladen - der nächste Tab führt zu „Zum Inhalt springen“.
document.getElementById('seitenanfang')?.focus({ preventScroll: true });
}
} catch {
// Rückfall: das Formular normal absenden, samt gedrücktem Knopf.
for (const control of form.querySelectorAll('button')) {
control.disabled = false;
}
withChoice(form, button).submit();
}
});
}
// form.submit() schickt den gedrückten Knopf nicht mit; sein Wert wandert als verstecktes Feld
// ins Formular, damit der Rückfall dieselbe Entscheidung sendet.
function withChoice(form, button) {
if (button?.name) {
const field = document.createElement('input');
field.type = 'hidden';
field.name = button.name;
field.value = button.value;
form.append(field);
}
return form;
}
})();
+8 -4
View File
@@ -1,5 +1,5 @@
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', postnav: 'Beitragsnavigation', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider', legal: 'Rechtszeile', social: 'Soziale Profile', html: 'Eigenes HTML' };
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', postnav: '⇄', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄', social: '⚭', html: '</>' };
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', postnav: 'Beitragsnavigation', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider', legal: 'Rechtszeile', social: 'Soziale Profile', html: 'Eigenes HTML', language: 'Sprachwahl' };
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', postnav: '⇄', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄', social: '⚭', html: '</>', language: '文' };
// A namespaced type belongs to a plugin this installation may not have. Such blocks stay in the
// document, stay selectable and movable, but the editor never edits or fills them.
/**
@@ -12,7 +12,7 @@ export const CATEGORIES = [
{ name: 'Inhalt', types: ['heading', 'text', 'image', 'button', 'icon', 'divider', 'spacer'] },
{ name: 'Bausteine', types: ['iconcard', 'stat', 'quote', 'cta', 'contact', 'social', 'logos', 'progress'] },
{ name: 'Gruppen', types: ['gallery', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'] },
{ name: 'Seite und Daten', types: ['postlist', 'postnav', 'breadcrumb', 'menu', 'form', 'legal', 'partialref', 'embed', 'html'] },
{ name: 'Seite und Daten', types: ['postlist', 'postnav', 'breadcrumb', 'menu', 'language', 'form', 'legal', 'partialref', 'embed', 'html'] },
];
/** How many columns a row can be added with. */
@@ -128,6 +128,8 @@ export function createBlock(type) {
if (type === 'postlist') block.props = { count: 3, category: '', tag: '', layout: 'karten' };
if (type === 'postnav') block.props = { previousLabel: 'Vorheriger Beitrag', nextLabel: 'Nächster Beitrag' };
if (type === 'legal') block.props = { label: 'Rechtliches' };
// Welche Sprachen es gibt, weiß die Website; der Block sagt nur, ob er Kürzel oder Namen zeigt.
if (type === 'language') block.props = { display: 'code' };
// Leer eingefuegt: nichts steht auf der Seite, bis jemand Code eintraegt.
if (type === 'html') block.props = { html: '' };
if (type === 'iconcard') block.props = { icon: 'check', title: 'Deine Leistung', text: 'Beschreibe hier kurz, worum es geht.', href: '' };
@@ -180,6 +182,7 @@ export function repairSchema(document) {
}
};
walk(document.blocks);
if (document.translations) upgradeDocument(document, 27);
return document;
}
@@ -194,6 +197,7 @@ export function upgradeForType(document, type) {
if (type === 'postnav') upgradeDocument(document, 18);
if (type === 'legal') upgradeDocument(document, 19);
if (type === 'html') upgradeDocument(document, 24);
if (type === 'language') upgradeDocument(document, 27);
if (['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'].includes(type)) upgradeDocument(document, 6);
if (type === 'tabs' || type === 'tabitem') upgradeDocument(document, 7);
if (type === 'form') upgradeDocument(document, 8);
@@ -344,7 +348,7 @@ export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' }
// blieb bei 12 stehen, während Bausteine schon v13 bis v15 verlangten: in einem aktuellen Dokument
// liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt
// nirgends. Eine Zahl, an einer Stelle.
export const SCHEMA_VERSION = 26;
export const SCHEMA_VERSION = 27;
export const VISIBILITY = { all: 'Auf allen Geräten', desktop: 'Nur Desktop', tablet: 'Nur Tablet', mobile: 'Nur Mobil', 'hide-desktop': 'Auf Desktop ausblenden', 'hide-tablet': 'Auf Tablet ausblenden', 'hide-mobile': 'Auf Mobil ausblenden', hidden: 'Überall ausblenden' };
+32
View File
@@ -285,3 +285,35 @@ html:has(>.editor-body){overflow:hidden;height:100%}
.canvas-panel{height:62vh;flex:none}.sidebar{height:62vh;flex:none;border-right:0;border-top:1px solid var(--line)}
.library-buttons{grid-template-columns:repeat(4,minmax(0,1fr))}
}
/* Übersetzen (ADR 111). Der Umschalter sitzt neben der Vorschaugröße; im Übersetzungsmodus
verschwindet alles, was den Aufbau ändert - Reiter, Blockaktionen, Werkzeuge der Vorschau. */
.language-switch{display:flex;gap:2px;padding:3px;background:#ffffff14;border-radius:9px}
.language-switch[hidden]{display:none}
.language-switch button{border:0;background:transparent;color:#c9d4cd;padding:0 10px;min-height:30px;min-width:36px;border-radius:6px;font-size:12px;font-weight:700;letter-spacing:.04em}
.language-switch button:hover:not(:disabled){color:#fff}
.editor-header .language-switch button[aria-pressed=true]{background:#fff;color:var(--ink)}
#editor[data-language] .language-switch button[aria-pressed=true]{background:#f5c451;color:#1d1d1b}
#editor[data-language] .sidebar-nav,#editor[data-language] .sidebar-foot,#editor[data-language] .ov-toolbar,#editor[data-language] .ov-insert,#editor[data-language] .ov-resize,#editor[data-language] .ov-drop{display:none!important}
.translate-head{display:grid;gap:8px;margin-bottom:16px}
.translate-head h2{margin:0;font-size:16px}
.translate-head h2:focus{outline:none}
.translate-head progress{width:100%;height:8px;accent-color:var(--ink)}
.translate-summary{margin:0;font-size:13px;font-weight:600}
.translate-group{border-top:1px solid var(--line,#dfe5e1);padding:12px 0 4px;scroll-margin-top:12px}
.translate-group.is-current{box-shadow:inset 3px 0 0 #f5c451;padding-left:10px}
.translate-group h3{margin:0 0 8px;font-size:13px}
.translate-jump{border:0;background:none;padding:0;font:inherit;font-weight:700;color:inherit;text-align:left;cursor:pointer}
.translate-jump:hover{text-decoration:underline}
.translate-field{display:grid;gap:5px;margin-bottom:14px}
.translate-field-head{display:flex;justify-content:space-between;align-items:baseline;gap:8px}
.translate-field-head label{margin:0}
.translate-state{font-size:11px;font-weight:700;padding:2px 7px;border-radius:999px;background:#e8ece9;color:#384640;white-space:nowrap}
.translate-field[data-state=missing] .translate-state{background:#fde9c8;color:#6b3f00}
.translate-field[data-state=stale] .translate-state{background:#fbd5d0;color:#7a1d12}
.translate-field[data-state=done] .translate-state{background:#d7eedf;color:#1c5233}
.translate-original{margin:0;padding:6px 8px;border-radius:6px;background:#f2f4f3;color:#46514c;font-size:12.5px;line-height:1.45;white-space:pre-line;max-height:7.5em;overflow:auto}
.translate-actions{display:flex;gap:12px;flex-wrap:wrap}
.translate-slug{display:flex;align-items:center;gap:4px}
.translate-slug input{flex:1;min-width:0}
.translate-prefix{font-size:12px;color:#5b6862;white-space:nowrap}
.translate-filter{margin-top:4px}
+257 -12
View File
@@ -13,6 +13,7 @@ import { VISIBILITY, setVisibility, registerPlugins, pluginDefinition, PLUGIN_PR
// Symbole, ihre deutschen Namen und die Kachelauswahl. Die Liste spiegelt DocumentValidator::ICONS.
import { renderIconField } from './icons.js';
import { entries as translationEntries, setText, confirmText, setPageValue, slugify, plainText } from './translate.js';
const ICON_SIZES = [12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 120, 160, 200, 240];
// Mirrors SocialProfiles::NETWORKS; the addresses themselves live in the settings.
const SOCIAL_NETWORKS = { facebook: 'Facebook', instagram: 'Instagram', linkedin: 'LinkedIn', youtube: 'YouTube', tiktok: 'TikTok', x: 'X', threads: 'Threads', pinterest: 'Pinterest', xing: 'Xing', whatsapp: 'WhatsApp', google: 'Google-Unternehmensprofil' };
@@ -32,6 +33,9 @@ let device = 'desktop', propertyPanel = 'content', designTokens, designSchema, d
// same factor, so it is kept here instead of being recomputed from the transform string.
let previewScale = 1, overlay = null, pendingInsert = null;
const collapsed = new Set();
// Übersetzen (ADR 111): welche Sprachen es gibt, und welche gerade bearbeitet wird. null ist die
// Hauptsprache - dann ist der Editor genau der von vorher.
let languageInfo = null, language = null, translateFilter = 'all';
let renderedProperties = null;
let revealAfterPreview = null;
let previewPlayback = false, stopPlayback = () => {};
@@ -119,7 +123,7 @@ function controls() {
$('#undo').disabled = !ready || busy || !history.past.length;
$('#redo').disabled = !ready || busy || !history.future.length;
const current = ready ? locate(history.document.blocks, selected) : null;
for (const id of ['duplicate', 'delete', 'move-up', 'move-down', 'nest', 'outdent', 'go-parent', 'add-here', 'close-element']) $(`#${id}`).disabled = !current || busy;
for (const id of ['duplicate', 'delete', 'move-up', 'move-down', 'nest', 'outdent', 'go-parent', 'add-here', 'close-element']) $(`#${id}`).disabled = !current || busy || Boolean(language);
if (current) {
$('#move-up').disabled ||= current.index === 0;
$('#move-down').disabled ||= current.index === current.siblings.length - 1;
@@ -142,6 +146,8 @@ function controls() {
$('[data-panel="design"]').disabled ||= !current;
$('#open-media-library').disabled = !ready || busy;
$('#preview-motion').disabled = !ready || busy;
$('#language-switch').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
$('#translate-panel').querySelectorAll('input,textarea,button').forEach(input => { input.disabled = busy; });
}
function renderTree() {
@@ -203,6 +209,7 @@ function choose(id, scroll = true) {
history.mergeKey = null;
syncView();
renderTree(); renderProperties(); controls(); highlight(scroll);
if (language && selected) focusTranslation(selected);
}
/**
@@ -210,6 +217,7 @@ function choose(id, scroll = true) {
* the exception: there the selection only moves the highlight, editing is one more step.
*/
function syncView() {
if (language) return;
if (selected && view !== 'structure') setView('element');
else if (!selected && view === 'element') setView('elements');
}
@@ -594,6 +602,12 @@ function renderContent(block, panel) {
if (block.type === 'legal') {
field('Beschriftung für Vorlesesoftware', 'label', block.props?.label || 'Rechtliches', { maxLength: 60, help: 'Der Baustein zeigt Impressum, Datenschutz und – sobald jemand gefragt wurde – den Weg zurück zur Einwilligung. Die Adressen stehen in den Einstellungen. Platziere ihn dort, wo die Links erscheinen sollen, zum Beispiel im Fußbereich.' });
}
if (block.type === 'language') {
segmented('Anzeige', 'display', block.props.display === 'name' ? 'name' : 'code', [['code', 'Kürzel (DE · EN)'], ['name', 'Namen (Deutsch · English)']], 27);
const help = document.createElement('p'); help.className = 'field-help';
help.textContent = 'Zeigt dieselbe Seite in jeder Sprache, in der sie veröffentlicht ist. Die Sprachen stehen unter Einstellungen → Sprachen. Auf einer Seite, die es nur in einer Sprache gibt, bleibt die Sprachwahl leer. Am besten in den Kopfbereich setzen.';
panel.append(help);
}
if (block.type === 'postnav') {
field('Beschriftung zurück', 'previousLabel', block.props.previousLabel, { maxLength: 60 });
field('Beschriftung weiter', 'nextLabel', block.props.nextLabel, { maxLength: 60, help: 'Die Beiträge selbst sucht die Website beim Ausliefern - innerhalb derselben Kategorie.' });
@@ -978,7 +992,7 @@ async function updatePreview() {
const sequence = previewSequence;
const controller = new AbortController(); previewController = controller;
try {
const html = await request('/preview', { document: history.document }, controller.signal);
const html = await request('/preview', { document: history.document, language }, controller.signal);
if (sequence !== previewSequence) return;
const frame = $('#preview');
const top = frame.contentWindow?.scrollY || 0;
@@ -1019,7 +1033,7 @@ async function updatePreview() {
menuSummary.parentElement.open = !menuSummary.parentElement.open;
}
const insert = event.target.closest('[data-editor-target]');
if (insert && !busy && history) {
if (insert && !busy && history && !language) {
stopInlineEdit();
const id = insert.dataset.editorTarget;
if (!locate(history.document.blocks, id)) return;
@@ -1044,12 +1058,13 @@ async function updatePreview() {
const element = event.target.closest('[id]');
if (!element) return;
const block = locate(history.document.blocks, element.id)?.block;
if (language) return;
if (block && INLINE_TEXT[block.type]) { event.preventDefault(); choose(element.id, false); startInlineEdit(element.id); }
});
// Right click: the element's actions where the pointer is, as in Flatsome.
doc.addEventListener('contextmenu', event => {
const element = event.target.closest('[id]');
if (!element || !locate(history.document.blocks, element.id) || busy || inlineEdit) return;
if (!element || !locate(history.document.blocks, element.id) || busy || inlineEdit || language) return;
event.preventDefault();
choose(element.id, false);
openContextMenu(element.id, event.clientX, event.clientY);
@@ -1065,7 +1080,8 @@ async function updatePreview() {
// The head may arrive before the blocks. Decorate only once the complete DOM exists.
const finish = () => {
if (sequence !== previewSequence || doc !== frame.contentDocument) return;
decorateContainers(doc, history.document);
// Beim Übersetzen gibt es nichts einzufügen: der Aufbau gehört der Hauptsprache.
if (!language) decorateContainers(doc, history.document);
overlay?.attach();
styleReady.then(loaded => {
if (sequence !== previewSequence || doc !== frame.contentDocument) return;
@@ -1075,7 +1091,9 @@ async function updatePreview() {
frame.style.visibility = '';
showMotionStatus();
playPreview();
$('#preview-status').textContent = loaded
$('#preview-status').textContent = loaded && language
? `Entwurf auf ${languageName(language)} · Klicken springt zum Text in der Übersetzungsliste. Leere Felder zeigen den Text der Hauptsprache.`
: loaded
? 'Entwurf · Klicken wählt aus, Doppelklick bearbeitet Text, Elemente lassen sich hierher ziehen.'
: 'Editor-Hilfsflächen konnten nicht geladen werden. Bitte den Editor neu laden.';
});
@@ -1114,7 +1132,226 @@ function changed() {
if (!locate(history.document.blocks, selected)) selected = null;
if (selected && !$('#preview').contentDocument?.getElementById(selected)) revealAfterPreview = selected;
syncView();
renderTree(); renderProperties(); controls(); schedulePreview();
renderTree(); renderProperties(); renderTranslations(); controls(); schedulePreview();
}
// ---------------------------------------------------------------------------------------------
// Übersetzen (ADR 111).
//
// Dieselbe Seite, dieselbe Vorschau - nur die Seitenleiste zeigt statt Werkzeugkasten und
// Eigenschaften die Liste der Texte: Original links darüber, Übersetzung darunter. Aufbau und
// Gestaltung bleiben in der Hauptsprache; hier lässt sich nichts verschieben oder löschen.
// Jede Eingabe ist ein gewöhnlicher Schritt im Verlauf, also gelten Undo, Speichern und
// Veröffentlichen wie immer und für alle Sprachen zugleich.
// ---------------------------------------------------------------------------------------------
const languageName = code => languageInfo?.extra.find(item => item.code === code)?.name ?? languageInfo?.main.name ?? code;
function renderLanguageSwitch() {
const holder = $('#language-switch');
const extra = languageInfo?.extra ?? [];
holder.hidden = !extra.length;
$('#publish-languages').hidden = !extra.length;
holder.replaceChildren();
if (!extra.length) return;
for (const item of [languageInfo.main, ...extra]) {
const button = document.createElement('button'); button.type = 'button';
button.textContent = item.code.toUpperCase();
button.title = item === languageInfo.main ? `${item.name} – Hauptsprache: Aufbau, Gestaltung und Texte` : `${item.name} – nur die Texte übersetzen`;
// Der Name beginnt mit dem sichtbaren Kürzel, damit auch Sprachsteuerung den Knopf findet.
button.setAttribute('aria-label', item === languageInfo.main ? `${item.code.toUpperCase()} – ${item.name} (Hauptsprache) bearbeiten` : `${item.code.toUpperCase()} – ${item.name} übersetzen`);
const active = (language ?? languageInfo.main.code) === item.code;
button.setAttribute('aria-pressed', String(active));
button.addEventListener('click', () => setLanguage(item === languageInfo.main ? null : item.code));
holder.append(button);
}
}
function setLanguage(code) {
if (busy || !history || code === language) return;
stopInlineEdit(); closeContextMenu(); setPendingInsert(null);
language = code;
if (language) root.dataset.language = language; else delete root.dataset.language;
selected = null; history.mergeKey = null; translateFilter = 'all';
setView(language ? 'translate' : 'elements');
renderLanguageSwitch(); renderTree(); renderProperties(); renderTranslations(); controls(); schedulePreview();
if (language) $('#translate-panel').querySelector('h2')?.focus();
}
/** Springt in der Liste zu den Texten des gewählten Blocks oder des ersten Blocks darin. */
function focusTranslation(id) {
const panel = $('#translate-panel');
const ids = [];
const collect = block => { ids.push(block.id); (block.children ?? []).forEach(collect); };
const block = locate(history.document.blocks, id)?.block;
if (!block) return;
collect(block);
const group = ids.map(item => panel.querySelector(`.translate-group[data-block-id="${CSS.escape(item)}"]`)).find(Boolean);
if (!group) return;
if (group.hidden) { translateFilter = 'all'; renderTranslations(); return focusTranslation(id); }
panel.querySelectorAll('.translate-group.is-current').forEach(item => item.classList.remove('is-current'));
group.classList.add('is-current');
const body = $('.sidebar-body');
body.scrollTop += group.getBoundingClientRect().top - body.getBoundingClientRect().top - 12;
group.querySelector('textarea,input,[contenteditable="true"]')?.focus({ preventScroll: true });
}
const STATE_TEXT = { missing: 'Fehlt', stale: 'Original geändert', done: 'Übersetzt' };
function renderTranslations() {
const panel = $('#translate-panel');
if (!language || !history) { panel.replaceChildren(); return; }
const scroll = $('.sidebar-body').scrollTop;
const info = languageInfo.extra.find(item => item.code === language);
const doc = history.document;
const translation = doc.translations?.[language] ?? {};
// Ein Kopf- oder Fußbereich hat weder Adresse noch Seitentitel - nur Texte in Blöcken.
const list = translationEntries(doc, language).filter(entry => !languageInfo.area || entry.blockId);
const count = { total: list.length, done: list.filter(entry => entry.state !== 'missing').length, stale: list.filter(entry => entry.state === 'stale').length };
const el = (tag, className = '', text = '') => { const node = document.createElement(tag); if (className) node.className = className; if (text) node.textContent = text; return node; };
panel.replaceChildren();
const head = el('div', 'translate-head');
const title = el('h2', '', `Übersetzung: ${info.name}`); title.tabIndex = -1;
const meter = el('progress'); meter.max = Math.max(1, count.total); meter.value = count.done; meter.setAttribute('aria-label', 'Übersetzt');
const summary = el('p', 'translate-summary', `${count.done} von ${count.total} Texten übersetzt${count.stale ? ` · ${count.stale} mit geändertem Original` : ''}`);
summary.setAttribute('role', 'status');
const hint = el('p', 'field-help', `Aufbau, Bilder und Gestaltung bearbeitest du in ${languageInfo.main.name}. Hier stehen nur die Texte. Ein leeres Feld zeigt auf der Website den Text in ${languageInfo.main.name}.`);
const filter = el('div', 'segmented translate-filter'); filter.setAttribute('role', 'group'); filter.setAttribute('aria-label', 'Texte filtern');
filter.style.gridTemplateColumns = 'repeat(3, 1fr)';
for (const [value, text] of [['all', 'Alle'], ['missing', 'Fehlend'], ['stale', 'Geändert']]) {
const option = el('button', '', text); option.type = 'button'; option.setAttribute('aria-pressed', String(translateFilter === value));
option.addEventListener('click', () => { translateFilter = value; renderTranslations(); });
filter.append(option);
}
head.append(title, meter, summary, hint, filter);
panel.append(head);
// Adresse und Suchtitel: gehören nur zur Übersetzung, ein Original gibt es dafür nicht.
const page = el('section', 'translate-group translate-page'); page.dataset.blockId = '';
if (languageInfo.area) page.hidden = true;
page.append(el('h3', '', 'Seite'));
const published = languageInfo.published?.[language];
if (languageInfo.home) {
page.append(el('p', 'field-help', `Startseite – erscheint unter /${language}.`));
} else {
const label = el('label', '', 'Adresse'); label.htmlFor = 'translate-slug';
const wrap = el('div', 'translate-slug');
const prefix = el('span', 'translate-prefix', `/${language}/…/`); prefix.setAttribute('aria-hidden', 'true');
const input = el('input'); input.id = 'translate-slug'; input.maxLength = 80; input.value = translation.slug ?? '';
input.placeholder = languageInfo.slug; input.spellcheck = false; input.autocomplete = 'off';
input.pattern = '[a-z0-9]+(-[a-z0-9]+)*';
const help = el('p', 'field-help', `Kleinbuchstaben, Ziffern, Bindestriche. Leer: wie in ${languageInfo.main.name} (${languageInfo.slug}).`); help.id = 'translate-slug-help';
input.setAttribute('aria-describedby', help.id);
input.addEventListener('input', () => { history.change(content => setPageValue(content, language, 'slug', input.value), 'tr-slug'); controls(); });
const suggest = el('button', 'text-button', 'Aus dem übersetzten Titel'); suggest.type = 'button';
suggest.addEventListener('click', () => {
const proposal = slugify(history.document.translations?.[language]?.title ?? '');
if (!proposal) { help.textContent = 'Erst den Seitentitel übersetzen, dann lässt sich die Adresse daraus bilden.'; return; }
input.value = proposal; history.change(content => setPageValue(content, language, 'slug', proposal)); controls();
});
wrap.append(prefix, input);
page.append(label, wrap, help, suggest);
}
const seoLabel = el('label', '', 'Titel für Suchmaschinen'); seoLabel.htmlFor = 'translate-seo';
const seo = el('input'); seo.id = 'translate-seo'; seo.maxLength = 160; seo.value = translation.seoTitle ?? ''; seo.lang = language;
seo.placeholder = 'Leer: der übersetzte Seitentitel';
seo.addEventListener('input', () => { history.change(content => setPageValue(content, language, 'seoTitle', seo.value), 'tr-seo'); controls(); });
page.append(seoLabel, seo);
page.append(el('p', 'field-help', published
? `Veröffentlicht unter /${language}${languageInfo.home ? '' : `/${published}`}. Änderungen gehen mit „Veröffentlichen“ online.`
: `Noch nicht auf der Website. Die Seite erscheint auf ${info.name}, sobald ein Text übersetzt und die Seite veröffentlicht ist.`));
panel.append(page);
// Die Texte, gruppiert nach Block in Seitenreihenfolge; Seitentitel und Beschreibung vorn.
let group = null, groupId;
for (const entry of list) {
const id = entry.blockId ?? '__page';
if (id !== groupId) {
groupId = id;
group = el('section', 'translate-group'); group.dataset.blockId = entry.blockId ?? '';
const heading = el('h3');
if (entry.blockId) {
const jump = el('button', 'translate-jump', describe(entry.block)); jump.type = 'button';
jump.title = 'In der Vorschau zeigen';
const current = group;
jump.addEventListener('click', () => { selected = entry.blockId; highlight(true); panel.querySelectorAll('.translate-group.is-current').forEach(item => item.classList.remove('is-current')); current.classList.add('is-current'); });
heading.append(jump);
} else heading.textContent = 'Seitentitel und Beschreibung';
group.append(heading);
panel.append(group);
}
group.append(translationField(entry, info));
}
for (const section of panel.querySelectorAll('.translate-group:not(.translate-page)')) {
const fields = [...section.querySelectorAll('.translate-field')];
for (const field of fields) field.hidden = translateFilter !== 'all' && field.dataset.state !== translateFilter;
section.hidden = fields.every(field => field.hidden);
}
if (!list.length) panel.append(el('p', 'panel-hint', languageInfo.area ? 'In diesem Bereich gibt es keine Texte zum Übersetzen. Menübeschriftungen übersetzt du unter Menüs.' : 'Auf dieser Seite gibt es noch keine Texte zum Übersetzen.'));
else if (languageInfo.area) head.append(el('p', 'field-help', 'Gespeicherte Übersetzungen eines Bereichs gelten sofort auf allen Seiten dieser Sprache. Menübeschriftungen übersetzt du unter Menüs.'));
else if (translateFilter !== 'all' && !panel.querySelector('.translate-field:not([hidden])')) panel.append(el('p', 'panel-hint', translateFilter === 'missing' ? 'Alles übersetzt.' : 'Kein Original wurde seit der Übersetzung geändert.'));
if (selected) panel.querySelector(`.translate-group[data-block-id="${CSS.escape(selected)}"]`)?.classList.add('is-current');
$('.sidebar-body').scrollTop = scroll;
controls();
}
/** Kurzname eines Blocks für die Liste: Art und der Anfang des Originals. */
function describe(block) {
const text = plainText(block.props?.text ?? block.props?.title ?? block.props?.alt ?? block.props?.label ?? '');
return text ? `${blockLabel(block)} · ${text.length > 40 ? `${text.slice(0, 40)} …` : text}` : blockLabel(block);
}
function translationField(entry, info) {
const el = (tag, className = '', text = '') => { const node = document.createElement(tag); if (className) node.className = className; if (text) node.textContent = text; return node; };
const row = el('div', 'translate-field'); row.dataset.state = entry.state; row.dataset.key = entry.key;
const id = `tr-${entry.key.replace(/[^a-zA-Z0-9_-]/g, '-')}`;
const label = el('label', '', entry.label); label.htmlFor = id;
const badge = el('span', 'translate-state', STATE_TEXT[entry.state]);
const top = el('div', 'translate-field-head'); top.append(label, badge);
const original = el('p', 'translate-original'); original.lang = languageInfo.main.code;
original.textContent = entry.field.rich ? plainText(entry.original) : entry.original;
const originalId = `${id}-original`; original.id = originalId;
const update = value => {
history.change(content => setText(content, language, entry.key, value), `tr-${entry.key}`);
const state = value.trim() === '' ? 'missing' : 'done';
row.dataset.state = state; badge.textContent = STATE_TEXT[state];
controls(); schedulePreview();
};
row.append(top, original);
let input;
const rich = entry.field.rich === 'text' ? history.document.schemaVersion >= 12 : entry.field.rich === 'heading' ? history.document.schemaVersion >= 17 : false;
if (rich) {
const editor = createRichText(entry.value, markup => update(plainText(markup) === '' ? '' : markup), { label: `${entry.label} auf ${info.name}` });
editor.element.lang = language;
if (info.rtl) editor.element.dir = 'rtl';
editor.element.querySelector('[contenteditable]')?.setAttribute('aria-describedby', originalId);
// Das Label zeigt auf die Fläche, die man beschreibt.
const surface = editor.element.querySelector('[contenteditable]'); if (surface) { surface.id = id; label.addEventListener('click', () => surface.focus()); }
row.append(editor.element);
} else {
input = el(entry.field.multiline ? 'textarea' : 'input');
input.id = id; input.value = entry.value; input.maxLength = entry.field.max; input.lang = language;
if (info.rtl) input.dir = 'rtl';
if (entry.field.multiline) input.rows = 3;
input.setAttribute('aria-describedby', originalId);
input.addEventListener('input', () => update(input.value));
row.append(input);
}
const actions = el('div', 'translate-actions');
if (entry.state === 'missing') {
const copy = el('button', 'text-button', 'Original übernehmen'); copy.type = 'button';
copy.title = 'Den Text der Hauptsprache als Ausgangspunkt einsetzen';
copy.addEventListener('click', () => { history.change(content => setText(content, language, entry.key, entry.original)); changed(); });
actions.append(copy);
}
if (entry.state === 'stale') {
const ok = el('button', 'text-button', 'Übersetzung passt'); ok.type = 'button';
ok.title = 'Das Original wurde geändert, die Übersetzung stimmt trotzdem';
ok.addEventListener('click', () => { history.change(content => confirmText(content, language, entry.key)); changed(); });
actions.append(ok);
}
if (actions.childElementCount) row.append(actions);
return row;
}
async function loadDesignSchema() {
@@ -1152,10 +1389,15 @@ async function load() {
// der Server schon die Vorschau ab (siehe repairSchema).
history = new EditorHistory(repairSchema(data.document));
({ version, revisionId, publishedRevisionId, csrfToken } = data);
languageInfo = data.languages ?? null;
if (language && !languageInfo?.extra.some(item => item.code === language)) { language = null; delete root.dataset.language; }
renderLanguageSwitch();
// Die Bausteine der eingeschalteten Plugins kommen mit dem Dokument; der Werkzeugkasten
// bekommt dafür eine eigene Gruppe.
registerPlugins(data.plugins || [], data.presets || []); buildLibrary(); buildPluginPresets();
selected = null; propertyPanel = 'content'; collapsed.clear(); conflict = false; notify(); changed(); resizePreview();
selected = null; propertyPanel = 'content'; collapsed.clear(); conflict = false; notify();
if (language) setView('translate');
changed(); resizePreview();
} catch (error) { notify(error.message, true); }
finally { busy = false; controls(); }
}
@@ -1167,13 +1409,15 @@ async function save(publish = false) {
try {
const data = await request(publish ? '/publish' : '/draft', publish ? { expectedVersion: version, revisionId } : { expectedVersion: version, document: snapshot });
({ version, revisionId, publishedRevisionId, csrfToken } = data);
if (data.languages) languageInfo = data.languages;
// Der Server bereinigt Rich Text beim Speichern. Weicht seine Fassung ab, uebernimmt der
// Editor sie - als gespeicherter Stand, ohne den Verlauf zu verlieren.
if (!publish && data.document && JSON.stringify(data.document) !== JSON.stringify(snapshot) && JSON.stringify(history.document) === JSON.stringify(snapshot)) {
history.adopt(data.document);
history.markSaved(data.document);
renderTree(); renderProperties(); schedulePreview();
renderTree(); renderProperties(); renderTranslations(); schedulePreview();
} else history.markSaved(snapshot);
if (publish && language) renderTranslations();
notify();
} catch (error) { notify(error.message, true); }
finally { busy = false; controls(); }
@@ -1514,12 +1758,13 @@ overlay = createCanvasOverlay({
getDocument: () => history.document,
getSelected: () => selected,
onAction: (action, id) => {
if (!id || busy || !history) return;
if (!id || busy || !history || language) return;
if (action === 'up') actions.move(id, -1);
else if (action === 'down') actions.move(id, 1);
else actions[action]?.(id);
},
onInsert: (id, where) => {
if (language) return;
const item = locate(history.document.blocks, id);
if (!item) return;
setPendingInsert({ parentId: item.parent?.id ?? null, index: item.index + (where === 'after' ? 1 : 0) });
@@ -1527,12 +1772,12 @@ overlay = createCanvasOverlay({
// The border between two columns was dragged (or nudged with the arrow keys): the shares of
// the whole row for the current device, one undo step per drag.
onResize: (rowId, value) => {
if (busy || !history) return;
if (busy || !history || language) return;
history.change(doc => setStyle(doc, rowId, device, 'columnWidths', value));
changed();
},
onDrop: (payload, plan) => {
if (busy || !history) return;
if (busy || !history || language) return;
if (payload.kind === 'add') {
const id = history.change(doc => payload.columns
? insertRowAt(doc, payload.columns, plan.parentId, plan.index)
Binary file not shown.
Binary file not shown.
+4 -4
View File
@@ -15,10 +15,6 @@
/* latin */
@font-face{font-family:'Cormorant Garamond';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/cormorant-garamond-700-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
/* latin-ext */
@font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-sans-400-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF}
/* latin */
@font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-sans-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
/* latin-ext */
@font-face{font-family:'Barlow';font-style:normal;font-weight:400;font-display:swap;src:url(/assets/fonts/barlow-400-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
/* latin */
@font-face{font-family:'Barlow';font-style:normal;font-weight:400;font-display:swap;src:url(/assets/fonts/barlow-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
@@ -38,3 +34,7 @@
@font-face{font-family:'Barlow Condensed';font-style:normal;font-weight:800;font-display:swap;src:url(/assets/fonts/barlow-condensed-800-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
/* latin */
@font-face{font-family:'Barlow Condensed';font-style:normal;font-weight:800;font-display:swap;src:url(/assets/fonts/barlow-condensed-800-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
/* latin-ext */
@font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-sans-400-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
/* latin */
@font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-sans-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
+32
View File
@@ -250,10 +250,35 @@ if (root) {
if (help) help.textContent = 'Ein E-Mail-Feld des Formulars. Dann lässt sich auf die Nachricht direkt antworten.';
};
// Eine Feldkarte ist eine Zeile; die Details öffnet man bei Bedarf. Darunter steht kurz, was
// in ihnen eingestellt ist, damit man nicht jede Karte aufklappen muss.
const setOpen = (card, open) => {
card.querySelector('[data-field-body]').hidden = !open;
const toggle = card.querySelector('[data-field-toggle]');
toggle.setAttribute('aria-expanded', String(open));
card.classList.toggle('is-open', open);
};
const meta = card => {
const type = card.querySelector('[data-field-type]').value;
const parts = [card.querySelector('[data-field-name]').value.trim()];
const options = card.querySelector('[data-field-options-input]').value.split(',').map(o => o.trim()).filter(Boolean);
if (['select', 'radio', 'checkboxes'].includes(type)) parts.push(options.length ? options.join(', ') : 'noch keine Optionen');
if (card.querySelector('[data-field-help]').value.trim()) parts.push('Hilfetext');
if (card.querySelector('[data-field-showif]').value.trim()) parts.push('mit Bedingung');
for (const translation of card.querySelectorAll('[data-field-translation]')) {
if ([...translation.querySelectorAll('input')].some(input => input.value.trim())) parts.push(`${translation.dataset.fieldTranslation.toUpperCase()} ✓`);
}
return parts.filter(Boolean).join(' · ');
};
const refresh = () => {
cards().forEach((card, index) => {
card.querySelector('[data-field-index]').textContent = String(index + 1);
const type = card.querySelector('[data-field-type]').value;
const line = card.querySelector('[data-field-meta]');
line.textContent = meta(card); line.hidden = !line.textContent || card.classList.contains('is-open');
const label = card.querySelector('[data-field-label]').value.trim() || `Feld ${index + 1}`;
card.querySelector('[data-field-toggle]').setAttribute('aria-label', `Details zu „${label}“`);
card.querySelector('[data-field-options]').hidden = !['select', 'radio', 'checkboxes'].includes(type);
card.querySelector('[data-field-images]').hidden = !['radio', 'checkboxes'].includes(type);
if (['radio', 'checkboxes'].includes(type)) renderImagePickers(card);
@@ -276,12 +301,19 @@ if (root) {
const empty = cards().filter(card => card.classList.contains('field-card-new'));
if (cards().length > empty.length) empty.forEach(card => card.remove());
add.hidden = false;
for (const card of cards()) {
card.querySelector('[data-field-toggle]').hidden = false;
setOpen(card, card.classList.contains('field-card-new'));
}
add.addEventListener('click', () => {
list.insertAdjacentHTML('beforeend', template.innerHTML.replaceAll('__n__', String(next++)));
const card = list.lastElementChild; card.classList.remove('field-card-new');
card.querySelector('[data-field-toggle]').hidden = false; setOpen(card, true);
refresh(); card.querySelector('[data-field-label]').focus();
});
list.addEventListener('click', event => {
const toggle = event.target.closest('[data-field-toggle]');
if (toggle) { const card = toggle.closest('[data-field]'); setOpen(card, !card.classList.contains('is-open')); refresh(); return; }
const button = event.target.closest('[data-remove-field]');
if (!button) return;
button.closest('[data-field]').remove(); refresh(); add.focus();
+16 -10
View File
@@ -16,6 +16,10 @@
};
const setup = form => {
// Die Texte des Skripts. Auf einer übersetzten Seite bringt das Formular sie mit (ADR 111).
let texts = {};
try { texts = JSON.parse(form.dataset.texts || '{}'); } catch { texts = {}; }
const t = (key, fallback) => texts[key] || fallback;
const rules = [...form.querySelectorAll('[data-show-if]')];
const steps = [...form.querySelectorAll('[data-step]')];
const submit = form.querySelector('.cms-button[type="submit"]');
@@ -54,27 +58,27 @@
if (wantsSummary) {
summary = el('div', 'form-step form-summary');
summary.setAttribute('data-step', '');
const title = el('p', 'form-step-title', 'Zusammenfassung');
const title = el('p', 'form-step-title', t('summary', 'Zusammenfassung'));
title.tabIndex = -1;
summary.append(title, el('p', 'form-step-intro', 'Bitte prüfen Sie Ihre Angaben. Über „Ändern“ kommen Sie zum jeweiligen Schritt zurück.'));
summary.append(title, el('p', 'form-step-intro', t('summaryIntro', 'Bitte prüfen Sie Ihre Angaben. Über „Ändern“ kommen Sie zum jeweiligen Schritt zurück.')));
summary.append(el('div', 'form-summary-body'));
steps[steps.length - 1].after(summary);
steps.push(summary);
}
const progress = el('ol', 'form-progress');
progress.setAttribute('aria-label', 'Schritte des Formulars');
progress.setAttribute('aria-label', t('steps', 'Schritte des Formulars'));
steps.forEach((step, index) => {
const item = el('li', 'form-progress-step');
item.append(el('span', 'form-progress-number', String(index + 1)), el('span', 'form-progress-title', step.querySelector('.form-step-title')?.textContent.trim() || `Schritt ${index + 1}`));
item.append(el('span', 'form-progress-number', String(index + 1)), el('span', 'form-progress-title', step.querySelector('.form-step-title')?.textContent.trim() || `${t('step', 'Schritt')} ${index + 1}`));
progress.append(item);
});
form.insertBefore(progress, steps[0]);
const nav = el('div', 'form-step-nav');
const back = el('button', 'button cms-button form-step-back', 'Zurück');
const back = el('button', 'button cms-button form-step-back', t('back', 'Zurück'));
back.type = 'button';
const next = el('button', 'button cms-button form-step-next', 'Weiter');
const next = el('button', 'button cms-button form-step-next', t('next', 'Weiter'));
next.type = 'button';
nav.append(back, next);
(submit || form).before(nav);
@@ -90,8 +94,10 @@
if (!picked.length) return '';
const unit = field.querySelector('.form-unit')?.textContent || '';
return picked.map(input => {
if (input.type === 'checkbox' && input.value === 'ja' && field.querySelector('.check-row label')) return 'Ja';
if (input.type === 'checkbox' && input.value === 'ja' && field.querySelector('.check-row label')) return t('yes', 'Ja');
if (input.tagName === 'SELECT') return input.selectedOptions[0]?.textContent || input.value;
// Angezeigt wird die Beschriftung der Option - auf einer übersetzten Seite die übersetzte.
if (input.type === 'checkbox' || input.type === 'radio') return (input.labels?.[0]?.querySelector('.form-choice-text') ?? input.labels?.[0])?.textContent.trim() || input.value;
return input.value + (unit ? ` ${unit}` : '');
}).join(', ');
};
@@ -103,8 +109,8 @@
if (step === summary || step.hasAttribute('data-inactive')) return;
const block = el('section', 'form-summary-step');
const head = el('div', 'form-summary-head');
head.append(el('h4', 'form-summary-title', step.querySelector('.form-step-title')?.textContent.trim() || `Schritt ${index + 1}`));
const change = el('button', 'form-summary-change', 'Ändern');
head.append(el('h4', 'form-summary-title', step.querySelector('.form-step-title')?.textContent.trim() || `${t('step', 'Schritt')} ${index + 1}`));
const change = el('button', 'form-summary-change', t('change', 'Ändern'));
change.type = 'button';
change.setAttribute('aria-label', `${head.firstChild.textContent} ändern`);
change.addEventListener('click', () => show(index));
@@ -153,7 +159,7 @@
if (group.closest('[data-inactive]')) continue;
const note = group.querySelector('.field-error[data-live]');
if (!group.querySelector('input:checked')) {
if (!note) group.append(Object.assign(el('p', 'field-error', 'Bitte wähle mindestens eine Option.'), { dataset: { live: '' } }));
if (!note) group.append(Object.assign(el('p', 'field-error', t('chooseOne', 'Bitte wähle mindestens eine Option.')), { dataset: { live: '' } }));
else note.hidden = false;
group.classList.add('has-error');
group.querySelector('input')?.focus();
+6
View File
@@ -107,6 +107,10 @@ export const ICON_PATHS = {
hardware: 'M4 5h16v11H4Zm4 15h8m-4-4v4',
network: 'M5 3h14v5H5Zm0 13h14v5H5Zm7-8v8M8 8v3h8V8',
design: 'M4 20h16M6 16 18 4l3 3L9 19l-4 1Z',
// Soziale Netzwerke
facebook: 'M15.5 4h-2.2A3.8 3.8 0 0 0 9.5 7.8V10H7v3.2h2.5V20h3.3v-6.8h2.4l.5-3.2h-2.9V8.2c0-.6.3-.9.9-.9h2Z',
instagram: 'M8.1 3.5h7.8a4.6 4.6 0 0 1 4.6 4.6v7.8a4.6 4.6 0 0 1-4.6 4.6H8.1a4.6 4.6 0 0 1-4.6-4.6V8.1a4.6 4.6 0 0 1 4.6-4.6ZM12 8.1a3.9 3.9 0 1 0 0 7.8 3.9 3.9 0 0 0 0-7.8ZM17 7h.01',
whatsapp: 'M4 20l1.4-4A8.5 8.5 0 1 1 8.6 19.1ZM9.3 9.2c0 3 2.5 5.5 5.5 5.5l1.3-1.4-2-.9-.9.8a4.1 4.1 0 0 1-2.3-2.3l.8-.9-.9-2Z',
};
export const ICON_LABELS = {
@@ -130,6 +134,7 @@ export const ICON_LABELS = {
check: 'Häkchen', 'check-circle': 'Häkchen im Kreis', info: 'Info', help: 'Frage', alert: 'Warnung',
search: 'Suche', settings: 'Einstellungen', filter: 'Filter', grid: 'Raster', list: 'Liste', sparkle: 'Funkeln',
coffee: 'Kaffee', utensils: 'Besteck', laptop: 'Laptop', hardware: 'Bildschirm', network: 'Netzwerk', design: 'Stift',
facebook: 'Facebook', instagram: 'Instagram', whatsapp: 'WhatsApp',
};
/** Die Kategorien des Auswahlfensters, in der Reihenfolge der Reiter. Jedes Symbol steht genau einmal. */
@@ -141,6 +146,7 @@ export const ICON_GROUPS = [
['Personen & Sicherheit', ['user', 'people', 'login', 'lock', 'key', 'shield', 'eye', 'thumbs-up', 'award', 'graduation', 'heart', 'star']],
['Haus & Handwerk', ['home', 'building', 'wrench', 'hammer', 'brush', 'ruler', 'leaf', 'tree', 'sun', 'droplet', 'flame', 'bolt', 'solar', 'thermometer', 'snowflake', 'car']],
['Allgemein', ['check', 'check-circle', 'info', 'help', 'alert', 'search', 'settings', 'filter', 'grid', 'list', 'sparkle', 'coffee', 'utensils', 'laptop', 'hardware', 'network', 'design']],
['Soziale Netzwerke', ['facebook', 'instagram', 'whatsapp']],
];
const svgNS = 'http://www.w3.org/2000/svg';
+1
View File
@@ -54,6 +54,7 @@ export const PREVIEWS = {
postnav: '<path d="M9.4 4.6L6 8l3.4 3.4M14.6 4.6L18 8l-3.4 3.4"/><path d="M6.4 8h3.6M14 8h3.6"/>',
breadcrumb: `${bar(2, 7.2, 5, 1.7)}<path d="M8.6 6.3l1.6 1.7-1.6 1.7"/>${bar(11.6, 7.2, 4, 1.7)}<path d="M17.2 6.3l1.6 1.7-1.6 1.7"/>${bar(20.2, 7.2, 2, 1.7)}`,
menu: `${bar(2, 7.2, 5, 1.8)}${bar(9, 7.2, 6, 1.8)}${bar(17, 7.2, 5, 1.8)}`,
language: '<circle cx="12" cy="8" r="5.6"/><path d="M6.4 8h11.2M12 2.4c-2.4 2.8-2.4 8.4 0 11.2M12 2.4c2.4 2.8 2.4 8.4 0 11.2"/>',
form: '<rect x="2.5" y="2" width="19" height="3.6" rx="1.2"/><rect x="2.5" y="6.8" width="19" height="3.6" rx="1.2"/><rect x="2.5" y="11.6" width="8" height="3.4" rx="1.2" fill="currentColor" stroke="none"/>',
legal: `${bar(2, 4, 20, 1.6)}${bar(2, 7.4, 16, 1.6)}${bar(2, 10.8, 11, 1.6)}`,
partialref: '<rect x="2.5" y="4.5" width="13" height="9" rx="1.5"/><path d="M6.5 4.5V2h13v9h-2.5"/>',
+6
View File
@@ -85,6 +85,12 @@ function createRow(data) {
summary.append(el('strong', 'menu-entry-title', data.label), el('span', 'menu-entry-kind', data.type === 'page' ? 'Seitenlink' : 'Eigener Link'), chevron);
const body = el('div', 'menu-entry-fields'); details.append(summary, body); row.append(handle, details);
const label = field('label', data.label, 'Beschriftung'); label.required = true; label.maxLength = 80;
// Je weitere Sprache eine eigene Beschriftung (ADR 111); leer zeigt die der Hauptsprache.
for (const language of JSON.parse(form.dataset.languages || '[]')) {
const translated = field(`label_${language.code}`, data[`label_${language.code}`] ?? data.labels?.[language.code] ?? '', `Beschriftung ${language.name}`);
translated.maxLength = 80; translated.lang = language.code; translated.placeholder = `Leer: wie „${data.label}“`;
if (language.rtl) translated.dir = 'rtl';
}
if (data.type === 'page') {
const picker = form.querySelector('.menu-add .content-picker').cloneNode(true); delete picker.dataset.ready;
const input = picker.querySelector('[role="combobox"]'); input.id = `menu-target-${id}`; input.value = data.pageDisplay || '';
+26 -1
View File
@@ -1,4 +1,7 @@
/**
* Das kleine Skript des Kopfbereichs: markiert die gescrollte Seite und schließt das Menü nach
* einer Wahl (initMenus, unten).
*
* Marks the page as scrolled.
*
* A header can carry a second set of colours for the scrolled page - transparent over the hero,
@@ -31,4 +34,26 @@ export function initScrolled(doc = document) {
};
}
if (!document.querySelector('#editor')) initScrolled();
/**
* Schließt das aufgeklappte Menü, sobald darin etwas gewählt wird (ADR 119).
*
* Ein Sprungziel wie #kontakt bleibt auf derselben Seite - das `details` blieb offen und lag über
* dem Ziel. Bei einem Link auf eine andere Seite stand es nach „Zurück“ (Seiten-Cache des
* Browsers) noch offen da. Ohne Skript bleibt es beim Klappen von Hand und beim Klick daneben.
*/
export function initMenus(doc = document) {
const onClick = (event) => {
const link = event.target.closest?.('.menu-burger-body a');
const menu = link?.closest('details.menu-burger');
if (menu) {
menu.open = false;
}
};
doc.addEventListener('click', onClick);
return () => doc.removeEventListener('click', onClick);
}
if (!document.querySelector('#editor')) {
initScrolled();
initMenus();
}
+14
View File
@@ -264,6 +264,8 @@ body.consent-open{padding-bottom:320px}
/* A single icon, sized by class because inline styles are forbidden by the policy. */
.block-icon{display:inline-flex;align-items:center;justify-content:center;color:var(--accent);line-height:0}
.block-icon svg{width:100%;height:100%;display:block}
/* Der Strich aller Symbole (icon.html.twig) - einmal hier statt als Attribute an jedem einzelnen. */
svg.block-icon{fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}
/* Das eingebettete SVG traegt dieselbe Klasse wie seine Huelle und setzte deshalb die
Akzentfarbe erneut - eine am Symbolblock eingestellte Farbe kam nie an. */
.block-icon .block-icon{color:inherit}
@@ -475,6 +477,12 @@ body.consent-open{padding-bottom:320px}
.menu-collapse-mobile>.menu-list{display:none}
}
.menu-burger>summary{min-height:44px;min-width:44px}
/* Ein Klick neben das aufgeklappte Menue schliesst es - ohne Skript: Solange es offen ist, liegt
eine unsichtbare Flaeche ueber der Seite, die zum Menue-Knopf gehoert. Ein Klick darauf ist ein
Klick auf den Knopf, und der klappt zu. Die Flaeche des Menues liegt darueber und bleibt
bedienbar; :where() haelt die Regel schwaecher als die Platzierung im Kopfbereich. */
.menu-burger[open]>summary::before{content:'';position:fixed;top:0;right:0;bottom:0;left:0;z-index:29;cursor:default}
:where(.menu-burger[open])>.menu-burger-body{position:relative;z-index:30}
.menu-burger-body{overflow-wrap:anywhere}
/* Im Kopfbereich klappt das Menue als Flaeche unter dem Kopf auf, ueber die volle Breite.
Inline geoeffnet stand es sonst in der schmalen Spalte neben der Marke und schob den
@@ -570,6 +578,12 @@ html.kant-top-armed.kant-top-shown .scroll-top{opacity:1;visibility:visible;tran
.block-legal{display:flex;flex-wrap:wrap;gap:6px;align-items:center;font-size:13px}
.block-legal a{color:inherit;text-decoration:none}
.block-legal a:hover{text-decoration:underline}
/* Die Sprachwahl (ADR 111): eine kurze Reihe, die gewählte Sprache fett. Jedes Ziel mindestens
24 px, damit es mit dem Finger zu treffen ist. */
.block-language ul{display:flex;flex-wrap:wrap;gap:2px;align-items:center;list-style:none;margin:0;padding:0}
.block-language a{display:inline-flex;align-items:center;justify-content:center;min-width:24px;min-height:24px;padding:4px 8px;color:inherit;text-decoration:none;font-size:14px;line-height:1.2}
.block-language a:hover{text-decoration:underline}
.block-language a[aria-current]{font-weight:700}
.image-zoom{cursor:zoom-in}
.zoom-dialog{border:0;padding:0;max-width:min(96vw,1400px);max-height:94vh;background:transparent;color:#fff;overflow:visible}
.zoom-dialog::backdrop{background:rgba(8,12,20,.88)}
+190
View File
@@ -0,0 +1,190 @@
/*
* Update mit einem Klick (ADR 113), nach dem Vorbild von reiseSoft.
*
* Beim Öffnen der Systemseite fragt sie selbst, ob es eine neue Fassung gibt, und zeigt deren
* Hinweise und einen Knopf. Nach der Bestätigung startet eine Anfrage das Update (Sichern, Laden,
* Umschalten); diese Seite fragt währenddessen alle anderthalb Sekunden den Fortschritt ab. Ist
* der Tausch durch, ruft sie den Abschluss auf - eine neue Anfrage mit dem neuen Code:
* Datenbank anpassen, prüfen, Wartungsseite aus. Schlägt etwas fehl, schaltet der Server zurück
* und die Seite sagt es.
*
* Ohne Skript bleiben die Formulare unter „Weitere Möglichkeiten“.
*/
const karte = document.querySelector('[data-update]');
if (karte) {
const live = karte.querySelector('[data-update-live]');
const dialog = document.getElementById('update-dialog');
const csrf = karte.dataset.csrf;
const SCHRITTE = [
['sichern', 'Daten sichern'],
['laden', 'Fassung laden und prüfen'],
['umschalten', 'Umschalten'],
['migrieren', 'Datenbank anpassen'],
['pruefen', 'Funktion prüfen'],
];
let ziel = null, timer = null, laeuft = false;
const el = (tag, className, text) => {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
};
const datum = iso => iso ? new Date(iso + 'T00:00:00').toLocaleDateString('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }) : '';
const abfragen = async url => {
const antwort = await fetch(url, { credentials: 'same-origin', cache: 'no-store', headers: { Accept: 'application/json' } });
if (!antwort.ok) throw new Error(String(antwort.status));
return antwort.json();
};
const senden = async (url, daten) => {
const antwort = await fetch(url, {
method: 'POST', credentials: 'same-origin', cache: 'no-store',
headers: { 'X-CSRF-Token': csrf, Accept: 'application/json' },
body: new URLSearchParams(daten),
});
const text = await antwort.text();
try { return JSON.parse(text); } catch { throw new Error(`Antwort ${antwort.status}`); }
};
// Die Hinweise der Releaseseite sind Markdown. Gezeigt wird Text mit Zeilen und **fett** -
// als Textknoten, nie als HTML: was dort steht, kommt von außen.
function notizen(text) {
const box = el('div', 'update-notizen');
text.split('\n').forEach((zeile, index) => {
if (index) box.append(document.createElement('br'));
zeile.replace(/^#+\s*/, '').split(/(\*\*[^*]+\*\*)/).forEach(teil => {
box.append(teil.startsWith('**') && teil.endsWith('**') && teil.length > 4 ? el('strong', '', teil.slice(2, -2)) : document.createTextNode(teil));
});
});
return box;
}
// ------------------------------------------------------------------ Ansicht: Prüfen
function warten(text) {
live.hidden = false;
live.replaceChildren(el('p', 'update-wait', text));
live.firstChild.prepend(el('span', 'update-spinner'));
}
function zeigeErgebnis(daten) {
live.hidden = false;
live.replaceChildren();
const letzter = daten.status;
if (letzter && !letzter.laeuft && ['fertig', 'fehler', 'zurueckgerollt'].includes(letzter.phase) && letzter.auf) {
const box = el('p', `notice ${letzter.phase === 'fertig' ? 'done' : 'error'} update-letztes`);
box.append(el('strong', '', letzter.phase === 'fertig' ? `Letztes Update auf ${letzter.auf} erfolgreich.` : `Letztes Update auf ${letzter.auf} nicht durchgelaufen.`));
if (letzter.phase !== 'fertig') box.append(' ', letzter.meldung || '');
live.append(box);
}
if (daten.fehler) {
const zeile = el('p', 'notice error', daten.fehler + ' ');
const nochmal = el('button', 'link-button', 'Erneut fragen'); nochmal.type = 'button';
nochmal.addEventListener('click', pruefen);
zeile.append(nochmal); live.append(zeile);
return;
}
if (!daten.neu) {
live.append(el('p', 'update-aktuell', `Du bist auf dem neuesten Stand (${daten.installiert}).`));
return;
}
ziel = daten.neu.version;
const box = el('div', 'update-neu');
box.append(el('p', 'update-neu-label', 'Neue Fassung verfügbar'));
box.append(el('h3', '', daten.neu.name || `KantSites ${ziel}`));
box.append(el('p', 'muted', [ziel, daten.neu.datum ? `veröffentlicht am ${datum(daten.neu.datum)}` : ''].filter(Boolean).join(' · ')));
if (daten.neu.notizen) box.append(notizen(daten.neu.notizen));
const knopf = el('button', 'primary', 'Jetzt aktualisieren'); knopf.type = 'button';
knopf.addEventListener('click', () => { dialog.querySelector('[data-update-ziel]').textContent = ziel; dialog.showModal(); });
box.append(knopf);
live.append(box);
}
async function pruefen() {
warten('Frage nach neuen Fassungen …');
try {
const daten = await abfragen(karte.dataset.pruefen);
if (daten.status?.laeuft) { zeigeLauf(daten.status); verfolgen(); return; }
zeigeErgebnis(daten);
} catch {
zeigeErgebnis({ fehler: 'Die Abfrage ist fehlgeschlagen.' });
}
}
// ------------------------------------------------------------------ Ansicht: Lauf
function zeigeLauf(status, server = true) {
live.hidden = false;
const box = el('div', 'update-lauf');
const kopf = el('p', 'update-lauf-kopf');
const ende = ['fertig', 'fehler', 'zurueckgerollt'].includes(status.phase);
if (!ende) kopf.append(el('span', 'update-spinner'));
kopf.append(ende
? (status.phase === 'fertig' ? `Update auf ${status.auf} abgeschlossen` : `Update auf ${status.auf} nicht durchgelaufen`)
: `Update auf ${status.auf || ziel || ''} läuft`);
box.append(kopf);
// Bei einem Fehler oder beim Zurückschalten markiert die Liste den Schritt, in dem es passierte.
const gescheitert = ['fehler', 'zurueckgerollt', 'zurueckschalten'].includes(status.phase);
const aktuell = gescheitert
? Math.max(0, SCHRITTE.findIndex(([phase]) => phase === status.bei))
: SCHRITTE.findIndex(([phase]) => phase === status.phase);
const liste = el('ol', 'update-schritte');
SCHRITTE.forEach(([phase, text], index) => {
let zustand = index < aktuell || status.phase === 'fertig' ? 'erledigt' : index === aktuell && !ende ? 'aktiv' : 'offen';
if (gescheitert && index === aktuell) zustand = 'fehler';
const punkt = el('li', `update-schritt is-${zustand}`);
punkt.append(el('span', 'update-marke', zustand === 'erledigt' ? '✓' : zustand === 'fehler' ? '!' : ''), el('span', '', text));
punkt.querySelector('.update-marke').setAttribute('aria-hidden', 'true');
punkt.append(el('span', 'visually-hidden', { erledigt: ' – erledigt', aktiv: ' – läuft', offen: '', fehler: ' – fehlgeschlagen' }[zustand]));
liste.append(punkt);
});
box.append(liste);
const meldung = el('p', `update-meldung${status.phase === 'fertig' ? ' is-ok' : ende ? ' is-fehler' : ''}`,
server ? (status.meldung || '') : 'Der Server tauscht gerade die Dateien – die Seite meldet sich gleich wieder.');
box.append(meldung);
if (ende) {
const weiter = el('button', 'secondary', status.phase === 'fertig' ? 'Seite neu laden' : 'Zur Übersicht'); weiter.type = 'button';
weiter.addEventListener('click', () => location.reload());
box.append(weiter);
}
live.replaceChildren(box);
}
async function tick() {
try {
const daten = await abfragen(karte.dataset.status);
if (daten.status) zeigeLauf(daten.status);
if (daten.installiert) karte.querySelector('[data-update-installiert]').textContent = daten.installiert;
if (daten.status && !daten.status.laeuft && !laeuft) stoppen();
} catch {
// Während des Tauschs kann eine Abfrage scheitern; die nächste klappt wieder.
const letzter = live.querySelector('.update-meldung');
if (letzter) letzter.textContent = 'Der Server tauscht gerade die Dateien – die Seite meldet sich gleich wieder.';
}
}
function verfolgen() { if (!timer) timer = setInterval(tick, 1500); }
function stoppen() { clearInterval(timer); timer = null; }
async function starten() {
dialog.close();
laeuft = true;
zeigeLauf({ phase: 'sichern', auf: ziel, meldung: 'Das Update beginnt …', laeuft: true });
verfolgen();
try {
const start = await senden(karte.dataset.starten, { version: ziel });
if (!start.ok) throw Object.assign(new Error(start.meldung || 'Das Update ist nicht durchgelaufen.'), { status: start.status });
const ende = await senden(karte.dataset.abschliessen, { token: start.token });
laeuft = false;
await tick();
stoppen();
if (ende.ok) setTimeout(() => location.reload(), 2500);
} catch (fehler) {
laeuft = false;
await tick();
stoppen();
if (!live.querySelector('.update-meldung.is-fehler')) zeigeLauf(fehler.status || { phase: 'fehler', auf: ziel, meldung: fehler.message });
}
}
dialog.querySelector('[data-update-abbrechen]').addEventListener('click', () => dialog.close());
dialog.querySelector('[data-update-los]').addEventListener('click', starten);
pruefen();
}
+160
View File
@@ -0,0 +1,160 @@
/**
* Übersetzen im Editor (ADR 111).
*
* Eine Seite wird einmal gebaut; je weitere Sprache trägt ihr Dokument unter `translations` nur
* die Texte, adressiert über die ID des Blocks. Dieses Modul kennt die übersetzbaren Felder,
* schreibt eine Übersetzung in das Dokument und sagt, was fehlt oder veraltet ist. Es ändert
* nie Aufbau, Gestaltung oder die Texte der Hauptsprache.
*/
import { locate, upgradeDocument, blockLabel } from './editor-model.js';
/** Dieselbe Liste wie Translations::FIELDS, mit Beschriftung, Länge und Art des Feldes. */
export const FIELDS = {
heading: [{ prop: 'text', label: 'Überschrift', max: 2000, rich: 'heading' }],
text: [{ prop: 'text', label: 'Text', max: 20000, rich: 'text', multiline: true }],
button: [{ prop: 'text', label: 'Beschriftung', max: 300 }],
image: [{ prop: 'alt', label: 'Alternativtext', max: 300, multiline: true }, { prop: 'caption', label: 'Bildunterschrift', max: 500, multiline: true }],
iconcard: [{ prop: 'title', label: 'Titel', max: 120 }, { prop: 'text', label: 'Text', max: 600, multiline: true }],
stat: [{ prop: 'value', label: 'Wert', max: 16 }, { prop: 'label', label: 'Beschriftung', max: 80 }],
quote: [{ prop: 'text', label: 'Zitat', max: 1000, multiline: true }, { prop: 'author', label: 'Name', max: 120 }, { prop: 'role', label: 'Rolle', max: 120 }],
cta: [{ prop: 'title', label: 'Titel', max: 160 }, { prop: 'text', label: 'Text', max: 600, multiline: true }, { prop: 'buttonText', label: 'Button', max: 80 }],
contact: [{ prop: 'address', label: 'Anschrift', max: 200, multiline: true }],
accordionitem: [{ prop: 'title', label: 'Titel', max: 160 }],
tabitem: [{ prop: 'title', label: 'Titel', max: 160 }],
legal: [{ prop: 'label', label: 'Beschriftung für Vorlesesoftware', max: 60 }],
postnav: [{ prop: 'previousLabel', label: 'Beschriftung zurück', max: 60 }, { prop: 'nextLabel', label: 'Beschriftung weiter', max: 60 }],
icon: [{ prop: 'label', label: 'Beschriftung', max: 120 }],
progress: [{ prop: 'label', label: 'Beschriftung', max: 120 }],
embed: [{ prop: 'title', label: 'Titel', max: 160 }, { prop: 'hint', label: 'Hinweis', max: 300, multiline: true }],
};
/** Felder der Seite selbst. `seoTitle` hat kein Gegenstück im Dokument der Hauptsprache. */
export const PAGE_FIELDS = [
{ key: 'title', label: 'Seitentitel', max: 160 },
{ key: 'description', label: 'Beschreibung für Suchmaschinen', max: 320, multiline: true },
];
/**
* Der Vermerk, wie ein Text der Hauptsprache beim Übersetzen aussah: FNV-1a über UTF-8, 32 Bit,
* als acht Hexziffern - dasselbe wie hash('fnv1a32') in PHP (Translations::fingerprint).
*/
export function fingerprint(text) {
let hash = 0x811c9dc5;
for (const byte of new TextEncoder().encode(text)) {
hash ^= byte;
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash.toString(16).padStart(8, '0');
}
const empty = value => typeof value !== 'string' || value.trim() === '';
/** Das sichtbare Wort eines formatierten Texts, für Überschriften in der Liste. */
export function plainText(markup) {
const node = document.createElement('template');
node.innerHTML = markup ?? '';
return node.content.textContent.replace(/\s+/g, ' ').trim();
}
/**
* Alle übersetzbaren Texte des Dokuments in Seitenreihenfolge, je mit Original, Übersetzung und
* Zustand: "missing" (fehlt), "stale" (Original seitdem geändert) oder "done".
*
* @return {Array<{key: string, blockId: string|null, prop: string, label: string, block: object|null, original: string, value: string, state: string, field: object}>}
*/
export function entries(document, locale) {
const translation = document.translations?.[locale] ?? {};
const list = [];
const push = (key, blockId, prop, field, original, value, block = null) => {
if (empty(original)) return;
const hash = translation.source?.[key];
const state = empty(value) ? 'missing' : hash && hash !== fingerprint(original) ? 'stale' : 'done';
list.push({ key, blockId, prop, label: field.label, block, original, value: value ?? '', state, field });
};
for (const field of PAGE_FIELDS) push(field.key, null, field.key, field, document[field.key], translation[field.key]);
const walk = blocks => {
for (const block of blocks ?? []) {
for (const field of FIELDS[block.type] ?? []) {
// Ein dekoratives Bild oder Symbol hat keinen Text, also auch keinen zu übersetzen.
if (['alt', 'label'].includes(field.prop) && block.props?.decorative === true) continue;
push(`${block.id}.${field.prop}`, block.id, field.prop, field, block.props?.[field.prop], translation.blocks?.[block.id]?.[field.prop], block);
}
walk(block.children);
}
};
walk(document.blocks);
return list;
}
/** Zählt, was übersetzt ist: { total, done, stale }. */
export function progress(document, locale) {
const list = entries(document, locale);
return { total: list.length, done: list.filter(entry => entry.state !== 'missing').length, stale: list.filter(entry => entry.state === 'stale').length };
}
/** Die Übersetzung einer Sprache, angelegt, falls es sie noch nicht gibt. */
function ensure(document, locale) {
upgradeDocument(document, 27);
document.translations ??= {};
return (document.translations[locale] ??= {});
}
/** Räumt eine Sprache auf, die nichts mehr enthält, damit das Dokument nicht wächst. */
function tidy(document, locale) {
const translation = document.translations?.[locale];
if (!translation) return;
for (const [id, fields] of Object.entries(translation.blocks ?? {})) if (!Object.keys(fields).length) delete translation.blocks[id];
if (translation.blocks && !Object.keys(translation.blocks).length) delete translation.blocks;
if (translation.source && !Object.keys(translation.source).length) delete translation.source;
if (!Object.keys(translation).length) delete document.translations[locale];
if (document.translations && !Object.keys(document.translations).length) delete document.translations;
}
/**
* Schreibt einen übersetzten Text. Mit dem Text wird vermerkt, wie das Original gerade aussieht -
* ändert es sich später, gilt die Übersetzung als veraltet. Leer heißt: nicht übersetzt.
* Läuft innerhalb einer Transaktion der History.
*/
export function setText(document, locale, key, value) {
const translation = ensure(document, locale);
const [blockId, prop] = key.includes('.') ? key.split('.') : [null, key];
const original = blockId ? locate(document.blocks, blockId)?.block?.props?.[prop] : document[prop];
if (blockId) {
translation.blocks ??= {};
const fields = (translation.blocks[blockId] ??= {});
if (empty(value)) delete fields[prop]; else fields[prop] = value;
} else if (empty(value)) delete translation[prop]; else translation[prop] = value;
translation.source ??= {};
if (empty(value) || typeof original !== 'string') delete translation.source[key];
else translation.source[key] = fingerprint(original);
tidy(document, locale);
}
/** Bestätigt eine veraltete Übersetzung, ohne sie zu ändern: das Original ist so gemeint. */
export function confirmText(document, locale, key) {
const translation = document.translations?.[locale];
const [blockId, prop] = key.includes('.') ? key.split('.') : [null, key];
const original = blockId ? locate(document.blocks, blockId)?.block?.props?.[prop] : document[prop];
if (!translation || typeof original !== 'string') return;
(translation.source ??= {})[key] = fingerprint(original);
}
/** Adresse und Suchtitel einer Sprache. Leer heißt: wie in der Hauptsprache beziehungsweise wie der Titel. */
export function setPageValue(document, locale, key, value) {
const translation = ensure(document, locale);
if (empty(value)) delete translation[key]; else translation[key] = key === 'slug' ? value.trim().toLowerCase() : value;
tidy(document, locale);
}
/** Aus einem Titel eine Adresse: Kleinbuchstaben, Ziffern, einzelne Bindestriche. */
export function slugify(text) {
return plainText(text).toLowerCase()
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
.normalize('NFKD').replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80).replace(/-+$/g, '');
}
/** Kurzer Name eines Blocks für die Liste: Art und der Anfang seines Originaltexts. */
export function describeBlock(block) {
return blockLabel(block);
}
+17 -6
View File
@@ -1,6 +1,7 @@
<?php
use App\Kernel;
use App\Ops\Notfall;
use Symfony\Component\HttpFoundation\Request;
// PHP nennt in jeder Antwort seine genaue Fassung („X-Powered-By: PHP/8.4.22"). Das nützt
@@ -8,9 +9,19 @@ use Symfony\Component\HttpFoundation\Request;
// Hosting laesst sich expose_php nicht umstellen, dieser Aufruf wirkt trotzdem.
header_remove('X-Powered-By');
require dirname(__DIR__).'/config/bootstrap.php';
$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
$request = Request::createFromGlobals();
$response = $kernel->handle($request);
$response->send();
$kernel->terminate($request, $response);
// Die Notfallschicht zuerst und ohne Autoloader (ADR 113): Scheitert alles Weitere - fehlende
// Abhängigkeiten, eine unlesbare Datenbank, ein Fehler beim Start -, sehen Besucher eine
// Wartungsseite statt einer weißen Seite, und der Grund steht in var/log/notfall.log.
require_once dirname(__DIR__).'/src/Ops/Notfall.php';
Notfall::einschalten(dirname(__DIR__));
try {
require dirname(__DIR__).'/config/bootstrap.php';
$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
$request = Request::createFromGlobals();
$response = $kernel->handle($request);
$response->send();
$kernel->terminate($request, $response);
} catch (Throwable $fehler) {
Notfall::ausnahme($fehler, $_SERVER['REQUEST_URI'] ?? '');
}
+8
View File
@@ -46,6 +46,7 @@ try {
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
await page.getByRole('button', { name: 'Anmelden', exact: true }).click();
await page.waitForURL(origin + '/admin'); await page.goto(origin + '/admin/forms');
await page.locator('.form-list a').first().click(); await page.locator('.form-def').waitFor();
await page.setViewportSize({ width: 390, height: 844 });
await page.locator('.field-card').first().screenshot({ path: 'reports/admin-quality/form-field-mobile.png' });
console.log('Public no-JavaScript baseline and visual captures passed.');
@@ -165,11 +166,18 @@ try {
checks.push({ name: 'menu search by keyboard, add/nest/remove/undo/save, invalid URL and conflict retain input', passed: true });
await page.goto(origin + '/admin/forms');
await page.locator('.form-list a').first().click();
const form = page.locator('.form-def').first(), message = form.locator('[name="success"]');
// Der Abschnitt „Zustellung“ ist zugeklappt, bis man ihn öffnet.
const openDelivery = async () => {
await form.locator('details.form-section').first().evaluate(section => { section.open = true; });
};
await openDelivery();
const widths = await form.evaluate(form => ({ message: form.querySelector('[name="success"]').clientWidth, fields: form.querySelector('.fields').clientWidth }));
assert.ok(widths.message >= widths.fields - 5, 'success message uses available form width');
await message.fill('Vielen Dank für deine Nachricht.\nWir melden uns persönlich bei dir.');
await form.getByRole('button', { name: 'Formular speichern', exact: true }).click();
await page.waitForLoadState('load'); await openDelivery();
assert.equal(await message.inputValue(), 'Vielen Dank für deine Nachricht.\nWir melden uns persönlich bei dir.');
checks.push({ name: 'wide multiline form response persists', passed: true });
+10 -5
View File
@@ -69,7 +69,8 @@ try {
const queries = Number(response.headers()['x-kant-queries'] ?? -1);
const html = (await response.text()).length;
const styles = [...transferred.values()].filter(entry => entry.type === 'text/css').reduce((sum, entry) => sum + entry.length, 0);
const scripts = [...transferred.values()].filter(entry => entry.type.includes('javascript')).length;
// consent.js zählt nicht: nur solange eine Einwilligung offen ist (ADR 115).
const scripts = [...transferred.entries()].filter(([url, entry]) => entry.type.includes('javascript') && !url.includes('/assets/consent.js')).length;
const requests = transferred.size;
// Bilder gehören zum Inhalt: eine bebilderte Startseite lädt zu Recht mehr Dateien als eine
// Textseite. Gedeckelt wird deshalb das Beiwerk (Stile, Schriften, Skripte) und das
@@ -114,7 +115,9 @@ try {
assert.equal(await page.locator('h1').count(), 1);
// Strukturierte Daten stehen zwar in einem script-Element, führen aber nichts aus. Gezählt
// wird, was laufen kann - und das nur, wenn die Seite es in ihrer eigenen Richtlinie erlaubt.
const laufend = await page.locator('script:not([type="application/ld+json"])').count();
// consent.js zählt nicht mit: Es steht nur auf der Seite, solange eine Einwilligung offen ist,
// und nach der Entscheidung nicht mehr (ADR 115).
const laufend = await page.locator('script:not([type="application/ld+json"]):not([src^="/assets/consent.js"])').count();
const darfSkripte = !/script-src 'none'/.test(response.headers()['content-security-policy'] ?? '');
assert.ok(darfSkripte ? laufend <= 3 : 0 === laufend, `${path}: ${laufend} Skript(e), erlaubt ${darfSkripte ? 3 : 0}`);
// Einblendungen zu Ende spielen, bevor gemessen wird: mitten in einer Einblendung ist ein
@@ -162,7 +165,8 @@ try {
const hinweis = page.locator('.consent-bar button[value="none"]');
if (await hinweis.count()) {
await hinweis.click();
await page.waitForLoadState('domcontentloaded');
// Mit consent.js ohne Neuladen (ADR 115): warten, bis der Hinweis weg ist.
await page.locator('.consent-bar').waitFor({ state: 'detached' });
}
await page.keyboard.press('Tab');
assert.equal(await page.locator(':focus').innerText(), 'Zum Inhalt springen');
@@ -171,7 +175,8 @@ try {
// Weiter mit der Tastatur: der erste Verweis im Inhalt muss anklickbar sein und tatsächlich
// woandershin führen. Vorher standen hier die Linktexte der Demo - gegen jede andere
// Installation lief die Prüfung damit ins Leere statt etwas zu prüfen.
const ersterLink = page.locator('main a[href^="/"]').first();
// Ein PDF zum Herunterladen ist keine Seite mit Überschrift; es zählt hier nicht mit.
const ersterLink = page.locator('main a[href^="/"]:not([href$=".pdf"])').first();
const ziel = await ersterLink.getAttribute('href');
await ersterLink.click();
assert.equal(new URL(page.url()).pathname, ziel.split('#')[0] || '/');
@@ -189,7 +194,7 @@ try {
await noJs.waitForLoadState('domcontentloaded');
}
assert.equal(await noJs.locator('h1').count(), 1);
const ohneJs = noJs.locator('main a[href^="/"]').first();
const ohneJs = noJs.locator('main a[href^="/"]:not([href$=".pdf"])').first();
const zielOhneJs = await ohneJs.getAttribute('href');
await ohneJs.click();
assert.equal(new URL(noJs.url()).pathname, zielOhneJs.split('#')[0] || '/');
+66 -7
View File
@@ -4,10 +4,15 @@
* Bereitet die Bilder des Hüfner-Entwurfs für die Mediathek vor.
*
* Kopiert die Fotos und das Logo aus dem gelieferten Ordner (images/) und schneidet aus dem Logo
* den Baum mit den Steinen als quadratisches Seitensymbol (favicon.png) heraus. Der Baum ist im
* den Baum (ohne die Steine) als quadratisches Seitensymbol (favicon.png) heraus. Der Baum ist im
* Logo links, der Schriftzug rechts; ein Favicon mit Schrift wäre in 32 px unlesbar.
*
* Aufruf: php scripts/build-huefner-assets.php <Quellordner mit images/> <Zielordner>
* Aufruf: php scripts/build-huefner-assets.php <Quellordner mit images/> <Zielordner> [<Unterlagen>]
*
* Das dritte Argument ist Umuts Ordner „unterlagenfrwebsite“ (12.10.2026): Alpaka-Fotos, PQ-Logo,
* Galabau-Signet und die drei PDFs. Sie bekommen hier feste, einfache Namen, damit das Seed-Skript
* nicht an „Freistellungsbescheinigung §48 … bis 11.05.2029.pdf“ hängt. Kommt eine neue
* Bescheinigung, genügt es, sie nach dem gleichen Muster benannt in den Ordner zu legen.
*/
$quelle = rtrim($argv[1] ?? '', '/\\');
$ziel = rtrim($argv[2] ?? '', '/\\');
@@ -24,22 +29,76 @@ foreach (glob($quelle.'/images/*.{jpg,png}', GLOB_BRACE) ?: [] as $datei) {
copy($datei, $ziel.'/'.basename($datei));
}
// Baum und Steine: im 1600 x 607 px großen Logo etwa x 15–500, y 10–560.
// Nur der Baum: Krone (grün) und Stamm (dunkel) aus dem linken Teil des Logos, ohne die grauen
// Steine - die liefen bisher rechts aus dem Ausschnitt und sind in 32 px nur Rauschen (Umut,
// 12.10.2026: „schneide bitte den Baum zurecht“). Der Stamm ist dunkler als die Steine.
$logo = imagecreatefrompng($quelle.'/images/logo.png');
if (false === $logo) {
fwrite(STDERR, "logo.png lässt sich nicht lesen.\n");
exit(1);
}
$punkte = [];
[$bx0, $by0, $bx1, $by1] = [PHP_INT_MAX, PHP_INT_MAX, 0, 0];
for ($y = 10; $y < 520; ++$y) {
for ($x = 10; $x < 500; ++$x) {
$c = imagecolorat($logo, $x, $y);
if ((($c >> 24) & 127) > 100) {
continue;
}
[$r, $g, $b] = [($c >> 16) & 255, ($c >> 8) & 255, $c & 255];
if ($g > $r + 30 || ($r + $g + $b) / 3 < 70) {
$punkte[] = [$x, $y, $c];
[$bx0, $by0, $bx1, $by1] = [min($bx0, $x), min($by0, $y), max($bx1, $x), max($by1, $y)];
}
}
}
$baum = imagecreatetruecolor($bx1 - $bx0 + 1, $by1 - $by0 + 1);
imagesavealpha($baum, true);
imagealphablending($baum, false);
imagefill($baum, 0, 0, imagecolorallocatealpha($baum, 0, 0, 0, 127));
foreach ($punkte as [$x, $y, $c]) {
imagesetpixel($baum, $x - $bx0, $y - $by0, $c);
}
$kante = 512;
$symbol = imagecreatetruecolor($kante, $kante);
imagesavealpha($symbol, true);
imagealphablending($symbol, false);
imagefill($symbol, 0, 0, imagecolorallocatealpha($symbol, 0, 0, 0, 127));
imagealphablending($symbol, true);
[$x, $y, $b, $h] = [15, 10, 490, 550];
$massstab = min(($kante - 24) / $b, ($kante - 24) / $h);
[$b, $h] = [imagesx($baum), imagesy($baum)];
$massstab = min(($kante - 32) / $b, ($kante - 32) / $h);
$zb = (int) round($b * $massstab);
$zh = (int) round($h * $massstab);
imagecopyresampled($symbol, $logo, (int) (($kante - $zb) / 2), (int) (($kante - $zh) / 2), $x, $y, $zb, $zh, $b, $h);
imagecopyresampled($symbol, $baum, (int) (($kante - $zb) / 2), (int) (($kante - $zh) / 2), 0, 0, $zb, $zh, $b, $h);
imagepng($symbol, $ziel.'/favicon.png');
echo "Bilder kopiert, Seitensymbol geschnitten: $ziel\n";
$unterlagen = rtrim($argv[3] ?? '', '/\\');
if ('' === $unterlagen) {
exit(0);
}
// Muster im Ordner => fester Name. Bei mehreren Treffern gewinnt der letzte in Namensreihenfolge.
$feste = [
'Alpaka (1).jpeg' => 'alpaka-plakat.jpg',
'Alpaka (2).jpeg' => 'alpaka-wiese.jpg',
'Alpaka (3).jpeg' => 'alpaka-stall.jpg',
'Alpaka (4).jpeg' => 'alpaka-blume.jpg',
'Alpaka (6).jpeg' => 'alpaka-zaunbau.jpg',
'Alpaka (9).jpeg' => 'alpaka-rasen.jpg',
'PQ-Logo*.png' => 'pq-logo.png',
'Signum*.png' => 'galabau-signet.png',
'100-emoji.png' => 'hundert.png',
'PQ-Bescheinigung*.pdf' => 'pq-bescheinigung.pdf',
'Freistellungsbescheinigung §13*.pdf' => 'freistellung-13b.pdf',
'Freistellungsbescheinigung §48*.pdf' => 'freistellung-48b.pdf',
];
$dateien = scandir($unterlagen) ?: [];
foreach ($feste as $muster => $name) {
$treffer = array_values(array_filter($dateien, static fn (string $d): bool => fnmatch($muster, $d, FNM_NOESCAPE)));
if ([] === $treffer) {
fwrite(STDERR, "Fehlt in den Unterlagen: $muster\n");
exit(1);
}
sort($treffer);
copy($unterlagen.'/'.end($treffer), $ziel.'/'.$name);
}
echo 'Unterlagen übernommen: '.count($feste)." Dateien\n";
+88
View File
@@ -0,0 +1,88 @@
<?php
/**
* Bereitet die Bilder des Kantarci-Reisen-Entwurfs für die Mediathek vor.
*
* Aus dem gelieferten Paket (Website/ und Logo/) entstehen:
* - logo.png: das Logo ohne den breiten durchsichtigen Rand (im Original rund ein Viertel der Fläche),
* - favicon.png: das „K“ mit Sonne und Schwingen als quadratisches Seitensymbol,
* - kueste.webp: das Reisefoto der Vorlage, unverändert,
* - reisesoft-uebersicht.png: der Screenshot der reiseSoft-Übersicht, falls als zweites Argument
* angegeben (aufgenommen aus einer lokalen Instanz mit den fiktiven Demodaten von reiseSoft).
*
* Aufruf: php scripts/build-kantarci-reisen-assets.php <Paketordner Kantarci-Reisen> <Zielordner> [Screenshot]
*/
$quelle = rtrim($argv[1] ?? '', '/\\');
$ziel = rtrim($argv[2] ?? '', '/\\');
$screenshot = $argv[3] ?? '';
if ('' === $quelle || '' === $ziel || !is_file($quelle.'/Website/coast.webp')) {
fwrite(STDERR, "Aufruf: php scripts/build-kantarci-reisen-assets.php <Ordner Kantarci-Reisen> <Zielordner> [Screenshot]\n");
exit(1);
}
if (!is_dir($ziel) && !mkdir($ziel, 0775, true)) {
fwrite(STDERR, "Zielordner lässt sich nicht anlegen: $ziel\n");
exit(1);
}
copy($quelle.'/Website/coast.webp', $ziel.'/kueste.webp');
if ('' !== $screenshot) {
copy($screenshot, $ziel.'/reisesoft-uebersicht.png');
}
$logo = imagecreatefrompng($quelle.'/Logo/kantarci-reisen-logo-original.png');
if (false === $logo) {
fwrite(STDERR, "Das Logo lässt sich nicht lesen.\n");
exit(1);
}
$breite = imagesx($logo);
$hoehe = imagesy($logo);
/** Kleinstes Rechteck um alle sichtbaren Pixel (Deckkraft über 10 %) innerhalb eines Bereichs. */
$rahmen = static function (int $x0, int $y0, int $x1, int $y1) use ($logo): array {
[$links, $oben, $rechts, $unten] = [$x1, $y1, $x0, $y0];
for ($y = $y0; $y < $y1; $y += 2) {
for ($x = $x0; $x < $x1; $x += 2) {
if ((imagecolorat($logo, $x, $y) >> 24 & 0x7F) < 115) {
$links = min($links, $x);
$rechts = max($rechts, $x);
$oben = min($oben, $y);
$unten = max($unten, $y);
}
}
}
return [$links, $oben, $rechts - $links + 1, $unten - $oben + 1];
};
$neu = static function (int $b, int $h) {
$bild = imagecreatetruecolor($b, $h);
imagesavealpha($bild, true);
imagealphablending($bild, false);
imagefill($bild, 0, 0, imagecolorallocatealpha($bild, 0, 0, 0, 127));
imagealphablending($bild, true);
return $bild;
};
// Ganzes Logo mit 12 px Luft.
[$x, $y, $b, $h] = $rahmen(0, 0, $breite, $hoehe);
$rand = 12;
$zuschnitt = $neu($b + 2 * $rand, $h + 2 * $rand);
imagecopy($zuschnitt, $logo, $rand, $rand, $x, $y, $b, $h);
imagepng($zuschnitt, $ziel.'/logo.png');
// Das Bildzeichen steht links bis etwa 38 % der Breite. Die obere Schwinge reicht dabei über den
// Anfang des Schriftzugs; das „K“ von KANTARCI beginnt erst unterhalb von ihr (ab etwa 35,5 % der
// Breite und 40 % der Höhe). Diese Ecke wird vor dem Zuschnitt geleert.
imagealphablending($logo, false);
imagefilledrectangle($logo, (int) ($breite * 0.355), (int) ($hoehe * 0.40), $breite - 1, $hoehe - 1, imagecolorallocatealpha($logo, 0, 0, 0, 127));
[$x, $y, $b, $h] = $rahmen(0, 0, (int) ($breite * 0.40), $hoehe);
$kante = 512;
$symbol = $neu($kante, $kante);
$massstab = min(($kante - 40) / $b, ($kante - 40) / $h);
$zb = (int) round($b * $massstab);
$zh = (int) round($h * $massstab);
imagecopyresampled($symbol, $logo, (int) (($kante - $zb) / 2), (int) (($kante - $zh) / 2), $x, $y, $zb, $zh, $b, $h);
imagepng($symbol, $ziel.'/favicon.png');
echo "Bilder vorbereitet: $ziel\n";
+3 -1
View File
@@ -67,7 +67,7 @@ echo "Fassung {$version}\n";
// migrations.php und cli-config.php gehoeren dazu: ohne die erste findet Doctrine beim
// Einrichten keine Migrationsliste und bricht mit "configuration file does not exist" ab - genau
// dort, wo eine frische Installation ihre Tabellen anlegen will.
$include = ['bin', 'config', 'migrations', 'plugins', 'public', 'src', 'templates', 'composer.json', 'composer.lock', 'migrations.php', 'cli-config.php', 'VERSION', 'README.md', '.env.example'];
$include = ['bin', 'config', 'migrations', 'plugins', 'public', 'src', 'templates', 'composer.json', 'composer.lock', 'migrations.php', 'cli-config.php', 'VERSION', 'README.md', '.env.example', '.htaccess'];
$skip = ['.git', '.github', '.claude', '.gitattributes', '.gitignore', '.env.lock', '.php-cs-fixer.cache', 'node_modules', 'var', 'tests', 'scripts', 'reports', 'phpunit.xml.dist', '.php-cs-fixer.dist.php', 'package.json', 'package-lock.json', 'AGENTS.md', 'ENTWICKLUNGSPLAN.md', 'docs', 'composer.phar', '.venv', 'vendor', 'phpunit.cache', 'migrations.php.bak'];
$copy = static function (string $from, string $to) use (&$copy, $skip): int {
@@ -116,6 +116,8 @@ if (!$ok) {
exit(1);
}
echo 'vendor/ gebaut'.($flags ? ' ohne Entwicklungspakete' : ' MIT Entwicklungspaketen')."\n";
// vendor/ gehört nicht ins Netz, auch wenn die Domain falsch zeigt (ADR 116).
copy($root.'/config/schutz/sperren.htaccess', $staging.'/vendor/.htaccess');
// The environment file is not part of a release: it holds the secret of one installation.
foreach (['.env.local', '.env.prod.local'] as $secret) {
+1 -2
View File
@@ -1147,9 +1147,8 @@ try {
assert.equal(await savedOg.inputValue(), chosenOg, 'the image survives saving');
checks.push({ name: 'image fields in the content settings pick from the library', passed: true });
// Formular-Editor: Bedingung, Antwortadresse und Bild je Option werden gewählt, nicht getippt.
await page.goto(origin + '/admin/forms');
await page.goto(origin + '/admin/forms/neu');
const neu = page.locator('#neues-formular');
if (!(await neu.evaluate(details => details.open))) await neu.locator('summary').click();
await neu.locator('[name="name"]').fill('Anfrage Prüfung');
await neu.locator('[name="slug"]').fill('anfrage-pruefung');
await neu.locator('[name="recipient"]').fill('info@example.com');
+28 -1
View File
@@ -17,6 +17,9 @@
* Eintrag wie ../../.env landet nirgends.
* - Sie arbeitet nur in ein leeres Verzeichnis. Eine bestehende Installation wird nicht
* überschrieben, auch nicht teilweise.
* - Nach dem Entpacken legt sie den Installationsschlüssel an (var/install.key, ADR 116) und
* zeigt ihn nur auf ihrer Abschlussseite. Ohne ihn öffnet sich der Installer nicht - wer die
* frische Installation zuerst aufruft, kann sie so nicht übernehmen.
*/
// Vom Buildskript gesetzt: `php scripts/build-release.php` schreibt Adresse und Prüfsumme des
@@ -176,6 +179,27 @@ function kant_checks(string $target): array
return $checks;
}
/** Legt den Installationsschlüssel an (oder liest einen vorhandenen) und gibt ihn zurück. */
function kant_install_key(string $target): string
{
$file = $target.'/var/install.key';
$key = is_file($file) ? trim((string) file_get_contents($file)) : '';
if (1 === preg_match('/^[a-f0-9]{32}$/D', $key)) {
return $key;
}
if (!is_dir($target.'/var')) {
mkdir($target.'/var', 0775, true);
}
$key = bin2hex(random_bytes(16));
if (false === file_put_contents($file, $key."
")) {
throw new RuntimeException('Der Installationsschlüssel ließ sich nicht schreiben (var/install.key).');
}
@chmod($file, 0600);
return $key;
}
/** Der Ablauf, wenn der Knopf gedrückt wurde. Gibt [Erfolg, Meldung] zurück. */
function kant_run(string $target): array
{
@@ -192,6 +216,7 @@ function kant_run(string $target): array
return [false, 'Die Prüfsumme des heruntergeladenen Archivs stimmt nicht. Erwartet: '.KANT_ARCHIVE_SHA256.', erhalten: '.$hash.'. Es wurde nichts entpackt.'];
}
$files = kant_unpack($archive, $target);
kant_install_key($target);
return [true, 'Entpackt: '.$files.' Dateien.'];
} catch (Throwable $e) {
@@ -259,7 +284,9 @@ header('X-Robots-Tag: noindex, nofollow');
<h1>Die Dateien liegen.</h1>
<p class="lead">Diese Datei hat sich selbst gelöscht. Weiter geht es mit der Einrichtung: Datenbank, Konto, Website.</p>
<p class="notice done"><?= $e($message) ?></p>
<p><a href="install">Zur Einrichtung →</a></p>
<?php $key = kant_install_key($target); ?>
<p>Dein Installationsschlüssel: <code><?= $e($key) ?></code><br><span style="font-size:13px;color:#5d6b62">Nur diese Seite zeigt ihn an. Der Verweis unten trägt ihn schon; liegt er dir nicht mehr vor, steht er in <code>var/install.key</code>.</span></p>
<p><a href="install/schluessel?schluessel=<?= $e($key) ?>" rel="noreferrer">Zur Einrichtung →</a></p>
<p style="font-size:13px;color:#5d6b62">Führt der Verweis ins Leere, zeigt die Domain nicht auf <code>public/</code>. Das muss beim Hoster eingestellt werden – ohne das wären auch <code>.env</code> und <code>var/</code> über das Netz erreichbar.</p>
<?php } else { ?>
<h1>KantSites installieren</h1>
+8 -3
View File
@@ -1,6 +1,7 @@
#!/bin/sh
# Wirft die Hüfner-Installation weg und baut sie neu auf. Nur fuer den Aufbau gedacht.
# Erstes Argument: der entpackte Entwurf (Ordner mit index.html und images/).
# Zweites Argument: Umuts Unterlagen (Alpakas, PQ, Freistellungsbescheinigungen, Signet).
set -e
root="C:/Django Project/KantSites"
php="${PHP_BINARY:-C:/php84/php.exe}"
@@ -8,10 +9,10 @@ export DATABASE_URL="sqlite:///$root/var/huefner.db"
export MEDIA_DIRECTORY="$root/var/huefner-media"
assets="$root/var/huefner-assets"
if [ -n "$1" ]; then
"$php" scripts/build-huefner-assets.php "$1" "$assets"
"$php" scripts/build-huefner-assets.php "$1" "$assets" "$2"
fi
if [ ! -f "$assets/favicon.png" ]; then
echo "Bilder fehlen: erst mit dem Ordner des Entwurfs als Argument aufrufen." >&2
if [ ! -f "$assets/favicon.png" ] || [ ! -f "$assets/pq-bescheinigung.pdf" ]; then
echo "Bilder fehlen: erst mit dem Ordner des Entwurfs und den Unterlagen als Argumente aufrufen." >&2
exit 1
fi
rm -f "$root/var/huefner.db"
@@ -24,3 +25,7 @@ if [ -f "$root/var/cacert.pem" ]; then
else
"$php" bin/console cms:fonts:fetch
fi
# Das Inhaltspaket für die Live-Seite (ADR 118): dort unter System → Inhalte übertragen importieren.
# Konten bleiben dabei, wie sie live sind.
rm -f "$root/var/huefner-paket.zip"
"$php" bin/console cms:paket:export --ziel="$root/var/huefner-paket.zip"
+29
View File
@@ -0,0 +1,29 @@
#!/bin/sh
# Wirft die Kantarci-Reisen-Installation weg und baut sie neu auf. Nur fuer den Aufbau gedacht.
# Erstes Argument: der entpackte Ordner „Kantarci-Reisen“ aus dem Komplettpaket (Website/, Logo/).
# Zweites Argument: der Screenshot der reiseSoft-Uebersicht (PNG, Beispieldaten).
set -e
root="C:/Django Project/KantSites"
php="${PHP_BINARY:-C:/php84/php.exe}"
export DATABASE_URL="sqlite:///$root/var/kantarci-reisen.db"
export MEDIA_DIRECTORY="$root/var/kantarci-reisen-media"
assets="$root/var/kantarci-reisen-assets"
if [ -n "$1" ]; then
"$php" scripts/build-kantarci-reisen-assets.php "$1" "$assets" "$2"
fi
if [ ! -f "$assets/favicon.png" ] || [ ! -f "$assets/reisesoft-uebersicht.png" ]; then
echo "Bilder fehlen: erst mit dem Paketordner und dem Screenshot als Argumente aufrufen." >&2
exit 1
fi
rm -f "$root/var/kantarci-reisen.db"
rm -rf "$root/var/kantarci-reisen-media"
"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q
"$php" -d memory_limit=512M scripts/seed-kantarci-reisen.php "$assets"
# DM Sans vom eigenen Server; der Abruf ergaenzt fonts.css nur.
if [ -f "$root/var/cacert.pem" ]; then
"$php" -d curl.cainfo="$root/var/cacert.pem" -d openssl.cafile="$root/var/cacert.pem" bin/console cms:fonts:fetch
else
"$php" bin/console cms:fonts:fetch
fi
# Tuerkisch als zweite Sprache (ADR 111): Seiten, Kopf, Fuss, Menue, Formular, Sprachwahl.
"$php" scripts/translate-kantarci-reisen.php "$root/var/kantarci-reisen.db"
+589 -199
View File
File diff suppressed because it is too large. Load diff
+906
View File
@@ -0,0 +1,906 @@
<?php
/**
* Baut den Entwurf „Kantarci Reisen“ als Inhalt dieses CMS auf.
*
* Vorlage ist Umuts Paket „Kantarci-Reisen-Komplettpaket.zip“ (08.10.2026): eine statische Seite
* mit weißem Kopf, geteiltem Hero (Petrol links, Küstenfoto rechts), drei Angeboten, „Gute Reisen
* beginnen mit einem Gespräch“, Anfrage auf Petrol mit weißer Formularkarte, reiseSoft-Abschnitt
* und Fuß. Wortlaut, Farben (Petrol #0A363B, Orange #FF753D) und die kursive Serifenschrift für
* die Pointe („wo*anders.*“) sind daraus übernommen. Gebaut ist alles aus gewöhnlichen Bausteinen,
* Gestaltungswerten und eigenem CSS je Baustein – kein Kerncode, kein Plugin.
*
* Bewusste Abweichungen und Ergänzungen:
* - Statt Arial die DM Sans vom eigenen Server; die Serifenpointe bleibt Georgia kursiv wie in der
* Vorlage (Systemschrift, nichts zu laden).
* - Das Formular ist ein echtes CMS-Formular mit Versand an info@kantarci-reisen.de, nicht die
* Demo der Vorlage. Dazu Telefon, Reisezeitraum und Personenzahl, alle freiwillig.
* - Die reiseSoft-Karte der Vorlage (drei Zeilen als Attrappe) ist ein echter Screenshot der
* Übersicht – aufgenommen aus einer lokalen reiseSoft-Instanz mit deren fiktiven Demodaten,
* also ohne Kundendaten. Daneben drei Funktionen, die reiseSoft wirklich hat, und ein Knopf zur
* Plattform unter reisesoft.kantarci-reisen.de.
* - Neu: „So läuft’s“ in drei Schritten, Anschrift im Abschnitt „Über uns“, Impressum,
* Datenschutz und 404. „01 / 03“ im Hero (ohne Bedeutung) ist der Ort.
* - Inhaber, Anschrift, E-Mail und Telefon von Umut. Eine USt-IdNr. gibt es nicht, Öffnungszeiten
* sind nicht bekannt; beides erscheint deshalb nirgends.
* - WhatsApp als zweiter Weg zur Anfrage: Knopf unter „Über uns“ und neben dem Formular, Link im
* Fuß und eine runde Schaltfläche unten links auf jeder Seite. Gebaut aus dem Baustein
* „Soziale Profile“ (Netzwerk WhatsApp aus den Einstellungen), der das Markensymbol mitbringt.
*
* Das Skript schreibt ausschließlich in die Datenbank aus DATABASE_URL und in MEDIA_DIRECTORY,
* beide müssen auf eine frische, migrierte Installation zeigen (siehe reset-kantarci-reisen.sh).
* Erstes Argument: der Ordner aus build-kantarci-reisen-assets.php.
*/
require dirname(__DIR__).'/config/bootstrap.php';
use App\Content\DocumentValidator;
use App\Content\FormRepository;
use App\Content\MediaLibrary;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\PartialRepository;
use App\Content\SiteSettings;
use App\Content\Theme;
use App\Infrastructure\ConnectionFactory;
use Symfony\Component\HttpFoundation\File\UploadedFile;
$root = dirname(__DIR__);
$assets = $argv[1] ?? null;
if (null === $assets || !is_dir($assets)) {
fwrite(STDERR, "Bitte den Ordner mit den Bildern als Argument angeben.\n");
exit(1);
}
$url = str_replace('%kernel.project_dir%', str_replace('\\', '/', $root), (string) $_SERVER['DATABASE_URL']);
$mediaDirectory = str_replace('%kernel.project_dir%', $root, (string) $_SERVER['MEDIA_DIRECTORY']);
if (!str_contains($url, 'kantarci-reisen')) {
fwrite(STDERR, "Sicherung: DATABASE_URL muss auf die Kantarci-Reisen-Datenbank zeigen.\n");
exit(1);
}
$db = ConnectionFactory::create($url);
if (false !== $db->fetchOne('SELECT id FROM page LIMIT 1')) {
fwrite(STDERR, "Diese Datenbank enthält bereits Seiten. Abbruch, damit nichts überschrieben wird.\n");
exit(1);
}
$media = new MediaLibrary($db, $mediaDirectory);
$settings = new SiteSettings($db, $media);
$pages = new PageRepository($db, new DocumentValidator(), $settings);
$partials = new PartialRepository($db, new DocumentValidator());
$menus = new MenuRepository($db, $settings);
$forms = new FormRepository($db);
$theme = new Theme($db);
$firma = 'Kantarci Reisen';
$inhaber = 'I. Kantarci';
$mail = 'info@kantarci-reisen.de';
$strasse = 'Ooser Hauptstraße 51';
$plz = '76532';
$ort = 'Baden-Baden';
$reisesoft = 'https://reisesoft.kantarci-reisen.de';
// Telefon und WhatsApp von Umut (08.10.2026), dieselbe Mobilnummer.
$telefon = '0170 2945935';
$tel = 'tel:+491702945935';
$whatsapp = 'https://wa.me/491702945935?text='.rawurlencode('Hallo Kantarci Reisen, ich interessiere mich für eine Reise.');
$SCHEMA = DocumentValidator::SCHEMA_VERSION;
// ---------------------------------------------------------------------------------------------
// Bausteine
// ---------------------------------------------------------------------------------------------
$seiten = static fn (string $w): array => ['top' => $w, 'right' => $w, 'bottom' => $w, 'left' => $w];
$nurOben = static fn (string $w): array => ['top' => $w, 'right' => '0px', 'bottom' => '0px', 'left' => '0px'];
$nurUnten = static fn (string $w): array => ['top' => '0px', 'right' => '0px', 'bottom' => $w, 'left' => '0px'];
$rund = static fn (string $w): array => ['topLeft' => $w, 'topRight' => $w, 'bottomRight' => $w, 'bottomLeft' => $w];
$section = static function (string $id, array $children, array $style = [], ?string $anchor = null, string $css = '', string $breite = 'normal'): array {
$block = ['id' => $id, 'type' => 'section', 'children' => $children, 'style' => $style];
if (null !== $anchor) {
$block['anchor'] = $anchor;
}
if ('' !== $css) {
$block['css'] = $css;
}
if ('normal' !== $breite) {
$block['layout'] = ['width' => $breite];
}
return $block;
};
$row = static function (string $id, array $children, array $design = [], array $style = [], string $css = ''): array {
$block = ['id' => $id, 'type' => 'row', 'children' => $children];
if ([] !== $design) {
$block['layout'] = ['design' => $design];
}
if ([] !== $style) {
$block['style'] = $style;
}
if ('' !== $css) {
$block['css'] = $css;
}
return $block;
};
$zweiSpalten = ['desktop' => ['columns' => 'two'], 'tablet' => ['columns' => 'one'], 'mobile' => ['columns' => 'one']];
/** Eine Zeile, die nebeneinander stellt statt zu rastern. */
$zeile = static function (string $id, array $children, string $gap, array $extra = [], array $mobil = [], array $tablet = [], string $css = '') use ($row): array {
$style = ['desktop' => ['display' => 'flex', 'alignItems' => 'center', 'gap' => $gap, 'margin' => ['top' => '0px'], ...$extra]];
if ([] !== $tablet) {
$style['tablet'] = $tablet;
}
if ([] !== $mobil) {
$style['mobile'] = $mobil;
}
return $row($id, $children, [], $style, $css);
};
$column = static function (string $id, array $children, array $style = [], string $css = '', string $sichtbar = 'all'): array {
$block = ['id' => $id, 'type' => 'column', 'children' => $children];
if ([] !== $style) {
$block['style'] = $style;
}
if ('' !== $css) {
$block['css'] = $css;
}
if ('all' !== $sichtbar) {
$block['layout'] = ['visibility' => $sichtbar];
}
return $block;
};
$heading = static function (string $id, string $text, int $level, array $style = [], string $css = ''): array {
$block = ['id' => $id, 'type' => 'heading', 'props' => ['text' => $text, 'level' => $level]];
if ([] !== $style) {
$block['style'] = $style;
}
if ('' !== $css) {
$block['css'] = $css;
}
return $block;
};
$text = static function (string $id, string $inhalt, array $style = [], string $css = '', string $sichtbar = 'all'): array {
$block = ['id' => $id, 'type' => 'text', 'props' => ['text' => $inhalt]];
if ([] !== $style) {
$block['style'] = $style;
}
if ('' !== $css) {
$block['css'] = $css;
}
if ('all' !== $sichtbar) {
$block['layout'] = ['visibility' => $sichtbar];
}
return $block;
};
$bild = static function (string $id, array $eintrag, string $alt, array $style = [], ?string $href = null, string $css = ''): array {
$gross = end($eintrag['variants']);
$props = [
'src' => $gross['src'], 'alt' => $alt, 'decorative' => '' === $alt, 'width' => $gross['width'], 'height' => $gross['height'],
'mediaId' => $eintrag['id'],
'sources' => array_map(static fn (array $v): array => ['src' => $v['src'], 'width' => $v['width']], $eintrag['variants']),
];
if (null !== $href) {
$props['href'] = $href;
$props['click'] = 'link';
}
$block = ['id' => $id, 'type' => 'image', 'props' => $props, 'style' => $style];
if ('' !== $css) {
$block['css'] = $css;
}
return $block;
};
$symbol = static fn (string $id, string $name, int $groesse, array $style = []): array => ['id' => $id, 'type' => 'icon', 'props' => ['icon' => $name, 'size' => $groesse, 'decorative' => true, 'label' => ''], 'style' => $style];
/**
* Ein Button wie in der Vorlage: ohne Pfeil, hebt sich beim Überfahren leicht an.
*
* @param string $art orange (Theme), petrol, rahmen (Petrol auf Hell), hell (Rahmen auf Petrol)
*/
$knopf = static function (string $id, string $inhalt, string $href, string $art = 'orange', array $style = [], string $icon = 'none') : array {
$farbe = [
'orange' => '',
'petrol' => "background:#0a363b;color:#fff;\n&:hover{background:#20545a;color:#fff}\n",
'rahmen' => "background:transparent;color:#0a363b;box-shadow:inset 0 0 0 1px #0a363b;\n&:hover{background:#f0f5f4;color:#0a363b}\n",
'hell' => "background:transparent;color:#fff;box-shadow:inset 0 0 0 1px rgba(255,255,255,.55);\n&:hover{background:rgba(255,255,255,.1);color:#fff}\n",
][$art];
$props = ['text' => $inhalt, 'href' => $href, 'icon' => $icon];
if ('none' !== $icon) {
$props['iconPosition'] = 'end';
$props['iconMotion'] = 'slide';
}
return ['id' => $id, 'type' => 'button', 'props' => $props, 'css' => "min-height:50px;padding:0 26px;margin-top:0;transition:transform .2s ease,background-color .2s ease;\n".$farbe.'&:hover{transform:translateY(-2px)}']
+ ([] === $style ? [] : ['style' => $style]);
};
/** WhatsApp als Knopf: Symbol und Name aus dem Baustein „Soziale Profile“, in WhatsApp-Grün. */
$waKnopf = static fn (string $id, array $style = []): array => ['id' => $id, 'type' => 'social',
'props' => ['size' => 20, 'shape' => 'plain', 'layout' => 'horizontal', 'labels' => true, 'networks' => ['whatsapp']],
'css' => "& a{min-height:50px;padding:0 24px;gap:10px;border-radius:4px;background:#25d366;color:#073b2f;font-size:15px;font-weight:600;transition:transform .2s ease,background-color .2s ease}\n& a:hover{background:#3ee07a;color:#073b2f;transform:translateY(-2px)}",
] + ([] === $style ? [] : ['style' => $style]);
/**
* Setzt die Einblendungen nach einer Liste.
*
* @param array<string, array{0: string, 1?: string}> $plan Block-ID => [Art, Verzögerung]
*/
$einblenden = static function (array $dokument, array $plan): array {
$gehen = function (array $bloecke) use (&$gehen, $plan): array {
foreach ($bloecke as $i => $block) {
if (isset($plan[$block['id'] ?? ''])) {
[$art, $verzug] = [...$plan[$block['id']], 'none'];
$bloecke[$i]['layout'] = [...($block['layout'] ?? []), 'effects' => ['entrance' => $art, 'delay' => $verzug]];
}
if (isset($block['children'])) {
$bloecke[$i]['children'] = $gehen($block['children']);
}
}
return $bloecke;
};
$dokument['blocks'] = $gehen($dokument['blocks']);
return $dokument;
};
// Die kursive Serifenpointe der Vorlage: auf Petrol Apricot, auf Hell in der Schriftfarbe.
$serif = "& em{font-family:Georgia,serif;font-style:italic;font-weight:400;letter-spacing:-.045em}";
$serifHell = $serif."\n& em{color:#ffab82}";
$linkCss = "& a{color:#0a363b;text-decoration:underline;text-decoration-color:#ff753d;text-underline-offset:4px}\n& a:hover{color:#a43e13}";
/** Die Kleinzeile in Versalien („WOHIN ZIEHT ES DICH?“). */
$kleinzeile = static fn (string $id, string $inhalt, string $farbe = '@text', string $unten = '22px') => $text($id, $inhalt, ['desktop' => [
'fontSize' => '12px', 'fontWeight' => '600', 'letterSpacing' => '0.15em', 'lineHeight' => 1.7, 'textTransform' => 'uppercase', 'color' => $farbe, 'margin' => ['top' => '0px', 'bottom' => $unten],
]]);
$h2Stil = [
'desktop' => ['fontSize' => '49px', 'fontWeight' => '500', 'lineHeight' => 1.1, 'letterSpacing' => '-0.04em', 'color' => '@tinte', 'margin' => ['top' => '0px', 'bottom' => '0px']],
'tablet' => ['fontSize' => '40px'],
'mobile' => ['fontSize' => '35px'],
];
$fliess = static fn (string $farbe = '@text', string $unten = '0px', string $breite = 'none', string $groesse = '16px') => [
'desktop' => ['fontSize' => $groesse, 'lineHeight' => 1.8, 'color' => $farbe, 'maxWidth' => $breite, 'margin' => ['top' => '0px', 'bottom' => $unten]],
'mobile' => ['maxWidth' => 'none'],
];
// Ein Link über die ganze Karte: Die Karte ist positioniert, der Text darin nicht, also spannt
// das ::after des Links die ganze Karte auf. Ein Link, ein Name, ganze Fläche klickbar.
$ganzeKarte = "position:relative;\n& a::after{content:'';position:absolute;top:0;right:0;bottom:0;left:0}\n& a:focus-visible{outline:none}\n&:focus-within{outline:3px solid #ff753d;outline-offset:4px}";
// ---------------------------------------------------------------------------------------------
// Website, Gestaltung, Medien
// ---------------------------------------------------------------------------------------------
$db->insert('website', ['id' => 'default', 'name' => $firma, 'default_locale' => 'de', 'theme' => '{}']);
$theme->save(
[
['key' => 'tinte', 'name' => 'Petrol (Schrift, dunkle Flächen)', 'value' => '#0a363b'],
['key' => 'petrol-hell', 'name' => 'Petrol hell (beim Überfahren)', 'value' => '#20545a'],
['key' => 'orange', 'name' => 'Orange (Knöpfe, Linien)', 'value' => '#ff753d'],
['key' => 'orange-hell', 'name' => 'Orange hell (Knöpfe beim Überfahren)', 'value' => '#ff8b5a'],
['key' => 'apricot', 'name' => 'Apricot (Pointe auf Petrol)', 'value' => '#ffab82'],
['key' => 'orange-text', 'name' => 'Orange dunkel (Schrift auf Weiß)', 'value' => '#a43e13'],
['key' => 'knopf-schrift', 'name' => 'Schrift auf Orange', 'value' => '#102f30'],
['key' => 'text', 'name' => 'Fließtext', 'value' => '#52696b'],
['key' => 'linie', 'name' => 'Linien', 'value' => '#dbe3e3'],
['key' => 'pale', 'name' => 'Helles Petrol (Flächen)', 'value' => '#f0f5f4'],
['key' => 'auf-dunkel', 'name' => 'Schrift auf Petrol', 'value' => '#c0d5d5'],
['key' => 'papier', 'name' => 'Weiß', 'value' => '#ffffff'],
],
[
['key' => 'body', 'name' => 'Text und Überschriften', 'family' => 'DM Sans', 'source' => 'google', 'weights' => [400, 500, 600, 700], 'fallback' => 'sans-serif'],
],
['fontFamily' => 'body', 'headingFontFamily' => 'body', 'fontSize' => '16px', 'lineHeight' => 1.6, 'color' => '@tinte', 'backgroundColor' => '@papier', 'linkColor' => '@tinte', 'containerWidth' => '1200px', 'headingScale' => 1.25, 'linkUnderline' => false, 'imageRadius' => '0px'],
['shape' => 'rounded', 'appearance' => 'solid', 'size' => 'normal', 'fontFamily' => 'body', 'fontSize' => 15, 'fontWeight' => 600, 'letterSpacing' => 0, 'textTransform' => 'none', 'backgroundColor' => '@orange', 'color' => '@knopf-schrift', 'hoverBackgroundColor' => '@orange-hell', 'hoverColor' => '@knopf-schrift'],
);
echo "Farben, Schrift und Buttons gesetzt\n";
$medien = [];
foreach ([
'logo' => ['logo.png', 'Kantarci Reisen'],
'favicon' => ['favicon.png', 'Bildzeichen von Kantarci Reisen: K mit Sonne und Schwingen'],
'kueste' => ['kueste.webp', 'Türkisfarbene Mittelmeerbucht mit sonniger Strandterrasse'],
'reisesoft' => ['reisesoft-uebersicht.png', 'reiseSoft-Übersicht mit offenen Posten, Umsatz und den letzten Vorgängen (Beispieldaten)'],
] as $schluessel => [$datei, $alt]) {
$pfad = rtrim($assets, '/\\').'/'.$datei;
if (!is_file($pfad)) {
fwrite(STDERR, "Bild fehlt: $pfad\n");
exit(1);
}
$eintrag = $media->upload(new UploadedFile($pfad, basename($datei), null, null, true));
$media->update($eintrag['id'], ['title' => pathinfo($datei, PATHINFO_FILENAME), 'alt' => $alt, 'archived' => false, 'version' => $eintrag['version']]);
$medien[$schluessel] = $media->find($eintrag['id']);
echo "Bild übernommen: $datei\n";
}
// ---------------------------------------------------------------------------------------------
// Formular
// ---------------------------------------------------------------------------------------------
$forms->save('reiseanfrage', 'Reiseanfrage', [
['name' => 'name', 'type' => 'text', 'label' => 'Dein Name', 'required' => true, 'help' => 'Vor- und Nachname'],
['name' => 'email', 'type' => 'email', 'label' => 'Deine E-Mail', 'required' => true],
['name' => 'telefon', 'type' => 'phone', 'label' => 'Telefon', 'required' => false, 'help' => 'Freiwillig – wenn wir dich lieber anrufen sollen'],
['name' => 'anliegen', 'type' => 'select', 'label' => 'Anliegen', 'required' => true, 'options' => ['Türkei-Urlaub', 'Hotels weltweit', 'Flüge in die Türkei', 'reiseSoft für mein Reisebüro', 'Allgemeine Anfrage']],
['name' => 'zeitraum', 'type' => 'text', 'label' => 'Reisezeitraum', 'required' => false, 'help' => 'z. B. Pfingstferien oder 2 Wochen im August'],
['name' => 'personen', 'type' => 'number', 'label' => 'Personen', 'required' => false, 'min' => '1', 'max' => '30'],
['name' => 'wuensche', 'type' => 'textarea', 'label' => 'Deine Wünsche', 'required' => true, 'help' => 'Reiseziel, Abflughafen, Budget, was dir wichtig ist …'],
['name' => 'datenschutz', 'type' => 'consent', 'label' => 'Ich stimme der Verarbeitung meiner Angaben gemäß Datenschutzhinweisen zu.'],
], ['recipient' => $mail, 'subject' => 'Neue Reiseanfrage über die Website', 'success' => 'Danke! Deine Anfrage ist bei uns angekommen. Wir melden uns persönlich bei dir – meist innerhalb eines Werktags.', 'replyToField' => 'email', 'buttonLabel' => 'Anfrage senden'], 0);
echo "Formular angelegt: reiseanfrage\n";
// ---------------------------------------------------------------------------------------------
// Kopfbereich: weiß, Logo, Menü, „Reise anfragen“ als Menü-Button
// ---------------------------------------------------------------------------------------------
$kopf = [
'schemaVersion' => $SCHEMA, 'title' => 'Kopfbereich', 'description' => 'Erscheint auf jeder Seite über dem Inhalt.',
'blocks' => [
$section('kopf', [
$zeile('kopf-zeile', [
$bild('kopf-logo', $medien['logo'], $firma.' – zur Startseite', [
// Feste Breite: sonst bekommt das Logo seine Breite erst nach dem Laden (CLS).
'desktop' => ['width' => '230px', 'maxWidth' => '230px', 'flexShrink' => 0, 'margin' => $seiten('0px')],
'mobile' => ['width' => '132px', 'maxWidth' => '132px'],
], '/', '& img{display:block;width:100%;height:auto;border-radius:0}'),
['id' => 'kopf-menue', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => 'horizontal', 'collapse' => 'tablet', 'burgerLabel' => 'icon'],
'style' => ['desktop' => [
'fontSize' => '15px', 'fontWeight' => '500', 'color' => '@tinte',
'burgerColor' => '@tinte', 'burgerBackgroundColor' => '@papier', 'burgerBorderColor' => '@linie', 'burgerBorderRadius' => '4px',
]],
// Orange Linie, die beim Überfahren von links einläuft; das Klappmenü liegt als
// Fläche unter dem ganzen Kopf.
'css' => "& .menu-list{gap:4px 36px;align-items:center}\n"
."& .menu-list a{position:relative;padding:8px 0;text-decoration:none}\n"
."& .menu-list a::after{content:'';position:absolute;left:0;right:100%;bottom:2px;height:2px;background:#ff753d;transition:right .25s ease}\n"
."& .menu-list a:hover::after{right:0}\n"
// Der Menü-Button ist auch ein Link der Liste: Polster und Linie gehören nicht zu ihm.
."& .menu-list .menu-button{padding:12px 22px}\n"
."& .menu-list .menu-button::after{display:none}\n"
."& .menu-burger summary{width:46px;height:46px;display:flex;align-items:center;justify-content:center;padding:0}\n"
."& .menu-burger-body{position:absolute;left:0;right:0;top:100%;background:#fff;padding:4px 20px 20px;border-top:1px solid #dbe3e3;box-shadow:0 18px 30px rgba(10,54,59,.1);font-size:18px}\n"
."& .menu-burger-body .menu-list{flex-direction:column;align-items:stretch;gap:0}\n"
."& .menu-burger-body .menu-list a{display:block;padding:14px 0;border-bottom:1px solid #e8eded}\n"
.'& .menu-burger-body .menu-list a::after{display:none}'],
], '28px', ['justifyContent' => 'space-between', 'minHeight' => '96px'], ['minHeight' => '72px', 'gap' => '6px']),
], [
'desktop' => ['backgroundColor' => '#fffffff5', 'padding' => ['top' => '0px', 'bottom' => '0px'], 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie'],
], null, '', 'wide'),
],
];
// ---------------------------------------------------------------------------------------------
// Fußbereich
// ---------------------------------------------------------------------------------------------
$fussLink = "& a{color:#52696b;text-decoration:none;transition:color .2s}\n& a:hover{color:#0a363b}";
$fussTitel = static fn (string $id, string $inhalt) => $kleinzeile($id, $inhalt, '@tinte', '14px');
$fussText = static fn (string $id, string $inhalt) => $text($id, $inhalt, ['desktop' => ['fontSize' => '15px', 'lineHeight' => 1.9, 'color' => '@text', 'margin' => ['bottom' => '0px']]], $fussLink."\n& a{overflow-wrap:anywhere}");
$fuss = [
'schemaVersion' => $SCHEMA, 'title' => 'Fußbereich', 'description' => 'Erscheint auf jeder Seite unter dem Inhalt.',
'blocks' => [
$section('fuss', [
$column('fuss-raster', [
$column('fuss-marke', [
$bild('fuss-logo', $medien['logo'], $firma, ['desktop' => ['width' => '200px', 'maxWidth' => '200px', 'margin' => $seiten('0px')]], null, '& img{display:block;width:100%;height:auto;border-radius:0}'),
$text('fuss-spruch', 'Türkei im Herzen. Die Welt im Blick.', ['desktop' => ['fontSize' => '15px', 'lineHeight' => 1.6, 'color' => '@text', 'margin' => ['top' => '18px', 'bottom' => '0px']]]),
]),
$column('fuss-reisen', [
$fussTitel('fuss-reisen-titel', 'Reisen'),
$fussText('fuss-reisen-liste', '<a href="/#reisen">Türkei-Urlaub</a><br><a href="/#reisen">Hotels weltweit</a><br><a href="/#reisen">Flüge in die Türkei</a><br><a href="/#kontakt">Reise anfragen</a>'),
]),
$column('fuss-business', [
$fussTitel('fuss-business-titel', 'Für Reisebüros'),
$fussText('fuss-business-liste', '<a href="/#reisesoft">reiseSoft kennenlernen</a><br><a href="'.$reisesoft.'">Zur reiseSoft-Plattform</a>'),
]),
$column('fuss-kontakt', [
$fussTitel('fuss-kontakt-titel', 'Kontakt'),
$fussText('fuss-kontakt-text', $firma.'<br>'.$strasse.'<br>'.$plz.' '.$ort.'<br><a href="'.$tel.'">'.$telefon.'</a><br><a href="mailto:'.$mail.'">'.$mail.'</a><br><a href="'.$whatsapp.'">WhatsApp</a>'),
]),
], [
'desktop' => ['display' => 'grid', 'gridColumns' => 4, 'gap' => '40px', 'padding' => ['top' => '64px', 'bottom' => '40px']],
'tablet' => ['gridColumns' => 2],
'mobile' => ['gridColumns' => 1, 'gap' => '32px', 'padding' => ['top' => '48px', 'bottom' => '32px']],
]),
// Auf jeder Seite unten links: WhatsApp als runde Schaltfläche (rechts sitzt „nach oben“).
['id' => 'fuss-whatsapp', 'type' => 'social', 'props' => ['size' => 32, 'shape' => 'plain', 'layout' => 'horizontal', 'labels' => false, 'networks' => ['whatsapp']],
'css' => "position:fixed;left:20px;bottom:20px;z-index:40;margin:0\n& a{width:58px;height:58px;justify-content:center;border-radius:50%;background:#25d366;color:#073b2f;box-shadow:0 10px 28px rgba(10,54,59,.28);transition:transform .2s ease}\n& a:hover{transform:scale(1.07);color:#073b2f}"],
$zeile('fuss-unten', [
$text('fuss-copyright', '© 2026 '.$firma, ['desktop' => ['fontSize' => '13px', 'color' => '@text', 'margin' => ['bottom' => '0px']]]),
$zeile('fuss-links', [
['id' => 'fuss-rechtliches', 'type' => 'legal', 'props' => ['label' => 'Rechtliches'],
'style' => ['desktop' => ['fontSize' => '13px', 'gap' => '24px', 'color' => '@text']],
'css' => $fussLink],
$text('fuss-agentur', '<a href="https://kant-it.de">Website by Kant-IT Solutions</a>', ['desktop' => ['fontSize' => '13px', 'margin' => ['bottom' => '0px']]], $fussLink),
], '24px', ['flexWrap' => 'wrap'], ['rowGap' => '10px', 'columnGap' => '18px']),
], '24px', ['justifyContent' => 'space-between', 'flexWrap' => 'wrap', 'rowGap' => '12px', 'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '22px', 'bottom' => '32px']],
['flexDirection' => 'column', 'alignItems' => 'flex-start']),
], [
'desktop' => ['backgroundColor' => '@papier', 'padding' => ['top' => '0px', 'bottom' => '0px'], 'borderWidth' => $nurOben('7px'), 'borderStyle' => 'solid', 'borderColor' => '@tinte'],
]),
],
];
$partials->create('header', 'default', 'Kopfbereich', $kopf);
$partials->create('footer', 'default', 'Fußbereich', $fuss);
echo "Kopf- und Fußbereich angelegt\n";
// ---------------------------------------------------------------------------------------------
// Startseite
// ---------------------------------------------------------------------------------------------
/** Ein Angebot: Nummer und Kleinzeile, Titel, Satz, Textlink; die ganze Karte ist der Link. */
$angebot = static function (int $n, string $klein, string $icon, string $titel, string $satz, string $link) use ($column, $zeile, $text, $heading, $symbol, $nurOben, $ganzeKarte): array {
$id = 'angebot-'.$n;
return $column($id, [
$zeile($id.'-kopf', [
$symbol($id.'-icon', $icon, 24, ['desktop' => ['color' => '@orange-text']]),
$text($id.'-klein', $klein, ['desktop' => ['fontSize' => '11px', 'fontWeight' => '600', 'letterSpacing' => '0.12em', 'textTransform' => 'uppercase', 'color' => '#687e80', 'margin' => ['bottom' => '0px']]]),
], '12px', ['justifyContent' => 'space-between', 'margin' => ['bottom' => '30px']]),
$heading($id.'-titel', $titel, 3, [
'desktop' => ['fontSize' => '30px', 'fontWeight' => '500', 'lineHeight' => 1.15, 'letterSpacing' => '-0.035em', 'color' => '@tinte', 'margin' => ['top' => '0px', 'bottom' => '18px']],
'mobile' => ['fontSize' => '28px'],
]),
$text($id.'-satz', $satz, ['desktop' => ['fontSize' => '15px', 'lineHeight' => 1.8, 'color' => '@text', 'maxWidth' => '320px', 'margin' => ['bottom' => '26px']], 'mobile' => ['maxWidth' => 'none']]),
$text($id.'-link', '<a href="#kontakt">'.$link.'</a>', ['desktop' => ['fontSize' => '14px', 'fontWeight' => '600', 'margin' => ['top' => 'auto', 'bottom' => '0px']]],
"& a{display:inline-block;color:#0a363b;text-decoration:none;border-bottom:1px solid #ff753d;padding-bottom:6px;transition:border-color .2s}\n& a:hover{border-bottom-color:#0a363b}"),
], [
'desktop' => ['display' => 'flex', 'flexDirection' => 'column', 'padding' => ['top' => '30px', 'right' => '30px', 'bottom' => '34px', 'left' => '30px'],
'borderWidth' => ['top' => '0px', 'right' => '0px', 'bottom' => '0px', 'left' => $n > 1 ? '1px' : '0px'], 'borderStyle' => 'solid', 'borderColor' => '@linie'],
'mobile' => ['padding' => ['top' => '26px', 'right' => '0px', 'bottom' => '28px', 'left' => '0px'], 'borderWidth' => $nurOben($n > 1 ? '1px' : '0px')],
], $ganzeKarte."\ntransition:background-color .25s ease;\n&:hover{background-color:#f0f5f4}");
};
/** Ein Schritt: große Ziffer in Orange-Linie, Titel, Satz. */
$schritt = static fn (int $n, string $titel, string $satz) => $column('schritt-'.$n, [
$text('schritt-'.$n.'-nummer', sprintf('%02d', $n), ['desktop' => ['fontSize' => '13px', 'fontWeight' => '600', 'letterSpacing' => '0.12em', 'color' => '@orange-text', 'borderWidth' => $nurOben('2px'), 'borderStyle' => 'solid', 'borderColor' => '@orange', 'padding' => ['top' => '16px'], 'margin' => ['bottom' => '14px']]]),
$heading('schritt-'.$n.'-titel', $titel, 3, ['desktop' => ['fontSize' => '22px', 'fontWeight' => '500', 'lineHeight' => 1.25, 'letterSpacing' => '-0.02em', 'color' => '@tinte', 'margin' => ['top' => '0px', 'bottom' => '10px']]]),
$text('schritt-'.$n.'-satz', $satz, ['desktop' => ['fontSize' => '15px', 'lineHeight' => 1.75, 'color' => '@text', 'margin' => ['bottom' => '0px']]]),
]);
/** Eine Funktion von reiseSoft: Nummer im Kästchen, Titel, Satz. */
$funktion = static fn (int $n, string $titel, string $satz) => $zeile('funktion-'.$n, [
$text('funktion-'.$n.'-nummer', sprintf('%02d', $n), ['desktop' => ['display' => 'flex', 'alignItems' => 'center', 'justifyContent' => 'center', 'width' => '36px', 'height' => '36px', 'flexShrink' => 0, 'backgroundColor' => '@papier', 'borderRadius' => $rund('4px'), 'fontSize' => '12px', 'fontWeight' => '600', 'color' => '@tinte', 'margin' => ['bottom' => '0px']]]),
$text('funktion-'.$n.'-text', '<strong>'.$titel.'</strong>'.$satz, ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.55, 'color' => '@text', 'margin' => ['bottom' => '0px']]], '& strong{display:block;font-size:15px;font-weight:600;color:#0a363b;margin-bottom:2px}'),
], '16px', ['alignItems' => 'flex-start', 'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '#d5dfde', 'padding' => ['top' => '16px', 'bottom' => '16px']]);
// Formular im Zuschnitt der Vorlage: Felder hell mit Rahmen, Name/E-Mail, Telefon/Anliegen und
// Zeitraum/Personen nebeneinander, Knopf über die ganze Breite.
$formularCss = '& .block-form{max-width:none;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));column-gap:18px;row-gap:18px}
& .block-form>.form-field{grid-column:1/-1}
& .block-form>.cms-button{grid-column:1/-1}
& .block-form>.form-error-summary{grid-column:1/-1}
& .block-form>.form-success{grid-column:1/-1}
& .block-form>.form-missing{grid-column:1/-1}
& .block-form>[data-label="Dein Name"]{grid-column:span 1}
& .block-form>[data-label="Deine E-Mail"]{grid-column:span 1}
& .block-form>[data-label="Telefon"]{grid-column:span 1}
& .block-form>[data-label="Anliegen"]{grid-column:span 1}
& .block-form>[data-label="Reisezeitraum"]{grid-column:span 1}
& .block-form>[data-label="Personen"]{grid-column:span 1}
& .form-field{align-content:start}
& .form-field label{font-size:14px;font-weight:600;color:#0a363b}
& .form-field input{background:#f5f8f7;border:1px solid #b5c6c6;border-radius:4px;padding:13px 14px;font-size:16px;color:#0a363b;min-height:48px}
& .form-field select{background:#f5f8f7;border:1px solid #b5c6c6;border-radius:4px;padding:13px 14px;font-size:16px;color:#0a363b;min-height:48px}
& .form-field textarea{background:#f5f8f7;border:1px solid #b5c6c6;border-radius:4px;padding:13px 14px;font-size:16px;color:#0a363b;min-height:110px}
& .form-field input:focus{border-color:#0a363b;outline:2px solid #ff753d;outline-offset:2px}
& .form-field select:focus{border-color:#0a363b;outline:2px solid #ff753d;outline-offset:2px}
& .form-field textarea:focus{border-color:#0a363b;outline:2px solid #ff753d;outline-offset:2px}
& .field-note{font-size:12px;color:#52696b;opacity:1}
& .check-row label{font-weight:400;font-size:13px;line-height:1.6;color:#52696b}
& .check-row input{accent-color:#0a363b}
& .cms-button{width:100%;justify-content:center;min-height:54px;font-size:16px;transition:transform .2s ease,background-color .2s ease}
& .cms-button:hover{transform:translateY(-2px)}
& .cms-button .button-arrow{display:none}';
$startseite = [
'schemaVersion' => $SCHEMA,
'title' => 'Zeit für woanders.',
'description' => 'Türkei im Herzen. Die Welt im Blick. Persönliche Reiseberatung in Baden-Baden: Türkei-Urlaub, Hotels weltweit und Flüge in die Türkei.',
'blocks' => [
// 1 – Hero: Petrol links mit Text, Foto rechts mit Bildunterschrift.
$section('hero', [
$row('hero-zeile', [
$column('hero-text', [
$kleinzeile('hero-klein', 'Türkei im Herzen. Die Welt im Blick.', '@auf-dunkel', '0px'),
$heading('hero-titel', 'Zeit für<br>wo<em>anders.</em>', 1, [
'desktop' => ['fontSize' => '104px', 'fontWeight' => '500', 'lineHeight' => 1.01, 'letterSpacing' => '-0.058em', 'color' => '@papier', 'margin' => ['top' => '30px', 'bottom' => '30px']],
'tablet' => ['fontSize' => '76px'],
'mobile' => ['fontSize' => '68px', 'margin' => ['top' => '24px', 'bottom' => '22px']],
], $serifHell),
$text('hero-satz', 'Salz auf der Haut. Sonne im Gesicht.<br>Und eine Reise, die zu dir passt.', [
'desktop' => ['fontSize' => '17px', 'lineHeight' => 1.75, 'color' => '#d9e4e3', 'margin' => ['bottom' => '32px']],
'mobile' => ['fontSize' => '16px', 'margin' => ['bottom' => '24px']],
]),
$knopf('hero-knopf', 'Deine Auszeit planen', '#kontakt'),
$zeile('hero-unten', [
$text('hero-unten-links', 'Persönlich beraten.<br>Entspannt verreisen.', ['desktop' => ['fontSize' => '11px', 'lineHeight' => 1.6, 'letterSpacing' => '0.14em', 'textTransform' => 'uppercase', 'color' => '#a1bcbc', 'margin' => ['bottom' => '0px']]]),
$text('hero-unten-rechts', 'Baden-Baden · Oos', ['desktop' => ['fontSize' => '12px', 'letterSpacing' => '0.08em', 'color' => '#a1bcbc', 'margin' => ['bottom' => '0px']]], 'white-space:nowrap'),
], '20px', ['justifyContent' => 'space-between', 'alignItems' => 'flex-end', 'width' => '100%', 'margin' => ['top' => 'auto'], 'padding' => ['top' => '56px']], ['padding' => ['top' => '32px']]),
], [
'desktop' => ['display' => 'flex', 'flexDirection' => 'column', 'alignItems' => 'flex-start', 'backgroundColor' => '@tinte', 'color' => '@papier',
'padding' => ['top' => '68px', 'right' => '11%', 'bottom' => '34px', 'left' => '11%']],
'tablet' => ['padding' => ['top' => '48px', 'right' => '8%', 'bottom' => '30px', 'left' => '8%']],
'mobile' => ['padding' => ['top' => '40px', 'right' => '18px', 'bottom' => '28px', 'left' => '18px']],
]),
$column('hero-foto-spalte', [
// Das größte Element über der Falz: keine Einblendung (LCP).
$bild('hero-foto', $medien['kueste'], 'Türkisfarbene Mittelmeerbucht mit sonniger Strandterrasse', [
'desktop' => ['position' => 'absolute', 'top' => '0px', 'right' => '0px', 'bottom' => '0px', 'left' => '0px', 'margin' => $seiten('0px')],
], null, '& img{width:100%;height:100%;object-fit:cover;object-position:55% center;border-radius:0}'),
$text('hero-etikett', 'Reiseinspiration', ['desktop' => ['position' => 'absolute', 'top' => '24px', 'right' => '22px', 'backgroundColor' => '@papier', 'fontSize' => '10px', 'fontWeight' => '600', 'letterSpacing' => '0.15em', 'textTransform' => 'uppercase', 'color' => '@tinte', 'padding' => ['top' => '9px', 'right' => '11px', 'bottom' => '9px', 'left' => '11px'], 'margin' => ['bottom' => '0px']]]),
$text('hero-bildzeile', '<strong>Dein nächster Lieblingsort</strong>Ein bisschen mehr Meer.', [
'desktop' => ['position' => 'absolute', 'left' => '32px', 'bottom' => '32px', 'maxWidth' => '82%', 'backgroundColor' => '@papier', 'fontSize' => '23px', 'fontWeight' => '500', 'lineHeight' => 1.2, 'letterSpacing' => '-0.025em', 'color' => '@tinte',
'padding' => ['top' => '19px', 'right' => '23px', 'bottom' => '19px', 'left' => '23px'], 'margin' => ['bottom' => '0px']],
'mobile' => ['left' => '18px', 'bottom' => '18px', 'fontSize' => '20px', 'padding' => ['top' => '15px', 'right' => '18px', 'bottom' => '15px', 'left' => '18px']],
], '& strong{display:block;font-size:10px;font-weight:600;letter-spacing:.17em;text-transform:uppercase;margin-bottom:9px}'),
], [
'desktop' => ['position' => 'relative', 'overflow' => 'hidden', 'minHeight' => '650px', 'backgroundColor' => '#3e8a94'],
'tablet' => ['minHeight' => '440px'],
'mobile' => ['minHeight' => '370px'],
]),
], $zweiSpalten, ['desktop' => ['gap' => '0px', 'margin' => ['top' => '0px'], 'columnWidths' => '9 11', 'alignItems' => 'stretch']]),
], ['desktop' => ['padding' => ['top' => '0px', 'bottom' => '0px']], 'mobile' => ['padding' => ['top' => '0px', 'bottom' => '0px']]], null, '& .section-inner{max-width:1600px;margin-inline:auto}', 'full'),
// 2 – Reisen: Kopf mit Satz rechts, drei Angebote zwischen Linien.
$section('reisen-bereich', [
$row('reisen-kopf', [
$column('reisen-kopf-titel', [
$kleinzeile('reisen-klein', 'Wohin zieht es dich?'),
$heading('reisen-titel', 'Du hast Fernweh.<br>Wir haben Ideen.', 2, $h2Stil),
]),
$text('reisen-satz', 'Eine Auszeit am Meer, ein neuer Lieblingsort oder ein Besuch bei der Familie. Wir finden die passende Reise für dich.', [
'desktop' => ['maxWidth' => '360px', 'fontSize' => '15px', 'lineHeight' => 1.8, 'color' => '@text', 'margin' => ['bottom' => '0px']],
'mobile' => ['maxWidth' => 'none'],
]),
], [], [
'desktop' => ['display' => 'flex', 'flexWrap' => 'wrap', 'justifyContent' => 'space-between', 'alignItems' => 'flex-end', 'rowGap' => '22px', 'columnGap' => '50px', 'margin' => ['top' => '0px', 'bottom' => '46px']],
'mobile' => ['margin' => ['bottom' => '30px']],
]),
$column('reisen-raster', [
$angebot(1, 'Unser Schwerpunkt', 'sun', 'Türkei.<br>Ganz nach dir.', 'Von der Türkischen Riviera bis zur Ägäis. Entdecke Urlaub zwischen Gastfreundschaft, Küste und Kultur – mit Tipps aus erster Hand.', 'Türkei-Urlaub anfragen'),
$angebot(2, 'Hotels weltweit', 'building', 'Einchecken.<br>Abschalten.', 'Kleines Boutiquehotel oder großzügiges Resort? Wir helfen dir, den richtigen Ort für deine Auszeit zu finden.', 'Hotel anfragen'),
$angebot(3, 'Flüge in die Türkei', 'navigation', 'Manchmal ist<br>das Ziel Zuhause.', 'Für Urlaub, Familie oder einfach Sehnsucht. Wir suchen dir die passende Verbindung – auch ab Karlsruhe/Baden-Baden, Stuttgart oder Frankfurt.', 'Türkei-Flug anfragen'),
], [
'desktop' => ['display' => 'grid', 'gridColumns' => 3, 'gap' => '0px', 'borderWidth' => ['top' => '1px', 'right' => '0px', 'bottom' => '1px', 'left' => '0px'], 'borderStyle' => 'solid', 'borderColor' => '@linie'],
'mobile' => ['gridColumns' => 1],
], '& [id="angebot-1"]{padding-left:0}'),
], [
'desktop' => ['padding' => ['top' => '96px', 'bottom' => '88px']],
'mobile' => ['padding' => ['top' => '56px', 'bottom' => '48px']],
], 'reisen'),
// 3 – Über uns: „Gute Reisen beginnen mit einem Gespräch“, dazu die Anschrift.
$section('beratung-bereich', [
$row('beratung-zeile', [
$column('beratung-links', [
$kleinzeile('beratung-klein', 'Dein Reisebüro.<br>Dein direkter Draht.'),
$zeile('beratung-ort', [
$symbol('beratung-ort-icon', 'pin', 20, ['desktop' => ['color' => '@orange-text', 'flexShrink' => 0]]),
$text('beratung-ort-text', $firma.'<br>'.$strasse.'<br>'.$plz.' '.$ort.'-Oos', ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.7, 'color' => '@text', 'margin' => ['bottom' => '0px']]]),
], '10px', ['alignItems' => 'flex-start', 'margin' => ['top' => '8px']]),
$zeile('beratung-mail', [
$symbol('beratung-mail-icon', 'mail', 20, ['desktop' => ['color' => '@orange-text', 'flexShrink' => 0]]),
$text('beratung-mail-text', '<a href="mailto:'.$mail.'">'.$mail.'</a>', ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.7, 'color' => '@text', 'margin' => ['bottom' => '0px']]], $linkCss),
], '10px', ['margin' => ['top' => '14px']]),
$zeile('beratung-telefon', [
$symbol('beratung-telefon-icon', 'phone', 20, ['desktop' => ['color' => '@orange-text', 'flexShrink' => 0]]),
$text('beratung-telefon-text', '<a href="'.$tel.'">'.$telefon.'</a>', ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.7, 'color' => '@text', 'margin' => ['bottom' => '0px']]], $linkCss),
], '10px', ['margin' => ['top' => '14px']]),
]),
$column('beratung-rechts', [
$heading('beratung-titel', 'Gute Reisen beginnen<br>mit einem <em>Gespräch.</em>', 2, [...$h2Stil, 'desktop' => [...$h2Stil['desktop'], 'fontSize' => '56px']], $serif),
$zeile('beratung-unten', [
$text('beratung-satz', 'Erzähl uns, was dir wichtig ist. Wir hören zu, denken mit und planen mit dir. Persönlich, unkompliziert und mit besonderer Nähe zur Türkei – ob Strandurlaub, Familienbesuch oder Städtereise nach Istanbul.', [
'desktop' => ['flexGrow' => 1, 'flexBasis' => '300px', 'maxWidth' => '430px', 'fontSize' => '15px', 'lineHeight' => 1.8, 'color' => '@text', 'margin' => ['bottom' => '0px']],
'mobile' => ['flexBasis' => 'auto', 'maxWidth' => 'none'],
]),
$zeile('beratung-knoepfe', [
$knopf('beratung-knopf', 'Lass uns sprechen', '#kontakt', 'rahmen'),
$waKnopf('beratung-whatsapp'),
], '12px', ['flexWrap' => 'wrap', 'flexShrink' => 0]),
], '40px', ['justifyContent' => 'space-between', 'flexWrap' => 'wrap', 'rowGap' => '22px', 'margin' => ['top' => '30px']]),
]),
], $zweiSpalten, [
'desktop' => ['gap' => '30px', 'margin' => ['top' => '0px'], 'columnWidths' => '1 2'],
'tablet' => ['gap' => '24px'],
]),
// Neu gegenüber der Vorlage: der Weg zur Reise in drei Schritten.
$column('schritte', [
$schritt(1, 'Wunsch erzählen', 'Per Formular, E-Mail oder direkt bei uns in Oos. Ein grober Plan genügt.'),
$schritt(2, 'Vorschläge bekommen', 'Wir vergleichen Hotels, Flüge und Termine und melden uns mit passenden Ideen.'),
$schritt(3, 'Entspannt losfliegen', 'Du entscheidest, wir buchen. Unterlagen und Ansprechpartner kommen von uns.'),
], [
'desktop' => ['display' => 'grid', 'gridColumns' => 3, 'gap' => '40px', 'margin' => ['top' => '72px']],
'mobile' => ['gridColumns' => 1, 'gap' => '28px', 'margin' => ['top' => '48px']],
]),
], [
'desktop' => ['padding' => ['top' => '0px', 'bottom' => '96px']],
'mobile' => ['padding' => ['top' => '0px', 'bottom' => '56px']],
], 'beratung', '& h2 br{display:inline}'),
// 4 – Anfrage: Petrol mit oranger Oberkante, weiße Formularkarte rechts.
$section('kontakt-bereich', [
$row('kontakt-zeile', [
$column('kontakt-text', [
$kleinzeile('kontakt-klein', 'Der erste Schritt zur Auszeit', '@auf-dunkel'),
$heading('kontakt-titel', 'Wohin darf<br>es <em>gehen?</em>', 2, [
'desktop' => ['fontSize' => '66px', 'fontWeight' => '500', 'lineHeight' => 1.05, 'letterSpacing' => '-0.045em', 'color' => '@papier', 'margin' => ['top' => '0px', 'bottom' => '25px']],
'mobile' => ['fontSize' => '54px'],
], $serifHell),
$text('kontakt-satz', 'Ein Wunsch. Ein grober Plan. Oder schon ein festes Ziel. Erzähl uns davon – wir melden uns persönlich bei dir.', $fliess('#c2d3d3', '32px', '320px')),
$text('kontakt-whatsapp-satz', 'Lieber kurz schreiben? Auf WhatsApp antworten wir dir persönlich.', ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.6, 'color' => '#c2d3d3', 'margin' => ['bottom' => '14px']]]),
$waKnopf('kontakt-whatsapp', ['desktop' => ['margin' => ['bottom' => '36px']]]),
$text('kontakt-hinweis', 'Türkei-Reisen · Hotels weltweit · Türkei-Flüge<br><a href="'.$tel.'">'.$telefon.'</a> · <a href="mailto:'.$mail.'">'.$mail.'</a>', ['desktop' => ['fontSize' => '13px', 'lineHeight' => 1.9, 'color' => '#a5bebe', 'margin' => ['bottom' => '0px']]],
"& a{color:#fff;text-decoration:underline;text-decoration-color:#ff753d;text-underline-offset:4px}\n& a:hover{color:#ffab82}"),
]),
$column('kontakt-karte', [
$kleinzeile('kontakt-karte-klein', 'Persönliche Reiseberatung', '@orange-text', '10px'),
$heading('kontakt-karte-titel', 'Deine Reiseanfrage', 3, ['desktop' => ['fontSize' => '30px', 'fontWeight' => '500', 'letterSpacing' => '-0.035em', 'color' => '@tinte', 'margin' => ['top' => '0px', 'bottom' => '10px']], 'mobile' => ['fontSize' => '28px']]),
$text('kontakt-karte-satz', 'Trag deine Wünsche ein – hier beginnt deine nächste Reise.', ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.6, 'color' => '@text', 'margin' => ['bottom' => '26px']]]),
['id' => 'kontakt-formular', 'type' => 'form', 'props' => ['form' => 'reiseanfrage'], 'css' => $formularCss],
$text('kontakt-karte-hinweis', 'Deine Angaben nutzen wir nur für deine Anfrage. Mehr in den <a href="/datenschutz">Datenschutzhinweisen</a>.', ['desktop' => ['fontSize' => '12px', 'lineHeight' => 1.6, 'color' => '@text', 'margin' => ['top' => '16px', 'bottom' => '0px']]], $linkCss),
], [
'desktop' => ['backgroundColor' => '@papier', 'color' => '@tinte', 'borderRadius' => $rund('8px'), 'padding' => $seiten('36px'), 'shadowY' => '20px', 'shadowBlur' => '50px', 'shadowColor' => '#001c2030'],
'mobile' => ['padding' => ['top' => '25px', 'right' => '20px', 'bottom' => '25px', 'left' => '20px']],
]),
], $zweiSpalten, [
'desktop' => ['gap' => '7%', 'alignItems' => 'center', 'margin' => ['top' => '0px'], 'columnWidths' => '9 11'],
'tablet' => ['gap' => '40px'],
]),
], [
'desktop' => ['backgroundColor' => '@tinte', 'color' => '@papier', 'padding' => ['top' => '72px', 'bottom' => '72px'], 'borderWidth' => $nurOben('7px'), 'borderStyle' => 'solid', 'borderColor' => '@orange'],
'mobile' => ['padding' => ['top' => '44px', 'bottom' => '40px']],
], 'kontakt'),
// 5 – reiseSoft: Text und Funktionen links, echter Screenshot rechts.
$section('reisesoft-bereich', [
$row('reisesoft-zeile', [
$column('reisesoft-text', [
$zeile('reisesoft-marke', [
$text('reisesoft-name', 'reise<strong>Soft</strong>', ['desktop' => ['fontSize' => '25px', 'letterSpacing' => '-0.04em', 'color' => '@tinte', 'margin' => ['bottom' => '0px']]], '& strong{font-weight:700}'),
$text('reisesoft-etikett', 'Für Reisebüros', ['desktop' => ['fontSize' => '10px', 'fontWeight' => '600', 'letterSpacing' => '0.12em', 'textTransform' => 'uppercase', 'color' => '@tinte', 'backgroundColor' => '@papier', 'padding' => ['top' => '6px', 'right' => '8px', 'bottom' => '6px', 'left' => '8px'], 'margin' => ['bottom' => '0px']]]),
], '14px', ['margin' => ['bottom' => '26px']]),
$kleinzeile('reisesoft-klein', 'Für die andere Seite des Reisens'),
$heading('reisesoft-titel', 'Weniger Verwaltung.<br>Mehr <em>Reisebüro.</em>', 2, [...$h2Stil, 'desktop' => [...$h2Stil['desktop'], 'fontSize' => '42px', 'margin' => ['top' => '0px', 'bottom' => '24px']]], $serif),
$text('reisesoft-satz', 'reiseSoft ist unsere Webplattform für Reisebüros – entstanden aus dem eigenen Büroalltag. Buchungen, Kunden, Rechnungen und Zahlungen an einem Ort, damit mehr Zeit für die Beratung bleibt.', $fliess('@text', '24px', '440px', '15px')),
$column('reisesoft-funktionen', [
$funktion(1, 'Buchung per Strg+V übernehmen', 'Aus Amadeus oder dem Airline-Portal kopieren, einfügen, prüfen – fertig.'),
$funktion(2, 'Rechnung und E-Rechnung', 'Fortlaufende Rechnungsnummern, ZUGFeRD und XRechnung, Versand per E-Mail.'),
$funktion(3, 'Offene Posten im Blick', 'Zahlungen buchen, Umsatz und Ertrag auswerten – direkt im Browser.'),
], ['desktop' => ['margin' => ['bottom' => '30px']]], '& [id="funktion-3"]{border-bottom:1px solid #d5dfde}'),
$zeile('reisesoft-knoepfe', [
$knopf('reisesoft-plattform', 'Zur reiseSoft-Plattform', $reisesoft, 'petrol', [], 'external'),
$knopf('reisesoft-anfragen', 'reiseSoft kennenlernen', '#kontakt', 'rahmen'),
], '12px', ['flexWrap' => 'wrap']),
]),
$column('reisesoft-bild-spalte', [
$column('reisesoft-fenster', [
$zeile('reisesoft-fenster-leiste', [
$text('reisesoft-fenster-adresse', 'reisesoft.kantarci-reisen.de', ['desktop' => ['flexGrow' => 1, 'fontSize' => '11px', 'color' => '@text', 'textAlign' => 'center', 'backgroundColor' => '@pale', 'borderRadius' => $rund('4px'), 'padding' => ['top' => '5px', 'right' => '10px', 'bottom' => '5px', 'left' => '10px'], 'margin' => ['bottom' => '0px']]]),
], '16px', ['padding' => ['top' => '10px', 'right' => '14px', 'bottom' => '10px', 'left' => '14px'], 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '#e8eded'], [], [],
// Die drei Punkte eines Browserfensters, als Schatten eines einzigen Punkts.
"&::before{content:'';flex-shrink:0;width:9px;height:9px;margin-right:30px;border-radius:50%;background:#ff753d;box-shadow:15px 0 #d5dfde,30px 0 #d5dfde}"),
$bild('reisesoft-bild', $medien['reisesoft'], 'reiseSoft-Übersicht mit offenen Posten, Umsatz und den letzten Vorgängen (Beispieldaten) – zur Plattform', ['desktop' => ['margin' => $seiten('0px')]], $reisesoft,
"& img{display:block;width:100%;height:auto;border-radius:0}\n& .image-link:focus-visible{outline:3px solid #ff753d;outline-offset:-3px}"),
], [
'desktop' => ['overflow' => 'hidden', 'backgroundColor' => '@papier', 'borderWidth' => $seiten('1px'), 'borderStyle' => 'solid', 'borderColor' => '#d5dfde', 'borderRadius' => $rund('8px'), 'shadowY' => '24px', 'shadowBlur' => '50px', 'shadowColor' => '#0a363b26'],
], "transform:rotate(-2deg);transition:transform .5s cubic-bezier(.2,.7,.2,1)\n&:hover{transform:rotate(0deg) scale(1.01)}"),
$text('reisesoft-bildzeile', 'Echte Oberfläche, fiktive Beispieldaten.', ['desktop' => ['fontSize' => '12px', 'color' => '@text', 'textAlign' => 'right', 'margin' => ['top' => '22px', 'bottom' => '0px']]]),
]),
], $zweiSpalten, [
'desktop' => ['gap' => '72px', 'alignItems' => 'center', 'margin' => ['top' => '0px'], 'columnWidths' => '9 11'],
'tablet' => ['gap' => '48px'],
]),
], [
'desktop' => ['backgroundColor' => '@pale', 'padding' => ['top' => '88px', 'bottom' => '96px']],
'mobile' => ['padding' => ['top' => '48px', 'bottom' => '52px']],
], 'reisesoft'),
],
];
// ---------------------------------------------------------------------------------------------
// Seiten anlegen und veröffentlichen
// ---------------------------------------------------------------------------------------------
$anlegen = static function (string $slug, array $dokument, string $seoTitel = '', string $teilbild = '') use ($pages, $db): string {
$id = $pages->createPage($slug, null, 'page', $dokument);
if ('' !== $seoTitel) {
$db->executeStatement('UPDATE page SET seo_title = ? WHERE id = ?', [$seoTitel, $id]);
}
if ('' !== $teilbild) {
$db->executeStatement('UPDATE page SET og_media_id = ? WHERE id = ?', [$teilbild, $id]);
}
$seite = $db->fetchAssociative('SELECT draft_revision_id, version FROM page WHERE id = ?', [$id]);
$pages->publish($id, $seite['draft_revision_id'], (int) $seite['version']);
echo 'Seite angelegt: /'.$db->fetchOne('SELECT path FROM page WHERE id = ?', [$id])."\n";
return $id;
};
// Der Hero-Text steigt beim Laden auf, das Foto nicht (LCP). Weiter unten erscheinen Köpfe und
// Raster beim Scrollen.
$startseite = $einblenden($startseite, [
'hero-titel' => ['rise', 'short'],
'hero-satz' => ['rise', 'medium'],
'hero-knopf' => ['rise', 'medium'],
'hero-bildzeile' => ['left', 'long'],
'reisen-kopf' => ['rise'],
'angebot-1' => ['rise'],
'angebot-2' => ['rise', 'short'],
'angebot-3' => ['rise', 'medium'],
'beratung-zeile' => ['rise'],
'schritt-1' => ['rise'],
'schritt-2' => ['rise', 'short'],
'schritt-3' => ['rise', 'medium'],
'kontakt-text' => ['rise'],
'kontakt-karte' => ['rise', 'short'],
'reisesoft-text' => ['rise'],
'reisesoft-bild-spalte' => ['right', 'short'],
]);
$homeId = $anlegen('home', $startseite, 'Kantarci Reisen – Türkei-Urlaub, Hotels & Flüge | Reisebüro Baden-Baden', $medien['kueste']['id']);
/** Eine Rechtsseite: Kleinzeile, Titel und Vorspann auf Petrol, dann Absätze mit Zwischenüberschriften. */
$rechtsseite = static function (string $praefix, string $label, string $titel, string $vorspann, array $absaetze) use ($section, $row, $column, $kleinzeile, $heading, $text, $nurOben, $linkCss): array {
$kopf = $section($praefix.'-kopf', [
$kleinzeile($praefix.'-label', $label, '@auf-dunkel'),
$heading($praefix.'-h1', $titel, 1, [
'desktop' => ['fontSize' => '66px', 'fontWeight' => '500', 'lineHeight' => 1.05, 'letterSpacing' => '-0.045em', 'color' => '@papier', 'margin' => ['top' => '0px', 'bottom' => '20px']],
'mobile' => ['fontSize' => '46px'],
]),
$text($praefix.'-vorspann', $vorspann, [
'desktop' => ['maxWidth' => '640px', 'fontSize' => '17px', 'lineHeight' => 1.75, 'color' => '@auf-dunkel', 'margin' => ['bottom' => '0px']],
'mobile' => ['fontSize' => '16px'],
]),
], [
'desktop' => ['backgroundColor' => '@tinte', 'color' => '@papier', 'padding' => ['top' => '80px', 'bottom' => '68px'], 'borderWidth' => ['top' => '0px', 'right' => '0px', 'bottom' => '7px', 'left' => '0px'], 'borderStyle' => 'solid', 'borderColor' => '@orange'],
'mobile' => ['padding' => ['top' => '52px', 'bottom' => '44px']],
]);
$punkte = [];
foreach ($absaetze as $i => [$zwischentitel, $inhalt]) {
$punkte[] = $row($praefix.'-punkt-'.$i, [
$column($praefix.'-punkt-'.$i.'-titel', '' === $zwischentitel ? [] : [
$heading($praefix.'-h2-'.$i, $zwischentitel, 2, [
'desktop' => ['fontSize' => '19px', 'fontWeight' => '600', 'lineHeight' => 1.3, 'letterSpacing' => '-0.01em', 'color' => '@tinte', 'margin' => ['bottom' => '0px']],
]),
], [
'desktop' => ['flexBasis' => '260px', 'flexShrink' => 0],
'tablet' => ['flexBasis' => '200px'],
'mobile' => ['flexBasis' => 'auto'],
]),
$column($praefix.'-punkt-'.$i.'-text', [
$text($praefix.'-p-'.$i, $inhalt, ['desktop' => ['fontSize' => '16px', 'lineHeight' => 1.75, 'color' => '@text', 'margin' => ['bottom' => '0px']]], $linkCss),
], ['desktop' => ['flexGrow' => 1, 'flexBasis' => '0', 'minWidth' => '0'], 'mobile' => ['flexBasis' => 'auto']]),
], [], [
'desktop' => ['display' => 'flex', 'alignItems' => 'flex-start', 'gap' => '44px', 'margin' => ['top' => '0px'],
'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '26px', 'bottom' => '26px']],
'tablet' => ['gap' => '28px'],
'mobile' => ['flexDirection' => 'column', 'gap' => '8px', 'padding' => ['top' => '20px', 'bottom' => '20px']],
]);
}
$inhalt = $section($praefix, [
$column($praefix.'-inhalt', $punkte, ['desktop' => ['maxWidth' => '920px']]),
], ['desktop' => ['padding' => ['top' => '48px', 'bottom' => '96px']], 'mobile' => ['padding' => ['top' => '24px', 'bottom' => '64px']]]);
return [$kopf, $inhalt];
};
$anlegen('impressum', [
'schemaVersion' => $SCHEMA, 'title' => 'Impressum',
'description' => 'Anbieterangaben von Kantarci Reisen, Inhaber I. Kantarci, Baden-Baden.',
'blocks' => [
...$rechtsseite('impressum', 'Rechtliche Angaben', 'Impressum', 'Angaben gemäß § 5 DDG und § 18 Abs. 2 MStV.', [
['Anbieter', $firma.'<br>Inhaber: '.$inhaber.'<br>'.$strasse.'<br>'.$plz.' '.$ort],
['Kontakt', 'Telefon: <a href="'.$tel.'">'.$telefon.'</a><br>E-Mail: <a href="mailto:'.$mail.'">'.$mail.'</a><br>WhatsApp: <a href="'.$whatsapp.'">'.$telefon.'</a>'],
['Verantwortlich für den Inhalt', $inhaber.', Anschrift wie oben.'],
['Reisevermittlung', 'Kantarci Reisen vermittelt Reiseleistungen wie Pauschalreisen, Hotelaufenthalte und Flüge. Vertragspartner für die jeweilige Leistung ist der Reiseveranstalter, das Hotel oder die Fluggesellschaft; es gelten deren Geschäfts- und Beförderungsbedingungen, die wir vor der Buchung mitgeben.'],
['Verbraucherstreitbeilegung', 'Wir sind nicht bereit und nicht verpflichtet, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.'],
['Haftung für Inhalte', 'Als Diensteanbieter sind wir für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Reiseinformationen geben wir nach bestem Wissen weiter; verbindlich sind die Angaben im jeweiligen Angebot. Bei Bekanntwerden von Rechtsverletzungen entfernen wir betroffene Inhalte umgehend.'],
['Haftung für Links', 'Diese Website verweist auf die Plattform reiseSoft unter <a href="'.$reisesoft.'">reisesoft.kantarci-reisen.de</a>. Für Inhalte fremder Seiten ist der jeweilige Anbieter verantwortlich.'],
['Urheberrecht', 'Die von uns erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechts bedürfen der schriftlichen Zustimmung.'],
['Bildnachweis', 'Logo: '.$firma.'. Reisefoto der Startseite: KI-generiert. Screenshot: reiseSoft mit fiktiven Beispieldaten.'],
]),
],
], 'Impressum | '.$firma);
$anlegen('datenschutz', [
'schemaVersion' => $SCHEMA, 'title' => 'Datenschutz',
'description' => 'Welche Daten beim Besuch der Website von Kantarci Reisen und bei einer Reiseanfrage verarbeitet werden.',
'blocks' => [
...$rechtsseite('datenschutz', 'Deine Daten', 'Datenschutz', 'Hier erfährst du, welche Daten beim Besuch dieser Website und bei einer Anfrage verarbeitet werden – und welche Rechte du hast.', [
['1. Verantwortlicher', $firma.', Inhaber '.$inhaber.', '.$strasse.', '.$plz.' '.$ort.'. Telefon <a href="'.$tel.'">'.$telefon.'</a>, E-Mail <a href="mailto:'.$mail.'">'.$mail.'</a>.'],
['2. Besuch dieser Website', 'Beim Aufruf verarbeitet unser Hostingdienstleister technisch erforderliche Verbindungsdaten: IP-Adresse, Zeitpunkt, angeforderte Adresse, Browserangaben und HTTP-Status. Das dient der sicheren Bereitstellung und der Fehlersuche. Rechtsgrundlage ist unser berechtigtes Interesse an einem störungsfreien Betrieb, Art. 6 Abs. 1 lit. f DSGVO.<br><br>Mit dem Hostingdienstleister besteht ein Vertrag über die Auftragsverarbeitung nach Art. 28 DSGVO. Die Serverprotokolle werden nach spätestens sieben Tagen gelöscht oder anonymisiert, sofern sie nicht zur Aufklärung eines Sicherheitsvorfalls länger gebraucht werden.'],
['3. Cookies und externe Dienste', 'Diese Website setzt keine Analyse- oder Werbecookies. Bilder, Schriften und Gestaltung kommen vom eigenen Server; es werden keine Karten, Videos oder Schriften von Dritten nachgeladen. Deshalb gibt es auch keinen Einwilligungsdialog.'],
['4. Reiseanfrage', 'Wenn du uns über das Formular schreibst, verarbeiten wir deinen Namen, deine E-Mail-Adresse, auf Wunsch deine Telefonnummer, Reisezeitraum und Personenzahl sowie deine Wünsche, um deine Anfrage zu beantworten und passende Angebote vorzubereiten (Art. 6 Abs. 1 lit. b DSGVO). Die Anfrage wird in unserem System gespeichert und per E-Mail an uns weitergeleitet. Wir löschen sie, wenn sie erledigt ist und keine gesetzliche Aufbewahrungspflicht besteht.'],
['5. Buchungen', 'Kommt es zu einer Buchung, geben wir die dafür nötigen Angaben (zum Beispiel Namen der Reisenden laut Ausweis und Reisedaten) an den Reiseveranstalter, das Hotel oder die Fluggesellschaft weiter (Art. 6 Abs. 1 lit. b DSGVO). Rechnungsunterlagen bewahren wir so lange auf, wie Handels- und Steuerrecht es verlangen.'],
['6. Kontakt per E-Mail oder Telefon', 'Wenn du uns schreibst oder anrufst, verarbeiten wir deine Angaben, um dein Anliegen zu bearbeiten (Art. 6 Abs. 1 lit. b oder f DSGVO).'],
['7. WhatsApp', 'Die WhatsApp-Knöpfe sind einfache Links. Erst wenn du einen davon nutzt, öffnet sich WhatsApp, ein Dienst der WhatsApp Ireland Limited (Meta). Dabei verarbeitet WhatsApp deine Telefonnummer, Nachrichten und Nutzungsdaten nach eigenen Datenschutzhinweisen, auch in Ländern außerhalb der EU. Wir nutzen deine Nachricht nur, um dein Anliegen zu beantworten (Art. 6 Abs. 1 lit. b DSGVO). Wenn du das nicht möchtest, erreichst du uns genauso per Formular, E-Mail oder Telefon.'],
['8. reiseSoft', 'Der Link zur Plattform reiseSoft führt auf eine eigene Anwendung unter reisesoft.kantarci-reisen.de. Erst wenn du ihm folgst, werden dort Daten verarbeitet; für angemeldete Reisebüros gelten die Hinweise der Plattform.'],
['9. Deine Rechte', 'Du hast unter den gesetzlichen Voraussetzungen das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit. Einer Verarbeitung auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO kannst du widersprechen.'],
['10. Beschwerderecht', 'Du kannst dich bei einer Datenschutz-Aufsichtsbehörde beschweren, für uns beim Landesbeauftragten für den Datenschutz und die Informationsfreiheit Baden-Württemberg.'],
['', 'Fragen zum Datenschutz beantworten wir gern unter <a href="mailto:'.$mail.'">'.$mail.'</a>.'],
]),
],
], 'Datenschutz | '.$firma);
$fehlerId = $anlegen('404', [
'schemaVersion' => $SCHEMA, 'title' => 'Seite nicht gefunden',
'description' => 'Die gesuchte Seite gibt es nicht oder sie wurde verschoben.',
'blocks' => [
$section('fehler', [
$kleinzeile('fehler-label', 'Fehler 404', '@orange-text'),
$heading('fehler-titel', 'Hier geht’s<br>leider <em>nicht weiter.</em>', 1, [
'desktop' => ['fontSize' => '72px', 'fontWeight' => '500', 'lineHeight' => 1.05, 'letterSpacing' => '-0.05em', 'color' => '@tinte', 'margin' => ['top' => '0px', 'bottom' => '22px']],
'mobile' => ['fontSize' => '46px'],
], $serif),
$text('fehler-text', 'Diese Seite gibt es nicht oder sie wurde verschoben. Von der Startseite aus findest du sicher dein nächstes Ziel.', $fliess('@text', '32px', '480px', '17px')),
$knopf('fehler-knopf', 'Zur Startseite', '/'),
], [
'desktop' => ['padding' => ['top' => '112px', 'bottom' => '132px']],
'mobile' => ['padding' => ['top' => '64px', 'bottom' => '80px']],
]),
],
]);
$db->executeStatement('UPDATE page SET robots = ? WHERE id = ?', ['noindex', $fehlerId]);
// ---------------------------------------------------------------------------------------------
// Menü, Einstellungen, Konto
// ---------------------------------------------------------------------------------------------
$sprung = static fn (string $label, string $anchor, array $extra = []): array => ['label' => $label, 'type' => 'page', 'pageId' => $homeId, 'anchor' => $anchor, 'children' => [], ...$extra];
$menus->save('header', 'Hauptnavigation', [
$sprung('Reisen', 'reisen'),
$sprung('Über uns', 'beratung'),
$sprung('reiseSoft', 'reisesoft'),
$sprung('Kontakt', 'kontakt'),
// „Reise anfragen“ als Menü-Button (ADR 110): auf dem Telefon neben dem Menü-Knopf.
$sprung('Reise anfragen', 'kontakt', [
'icon' => 'arrow-right', 'iconPosition' => 'after',
'button' => [
'id' => 'm'.substr(hash('sha256', 'kantarci-reisen-anfrage'), 0, 10), 'shape' => 'rounded', 'appearance' => 'solid', 'size' => 'normal',
'fill' => '@orange', 'ink' => '@knopf-schrift', 'hoverFill' => '@tinte', 'hoverInk' => '@papier',
'mobile' => 'bar', 'iconMotion' => 'slide',
],
]),
], 0);
echo "Menü angelegt\n";
$settings->assignNotFoundPage($fehlerId, '');
$settings->save([
'legalName' => $firma,
'description' => 'Türkei im Herzen. Die Welt im Blick. Persönliche Reiseberatung in Baden-Baden: Türkei-Urlaub, Hotels weltweit und Flüge in die Türkei.',
'email' => $mail, 'phone' => '+49 170 2945935',
'social' => ['whatsapp' => $whatsapp],
'street' => $strasse, 'postalCode' => $plz, 'city' => $ort, 'country' => 'DE',
'areaServed' => implode("\n", ['Baden-Baden', 'Rastatt', 'Bühl', 'Gaggenau', 'Karlsruhe']),
'localBusiness' => true, 'llmsTxt' => true,
'faviconMediaId' => $medien['favicon']['id'],
'stickyHeader' => true, 'headerOverlay' => false,
'motion' => 'always',
'homePath' => 'home', 'imprintPath' => 'impressum', 'privacyPath' => 'datenschutz',
// Die Seite lädt nichts von Dritten; deshalb keine Einwilligungsleiste.
'consentBanner' => false, 'scrollTop' => true, 'postMetaHeader' => false,
]);
echo "Einstellungen gespeichert\n";
$passwort = bin2hex(random_bytes(12));
$db->insert('cms_user', ['id' => bin2hex(random_bytes(16)), 'username' => 'umut', 'password_hash' => password_hash($passwort, PASSWORD_BCRYPT), 'roles' => '["ROLE_ADMIN"]']);
echo "\nFertig.\nVerwaltung: Benutzer „umut“, Passwort: $passwort\n";
+381
View File
@@ -0,0 +1,381 @@
<?php
/*
* Kantarci Reisen auf Türkisch (ADR 111).
*
* Schaltet Türkisch als weitere Sprache ein und trägt die Übersetzung jeder Seite, des Kopf- und
* Fußbereichs, des Menüs und des Anfrageformulars ein; in den Kopfbereich kommt die Sprachwahl
* DE · TR. Die Seiten werden mit ihrer Übersetzung veröffentlicht. Läuft beliebig oft: jeder Lauf
* setzt die Übersetzungen auf den Stand dieser Datei.
*
* Aufruf: php scripts/translate-kantarci-reisen.php [Datenbankdatei]
* Ohne Argument: var/kantarci-reisen.db. reset-kantarci-reisen.sh ruft es nach dem Aufbau auf.
*
* Anrede „siz“: im Türkischen für ein Reisebüro üblicher als das „du“ der deutschen Fassung.
* Impressum und Datenschutz sagen, dass die deutsche Fassung maßgeblich ist.
*/
require dirname(__DIR__).'/vendor/autoload.php';
use App\Content\DocumentValidator;
use App\Content\FormRepository;
use App\Content\Languages;
use App\Content\MediaLibrary;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\PartialRepository;
use App\Content\SiteSettings;
use App\Content\Translations;
use App\Infrastructure\ConnectionFactory;
$root = dirname(__DIR__);
$path = $argv[1] ?? $root.'/var/kantarci-reisen.db';
if (!is_file($path)) {
fwrite(STDERR, "Keine Datenbank unter {$path}.\n");
exit(1);
}
$db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $path));
$settings = new SiteSettings($db, new MediaLibrary($db, $root.'/var/kantarci-reisen-media'));
$validator = new DocumentValidator();
$pages = new PageRepository($db, $validator, $settings);
$partials = new PartialRepository($db, $validator);
$menus = new MenuRepository($db, $settings);
$forms = new FormRepository($db);
$languages = new Languages($db, $settings);
const SPRACHE = 'tr';
$hinweisRecht = '<em>Bu çeviri bilgilendirme amaçlıdır; hukuken bağlayıcı olan Almanca metindir.</em>';
// ---------------------------------------------------------------------------------------------
// Seiten: Adresse, Titel, Beschreibung, Suchtitel und die Texte je Block
// ---------------------------------------------------------------------------------------------
$seiten = [
'home' => [
'title' => 'Başka bir yer için zaman.',
'description' => 'Kalbimizde Türkiye. Gözümüz dünyada. Baden-Baden’de kişisel seyahat danışmanlığı: Türkiye tatili, dünya genelinde oteller ve Türkiye uçuşları.',
'seoTitle' => 'Kantarci Reisen – Türkiye tatili, oteller ve uçuşlar | Baden-Baden seyahat acentesi',
'blocks' => [
'hero-klein.text' => 'Kalbimizde Türkiye. Gözümüz dünyada.',
'hero-titel.text' => 'Başka bir yer<br>için <em>zaman.</em>',
'hero-satz.text' => 'Tende tuz. Yüzde güneş.<br>Ve size uygun bir seyahat.',
'hero-knopf.text' => 'Tatilinizi planlayın',
'hero-unten-links.text' => 'Kişisel danışmanlık.<br>Rahat seyahat.',
'hero-unten-rechts.text' => 'Baden-Baden · Oos',
'hero-foto.alt' => 'Güneşli bir plaj terasıyla turkuaz Akdeniz koyu',
'hero-etikett.text' => 'Seyahat ilhamı',
'hero-bildzeile.text' => '<strong>Yeni favori yeriniz</strong>Biraz daha fazla deniz.',
'reisen-klein.text' => 'Sizi nereye çekiyor?',
'reisen-titel.text' => 'Sizde gezme isteği var.<br>Bizde fikirler.',
'reisen-satz.text' => 'Denizde bir mola, yeni bir favori yer ya da aile ziyareti. Size uygun seyahati birlikte buluyoruz.',
'angebot-1-klein.text' => 'Uzmanlık alanımız',
'angebot-1-titel.text' => 'Türkiye.<br>Tam size göre.',
'angebot-1-satz.text' => 'Türk Rivierası’ndan Ege’ye. Misafirperverlik, sahil ve kültür arasında bir tatil keşfedin – ilk elden ipuçlarıyla.',
'angebot-1-link.text' => '<a href="#kontakt">Türkiye tatili sorun</a>',
'angebot-2-klein.text' => 'Dünya genelinde oteller',
'angebot-2-titel.text' => 'Giriş yapın.<br>Kafanızı dinleyin.',
'angebot-2-satz.text' => 'Küçük bir butik otel mi, geniş bir resort mu? Tatiliniz için doğru yeri bulmanıza yardımcı oluyoruz.',
'angebot-2-link.text' => '<a href="#kontakt">Otel sorun</a>',
'angebot-3-klein.text' => 'Türkiye uçuşları',
'angebot-3-titel.text' => 'Bazen hedef<br>evdir.',
'angebot-3-satz.text' => 'Tatil, aile ya da sadece özlem için. Size uygun uçuşu buluyoruz – Karlsruhe/Baden-Baden, Stuttgart veya Frankfurt’tan da.',
'angebot-3-link.text' => '<a href="#kontakt">Türkiye uçuşu sorun</a>',
'beratung-klein.text' => 'Seyahat acenteniz.<br>Doğrudan iletişiminiz.',
'beratung-ort-text.text' => 'Kantarci Reisen<br>Ooser Hauptstraße 51<br>76532 Baden-Baden-Oos',
'beratung-titel.text' => 'İyi seyahatler<br>bir <em>sohbetle</em> başlar.',
'beratung-satz.text' => 'Sizin için neyin önemli olduğunu anlatın. Dinliyor, birlikte düşünüyor ve sizinle planlıyoruz. Kişisel, kolay ve Türkiye’ye özel bir yakınlıkla – ister plaj tatili, ister aile ziyareti, ister İstanbul’a şehir turu.',
'beratung-knopf.text' => 'Hadi konuşalım',
'schritt-1-titel.text' => 'İsteğinizi anlatın',
'schritt-1-satz.text' => 'Form, e-posta ya da doğrudan Oos’taki ofisimizde. Kabaca bir plan yeterli.',
'schritt-2-titel.text' => 'Önerileri alın',
'schritt-2-satz.text' => 'Otelleri, uçuşları ve tarihleri karşılaştırıyor, size uygun fikirlerle dönüyoruz.',
'schritt-3-titel.text' => 'Rahatça yola çıkın',
'schritt-3-satz.text' => 'Siz karar verirsiniz, biz rezervasyon yaparız. Belgeler ve irtibat kişisi bizden.',
'kontakt-klein.text' => 'Tatile ilk adım',
'kontakt-titel.text' => 'Nereye<br><em>gidelim?</em>',
'kontakt-satz.text' => 'Bir istek. Kabaca bir plan. Ya da belli bir hedef. Bize anlatın – size kişisel olarak dönüş yapacağız.',
'kontakt-whatsapp-satz.text' => 'Kısaca yazmayı mı tercih edersiniz? WhatsApp’ta size kişisel olarak cevap veriyoruz.',
'kontakt-hinweis.text' => 'Türkiye seyahatleri · Dünya genelinde oteller · Türkiye uçuşları<br><a href="tel:+491702945935">0170 2945935</a> · <a href="mailto:info@kantarci-reisen.de">info@kantarci-reisen.de</a>',
'kontakt-karte-klein.text' => 'Kişisel seyahat danışmanlığı',
'kontakt-karte-titel.text' => 'Seyahat talebiniz',
'kontakt-karte-satz.text' => 'İsteklerinizi yazın – bir sonraki seyahatiniz burada başlıyor.',
'kontakt-karte-hinweis.text' => 'Bilgilerinizi yalnızca talebiniz için kullanıyoruz. Ayrıntılar <a href="/datenschutz">gizlilik bildiriminde</a>.',
'reisesoft-etikett.text' => 'Seyahat acenteleri için',
'reisesoft-klein.text' => 'Seyahatin diğer tarafı için',
'reisesoft-titel.text' => 'Daha az idari iş.<br>Daha çok <em>seyahat acentesi.</em>',
'reisesoft-satz.text' => 'reiseSoft, seyahat acenteleri için web platformumuz – kendi ofis günlüğümüzden doğdu. Rezervasyonlar, müşteriler, faturalar ve ödemeler tek yerde; böylece danışmanlığa daha çok zaman kalır.',
'funktion-1-text.text' => '<strong>Rezervasyonu Ctrl+V ile aktarın</strong>Amadeus’tan veya havayolu portalından kopyalayın, yapıştırın, kontrol edin – hazır.',
'funktion-2-text.text' => '<strong>Fatura ve e-fatura</strong>Ardışık fatura numaraları, ZUGFeRD ve XRechnung, e-posta ile gönderim.',
'funktion-3-text.text' => '<strong>Açık kalemler göz önünde</strong>Ödemeleri kaydedin, ciro ve kârı değerlendirin – doğrudan tarayıcıda.',
'reisesoft-plattform.text' => 'reiseSoft platformuna',
'reisesoft-anfragen.text' => 'reiseSoft’u tanıyın',
'reisesoft-bild.alt' => 'Açık kalemler, ciro ve son işlemlerle reiseSoft genel görünümü (örnek veriler) – platforma',
'reisesoft-bildzeile.text' => 'Gerçek arayüz, hayali örnek veriler.',
// Sprachneutral, aber eingetragen: sonst zeigt der Editor sie als „fehlt“.
'schritt-1-nummer.text' => '01', 'schritt-2-nummer.text' => '02', 'schritt-3-nummer.text' => '03',
'funktion-1-nummer.text' => '01', 'funktion-2-nummer.text' => '02', 'funktion-3-nummer.text' => '03',
'beratung-mail-text.text' => '<a href="mailto:info@kantarci-reisen.de">info@kantarci-reisen.de</a>',
'beratung-telefon-text.text' => '<a href="tel:+491702945935">0170 2945935</a>',
'reisesoft-name.text' => 'reise<strong>Soft</strong>',
'reisesoft-fenster-adresse.text' => 'reisesoft.kantarci-reisen.de',
],
],
'impressum' => [
'slug' => 'kunye',
'title' => 'Künye',
'description' => 'Kantarci Reisen, sahibi I. Kantarci, Baden-Baden – sağlayıcı bilgileri.',
'seoTitle' => 'Künye | Kantarci Reisen',
'blocks' => [
'impressum-label.text' => 'Yasal bilgiler',
'impressum-h1.text' => 'Künye',
'impressum-vorspann.text' => '§ 5 DDG ve § 18 (2) MStV uyarınca bilgiler. '.$hinweisRecht,
'impressum-h2-0.text' => 'Sağlayıcı',
'impressum-p-0.text' => 'Kantarci Reisen<br>Sahibi: I. Kantarci<br>Ooser Hauptstraße 51<br>76532 Baden-Baden',
'impressum-h2-1.text' => 'İletişim',
'impressum-p-1.text' => 'Telefon: <a href="tel:+491702945935">0170 2945935</a><br>E-posta: <a href="mailto:info@kantarci-reisen.de">info@kantarci-reisen.de</a><br>WhatsApp: <a href="https://wa.me/491702945935?text=Merhaba%20Kantarci%20Reisen%2C%20bir%20seyahatle%20ilgileniyorum." rel="noopener">0170 2945935</a>',
'impressum-h2-2.text' => 'İçerikten sorumlu',
'impressum-p-2.text' => 'I. Kantarci, adres yukarıdaki gibi.',
'impressum-h2-3.text' => 'Seyahat aracılığı',
'impressum-p-3.text' => 'Kantarci Reisen paket turlar, otel konaklamaları ve uçuşlar gibi seyahat hizmetlerine aracılık eder. İlgili hizmetin sözleşme tarafı tur operatörü, otel veya havayolu şirketidir; rezervasyondan önce size ilettiğimiz onların genel ve taşıma koşulları geçerlidir.',
'impressum-h2-4.text' => 'Tüketici uyuşmazlıklarının çözümü',
'impressum-p-4.text' => 'Bir tüketici hakem kurulu önündeki uyuşmazlık çözüm sürecine katılmaya hazır ve yükümlü değiliz.',
'impressum-h2-5.text' => 'İçerikler için sorumluluk',
'impressum-p-5.text' => 'Hizmet sağlayıcı olarak bu sayfalardaki kendi içeriklerimizden genel yasalar çerçevesinde sorumluyuz. Seyahat bilgilerini bilgimiz dahilinde aktarıyoruz; bağlayıcı olan ilgili teklifteki bilgilerdir. Hukuka aykırılıklardan haberdar olduğumuzda ilgili içerikleri derhal kaldırırız.',
'impressum-h2-6.text' => 'Bağlantılar için sorumluluk',
'impressum-p-6.text' => 'Bu web sitesi <a href="https://reisesoft.kantarci-reisen.de" rel="noopener">reisesoft.kantarci-reisen.de</a> adresindeki reiseSoft platformuna yönlendirir. Yabancı sayfaların içeriklerinden ilgili sağlayıcı sorumludur.',
'impressum-h2-7.text' => 'Telif hakkı',
'impressum-p-7.text' => 'Bu sayfalarda tarafımızdan oluşturulan içerik ve eserler Alman telif hakkı yasasına tabidir. Telif hakkı sınırları dışında çoğaltma, işleme, yayma ve her türlü kullanım yazılı izne bağlıdır.',
'impressum-h2-8.text' => 'Görsel kaynakları',
'impressum-p-8.text' => 'Logo: Kantarci Reisen. Ana sayfadaki seyahat fotoğrafı: yapay zekâ ile üretilmiştir. Ekran görüntüsü: hayali örnek verilerle reiseSoft.',
],
],
'datenschutz' => [
'slug' => 'gizlilik',
'title' => 'Gizlilik',
'description' => 'Kantarci Reisen web sitesini ziyaret ederken ve seyahat talebinde hangi verilerin işlendiği.',
'seoTitle' => 'Gizlilik | Kantarci Reisen',
'blocks' => [
'datenschutz-label.text' => 'Verileriniz',
'datenschutz-h1.text' => 'Gizlilik',
'datenschutz-vorspann.text' => 'Bu web sitesini ziyaret ederken ve bir talepte bulunduğunuzda hangi verilerin işlendiğini ve hangi haklara sahip olduğunuzu burada öğrenebilirsiniz. '.$hinweisRecht,
'datenschutz-h2-0.text' => '1. Sorumlu',
'datenschutz-p-0.text' => 'Kantarci Reisen, sahibi I. Kantarci, Ooser Hauptstraße 51, 76532 Baden-Baden. Telefon <a href="tel:+491702945935">0170 2945935</a>, e-posta <a href="mailto:info@kantarci-reisen.de">info@kantarci-reisen.de</a>.',
'datenschutz-h2-1.text' => '2. Bu web sitesinin ziyareti',
'datenschutz-p-1.text' => 'Sayfa açıldığında barındırma hizmeti sağlayıcımız teknik olarak gerekli bağlantı verilerini işler: IP adresi, zaman, istenen adres, tarayıcı bilgileri ve HTTP durumu. Bu, güvenli sunum ve hata ayıklama içindir. Hukuki dayanak, kesintisiz işletimdeki meşru menfaatimizdir, GVKT (DSGVO) md. 6 f. 1 b. f.<br><br>Barındırma hizmeti sağlayıcısıyla GVKT md. 28 uyarınca bir veri işleme sözleşmesi bulunmaktadır. Sunucu kayıtları, bir güvenlik olayının aydınlatılması için daha uzun süre gerekmedikçe en geç yedi gün sonra silinir veya anonimleştirilir.',
'datenschutz-h2-2.text' => '3. Çerezler ve harici hizmetler',
'datenschutz-p-2.text' => 'Bu web sitesi analiz veya reklam çerezi kullanmaz. Resimler, yazı tipleri ve tasarım kendi sunucumuzdan gelir; üçüncü taraflardan harita, video veya yazı tipi yüklenmez. Bu nedenle bir onay penceresi de yoktur.',
'datenschutz-h2-3.text' => '4. Seyahat talebi',
'datenschutz-p-3.text' => 'Bize form aracılığıyla yazdığınızda, talebinizi yanıtlamak ve uygun teklifler hazırlamak için adınızı, e-posta adresinizi, isteğe bağlı olarak telefon numaranızı, seyahat dönemini, kişi sayısını ve isteklerinizi işleriz (GVKT md. 6 f. 1 b. b). Talep sistemimizde kaydedilir ve e-posta ile bize iletilir. Talep sonuçlandığında ve yasal saklama yükümlülüğü bulunmadığında silinir.',
'datenschutz-h2-4.text' => '5. Rezervasyonlar',
'datenschutz-p-4.text' => 'Bir rezervasyon yapılırsa, bunun için gerekli bilgileri (örneğin kimlikteki yolcu adları ve seyahat tarihleri) tur operatörüne, otele veya havayolu şirketine iletiriz (GVKT md. 6 f. 1 b. b). Fatura belgelerini ticaret ve vergi hukukunun gerektirdiği süre boyunca saklarız.',
'datenschutz-h2-5.text' => '6. E-posta veya telefonla iletişim',
'datenschutz-p-5.text' => 'Bize yazdığınızda veya bizi aradığınızda, talebinizi işlemek için bilgilerinizi işleriz (GVKT md. 6 f. 1 b. b veya f).',
'datenschutz-h2-6.text' => '7. WhatsApp',
'datenschutz-p-6.text' => 'WhatsApp düğmeleri basit bağlantılardır. Yalnızca birini kullandığınızda, WhatsApp Ireland Limited (Meta) tarafından sunulan WhatsApp açılır. WhatsApp bu sırada telefon numaranızı, mesajlarınızı ve kullanım verilerinizi kendi gizlilik bildirimine göre, AB dışındaki ülkelerde de işler. Mesajınızı yalnızca talebinizi yanıtlamak için kullanırız (GVKT md. 6 f. 1 b. b). Bunu istemiyorsanız bize form, e-posta veya telefonla da ulaşabilirsiniz.',
'datenschutz-h2-7.text' => '8. reiseSoft',
'datenschutz-p-7.text' => 'reiseSoft platformuna giden bağlantı, reisesoft.kantarci-reisen.de adresindeki ayrı bir uygulamaya yönlendirir. Veriler ancak bağlantıyı takip ettiğinizde orada işlenir; kayıtlı seyahat acenteleri için platformun bildirimleri geçerlidir.',
'datenschutz-h2-8.text' => '9. Haklarınız',
'datenschutz-p-8.text' => 'Yasal koşullar çerçevesinde bilgi alma, düzeltme, silme, işlemenin kısıtlanması ve veri taşınabilirliği haklarına sahipsiniz. GVKT md. 6 f. 1 b. f’ye dayanan bir işlemeye itiraz edebilirsiniz.',
'datenschutz-h2-9.text' => '10. Şikâyet hakkı',
'datenschutz-p-9.text' => 'Bir veri koruma denetim makamına şikâyette bulunabilirsiniz; bizim için yetkili makam Baden-Württemberg Veri Koruma ve Bilgi Edinme Özgürlüğü Görevlisi’dir (Landesbeauftragter für den Datenschutz und die Informationsfreiheit Baden-Württemberg).',
'datenschutz-p-10.text' => 'Gizlilikle ilgili sorularınızı <a href="mailto:info@kantarci-reisen.de">info@kantarci-reisen.de</a> adresinden memnuniyetle yanıtlarız.',
],
],
'404' => [
'title' => 'Sayfa bulunamadı',
'description' => 'Aradığınız sayfa mevcut değil veya taşınmış.',
'blocks' => [
'fehler-label.text' => 'Hata 404',
'fehler-titel.text' => 'Maalesef burada<br><em>devam edilemiyor.</em>',
'fehler-text.text' => 'Bu sayfa mevcut değil veya taşınmış. Ana sayfadan bir sonraki hedefinizi kolayca bulabilirsiniz.',
'fehler-knopf.text' => 'Ana sayfaya',
],
],
];
// ---------------------------------------------------------------------------------------------
// Kopf, Fuß, Menü, Formular
// ---------------------------------------------------------------------------------------------
$bereiche = [
'header' => ['kopf-logo.alt' => 'Kantarci Reisen – ana sayfaya'],
'footer' => [
'fuss-logo.alt' => 'Kantarci Reisen',
'fuss-copyright.text' => '© 2026 Kantarci Reisen',
'fuss-spruch.text' => 'Kalbimizde Türkiye. Gözümüz dünyada.',
'fuss-reisen-titel.text' => 'Seyahatler',
'fuss-reisen-liste.text' => '<a href="/#reisen">Türkiye tatili</a><br><a href="/#reisen">Dünya genelinde oteller</a><br><a href="/#reisen">Türkiye uçuşları</a><br><a href="/#kontakt">Seyahat talebi</a>',
'fuss-business-titel.text' => 'Seyahat acenteleri için',
'fuss-business-liste.text' => '<a href="/#reisesoft">reiseSoft’u tanıyın</a><br><a href="https://reisesoft.kantarci-reisen.de" rel="noopener">reiseSoft platformuna</a>',
'fuss-kontakt-titel.text' => 'İletişim',
'fuss-kontakt-text.text' => 'Kantarci Reisen<br>Ooser Hauptstraße 51<br>76532 Baden-Baden<br><a href="tel:+491702945935">0170 2945935</a><br><a href="mailto:info@kantarci-reisen.de">info@kantarci-reisen.de</a><br><a href="https://wa.me/491702945935?text=Merhaba%20Kantarci%20Reisen%2C%20bir%20seyahatle%20ilgileniyorum." rel="noopener">WhatsApp</a>',
'fuss-rechtliches.label' => 'Yasal bilgiler',
'fuss-agentur.text' => '<a href="https://kant-it.de" rel="noopener">Web sitesi: Kant-IT Solutions</a>',
],
];
$menue = [
'Reisen' => 'Seyahatler',
'Über uns' => 'Hakkımızda',
'reiseSoft' => 'reiseSoft',
'Kontakt' => 'İletişim',
'Reise anfragen' => 'Seyahat talebi',
];
$formular = [
'buttonLabel' => 'Talebi gönder',
'success' => 'Teşekkürler! Talebiniz bize ulaştı. Size kişisel olarak dönüş yapacağız – genellikle bir iş günü içinde.',
'fields' => [
'name' => ['label' => 'Adınız', 'help' => 'Ad ve soyad'],
'email' => ['label' => 'E-posta adresiniz'],
'telefon' => ['label' => 'Telefon', 'help' => 'İsteğe bağlı – sizi aramamızı tercih ederseniz'],
'anliegen' => ['label' => 'Konu', 'options' => [
'Türkei-Urlaub' => 'Türkiye tatili',
'Hotels weltweit' => 'Dünya genelinde oteller',
'Flüge in die Türkei' => 'Türkiye uçuşları',
'reiseSoft für mein Reisebüro' => 'Seyahat acentem için reiseSoft',
'Allgemeine Anfrage' => 'Genel talep',
]],
'zeitraum' => ['label' => 'Seyahat dönemi', 'help' => 'ör. Pentekost tatili veya Ağustos’ta 2 hafta'],
'personen' => ['label' => 'Kişi sayısı'],
'wuensche' => ['label' => 'İstekleriniz', 'help' => 'Varış noktası, kalkış havalimanı, bütçe, sizin için önemli olanlar …'],
'datenschutz' => ['label' => 'Bilgilerimin gizlilik bildirimine uygun olarak işlenmesini kabul ediyorum.'],
],
];
// ---------------------------------------------------------------------------------------------
// Ausführen
// ---------------------------------------------------------------------------------------------
$languages->save([SPRACHE]);
echo "Sprache eingeschaltet: Türkisch\n";
/** Legt die Übersetzung auf das Dokument; meldet Schlüssel, die es im Dokument nicht gibt. */
$uebersetzen = static function (array $document, array $daten, string $ort): array {
$texte = [];
$vorhanden = [];
$walk = static function (array $blocks) use (&$walk, &$vorhanden): void {
foreach ($blocks as $block) {
foreach (Translations::FIELDS[$block['type'] ?? ''] ?? [] as $prop) {
$vorhanden[$block['id'].'.'.$prop] = $block['props'][$prop] ?? '';
}
$walk($block['children'] ?? []);
}
};
$walk($document['blocks']);
$translation = [];
$source = [];
foreach ($daten['blocks'] ?? $daten as $schluessel => $text) {
if (!array_key_exists($schluessel, $vorhanden)) {
throw new RuntimeException(sprintf('%s: „%s“ gibt es im Dokument nicht.', $ort, $schluessel));
}
[$id, $prop] = explode('.', $schluessel, 2);
$translation['blocks'][$id][$prop] = $text;
$source[$schluessel] = Translations::fingerprint((string) $vorhanden[$schluessel]);
}
foreach (['title', 'description'] as $feld) {
if (isset($daten[$feld])) {
$translation[$feld] = $daten[$feld];
$source[$feld] = Translations::fingerprint((string) $document[$feld]);
}
}
foreach (['slug', 'seoTitle'] as $feld) {
if (isset($daten[$feld])) {
$translation[$feld] = $daten[$feld];
}
}
$translation['source'] = $source;
$document['schemaVersion'] = max((int) $document['schemaVersion'], Translations::SCHEMA);
$document['translations'] = [SPRACHE => $translation];
return $document;
};
foreach ($seiten as $pfad => $daten) {
$zeile = $db->fetchAssociative('SELECT id, version FROM page WHERE path = ? AND deleted_at IS NULL', [$pfad]);
if (false === $zeile) {
throw new RuntimeException("Seite /{$pfad} fehlt.");
}
$entwurf = $pages->findForEditor($zeile['id']);
$dokument = $uebersetzen($entwurf['document'], $daten, '/'.$pfad);
$revision = $pages->saveDraft($zeile['id'], $dokument, $entwurf['version']);
$pages->publish($zeile['id'], $revision, $entwurf['version'] + 1);
$zaehlung = Translations::progress($dokument, SPRACHE);
printf("Seite /%s: %d von %d Texten übersetzt, veröffentlicht\n", $pfad, $zaehlung['done'], $zaehlung['total']);
}
foreach ($bereiche as $art => $texte) {
$bereich = $partials->findByKind($art);
$dokument = $uebersetzen($bereich['document'], $texte, $art);
if ('header' === $art) {
// Die Sprachwahl rechts neben dem Menü, als Kürzel. Auf dem Telefon bleibt sie neben dem
// Menü-Knopf stehen; das Menü selbst rückt mit margin-left:auto nach rechts.
$zeile = &$dokument['blocks'][0]['children'][0];
$zeile['children'] = array_values(array_filter($zeile['children'], static fn (array $b): bool => 'kopf-sprache' !== $b['id']));
foreach ($zeile['children'] as &$kind) {
if ('kopf-menue' === $kind['id'] && !str_contains($kind['css'] ?? '', 'margin-left:auto')) {
$kind['css'] = "&{margin-left:auto}\n".($kind['css'] ?? '');
}
}
unset($kind);
$zeile['children'][] = [
'id' => 'kopf-sprache', 'type' => 'language', 'props' => ['display' => 'code'],
// Auf dem Telefon ist neben Logo, Anfrageknopf und Menü kein Platz - dort steht die
// Sprachwahl im Fuß.
'layout' => ['visibility' => 'hide-mobile'],
'style' => [
'desktop' => ['fontSize' => '14px', 'fontWeight' => '600', 'color' => '@tinte', 'margin' => ['left' => '20px']],
'mobile' => ['margin' => ['left' => '0px']],
],
'css' => "& a{border-radius:4px;letter-spacing:.04em}\n& a[aria-current]{box-shadow:inset 0 -2px 0 #ff753d}\n& a:hover{text-decoration:none;background:#f1f5f5}",
];
unset($zeile);
}
if ('footer' === $art) {
// Unten neben der Rechtszeile, auf jedem Gerät - auf dem Telefon der Weg zur Sprache.
$sprache = [
'id' => 'fuss-sprache', 'type' => 'language', 'props' => ['display' => 'name'],
'style' => ['desktop' => ['fontSize' => '13px']],
'css' => "& a{padding:2px 6px;color:inherit}
& a[aria-current]{box-shadow:inset 0 -2px 0 #ff753d}",
];
$einsetzen = static function (array $blocks) use (&$einsetzen, $sprache): array {
foreach ($blocks as $i => $block) {
$kinder = array_values(array_filter($block['children'] ?? [], static fn (array $b): bool => 'fuss-sprache' !== $b['id']));
if ('fuss-links' === $block['id']) {
array_unshift($kinder, $sprache);
}
$blocks[$i]['children'] = $einsetzen($kinder);
}
return $blocks;
};
$dokument['blocks'] = $einsetzen($dokument['blocks']);
}
$partials->save($bereich['id'], $dokument, $bereich['version']);
$zaehlung = Translations::progress($dokument, SPRACHE);
printf("Bereich %s: %d von %d Texten übersetzt\n", $art, $zaehlung['done'], $zaehlung['total']);
}
$alle = $menus->all();
$kopfmenue = $alle['header'];
$beschriften = static function (array $items) use (&$beschriften, $menue): array {
foreach ($items as $i => $item) {
if (isset($menue[$item['label']])) {
$items[$i]['labels'] = [SPRACHE => $menue[$item['label']]];
}
$items[$i]['children'] = $beschriften($item['children'] ?? []);
}
return $items;
};
$menus->save('header', $kopfmenue['name'], $beschriften($kopfmenue['items']), $kopfmenue['version']);
echo "Menü: ".count($menue)." Beschriftungen\n";
$form = $forms->findBySlug('reiseanfrage');
$einstellungen = $form['settings'];
$einstellungen['translations'] = [SPRACHE => $formular];
$forms->save('reiseanfrage', $form['name'], array_map(static fn (array $f): array => [...$f, 'images' => $f['images'] ?: '', 'min' => $f['min'] ?? '', 'max' => $f['max'] ?? ''], $form['fields']), $einstellungen, $form['version']);
echo "Formular reiseanfrage übersetzt\n";
echo "Fertig: /tr, /tr/kunye, /tr/gizlilik\n";
+9
View File
@@ -0,0 +1,9 @@
# Dieser Ordner gehoert nicht ins Netz (ADR 116). Zeigt die Domain richtig auf public/, wird diese
# Datei nie gelesen; zeigt sie versehentlich auf den Projektordner, sperrt sie hier alles.
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Command;
use App\Ops\HttpsRedirect;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'cms:https', description: 'Shows or switches the HTTPS redirect and HSTS in public/.htaccess, with a probe before and after (ADR 120).')]
final class HttpsCommand extends Command
{
public function __construct(private HttpsRedirect $https, private string $siteOrigin)
{
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('aktion', InputArgument::OPTIONAL, 'an, aus, hsts-an oder hsts-aus; ohne Angabe der Stand.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$action = (string) $input->getArgument('aktion');
if ('' === $action) {
$state = $this->https->state();
$io->writeln(sprintf('Umleitung auf HTTPS: %s · HSTS: %s%s', $state['redirect'] ? 'an' : 'aus', $state['hsts'] ? 'an' : 'aus', $state['found'] ? '' : ' · Zeilen fehlen in public/.htaccess'));
return Command::SUCCESS;
}
$result = match ($action) {
'an' => $this->https->enableRedirect($this->siteOrigin),
'aus' => $this->https->disableRedirect(),
'hsts-an' => $this->https->switchHsts(true, $this->siteOrigin),
'hsts-aus' => $this->https->switchHsts(false, $this->siteOrigin),
default => ['ok' => false, 'message' => 'Erlaubt sind an, aus, hsts-an und hsts-aus.'],
};
$result['ok'] ? $io->success($result['message']) : $io->error($result['message']);
return $result['ok'] ? Command::SUCCESS : Command::FAILURE;
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Command;
use App\Domain\InvalidPage;
use App\Ops\SitePackage;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'cms:paket:export', description: 'Writes a content package (pages, media, forms, design, settings) for import into another installation (ADR 118).')]
final class PackageExportCommand extends Command
{
public function __construct(private SitePackage $packages)
{
parent::__construct();
}
protected function configure(): void
{
$this->addOption('mit-benutzern', null, InputOption::VALUE_NONE, 'Konten mit Passwort-Hashes mitnehmen.')
->addOption('ziel', null, InputOption::VALUE_REQUIRED, 'Pfad der Zip-Datei (Standard: var/pakete/paket-<website>-<zeit>.zip).');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
try {
$file = $this->packages->export((bool) $input->getOption('mit-benutzern'), $input->getOption('ziel') ?: null);
} catch (InvalidPage $e) {
$io->error($e->getMessage());
return Command::FAILURE;
}
$manifest = $this->packages->inspect($file);
$io->success(sprintf('%s: %d Seite(n), %d Mediendatei(en), Konten %s.', $file, (int) ($manifest['inhalt']['page'] ?? 0), (int) $manifest['medien'], $manifest['benutzer'] ? 'enthalten' : 'nicht enthalten'));
return Command::SUCCESS;
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Command;
use App\Ops\SitePackage;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'cms:paket:import', description: 'Replaces the content of this installation with a content package; accounts stay unless --mit-benutzern (ADR 118).')]
final class PackageImportCommand extends Command
{
public function __construct(private SitePackage $packages)
{
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('datei', InputArgument::REQUIRED, 'Die Zip-Datei des Pakets.')
->addOption('mit-benutzern', null, InputOption::VALUE_NONE, 'Konten und Passwörter aus dem Paket übernehmen.')
->addOption('ja', null, InputOption::VALUE_NONE, 'Ohne Rückfrage ersetzen.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$file = (string) $input->getArgument('datei');
if (!$input->getOption('ja') && !$io->confirm('Die Inhalte dieser Installation werden ersetzt (vorher wird gesichert). Weiter?', false)) {
return Command::FAILURE;
}
$result = $this->packages->import($file, (bool) $input->getOption('mit-benutzern'));
foreach ($result['steps'] as $step) {
$io->writeln(sprintf('%s %s: %s', $step['ok'] ? 'ok ' : 'FEHLER', $step['step'], $step['detail']));
}
return $result['ok'] ? Command::SUCCESS : Command::FAILURE;
}
}
+5 -2
View File
@@ -158,6 +158,9 @@ final class Consent
{
$settings = $this->settings->load();
$decision = $this->decision($request);
// Auf einer Seite in einer weiteren Sprache der Hinweis in dieser Sprache (ADR 111).
$locale = $request->attributes->get('kant_locale');
$translated = is_string($locale) ? ($settings['consentTexts'][$locale] ?? []) : [];
return [
'groups' => array_map(
@@ -171,8 +174,8 @@ final class Consent
),
'decided' => null !== $decision,
'decidedAt' => $decision['at'] ?? null,
'text' => $settings['consentText'],
'title' => $settings['consentTitle'],
'text' => $translated['text'] ?? $settings['consentText'],
'title' => $translated['title'] ?? $settings['consentTitle'],
'layout' => $settings['consentLayout'],
'privacyPath' => $settings['privacyPath'],
'imprintPath' => $settings['imprintPath'],
+17 -2
View File
@@ -141,8 +141,15 @@ final class CustomCode
if (1 !== preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $blockId)) {
return '';
}
// Gespeichert wurde nur, was beim Speichern galt. Ist eine Regel inzwischen strenger
// (ADR 116), fällt das CSS dieses einen Blocks weg - die Seite selbst bleibt erreichbar.
try {
$rules = $this->parse($css);
} catch (InvalidDocument) {
return '';
}
$out = '';
foreach ($this->parse($css) as $suffix => $declarations) {
foreach ($rules as $suffix => $declarations) {
if ('' === $declarations) {
continue;
}
@@ -193,6 +200,13 @@ final class CustomCode
if (1 !== preg_match('/^[a-zA-Z0-9 .:>+~_\[\]="\'-]*$/D', $suffix)) {
throw new InvalidDocument(sprintf('„&%s“ ist als verschachtelter Selektor nicht erlaubt.', $suffix));
}
// Geschwister des Elements selbst (`& ~ *`, `& + p`) liegen außerhalb des Blocks - damit
// ließe sich die ganze Seite umgestalten. Innerhalb des Blocks bleiben `~` und `+`
// erlaubt. Und Anführungszeichen paarweise: ein offener String verschluckte sonst
// alle folgenden Regeln der Seite (ADR 116).
if (1 === preg_match('/^\s*[~+]/', $suffix) || 0 !== substr_count($suffix, '"') % 2 || 0 !== substr_count($suffix, "'") % 2) {
throw new InvalidDocument(sprintf('„&%s“ reicht über den Block hinaus und ist deshalb nicht erlaubt.', $suffix));
}
// Zusammenfuegen statt ersetzen: zweimal derselbe Selektor ist in CSS eine Kaskade,
// nicht ein Widerruf. Vorher gewann die letzte Regel, und alles, was nur in der
// ersten stand, verschwand ohne Meldung - gefunden am Projektraster von EB Solar,
@@ -247,7 +261,8 @@ final class CustomCode
$out[] = $property.':'.$value;
continue;
}
if ('' === $value || 1 !== preg_match('/^[a-zA-Z0-9 ,.%#()\/:_+*-]{1,300}$/D', $value)) {
// Kein Kommentaranfang und -ende: ein offener Kommentar verschluckte die Regeln danach.
if ('' === $value || 1 !== preg_match('/^[a-zA-Z0-9 ,.%#()\/:_+*-]{1,300}$/D', $value) || str_contains($value, '/*') || str_contains($value, '*/')) {
throw new InvalidDocument(sprintf('Der Wert von „%s“ enthält Zeichen, die in eigenem CSS nicht vorgesehen sind.', $property));
}
$out[] = $property.':'.$value;
+62 -4
View File
@@ -16,7 +16,7 @@ final class DocumentValidator
/** Blocks introduced with schema v6 for the sections a marketing page actually needs. */
public const V6_TYPES = ['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'];
/** Icons ship as inline SVG from this fixed list; no icon font and no external request. */
public const ICONS = ['arrow-up-right', 'arrow-right', 'arrow-left', 'arrow-up', 'arrow-down', 'chevron-right', 'chevron-left', 'chevron-up', 'chevron-down', 'arrow-circle', 'external', 'refresh', 'menu', 'close', 'plus', 'minus', 'phone', 'app', 'mail', 'chat', 'send', 'calendar', 'clock', 'pin', 'map', 'navigation', 'web', 'wifi', 'support', 'shop', 'cart', 'tag', 'euro', 'credit-card', 'gift', 'percent', 'truck', 'package', 'briefcase', 'document', 'download', 'upload', 'print', 'link', 'share', 'image', 'camera', 'video', 'play', 'pause', 'music', 'mic', 'book', 'user', 'people', 'login', 'lock', 'key', 'shield', 'eye', 'thumbs-up', 'award', 'graduation', 'heart', 'star', 'home', 'building', 'wrench', 'hammer', 'brush', 'ruler', 'leaf', 'tree', 'sun', 'droplet', 'flame', 'bolt', 'solar', 'thermometer', 'snowflake', 'car', 'check', 'check-circle', 'info', 'help', 'alert', 'search', 'settings', 'filter', 'grid', 'list', 'sparkle', 'coffee', 'utensils', 'laptop', 'hardware', 'network', 'design'];
public const ICONS = ['arrow-up-right', 'arrow-right', 'arrow-left', 'arrow-up', 'arrow-down', 'chevron-right', 'chevron-left', 'chevron-up', 'chevron-down', 'arrow-circle', 'external', 'refresh', 'menu', 'close', 'plus', 'minus', 'phone', 'app', 'mail', 'chat', 'send', 'calendar', 'clock', 'pin', 'map', 'navigation', 'web', 'wifi', 'support', 'shop', 'cart', 'tag', 'euro', 'credit-card', 'gift', 'percent', 'truck', 'package', 'briefcase', 'document', 'download', 'upload', 'print', 'link', 'share', 'image', 'camera', 'video', 'play', 'pause', 'music', 'mic', 'book', 'user', 'people', 'login', 'lock', 'key', 'shield', 'eye', 'thumbs-up', 'award', 'graduation', 'heart', 'star', 'home', 'building', 'wrench', 'hammer', 'brush', 'ruler', 'leaf', 'tree', 'sun', 'droplet', 'flame', 'bolt', 'solar', 'thermometer', 'snowflake', 'car', 'check', 'check-circle', 'info', 'help', 'alert', 'search', 'settings', 'filter', 'grid', 'list', 'sparkle', 'coffee', 'utensils', 'laptop', 'hardware', 'network', 'design', 'facebook', 'instagram', 'whatsapp'];
/** Das Symbol eines Buttons, wenn keins gewählt ist: der Pfeil, den jeder Button vor v25 hatte. */
public const BUTTON_DEFAULT_ICON = 'arrow-up-right';
public const SPACER_SIZES = ['small', 'medium', 'large', 'huge'];
@@ -41,8 +41,10 @@ final class DocumentValidator
/** Schema v23: die sozialen Profile aus den Einstellungen, als Symbolreihe an jeder Stelle. */
public const V23_TYPES = ['social'];
public const V24_TYPES = ['html'];
/** Schema v27 (ADR 111): die Sprachwahl, gefüllt aus den Sprachen, in denen die Seite vorliegt. */
public const V27_TYPES = ['language'];
/** Die aktuelle Fassung des Dokumentschemas - dieselbe Zahl wie SCHEMA_VERSION in editor-model.js. */
public const SCHEMA_VERSION = 26;
public const SCHEMA_VERSION = 27;
/**
* @param bool $requireHeading A page needs exactly one main heading. A header, a footer or a
@@ -62,7 +64,10 @@ final class DocumentValidator
$this->text($document['title'] ?? null, 160);
$this->text($document['description'] ?? null, 320);
$this->require(is_array($document['blocks'] ?? null) && array_is_list($document['blocks']), 'Blocks must be a list.');
$this->require(strlen(json_encode($document, JSON_THROW_ON_ERROR)) <= 262144, 'Document exceeds 256 KiB.');
// Die Grenze gilt für die Seite selbst; jede Übersetzung bringt ihre Texte noch einmal mit
// und bekommt deshalb insgesamt Platz für bis zu drei weitere Fassungen.
$this->require(strlen(json_encode(Translations::strip($document), JSON_THROW_ON_ERROR)) <= 262144, 'Document exceeds 256 KiB.');
$this->require(strlen(json_encode($document, JSON_THROW_ON_ERROR)) <= 1048576, 'Die Seite ist mit ihren Übersetzungen größer als 1 MiB.');
$ids = [];
$headings = 0;
foreach ($document['blocks'] as $block) {
@@ -82,6 +87,55 @@ final class DocumentValidator
$requireHeading ? 1 === $headings : 0 === $headings,
$requireHeading ? 'A page requires exactly one main heading.' : 'A shared area must not contain a main heading.',
);
if (array_key_exists('translations', $document)) {
$this->translations($document, $requireHeading);
}
}
/**
* Die Übersetzungen eines Dokuments (ADR 111). Geprüft wird zuerst die Form, dann jede
* Sprache so, wie sie ausgeliefert wird: als Dokument mit den übersetzten Texten, durch
* dieselbe Prüfung wie die Hauptsprache. Eine Übersetzung kann so keine Länge, keine
* Auszeichnung und keine Überschriftenregel umgehen.
*/
private function translations(array $document, bool $requireHeading): void
{
$translations = $document['translations'];
$this->require(Translations::supports($document['schemaVersion']), 'Übersetzungen brauchen Dokumentschema v27.');
$this->require(is_array($translations) && ([] === $translations || !array_is_list($translations)), 'Die Übersetzungen sind ein Objekt je Sprache.');
foreach ($translations as $locale => $translation) {
$this->require(is_string($locale) && isset(Languages::CATALOG[$locale]), 'Unbekannte Sprache in den Übersetzungen.');
$this->require(is_array($translation) && ([] === $translation || !array_is_list($translation)), 'Eine Übersetzung ist ein Objekt.');
foreach ($translation as $key => $value) {
$this->require(in_array($key, ['slug', 'blocks', 'source', ...array_keys(Translations::PAGE_FIELDS)], true), sprintf('Unbekanntes Feld „%s“ in einer Übersetzung.', $key));
}
foreach (Translations::PAGE_FIELDS as $field => $max) {
if (isset($translation[$field])) {
$this->text($translation[$field], $max, true);
}
}
if (isset($translation['slug'])) {
$this->require(is_string($translation['slug']) && strlen($translation['slug']) <= 80 && 1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $translation['slug']), 'Die übersetzte Adresse darf nur Kleinbuchstaben, Ziffern und einzelne Bindestriche enthalten.');
}
$blocks = $translation['blocks'] ?? [];
$this->require(is_array($blocks) && count($blocks) <= Translations::MAX_BLOCKS && ([] === $blocks || !array_is_list($blocks)), 'Die übersetzten Texte sind ein Objekt je Block.');
foreach ($blocks as $id => $fields) {
$this->require(is_string($id) && 1 === preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $id) && is_array($fields), 'Übersetzte Texte gehören zu einem Block.');
foreach ($fields as $prop => $value) {
$this->require(is_string($prop) && 1 === preg_match('/^[a-zA-Z]{1,40}$/D', $prop) && is_string($value), 'Ein übersetzter Text ist Text.');
}
}
$source = $translation['source'] ?? [];
$this->require(is_array($source) && count($source) <= Translations::MAX_BLOCKS * 3 + 2, 'Ungültige Vermerke zur Übersetzung.');
foreach ($source as $key => $hash) {
$this->require(is_string($key) && strlen($key) <= 110 && is_string($hash) && 1 === preg_match('/^[a-f0-9]{8}$/D', $hash), 'Ungültiger Vermerk zur Übersetzung.');
}
try {
$this->validate(Translations::apply($document, $locale), $requireHeading, $this->writing);
} catch (InvalidDocument $error) {
throw new InvalidDocument(sprintf('%s (%s): %s', Languages::CATALOG[$locale]['name'], $locale, $error->getMessage()), previous: $error);
}
}
}
private function block(mixed $block, array &$ids, int &$headings, int $depth, int $schemaVersion, ?string $parentType): void
@@ -101,7 +155,7 @@ final class DocumentValidator
// page: a deactivated plugin must never make stored content unreadable. Nothing from
// props is rendered for such a block, so unvalidated values never reach the output.
$plugin = $schemaVersion >= 4 && is_string($type) && 1 === preg_match('/^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/D', $type);
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || ($schemaVersion >= 18 && in_array($type, self::V18_TYPES, true)) || ($schemaVersion >= 19 && in_array($type, self::V19_TYPES, true)) || ($schemaVersion >= 23 && in_array($type, self::V23_TYPES, true)) || ($schemaVersion >= 24 && in_array($type, self::V24_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || ($schemaVersion >= 18 && in_array($type, self::V18_TYPES, true)) || ($schemaVersion >= 19 && in_array($type, self::V19_TYPES, true)) || ($schemaVersion >= 23 && in_array($type, self::V23_TYPES, true)) || ($schemaVersion >= 24 && in_array($type, self::V24_TYPES, true)) || ($schemaVersion >= 27 && in_array($type, self::V27_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
// An accordion entry only ever renders inside an accordion; at the top level or anywhere
// else it would produce a details element without its group.
$this->require('accordionitem' !== $type || 'accordion' === $parentType, 'An accordion entry belongs into an accordion.');
@@ -333,6 +387,10 @@ final class DocumentValidator
$this->require(in_array($props['layout'], ['horizontal', 'vertical', 'burger'], true), 'Unbekannte Menüart.');
}
}
if ('language' === $type) {
// Welche Sprachen es gibt, weiß nur die Website - der Block sagt nur, wie er sie nennt.
$this->require(in_array($props['display'] ?? null, ['code', 'name'], true), 'Die Sprachwahl zeigt Kürzel („code“) oder Namen („name“).');
}
if ('partialref' === $type) {
// Only the address of the section is content. Its blocks live in the partial, so one
// change reaches every page that links it and a page cannot alter a shared section.
+200 -13
View File
@@ -46,6 +46,47 @@ final class FormRepository
/** A human needs longer than this to fill in a form; anything faster is a script. */
public const MIN_SECONDS = 3;
public const MAX_SECONDS = 86400;
/**
* Gespeicherte Anfragen je Formular und insgesamt je Stunde (ADR 116). Darüber hinaus wird
* nichts mehr gespeichert und nichts verschickt - eine Flut füllt sonst Postfach und
* Datenbank, und die Grenze je Absender hilft nicht gegen viele Absender.
*/
public const FORM_RATE_LIMIT = 30;
public const GLOBAL_RATE_LIMIT = 120;
/**
* Das Siegel eines Formulars: gebunden an das Formular und an den Zweck, damit ein von einer
* Seite geholtes Siegel nicht für jedes Formular gilt (ADR 116).
*/
public static function stamp(string $slug, int $issued, string $secret): string
{
return hash_hmac('sha256', 'formular|'.$slug.'|'.$issued, $secret);
}
/**
* Wer ein Absender ist, für die Grenze je Stunde. Bei IPv6 zählt das /64-Netz: ein einzelner
* Anschluss hat davon Milliarden Adressen, mit der vollen Adresse wäre die Grenze wirkungslos.
*/
public static function senderKey(?string $ip, string $secret): string
{
$ip = (string) $ip;
if (str_contains($ip, ':') && false !== ($binary = @inet_pton($ip)) && 16 === \strlen($binary)) {
$ip = bin2hex(substr($binary, 0, 8)).'::/64';
}
return hash_hmac('sha256', 'absender|'.$ip, $secret);
}
/** Ob für dieses Formular oder insgesamt in der letzten Stunde schon zu viel ankam. */
public function flooded(string $formId): bool
{
$since = gmdate('Y-m-d H:i:s', time() - 3600);
if (self::FORM_RATE_LIMIT <= (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission WHERE form_id = ? AND created_at >= ?', [$formId, $since])) {
return true;
}
return self::GLOBAL_RATE_LIMIT <= (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission WHERE created_at >= ?', [$since]);
}
public function __construct(private Connection $db)
{
@@ -322,6 +363,10 @@ final class FormRepository
// Skript steht das ganze Formular ohnehin auf einer Seite.
'summary' => (bool) ($settings['summary'] ?? false),
];
$translations = $this->validateTranslations($settings['translations'] ?? [], $fields);
if ([] !== $translations) {
$clean['translations'] = $translations;
}
// Das Aussehen: leer heisst "wie die Website", sonst ein Palettenschluessel oder ein
// Hexwert - dieselbe Regel wie fuer jede Farbe im Editor.
foreach (self::COLORS as $field => $label) {
@@ -335,6 +380,144 @@ final class FormRepository
return $clean;
}
/**
* Die Übersetzungen eines Formulars (ADR 111): je Sprache Erfolgsmeldung, Button und je Feld
* Beschriftung, Hilfetext, Einheit und die angezeigten Optionen. Übersetzt wird nur, was
* Besucher lesen. Abgeschickt werden weiter die Optionen der Hauptsprache - Bedingungen,
* Prüfung, gespeicherte Einsendungen und die E-Mail an den Empfänger bleiben dieselben.
*/
private function validateTranslations(mixed $input, array $fields): array
{
if (!is_array($input)) {
throw new InvalidForm('Die Übersetzungen des Formulars sind ungültig.');
}
$byName = array_column($fields, null, 'name');
$clean = [];
foreach ($input as $locale => $translation) {
if (!is_string($locale) || !isset(Languages::CATALOG[$locale]) || !is_array($translation)) {
throw new InvalidForm('Eine Übersetzung des Formulars gehört zu einer unbekannten Sprache.');
}
$out = [];
foreach (['success' => 300, 'buttonLabel' => 40] as $key => $max) {
$text = trim((string) ($translation[$key] ?? ''));
if ('' !== $text) {
$out[$key] = mb_substr($text, 0, $max);
}
}
foreach ((is_array($translation['fields'] ?? null) ? $translation['fields'] : []) as $name => $values) {
$field = $byName[$name] ?? null;
if (null === $field || !is_array($values)) {
continue;
}
$entry = [];
foreach (['label' => 120, 'help' => 200, 'unit' => 12] as $key => $max) {
$text = trim((string) ($values[$key] ?? ''));
if ('' !== $text) {
$entry[$key] = mb_substr($text, 0, $max);
}
}
foreach ((is_array($values['options'] ?? null) ? $values['options'] : []) as $original => $text) {
$text = trim((string) $text);
if (in_array((string) $original, $field['options'], true) && '' !== $text) {
$entry['options'][(string) $original] = mb_substr($text, 0, 120);
}
}
if ([] !== $entry) {
$out['fields'][$name] = $entry;
}
}
if ([] !== $out) {
$clean[$locale] = $out;
}
}
return $clean;
}
/**
* Das Formular in einer weiteren Sprache: Beschriftungen, Hilfetexte, Einheiten, Button und
* Erfolgsmeldung übersetzt, wo es Übersetzungen gibt. Optionen behalten ihren Wert; nur die
* Anzeige steht in `optionLabels`.
*/
public static function localise(array $form, ?string $locale): array
{
$translation = null === $locale ? [] : ($form['settings']['translations'][$locale] ?? []);
foreach ($form['fields'] as $index => $field) {
$values = $translation['fields'][$field['name']] ?? [];
foreach (['label', 'help', 'unit'] as $key) {
if (isset($values[$key])) {
$form['fields'][$index][$key] = $values[$key];
}
}
$form['fields'][$index]['optionLabels'] = $values['options'] ?? [];
}
foreach (['success', 'buttonLabel'] as $key) {
if (isset($translation[$key])) {
$form['settings'][$key] = $translation[$key];
}
}
$form['locale'] = $locale;
return $form;
}
/**
* Die Meldungen der Prüfung in weiteren Sprachen. Schlüssel ist die deutsche Vorlage samt
* Platzhaltern; ohne eigene Liste gilt Englisch.
*/
public const MESSAGES = [
'en' => [
'Bitte wähle nur aus den angebotenen Optionen.' => 'Please choose only from the options offered.',
'Bitte wähle mindestens eine Option.' => 'Please choose at least one option.',
'Bitte kürze die Eingabe auf höchstens %d Zeichen.' => 'Please shorten your entry to at most %d characters.',
'Ohne diese Einwilligung können wir die Anfrage nicht bearbeiten.' => 'We cannot process your request without this consent.',
'Bitte wähle eine der angebotenen Optionen.' => 'Please choose one of the options offered.',
'Dieses Feld wird benötigt.' => 'This field is required.',
'Bitte gib eine gültige E-Mail-Adresse an.' => 'Please enter a valid email address.',
'Bitte gib eine gültige Telefonnummer an.' => 'Please enter a valid phone number.',
'Bitte gib eine Zahl an.' => 'Please enter a number.',
'Bitte gib höchstens %s an.' => 'Please enter at most %s.',
'Bitte gib mindestens %s an.' => 'Please enter at least %s.',
'Bitte gib einen Wert zwischen %s und %s an.' => 'Please enter a value between %s and %s.',
],
'tr' => [
'Bitte wähle nur aus den angebotenen Optionen.' => 'Lütfen yalnızca sunulan seçeneklerden seçin.',
'Bitte wähle mindestens eine Option.' => 'Lütfen en az bir seçenek seçin.',
'Bitte kürze die Eingabe auf höchstens %d Zeichen.' => 'Lütfen girişinizi en fazla %d karaktere kısaltın.',
'Ohne diese Einwilligung können wir die Anfrage nicht bearbeiten.' => 'Bu onay olmadan talebinizi işleme alamayız.',
'Bitte wähle eine der angebotenen Optionen.' => 'Lütfen sunulan seçeneklerden birini seçin.',
'Dieses Feld wird benötigt.' => 'Bu alan zorunludur.',
'Bitte gib eine gültige E-Mail-Adresse an.' => 'Lütfen geçerli bir e-posta adresi girin.',
'Bitte gib eine gültige Telefonnummer an.' => 'Lütfen geçerli bir telefon numarası girin.',
'Bitte gib eine Zahl an.' => 'Lütfen bir sayı girin.',
'Bitte gib höchstens %s an.' => 'Lütfen en fazla %s girin.',
'Bitte gib mindestens %s an.' => 'Lütfen en az %s girin.',
'Bitte gib einen Wert zwischen %s und %s an.' => 'Lütfen %s ile %s arasında bir değer girin.',
],
];
private static function message(?string $locale, string $text, mixed ...$args): string
{
$template = null === $locale || 'de' === $locale ? $text : (self::MESSAGES[$locale][$text] ?? self::MESSAGES['en'][$text] ?? $text);
return [] === $args ? $template : sprintf($template, ...$args);
}
/**
* Wie viele Einsendungen je Formular eingegangen sind, für die Übersicht.
*
* @return array<string, int> Formular-ID => Anzahl
*/
public function submissionCounts(): array
{
$counts = [];
foreach ($this->db->fetchAllAssociative('SELECT form_id, COUNT(*) AS n FROM form_submission GROUP BY form_id') as $row) {
$counts[(string) $row['form_id']] = (int) $row['n'];
}
return $counts;
}
/** Ob dieses Formular auf der Seite das Skript braucht: Schritte, Bedingungen oder die Zusammenfassung. */
public function needsScript(array $form): bool
{
@@ -401,8 +584,12 @@ final class FormRepository
*
* @return array{values: array<string, string>, errors: array<string, string>, consent: bool}
*/
public function check(array $form, array $input): array
/**
* @param string|null $locale eine weitere Sprache: die Meldungen stehen dann in ihr
*/
public function check(array $form, array $input, ?string $locale = null): array
{
$t = static fn (string $text, mixed ...$args): string => self::message($locale, $text, ...$args);
$values = [];
$errors = [];
$consent = false;
@@ -427,10 +614,10 @@ final class FormRepository
if ('checkboxes' === $field['type']) {
$picked = array_values(array_filter(array_map(static fn ($v): string => is_string($v) ? trim($v) : '', is_array($raw) ? $raw : [$raw]), static fn (string $v): bool => '' !== $v));
if ([] !== array_diff($picked, $field['options'])) {
$errors[$name] = 'Bitte wähle nur aus den angebotenen Optionen.';
$errors[$name] = $t('Bitte wähle nur aus den angebotenen Optionen.');
$picked = array_values(array_intersect($picked, $field['options']));
} elseif ([] === $picked && $field['required']) {
$errors[$name] = 'Bitte wähle mindestens eine Option.';
$errors[$name] = $t('Bitte wähle mindestens eine Option.');
}
$chosen[$name] = $picked;
$values[$name] = implode(', ', $picked);
@@ -439,39 +626,39 @@ final class FormRepository
$value = is_string($raw) ? trim($raw) : '';
$limit = 'textarea' === $field['type'] ? 5000 : 200;
if (mb_strlen($value) > $limit) {
$errors[$name] = sprintf('Bitte kürze die Eingabe auf höchstens %d Zeichen.', $limit);
$errors[$name] = $t('Bitte kürze die Eingabe auf höchstens %d Zeichen.', $limit);
$value = mb_substr($value, 0, $limit);
}
if ('consent' === $field['type']) {
$consent = '' !== $value;
if (!$consent) {
$errors[$name] = 'Ohne diese Einwilligung können wir die Anfrage nicht bearbeiten.';
$errors[$name] = $t('Ohne diese Einwilligung können wir die Anfrage nicht bearbeiten.');
}
$values[$name] = $consent ? 'ja' : '';
continue;
}
if ('' === $value) {
if ($field['required']) {
$errors[$name] = in_array($field['type'], self::CHOICE_TYPES, true) ? 'Bitte wähle eine der angebotenen Optionen.' : 'Dieses Feld wird benötigt.';
$errors[$name] = in_array($field['type'], self::CHOICE_TYPES, true) ? $t('Bitte wähle eine der angebotenen Optionen.') : $t('Dieses Feld wird benötigt.');
}
$values[$name] = '';
continue;
}
if ('email' === $field['type'] && false === filter_var($value, FILTER_VALIDATE_EMAIL)) {
$errors[$name] = 'Bitte gib eine gültige E-Mail-Adresse an.';
$errors[$name] = $t('Bitte gib eine gültige E-Mail-Adresse an.');
}
if ('phone' === $field['type'] && 1 !== preg_match('/^\+?[0-9 ()\/-]{3,32}$/D', $value)) {
$errors[$name] = 'Bitte gib eine gültige Telefonnummer an.';
$errors[$name] = $t('Bitte gib eine gültige Telefonnummer an.');
}
if ('number' === $field['type']) {
$zahl = str_replace(',', '.', $value);
if (!is_numeric($zahl)) {
$errors[$name] = 'Bitte gib eine Zahl an.';
$errors[$name] = $t('Bitte gib eine Zahl an.');
} elseif ((null !== $field['min'] && (float) $zahl < $field['min']) || (null !== $field['max'] && (float) $zahl > $field['max'])) {
$errors[$name] = match (true) {
null === $field['min'] => sprintf('Bitte gib höchstens %s an.', self::zahl($field['max'])),
null === $field['max'] => sprintf('Bitte gib mindestens %s an.', self::zahl($field['min'])),
default => sprintf('Bitte gib einen Wert zwischen %s und %s an.', self::zahl($field['min']), self::zahl($field['max'])),
null === $field['min'] => $t('Bitte gib höchstens %s an.', self::zahl($field['max'])),
null === $field['max'] => $t('Bitte gib mindestens %s an.', self::zahl($field['min'])),
default => $t('Bitte gib einen Wert zwischen %s und %s an.', self::zahl($field['min']), self::zahl($field['max'])),
};
} else {
// Gespeichert wird die Zahl, wie sie ist - ohne führende Nullen, mit Punkt.
@@ -480,7 +667,7 @@ final class FormRepository
}
if (in_array($field['type'], ['select', 'radio'], true)) {
if (!in_array($value, $field['options'], true)) {
$errors[$name] = 'Bitte wähle eine der angebotenen Optionen.';
$errors[$name] = $t('Bitte wähle eine der angebotenen Optionen.');
} else {
$chosen[$name] = [$value];
}
+137
View File
@@ -0,0 +1,137 @@
<?php
namespace App\Content;
use App\Domain\InvalidPage;
use Doctrine\DBAL\Connection;
/**
* Die Sprachen einer Website (ADR 111).
*
* Es gibt genau eine Hauptsprache - die der Seiten selbst (`website.default_locale`, bei allen
* bisherigen Installationen Deutsch). Jede weitere Sprache ist eine Übersetzungsebene über
* denselben Seiten: Aufbau und Gestaltung bleiben einmal da, je Sprache kommen nur Texte und
* die Adresse dazu. Ohne weitere Sprache ist von alldem nichts zu sehen.
*/
final class Languages
{
/**
* Die Sprachen, die zur Wahl stehen: Name in der Sprache selbst (für die Sprachwahl auf der
* Website), deutscher Name (für die Verwaltung) und die Form, die Open Graph erwartet.
*/
public const CATALOG = [
'de' => ['native' => 'Deutsch', 'name' => 'Deutsch', 'og' => 'de_DE'],
'en' => ['native' => 'English', 'name' => 'Englisch', 'og' => 'en_US'],
'fr' => ['native' => 'Français', 'name' => 'Französisch', 'og' => 'fr_FR'],
'it' => ['native' => 'Italiano', 'name' => 'Italienisch', 'og' => 'it_IT'],
'es' => ['native' => 'Español', 'name' => 'Spanisch', 'og' => 'es_ES'],
'nl' => ['native' => 'Nederlands', 'name' => 'Niederländisch', 'og' => 'nl_NL'],
'pl' => ['native' => 'Polski', 'name' => 'Polnisch', 'og' => 'pl_PL'],
'tr' => ['native' => 'Türkçe', 'name' => 'Türkisch', 'og' => 'tr_TR'],
'ru' => ['native' => 'Русский', 'name' => 'Russisch', 'og' => 'ru_RU'],
'ar' => ['native' => 'العربية', 'name' => 'Arabisch', 'og' => 'ar_AR'],
];
/** Sprachen, die von rechts nach links geschrieben werden. */
public const RTL = ['ar'];
/** Mehr Sprachen als diese braucht keine Handwerkerseite, und jede kostet Pflege. */
public const MAX_EXTRA = 5;
private ?string $main = null;
public function __construct(private Connection $db, private SiteSettings $settings)
{
}
/** Die Hauptsprache: die Sprache, in der Seiten gebaut werden. */
public function main(): string
{
if (null !== $this->main) {
return $this->main;
}
try {
$locale = $this->db->fetchOne('SELECT default_locale FROM website WHERE id = ?', ['default']);
} catch (\Doctrine\DBAL\Exception) {
$locale = false;
}
return $this->main = is_string($locale) && isset(self::CATALOG[$locale]) ? $locale : 'de';
}
/**
* Die weiteren Sprachen, in der eingestellten Reihenfolge. Leer bei einer einsprachigen
* Website - und dann zeigt weder der Editor noch die Website etwas davon.
*
* @return list<string>
*/
public function extra(): array
{
$stored = $this->settings->load()['languages'] ?? [];
$main = $this->main();
return array_values(array_filter(is_array($stored) ? $stored : [], static fn (mixed $code): bool => is_string($code) && isset(self::CATALOG[$code]) && $code !== $main));
}
/** @return list<string> Hauptsprache zuerst */
public function all(): array
{
return [$this->main(), ...$this->extra()];
}
public function isExtra(string $code): bool
{
return in_array($code, $this->extra(), true);
}
/** Für den Editor und die Einstellungen: Kennung, Name und Name in der Sprache selbst. */
public function describe(string $code): array
{
return ['code' => $code, 'name' => self::CATALOG[$code]['name'] ?? $code, 'native' => self::CATALOG[$code]['native'] ?? $code, 'rtl' => in_array($code, self::RTL, true)];
}
public static function ogLocale(string $code): string
{
return self::CATALOG[$code]['og'] ?? $code;
}
/**
* Legt die weiteren Sprachen fest.
*
* Eine Sprache wird zum ersten Teil der Adresse (/en/...). Gibt es schon eine Seite, die
* genau so heißt, wäre sie danach nicht mehr erreichbar - das wird abgelehnt statt still
* hingenommen. Eine entfernte Sprache verliert ihre Übersetzungen nicht: sie bleiben in den
* Seiten liegen und sind wieder da, wenn die Sprache zurückkommt.
*
* @param list<string> $codes
*/
public function save(array $codes): void
{
$main = $this->main();
$clean = [];
foreach ($codes as $code) {
$code = strtolower(trim((string) $code));
if ('' === $code || $code === $main) {
continue;
}
if (!isset(self::CATALOG[$code])) {
throw new InvalidPage(sprintf('Die Sprache „%s“ steht nicht zur Wahl.', $code));
}
$clean[$code] = true;
}
$clean = array_keys($clean);
if (count($clean) > self::MAX_EXTRA) {
throw new InvalidPage(sprintf('Mehr als %d weitere Sprachen sind nicht vorgesehen.', self::MAX_EXTRA));
}
foreach ($clean as $code) {
if (in_array($code, PageRepository::RESERVED_SLUGS, true)) {
continue;
}
$taken = $this->db->fetchOne('SELECT path FROM page WHERE website_id = ? AND (path = ? OR path LIKE ?) AND deleted_at IS NULL', ['default', $code, $code.'/%']);
if (false !== $taken) {
throw new InvalidPage(sprintf('Die Seite /%s belegt die Adresse, unter der %s erscheinen würde. Benenne sie zuerst um.', $code, self::CATALOG[$code]['name']));
}
}
$this->settings->saveLanguages($clean);
}
}
+22 -5
View File
@@ -95,7 +95,11 @@ final class MenuRepository
*/
private ?array $pages = null;
public function resolve(array $items, string $currentPath, string $locale = 'de'): array
/**
* @param string|null $language eine weitere Sprache (ADR 111): Einträge zeigen dann ihre
* Beschriftung in dieser Sprache, sofern sie eine haben
*/
public function resolve(array $items, string $currentPath, string $locale = 'de', ?string $language = null): array
{
if (null === $this->pages) {
$this->pages = [];
@@ -104,15 +108,16 @@ final class MenuRepository
}
}
return $this->resolveLevel($items, $this->pages, $currentPath, $this->settings->homePath());
return $this->resolveLevel($items, $this->pages, $currentPath, $this->settings->homePath(), $language);
}
private function resolveLevel(array $items, array $pages, string $currentPath, string $homePath): array
private function resolveLevel(array $items, array $pages, string $currentPath, string $homePath, ?string $language = null): array
{
$links = [];
foreach ($items as $item) {
$children = $this->resolveLevel($item['children'] ?? [], $pages, $currentPath, $homePath);
$label = $item['label'] ?? '';
$children = $this->resolveLevel($item['children'] ?? [], $pages, $currentPath, $homePath, $language);
$translated = null === $language ? '' : trim((string) ($item['labels'][$language] ?? ''));
$label = '' !== $translated ? $translated : ($item['label'] ?? '');
if ('page' === $item['type']) {
$page = $pages[$item['pageId']] ?? null;
if (null === $page || null === $page['published_revision_id']) {
@@ -159,6 +164,18 @@ final class MenuRepository
if (!is_array($item) || !is_string($item['label'] ?? null) || '' === trim($item['label']) || mb_strlen($item['label']) > 80) {
throw new InvalidMenu('Jeder Eintrag braucht eine Beschriftung mit höchstens 80 Zeichen.');
}
// Beschriftungen in weiteren Sprachen (ADR 111): je Sprache ein Text, wie die
// Beschriftung höchstens 80 Zeichen. Eine leere zeigt die Beschriftung der Hauptsprache.
if (array_key_exists('labels', $item)) {
if (!is_array($item['labels']) || ([] !== $item['labels'] && array_is_list($item['labels']))) {
throw new InvalidMenu('Die übersetzten Beschriftungen sind ungültig.');
}
foreach ($item['labels'] as $code => $text) {
if (!is_string($code) || !isset(Languages::CATALOG[$code]) || !is_string($text) || '' === trim($text) || mb_strlen($text) > 80) {
throw new InvalidMenu('Eine übersetzte Beschriftung hat höchstens 80 Zeichen und gehört zu einer bekannten Sprache.');
}
}
}
if (array_key_exists('newTab', $item) && !is_bool($item['newTab'])) {
throw new InvalidMenu('In neuem Tab öffnen ist an oder aus.');
}
+277 -9
View File
@@ -50,6 +50,8 @@ final class PageRepository
$path = $parent['path'].'/'.$slug;
} elseif (in_array($slug, self::RESERVED_SLUGS, true)) {
throw new InvalidPage('Diese Adresse ist für das CMS reserviert.');
} elseif (in_array($slug, $this->languageCodes(), true)) {
throw new InvalidPage(sprintf('Unter /%s erscheint die Website in einer weiteren Sprache. Wähle eine andere Adresse.', $slug));
}
if (false !== $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND locale = ? AND path = ?', ['default', $locale, $path])) {
throw new InvalidPage(sprintf('Die Adresse /%s ist bereits vergeben.', $path));
@@ -119,6 +121,8 @@ final class PageRepository
throw new InvalidPage('Eine Seite kann nicht unter eine ihrer eigenen Unterseiten verschoben werden.');
}
$newPath = $parent['path'].'/'.$slug;
} elseif (in_array($slug, $this->languageCodes(), true)) {
throw new InvalidPage(sprintf('Unter /%s erscheint die Website in einer weiteren Sprache. Wähle eine andere Adresse.', $slug));
}
if ($newPath === $page['path']) {
return 0;
@@ -148,6 +152,11 @@ final class PageRepository
$redirects->remember($row['path'], $moved);
}
$this->db->executeStatement('UPDATE page SET slug = ?, parent_id = ?, version = version + 1 WHERE id = ?', [$slug, $parentId, $pageId]);
// Die Übersetzungen hängen an derselben Hierarchie: unter einer verschobenen Seite
// ändern sich auch die Adressen der anderen Sprachen.
if ($this->multilingual()) {
$this->writeTranslationPaths($this->translationPaths(), $redirects);
}
return count($affected);
});
@@ -160,6 +169,27 @@ final class PageRepository
*
* @return int the number of pages moved into the bin
*/
/**
* Ob die Seite - mit $subtree auch eine ihrer Unterseiten - öffentlich ist. Was eine solche
* Seite live verändert (Adresse, Papierkorb, Bereiche, Beitragsdaten), darf nur, wer auch
* veröffentlichen darf (ADR 116).
*/
public function isLive(string $pageId, bool $subtree = false): bool
{
$page = $this->db->fetchAssociative('SELECT path, locale, published_revision_id FROM page WHERE id = ? AND website_id = ? AND deleted_at IS NULL', [$pageId, 'default']);
if (false === $page) {
return false;
}
if (null !== $page['published_revision_id'] || !$subtree) {
return null !== $page['published_revision_id'];
}
return false !== $this->db->fetchOne(
'SELECT id FROM page WHERE website_id = ? AND locale = ? AND path LIKE ? AND deleted_at IS NULL AND published_revision_id IS NOT NULL LIMIT 1',
['default', $page['locale'], $page['path'].'/%'],
);
}
public function trashPage(string $pageId, int $expectedVersion): int
{
return $this->db->transactional(function () use ($pageId, $expectedVersion): int {
@@ -270,11 +300,14 @@ final class PageRepository
{
$published = [];
foreach ($this->db->fetchAllAssociative(
'SELECT id, path, draft_revision_id, version FROM page WHERE website_id = ? AND deleted_at IS NULL AND publish_at IS NOT NULL AND publish_at <= ? AND draft_revision_id IS NOT NULL AND (published_revision_id IS NULL OR published_revision_id <> draft_revision_id)',
// Online geht die Fassung, die beim Planen vorlag (ADR 116) - nicht ein Entwurf, den
// danach jemand ohne Recht zum Veröffentlichen gespeichert hat. Planungen aus der Zeit
// vor dieser Spalte tragen keine Fassung; für sie bleibt es beim Entwurf.
'SELECT id, path, COALESCE(publish_revision_id, draft_revision_id) AS revision, version FROM page WHERE website_id = ? AND deleted_at IS NULL AND publish_at IS NOT NULL AND publish_at <= ? AND COALESCE(publish_revision_id, draft_revision_id) IS NOT NULL AND (published_revision_id IS NULL OR published_revision_id <> COALESCE(publish_revision_id, draft_revision_id))',
['default', gmdate('Y-m-d H:i:s')],
) as $row) {
try {
$this->publish($row['id'], $row['draft_revision_id'], (int) $row['version']);
$this->publish($row['id'], $row['revision'], (int) $row['version']);
} catch (InvalidPage|InvalidDocument|StaleWrite|\InvalidArgumentException $e) {
// Eine Seite, deren Fassung heute nicht mehr gilt, bleibt Entwurf - und haelt die
// anderen nicht auf. Der Grund steht im Protokoll, die Seite behaelt ihren Termin.
@@ -282,7 +315,7 @@ final class PageRepository
continue;
}
$this->db->executeStatement('UPDATE page SET publish_at = NULL WHERE id = ?', [$row['id']]);
$this->db->executeStatement('UPDATE page SET publish_at = NULL, publish_revision_id = NULL WHERE id = ?', [$row['id']]);
$published[] = $row['path'];
}
@@ -295,10 +328,13 @@ final class PageRepository
if ('' !== $when && 1 !== preg_match('/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}$/D', $when)) {
throw new InvalidPage('Der Zeitpunkt braucht die Form JJJJ-MM-TT HH:MM.');
}
$changed = $this->db->executeStatement(
'UPDATE page SET publish_at = ?, version = version + 1 WHERE id = ? AND version = ?',
['' === $when ? null : str_replace('T', ' ', $when).':00', $pageId, $expectedVersion],
);
// Mit dem Termin wird der jetzige Entwurf festgehalten: genau er geht online.
$changed = '' === $when
? $this->db->executeStatement('UPDATE page SET publish_at = NULL, publish_revision_id = NULL, version = version + 1 WHERE id = ? AND version = ?', [$pageId, $expectedVersion])
: $this->db->executeStatement(
'UPDATE page SET publish_at = ?, publish_revision_id = draft_revision_id, version = version + 1 WHERE id = ? AND version = ?',
[str_replace('T', ' ', $when).':00', $pageId, $expectedVersion],
);
if (1 !== $changed) {
throw new StaleWrite('Die Seite wurde inzwischen geändert. Lade die Übersicht neu.');
}
@@ -324,6 +360,9 @@ final class PageRepository
// Detach both pointers first: the revision rows are referenced by the page row.
$this->db->executeStatement('UPDATE page SET draft_revision_id = NULL, published_revision_id = NULL WHERE id = ?', [$pageId]);
$this->db->executeStatement('DELETE FROM page_term WHERE page_id = ?', [$pageId]);
if ($this->multilingual() || $this->db->createSchemaManager()->tablesExist(['page_translation'])) {
$this->db->executeStatement('DELETE FROM page_translation WHERE page_id = ?', [$pageId]);
}
$this->db->executeStatement('DELETE FROM revision WHERE page_id = ?', [$pageId]);
$this->db->executeStatement('DELETE FROM page WHERE id = ?', [$pageId]);
});
@@ -333,10 +372,15 @@ final class PageRepository
{
// Sanitising happens before validating and before storing, so the database never holds
// markup the allowlist would reject and no renderer has to remember to filter.
$document = $this->richText->normaliseDocument($document);
$document = Translations::normalise($this->richText->normaliseDocument($document), $this->richText);
$this->validator->validate($document, writing: true);
return $this->db->transactional(function () use ($pageId, $document, $expectedVersion): string {
// Eine übersetzte Adresse, die schon vergeben ist, fällt hier auf und nicht erst beim
// Veröffentlichen. Geschrieben wird sie erst dann - bis dahin ist sie Entwurf.
if ($this->multilingual($document)) {
$this->translationPaths($pageId, $this->translatedSlugs($pageId, $document));
}
$id = bin2hex(random_bytes(16));
$changed = $this->db->executeStatement(
'UPDATE page SET version = version + 1, draft_revision_id = ? WHERE id = ? AND version = ?',
@@ -364,7 +408,8 @@ final class PageRepository
if (false === $raw) {
throw new \InvalidArgumentException('Revision does not belong to this page.');
}
$this->validator->validate(json_decode($raw, true, 512, JSON_THROW_ON_ERROR));
$document = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
$this->validator->validate($document);
$changed = $this->db->executeStatement(
'UPDATE page SET published_revision_id = ?, version = version + 1 WHERE id = ? AND version = ?',
[$revisionId, $pageId, $expectedVersion],
@@ -372,6 +417,9 @@ final class PageRepository
if (1 !== $changed) {
throw new StaleWrite('The page changed. Reload before publishing.');
}
// Mit der Fassung gehen auch ihre Übersetzungen online - oder offline, wenn sie keine
// mehr hat. Die Adressen spiegeln damit genau den veröffentlichten Stand.
$this->publishTranslations($pageId, $document);
// A post that goes public without its own date gets one, so the archive can order it.
$this->db->executeStatement(
"UPDATE page SET published_at = ? WHERE id = ? AND type = 'post' AND published_at IS NULL",
@@ -391,10 +439,230 @@ final class PageRepository
}
$page['document'] = json_decode($page['document'], true, 512, JSON_THROW_ON_ERROR);
$this->validator->validate($page['document']);
$page['document'] = Translations::strip($page['document']);
return $page;
}
/**
* Eine veröffentlichte Seite in einer weiteren Sprache, gefunden über ihre übersetzte
* Adresse. Das Dokument kommt mit den übersetzten Texten; `path` bleibt die Adresse der
* Hauptsprache, weil Navigation, Brotkrumen und Formulare an ihr hängen.
*/
public function findPublishedTranslation(string $path, string $locale): ?array
{
$page = $this->db->fetchAssociative(
'SELECT p.id, p.slug, p.path, p.type, p.locale, p.author, p.excerpt, p.published_at, p.robots, p.seo_title, p.og_media_id, p.featured_media_id, p.header_area, p.footer_area, r.document, r.id AS revision_id, w.name AS website_name, t.path AS translated_path FROM page_translation t JOIN page p ON p.id = t.page_id JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id JOIN website w ON w.id = p.website_id WHERE t.path = ? AND t.locale = ? AND t.website_id = ? AND p.deleted_at IS NULL',
[$path, $locale, 'default'],
);
if (false === $page) {
return null;
}
$document = json_decode($page['document'], true, 512, JSON_THROW_ON_ERROR);
$this->validator->validate($document);
if (!in_array($locale, Translations::locales($document), true)) {
return null;
}
$translation = $document['translations'][$locale];
$page['document'] = Translations::apply($document, $locale);
$page['main_locale'] = $page['locale'];
$page['locale'] = $locale;
// Ohne eigenen Suchtitel nimmt die Vorlage den übersetzten Seitentitel - nie den Suchtitel
// der Hauptsprache, der in der falschen Sprache in den Suchergebnissen stünde.
$page['seo_title'] = $translation['seoTitle'] ?? '';
return $page;
}
/**
* Unter welchen Adressen eine Seite in den weiteren Sprachen veröffentlicht ist, als Pfad
* ohne Schrägstrich vorn.
*
* @return array<string, string> Sprache => Pfad
*/
public function translatedPaths(string $pageId): array
{
$paths = [];
foreach ($this->db->fetchAllAssociative('SELECT locale, path FROM page_translation WHERE page_id = ? AND website_id = ? ORDER BY locale', [$pageId, 'default']) as $row) {
$paths[$row['locale']] = $row['path'];
}
return $paths;
}
/**
* Alle veröffentlichten Seiten einer Sprache: Pfad der Hauptsprache => übersetzter Pfad.
* Damit zeigen Links auf einer übersetzten Seite auf die übersetzten Fassungen.
*
* @return array<string, string>
*/
public function translationMap(string $locale): array
{
$map = [];
foreach ($this->db->fetchAllAssociative(
'SELECT p.path AS main_path, t.path FROM page_translation t JOIN page p ON p.id = t.page_id WHERE t.locale = ? AND t.website_id = ? AND p.deleted_at IS NULL AND p.published_revision_id IS NOT NULL',
[$locale, 'default'],
) as $row) {
$map[$row['main_path']] = $row['path'];
}
return $map;
}
/**
* Die weiteren Sprachen, direkt gelesen: über SiteSettings::load() bliebe sonst eine ältere
* Startseite für den Rest der Anfrage zwischengespeichert.
*
* @return list<string>
*/
private function languageCodes(): array
{
$raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']);
$settings = is_string($raw) ? json_decode($raw, true) : null;
return is_array($settings['languages'] ?? null) ? $settings['languages'] : [];
}
/**
* Ob die übersetzten Adressen überhaupt gepflegt werden müssen: nur bei einer Website mit
* weiteren Sprachen oder einem Dokument mit Übersetzungen. Eine einsprachige Website fasst
* die Tabelle so nie an - auch nicht, wenn nach einem Update die Migration noch aussteht.
* Abgewählte Sprachen holt das nächste Veröffentlichen nach: es rechnet alle Pfade neu.
*/
private function multilingual(?array $document = null): bool
{
return [] !== $this->languageCodes() || (null !== $document && [] !== Translations::locales($document));
}
/** @return array<string, string> Sprache => übersetzte Adresse, aus dem Dokument */
private function translatedSlugs(string $pageId, array $document): array
{
$slug = (string) $this->db->fetchOne('SELECT slug FROM page WHERE id = ?', [$pageId]);
$slugs = [];
foreach (Translations::locales($document) as $locale) {
$slugs[$locale] = (string) ($document['translations'][$locale]['slug'] ?? $slug);
}
return $slugs;
}
/**
* Schreibt die übersetzten Adressen einer gerade veröffentlichten Seite: neue Sprachen kommen
* dazu, entfernte fallen weg, und eine geänderte Adresse bekommt eine Weiterleitung.
*/
private function publishTranslations(string $pageId, array $document): void
{
if ($this->multilingual($document)) {
$this->writeTranslationPaths($this->translationPaths($pageId, $this->translatedSlugs($pageId, $document)), new RedirectRepository($this->db));
}
}
/**
* Rechnet die übersetzten Adressen aller Seiten aus. Eine Seite steht in einer Sprache unter
* dem übersetzten Pfad ihrer Elternseite - oder, wenn die nicht übersetzt ist, unter deren
* Pfad in der Hauptsprache - und dahinter ihre übersetzte Adresse.
*
* Mit $pageId ersetzt $slugs die Sprachen dieser einen Seite (Entwurf oder Veröffentlichung);
* sonst gilt der gespeicherte Stand (nach dem Verschieben). Doppelte Adressen werden
* abgelehnt, bevor irgendetwas geschrieben ist.
*
* @param array<string, string> $slugs Sprache => Adresse
*
* @return array<string, array<string, array{slug: string, path: string, old: ?string}>> Sprache => Seite => Zeile
*/
private function translationPaths(?string $pageId = null, array $slugs = []): array
{
$pages = [];
foreach ($this->db->fetchAllAssociative('SELECT id, slug, parent_id FROM page WHERE website_id = ?', ['default']) as $row) {
$pages[$row['id']] = $row;
}
$wanted = [];
$old = [];
foreach ($this->db->fetchAllAssociative('SELECT page_id, locale, slug, path FROM page_translation WHERE website_id = ?', ['default']) as $row) {
$wanted[$row['locale']][$row['page_id']] = $row['slug'];
$old[$row['locale']][$row['page_id']] = $row['path'];
}
if (null !== $pageId) {
foreach (array_keys($wanted) as $locale) {
unset($wanted[$locale][$pageId]);
}
foreach ($slugs as $locale => $slug) {
$wanted[$locale][$pageId] = $slug;
}
}
$result = [];
foreach ($wanted as $locale => $rows) {
$memo = [];
$pathOf = function (string $id, int $depth = 0) use (&$pathOf, &$memo, $pages, $rows): string {
if (isset($memo[$id])) {
return $memo[$id];
}
$page = $pages[$id] ?? null;
if (null === $page || $depth > self::MAX_DEPTH) {
return '';
}
$segment = $rows[$id] ?? $page['slug'];
$parent = null === $page['parent_id'] ? '' : $pathOf($page['parent_id'], $depth + 1);
return $memo[$id] = '' === $parent ? $segment : $parent.'/'.$segment;
};
$taken = [];
foreach ($rows as $id => $slug) {
$id = (string) $id;
if (!isset($pages[$id])) {
continue;
}
$path = $pathOf($id);
if (isset($taken[$path])) {
throw new InvalidPage(sprintf('Die Adresse /%s/%s ist in %s bereits vergeben. Wähle für die Übersetzung eine andere Adresse.', $locale, $path, Languages::CATALOG[$locale]['name'] ?? $locale));
}
$taken[$path] = true;
$result[$locale][$id] = ['slug' => $slug, 'path' => $path, 'old' => $old[$locale][$id] ?? null];
}
}
return $result;
}
/**
* Bringt die Tabelle auf den ausgerechneten Stand. Geänderte Pfade wandern in zwei Schritten,
* damit zwei Seiten, die ihre Adressen tauschen, nicht am eindeutigen Index scheitern.
*/
private function writeTranslationPaths(array $paths, RedirectRepository $redirects): void
{
$keep = [];
foreach ($paths as $locale => $rows) {
foreach (array_keys($rows) as $id) {
$keep[$id.'|'.$locale] = true;
}
}
foreach ($this->db->fetchAllAssociative('SELECT page_id, locale FROM page_translation WHERE website_id = ?', ['default']) as $row) {
if (!isset($keep[$row['page_id'].'|'.$row['locale']])) {
$this->db->executeStatement('DELETE FROM page_translation WHERE page_id = ? AND locale = ?', [$row['page_id'], $row['locale']]);
}
}
$moved = [];
foreach ($paths as $locale => $rows) {
foreach ($rows as $id => $row) {
if (null === $row['old']) {
$this->db->insert('page_translation', ['page_id' => $id, 'website_id' => 'default', 'locale' => $locale, 'slug' => $row['slug'], 'path' => '~'.$id]);
$moved[] = [$id, $locale, $row];
} elseif ($row['old'] !== $row['path']) {
$this->db->executeStatement('UPDATE page_translation SET path = ? WHERE page_id = ? AND locale = ?', ['~'.$id, $id, $locale]);
$moved[] = [$id, $locale, $row];
} else {
$this->db->executeStatement('UPDATE page_translation SET slug = ? WHERE page_id = ? AND locale = ?', [$row['slug'], $id, $locale]);
}
}
}
foreach ($moved as [$id, $locale, $row]) {
$this->db->executeStatement('UPDATE page_translation SET slug = ?, path = ? WHERE page_id = ? AND locale = ?', [$row['slug'], $row['path'], $id, $locale]);
if (null !== $row['old']) {
$redirects->remember($locale.'/'.$row['old'], $locale.'/'.$row['path']);
}
}
}
/**
* Which header and footer a page wants: null for the standard one, 'none' for no area at all,
* or the address of another one.
+2 -2
View File
@@ -98,7 +98,7 @@ final class PartialRepository
if ('section' !== $kind && null === $this->findByKind($kind, 'default', $locale)) {
$slug = 'default';
}
$document = $this->richText->normaliseDocument($document);
$document = Translations::normalise($this->richText->normaliseDocument($document), $this->richText);
$this->validator->validate($document, false, true);
if (null !== $this->findByKind($kind, $slug, $locale)) {
throw new InvalidPage(sprintf('„%s“ ist als Adresse bereits vergeben.', $slug));
@@ -116,7 +116,7 @@ final class PartialRepository
public function save(string $id, array $document, int $expectedVersion): void
{
$document = $this->richText->normaliseDocument($document);
$document = Translations::normalise($this->richText->normaliseDocument($document), $this->richText);
$this->validator->validate($document, false, true);
$this->db->transactional(function () use ($id, $document, $expectedVersion): void {
// A section must not end up containing itself, directly or through another section.
+40 -11
View File
@@ -114,7 +114,12 @@ final class PostRepository
/**
* @return array{posts: list<array>, total: int, pages: int, page: int}
*/
public function published(int $page = 1, ?string $termId = null, int $perPage = self::PER_PAGE, string $locale = 'de'): array
/**
* @param string|null $language eine weitere Sprache (ADR 111): Titel und Auszug kommen aus
* der Übersetzung des Beitrags, sofern er eine hat, die Namen der
* Kategorien aus den Einstellungen
*/
public function published(int $page = 1, ?string $termId = null, int $perPage = self::PER_PAGE, string $locale = 'de', ?string $language = null): array
{
$where = "p.website_id = ? AND p.locale = ? AND p.type = 'post' AND p.published_revision_id IS NOT NULL AND p.deleted_at IS NULL";
$params = ['default', $locale];
@@ -138,25 +143,33 @@ final class PostRepository
// einzeln nach seinem Bild und seinen Begriffen, eine Liste mit zwölf Einträgen kostete
// also 24 Abfragen statt zwei. Bei neighbours(), das 500 Beiträge lädt, war das absurd.
$images = $this->media->findMany(array_map(static fn (array $row) => (string) $row['featured_media_id'], $rows));
$terms = $this->termsForMany(array_column($rows, 'id'));
$terms = $this->termsForMany(array_column($rows, 'id'), $language);
return [
'posts' => array_map(fn (array $row) => $this->present($row, $images, $terms[$row['id']] ?? ['category' => [], 'tag' => []]), $rows),
'posts' => array_map(fn (array $row) => $this->present($row, $images, $terms[$row['id']] ?? ['category' => [], 'tag' => []], $language), $rows),
'total' => $total,
'pages' => $pages,
'page' => $page,
];
}
private function present(array $row, array $images, array $terms): array
private function present(array $row, array $images, array $terms, ?string $language = null): array
{
$document = json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR);
$item = $images[$row['featured_media_id']] ?? null;
// Übersetzt ist ein Beitrag, wenn seine veröffentlichte Fassung die Sprache trägt. Sonst
// steht er in der Hauptsprache in der Liste - wie jedes nicht übersetzte Feld.
$translated = null !== $language && in_array($language, Translations::locales($document), true);
$excerpt = $row['excerpt'] ?? $document['description'] ?? '';
if ($translated) {
$excerpt = $document['translations'][$language]['description'] ?? $excerpt;
$document = Translations::apply($document, $language);
}
return [
'title' => $document['title'] ?? $row['path'],
'href' => $this->settings->publicPath($row['path']),
'excerpt' => $row['excerpt'] ?? $document['description'] ?? '',
'excerpt' => $excerpt,
'author' => $row['author'],
'publishedAt' => $row['published_at'],
'image' => null === $item ? null : ['alt' => $item['alt'] ?? '', 'variants' => $item['variants']],
@@ -171,7 +184,7 @@ final class PostRepository
*
* @return array<string, array<string, list<array{slug: string, name: string, href: string}>>>
*/
private function termsForMany(array $pageIds): array
private function termsForMany(array $pageIds, ?string $language = null): array
{
$pageIds = array_values(array_unique(array_filter($pageIds)));
if ([] === $pageIds) {
@@ -184,12 +197,28 @@ final class PostRepository
$pageIds,
) as $row) {
$terms[$row['page_id']] ??= ['category' => [], 'tag' => []];
$terms[$row['page_id']][$row['kind']][] = ['slug' => $row['slug'], 'name' => $row['name'], 'href' => '/'.self::ARCHIVE_PREFIX[$row['kind']].'/'.$row['slug']];
$terms[$row['page_id']][$row['kind']][] = ['slug' => $row['slug'], 'name' => $this->termName($row, $language), 'href' => '/'.self::ARCHIVE_PREFIX[$row['kind']].'/'.$row['slug']];
}
return $terms;
}
/**
* Der Name einer Kategorie oder eines Schlagworts in einer weiteren Sprache, gepflegt unter
* Einstellungen → Sprachen (ADR 111). Ohne Übersetzung der Name der Hauptsprache.
*
* @param array{kind: string, slug: string, name: string} $term
*/
public function termName(array $term, ?string $language): string
{
if (null === $language) {
return $term['name'];
}
$name = $this->settings->load()['termNames'][$language][$term['kind'].':'.$term['slug']] ?? '';
return is_string($name) && '' !== $name ? $name : $term['name'];
}
/**
* @return array<string, list<array{slug: string, name: string, href: string}>>
*/
@@ -203,14 +232,14 @@ final class PostRepository
*
* @return array{previous: ?array, next: ?array}
*/
public function neighbours(string $pageId, string $locale = 'de'): array
public function neighbours(string $pageId, string $locale = 'de', ?string $language = null): array
{
$termId = (string) $this->db->fetchOne(
'SELECT t.id FROM term t JOIN page_term pt ON pt.term_id = t.id WHERE pt.page_id = ? AND t.kind = ? ORDER BY t.name LIMIT 1',
[$pageId, 'category'],
);
// Dieselbe Sortierung wie im Archiv, damit „vorheriges" dort dasselbe heißt wie hier.
$all = $this->published(1, '' === $termId ? null : $termId, 500, $locale)['posts'];
$all = $this->published(1, '' === $termId ? null : $termId, 500, $locale, $language)['posts'];
// present() gibt bewusst keine Kennung heraus - in eine Liste gehören Titel und Adresse.
// Verglichen wird deshalb über die Adresse, die innerhalb einer Website eindeutig ist.
$own = $this->settings->publicPath((string) $this->db->fetchOne('SELECT path FROM page WHERE id = ?', [$pageId]));
@@ -249,11 +278,11 @@ final class PostRepository
private ?bool $anyPublished = null;
public function termsFor(string $pageId): array
public function termsFor(string $pageId, ?string $language = null): array
{
$terms = ['category' => [], 'tag' => []];
foreach ($this->db->fetchAllAssociative('SELECT t.kind, t.slug, t.name FROM term t JOIN page_term pt ON pt.term_id = t.id WHERE pt.page_id = ? ORDER BY t.name', [$pageId]) as $row) {
$terms[$row['kind']][] = ['slug' => $row['slug'], 'name' => $row['name'], 'href' => '/'.self::ARCHIVE_PREFIX[$row['kind']].'/'.$row['slug']];
$terms[$row['kind']][] = ['slug' => $row['slug'], 'name' => $this->termName($row, $language), 'href' => '/'.self::ARCHIVE_PREFIX[$row['kind']].'/'.$row['slug']];
}
return $terms;
+76
View File
@@ -89,6 +89,13 @@ final class SiteSettings
'consentAccentInk' => '',
'privacyPath' => '',
'imprintPath' => '',
// Die weiteren Sprachen neben der Hauptsprache (ADR 111), gepflegt über Languages::save.
// Leer heißt: einsprachig - und dann ist nirgends etwas von Übersetzungen zu sehen.
'languages' => [],
// Namen von Kategorien und Schlagwörtern je weiterer Sprache: {en: {"category:projekte": "Projects"}}.
'termNames' => [],
// Überschrift und Text des Einwilligungshinweises je weiterer Sprache: {en: {title, text}}.
'consentTexts' => [],
];
/** Die Wochentage, wie schema.org sie nennt, mit deutscher Beschriftung für das Formular. */
@@ -191,6 +198,10 @@ final class SiteSettings
$values = self::DEFAULTS;
// Assigned separately, so saving company data cannot clear the error page.
$values['notFoundPageId'] = $this->load()['notFoundPageId'];
// Ebenso die Sprachen: sie haben einen eigenen Bildschirm mit eigener Prüfung.
$values['languages'] = $this->load()['languages'];
$values['termNames'] = $this->load()['termNames'];
$values['consentTexts'] = $this->load()['consentTexts'];
foreach (['legalName' => 200, 'description' => 300, 'phone' => 40, 'email' => 120, 'street' => 120, 'postalCode' => 16, 'city' => 120] as $key => $max) {
$values[$key] = mb_substr(trim((string) ($input[$key] ?? '')), 0, $max);
}
@@ -403,6 +414,71 @@ final class SiteSettings
$this->loaded = null;
}
/**
* Nur die Sprachen ändern, alles andere unberührt lassen. Geprüft wird in Languages::save.
*
* @param list<string> $codes
*/
public function saveLanguages(array $codes): void
{
$this->db->transactional(function () use ($codes): void {
$raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']);
$stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : [];
$stored = is_array($stored) ? $stored : [];
$stored['languages'] = array_values($codes);
$this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [
json_encode($stored, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), 'default',
]);
});
$this->loaded = null;
}
/**
* Die Texte der Einstellungen in weiteren Sprachen (ADR 111): Namen von Kategorien und
* Schlagwörtern sowie Überschrift und Text des Einwilligungshinweises. Alles andere bleibt.
*
* @param array<string, array<string, string>> $names Sprache => "art:adresse" => Name
* @param array<string, array<string, string>> $consent Sprache => {title, text}
*/
public function saveLanguageTexts(array $names, array $consent = []): void
{
$consentClean = [];
foreach ($consent as $locale => $texts) {
if (!is_string($locale) || !isset(Languages::CATALOG[$locale]) || !is_array($texts)) {
continue;
}
foreach (['title' => 80, 'text' => 600] as $key => $max) {
$text = mb_substr(trim((string) ($texts[$key] ?? '')), 0, $max);
if ('' !== $text) {
$consentClean[$locale][$key] = $text;
}
}
}
$clean = [];
foreach ($names as $locale => $entries) {
if (!is_string($locale) || !isset(Languages::CATALOG[$locale]) || !is_array($entries)) {
continue;
}
foreach ($entries as $key => $name) {
$name = mb_substr(trim((string) $name), 0, 120);
if ('' !== $name && 1 === preg_match('/^(category|tag):[a-z0-9-]{1,80}$/D', (string) $key)) {
$clean[$locale][(string) $key] = $name;
}
}
}
$this->db->transactional(function () use ($clean, $consentClean): void {
$raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']);
$stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : [];
$stored = is_array($stored) ? $stored : [];
$stored['termNames'] = $clean;
$stored['consentTexts'] = $consentClean;
$this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [
json_encode($stored, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), 'default',
]);
});
$this->loaded = null;
}
/**
* Which of the two mandatory pages are missing, by their settings key.
*
+275
View File
@@ -0,0 +1,275 @@
<?php
namespace App\Content;
/**
* Übersetzungen als Ebene über dem Blockbaum (ADR 111, Dokumentschema v27).
*
* Eine Seite wird einmal gebaut. Je weitere Sprache trägt ihr Dokument unter `translations`
* nur die Texte, die sich unterscheiden - adressiert über die stabile ID des Blocks:
*
* translations.en = {
* slug: "services", title: "…", description: "…", seoTitle: "…",
* blocks: { "blk_a1": { "text": "Our services" } },
* source: { "blk_a1.text": "1a2b3c4d" } // so sah der Text der Hauptsprache aus
* }
*
* Aufbau, Gestaltung, Bilder und Links bleiben dieselben. Ausgeliefert wird eine Sprache, indem
* die Texte auf eine Kopie des Dokuments gelegt werden (apply) - danach läuft dieselbe Prüfung
* und dieselbe Ausgabe wie für die Hauptsprache, ohne dass ein Renderer davon wissen muss. Ein
* Feld ohne Übersetzung zeigt den Text der Hauptsprache.
*
* Weil die Übersetzungen im Dokument stehen, gehören sie zu jeder Fassung: Entwurf,
* Veröffentlichung, Wiederherstellung und Undo im Editor gelten für alle Sprachen zugleich.
*/
final class Translations
{
public const SCHEMA = 27;
/**
* Welche Eigenschaften eines Blocks Text sind, den eine Übersetzung ersetzt - mit derselben
* Höchstlänge wie im DocumentValidator. Dieselbe Liste steht in translate.js.
*/
public const FIELDS = [
'heading' => ['text'],
'text' => ['text'],
'button' => ['text'],
'image' => ['alt', 'caption'],
'iconcard' => ['title', 'text'],
'stat' => ['value', 'label'],
'quote' => ['text', 'author', 'role'],
'cta' => ['title', 'text', 'buttonText'],
'contact' => ['address'],
'accordionitem' => ['title'],
'tabitem' => ['title'],
'legal' => ['label'],
'postnav' => ['previousLabel', 'nextLabel'],
'icon' => ['label'],
'progress' => ['label'],
'embed' => ['title', 'hint'],
];
/** Felder der Seite selbst und ihre Höchstlänge. */
public const PAGE_FIELDS = ['title' => 160, 'description' => 320, 'seoTitle' => 160];
/** Wie viele Einträge eine Sprache höchstens trägt - so viele, wie eine Seite Blöcke haben darf. */
public const MAX_BLOCKS = 300;
public static function supports(mixed $schemaVersion): bool
{
return is_int($schemaVersion) && $schemaVersion >= self::SCHEMA;
}
/** Die Sprachen, für die das Dokument überhaupt etwas übersetzt hat. @return list<string> */
public static function locales(array $document): array
{
$locales = [];
foreach (($document['translations'] ?? []) as $locale => $translation) {
if (is_string($locale) && is_array($translation) && self::hasContent($translation)) {
$locales[] = $locale;
}
}
return $locales;
}
/** Eine Übersetzung ohne Titel und ohne einen einzigen Text ist keine - nur ein Anfang. */
public static function hasContent(array $translation): bool
{
return '' !== trim((string) ($translation['title'] ?? '')) || [] !== array_filter($translation['blocks'] ?? [], static fn (mixed $fields): bool => is_array($fields) && [] !== $fields);
}
/** Das Dokument ohne Übersetzungen - so, wie die Hauptsprache es ausliefert. */
public static function strip(array $document): array
{
unset($document['translations']);
return $document;
}
/**
* Das Dokument in einer Sprache: die übersetzten Texte auf einer Kopie, die Übersetzungen
* selbst entfernt. Fehlt ein Text, bleibt der der Hauptsprache stehen.
*/
public static function apply(array $document, string $locale): array
{
$translation = $document['translations'][$locale] ?? [];
$document = self::strip($document);
if (!is_array($translation)) {
return $document;
}
foreach (['title', 'description'] as $field) {
if (is_string($translation[$field] ?? null) && '' !== trim($translation[$field])) {
$document[$field] = $translation[$field];
}
}
$blocks = is_array($translation['blocks'] ?? null) ? $translation['blocks'] : [];
if ([] !== $blocks) {
$document['blocks'] = self::overlay($document['blocks'] ?? [], $blocks);
}
return $document;
}
private static function overlay(array $list, array $translated): array
{
foreach ($list as $index => $block) {
if (!is_array($block)) {
continue;
}
$fields = $translated[$block['id'] ?? ''] ?? null;
$type = $block['type'] ?? '';
if (is_array($fields) && isset(self::FIELDS[$type])) {
foreach (self::FIELDS[$type] as $prop) {
$value = $fields[$prop] ?? null;
if (!is_string($value) || '' === trim($value) || !array_key_exists($prop, $block['props'] ?? [])) {
continue;
}
// Ein dekoratives Bild oder Symbol hat keinen Text - auch nicht übersetzt.
if (in_array($prop, ['alt', 'label'], true) && true === ($block['props']['decorative'] ?? false)) {
continue;
}
$list[$index]['props'][$prop] = $value;
}
}
if (is_array($block['children'] ?? null)) {
$list[$index]['children'] = self::overlay($block['children'], $translated);
}
}
return $list;
}
/**
* Räumt die Übersetzungen vor dem Speichern auf: formatierter Text geht durch denselben
* Filter wie in der Hauptsprache, leere Felder fallen weg, und Einträge für Blöcke, die es
* nicht mehr gibt, auch. Was danach noch ungültig ist, lehnt der DocumentValidator ab.
*/
public static function normalise(array $document, RichText $richText): array
{
if (!isset($document['translations']) || !is_array($document['translations'])) {
return $document;
}
$types = [];
$collect = static function (array $blocks) use (&$collect, &$types): void {
foreach ($blocks as $block) {
if (is_array($block) && is_string($block['id'] ?? null)) {
$types[$block['id']] = (string) ($block['type'] ?? '');
$collect(is_array($block['children'] ?? null) ? $block['children'] : []);
}
}
};
$collect(is_array($document['blocks'] ?? null) ? $document['blocks'] : []);
$rich = RichText::supports($document['schemaVersion'] ?? null);
$richHeadings = RichText::headings($document['schemaVersion'] ?? null);
foreach ($document['translations'] as $locale => $translation) {
if (!is_array($translation)) {
continue;
}
foreach (array_keys(self::PAGE_FIELDS) as $field) {
if (is_string($translation[$field] ?? null)) {
$translation[$field] = trim($translation[$field]);
if ('' === $translation[$field]) {
unset($translation[$field]);
}
}
}
if (is_string($translation['slug'] ?? null)) {
$translation['slug'] = strtolower(trim($translation['slug']));
if ('' === $translation['slug']) {
unset($translation['slug']);
}
}
$blocks = [];
foreach ((is_array($translation['blocks'] ?? null) ? $translation['blocks'] : []) as $id => $fields) {
$type = $types[$id] ?? null;
if (null === $type || !isset(self::FIELDS[$type]) || !is_array($fields)) {
continue;
}
foreach ($fields as $prop => $value) {
if (!in_array($prop, self::FIELDS[$type], true) || !is_string($value) || '' === trim($value)) {
continue;
}
if ('text' === $prop && (('text' === $type && $rich) || ('heading' === $type && $richHeadings))) {
$value = $richText->sanitise($value);
}
$blocks[$id][$prop] = $value;
}
}
$translation['blocks'] = $blocks;
if ([] === $blocks) {
unset($translation['blocks']);
}
// Nur Vermerke zu Texten, die es gibt; der Rest wäre ein Vermerk ohne Gegenstand.
$source = [];
foreach ((is_array($translation['source'] ?? null) ? $translation['source'] : []) as $key => $hash) {
[$id, $prop] = array_pad(explode('.', (string) $key, 2), 2, null);
$present = null === $prop ? isset($translation[$id]) : isset($blocks[$id][$prop]);
if ($present && is_string($hash)) {
$source[$key] = $hash;
}
}
$translation['source'] = $source;
if ([] === $source) {
unset($translation['source']);
}
$document['translations'][$locale] = $translation;
}
return $document;
}
/**
* Der Vermerk, wie ein Text der Hauptsprache beim Übersetzen aussah. FNV-1a, 32 Bit - kurz,
* und in PHP wie in JavaScript (translate.js) ohne Bibliothek gleich zu rechnen.
*/
public static function fingerprint(string $text): string
{
return hash('fnv1a32', $text);
}
/**
* Wie weit eine Sprache ist: übersetzbare Texte, davon übersetzt, davon seitdem in der
* Hauptsprache geändert.
*
* @return array{total: int, done: int, stale: int}
*/
public static function progress(array $document, string $locale): array
{
$translation = $document['translations'][$locale] ?? [];
$translation = is_array($translation) ? $translation : [];
$count = ['total' => 0, 'done' => 0, 'stale' => 0];
$tally = static function (string $key, string $original, mixed $value) use (&$count, $translation): void {
if ('' === trim($original)) {
return;
}
++$count['total'];
if (!is_string($value) || '' === trim($value)) {
return;
}
++$count['done'];
$hash = $translation['source'][$key] ?? null;
if (is_string($hash) && $hash !== self::fingerprint($original)) {
++$count['stale'];
}
};
foreach (['title', 'description'] as $field) {
$tally($field, (string) ($document[$field] ?? ''), $translation[$field] ?? null);
}
$walk = static function (array $blocks) use (&$walk, $tally, $translation): void {
foreach ($blocks as $block) {
foreach (self::FIELDS[$block['type'] ?? ''] ?? [] as $prop) {
if (in_array($prop, ['alt', 'label'], true) && true === ($block['props']['decorative'] ?? false)) {
continue;
}
$tally($block['id'].'.'.$prop, (string) ($block['props'][$prop] ?? ''), $translation['blocks'][$block['id']][$prop] ?? null);
}
$walk($block['children'] ?? []);
}
};
$walk($document['blocks'] ?? []);
return $count;
}
}
+9 -2
View File
@@ -33,7 +33,12 @@ final class ConsentController extends AbstractController
'none' => [],
default => array_map(static fn (mixed $group): string => (string) $group, (array) $request->request->all('groups')),
};
$response = $this->redirect($this->backTo($request));
// consent.js schickt dasselbe Formular im Hintergrund und kennzeichnet das mit dieser
// Kopfzeile: dann keine Weiterleitung, nur das Cookie - die Seite bleibt stehen und das
// Skript tauscht aus, was jetzt geladen werden darf (ADR 115). Ein fremdes Formular kann
// die Kopfzeile nicht setzen; ein fremdes Skript bräuchte dafür eine CORS-Freigabe.
$response = '1' === $request->headers->get('X-Kant-Consent') ? new Response(status: Response::HTTP_NO_CONTENT) : $this->redirect($this->backTo($request));
$response->headers->set('Cache-Control', 'no-store');
// Eine Entscheidung trifft nur der eigene Browser auf dieser Website. Ein fremdes Formular,
// das hierher postet, darf keine Einwilligung eintragen - die Route hat keine Sitzung und
// damit kein CSRF-Token, also entscheidet die Herkunft der Anfrage.
@@ -97,7 +102,9 @@ final class ConsentController extends AbstractController
$target = $request->request->getString('back');
// Nur ein Pfad dieser Website: `//fremd.example` ist protokollrelativ, und `/\fremd.example`
// lesen Browser genauso.
if ('' === $target || 1 !== preg_match('#^/(?![/\\\\])#', $target)) {
// Steuerzeichen und Leerraum ebenfalls nicht: Browser entfernen Tabulator und Zeilenumbruch
// aus Adressen, aus `/<Tab>/fremd.example` würde so wieder `//fremd.example`.
if ('' === $target || 1 !== preg_match('#^/(?![/\\\\])#', $target) || 1 === preg_match('/[\x00-\x20\x7F\\\\]/', $target)) {
return '/';
}
+69 -12
View File
@@ -7,15 +7,18 @@ use App\Content\DemoContent;
use App\Content\DocumentValidator;
use App\Content\FontLibrary;
use App\Content\FormRepository;
use App\Content\Languages;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\PartialRepository;
use App\Content\PostLayouts;
use App\Content\PostRepository;
use App\Content\RedirectRepository;
use App\Content\SiteSettings;
use App\Content\StyleCompiler;
use App\Content\StyleSchema;
use App\Content\Theme;
use App\Content\Translations;
use App\Domain\InvalidDocument;
use App\Domain\InvalidPage;
use App\Domain\StaleWrite;
@@ -31,7 +34,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
#[Route('/%kant.admin_path%')]
final class EditorController extends AbstractController
{
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private PartialRepository $partials, private CustomCode $code, private FontLibrary $fonts, private CsrfTokenManagerInterface $csrf, private RequestStack $requests, private PluginRegistry $plugins, private FormRepository $forms, private PostRepository $postsForPreview)
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private PartialRepository $partials, private CustomCode $code, private FontLibrary $fonts, private CsrfTokenManagerInterface $csrf, private RequestStack $requests, private PluginRegistry $plugins, private FormRepository $forms, private PostRepository $postsForPreview, private Languages $languages, private SiteSettings $siteSettings)
{
}
@@ -203,6 +206,9 @@ final class EditorController extends AbstractController
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
if ($blocked = $this->liveNeedsPublisher($id, false)) {
return $blocked;
}
$terms = static function (string $raw): array {
$names = array_filter(array_map(trim(...), explode(',', $raw)), static fn (string $n): bool => '' !== $n);
@@ -239,6 +245,9 @@ final class EditorController extends AbstractController
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
if ($blocked = $this->liveNeedsPublisher($id, false)) {
return $blocked;
}
// An empty field means "the standard area"; that is stored as null, not as an address.
$choice = static fn (string $value): ?string => '' === $value ? null : $value;
try {
@@ -262,6 +271,9 @@ final class EditorController extends AbstractController
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
if ($blocked = $this->liveNeedsPublisher($id, true)) {
return $blocked;
}
try {
$moved = $this->pages->movePage(
$id,
@@ -279,6 +291,22 @@ final class EditorController extends AbstractController
: sprintf('%d Adresse(n) geändert. Von den alten Adressen wird dauerhaft weitergeleitet.', $moved), 'success');
}
/**
* Ein Redakteur bearbeitet Entwürfe; was eine veröffentlichte Seite sofort verändert, darf
* nur, wer veröffentlichen darf (ADR 116): Adresse, Papierkorb, Kopf- und Fußbereich,
* Beitragsdaten. Bei Seiten, die nie veröffentlicht waren, bleibt alles wie bisher.
*/
private function liveNeedsPublisher(string $id, bool $subtree): ?Response
{
if ($this->isGranted('ROLE_PUBLISHER') || !$this->pages->isLive($id, $subtree)) {
return null;
}
return $this->back($subtree
? 'Diese Seite oder eine ihrer Unterseiten ist veröffentlicht. Adresse und Papierkorb ändert, wer veröffentlichen darf.'
: 'Diese Seite ist veröffentlicht. Diese Angaben wirken sofort öffentlich und ändert deshalb, wer veröffentlichen darf.');
}
#[Route('/pages/{id}/schedule', name: 'admin_schedule', methods: ['POST'])]
public function schedule(Request $request, string $id): Response
{
@@ -294,7 +322,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 beim ersten Aufruf der Website nach diesem Zeitpunkt – siehe „Aufgaben“.', 'success');
: 'Die Veröffentlichung ist geplant – mit dem Entwurf von jetzt. Spätere Änderungen gehen erst mit erneutem Planen oder Veröffentlichen online. Sie erfolgt beim ersten Aufruf der Website nach diesem Zeitpunkt – siehe „Aufgaben“.', 'success');
}
#[Route('/pages/{id}/restore', name: 'admin_restore_page', methods: ['POST'])]
@@ -354,6 +382,9 @@ final class EditorController extends AbstractController
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
if ($blocked = $this->liveNeedsPublisher($id, true)) {
return $blocked;
}
try {
$count = $this->pages->trashPage($id, $request->request->getInt('expectedVersion', -1));
} catch (InvalidPage|StaleWrite $e) {
@@ -440,8 +471,9 @@ final class EditorController extends AbstractController
if (!$this->isCsrfTokenValid('editor', $request->headers->get('X-CSRF-Token'))) {
return $this->json(['error' => 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'], 403);
}
if (strlen($request->getContent()) > 270000) {
return $this->json(['error' => 'Die Seite ist zu groß (maximal 256 KiB Inhalt).'], 413);
// Eine Seite darf 256 KiB haben, mit ihren Übersetzungen zusammen 1 MiB (ADR 111).
if (strlen($request->getContent()) > 1100000) {
return $this->json(['error' => 'Die Seite ist zu groß (maximal 256 KiB Inhalt, mit Übersetzungen 1 MiB).'], 413);
}
$page = $this->page($id);
try {
@@ -456,20 +488,30 @@ final class EditorController extends AbstractController
throw new \InvalidArgumentException();
}
$this->validator->validate($document, writing: true);
// Die Vorschau einer Übersetzung ist dieselbe Seite mit den übersetzten Texten -
// genau so, wie die Website sie in dieser Sprache ausliefert.
$language = $body['language'] ?? null;
$locale = is_string($language) && $this->languages->isExtra($language) ? $language : $this->languages->main();
$seoTitle = $locale === $this->languages->main() ? ($page['seo_title'] ?? null) : ($document['translations'][$locale]['seoTitle'] ?? '');
$document = $locale === $this->languages->main() ? Translations::strip($document) : Translations::apply($document, $locale);
$request->attributes->set('kant_alternates', array_map(fn (string $code): array => [...$this->languages->describe($code), 'locale' => $code, 'href' => '#', 'current' => $code === $locale], $this->languages->all()));
$request->attributes->set('kant_locale', $locale);
$localise = fn (array $doc): array => $locale === $this->languages->main() ? Translations::strip($doc) : Translations::apply($doc, $locale);
$theme = $this->theme->load();
$areas = [];
foreach (['header', 'footer'] as $kind) {
$area = $this->partials->findByKind($kind);
if (null !== $area) {
$areas[$kind] = $area['document'];
$areas[$kind] = $localise($area['document']);
}
}
$sections = [];
foreach ($this->partials->referencedSlugs($document) as $slug) {
$section = $this->partials->resolveSection($slug);
if (null !== $section) {
$sections[$slug] = $section['document'];
$sections[$slug] = $localise($section['document']);
}
}
// Wie auf der Website: die zweite Verknüpfung derselben Sektion bekommt eigene Kennungen.
@@ -485,7 +527,7 @@ final class EditorController extends AbstractController
}
$navigation = [];
foreach ($this->menus->all() as $slot => $menu) {
$navigation[$slot] = $this->menus->resolve($menu['items'], (string) $page['path']);
$navigation[$slot] = $this->menus->resolve($menu['items'], (string) $page['path'], language: $locale === $this->languages->main() ? null : $locale);
}
// Die Vorschau zeigt dieselbe Ausgabe wie die Website - einschliesslich eigenem
@@ -496,14 +538,14 @@ final class EditorController extends AbstractController
foreach ($documents as $doc) {
$styles .= $this->styles->compileDocument($doc).$this->code->compileDocument($doc);
}
$forms = $this->previewForms($documents, $page);
$forms = $this->previewForms($documents, $page, $locale === $this->languages->main() ? null : $locale);
foreach ($forms as $state) {
if (null !== $state['form']) {
$styles .= $this->forms->css($state['form'], $theme);
}
}
return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '', 'editing' => true, 'navigation' => $navigation, 'areas' => $areas, 'sections' => $sections, 'needsTabs' => false, 'forms' => $forms, 'pluginStyles' => $this->plugins->assetsFor($documents)['styles'], 'pageStyles' => $styles, 'localFonts' => $this->fonts->hasLocal($theme) ? $this->fonts->preload($theme) : []]);
return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document, 'locale' => $locale, 'seo_title' => $seoTitle], 'canonical' => '', 'editing' => true, 'navigation' => $navigation, 'areas' => $areas, 'sections' => $sections, 'needsTabs' => false, 'forms' => $forms, 'pluginStyles' => $this->plugins->assetsFor($documents)['styles'], 'pageStyles' => $styles, 'localFonts' => $this->fonts->hasLocal($theme) ? $this->fonts->preload($theme) : []]);
}
if (!isset($body['expectedVersion']) || !is_int($body['expectedVersion']) || $body['expectedVersion'] < 0) {
throw new \InvalidArgumentException();
@@ -536,6 +578,9 @@ final class EditorController extends AbstractController
return $this->json(['error' => 'Inzwischen wurde ein neuerer Stand gespeichert. Deine Änderungen bleiben im Editor. Lade sie als JSON herunter, bevor du den aktuellen Serverstand lädst.', 'conflict' => true], 409);
} catch (InvalidDocument $e) {
return $this->json(['error' => 'Bitte prüfe die Inhalte: '.$e->getMessage()], 422);
} catch (InvalidPage $e) {
// Etwa eine übersetzte Adresse, die eine andere Seite schon hat.
return $this->json(['error' => $e->getMessage()], 422);
} catch (\JsonException|\InvalidArgumentException) {
return $this->json(['error' => 'Die Anfrage enthält kein gültiges Seitendokument oder keine gültige Version.'], 422);
}
@@ -549,13 +594,16 @@ final class EditorController extends AbstractController
*
* @return array<string, array>
*/
private function previewForms(array $documents, array $page): array
private function previewForms(array $documents, array $page, ?string $language = null): array
{
$states = [];
$walk = function (array $blocks) use (&$walk, &$states, $page): void {
$walk = function (array $blocks) use (&$walk, &$states, $page, $language): void {
foreach ($blocks as $block) {
if ('form' === ($block['type'] ?? null) && '' !== ($block['props']['form'] ?? '')) {
$form = $this->forms->findBySlug((string) $block['props']['form']);
if (null !== $form && null !== $language) {
$form = FormRepository::localise($form, $language);
}
$states[$block['id']] = ['form' => $form, 'sent' => false, 'errors' => [], 'values' => [], 'optionImages' => [], 'blockId' => $block['id'], 'page' => $page, 'issued' => 0, 'issuedSignature' => '', 'turnstileKey' => ''];
}
$walk($block['children'] ?? []);
@@ -575,6 +623,15 @@ final class EditorController extends AbstractController
private function payload(array $page): array
{
return ['document' => $page['document'], 'version' => $page['version'], 'revisionId' => $page['draft_revision_id'], 'publishedRevisionId' => $page['published_revision_id'], 'csrfToken' => $this->csrf->getToken('editor')->getValue(), 'plugins' => $this->plugins->forEditor(), 'presets' => $this->plugins->presetsForEditor()];
return ['document' => $page['document'], 'version' => $page['version'], 'revisionId' => $page['draft_revision_id'], 'publishedRevisionId' => $page['published_revision_id'], 'csrfToken' => $this->csrf->getToken('editor')->getValue(), 'plugins' => $this->plugins->forEditor(), 'presets' => $this->plugins->presetsForEditor(),
// Für den Sprachumschalter (ADR 111). Ohne weitere Sprache leer - dann gibt es ihn nicht.
'languages' => [
'main' => $this->languages->describe($this->languages->main()),
'extra' => array_map(fn (string $code): array => $this->languages->describe($code), $this->languages->extra()),
'slug' => $page['slug'],
'home' => $page['path'] === $this->siteSettings->homePath(),
'published' => [] === $this->languages->extra() ? [] : $this->pages->translatedPaths($page['id']),
],
];
}
}
+51 -7
View File
@@ -4,6 +4,7 @@ namespace App\Controller;
use App\Content\ButtonDesign;
use App\Content\FormRepository;
use App\Content\Languages;
use App\Content\MediaLibrary;
use App\Content\Theme;
use App\Domain\InvalidForm;
@@ -15,25 +16,49 @@ use Symfony\Component\Routing\Attribute\Route;
final class FormAdminController extends AbstractController
{
public function __construct(private FormRepository $forms, private Theme $theme, private MediaLibrary $media)
public function __construct(private FormRepository $forms, private Theme $theme, private MediaLibrary $media, private Languages $languages)
{
}
/**
* Die Übersicht: jedes Formular eine Zeile, dazu die Einsendungen. Bearbeitet wird ein
* Formular auf seiner eigenen Seite - mit mehreren Formularen war die eine lange Seite mit
* allen Editoren und Vorschauen nicht mehr zu überblicken (Umut, 09.10.2026).
*/
#[Route('/%kant.admin_path%/forms', name: 'admin_forms', methods: ['GET'])]
public function index(Request $request): Response
{
// Empfaenger, Felder und die Einsendungen selbst: nichts davon gehoert in die Hand einer
// Rolle, die nicht einmal eine Seite veroeffentlichen darf.
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
$theme = $this->theme->load();
return $this->render('admin/forms.html.twig', [
'forms' => $this->forms->all(),
'counts' => $this->forms->submissionCounts(),
'submissions' => $this->forms->submissions($request->query->getInt('seite', 1)),
'languages' => array_map(fn (string $code): array => $this->languages->describe($code), $this->languages->extra()),
]);
}
/** Ein Formular bearbeiten, oder mit der Adresse „neu“ eines anlegen. */
#[Route('/%kant.admin_path%/forms/{slug}', name: 'admin_form_edit', requirements: ['slug' => '[a-z0-9-]{1,80}|neu'], methods: ['GET'])]
public function edit(string $slug): Response
{
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
$current = 'neu' === $slug ? null : $this->forms->findBySlug($slug);
if ('neu' !== $slug && null === $current) {
throw $this->createNotFoundException('Dieses Formular gibt es nicht.');
}
$theme = $this->theme->load();
return $this->render('admin/forms.html.twig', [
'editing' => true,
'current' => $current,
'types' => FormRepository::TYPES,
'colors' => FormRepository::COLORS,
// Die Bilder fuer eine Bildauswahl: die Mediathek als Liste, damit je Option gewaehlt statt getippt wird.
'images' => array_map(static fn (array $item): array => ['id' => $item['id'], 'title' => $item['title'], 'src' => $item['variants'][0]['src'] ?? ''], $this->media->page(1, false, 96)['items']),
'submissions' => $this->forms->submissions($request->query->getInt('seite', 1)),
'languages' => array_map(fn (string $code): array => $this->languages->describe($code), $this->languages->extra()),
// Die Vorschau zeichnet den Absendeknopf mit dem Standardbutton der Website und
// kennt die Palette, damit eine Farbe gewaehlt statt getippt wird.
'schema' => ['colors' => $theme['colors'], 'fonts' => $theme['fonts'], 'buttons' => $theme['buttons'] ?? ButtonDesign::defaults($theme['base']), 'base' => $theme['base']],
@@ -48,10 +73,27 @@ final class FormAdminController extends AbstractController
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
$fields = [];
// Je weitere Sprache: Felder tr_<sprache>_label, _help, _unit, _options am Feld, dazu
// success_<sprache> und buttonLabel_<sprache> am Formular (ADR 111). Die Optionen stehen
// als Kommaliste in derselben Reihenfolge wie die der Hauptsprache.
$translations = [];
foreach ($this->languages->extra() as $code) {
$translations[$code] = ['success' => $request->request->getString('success_'.$code), 'buttonLabel' => $request->request->getString('buttonLabel_'.$code), 'fields' => []];
}
foreach ($request->request->all('fields') as $row) {
if (!is_array($row) || '' === trim((string) ($row['label'] ?? ''))) {
continue;
}
$originals = array_map(trim(...), explode(',', (string) ($row['options'] ?? '')));
foreach (array_keys($translations) as $code) {
$translatedOptions = array_map(trim(...), explode(',', (string) ($row['tr_'.$code.'_options'] ?? '')));
$translations[$code]['fields'][(string) ($row['name'] ?? '')] = [
'label' => (string) ($row['tr_'.$code.'_label'] ?? ''),
'help' => (string) ($row['tr_'.$code.'_help'] ?? ''),
'unit' => (string) ($row['tr_'.$code.'_unit'] ?? ''),
'options' => array_combine($originals, array_pad(array_slice($translatedOptions, 0, count($originals)), count($originals), '')),
];
}
$fields[] = [
'name' => (string) ($row['name'] ?? ''),
'type' => (string) ($row['type'] ?? 'text'),
@@ -82,21 +124,23 @@ final class FormAdminController extends AbstractController
'replyToField' => $request->request->getString('replyToField'),
'buttonLabel' => $request->request->getString('buttonLabel'),
'summary' => '' !== $request->request->getString('summary'),
'translations' => $translations,
...array_map(fn (string $field): string => $request->request->getString($field), array_combine(array_keys(FormRepository::COLORS), array_keys(FormRepository::COLORS))),
],
$request->request->getInt('expectedVersion', -1),
);
} catch (InvalidForm|StaleWrite $e) {
return $this->back($e->getMessage());
return $this->back($e->getMessage(), 'error', $slug);
}
return $this->back('Das Formular wurde gespeichert.', 'success');
return $this->back('Das Formular wurde gespeichert.', 'success', 'neu' === $slug ? $request->request->getString('slug') : $slug);
}
private function back(string $message, string $type = 'error'): Response
private function back(string $message, string $type = 'error', ?string $slug = null): Response
{
$this->addFlash($type, $message);
return $this->redirectToRoute('admin_forms');
// Zurück zum bearbeiteten Formular; ohne Adresse (abgelaufene Sitzung) zur Übersicht.
return null === $slug ? $this->redirectToRoute('admin_forms') : $this->redirectToRoute('admin_form_edit', ['slug' => $slug]);
}
}
+32 -20
View File
@@ -3,15 +3,15 @@
namespace App\Controller;
use App\Content\FormRepository;
use App\Content\Languages;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\SiteSettings;
use App\Content\Turnstile;
use App\Ops\MailCheck;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\Routing\Attribute\Route;
@@ -28,10 +28,11 @@ final class FormController extends AbstractController
private PageRepository $pages,
private MenuRepository $menus,
private SiteSettings $settings,
private MailerInterface $mailer,
private MailCheck $mail,
private Turnstile $turnstile,
private string $secret,
private string $siteOrigin,
private Languages $languages,
) {
}
@@ -44,22 +45,36 @@ final class FormController extends AbstractController
if (null === $form || null === $page) {
throw $this->createNotFoundException('Formular oder Seite nicht gefunden.');
}
$senderHash = hash_hmac('sha256', (string) $request->getClientIp(), $this->secret);
$senderHash = FormRepository::senderKey($request->getClientIp(), $this->secret);
// Three quiet checks before the real validation. A caught bot is answered like a success
// so it learns nothing, but nothing is stored and nothing is sent.
$silentlyDropped = '' !== trim($request->request->getString('website'))
|| !$this->timingIsPlausible($request)
|| !$this->timingIsPlausible($request, $slug)
|| $this->forms->rateLimited($senderHash)
// Die vierte, freiwillige Pruefung. Sie antwortet wie die drei anderen still mit
// Erfolg: ein Absender, der erfaehrt, woran er gescheitert ist, versucht es gezielt
// noch einmal.
|| !$this->turnstile->passed($request->request->getString('cf-turnstile-response'), $request->getClientIp());
$checked = $this->forms->check($form, $request->request->all());
// Abgeschickt von einer übersetzten Seite (ADR 111): Meldungen in dieser Sprache, und der
// Weg zurück führt auf die übersetzte Seite - sofern es sie gibt.
$language = $request->request->getString('language');
$translatedPath = null;
if ($this->languages->isExtra($language)) {
$translatedPath = $this->pages->translatedPaths($page['id'])[$language] ?? null;
}
$language = null === $translatedPath ? null : $language;
$checked = $this->forms->check($form, $request->request->all(), $language);
if (!$silentlyDropped && [] !== $checked['errors']) {
// Re-render the page the form sits on, with the entered values and the messages.
return $this->renderPage($request, $page, $form, $checked);
return $this->renderPage($request, $page, $form, $checked, null === $language ? null : ($page['path'] === $this->settings->homePath() ? $language : $language.'/'.$translatedPath));
}
// Kommt gerade für dieses Formular oder insgesamt zu viel an, wird nichts mehr gespeichert -
// aber sichtbar, nicht still: ein echter Besucher soll wissen, dass er anrufen muss.
if (!$silentlyDropped && $this->forms->flooded($form['id'])) {
return $this->renderPage($request, $page, $form, ['errors' => ['_form' => 'Gerade gehen sehr viele Anfragen ein. Bitte versuchen Sie es später erneut oder melden Sie sich telefonisch.'], 'values' => $checked['values']], null === $language ? null : ($page['path'] === $this->settings->homePath() ? $language : $language.'/'.$translatedPath));
}
if (!$silentlyDropped) {
$submission = $this->forms->store($form['id'], $checked['values'], $checked['consent'], $senderHash);
@@ -69,14 +84,16 @@ final class FormController extends AbstractController
}
// Post/Redirect/Get so a reload does not send the message twice.
return $this->redirect($this->settings->publicPath($path).'?gesendet='.rawurlencode($slug).'#'.rawurlencode($request->request->getString('block')), 303);
$back = null === $language ? $this->settings->publicPath($path) : '/'.$language.($page['path'] === $this->settings->homePath() ? '' : '/'.$translatedPath);
return $this->redirect($back.'?gesendet='.rawurlencode($slug).'#'.rawurlencode($request->request->getString('block')), 303);
}
/** The signed timestamp makes the age of the form checkable without storing anything. */
private function timingIsPlausible(Request $request): bool
private function timingIsPlausible(Request $request, string $slug): bool
{
$issued = $request->request->getInt('issued');
if (!hash_equals(hash_hmac('sha256', (string) $issued, $this->secret), $request->request->getString('issuedSignature'))) {
if (!hash_equals(FormRepository::stamp($slug, $issued, $this->secret), $request->request->getString('issuedSignature'))) {
return false;
}
$age = time() - $issued;
@@ -109,15 +126,10 @@ final class FormController extends AbstractController
if ('' !== $replyTo && '' !== ($values[$replyTo] ?? '')) {
$email->replyTo($values[$replyTo]);
}
try {
$this->mailer->send($email);
return true;
} catch (TransportExceptionInterface) {
// The submission is already stored, so a broken mail setup loses no enquiry.
// It stays marked as undelivered and is visible in the administration.
return false;
}
// The submission is already stored, so a broken mail setup loses no enquiry. It stays
// marked as undelivered, and var/log/mail.log says why (System -> E-Mail-Versand).
return $this->mail->send($email, 'Formular '.$form['slug'])['ok'];
}
/**
@@ -128,7 +140,7 @@ final class FormController extends AbstractController
* vergass, sah eine Seite ohne Gestaltung. Jetzt geht der Weg durch dieselbe Stelle wie jeder
* andere Aufruf, und die Formularzustaende werden als Anfrageattribut mitgereicht.
*/
private function renderPage(Request $request, array $page, array $form, array $checked): Response
private function renderPage(Request $request, array $page, array $form, array $checked, ?string $translatedAddress = null): Response
{
// Nur das abgeschickte Formular zeigt Meldungen und behaelt seine Eingaben - wo auch immer
// es auf der Seite steht: im Inhalt, im Kopf, im Fuss oder in einer verknuepften Sektion.
@@ -137,7 +149,7 @@ final class FormController extends AbstractController
// Die Startseite antwortet unter "/" - mit ihrem eigenen Pfad wuerde sie dorthin umleiten,
// und aus der Fehlermeldung wuerde eine Weiterleitung ohne Meldung.
$adresse = $page['path'] === $this->settings->homePath() ? '' : (string) $page['path'];
$adresse = $translatedAddress ?? ($page['path'] === $this->settings->homePath() ? '' : (string) $page['path']);
$antwort = $this->forward('App\Controller\PageController::page', [
'path' => $adresse,
'request' => $request->duplicate(null, null, ['kant_form_state' => $zustaende, '_route' => 'page']),
+68 -8
View File
@@ -5,6 +5,8 @@ namespace App\Controller;
use App\Domain\InvalidPage;
use App\Ops\Installation;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -20,6 +22,10 @@ use Symfony\Component\Routing\Attribute\Route;
*
* After the first account exists, every route in this file answers 404 - not because a file was
* deleted, but because the database says there is an installation.
*
* Vorher öffnet sich kein Schritt ohne den Installationsschlüssel (ADR 116): Er steht auf der
* Abschlussseite von install.php bzw. in var/install.key. Einmal eingegeben, bleibt im Browser
* ein Cookie mit einem Nachweis darüber, nur für /install.
*/
final class InstallController extends AbstractController
{
@@ -27,10 +33,46 @@ final class InstallController extends AbstractController
{
}
#[Route('/install', name: 'install', methods: ['GET'], priority: 10)]
public function check(): Response
/** Der Name des Cookies, das den Installationsschlüssel nachweist. */
public const KEY_COOKIE = 'kant_install';
#[Route('/install/schluessel', name: 'install_key', methods: ['GET', 'POST'], priority: 11)]
public function key(Request $request): Response
{
$this->guard();
if ($this->installation->isInstalled()) {
throw new NotFoundHttpException('Diese Installation ist abgeschlossen.');
}
$error = null;
try {
$this->installation->installKey();
$given = $request->isMethod('POST') ? $request->request->getString('schluessel') : $request->query->getString('schluessel');
if ('' !== $given) {
if ($this->installation->acceptsKey($given)) {
// Weiter ohne den Schlüssel in der Adresse: er soll weder im Verlauf noch in
// einem Serverprotokoll als Verweis stehen bleiben.
$response = $this->redirectToRoute('install');
$response->headers->setCookie(Cookie::create(self::KEY_COOKIE, $this->installation->keyProof(), 0, '/install', null, $request->isSecure(), true, false, Cookie::SAMESITE_STRICT));
return $response;
}
$error = 'Dieser Schlüssel passt nicht.';
}
} catch (\RuntimeException $e) {
$error = $e->getMessage();
}
$response = $this->render('install/key.html.twig', ['step' => 1, 'error' => $error, 'keyFile' => Installation::KEY_FILE]);
$response->headers->set('Referrer-Policy', 'no-referrer');
return $response;
}
#[Route('/install', name: 'install', methods: ['GET'], priority: 10)]
public function check(Request $request): Response
{
if ($blocked = $this->guard($request)) {
return $blocked;
}
$checks = $this->installation->checks();
return $this->render('install/check.html.twig', [
@@ -43,7 +85,9 @@ final class InstallController extends AbstractController
#[Route('/install/datenbank', name: 'install_database', methods: ['GET', 'POST'], priority: 10)]
public function database(Request $request): Response
{
$this->guard();
if ($blocked = $this->guard($request)) {
return $blocked;
}
$error = null;
$driver = $request->request->getString('driver', 'sqlite');
if ($request->isMethod('POST') && $this->signed($request, 'datenbank')) {
@@ -89,7 +133,9 @@ final class InstallController extends AbstractController
#[Route('/install/migration', name: 'install_migration', methods: ['GET', 'POST'], priority: 10)]
public function migration(Request $request): Response
{
$this->guard();
if ($blocked = $this->guard($request)) {
return $blocked;
}
if (!$this->installation->environmentWritten()) {
return $this->redirectToRoute('install_database');
}
@@ -118,7 +164,9 @@ final class InstallController extends AbstractController
#[Route('/install/website', name: 'install_website', methods: ['GET', 'POST'], priority: 10)]
public function website(Request $request): Response
{
$this->guard();
if ($blocked = $this->guard($request)) {
return $blocked;
}
if (!$this->installation->schemaReady()) {
return $this->redirectToRoute('install_migration');
}
@@ -150,7 +198,9 @@ final class InstallController extends AbstractController
#[Route('/install/konto', name: 'install_account', methods: ['GET', 'POST'], priority: 10)]
public function account(Request $request): Response
{
$this->guard();
if ($blocked = $this->guard($request)) {
return $blocked;
}
if (!$this->installation->siteReady()) {
return $this->redirectToRoute('install_website');
}
@@ -192,11 +242,21 @@ final class InstallController extends AbstractController
}
}
private function guard(): void
/** 404 nach der Installation; davor ohne Schlüsselnachweis zurück zur Eingabe des Schlüssels. */
private function guard(Request $request): ?Response
{
if ($this->installation->isInstalled()) {
throw new NotFoundHttpException('Diese Installation ist abgeschlossen.');
}
try {
if ($this->installation->acceptsProof($request->cookies->getString(self::KEY_COOKIE))) {
return null;
}
} catch (\RuntimeException) {
// Kein Schlüssel lesbar oder schreibbar: die Eingabeseite sagt, woran es liegt.
}
return new RedirectResponse($this->generateUrl('install_key'));
}
private function token(string $step): array
+201
View File
@@ -0,0 +1,201 @@
<?php
namespace App\Controller;
use App\Ops\Environment;
use App\Ops\MailCheck;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
use Symfony\Component\Routing\Attribute\Route;
/**
* E-Mail-Versand (ADR 121, 122): eigene Seite unter „System“, damit die Systemseite nicht alles
* trägt. Weg wählen, ausprobieren, alle Wege des Servers testen, speichern - und die letzten
* Versuche mit Ursache.
*/
final class MailController extends AbstractController
{
public function __construct(private MailCheck $mail, private string $projectDir, private string $siteOrigin)
{
}
#[Route('/%kant.admin_path%/system/e-mail', name: 'admin_mail', methods: ['GET'])]
public function index(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$session = $request->getSession();
return $this->render('admin/mail.html.twig', [
'mail' => $this->mailFacts(),
'mailResult' => $session->remove('mail_result'),
'mailResults' => $session->remove('mail_results'),
'mailNotice' => $session->remove('mail_notice'),
// Die letzte Eingabe bleibt stehen, ohne Passwort.
'mailForm' => $session->remove('mail_form'),
]);
}
/**
* Versand einstellen und ausprobieren (ADR 121, 122): ein Weg testen, alle Wege des Servers
* nacheinander testen, oder den gewählten Weg als MAILER_DSN in .env speichern.
*
* Das SMTP-Passwort steht nie im Formular. Bleibt das Feld leer, gilt das gespeicherte, solange
* Server und Benutzer gleich bleiben.
*/
#[Route('/%kant.admin_path%/system/e-mail', name: 'admin_mail_save', methods: ['POST'])]
public function save(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$session = $request->getSession();
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
return $this->backToMail($request, 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
$eingabe = [
'weg' => $request->request->getString('weg'),
'server' => trim($request->request->getString('server')),
'port' => $request->request->getInt('port', 587),
'benutzer' => trim($request->request->getString('benutzer')),
'an' => trim($request->request->getString('to')),
'von' => trim($request->request->getString('from')),
];
$session->set('mail_form', $eingabe);
$aktion = $request->request->getString('aktion');
if ('alle' === $aktion) {
if (null !== $fehler = $this->mailAdressFehler($eingabe)) {
return $this->backToMail($request, $fehler);
}
$wege = array_map(static fn (array $weg): string => $weg['dsn'], MailCheck::serverWege());
if ('' !== $eingabe['server']) {
try {
$wege['smtp'] = $this->smtpDsn($request, $eingabe);
} catch (\InvalidArgumentException) {
// Unvollständiges SMTP bleibt bei „alle“ einfach draußen.
}
}
$ergebnisse = [];
foreach ($wege as $schluessel => $dsn) {
$ergebnisse[] = ['schluessel' => $schluessel] + $this->mail->send($this->testEmail($request, $eingabe), 'Testmail (alle Wege)', $dsn);
}
$session->set('mail_results', $ergebnisse);
return $this->backToMail($request);
}
try {
$dsn = isset(MailCheck::serverWege()[$eingabe['weg']]) ? MailCheck::serverWege()[$eingabe['weg']]['dsn'] : $this->smtpDsn($request, $eingabe);
} catch (\InvalidArgumentException $fehler) {
return $this->backToMail($request, $fehler->getMessage());
}
if ('speichern' === $aktion) {
if ('.env' !== $this->environmentSource('MAILER_DSN')) {
return $this->backToMail($request, 'MAILER_DSN ist in der Server-Umgebung gesetzt und hat dort Vorrang. Ändern lässt sie sich nur beim Hoster.');
}
try {
Environment::write($this->projectDir, ['MAILER_DSN' => $dsn]);
} catch (\RuntimeException $fehler) {
return $this->backToMail($request, $fehler->getMessage());
}
$weg = $this->mail->route($dsn);
return $this->backToMail($request, sprintf('Gespeichert: %s%s. Formulare versenden ab jetzt auf diesem Weg.', $weg['art'], '' !== $weg['ziel'] ? ' über '.$weg['ziel'] : ''), 'ok');
}
if (null !== $fehler = $this->mailAdressFehler($eingabe)) {
return $this->backToMail($request, $fehler);
}
$session->set('mail_result', ['an' => $eingabe['an']] + $this->mail->send($this->testEmail($request, $eingabe), 'Testmail', $dsn));
return $this->backToMail($request);
}
/** „Letzte Versuche“ leeren: var/log/mail.log und mail.log.1. */
#[Route('/%kant.admin_path%/system/e-mail/protokoll', name: 'admin_mail_clear', methods: ['POST'])]
public function clear(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
return $this->backToMail($request, 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
$this->mail->clearLog();
return $this->backToMail($request, 'Das Protokoll ist leer.', 'ok');
}
/** @param array{an: string, von: string} $eingabe */
private function mailAdressFehler(array $eingabe): ?string
{
foreach ([$eingabe['an'], $eingabe['von'] ?: $eingabe['an']] as $adresse) {
if (false === filter_var($adresse, FILTER_VALIDATE_EMAIL)) {
return '' === $adresse ? 'Für eine Testmail bitte eine Empfängeradresse eintragen.' : sprintf('„%s“ ist keine gültige E-Mail-Adresse.', mb_substr($adresse, 0, 100));
}
}
return null;
}
/** @param array{server: string, port: int, benutzer: string} $eingabe */
private function smtpDsn(Request $request, array $eingabe): string
{
if ('' === $eingabe['server']) {
throw new \InvalidArgumentException('Für SMTP bitte den Server eintragen, etwa smtp.ionos.de.');
}
$passwort = $request->request->getString('passwort');
if ('' === $passwort) {
$passwort = $this->mail->gespeichertesPasswort($eingabe['server'], $eingabe['benutzer']);
}
if ('' !== $eingabe['benutzer'] && '' === $passwort) {
throw new \InvalidArgumentException('Für SMTP bitte das Passwort des Postfachs eintragen.');
}
return MailCheck::smtpDsn($eingabe['server'], $eingabe['port'], $eingabe['benutzer'], $passwort);
}
/** @param array{an: string, von: string} $eingabe */
private function testEmail(Request $request, array $eingabe): Email
{
$website = rtrim($this->siteOrigin, '/') ?: $request->getSchemeAndHttpHost();
return (new Email())
->from(new Address($eingabe['von'] ?: $eingabe['an']))
->to(new Address($eingabe['an']))
->subject('Testmail von '.(parse_url($website, PHP_URL_HOST) ?: $website))
->text("Diese Testmail kommt aus der Verwaltung von {$website} (System → E-Mail-Versand).\n\nWenn sie angekommen ist, funktioniert der Versand der Formulare auf diesem Weg.\n");
}
private function backToMail(Request $request, ?string $meldung = null, string $art = 'error'): Response
{
if (null !== $meldung) {
$request->getSession()->set('mail_notice', ['art' => $art, 'text' => $meldung]);
}
return $this->redirect($this->generateUrl('admin_mail'));
}
/** @return array<string, mixed> */
private function mailFacts(): array
{
$route = $this->mail->route();
return $route + [
'einstellung' => $this->mail->einstellung(),
'wege' => MailCheck::serverWege(),
'quelle' => $this->environmentSource('MAILER_DSN'),
// Wer sich mit einer Adresse anmeldet, versendet meist auch nur unter ihr.
'absender' => false !== filter_var($route['benutzer'], FILTER_VALIDATE_EMAIL) ? $route['benutzer'] : '',
'protokoll' => $this->mail->recent(),
];
}
/** Explicit server values take precedence over the single installation file. */
private function environmentSource(string $name): string
{
$loaded = explode(',', $_SERVER['SYMFONY_DOTENV_VARS'] ?? $_ENV['SYMFONY_DOTENV_VARS'] ?? '');
return in_array($name, $loaded, true) ? '.env' : 'Server-Umgebung';
}
}
+12 -1
View File
@@ -3,6 +3,7 @@
namespace App\Controller;
use App\Content\ButtonDesign;
use App\Content\Languages;
use App\Content\MenuButton;
use App\Content\MenuRepository;
use App\Content\PageRepository;
@@ -17,7 +18,7 @@ use Symfony\Component\Routing\Attribute\Route;
final class MenuController extends AbstractController
{
public function __construct(private MenuRepository $menus, private PageRepository $pages, private Theme $theme)
public function __construct(private MenuRepository $menus, private PageRepository $pages, private Theme $theme, private Languages $languages)
{
}
@@ -85,6 +86,7 @@ final class MenuController extends AbstractController
'slots' => MenuRepository::SLOTS, 'slot' => $slot, 'slotName' => MenuRepository::SLOTS[$slot],
'menu' => $menu, 'rows' => $rows,
'menuIcons' => \App\Content\DocumentValidator::ICONS,
'languages' => array_map(fn (string $code): array => $this->languages->describe($code), $this->languages->extra()),
// Fuer die Button-Vorschau und die Farbwahl am Eintrag: dieselben Daten wie im Editor.
'buttonSchema' => ['buttons' => $theme['buttons'], 'colors' => $theme['colors'], 'fonts' => $theme['fonts'], 'buttonOptions' => ButtonDesign::OPTIONS, 'mobile' => MenuButton::MOBILE],
]);
@@ -130,6 +132,7 @@ final class MenuController extends AbstractController
'newTab' => $item['newTab'] ?? false,
'icon' => $item['icon'] ?? '', 'iconPosition' => $item['iconPosition'] ?? 'before',
'button' => $item['button'] ?? null,
'labels' => $item['labels'] ?? [],
];
array_push($rows, ...$this->flatten($item['children'] ?? [], $level + 1));
}
@@ -160,6 +163,11 @@ final class MenuController extends AbstractController
'level' => max(1, min(MenuRepository::MAX_DEPTH, (int) ($row['level'] ?? 1))),
'position' => (int) ($row['position'] ?? 0),
'button' => MenuButton::fromForm($row),
// Je weitere Sprache ein Feld label_<sprache>; leer heißt: wie in der Hauptsprache.
'labels' => array_filter(
array_combine($this->languages->extra(), array_map(static fn (string $code): string => mb_substr(trim((string) ($row['label_'.$code] ?? '')), 0, 80), $this->languages->extra())) ?: [],
static fn (string $text): bool => '' !== $text,
),
];
}
usort($flat, static fn (array $a, array $b): int => $a['position'] <=> $b['position']);
@@ -177,6 +185,9 @@ final class MenuController extends AbstractController
if (null !== $row['button']) {
$item['button'] = $row['button'];
}
if ([] !== $row['labels']) {
$item['labels'] = $row['labels'];
}
if ('url' === $row['type']) {
$item['url'] = $row['url'];
} else {
+104
View File
@@ -0,0 +1,104 @@
<?php
namespace App\Controller;
use App\Content\FontLibrary;
use App\Content\Theme;
use App\Domain\InvalidPage;
use App\Ops\SitePackage;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Routing\Attribute\Route;
/**
* Inhaltspakete exportieren und importieren (ADR 118) - unter System, nur für Administratoren.
*
* Der Import ersetzt die Inhalte einer laufenden Website. Deshalb: CSRF, ein ausdrückliches
* Häkchen, vorher eine Sicherung, während des Imports Wartung. Konten werden nur übernommen,
* wenn man das eigens wählt - sonst bleiben Benutzer und Passwörter dieser Website.
*/
final class PackageController extends AbstractController
{
public function __construct(private SitePackage $packages, private FontLibrary $fonts, private Theme $theme)
{
}
#[Route('/%kant.admin_path%/system/paket/export', name: 'admin_package_export', methods: ['POST'])]
public function export(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
@set_time_limit(0);
try {
$file = $this->packages->export($request->request->getBoolean('benutzer'));
} catch (InvalidPage $e) {
return $this->back($e->getMessage());
}
$response = new BinaryFileResponse($file, headers: ['Content-Type' => 'application/zip', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'no-store']);
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, basename($file));
// Ein Export ist zum Mitnehmen da, nicht zum Liegenlassen: er enthält alle Inhalte.
$response->deleteFileAfterSend();
return $response;
}
#[Route('/%kant.admin_path%/system/paket/import', name: 'admin_package_import', methods: ['POST'])]
public function import(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
if (!$request->request->getBoolean('bestaetigt')) {
return $this->back('Bitte bestätige, dass die Inhalte dieser Website ersetzt werden.');
}
$upload = $request->files->get('paket');
$waiting = basename($request->request->getString('wartend'));
$temporary = null;
if ($upload instanceof UploadedFile) {
if (!$upload->isValid()) {
return $this->back(\in_array($upload->getError(), [\UPLOAD_ERR_INI_SIZE, \UPLOAD_ERR_FORM_SIZE], true)
? 'Das Paket ist größer, als das Hosting für einen Upload erlaubt. Bitte per FTP nach var/pakete/ legen – dann erscheint es hier zur Auswahl.'
: 'Das Hochladen ist gescheitert. Bitte erneut versuchen oder das Paket per FTP nach var/pakete/ legen.');
}
$temporary = $this->packages->directory().'/hochgeladen-'.bin2hex(random_bytes(6)).'.zip';
@mkdir($this->packages->directory(), 0o775, true);
$upload->move(\dirname($temporary), basename($temporary));
$file = $temporary;
} elseif ('' !== $waiting && str_ends_with($waiting, '.zip') && is_file($this->packages->directory().'/'.$waiting)) {
$file = $this->packages->directory().'/'.$waiting;
} else {
return $this->back('Bitte ein Paket hochladen oder eines aus var/pakete/ wählen.');
}
@set_time_limit(0);
try {
$result = $this->packages->import($file, $request->request->getBoolean('benutzer'));
} finally {
if (null !== $temporary) {
@unlink($temporary);
}
}
// Das Ergebnis direkt, nicht über eine Weiterleitung: wurden die Konten ersetzt, gilt die
// Anmeldung ab der nächsten Anfrage nicht mehr.
// Schriften liegen nicht im Paket, sondern auf dem Server dieser Website (public/assets/fonts).
// Fehlt eine, die die neue Gestaltung verlangt, sagt die Ergebnisseite, wo man sie holt.
$missing = $result['ok'] ? $this->fonts->missing($this->theme->load()) : [];
return $this->render('admin/package-result.html.twig', ['result' => $result, 'fontsMissing' => $missing]);
}
private function back(string $message): Response
{
$this->addFlash('error', $message);
return $this->redirectToRoute('admin_system');
}
}
+310 -36
View File
@@ -7,6 +7,7 @@ use App\Content\CustomCode;
use App\Content\DocumentValidator;
use App\Content\FontLibrary;
use App\Content\FormRepository;
use App\Content\Languages;
use App\Content\MediaLibrary;
use App\Content\MenuRepository;
use App\Content\NotFoundPage;
@@ -18,6 +19,7 @@ use App\Content\SiteSettings;
use App\Content\StyleBundle;
use App\Content\StyleCompiler;
use App\Content\Theme;
use App\Content\Translations;
use App\Content\Turnstile;
use App\Plugin\PluginRegistry;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -27,21 +29,40 @@ use Symfony\Component\Routing\Attribute\Route;
final class PageController extends AbstractController
{
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private MediaLibrary $media, private FontLibrary $fonts, private StyleBundle $bundle, private Turnstile $turnstile, private string $secret, private string $siteOrigin, private NotFoundPage $notFoundPage, private PluginRegistry $plugins)
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private MediaLibrary $media, private FontLibrary $fonts, private StyleBundle $bundle, private Turnstile $turnstile, private string $secret, private string $siteOrigin, private NotFoundPage $notFoundPage, private PluginRegistry $plugins, private Languages $languages)
{
}
/**
* Die Sprache dieser Anfrage, wenn es nicht die Hauptsprache ist: Kennung und die Zuordnung
* Pfad der Hauptsprache => übersetzter Pfad (ADR 111). Null für die Hauptsprache.
*
* @var array{locale: string, map: array<string, string>}|null
*/
private ?array $translation = null;
/** Dieselbe Groessenangabe, die `blocks/block.html.twig` einem Bild ohne feste Breite gibt. */
private const IMAGE_SIZES = '(max-width: 650px) 100vw, (max-width: 1024px) 50vw, 800px';
// A segment is a slug; up to four of them form the public path. The leading lookahead keeps
// the CMS routes (/admin, /media, /assets) out of the catch-all, which matches before them.
private const PATH = '(?!admin(?:/|$)|media/|assets/|kategorie/|schlagwort/|beitraege(?:/|$)|einwilligung$|feed\.xml$|sitemap\.xml$|robots\.txt$|llms\.txt$)[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*){0,3}';
private const PATH = '(?!admin(?:/|$)|media/|assets/|kategorie/|schlagwort/|beitraege(?:/|$)|einwilligung$|feed\.xml$|sitemap\.xml$|robots\.txt$|llms\.txt$)[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*){0,4}';
#[Route('/', name: 'home', methods: ['GET'])]
#[Route('/{path}', name: 'page', requirements: ['path' => self::PATH], methods: ['GET'])]
public function page(Request $request, string $path = ''): Response
{
$this->translation = null;
// Eine weitere Sprache steht vorn in der Adresse: /en ist ihre Startseite, /en/services
// eine übersetzte Seite. Ohne weitere Sprache gibt es diesen Weg gar nicht.
$segments = explode('/', $path, 2);
if ('' !== $path && $this->languages->isExtra($segments[0])) {
return $this->translatedPage($request, $segments[0], $segments[1] ?? '');
}
// Mehr als vier Ebenen gibt es nur mit einer Sprache davor.
if (substr_count($path, '/') >= PageRepository::MAX_DEPTH) {
return $this->notFound($request);
}
$home = $this->settings->homePath();
$path = '' === $path ? $home : $path;
// One canonical address for the root page: its own path redirects to "/".
@@ -71,6 +92,134 @@ final class PageController extends AbstractController
return $this->renderPage($request, $page, $path);
}
/**
* Eine Seite in einer weiteren Sprache. Gefunden wird sie über ihre übersetzte Adresse; ihre
* Startseite antwortet unter /en und nicht zusätzlich unter /en/home.
*/
private function translatedPage(Request $request, string $locale, string $rest): Response
{
$map = $this->pages->translationMap($locale);
// Schon hier: auch eine 404 unter /en/ antwortet auf Englisch.
$this->translation = ['locale' => $locale, 'map' => $map];
$homeTranslated = $map[$this->settings->homePath()] ?? null;
if ('' !== $rest && $rest === $homeTranslated && null === $request->attributes->get('kant_form_state')) {
return $this->shell($this->redirect('/'.$locale, 301));
}
// Die Archive gibt es in jeder Sprache unter derselben Adresse mit vorangestellter Sprache.
if (1 === preg_match('#^(beitraege|kategorie/([a-z0-9-]{1,80})|schlagwort/([a-z0-9-]{1,80}))$#D', $rest, $archiv)) {
$this->translation = ['locale' => $locale, 'map' => $map];
return $this->renderArchive($request, str_starts_with($rest, 'kategorie/') ? 'category' : 'tag', '' !== ($archiv[2] ?? '') ? $archiv[2] : (($archiv[3] ?? '') ?: null));
}
$target = '' === $rest ? $homeTranslated : $rest;
$page = null === $target ? null : $this->pages->findPublishedTranslation($target, $locale);
if (null === $page) {
$redirect = '' === $rest ? null : $this->redirects->find($locale.'/'.$rest);
if (null !== $redirect && 301 === $redirect['status']) {
return $this->shell($this->redirect('/'.$redirect['to'], 301));
}
return $this->notFound($request, null !== $redirect ? 410 : 404);
}
if ($page['id'] === $this->settings->load()['notFoundPageId']) {
return $this->notFound($request);
}
return $this->renderPage($request, $page, $page['path']);
}
/**
* Kopf, Fuß und Sektionen in der Sprache dieser Anfrage: übersetzt, wo es Übersetzungen gibt,
* und ohne die Übersetzungen selbst in der Hauptsprache.
*/
private function localise(array $document): array
{
return null === $this->translation ? Translations::strip($document) : Translations::apply($document, $this->translation['locale']);
}
/**
* Die öffentliche Adresse eines Pfades der Hauptsprache in der Sprache dieser Anfrage - oder
* unverändert, wenn die Seite dort nicht übersetzt ist.
*/
private function localHref(string $href): string
{
if (null === $this->translation || !str_starts_with($href, '/') || str_starts_with($href, '//')) {
return $href;
}
$suffix = '';
if (false !== ($cut = strcspn($href, '?#')) && $cut < strlen($href)) {
$suffix = substr($href, $cut);
$href = substr($href, 0, $cut);
}
$path = trim($href, '/');
$home = $this->settings->homePath();
$locale = $this->translation['locale'];
if ('' === $path || $path === $home) {
return isset($this->translation['map'][$home]) ? '/'.$locale.$suffix : $href.$suffix;
}
if ('beitraege' === $path || str_starts_with($path, 'kategorie/') || str_starts_with($path, 'schlagwort/')) {
return '/'.$locale.'/'.$path.$suffix;
}
return isset($this->translation['map'][$path]) ? '/'.$locale.'/'.$this->translation['map'][$path].$suffix : $href.$suffix;
}
/**
* Auf einer übersetzten Seite zeigen Links auf Seiten, die es in dieser Sprache gibt, auf
* deren übersetzte Fassung - in Buttons, Menüs, Fließtext und Brotkrumen gleichermaßen.
* Ein Link auf eine nicht übersetzte Seite bleibt, wie er ist. Ausgenommen sind die Links
* der Sprachwahl, die mit hreflang bewusst auf eine bestimmte Sprache zeigen.
*/
private function localiseLinks(string $html): string
{
if (null === $this->translation) {
return $html;
}
return preg_replace_callback('/<a\b[^>]*>/i', function (array $tag): string {
if (str_contains($tag[0], 'hreflang=')) {
return $tag[0];
}
return preg_replace_callback('/\bhref="(\/[^"]*)"/', fn (array $href): string => 'href="'.$this->localHref($href[1]).'"', $tag[0]) ?? $tag[0];
}, $html) ?? $html;
}
/**
* Unter welchen Adressen diese Seite in welcher Sprache steht - für hreflang und die
* Sprachwahl. Leer, solange die Website nur eine Sprache hat.
*
* @return list<array{locale: string, native: string, href: string, url: string, current: bool}>
*/
private function alternates(array $page, int $status): array
{
$extra = $this->languages->extra();
if ([] === $extra || $status >= 400 || !isset($page['id']) || 'archive' === ($page['type'] ?? null)) {
return [];
}
$origin = rtrim($this->siteOrigin, '/');
$home = $this->settings->homePath();
$current = $this->translation['locale'] ?? $this->languages->main();
$main = $this->languages->main();
$list = [['locale' => $main, 'href' => $this->settings->publicPath($page['path'], $home)]];
$translated = $this->pages->translatedPaths($page['id']);
foreach ($extra as $locale) {
if (isset($translated[$locale])) {
$list[] = ['locale' => $locale, 'href' => $page['path'] === $home ? '/'.$locale : '/'.$locale.'/'.$translated[$locale]];
}
}
if (count($list) < 2) {
return [];
}
return array_map(fn (array $entry): array => [
...$entry,
'native' => $this->languages->describe($entry['locale'])['native'],
'url' => $origin.$entry['href'],
'current' => $entry['locale'] === $current,
], $list);
}
/**
* Eine eigene Adresse eines Plugins: die Vorlage bekommt die Einstellungen des Plugins und die
* Abfrageparameter, sonst nichts. Dieselben Schutzkopfzeilen wie jede Seite, kein Skript.
@@ -94,7 +243,45 @@ final class PageController extends AbstractController
public function notFound(Request $request, int $status = 404): Response
{
return $this->renderPage($request, $this->notFoundPage->page($status), '', $status);
$page = $this->notFoundPage->page($status);
$locale = $this->translation['locale'] ?? null;
// Die eigene 404-Seite ist eine Seite wie jede andere und wird im Editor übersetzt; die
// mitgelieferte bekommt ihre Texte aus dem Wörterbuch der festen Texte (ADR 111).
if (null !== $locale && in_array($locale, Translations::locales($page['document']), true)) {
$page['document'] = Translations::apply($page['document'], $locale);
} else {
$page['document'] = Translations::strip($page['document']);
if (null !== $locale && str_starts_with((string) $page['id'], 'error-')) {
$page['document'] = $this->translateFixedTexts($page['document'], $locale);
}
}
if (null !== $locale) {
$page['locale'] = $locale;
}
return $this->renderPage($request, $page, '', $status);
}
/** Titel, Beschreibung und Texte eines mitgelieferten Dokuments über das Wörterbuch. */
private function translateFixedTexts(array $document, string $locale): array
{
$t = static fn (string $text): string => \App\Twig\LanguageExtension::UI[$locale][$text] ?? \App\Twig\LanguageExtension::UI['en'][$text] ?? $text;
foreach (['title', 'description'] as $field) {
$document[$field] = $t((string) ($document[$field] ?? ''));
}
$walk = function (array $blocks) use (&$walk, $t): array {
foreach ($blocks as $index => $block) {
if (is_string($block['props']['text'] ?? null)) {
$blocks[$index]['props']['text'] = $t($block['props']['text']);
}
$blocks[$index]['children'] = $walk($block['children'] ?? []);
}
return $blocks;
};
$document['blocks'] = $walk($document['blocks'] ?? []);
return $document;
}
/** Published pages and error content share theme, areas, block rendering and CSP. */
@@ -102,10 +289,12 @@ final class PageController extends AbstractController
{
$styles = '';
// The page decides which header and footer it gets - including none.
$areas = $this->areas($status >= 400 ? null : $page);
$areas = array_map($this->localise(...), $this->areas($status >= 400 ? null : $page));
$theme = $this->theme->load();
// Theme first, block styles after, so a block always wins over the site defaults.
$sections = $this->sections($page['document'], $areas);
$sections = array_map($this->localise(...), $this->sections($page['document'], $areas));
// Die festen Texte der Vorlagen folgen der Sprache der Seite (LanguageExtension).
$request->attributes->set('kant_locale', $this->translation['locale'] ?? $this->languages->main());
// Dieselbe Sektion zweimal auf der Seite: ab der zweiten Verknüpfung eine Kopie mit eigenen
// Kennungen, in Renderreihenfolge Kopf, Inhalt, Fuß.
$pristine = $sections;
@@ -137,7 +326,14 @@ final class PageController extends AbstractController
// Only the header changes with the scroll position; a scrolled value anywhere in it asks
// for the small script that marks the page as scrolled.
// Den Nach-oben-Knopf blendet dasselbe Skript ein; ohne es bleibt er dauerhaft sichtbar.
$needsScrolled = (isset($areas['header']) && $this->hasScrolled($areas['header'])) || (bool) ($this->settings->load()['scrollTop'] ?? false);
// Dasselbe Skript schließt das aufgeklappte Menü nach einer Wahl (ADR 119) - gebraucht nur,
// wenn ein aufklappbares Menü auf ein Sprungziel dieser Seite zeigt: Ein Link auf eine
// andere Seite lädt neu (die Seiten senden no-store, also stellt „Zurück“ kein offenes
// Menü aus dem Seiten-Cache her). So bleibt eine Seite ohne solche Sprünge ohne Skript.
$navigation = $this->navigation($path);
$here = array_unique([rtrim($request->getPathInfo(), '/') ?: '/', $this->settings->publicPath($path, $this->settings->homePath())]);
$needsScrolled = (isset($areas['header']) && $this->hasScrolled($areas['header'])) || (bool) ($this->settings->load()['scrollTop'] ?? false)
|| [] !== array_filter($this->collapsibleMenuSlots($documents), fn (string $slot): bool => $this->jumpsWithin($navigation[$slot] ?? [], $here));
$styles = $this->theme->css($theme).$this->menus->buttonCss($theme['buttons']).$this->scrollTopStyles($this->settings->load(), $theme).$this->consent->styles($theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']);
foreach ([...array_values($areas), ...array_values($sections)] as $extra) {
$styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra);
@@ -176,20 +372,31 @@ final class PageController extends AbstractController
break;
}
}
$adresse = $status >= 400 ? null : rtrim($this->siteOrigin, '/').$this->settings->publicPath($path, $this->settings->homePath());
$adresse = $status >= 400 ? null : rtrim($this->siteOrigin, '/').$this->localHref($this->settings->publicPath($path, $this->settings->homePath()));
$alternates = $this->alternates($page, $status);
// Die Sprachwahl ist ein Baustein und kann im Kopf, im Fuß oder im Inhalt stehen; die
// Vorlage holt die Liste von hier, statt sie durch jede Einbindung zu reichen.
$request->attributes->set('kant_alternates', $alternates);
$canonical = null === $adresse ? null : $adresse.(null !== $blaettern && $blaettern['page'] > 1 ? '?seite='.$blaettern['page'] : '');
// Steht eine Einwilligung aus - Hinweis offen oder ein eingebetteter Inhalt wartet -, kommt
// consent.js dazu: Es speichert die Entscheidung ohne Neuladen. Ohne Skript bleibt das
// Formular mit Weiterleitung.
$embeds = $this->embedState($request, $page['document'], ...array_values($areas), ...array_values($sections));
$needsConsent = $embeds['blocked'] || $this->consent->needsBanner($request);
$response = $this->render('page.html.twig', [
'needsConsent' => $needsConsent,
'page' => $page,
'canonical' => $canonical,
'alternates' => $alternates,
'pagination' => null === $blaettern || null === $adresse ? null : ['page' => $blaettern['page'], 'pages' => $blaettern['pages'], 'base' => $adresse],
'navigation' => $this->navigation($path),
'navigation' => $navigation,
'postLists' => $postLists,
// Ohne Beiträge gibt es keinen Feed, auf den der Kopf verweisen könnte.
'hasPosts' => $this->posts->anyPublished(),
'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id']) : null,
'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id'], $this->translation['locale'] ?? null) : null,
// Die Nachbarn kosten eine Abfrage; sie fällt nur an, wenn die Seite den
// Baustein wirklich enthält.
'neighbours' => 'post' === $page['type'] && $this->hasType($page['document'], 'postnav') ? $this->posts->neighbours($page['id']) : [],
'neighbours' => 'post' === $page['type'] && $this->hasType($page['document'], 'postnav') ? $this->posts->neighbours($page['id'], language: $this->translation['locale'] ?? null) : [],
'needsTabs' => $needsTabs,
'needsFormScript' => $needsFormScript,
'pluginStyles' => $pluginAssets['styles'],
@@ -235,8 +442,8 @@ final class PageController extends AbstractController
// Die Pruefung braucht ein Skript von Cloudflare und einen Rahmen dafuer. Beides wird nur
// dort erlaubt, wo tatsaechlich ein Formular steht - nicht auf jeder Seite der Website.
$mitTurnstile = $this->turnstile->isActive() && (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'form'));
$wantsScript = ($mitTurnstile || $needsTabs || $needsFormScript || [] !== $pluginAssets['scripts'] || [] !== $areaScripts || $needsSlider || $needsMotion || $needsScrolled || $needsZoom || $needsDocument || [] !== $this->code->scriptsOf($page['document']));
$frames = $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections));
$wantsScript = ($mitTurnstile || $needsTabs || $needsFormScript || [] !== $pluginAssets['scripts'] || [] !== $areaScripts || $needsSlider || $needsMotion || $needsScrolled || $needsZoom || $needsDocument || $needsConsent || [] !== $this->code->scriptsOf($page['document']));
$frames = $embeds['hosts'];
if ($mitTurnstile) {
$frames[] = 'challenges.cloudflare.com';
}
@@ -244,6 +451,9 @@ final class PageController extends AbstractController
if ($status >= 400) {
$response->headers->set('X-Robots-Tag', 'noindex, follow');
}
if (null !== $this->translation) {
$response->setContent($this->localiseLinks((string) $response->getContent()));
}
return $this->shell($response, $wantsScript, $styles, $frames, $needsDocument);
}
@@ -256,9 +466,9 @@ final class PageController extends AbstractController
* The generated stylesheet is inline for speed, so it is allowed by its own hash rather than
* by 'unsafe-inline' – any other inline style, injected or not, stays blocked.
*
* @param list<string> $frameHosts Hosts of embeds that are actually rendered on this response.
* Nothing is added to the policy for an embed still waiting
* for consent.
* @param list<string> $frameHosts Hosts of the embeds on this page, also of those still
* waiting for consent (ADR 115: consent.js inserts them
* without a reload, and a policy cannot change afterwards)
* @param bool $frameSelf whether the page may frame its own PDFs (the viewer)
*/
private function shell(Response $response, bool $allowScript = false, string $styles = '', array $frameHosts = [], bool $frameSelf = false): Response
@@ -317,6 +527,10 @@ final class PageController extends AbstractController
if ('form' === ($block['type'] ?? null)) {
$slug = $block['props']['form'] ?? '';
$form = $this->forms->findBySlug($slug);
// In einer weiteren Sprache mit übersetzten Beschriftungen (ADR 111).
if (null !== $form && null !== $this->translation) {
$form = FormRepository::localise($form, $this->translation['locale']);
}
$states[$block['id']] = [
'form' => $form,
// Die Bilder einer Bildauswahl, einmal fuer alle Optionen aller Felder geholt.
@@ -325,7 +539,7 @@ final class PageController extends AbstractController
'errors' => [], 'values' => [],
'blockId' => $block['id'], 'page' => $page,
'issued' => $issued,
'issuedSignature' => hash_hmac('sha256', (string) $issued, $this->secret),
'issuedSignature' => FormRepository::stamp($slug, $issued, $this->secret),
// Leer, solange die Pruefung aus ist - dann steht auch kein Widget da.
'turnstileKey' => $this->turnstile->isActive() ? $this->turnstile->siteKey() : '',
];
@@ -420,9 +634,12 @@ final class PageController extends AbstractController
$prefix = '';
foreach (explode('/', (string) $page['path']) as $segment) {
$prefix = '' === $prefix ? $segment : $prefix.'/'.$segment;
$ancestor = $this->pages->findPublished($prefix);
// In einer weiteren Sprache heißt jede Stufe so, wie sie dort heißt - sofern es sie
// dort gibt. Sonst bleibt sie in der Hauptsprache stehen, mit ihrer Adresse.
$translated = null === $this->translation || !isset($this->translation['map'][$prefix]) ? null : $this->pages->findPublishedTranslation($this->translation['map'][$prefix], $this->translation['locale']);
$ancestor = $translated ?? $this->pages->findPublished($prefix);
if (null !== $ancestor) {
$trail[] = ['name' => $ancestor['document']['title'] ?? $prefix, 'href' => '/'.$prefix];
$trail[] = ['name' => $ancestor['document']['title'] ?? $prefix, 'href' => $this->localHref('/'.$prefix)];
}
}
@@ -505,7 +722,7 @@ final class PageController extends AbstractController
'@context' => 'https://schema.org',
'@type' => 'Article',
'headline' => mb_substr((string) ($page['document']['title'] ?? ''), 0, 110),
'mainEntityOfPage' => $origin.'/'.$page['path'],
'mainEntityOfPage' => $origin.$this->localHref('/'.$page['path']),
];
foreach ([['description', $page['excerpt'] ?? $page['document']['description'] ?? ''], ['datePublished', $page['published_at'] ?? '']] as [$key, $value]) {
if ('' !== (string) $value && null !== $value) {
@@ -564,6 +781,50 @@ final class PageController extends AbstractController
}
/** Whether any block carries values for the scrolled page (see StyleSchema::scrollable). */
/**
* Die Menüplätze (header, footer …) der Menüs, die als Knopf aufklappen: Burger oder waagerecht
* mit Zusammenklappen.
*
* @return list<string>
*/
private function collapsibleMenuSlots(array $documents): array
{
$slots = [];
$walk = function (array $blocks) use (&$walk, &$slots): void {
foreach ($blocks as $block) {
if ('menu' === ($block['type'] ?? null)) {
$layout = $block['props']['layout'] ?? 'horizontal';
if ('burger' === $layout || ('horizontal' === $layout && 'never' !== ($block['props']['collapse'] ?? 'tablet'))) {
$slots[] = (string) ($block['props']['slot'] ?? 'header');
}
}
$walk($block['children'] ?? []);
}
};
foreach ($documents as $document) {
$walk($document['blocks'] ?? []);
}
return array_values(array_unique($slots));
}
/** Ob ein Link des Menüs zu einem Sprungziel auf dieser Seite führt (#kontakt, /#kontakt auf der Startseite). @param list<string> $here */
private function jumpsWithin(array $links, array $here): bool
{
foreach ($links as $link) {
$href = (string) ($link['href'] ?? '');
$hash = strpos($href, '#');
if (false !== $hash && (0 === $hash || \in_array(rtrim(substr($href, 0, $hash), '/') ?: '/', $here, true))) {
return true;
}
if ($this->jumpsWithin($link['children'] ?? [], $here)) {
return true;
}
}
return false;
}
private function hasScrolled(array $document): bool
{
$walk = function (array $blocks) use (&$walk): bool {
@@ -618,23 +879,32 @@ final class PageController extends AbstractController
#[Route('/schlagwort/{slug}', name: 'tag', requirements: ['slug' => '[a-z0-9-]{1,80}'], methods: ['GET'])]
public function archive(Request $request, ?string $slug = null): Response
{
$kind = 'category' === $request->attributes->get('_route') ? 'category' : 'tag';
$this->translation = null;
return $this->renderArchive($request, 'category' === $request->attributes->get('_route') ? 'category' : 'tag', $slug);
}
private function renderArchive(Request $request, string $kind, ?string $slug): Response
{
$language = $this->translation['locale'] ?? null;
$t = static fn (string $text): string => null === $language ? $text : (\App\Twig\LanguageExtension::UI[$language][$text] ?? \App\Twig\LanguageExtension::UI['en'][$text] ?? $text);
$term = null;
if (null !== $slug) {
$term = $this->posts->findTerm($kind, $slug);
if (null === $term) {
return $this->notFound($request);
}
$term['name'] = $this->posts->termName($term, $language);
}
$page = max(1, $request->query->getInt('seite', 1));
$result = $this->posts->published($page, $term['id'] ?? null);
$result = $this->posts->published($page, $term['id'] ?? null, language: $language);
// Ein Archiv ohne Beitrag ist kein Inhalt, sondern eine Baustelle mit eigener Adresse -
// egal ob die Website noch gar keinen Blog hat oder ob diese eine Kategorie leer ist.
if (0 === $result['total']) {
return $this->notFound($request);
}
$base = null === $term ? '/beitraege' : '/'.PostRepository::ARCHIVE_PREFIX[$kind].'/'.$term['slug'];
$heading = null === $term ? 'Beiträge' : $term['name'];
$heading = null === $term ? $t('Beiträge') : $term['name'];
// Das Archiv ist eine Seite wie jede andere: ein Dokument aus Bausteinen, das durch dieselbe
// Aufbaukette läuft - Kopf und Fuß mit ihren Stilen, verknüpfte Sektionen, Schriften,
@@ -642,15 +912,15 @@ final class PageController extends AbstractController
// eigene Vorlage, die von alledem nur die Palette kannte.
$kopf = [
['id' => 'archiv-titel', 'type' => 'heading', 'props' => ['text' => $heading, 'level' => 1]],
['id' => 'archiv-zahl', 'type' => 'text', 'props' => ['text' => sprintf('%d %s', $result['total'], 1 === $result['total'] ? 'Beitrag' : 'Beiträge')]],
['id' => 'archiv-zahl', 'type' => 'text', 'props' => ['text' => sprintf('%d %s', $result['total'], 1 === $result['total'] ? $t('Beitrag') : $t('Beiträge'))]],
];
if (null !== $term) {
array_unshift($kopf, ['id' => 'archiv-art', 'type' => 'text', 'props' => ['text' => 'category' === $kind ? 'Kategorie' : 'Schlagwort'], 'style' => ['desktop' => ['fontSize' => '13px', 'fontWeight' => '700', 'letterSpacing' => '0.08em', 'textTransform' => 'uppercase', 'margin' => ['bottom' => '8px']]]]);
array_unshift($kopf, ['id' => 'archiv-art', 'type' => 'text', 'props' => ['text' => 'category' === $kind ? $t('Kategorie') : $t('Schlagwort')], 'style' => ['desktop' => ['fontSize' => '13px', 'fontWeight' => '700', 'letterSpacing' => '0.08em', 'textTransform' => 'uppercase', 'margin' => ['bottom' => '8px']]]]);
}
$document = [
'schemaVersion' => DocumentValidator::SCHEMA_VERSION,
'title' => $heading,
'description' => null === $term ? 'Alle Beiträge, die neuesten zuerst.' : sprintf('Beiträge %s „%s“.', 'category' === $kind ? 'in der Kategorie' : 'zum Schlagwort', $term['name']),
'description' => null === $term ? $t('Alle Beiträge, die neuesten zuerst.') : sprintf($t('category' === $kind ? 'Beiträge in der Kategorie „%s“.' : 'Beiträge zum Schlagwort „%s“.'), $term['name']),
'blocks' => [
['id' => 'archiv-kopf', 'type' => 'section', 'layout' => ['tone' => 'mint', 'width' => 'normal'], 'children' => $kopf],
['id' => 'archiv', 'type' => 'section', 'layout' => ['tone' => 'paper', 'width' => 'normal'], 'children' => [
@@ -659,7 +929,7 @@ final class PageController extends AbstractController
],
];
$seite = [
'id' => 'archiv-'.(null === $term ? 'beitraege' : $kind.'-'.$term['slug']), 'type' => 'archive', 'path' => ltrim($base, '/'), 'locale' => 'de',
'id' => 'archiv-'.(null === $term ? 'beitraege' : $kind.'-'.$term['slug']), 'type' => 'archive', 'path' => ltrim($base, '/'), 'locale' => $language ?? $this->languages->main(),
'website_name' => '', 'seo_title' => '', 'robots' => 'index', 'published_at' => null, 'author' => null, 'document' => $document,
];
@@ -705,7 +975,7 @@ final class PageController extends AbstractController
// immer die erste - sonst würden zwei Listen auf einer Seite einander
// umblättern.
$paginate = (bool) ($props['paginate'] ?? false);
$result = $this->posts->published($paginate ? $page : 1, $term['id'] ?? null, $props['count']);
$result = $this->posts->published($paginate ? $page : 1, $term['id'] ?? null, $props['count'], language: $this->translation['locale'] ?? null);
$lists[$block['id']] = $paginate ? $result : $result['posts'];
}
$walk($block['children'] ?? []);
@@ -866,23 +1136,27 @@ final class PageController extends AbstractController
}
/**
* The hosts of embeds that may load on this request.
* The hosts of all embeds on this page, and whether one of them still waits for consent.
*
* An embed whose group is not granted contributes nothing: its host must not appear in the
* policy, because the frame is not rendered either.
* Seit ADR 115 steht der Host auch dann in frame-src, wenn die Gruppe noch nicht erlaubt ist:
* Die Richtlinie einer Seite steht beim Laden fest, und consent.js setzt den Rahmen nach der
* Einwilligung ohne Neuladen ein. Die Erlaubnis lädt nichts; die Adresse des Inhalts steht
* weiterhin erst nach der Einwilligung im HTML.
*
* @return list<string>
* @return array{hosts: list<string>, blocked: bool}
*/
private function frameHosts(Request $request, array ...$documents): array
private function embedState(Request $request, array ...$documents): array
{
$hosts = [];
$walk = function (array $blocks) use (&$walk, &$hosts, $request): void {
$blocked = false;
$walk = function (array $blocks) use (&$walk, &$hosts, &$blocked, $request): void {
foreach ($blocks as $block) {
if ('embed' === ($block['type'] ?? null) && $this->consent->granted($request, $block['props']['group'] ?? '')) {
if ('embed' === ($block['type'] ?? null) && '' !== ($block['props']['url'] ?? '')) {
$host = parse_url($block['props']['url'] ?? '', PHP_URL_HOST);
if (is_string($host)) {
$hosts[$host] = true;
}
$blocked = $blocked || !$this->consent->granted($request, $block['props']['group'] ?? '');
}
$walk($block['children'] ?? []);
}
@@ -891,7 +1165,7 @@ final class PageController extends AbstractController
$walk($document['blocks'] ?? []);
}
return array_keys($hosts);
return ['hosts' => array_keys($hosts), 'blocked' => $blocked];
}
/**
@@ -986,7 +1260,7 @@ final class PageController extends AbstractController
{
$links = [];
foreach ($this->menus->all() as $slot => $menu) {
$links[$slot] = $this->menus->resolve($menu['items'], $currentPath);
$links[$slot] = $this->menus->resolve($menu['items'], $currentPath, language: $this->translation['locale'] ?? null);
}
return $links;
+29 -9
View File
@@ -6,10 +6,12 @@ use App\Content\CustomCode;
use App\Content\DocumentValidator;
use App\Content\FontLibrary;
use App\Content\FormRepository;
use App\Content\Languages;
use App\Content\MenuRepository;
use App\Content\PartialRepository;
use App\Content\StyleCompiler;
use App\Content\Theme;
use App\Content\Translations;
use App\Domain\InvalidDocument;
use App\Domain\InvalidPage;
use App\Domain\StaleWrite;
@@ -42,6 +44,7 @@ final class PartialController extends AbstractController
private CsrfTokenManagerInterface $csrf,
private PluginRegistry $plugins,
private FormRepository $forms,
private Languages $languages,
) {
}
@@ -74,7 +77,7 @@ final class PartialController extends AbstractController
$request->request->getString('slug') ?: $this->slugify($name),
$name,
// A shared area starts with one empty section, so there is something to click on.
['schemaVersion' => 11, 'title' => '' === $name ? 'Bereich' : $name, 'description' => 'Wird an jeder Stelle gezeigt, die ihn einbindet.', 'blocks' => [
['schemaVersion' => DocumentValidator::SCHEMA_VERSION, 'title' => '' === $name ? 'Bereich' : $name, 'description' => 'Wird an jeder Stelle gezeigt, die ihn einbindet.', 'blocks' => [
['id' => 'b_'.bin2hex(random_bytes(8)), 'type' => 'section', 'layout' => ['tone' => 'paper', 'width' => 'normal'], 'children' => []],
]],
);
@@ -125,8 +128,8 @@ final class PartialController extends AbstractController
if (!$this->isCsrfTokenValid('editor', $request->headers->get('X-CSRF-Token'))) {
return $this->json(['error' => 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'], 403);
}
if (strlen($request->getContent()) > 270000) {
return $this->json(['error' => 'Der Bereich ist zu groß (maximal 256 KiB Inhalt).'], 413);
if (strlen($request->getContent()) > 1100000) {
return $this->json(['error' => 'Der Bereich ist zu groß (maximal 256 KiB Inhalt, mit Übersetzungen 1 MiB).'], 413);
}
$partial = $this->partial($id);
try {
@@ -137,8 +140,9 @@ final class PartialController extends AbstractController
}
if ('admin_partial_preview' === $request->attributes->get('_route')) {
$this->validator->validate($document, false, true);
$language = $body['language'] ?? null;
return $this->renderPreview($partial, $document);
return $this->renderPreview($partial, $document, $request, is_string($language) && $this->languages->isExtra($language) ? $language : null);
}
if (!isset($body['expectedVersion']) || !is_int($body['expectedVersion'])) {
throw new \InvalidArgumentException();
@@ -163,18 +167,25 @@ final class PartialController extends AbstractController
}
/** The area on its own, in the shell of a page, so the editor preview looks like the result. */
private function renderPreview(array $partial, array $document): Response
private function renderPreview(array $partial, array $document, Request $request, ?string $language = null): Response
{
// Ein Bereich in einer weiteren Sprache (ADR 111): seine Texte, die seiner Sektionen und
// die Menübeschriftungen in dieser Sprache - wie auf einer übersetzten Seite.
$localise = static fn (array $doc): array => null === $language ? Translations::strip($doc) : Translations::apply($doc, $language);
$document = $localise($document);
$locale = $language ?? $this->languages->main();
$request->attributes->set('kant_locale', $locale);
$request->attributes->set('kant_alternates', array_map(fn (string $code): array => [...$this->languages->describe($code), 'locale' => $code, 'href' => '#', 'current' => $code === $locale], $this->languages->all()));
$theme = $this->theme->load();
$navigation = [];
foreach ($this->menus->all() as $slot => $menu) {
$navigation[$slot] = $this->menus->resolve($menu['items'], '');
$navigation[$slot] = $this->menus->resolve($menu['items'], '', language: $language);
}
$sections = [];
foreach ($this->partials->referencedSlugs($document) as $slug) {
$section = $this->partials->resolveSection($slug);
if (null !== $section) {
$sections[$slug] = $section['document'];
$sections[$slug] = $localise($section['document']);
}
}
@@ -184,10 +195,13 @@ final class PartialController extends AbstractController
$styles .= $this->styles->compileDocument($section).$this->code->compileDocument($section);
}
$forms = [];
$walk = function (array $blocks) use (&$walk, &$forms): void {
$walk = function (array $blocks) use (&$walk, &$forms, $language): void {
foreach ($blocks as $block) {
if ('form' === ($block['type'] ?? null) && '' !== ($block['props']['form'] ?? '')) {
$form = $this->forms->findBySlug((string) $block['props']['form']);
if (null !== $form && null !== $language) {
$form = FormRepository::localise($form, $language);
}
$forms[$block['id']] = ['form' => $form, 'sent' => false, 'errors' => [], 'values' => [], 'optionImages' => [], 'blockId' => $block['id'], 'page' => ['path' => ''], 'issued' => 0, 'issuedSignature' => '', 'turnstileKey' => ''];
if (null !== $form) {
$styles .= $this->forms->css($form, $theme);
@@ -202,7 +216,7 @@ final class PartialController extends AbstractController
}
return $this->render('page.html.twig', [
'page' => ['locale' => 'de', 'path' => '', 'type' => 'page', 'slug' => '', 'document' => $document],
'page' => ['locale' => $locale, 'path' => '', 'type' => 'page', 'slug' => '', 'document' => $document],
'canonical' => '',
'editing' => true,
'navigation' => $navigation,
@@ -232,6 +246,12 @@ final class PartialController extends AbstractController
'csrfToken' => $this->csrf->getToken('editor')->getValue(),
'plugins' => $this->plugins->forEditor(),
'presets' => $this->plugins->presetsForEditor(),
// Kopf, Fuß und Sektionen werden wie Seiten übersetzt, nur ohne Adresse und Suchtitel.
'languages' => [
'main' => $this->languages->describe($this->languages->main()),
'extra' => array_map(fn (string $code): array => $this->languages->describe($code), $this->languages->extra()),
'area' => true,
],
];
}
+20
View File
@@ -2,6 +2,7 @@
namespace App\Controller;
use App\Content\Languages;
use App\Content\PostRepository;
use App\Content\SiteSettings;
use Doctrine\DBAL\Connection;
@@ -22,6 +23,7 @@ final class SeoController extends AbstractController
private PostRepository $posts,
private string $siteOrigin,
private string $adminPath,
private Languages $languages,
) {
}
@@ -44,6 +46,24 @@ final class SeoController extends AbstractController
'lastmod' => substr(max((string) ($row['published_at'] ?? ''), (string) ($row['created_at'] ?? '')), 0, 10),
];
}
// Jede weitere Sprache mit ihren veröffentlichten Seiten (ADR 111), unter denselben
// Bedingungen wie die Hauptsprache. Die Zuordnung der Sprachen zueinander steht als
// hreflang in den Seiten selbst.
$extra = $this->languages->extra();
if ([] !== $extra) {
foreach ($this->db->fetchAllAssociative(
"SELECT t.locale, t.path AS translated, p.path, p.published_at, r.created_at FROM page_translation t JOIN page p ON p.id = t.page_id JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE t.website_id = ? AND p.robots = 'index' AND p.deleted_at IS NULL AND p.id <> ? ORDER BY t.locale, t.path",
['default', $this->settings->load()['notFoundPageId']],
) as $row) {
if (!in_array($row['locale'], $extra, true)) {
continue;
}
$entries[] = [
'loc' => $origin.'/'.$row['locale'].($row['path'] === $home ? '' : '/'.$row['translated']),
'lastmod' => substr(max((string) ($row['published_at'] ?? ''), (string) ($row['created_at'] ?? '')), 0, 10),
];
}
}
// Archives exist only while they list something.
if ([] !== $this->posts->published(1, null, 1)['posts']) {
$entries[] = ['loc' => $origin.'/beitraege', 'lastmod' => null];
+46 -1
View File
@@ -4,8 +4,10 @@ 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;
@@ -19,7 +21,7 @@ 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)
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private Theme $theme, private NotFoundPage $notFoundPage, private Languages $languages, private PostRepository $posts)
{
}
@@ -67,9 +69,52 @@ final class SettingsController extends AbstractController
'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
{
+231 -5
View File
@@ -3,16 +3,22 @@
namespace App\Controller;
use App\Command\SiteOriginCommand;
use App\Content\PageRepository;
use App\Content\SiteSettings;
use App\Domain\InvalidPage;
use App\Ops\ArchiveUpdate;
use App\Ops\Backup;
use App\Ops\Environment;
use App\Ops\HttpsRedirect;
use App\Ops\Installation;
use App\Ops\OriginCheck;
use App\Ops\SitePackage;
use App\Ops\Updater;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\Routing\Attribute\Route;
/**
@@ -24,7 +30,7 @@ use Symfony\Component\Routing\Attribute\Route;
*/
final class SystemController extends AbstractController
{
public function __construct(private Updater $updater, private Backup $backup, private ArchiveUpdate $archive, private Installation $installation, private string $adminPath, private string $databaseUrl, private string $mediaDirectory, private string $projectDir, private string $siteOrigin, private OriginCheck $originCheck)
public function __construct(private Updater $updater, private Backup $backup, private ArchiveUpdate $archive, private Installation $installation, private string $adminPath, private string $databaseUrl, private string $mediaDirectory, private string $projectDir, private string $siteOrigin, private OriginCheck $originCheck, private HttpKernelInterface $kernel, private PageRepository $pages, private SiteSettings $settings, private SitePackage $packages, private HttpsRedirect $https)
{
}
@@ -43,6 +49,9 @@ final class SystemController extends AbstractController
'asked' => $request->query->getBoolean('pruefen'),
'steps' => $request->getSession()->remove('update_steps') ?: [],
'backups' => $this->backup->all(),
// Inhaltspakete (ADR 118): die per FTP abgelegten und die Grenze für Uploads.
'packages' => $this->packages->waiting(),
'uploadLimit' => $this->uploadLimit(),
'adminPath' => $this->adminPath,
// Der Weg ohne Git: das Releasearchiv. Auf geteiltem Hosting ist das der einzige.
'archiveVersion' => $request->query->getBoolean('pruefen') ? $this->archive->updateAvailable() : null,
@@ -59,6 +68,9 @@ final class SystemController extends AbstractController
// bekommt hier den Hinweis, dass die Website woanders eingetragen ist.
'origin' => $this->originFacts($request),
'originRejected' => $request->getSession()->remove('origin_rejected'),
// HTTPS erzwingen und HSTS (ADR 120): der Stand in public/.htaccess.
'https' => $this->https->state(),
'secure' => $request->isSecure(),
]);
}
@@ -174,13 +186,14 @@ final class SystemController extends AbstractController
return $this->redirectToRoute('admin_system');
}
// Alles, was nach dem Dateitausch noch gebraucht wird, steht vorher fest: Danach darf diese
// Anfrage keinen Code mehr nachladen (siehe sendAndStop).
$abschluss = '/'.trim($this->adminPath, '/').'/system/abschliessen?token=';
$ergebnis = $this->archive->run($request->request->getString('target') ?: null);
$request->getSession()->set('update_steps', $ergebnis['steps']);
if ($ergebnis['migrate'] ?? false) {
$this->addFlash('success', 'Die Dateien sind ersetzt. Jetzt noch die Migrationen - der Knopf steht oben.');
return $this->redirectToRoute('admin_system');
$this->sendAndStop((string) $ergebnis['fassung'], $abschluss.$ergebnis['token'], $ergebnis['steps']);
}
$request->getSession()->set('update_steps', $ergebnis['steps']);
$this->addFlash($ergebnis['ok'] ? 'success' : 'error', $ergebnis['ok']
? 'Es war nichts einzuspielen.'
: 'Das Update ist nicht durchgelaufen. Die Schritte stehen unten.');
@@ -188,6 +201,180 @@ final class SystemController extends AbstractController
return $this->redirectToRoute('admin_system');
}
// -----------------------------------------------------------------------------------------
// Update mit einem Klick (ADR 113), nach dem Vorbild von reiseSoft: Die Systemseite fragt
// beim Öffnen selbst nach einer neuen Fassung, ein Knopf startet das Update, und die Seite
// zeigt Schritt für Schritt, was gerade passiert - bis „abgeschlossen“ oder „zurückgeschaltet“.
// -----------------------------------------------------------------------------------------
/** Gibt es eine neue Fassung? Für die Systemseite, die das beim Öffnen fragt. */
#[Route('/%kant.admin_path%/system/update/pruefen', name: 'admin_update_check', methods: ['GET'])]
public function updateCheck(): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$installiert = $this->updater->version();
$neueste = $this->archive->latest();
$antwort = ['installiert' => $installiert, 'status' => $this->archive->status(), 'offen' => null !== $this->archive->pending()];
if (null === $neueste) {
$antwort['fehler'] = 'Die Releaseseite antwortet gerade nicht. Später noch einmal versuchen.';
} elseif (version_compare($neueste['version'], $installiert, '>')) {
$antwort['neu'] = ['version' => $neueste['version'], 'name' => $neueste['name'], 'notizen' => $neueste['notes'], 'datum' => substr($neueste['published'], 0, 10)];
}
return new JsonResponse($antwort, 200, ['Cache-Control' => 'no-store']);
}
/** Der Fortschritt, alle zwei Sekunden abgefragt, solange das Update läuft. */
#[Route('/%kant.admin_path%/system/update/status', name: 'admin_update_status', methods: ['GET'])]
public function updateStatus(): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
return new JsonResponse(['status' => $this->archive->status(), 'installiert' => $this->updater->version()], 200, ['Cache-Control' => 'no-store']);
}
/**
* Startet das Update: Sichern, Laden, Prüfen, Umschalten. Die Antwort kommt nach dem Tausch
* als reines JSON ohne Framework (siehe sendJsonAndStop); den Abschluss ruft die Seite
* danach selbst auf.
*/
#[Route('/%kant.admin_path%/system/update/starten', name: 'admin_update_start', methods: ['POST'])]
public function updateStart(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
if (!$this->isCsrfTokenValid('system', $request->headers->get('X-CSRF-Token'))) {
return new JsonResponse(['ok' => false, 'meldung' => 'Deine Sitzung ist abgelaufen. Bitte die Seite neu laden.'], 403);
}
@set_time_limit(0);
$ergebnis = $this->archive->run($request->request->getString('version') ?: null);
if ($ergebnis['migrate'] ?? false) {
$this->sendJsonAndStop(['ok' => true, 'token' => $ergebnis['token'], 'fassung' => $ergebnis['fassung']]);
}
$letzter = end($ergebnis['steps']) ?: ['detail' => ''];
return new JsonResponse(['ok' => $ergebnis['ok'], 'meldung' => $letzter['detail'], 'status' => $this->archive->status()]);
}
/**
* Der Abschluss in einer frischen Anfrage mit dem neuen Code: Migrationen, Prüfung der
* Startseite, Wartung aus - oder Zurückschalten auf die vorherige Fassung. Die Antwort ist
* wieder reines JSON, denn nach einem Zurückschalten liegt erneut anderer Code auf der Platte.
*/
#[Route('/%kant.admin_path%/system/update/abschliessen', name: 'admin_update_finish', methods: ['POST'])]
public function updateFinish(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$offen = $this->archive->pending();
if (null === $offen) {
return new JsonResponse(['ok' => true, 'meldung' => 'Das Update ist bereits abgeschlossen.', 'status' => $this->archive->status()]);
}
if (!is_string($offen['token'] ?? null) || !hash_equals($offen['token'], $request->request->getString('token'))) {
return new JsonResponse(['ok' => false, 'meldung' => 'Dieser Abschluss gehört zu keinem offenen Update.'], 403);
}
@set_time_limit(0);
$ergebnis = $this->archive->finish($this->installation, $this->pruefen(...));
$this->sendJsonAndStop(['ok' => $ergebnis['ok'], 'status' => $this->archive->status()]);
}
/**
* Läuft die neue Fassung wirklich? Datenbank, Startseite in der Datenbank und die Startseite
* als Seite - als Unteranfrage, die an der Wartungsseite vorbeigeht. null heißt: alles gut.
*/
private function pruefen(): ?string
{
$home = $this->settings->homePath();
if (null === $this->pages->findPublished($home)) {
return sprintf('Die Startseite /%s ist nicht lesbar.', $home);
}
$antwort = $this->kernel->handle(Request::create('/'), HttpKernelInterface::SUB_REQUEST, false);
if ($antwort->getStatusCode() >= 400) {
return sprintf('Die Startseite antwortet mit %d.', $antwort->getStatusCode());
}
return null;
}
/** Wie sendAndStop, nur als JSON für die Systemseite. */
private function sendJsonAndStop(array $daten): never
{
while (ob_get_level() > 0) {
ob_end_clean();
}
http_response_code(200);
header('Content-Type: application/json; charset=UTF-8');
header('Cache-Control: no-store');
header('X-Robots-Tag: noindex');
echo json_encode($daten, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
/**
* Der zweite Schritt des Archivupdates, aufgerufen von der Zwischenseite selbst (ADR 112).
*
* Läuft in einer eigenen Anfrage und damit mit dem Code der neuen Fassung: Migrationen,
* Aufräumen, Wartungszustand aus. Der Einmalschlüssel aus var/update/offen.json ersetzt das
* CSRF-Token, das die Zwischenseite nicht mehr erzeugen kann; ohne Administratorrolle geht es
* ohnehin nicht. Ein zweiter Aufruf (Neuladen) findet nichts mehr offen und meldet das.
*/
#[Route('/%kant.admin_path%/system/abschliessen', name: 'admin_system_finish', methods: ['GET'])]
public function finishArchive(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$offen = $this->archive->pending();
if (null === $offen) {
$this->addFlash('success', 'Das Update ist bereits abgeschlossen.');
return $this->redirectToRoute('admin_system');
}
if (!is_string($offen['token'] ?? null) || !hash_equals($offen['token'], $request->query->getString('token'))) {
return $this->back('Dieser Link gehört zu keinem offenen Update. Schließe es oben mit „Migrationen ausführen“ ab.');
}
@set_time_limit(0);
$ergebnis = $this->archive->finish($this->installation);
$request->getSession()->set('update_steps', $ergebnis['steps']);
$this->addFlash($ergebnis['ok'] ? 'success' : 'error', $ergebnis['ok']
? sprintf('Update auf %s abgeschlossen. Die Website ist wieder erreichbar.', $offen['fassung'])
: 'Die Migrationen sind fehlgeschlagen. Die Website bleibt im Wartungszustand.');
return $this->redirectToRoute('admin_system');
}
/**
* Beendet die Anfrage, die gerade die Dateien getauscht hat, mit einer schlichten Seite.
*
* Dieser Prozess hat den alten Code im Speicher, auf der Platte liegt der neue. Alles, was
* jetzt noch nachgeladen würde - Antwort-Listener, Sitzung, Vorlagen -, käme aus der neuen
* Fassung und passte nicht zu dem, was schon geladen ist. Genau das endete vorher mit einem
* 500er, obwohl das Update gelungen war. Deshalb hier nur noch PHP selbst: Kopfzeilen, Text,
* Ende. Die Seite ruft den Abschluss sofort selbst auf, ein Link steht für den Fall daneben,
* dass der Browser das Weiterleiten unterdrückt.
*
* @param list<array{step: string, ok: bool, detail: string}> $schritte
*/
private function sendAndStop(string $fassung, string $weiter, array $schritte): never
{
while (ob_get_level() > 0) {
ob_end_clean();
}
$e = static fn (string $text): string => htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$liste = '';
foreach ($schritte as $schritt) {
$liste .= '<li>'.($schritt['ok'] ? '✓ ' : '! ').$e($schritt['step']).'</li>';
}
http_response_code(200);
header('Content-Type: text/html; charset=UTF-8');
header('Cache-Control: no-store');
header('X-Robots-Tag: noindex');
header("Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'");
echo '<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">'
.'<meta http-equiv="refresh" content="1;url='.$e($weiter).'"><title>Update wird abgeschlossen</title>'
.'<style>body{font:16px/1.5 system-ui,sans-serif;background:#f3f5f2;color:#203b34;margin:0;display:grid;place-items:center;min-height:100vh}main{background:#fff;border:1px solid #dfe5e1;border-radius:12px;padding:28px 32px;max-width:460px}h1{font-size:20px;margin:0 0 8px}ul{padding-left:18px;color:#46514c;font-size:14px}a{color:#1f5f45}</style></head>'
.'<body><main><h1>Update auf '.$e($fassung).' wird abgeschlossen …</h1>'
.'<p role="status">Die neuen Dateien sind da. Jetzt laufen die Migrationen, danach geht es von selbst zur Systemseite.</p>'
.'<ul>'.$liste.'</ul><p><a href="'.$e($weiter).'">Weiter, falls nichts passiert</a></p></main></body></html>';
exit;
}
#[Route('/%kant.admin_path%/system/update', name: 'admin_system_update', methods: ['POST'])]
public function update(Request $request): Response
{
@@ -311,6 +498,28 @@ final class SystemController extends AbstractController
), 'success');
}
/**
* HTTPS erzwingen oder HSTS schalten (ADR 120). Die Umleitung wird nur eingeschaltet, wenn
* HTTPS vorher und nachher hier ankommt; sonst bleibt public/.htaccess, wie sie war.
*/
#[Route('/%kant.admin_path%/system/https', name: 'admin_system_https', methods: ['POST'])]
public function https(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
$result = match ($request->request->getString('action')) {
'redirect-an' => $this->https->enableRedirect($this->siteOrigin),
'redirect-aus' => $this->https->disableRedirect(),
'hsts-an' => $this->https->switchHsts(true, $this->siteOrigin),
'hsts-aus' => $this->https->switchHsts(false, $this->siteOrigin),
default => ['ok' => false, 'message' => 'Unbekannte Aktion.'],
};
return $this->back($result['message'], $result['ok'] ? 'success' : 'error');
}
/** Die Gegenstelle zur Prüfung oben: öffentlich, und sie verrät nichts als diese Kennung. */
#[Route(OriginCheck::PATH, name: 'site_origin_probe', methods: ['GET'], priority: 10)]
public function originProbe(): Response
@@ -318,6 +527,23 @@ final class SystemController extends AbstractController
return new Response($this->originCheck->token(), 200, ['Content-Type' => 'text/plain; charset=UTF-8', 'Cache-Control' => 'no-store', 'X-Robots-Tag' => 'noindex']);
}
/** Die kleinere der beiden PHP-Grenzen, die einen Upload begrenzen - so, wie sie in der php.ini steht. */
private function uploadLimit(): string
{
$bytes = static function (string $value): int {
$value = trim($value);
$number = (int) $value;
return match (strtolower(substr($value, -1))) {
'g' => $number * 1024 ** 3, 'm' => $number * 1024 ** 2, 'k' => $number * 1024, default => $number,
};
};
$limits = array_filter([$bytes((string) ini_get('upload_max_filesize')), $bytes((string) ini_get('post_max_size'))]);
$min = [] === $limits ? 0 : min($limits);
return 0 === $min ? 'unbegrenzt' : number_format($min / 1048576, 0, ',', '.').' MB';
}
private function back(string $message, string $type = 'error'): Response
{
$this->addFlash($type, $message);
@@ -0,0 +1,50 @@
<?php
namespace App\EventSubscriber;
use App\Ops\Notfall;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\LogoutException;
/**
* Jeder Serverfehler, den das Framework abfängt, steht auch in var/log/notfall.log (ADR 113).
*
* Ohne das gab es auf dem Hosting kein eigenes Protokoll: Symfony schreibt nach stderr, und das
* landet - wenn überhaupt - im Fehlerlog des Hostings, das ein Betreiber erst suchen muss. 404 und
* andere gewollte Antworten gehören nicht hinein. Die Antwort selbst wird zur Wartungsseite.
*/
final class ErrorLogSubscriber implements EventSubscriberInterface
{
public function __construct(private bool $debug = false)
{
}
public static function getSubscribedEvents(): array
{
// Nach der Sicherheitsschicht (Priorität 1): „Zugriff verweigert“ ist dort eine
// Weiterleitung zur Anmeldung, kein Notfall. Vor der Fehlerseite des Frameworks (-128),
// damit der Eintrag auch dann entsteht, wenn sie selbst scheitert.
return [KernelEvents::EXCEPTION => ['onException', -64]];
}
public function onException(ExceptionEvent $event): void
{
$fehler = $event->getThrowable();
if ($event->hasResponse() || $fehler instanceof AuthenticationException || $fehler instanceof AccessDeniedException || $fehler instanceof LogoutException
|| ($fehler instanceof HttpExceptionInterface && $fehler->getStatusCode() < 500)) {
return;
}
Notfall::protokollieren($fehler, $event->getRequest()->getMethod().' '.$event->getRequest()->getRequestUri());
// Besucher sehen statt einer englischen „Oops“-Seite die Wartungsseite mit 503: für
// Suchmaschinen „gleich wieder da“. Im Entwicklungsmodus bleibt die ausführliche Seite.
if (!$this->debug) {
$event->setResponse(new Response(Notfall::html(), Response::HTTP_SERVICE_UNAVAILABLE, Notfall::KOPFZEILEN));
}
}
}
@@ -3,6 +3,7 @@
namespace App\EventSubscriber;
use App\Ops\Installation;
use App\Ops\Protection;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Event\RequestEvent;
@@ -36,6 +37,8 @@ final class InstallSubscriber implements EventSubscriberInterface
public function onRequest(RequestEvent $event): void
{
// Fehlende Sperrdateien nachtragen (var/, vendor/, Projektstamm) - einmal je Prozess.
Protection::ensure($this->projectDir);
if (!$event->isMainRequest() || is_file($this->projectDir.'/'.self::MARKER)) {
return;
}
@@ -35,7 +35,7 @@ final class MaintenanceSubscriber implements EventSubscriberInterface
return;
}
$path = $event->getRequest()->getPathInfo();
if (preg_match('#^/('.preg_quote($this->adminPath, '#').'|assets/|media/|favicon|\.well-known/kantsites-origin)#', $path)) {
if (preg_match('#^/('.preg_quote($this->adminPath, '#').'|assets/|media/|favicon|kantsites-origin$)#', $path)) {
return;
}
$since = @file_get_contents($this->projectDir.'/'.Updater::MAINTENANCE_FILE);
+197 -16
View File
@@ -51,6 +51,21 @@ final class ArchiveUpdate
/** Das Protokoll - dieselbe Datei, in die auch der Updater über Git schreibt. */
private const PROTOKOLL = 'var/updates.log';
/**
* Der Fortschritt für die Systemseite (ADR 113): Phase, Meldung, von welcher auf welche Fassung.
* Die Seite fragt ihn alle zwei Sekunden ab, während eine andere Anfrage das Update ausführt -
* das Vorbild ist reiseSoft.
*/
public const STATUS = 'var/update/status.json';
/** Die Phasen, die die Systemseite als Schritte zeigt, in ihrer Reihenfolge. */
public const PHASEN = ['sichern', 'laden', 'umschalten', 'migrieren', 'pruefen'];
/** Was mergePublic() überschrieben oder neu angelegt hat - für das Zurückschalten. */
private array $publicAlt = [];
private array $publicNeu = [];
private ?string $publicSicherung = null;
public function __construct(
private string $root,
private Updater $updater,
@@ -85,6 +100,10 @@ final class ArchiveUpdate
$version = $treffer[1];
return [
// Was die Releaseseite über die Fassung sagt - die Systemseite zeigt es vor dem Knopf.
'name' => (string) ($daten['name'] ?? ''),
'notes' => (string) ($daten['body'] ?? ''),
'published' => (string) ($daten['published_at'] ?? ''),
'version' => $version,
'url' => sprintf('%s/%s/kantsites-%s.zip', $this->releaseBase, $tag, $version),
'checksum' => $this->checksumOf(sprintf('%s/%s/kantsites-%s.zip.sha256', $this->releaseBase, $tag, $version)),
@@ -118,6 +137,12 @@ final class ArchiveUpdate
$melden = function (array &$schritte, string $schritt, bool $ok, string $detail = ''): bool {
$schritte[] = ['step' => $schritt, 'ok' => $ok, 'detail' => $detail];
$this->protokoll(sprintf('%s %s: %s', $ok ? 'ok ' : 'FEHLER', $schritt, $detail));
// Ein gescheiterter Schritt vor dem Tausch: die bisherige Fassung läuft unverändert.
// Die Sperre meldet ein anderes, laufendes Update - dessen Fortschritt bleibt stehen.
// Ist die Releaseseite nicht erreichbar, hat nichts begonnen; dann bleibt alles, wie es ist.
if (!$ok && !in_array($schritt, ['Sperre', 'Fassung ermitteln'], true)) {
$this->phase('fehler', $detail.' Die bisherige Fassung läuft unverändert weiter.', ['laeuft' => false]);
}
return $ok;
};
@@ -130,12 +155,19 @@ final class ArchiveUpdate
return ['ok' => false, 'steps' => $schritte];
}
$ziel = $version ?? $neueste['version'];
// Die Fassung landet in Adresse und Pfaden; nur die Form 1.2.3 ist zulässig (ADR 116).
if (1 !== preg_match('/^\d{1,4}\.\d{1,5}\.\d{1,6}$/D', $ziel)) {
$melden($schritte, 'Fassung ermitteln', false, 'Die gewünschte Fassung hat nicht die Form 1.2.3.');
return ['ok' => false, 'steps' => $schritte];
}
$melden($schritte, 'Fassung ermitteln', true, sprintf('Installiert: %s, verfügbar: %s.', $this->updater->version(), $neueste['version']));
if (version_compare($ziel, $this->updater->version(), '<=')) {
$melden($schritte, 'Nichts zu tun', true, 'Diese Installation ist bereits auf dem neuesten Stand.');
return ['ok' => true, 'steps' => $schritte];
}
$this->phase('sichern', 'Datenbank und Medien werden gesichert …', ['laeuft' => true, 'von' => $this->updater->version(), 'auf' => $ziel, 'seit' => gmdate('c')], true);
$lock = $this->updater->lock();
if (null === $lock) {
@@ -165,6 +197,7 @@ final class ArchiveUpdate
try {
$sicherung = $this->backup->create('vor dem Update');
$melden($schritte, 'Sicherung', true, sprintf('Datenbank und Medien liegen unter var/backups/%s.', (string) ($sicherung['name'] ?? 'sicherung')));
$this->phase('laden', sprintf('Fassung %s wird geladen …', $ziel));
} catch (\Throwable $fehler) {
$melden($schritte, 'Sicherung', false, 'Die Sicherung ist fehlgeschlagen: '.$fehler->getMessage().' Ohne sie wird nichts ersetzt.');
@@ -211,6 +244,7 @@ final class ArchiveUpdate
}
$melden($schritte, 'Entpacken', true, 'Vollständig: VERSION und vendor/ sind vorhanden.');
$this->phase('umschalten', 'Wartungsseite an, die neuen Dateien werden eingesetzt …');
$this->updater->maintenance(true);
$melden($schritte, 'Wartungszustand', true, 'Die Website antwortet währenddessen mit 503.');
@@ -229,11 +263,14 @@ final class ArchiveUpdate
++$ersetzt;
}
// public/ wird Datei für Datei aktualisiert: Schriften und erzeugte Bündel gehören dem
// Betreiber und wären sonst weg.
// Betreiber und wären sonst weg. Was dabei überschrieben wird, kommt vorher beiseite,
// damit ein Zurückschalten auch public/ zurückbringt.
$this->publicSicherung = $arbeit.'/alt-public';
$this->mergePublic($quelle.'/public', $this->root.'/public');
// Ebenso plugins/: die mitgelieferten Plugins werden aktualisiert, ein vom Betreiber
// installiertes Plugin bleibt, weil sein Ordner im Archiv nicht vorkommt.
$this->mergePublic($quelle.'/plugins', $this->root.'/plugins');
@file_put_contents($arbeit.'/public.json', json_encode(['alt' => $this->publicAlt, 'neu' => $this->publicNeu], JSON_UNESCAPED_SLASHES));
} catch (\Throwable $fehler) {
foreach (self::ERSETZEN as $eintrag) {
if (is_dir($alt.'/'.$eintrag) || is_file($alt.'/'.$eintrag)) {
@@ -256,23 +293,32 @@ final class ArchiveUpdate
// Speicher - Klassen, die PHP schon geladen hat, laedt es nicht neu. Die Migrationen
// gehoeren deshalb in eine eigene Anfrage, die mit der neuen Fassung startet. Der
// Wartungszustand bleibt so lange an.
// Ein Einmalschlüssel für den Abschluss: Die Seite, die diese Anfrage noch ausgibt, ruft
// damit den zweiten Schritt selbst auf - ohne Knopf, und ohne dass eine fremde Seite ihn
// auslösen könnte (dazu braucht es zusätzlich eine Anmeldung als Administrator).
$token = bin2hex(random_bytes(16));
@file_put_contents($this->root.'/'.self::MARKE, json_encode([
'fassung' => $ziel,
'seit' => gmdate('c'),
'alt' => basename($arbeit),
'token' => $token,
// Für das Zurückschalten: von welcher Fassung, und welche Sicherung dazu gehört.
'von' => (string) ($this->status()['von'] ?? ''),
'sicherung' => (string) ($sicherung['name'] ?? ''),
], JSON_UNESCAPED_SLASHES));
$this->phase('migrieren', 'Die neuen Dateien sind da. Die Datenbank wird angepasst …');
$melden($schritte, 'Dateien sind ersetzt', true, sprintf(
'Fassung %s liegt bereit. Jetzt fehlen noch die Migrationen - sie laufen im nächsten Schritt mit dem neuen Code.',
$ziel,
));
return ['ok' => true, 'steps' => $schritte, 'migrate' => true];
return ['ok' => true, 'steps' => $schritte, 'migrate' => true, 'token' => $token, 'fassung' => $ziel];
}
/**
* Steht ein zweiter Schritt aus? Gibt Fassung und Zeitpunkt zurück, sonst null.
*
* @return array{fassung: string, seit: string, alt: string}|null
* @return array{fassung: string, seit: string, alt: string, token?: string}|null
*/
public function pending(): ?array
{
@@ -297,25 +343,49 @@ final class ArchiveUpdate
*
* @return array{ok: bool, steps: list<array{step: string, ok: bool, detail: string}>}
*/
public function finish(Installation $installation): array
/**
* @param (callable(): ?string)|null $pruefen prüft die neue Fassung nach den Migrationen und
* gibt null zurück oder den Grund, warum sie nicht
* läuft - dann wird zurückgeschaltet
*/
public function finish(Installation $installation, ?callable $pruefen = null): array
{
$schritte = [];
$offen = $this->pending();
$this->protokoll(sprintf('--- Migrationen nach dem Update, %s UTC', gmdate('Y-m-d H:i:s')));
$this->phase('migrieren', 'Die Datenbank wird angepasst …', ['laeuft' => true]);
try {
$namen = $installation->migrate();
$schritte[] = ['step' => 'Migrationen', 'ok' => true, 'detail' => [] === $namen
? 'Es stand keine an - die Datenbank war bereits auf dem Stand der neuen Fassung.'
: sprintf('%d ausgeführt: %s.', count($namen), implode(', ', $namen))];
} catch (\Throwable $fehler) {
$schritte[] = ['step' => 'Migrationen', 'ok' => false, 'detail' => $fehler->getMessage().' Die Website bleibt im Wartungszustand, bis das geklärt ist.'];
$schritte[] = ['step' => 'Migrationen', 'ok' => false, 'detail' => $fehler->getMessage()];
$this->protokoll('FEHLER Migrationen: '.$fehler->getMessage());
return ['ok' => false, 'steps' => $schritte];
return $this->zurueckschalten($offen, 'Die Datenbank ließ sich nicht anpassen: '.$fehler->getMessage(), $schritte);
}
$this->protokoll('ok Migrationen: '.$schritte[0]['detail']);
if (null !== $pruefen) {
$this->phase('pruefen', 'Die neue Fassung wird geprüft …');
try {
$problem = $pruefen();
} catch (\Throwable $fehler) {
$problem = $fehler->getMessage();
}
if (null !== $problem) {
$schritte[] = ['step' => 'Funktion prüfen', 'ok' => false, 'detail' => $problem];
$this->protokoll('FEHLER Funktion prüfen: '.$problem);
return $this->zurueckschalten($offen, 'Die neue Fassung lief nicht: '.$problem, $schritte);
}
$schritte[] = ['step' => 'Funktion prüfen', 'ok' => true, 'detail' => 'Startseite und Datenbank antworten.'];
$this->protokoll('ok Funktion prüfen: Startseite und Datenbank antworten.');
}
@unlink($this->root.'/'.self::MARKE);
$this->updater->maintenance(false);
$this->protokoll('ok Migrationen: '.$schritte[0]['detail']);
foreach (self::ENTFALLEN_IN_PUBLIC as $eintrag) {
@unlink($this->root.'/public/'.$eintrag);
}
@@ -324,10 +394,109 @@ final class ArchiveUpdate
'Fassung %s läuft. Der vorherige Stand liegt unter var/update/ (die zwei jüngsten bleiben).',
$this->updater->version(),
)];
$this->phase('fertig', sprintf('Fassung %s läuft.', $this->updater->version()), ['laeuft' => false]);
return ['ok' => true, 'steps' => $schritte];
}
/**
* Zurück auf die Fassung von vorher: Code aus var/update/<lauf>/alt, die überschriebenen
* Dateien in public/ und plugins/, dann die Datenbank aus der Sicherung vor dem Update.
* Erst danach geht die Wartungsseite aus - eine halb zurückgeschaltete Website darf niemand
* sehen.
*/
private function zurueckschalten(?array $offen, string $grund, array $schritte): array
{
$bei = $this->status()['phase'] ?? null;
$this->phase('zurueckschalten', 'Etwas ist schiefgegangen – die bisherige Fassung wird wiederhergestellt …', ['bei' => in_array($bei, self::PHASEN, true) ? $bei : null]);
$arbeit = $this->root.'/var/update/'.basename((string) ($offen['alt'] ?? ''));
$fehler = [];
if (null === $offen || !is_dir($arbeit.'/alt')) {
$fehler[] = 'Der beiseitegelegte Stand fehlt.';
} else {
@mkdir($arbeit.'/verworfen', 0o775, true);
foreach (self::ERSETZEN as $eintrag) {
if (!file_exists($arbeit.'/alt/'.$eintrag)) {
continue;
}
if (file_exists($this->root.'/'.$eintrag) && !@rename($this->root.'/'.$eintrag, $arbeit.'/verworfen/'.$eintrag)) {
$fehler[] = $eintrag.' ließ sich nicht beiseitelegen.';
continue;
}
if (!@rename($arbeit.'/alt/'.$eintrag, $this->root.'/'.$eintrag)) {
$fehler[] = $eintrag.' ließ sich nicht zurücklegen.';
}
}
$liste = json_decode((string) @file_get_contents($arbeit.'/public.json'), true);
foreach ((array) ($liste['neu'] ?? []) as $relativ) {
@unlink($this->root.'/'.$relativ);
}
foreach ((array) ($liste['alt'] ?? []) as $relativ) {
if (!@copy($arbeit.'/alt-public/'.$relativ, $this->root.'/'.$relativ)) {
$fehler[] = $relativ.' ließ sich nicht zurückkopieren.';
}
}
}
if ('' !== (string) ($offen['sicherung'] ?? '')) {
$wieder = $this->backup->restore((string) $offen['sicherung']);
if (!$wieder['ok']) {
$fehler[] = 'Die Datenbank ließ sich nicht aus der Sicherung zurückholen.';
}
}
$von = (string) ($offen['von'] ?? '');
if ([] === $fehler) {
@unlink($this->root.'/'.self::MARKE);
$this->updater->maintenance(false);
$meldung = sprintf('%s Zurückgeschaltet auf %s – die Website läuft wie vorher.', $grund, '' === $von ? 'die vorherige Fassung' : $von);
$schritte[] = ['step' => 'Zurückgeschaltet', 'ok' => true, 'detail' => $meldung];
$this->protokoll('ok Zurückgeschaltet: '.$meldung);
$this->phase('zurueckgerollt', $meldung, ['laeuft' => false]);
} else {
// Bleibt die Wartungsseite an, sieht niemand eine halbe Website; der Rest ist Handarbeit.
$meldung = $grund.' Das Zurückschalten gelang nicht vollständig: '.implode(' ', $fehler).' Die Wartungsseite bleibt an.';
$schritte[] = ['step' => 'Zurückschalten', 'ok' => false, 'detail' => $meldung];
$this->protokoll('FEHLER Zurückschalten: '.$meldung);
$this->phase('fehler', $meldung, ['laeuft' => false]);
}
return ['ok' => false, 'steps' => $schritte, 'zurueckgeschaltet' => [] === $fehler];
}
/** Der letzte Fortschritt, oder null, wenn noch nie ein Update über diesen Weg lief. */
public function status(): ?array
{
$daten = json_decode((string) @file_get_contents($this->root.'/'.self::STATUS), true);
if (!is_array($daten)) {
return null;
}
// Ein Lauf, der seit einer halben Stunde nichts mehr meldet, ist abgebrochen - etwa weil
// das Hosting den Aufruf beendet hat. Dann soll die Seite nicht ewig „läuft“ zeigen.
if (($daten['laeuft'] ?? false) && strtotime((string) ($daten['aktualisiert'] ?? '')) < time() - 1800) {
$daten['laeuft'] = false;
$daten['phase'] = 'fehler';
$daten['meldung'] = 'Das Update hat seit einer halben Stunde nichts mehr gemeldet und gilt als abgebrochen. Den Stand zeigt das Protokoll unten.';
}
return $daten;
}
/** Schreibt den Fortschritt - in einem Zug, damit die Seite nie eine halbe Datei liest. */
private function phase(string $phase, string $meldung, array $mehr = [], bool $neu = false): void
{
$daten = $neu ? [] : ($this->status() ?? []);
// Bei einem Fehler zählt, in welchem Schritt er passierte - die Seite markiert genau den.
if (in_array($phase, ['fehler', 'zurueckgerollt'], true) && !array_key_exists('bei', $mehr)) {
$mehr['bei'] = in_array($daten['phase'] ?? null, self::PHASEN, true) ? $daten['phase'] : ($daten['bei'] ?? null);
}
$daten = [...$daten, ...$mehr, 'phase' => $phase, 'meldung' => $meldung, 'aktualisiert' => gmdate('c')];
$datei = $this->root.'/'.self::STATUS;
@mkdir(dirname($datei), 0o775, true);
$zwischen = $datei.'.'.bin2hex(random_bytes(4)).'.tmp';
if (false !== @file_put_contents($zwischen, json_encode($daten, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE))) {
@rename($zwischen, $datei);
}
}
/**
* Eine Zeile ins Protokoll, und zwar sofort.
*
@@ -361,6 +530,7 @@ final class ArchiveUpdate
continue;
}
$this->merken($ziel);
if ('.htaccess' === $eintrag && is_file($ziel)) {
$this->mergeHtaccess($quelle, $ziel);
@@ -370,6 +540,25 @@ final class ArchiveUpdate
}
}
/** Merkt sich eine Datei, bevor sie überschrieben oder neu angelegt wird. */
private function merken(string $ziel): void
{
if (null === $this->publicSicherung) {
return;
}
$relativ = ltrim(substr($ziel, strlen($this->root)), '/');
if (!is_file($ziel)) {
$this->publicNeu[] = $relativ;
return;
}
$kopie = $this->publicSicherung.'/'.$relativ;
@mkdir(dirname($kopie), 0o775, true);
if (@copy($ziel, $kopie)) {
$this->publicAlt[] = $relativ;
}
}
/**
* Die neue .htaccess uebernehmen, aber die Zeilen, die der Betreiber eingeschaltet hat,
* eingeschaltet lassen: HTTPS-Zwang und HSTS. Vorher kam jedes Update mit den auskommentierten
@@ -377,15 +566,7 @@ final class ArchiveUpdate
*/
private function mergeHtaccess(string $neu, string $alt): void
{
$vorher = (string) file_get_contents($alt);
$inhalt = (string) file_get_contents($neu);
foreach (['RewriteCond %{HTTPS} !=on', 'RewriteCond %{HTTP:X-Forwarded-Proto} !=https', 'RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]', 'Header always set Strict-Transport-Security'] as $zeile) {
$aktiv = 1 === preg_match('/^\s*'.preg_quote($zeile, '/').'/m', $vorher);
if ($aktiv) {
$inhalt = (string) preg_replace('/^(\s*)#\s*('.preg_quote($zeile, '/').')/m', '$1$2', $inhalt);
}
}
file_put_contents($alt, $inhalt);
file_put_contents($alt, HttpsRedirect::keepEnabled((string) file_get_contents($neu), (string) file_get_contents($alt)));
}
/**
+259
View File
@@ -0,0 +1,259 @@
<?php
namespace App\Ops;
/**
* Schaltet die Umleitung auf HTTPS und HSTS in public/.htaccess ein und aus (ADR 120).
*
* Die Zeilen stehen ausgeliefert auskommentiert in der Datei: Eine Installation ohne Zertifikat
* liefe mit ihnen in eine Endlosschleife, und die Anwendung selbst erzwingt kein HTTPS, weil
* PHP hinter einem Proxy oft nur http sieht. Bisher hieß das: per FTP in die .htaccess. Dieser
* Schalter macht dasselbe aus der Verwaltung, aber nur mit Probe davor und danach:
*
* 1. Antwortet diese Installation unter https://<Adresse> mit gültigem Zertifikat? Sonst nichts.
* 2. Zeilen einschalten.
* 3. Kommt https://<Adresse> weiterhin hier an? Sonst ist es eine Schleife (der Server meldet
* HTTPS nicht, kein Proxy-Kopf) - die Datei wird sofort zurückgeschrieben.
* 4. Leitet http://<Adresse> mit 301 auf https um? Wenn nicht, liest der Server die Regel nicht
* (kein mod_rewrite, AllowOverride) - die Zeilen bleiben, schaden nicht, und die Meldung sagt es.
*
* HSTS ist ein eigener Schritt: Ein Browser merkt sich den Kopf ein Jahr lang, ein Rückweg auf
* http ist dann für jeden Besucher zu, der die Seite einmal gesehen hat.
*/
class HttpsRedirect
{
public const REDIRECT_LINES = [
'RewriteCond %{HTTPS} !=on',
'RewriteCond %{HTTP:X-Forwarded-Proto} !=https',
'RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]',
];
public const HSTS_LINE = 'Header always set Strict-Transport-Security';
/** @var \Closure(string): array{status: int, location: string, error: string} */
private \Closure $httpProbe;
/** @param \Closure(string): array{status: int, location: string, error: string}|null $httpProbe */
public function __construct(private string $projectDir, private OriginCheck $originCheck, ?\Closure $httpProbe = null)
{
$this->httpProbe = $httpProbe ?? self::probeHttp(...);
}
public function file(): string
{
return $this->projectDir.'/public/.htaccess';
}
/** @return array{redirect: bool, hsts: bool, found: bool, writable: bool} */
public function state(): array
{
$content = is_file($this->file()) ? (string) file_get_contents($this->file()) : '';
return [
'redirect' => self::active($content, self::REDIRECT_LINES),
'hsts' => self::active($content, [self::HSTS_LINE]),
'found' => self::present($content, self::REDIRECT_LINES) && self::present($content, [self::HSTS_LINE]),
'writable' => is_file($this->file()) && is_writable($this->file()),
];
}
/**
* Schaltet die Umleitung ein. Gibt Erfolg und eine Meldung in einem Satz oder zwei zurück;
* bei Misserfolg ist die Datei so, wie sie vorher war.
*
* @return array{ok: bool, message: string}
*/
public function enableRedirect(string $origin): array
{
$origin = rtrim(trim($origin), '/');
if (!preg_match('#^https://[^/?\#]+$#', $origin)) {
return ['ok' => false, 'message' => 'Erst die Adresse der Website mit https:// eintragen – die Umleitung braucht ein Ziel.'];
}
if (null !== $problem = $this->originCheck->problem($origin)) {
return ['ok' => false, 'message' => $problem.' Ohne funktionierendes HTTPS bleibt die Umleitung aus.'];
}
$before = $this->read();
if (null === $before) {
return ['ok' => false, 'message' => 'public/.htaccess fehlt oder ist nicht lesbar.'];
}
if (!self::present($before, self::REDIRECT_LINES)) {
return ['ok' => false, 'message' => 'Die Zeilen für HTTPS stehen nicht in public/.htaccess (geändert oder sehr alte Fassung). Das nächste Update bringt sie zurück.'];
}
if (self::active($before, self::REDIRECT_LINES)) {
return ['ok' => true, 'message' => 'Die Umleitung auf HTTPS ist schon eingeschaltet.'];
}
if (!$this->write(self::switchLines($before, self::REDIRECT_LINES, true))) {
return ['ok' => false, 'message' => 'PHP darf public/.htaccess nicht schreiben. Die drei Zeilen unter „HTTPS erzwingen“ dort von Hand einschalten (das # davor entfernen).'];
}
if (null !== $problem = $this->originCheck->problem($origin)) {
$this->write($before);
return ['ok' => false, 'message' => 'Mit eingeschalteter Umleitung kam '.$origin.' nicht mehr an: '.rtrim($problem, '.').'. Vermutlich meldet der Server HTTPS nicht an die .htaccess (Proxy ohne X-Forwarded-Proto). Die Datei ist zurückgesetzt, nichts geändert.'];
}
$http = 'http://'.substr($origin, 8).OriginCheck::PATH;
$answer = ($this->httpProbe)($http);
$target = $origin.OriginCheck::PATH;
if (in_array($answer['status'], [301, 308], true) && $answer['location'] === $target) {
return ['ok' => true, 'message' => 'HTTPS wird jetzt erzwungen: http:// leitet mit 301 auf '.$origin.' um. HSTS erst einschalten, wenn das ein paar Tage gut läuft.'];
}
if (0 === $answer['status']) {
return ['ok' => true, 'message' => 'Die Umleitung ist eingeschaltet, und HTTPS antwortet weiter. Ob http:// umleitet, ließ sich vom Server aus nicht prüfen ('.$answer['error'].') – einmal im Browser http:// aufrufen.'];
}
return ['ok' => true, 'message' => sprintf('Die Zeilen sind eingeschaltet, aber http:// antwortet mit %d%s statt mit 301 auf HTTPS. Der Server liest die Umleitung offenbar nicht (mod_rewrite, AllowOverride) – beim Hoster die HTTPS-Weiterleitung einschalten.', $answer['status'], '' !== $answer['location'] ? ' nach '.$answer['location'] : '')];
}
/**
* Schaltet die Umleitung aus - und HSTS mit, denn HSTS ohne Umleitung verspricht etwas,
* das der Server nicht mehr einhält.
*
* @return array{ok: bool, message: string}
*/
public function disableRedirect(): array
{
$before = $this->read();
if (null === $before) {
return ['ok' => false, 'message' => 'public/.htaccess fehlt oder ist nicht lesbar.'];
}
$hsts = self::active($before, [self::HSTS_LINE]);
$after = self::switchLines(self::switchLines($before, self::REDIRECT_LINES, false), [self::HSTS_LINE], false);
if ($after !== $before && !$this->write($after)) {
return ['ok' => false, 'message' => 'PHP darf public/.htaccess nicht schreiben.'];
}
return ['ok' => true, 'message' => 'Die Umleitung auf HTTPS ist ausgeschaltet.'.($hsts ? ' HSTS ebenfalls – Browser, die den Kopf schon kennen, bleiben aber bis zu einem Jahr bei HTTPS.' : '')];
}
/** @return array{ok: bool, message: string} */
public function switchHsts(bool $on, string $origin): array
{
$before = $this->read();
if (null === $before || !self::present($before, [self::HSTS_LINE])) {
return ['ok' => false, 'message' => 'Die HSTS-Zeile steht nicht in public/.htaccess.'];
}
if ($on) {
if (!self::active($before, self::REDIRECT_LINES)) {
return ['ok' => false, 'message' => 'HSTS erst, wenn die Umleitung auf HTTPS eingeschaltet ist.'];
}
if (null !== $problem = $this->originCheck->problem(rtrim(trim($origin), '/'))) {
return ['ok' => false, 'message' => $problem.' HSTS bleibt aus.'];
}
}
if (!$this->write(self::switchLines($before, [self::HSTS_LINE], $on))) {
return ['ok' => false, 'message' => 'PHP darf public/.htaccess nicht schreiben.'];
}
return ['ok' => true, 'message' => $on
? 'HSTS ist eingeschaltet: Browser rufen diese Domain ein Jahr lang nur noch über HTTPS auf, Subdomains eingeschlossen.'
: 'HSTS ist ausgeschaltet. Browser, die den Kopf schon kennen, bleiben bis zu einem Jahr bei HTTPS.'];
}
/**
* Die neue .htaccess eines Updates, mit den Zeilen eingeschaltet, die in der alten
* eingeschaltet waren. Archiv- und Git-Update rufen beide hierher.
*/
public static function keepEnabled(string $new, string $old): string
{
if (self::active($old, self::REDIRECT_LINES)) {
$new = self::switchLines($new, self::REDIRECT_LINES, true);
}
if (self::active($old, [self::HSTS_LINE])) {
$new = self::switchLines($new, [self::HSTS_LINE], true);
}
return $new;
}
/** @param list<string> $lines */
public static function active(string $content, array $lines): bool
{
foreach ($lines as $line) {
if (1 !== preg_match('/^[ \t]*'.preg_quote($line, '/').'/m', $content)) {
return false;
}
}
return true;
}
/** @param list<string> $lines */
private static function present(string $content, array $lines): bool
{
foreach ($lines as $line) {
if (1 !== preg_match('/^[ \t]*(#[ \t]*)?'.preg_quote($line, '/').'/m', $content)) {
return false;
}
}
return true;
}
/** @param list<string> $lines */
private static function switchLines(string $content, array $lines, bool $on): string
{
foreach ($lines as $line) {
$quoted = preg_quote($line, '/');
$content = $on
? (string) preg_replace('/^([ \t]*)#[ \t]*('.$quoted.')/m', '$1$2', $content)
: (string) preg_replace('/^([ \t]*)('.$quoted.')/m', '$1#$2', $content);
}
return $content;
}
private function read(): ?string
{
$content = is_file($this->file()) ? @file_get_contents($this->file()) : false;
return false === $content ? null : $content;
}
private function write(string $content): bool
{
return is_writable($this->file()) && false !== @file_put_contents($this->file(), $content, LOCK_EX);
}
/**
* Eine Anfrage über http, ohne Weiterleitungen zu folgen: gefragt ist nur, wohin sie zeigt.
*
* @return array{status: int, location: string, error: string}
*/
private static function probeHttp(string $url): array
{
if (function_exists('curl_init')) {
$curl = curl_init($url);
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_NOBODY => false,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_PROTOCOLS => CURLPROTO_HTTP,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 10,
CURLOPT_USERAGENT => 'KantSites-HTTPS-Check',
]);
$raw = curl_exec($curl);
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$error = curl_error($curl);
curl_close($curl);
$location = is_string($raw) && preg_match('/^Location:[ \t]*(\S+)/mi', $raw, $match) ? $match[1] : '';
return ['status' => $status, 'location' => $location, 'error' => '' !== $error ? $error : 'keine Antwort'];
}
$context = stream_context_create(['http' => ['follow_location' => 0, 'ignore_errors' => true, 'timeout' => 10, 'user_agent' => 'KantSites-HTTPS-Check']]);
$http_response_header = [];
$body = @file_get_contents($url, false, $context);
$headers = $http_response_header;
if (false === $body && [] === $headers) {
return ['status' => 0, 'location' => '', 'error' => 'keine Verbindung über http'];
}
$status = preg_match('#^HTTP/\S+\s+(\d{3})#', (string) ($headers[0] ?? ''), $match) ? (int) $match[1] : 0;
$location = '';
foreach ($headers as $header) {
if (preg_match('/^Location:[ \t]*(\S+)/i', $header, $match)) {
$location = $match[1];
}
}
return ['status' => $status, 'location' => $location, 'error' => 'keine Antwort'];
}
}
+76 -12
View File
@@ -37,6 +37,14 @@ final class Installation
/** Longer than a person needs for a form, short enough that a stale tab stops working. */
public const TOKEN_LIFETIME = 7200;
/**
* Der Installationsschlüssel (ADR 116). Ohne ihn öffnet sich kein Schritt des Installers: wer
* eine frisch entpackte Installation zuerst aufruft, soll sie nicht übernehmen können.
* install.php legt ihn an und zeigt ihn nur dem Browser, der entpackt hat; wer per FTP
* entpackt, findet ihn in dieser Datei. Mit dem ersten Konto wird er gelöscht.
*/
public const KEY_FILE = 'var/install.key';
public function __construct(
private Connection $db,
private PageRepository $pages,
@@ -357,16 +365,73 @@ final class Installation
if (!hash_equals($password, $repeated)) {
throw new InvalidPage('Die beiden Passwörter stimmen nicht überein.');
}
if ($this->isInstalled()) {
throw new InvalidPage('Es gibt bereits ein Konto. Die Installation ist damit abgeschlossen.');
}
$user = new CmsUser(bin2hex(random_bytes(16)), $username, '', ['ROLE_ADMIN']);
$this->db->insert('cms_user', [
'id' => $user->id,
'username' => $username,
'password_hash' => $this->hasher->hashPassword($user, $password),
'roles' => json_encode($user->getRoles(), JSON_THROW_ON_ERROR),
]);
$hash = $this->hasher->hashPassword($user, $password);
// Prüfen und Anlegen in einer Transaktion: zwei gleichzeitige Absendungen sollen nicht zwei
// erste Konten ergeben.
$this->db->transactional(function () use ($user, $username, $hash): void {
if ($this->isInstalled()) {
throw new InvalidPage('Es gibt bereits ein Konto. Die Installation ist damit abgeschlossen.');
}
$this->db->insert('cms_user', [
'id' => $user->id,
'username' => $username,
'password_hash' => $hash,
'roles' => json_encode($user->getRoles(), JSON_THROW_ON_ERROR),
]);
});
// Der Schlüssel hat seine Arbeit getan; liegen bleiben soll er nicht.
@unlink($this->root.'/'.self::KEY_FILE);
}
/**
* Der Schlüssel dieser Installation; beim ersten Aufruf angelegt, wenn install.php es nicht
* schon getan hat. 128 Bit aus random_bytes - raten lohnt nicht, eine Sperre braucht es nicht.
*/
public function installKey(): string
{
$file = $this->root.'/'.self::KEY_FILE;
$key = is_file($file) ? trim((string) file_get_contents($file)) : '';
if (1 === preg_match('/^[a-f0-9]{32}$/D', $key)) {
return $key;
}
if (!is_dir(\dirname($file)) && !@mkdir(\dirname($file), 0o775, true) && !is_dir(\dirname($file))) {
throw new \RuntimeException('var/ ist nicht beschreibbar; ohne Installationsschlüssel geht es nicht weiter.');
}
$key = bin2hex(random_bytes(16));
// x: nur anlegen, wenn es die Datei noch nicht gibt - zwei gleichzeitige erste Aufrufe
// sollen sich nicht gegenseitig den Schlüssel überschreiben.
$handle = @fopen($file, 'x');
if (false === $handle) {
$existing = trim((string) @file_get_contents($file));
if (1 === preg_match('/^[a-f0-9]{32}$/D', $existing)) {
return $existing;
}
throw new \RuntimeException('Der Installationsschlüssel ließ sich nicht schreiben ('.self::KEY_FILE.').');
}
fwrite($handle, $key.'
');
fclose($handle);
@chmod($file, 0o600);
return $key;
}
/** Ob die Eingabe der Schlüssel ist - Leerzeichen und Großbuchstaben aus dem Abtippen zählen nicht. */
public function acceptsKey(string $given): bool
{
return hash_equals($this->installKey(), strtolower(preg_replace('/\s+/', '', $given) ?? ''));
}
/** Was als Cookie im Browser bleibt: nicht der Schlüssel selbst, sondern ein Nachweis über ihn. */
public function keyProof(): string
{
return hash_hmac('sha256', 'installer', $this->installKey());
}
public function acceptsProof(string $proof): bool
{
return '' !== $proof && hash_equals($this->keyProof(), $proof);
}
/**
@@ -374,9 +439,8 @@ final class Installation
*
* The session cookie of this application is scoped to `/admin`, so the installer has no
* session - and it should not get one for six screens. The signature ties a form to a step and
* to a point in time. It is not protection against somebody who can reach the installer: that
* protection is that the installer stops existing after the first account, which is why the
* last screen says to install right after uploading.
* to a point in time. Wer überhaupt an den Installer darf, entscheidet seit ADR 116 der
* Installationsschlüssel; die Unterschrift bindet nur das Formular an seinen Schritt.
*/
public function sign(string $step, int $issued): string
{
+299
View File
@@ -0,0 +1,299 @@
<?php
namespace App\Ops;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mailer\Transport\Dsn;
use Symfony\Component\Mailer\Transport\TransportInterface;
use Symfony\Component\Mime\Email;
/**
* Versand mit Protokoll (ADR 121).
*
* huefner-gartenbau.de am 12.10.2026: zwei Anfragen „Nicht versendet“, und nirgends stand,
* warum. Der Formularversand hat die Ausnahme verschluckt; die Ursache - sendmail, das der Hoster
* so nicht anbietet, ein falsches SMTP-Passwort, ein Absender, den der Server nicht annimmt -
* blieb Raten. Jetzt geht jeder Versand durch diese Klasse: Formular und Testmail aus der
* Verwaltung. Sie schreibt eine Zeile nach var/log/mail.log (per FTP lesbar) mit Meldung, einem
* Hinweis auf Deutsch und den Antworten des Servers.
*
* Aus dem SMTP-Gespräch bleiben nur die Zeilen des Servers. Was der Client schickt, enthält bei
* AUTH LOGIN Benutzer und Passwort in Base64 - das gehört in kein Protokoll.
*/
final class MailCheck
{
/** Größer wird die Datei nicht; die ältere Hälfte wandert nach mail.log.1. */
private const GRENZE = 262144;
/**
* Der Transport erst beim ersten Versand: Eine ungültige MAILER_DSN soll den Versand scheitern
* lassen und im Protokoll stehen, nicht schon die Seite, die ihn anbietet.
*/
/** Baut einen Transport aus einer DSN, die (noch) nicht eingestellt ist - zum Ausprobieren. */
private \Closure $fabrik;
public function __construct(#[Autowire(service: 'mailer.transports', lazy: true)] private TransportInterface $transport, private string $dsn, private string $logFile, ?\Closure $fabrik = null)
{
$this->fabrik = $fabrik ?? static fn (string $dsn): TransportInterface => Transport::fromDsn($dsn);
}
/**
* Die Wege, die ein gewöhnliches Hosting ohne Zugangsdaten anbietet (ADR 122). Bei IONOS etwa
* scheitert sendmail -bs, mail() geht.
*
* @return array<string, array{label: string, dsn: string}>
*/
public static function serverWege(): array
{
return [
'native' => ['label' => 'PHP mail() des Servers', 'dsn' => 'native://default'],
'sendmail-t' => ['label' => 'sendmail -t des Servers', 'dsn' => 'sendmail://default?command=/usr/sbin/sendmail%20-t%20-i'],
'sendmail-bs' => ['label' => 'sendmail -bs des Servers', 'dsn' => 'sendmail://default'],
];
}
/** smtp://… aus Einzelfeldern; Benutzer und Passwort URL-kodiert. Port 465 heißt smtps. */
public static function smtpDsn(string $server, int $port, string $benutzer, string $passwort): string
{
$server = trim($server);
if (1 !== preg_match('/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/iD', $server)) {
throw new \InvalidArgumentException('Der SMTP-Server ist kein gültiger Rechnername, etwa smtp.ionos.de.');
}
if ($port < 1 || $port > 65535) {
throw new \InvalidArgumentException('Der Port muss zwischen 1 und 65535 liegen, meist 587.');
}
$anmeldung = '' === $benutzer ? '' : rawurlencode($benutzer).('' === $passwort ? '' : ':'.rawurlencode($passwort)).'@';
return (465 === $port ? 'smtps' : 'smtp').'://'.$anmeldung.strtolower($server).':'.$port;
}
/**
* Die aktuelle Einstellung zum Vorbelegen der Felder - ohne Passwort, nur ob es eins gibt.
*
* @return array{weg: string, server: string, port: int, benutzer: string, passwort: bool}
*/
public function einstellung(): array
{
$leer = ['weg' => 'smtp', 'server' => '', 'port' => 587, 'benutzer' => '', 'passwort' => false];
foreach (self::serverWege() as $schluessel => $weg) {
if ($weg['dsn'] === $this->dsn) {
return ['weg' => $schluessel] + $leer;
}
}
try {
$dsn = Dsn::fromString($this->dsn);
} catch (\Throwable) {
return $leer;
}
if (!in_array($dsn->getScheme(), ['smtp', 'smtps'], true)) {
return ['weg' => 'native' === $dsn->getScheme() ? 'native' : ('sendmail' === $dsn->getScheme() ? 'sendmail-bs' : 'smtp')] + $leer;
}
return [
'weg' => 'smtp',
'server' => $dsn->getHost(),
'port' => $dsn->getPort() ?? ('smtps' === $dsn->getScheme() ? 465 : 587),
'benutzer' => (string) $dsn->getUser(),
'passwort' => '' !== (string) $dsn->getPassword(),
];
}
/** Das gespeicherte SMTP-Passwort, wenn Server und Benutzer gleich bleiben - sonst leer. */
public function gespeichertesPasswort(string $server, string $benutzer): string
{
try {
$dsn = Dsn::fromString($this->dsn);
} catch (\Throwable) {
return '';
}
if (!in_array($dsn->getScheme(), ['smtp', 'smtps'], true) || strtolower($dsn->getHost()) !== strtolower(trim($server)) || (string) $dsn->getUser() !== $benutzer) {
return '';
}
return (string) $dsn->getPassword();
}
/**
* Schickt die Nachricht und hält das Ergebnis fest. Wirft nie - ein kaputter Versand darf
* keine Anfrage und keine Seite kosten.
*
* Mit $dsn geht der Versand über diesen Weg statt über den eingestellten - zum Ausprobieren
* vor dem Speichern.
*
* @return array{ok: bool, meldung: string, hinweis: string, dialog: string, weg: string}
*/
public function send(Email $email, string $anlass, ?string $dsn = null): array
{
$an = implode(', ', array_map(static fn ($adresse): string => $adresse->getAddress(), $email->getTo()));
$scheme = self::schemeVon($dsn ?? $this->dsn);
try {
// Derselbe Weg wie eingestellt: der Transport des Frameworks, samt seinen Ereignissen.
$transport = null === $dsn || $dsn === $this->dsn ? $this->transport : ($this->fabrik)($dsn);
$gesendet = $transport->send($email);
$ergebnis = [
'ok' => true,
'meldung' => 'Vom Mailserver angenommen.',
'hinweis' => self::hinweisNachErfolg($scheme),
'dialog' => self::dialog((string) $gesendet?->getDebug()),
];
} catch (TransportExceptionInterface $fehler) {
$ergebnis = [
'ok' => false,
'meldung' => self::einzeilig($fehler->getMessage()),
'hinweis' => self::hinweis($fehler->getMessage(), $scheme),
'dialog' => self::dialog($fehler->getDebug()),
];
} catch (\Throwable $fehler) {
// Eine ungültige MAILER_DSN fällt erst hier auf, nicht beim Start.
$ergebnis = [
'ok' => false,
'meldung' => self::einzeilig($fehler->getMessage()),
'hinweis' => self::hinweis($fehler->getMessage(), $scheme),
'dialog' => '',
];
}
$weg = $this->route($dsn);
$ergebnis['weg'] = trim($weg['art'].' '.$weg['ziel']);
$this->protokollieren(['zeit' => gmdate('Y-m-d H:i:s'), 'anlass' => $anlass, 'an' => $an, 'von' => $email->getFrom()[0]?->getAddress() ?? ''] + $ergebnis);
return $ergebnis;
}
/**
* Wie versendet wird - ohne Passwort.
*
* @return array{art: string, ziel: string, benutzer: string, gueltig: bool}
*/
public function route(?string $roh = null): array
{
try {
$dsn = Dsn::fromString($roh ?? $this->dsn);
} catch (\Throwable) {
return ['art' => 'ungültig', 'ziel' => '', 'benutzer' => '', 'gueltig' => false];
}
$art = match ($dsn->getScheme()) {
'smtp', 'smtps' => 'SMTP',
'sendmail', 'sendmail+smtp' => str_contains((string) $dsn->getOption('command'), ' -t') ? 'sendmail -t des Servers' : 'sendmail -bs des Servers',
'native' => 'PHP mail() des Servers',
'null' => 'kein Versand (null)',
default => $dsn->getScheme(),
};
$port = $dsn->getPort();
return [
'art' => $art,
'ziel' => in_array($dsn->getScheme(), ['smtp', 'smtps'], true) ? $dsn->getHost().(null !== $port ? ':'.$port : '') : '',
'benutzer' => (string) $dsn->getUser(),
'gueltig' => true,
];
}
/**
* Die letzten Einträge, neueste zuerst.
*
* @return list<array<string, mixed>>
*/
public function recent(int $anzahl = 10): array
{
if (!is_file($this->logFile)) {
return [];
}
$zeilen = file($this->logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
$eintraege = [];
foreach (array_reverse(array_slice($zeilen, -$anzahl)) as $zeile) {
$eintrag = json_decode($zeile, true);
if (is_array($eintrag)) {
$eintraege[] = $eintrag;
}
}
return $eintraege;
}
/** Protokoll leeren: die Datei und ihre ältere Hälfte. */
public function clearLog(): void
{
foreach ([$this->logFile, $this->logFile.'.1'] as $datei) {
if (is_file($datei)) {
@unlink($datei);
}
}
}
public function logFile(): string
{
return $this->logFile;
}
/** Was als Nächstes zu tun ist, für die häufigen Fälle. Leer, wenn nichts passt. */
public static function hinweis(string $meldung, string $scheme): string
{
$m = strtolower($meldung);
return match (true) {
'null' === $scheme => 'MAILER_DSN steht auf null://null - es wird gar nichts verschickt.',
(str_contains($m, 'dsn') && (str_contains($m, 'invalid') || str_contains($m, 'malformed') || str_contains($m, 'must contain'))) || (str_contains($m, 'scheme') && str_contains($m, 'not supported')) => 'Die Einstellung des Versands ist ungültig. Unter System → E-Mail-Versand neu setzen.',
str_contains($m, '535') || str_contains($m, 'authenticat') => 'Der Mailserver lehnt die Anmeldung ab. Benutzer (die volle Adresse) und Passwort prüfen; Sonderzeichen im Passwort URL-kodieren (# → %23, / → %2F, % → %25, @ → %40). Bei Microsoft 365 muss für das Postfach „Authentifiziertes SMTP“ eingeschaltet sein.',
str_contains($m, 'connection could not be established') || str_contains($m, 'connection refused') || str_contains($m, 'timed out') || str_contains($m, 'getaddrinfo') || str_contains($m, 'unable to connect') => 'Der Mailserver ist von hier aus nicht erreichbar. Servername und Port prüfen (meist 587); manche Hoster sperren ausgehende Verbindungen auf bestimmten Ports.',
str_contains($m, 'certificate') || str_contains($m, 'ssl') || str_contains($m, 'tls') => 'Die verschlüsselte Verbindung kam nicht zustande. Port 587 nutzt STARTTLS, Port 465 braucht smtps:// - Port und Schema müssen zusammenpassen, und der Servername muss zum Zertifikat passen.',
str_contains($m, 'sender address is not allowed') || str_contains($m, 'envelope from address') => 'Der Server lehnt den Absender ab. Beim Versand über den Server (mail(), sendmail) muss der Absender zu einer eigenen Domain beim Hoster gehören, nicht etwa Gmail; bei SMTP ist es meist genau das angemeldete Postfach.',
str_contains($scheme, 'sendmail') => 'sendmail ist auf diesem Server so nicht nutzbar. Zuerst „PHP mail() des Servers“ probieren, dauerhaft besser SMTP mit einem echten Postfach.',
1 === preg_match('/\b55[0-4]\b/', $m) || str_contains($m, 'sender') || str_contains($m, 'relay') => 'Der Mailserver nimmt Absender oder Empfänger nicht an. Der Absender muss meist genau das Postfach sein, mit dem angemeldet wird.',
default => '',
};
}
/** Angenommen heißt nicht angekommen - die üblichen Gründe dafür. */
public static function hinweisNachErfolg(string $scheme): string
{
if ('null' === $scheme) {
return self::hinweis('', 'null');
}
return 'Kommt nichts an: Spam-Ordner prüfen. Der Absender sollte zur eigenen Domain gehören (SPF). Liegt das Postfach des Empfängers nicht beim Hoster (etwa Microsoft 365), darf die Domain dort nicht auch als Mail-Domain eingerichtet sein - sonst stellt der Hoster intern zu.';
}
/**
* Nur die Antworten des Servers aus dem SMTP-Gespräch, höchstens die letzten zwölf.
* Symfony schreibt sie mit „< “ am Anfang, die eigenen Zeilen mit „> “.
*/
public static function dialog(string $debug): string
{
$zeilen = [];
foreach (preg_split('/\R/', $debug) ?: [] as $zeile) {
if (str_starts_with($zeile, '< ')) {
$zeilen[] = $zeile;
}
}
return implode("\n", array_slice($zeilen, -12));
}
private static function schemeVon(string $roh): string
{
try {
return Dsn::fromString($roh)->getScheme();
} catch (\Throwable) {
return '';
}
}
private static function einzeilig(string $text): string
{
return mb_substr(trim((string) preg_replace('/\s+/', ' ', $text)), 0, 1000);
}
private function protokollieren(array $eintrag): void
{
$ordner = dirname($this->logFile);
if (!is_dir($ordner)) {
@mkdir($ordner, 0o775, true);
}
if (is_file($this->logFile) && filesize($this->logFile) > self::GRENZE) {
@rename($this->logFile, $this->logFile.'.1');
}
@file_put_contents($this->logFile, json_encode($eintrag, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE)."\n", FILE_APPEND | LOCK_EX);
}
}
+123
View File
@@ -0,0 +1,123 @@
<?php
namespace App\Ops;
/**
* Die letzte Sicherung, wenn die Anwendung nicht einmal mehr eine Fehlerseite zustande bringt
* (ADR 113).
*
* Kantarci Reisen am 09.10.2026: Datenbank per FTP ersetzt, danach antwortete jede Adresse mit
* einem leeren 500er - für Besucher eine weiße Seite, für den Betreiber kein Hinweis, warum. Der
* Grund stand nur im Fehlerprotokoll des Hostings.
*
* Diese Klasse hängt an nichts aus dem Framework: kein Container, keine Datenbank, keine
* Vorlage. Sie schreibt den Grund nach var/log/notfall.log (per FTP lesbar, außerhalb von
* public/) und zeigt Besuchern eine schlichte Wartungsseite mit 503 - für Suchmaschinen heißt das
* „gleich wieder da“, nicht „kaputt“. Details stehen nie auf der Seite.
*/
final class Notfall
{
public const PROTOKOLL = 'var/log/notfall.log';
/** Größer wird die Datei nicht; die ältere Hälfte wandert nach notfall.log.1. */
private const GRENZE = 1048576;
/** Fehlerarten, nach denen PHP abbricht - nur die gehören auf die Notfallseite. */
private const ABBRUCH = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR];
public const KOPFZEILEN = [
'Content-Type' => 'text/html; charset=UTF-8',
'Retry-After' => '300',
'Cache-Control' => 'no-store',
'X-Robots-Tag' => 'noindex',
'Content-Security-Policy' => "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'",
];
private static ?string $wurzel = null;
private static bool $gezeigt = false;
/** Einmal ganz am Anfang von public/index.php. */
public static function einschalten(string $wurzel): void
{
self::$wurzel = $wurzel;
register_shutdown_function(static function (): void {
$fehler = error_get_last();
if (null === $fehler || !in_array($fehler['type'], self::ABBRUCH, true)) {
return;
}
self::melden(sprintf('PHP-Abbruch: %s in %s:%d', $fehler['message'], $fehler['file'], $fehler['line']));
self::seite();
// Danach soll nichts mehr ausgeben - auch nicht die Fehlerseite des Frameworks, das
// sich ebenfalls fürs Herunterfahren angemeldet hat. exit in einer Shutdown-Funktion
// beendet die übrigen.
exit;
});
}
/** Eine Ausnahme, die aus dem Framework herausgefallen ist. */
public static function ausnahme(\Throwable $fehler, string $adresse = ''): void
{
self::melden(self::beschreiben($fehler, $adresse));
self::seite();
}
/** Ein Fehler, den das Framework abgefangen und als 500 beantwortet hat - nur protokollieren. */
public static function protokollieren(\Throwable $fehler, string $adresse = ''): void
{
self::melden(self::beschreiben($fehler, $adresse));
}
public static function beschreiben(\Throwable $fehler, string $adresse = ''): string
{
$zeile = sprintf('%s: %s in %s:%d', $fehler::class, $fehler->getMessage(), $fehler->getFile(), $fehler->getLine());
// Die eigentliche Ursache steckt oft eine Ebene tiefer, etwa das SQLite-Problem unter
// einer Doctrine-Ausnahme.
for ($tiefer = $fehler->getPrevious(), $n = 0; null !== $tiefer && $n < 3; $tiefer = $tiefer->getPrevious(), ++$n) {
$zeile .= sprintf(' | Ursache: %s: %s', $tiefer::class, $tiefer->getMessage());
}
return ('' === $adresse ? '' : $adresse.' – ').$zeile;
}
private static function melden(string $text): void
{
$wurzel = self::$wurzel ?? dirname(__DIR__, 2);
$datei = $wurzel.'/'.self::PROTOKOLL;
if (!is_dir(dirname($datei))) {
@mkdir(dirname($datei), 0o775, true);
}
if (is_file($datei) && filesize($datei) > self::GRENZE) {
@rename($datei, $datei.'.1');
}
// Zeilenumbrüche aus Meldungen würden das Protokoll zerreißen; eine Zeile je Ereignis.
$text = str_replace(["\r", "\n"], ' ', $text);
@file_put_contents($datei, sprintf("[%s UTC] %s\n", gmdate('Y-m-d H:i:s'), mb_substr($text, 0, 4000)), FILE_APPEND | LOCK_EX);
}
/** Die Seite für Besucher. Ohne Details, ohne Abhängigkeiten, ohne fremde Quellen. */
private static function seite(): void
{
if (self::$gezeigt || PHP_SAPI === 'cli') {
return;
}
self::$gezeigt = true;
while (ob_get_level() > 0) {
@ob_end_clean();
}
if (!headers_sent()) {
http_response_code(503);
foreach (self::KOPFZEILEN as $name => $wert) {
header($name.': '.$wert);
}
}
echo self::html();
}
/** Die Wartungsseite als Text, auch für den Fehler-Listener im Framework. */
public static function html(): string
{
return '<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">'
.'<title>Gleich wieder da</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;font:17px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif;background:#f4f6f5;color:#1f2d27}'
.'main{max-width:460px;padding:32px;text-align:center}h1{font-size:26px;line-height:1.25;margin:0 0 12px}p{margin:0;color:#46514c}</style></head>'
.'<body><main><h1>Die Website ist gleich wieder da.</h1><p>Gerade wird etwas erneuert. Bitte versuche es in ein paar Minuten noch einmal.</p>'
.'<p lang="en" style="margin-top:14px;font-size:14px">This website will be back shortly.</p></main></body></html>';
}
}
+5 -1
View File
@@ -13,7 +13,11 @@ namespace App\Ops;
*/
class OriginCheck
{
public const PATH = '/.well-known/kantsites-origin';
/**
* Bewusst nicht unter /.well-known/: IONOS beantwortet alles darunter selbst mit 403, bevor
* die Anfrage die Installation erreicht (huefner-gartenbau.de, 12.10.2026, ADR 122).
*/
public const PATH = '/kantsites-origin';
private Download $download;
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Ops;
/**
* Sperrdateien, die dafür sorgen, dass nichts außer public/ über das Netz erreichbar ist - auch
* wenn die Domain versehentlich auf den Projektordner zeigt (ADR 116).
*
* Die meisten liegen im Release (bin/, config/, src/ …, vendor/ und der Projektstamm). Zwei
* entstehen erst auf dem Server: var/ gibt es im Archiv nicht, und die Datei im Projektstamm soll
* auch eine Installation bekommen, die von einer Fassung vor 1.30.0 aktualisiert wurde - deren
* Updater kannte sie noch nicht. Angelegt wird nur, was fehlt; eine eigene .htaccess des Betreibers
* im Projektstamm bleibt unberührt.
*/
final class Protection
{
private static bool $checked = false;
public static function ensure(string $root): void
{
if (self::$checked) {
return;
}
self::$checked = true;
$sperren = $root.'/config/schutz/sperren.htaccess';
$stamm = $root.'/config/schutz/stamm.htaccess';
foreach ([$root.'/var/.htaccess' => $sperren, $root.'/vendor/.htaccess' => $sperren, $root.'/.htaccess' => $stamm] as $ziel => $vorlage) {
if (!is_file($ziel) && is_dir(\dirname($ziel)) && is_file($vorlage)) {
@copy($vorlage, $ziel);
}
}
}
/** Für Tests: die nächste Anfrage prüft wieder. */
public static function reset(): void
{
self::$checked = false;
}
}
+486
View File
@@ -0,0 +1,486 @@
<?php
namespace App\Ops;
use App\Content\DocumentValidator;
use App\Domain\InvalidDocument;
use App\Domain\InvalidPage;
use Doctrine\DBAL\Connection;
/**
* Inhalte einer Website als Paket - exportieren und in eine andere Installation importieren
* (ADR 118).
*
* Anlass: Eine lokal aufgebaute Website wurde bisher als ganze Datenbankdatei hochgeladen. Damit
* kamen auch die Konten mit, und nach jeder Änderung galt live ein neues Passwort; was nur live
* entstanden war (Anfragen, Statistik), war weg. Ein Paket trägt deshalb nur Inhalte: Seiten mit
* allen Fassungen, Bereiche, Menüs, Medien, Formulare, Weiterleitungen, Kategorien, Übersetzungen,
* Gestaltung und Einstellungen. Konten nur, wenn man sie ausdrücklich mitnimmt - und beim Import
* werden sie nur übernommen, wenn man das ausdrücklich wählt.
*
* Das Format ist Zip mit JSON statt einer SQLite-Datei: So geht ein Paket von SQLite nach MySQL
* und zurück, und der Import prüft jede Zeile, bevor er schreibt.
*/
final class SitePackage
{
public const FORMAT = 1;
public const MANIFEST = 'paket.json';
public const DATA = 'inhalt.json';
public const MEDIA = 'medien/';
/** Was ersetzt wird, in der Reihenfolge des Einfügens (revision gehört zu page). */
public const CONTENT_TABLES = ['page', 'revision', 'partial', 'menu', 'media', 'document', 'form', 'redirect', 'term', 'page_term', 'page_translation'];
/** Einstellungen dieser Installation, die ein Paket nie überschreibt: Zugangsdaten zu Diensten. */
public const KEEP_SETTINGS = ['turnstile', 'turnstileSiteKey', 'turnstileSecret'];
/** Dateinamen im Medienordner: Bildvarianten, PDFs, erzeugte Seitensymbole. Sonst nichts. */
public const MEDIA_NAME = '/^(?:[a-f0-9]{32}(?:-\d{1,5})?\.(?:webp|pdf)|favicon-\d{1,4}\.png)$/D';
private const MAX_FILES = 5000;
private const MAX_FILE_BYTES = 64 * 1024 * 1024;
private const MAX_TOTAL_BYTES = 1024 * 1024 * 1024;
private const MAX_DATA_BYTES = 64 * 1024 * 1024;
public function __construct(
private Connection $db,
private DocumentValidator $validator,
private Backup $backup,
private Updater $updater,
private string $root,
private string $mediaDirectory,
) {
}
/** Wohin Pakete gehören, die per FTP kommen, und wo Exporte entstehen. */
public function directory(): string
{
return $this->root.'/var/pakete';
}
/**
* Schreibt ein Paket und gibt seinen Pfad zurück.
*
* @throws InvalidPage
*/
public function export(bool $withUsers = false, ?string $target = null): string
{
if (!class_exists(\ZipArchive::class)) {
throw new InvalidPage('Ohne die PHP-Erweiterung zip lässt sich kein Paket schreiben.');
}
$tables = [];
foreach (self::CONTENT_TABLES as $table) {
$tables[$table] = $this->rows($table);
}
$website = $this->db->fetchAssociative('SELECT id, name, default_locale, theme, settings FROM website WHERE id = ?', ['default']);
if (false === $website) {
throw new InvalidPage('Diese Installation hat noch keine Website.');
}
// Zugangsdaten zu Diensten bleiben, wo sie sind - auch im Paket stehen sie nicht.
$settings = json_decode((string) ($website['settings'] ?? '{}'), true) ?: [];
foreach (self::KEEP_SETTINGS as $key) {
unset($settings[$key]);
}
$website['settings'] = json_encode($settings, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$tables['website'] = [$website];
if ($withUsers) {
$tables['cms_user'] = $this->rows('cms_user');
}
$data = json_encode(['tabellen' => $tables], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$files = [];
foreach (scandir($this->mediaDirectory) ?: [] as $name) {
if (1 === preg_match(self::MEDIA_NAME, $name) && is_file($this->mediaDirectory.'/'.$name)) {
$files[] = $name;
}
}
$manifest = [
'format' => self::FORMAT,
'art' => 'KantSites-Inhaltspaket',
'fassung' => $this->backup->version(),
'erstellt' => gmdate('c'),
'website' => (string) $website['name'],
'benutzer' => $withUsers,
'inhalt' => array_map('count', $tables),
'medien' => count($files),
'sha256' => hash('sha256', $data),
];
$target ??= $this->directory().'/'.$this->fileName((string) $website['name']);
if (!is_dir(\dirname($target)) && !@mkdir(\dirname($target), 0o775, true)) {
throw new InvalidPage('Der Ordner für Pakete ließ sich nicht anlegen. Schreibrechte auf var/ prüfen.');
}
$zip = new \ZipArchive();
if (true !== $zip->open($target, \ZipArchive::CREATE | \ZipArchive::OVERWRITE)) {
throw new InvalidPage('Das Paket ließ sich nicht anlegen.');
}
$zip->addFromString(self::MANIFEST, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$zip->addFromString(self::DATA, $data);
foreach ($files as $name) {
$zip->addFile($this->mediaDirectory.'/'.$name, self::MEDIA.$name);
}
$zip->close();
return $target;
}
/** Pakete, die per FTP in var/pakete liegen - neueste zuerst. @return list<array{name: string, bytes: int, manifest: ?array}> */
public function waiting(): array
{
$found = [];
foreach (glob($this->directory().'/*.zip') ?: [] as $file) {
try {
$manifest = $this->inspect($file);
} catch (InvalidPage) {
$manifest = null;
}
$found[] = ['name' => basename($file), 'bytes' => (int) filesize($file), 'manifest' => $manifest, 'time' => (int) filemtime($file)];
}
usort($found, static fn (array $a, array $b): int => $b['time'] <=> $a['time']);
return $found;
}
/**
* Prüft ein Paket, ohne etwas zu schreiben, und gibt sein Manifest zurück.
*
* @throws InvalidPage
*/
public function inspect(string $file): array
{
$zip = $this->open($file);
try {
return $this->checkedManifest($zip);
} finally {
$zip->close();
}
}
/**
* Spielt ein Paket ein.
*
* @return array{ok: bool, steps: list<array{step: string, ok: bool, detail: string}>, users: bool}
*/
public function import(string $file, bool $withUsers = false): array
{
$steps = [];
$step = static function (string $name, bool $ok, string $detail) use (&$steps): bool {
$steps[] = ['step' => $name, 'ok' => $ok, 'detail' => $detail];
return $ok;
};
$fail = static function () use (&$steps): array {
return ['ok' => false, 'steps' => $steps, 'users' => false];
};
try {
$zip = $this->open($file);
} catch (InvalidPage $e) {
$step('Paket öffnen', false, $e->getMessage());
return $fail();
}
try {
try {
$manifest = $this->checkedManifest($zip);
$step('Paket prüfen', true, sprintf('„%s“ vom %s, Fassung %s, %d Seite(n), %d Mediendatei(en).', $manifest['website'], substr((string) $manifest['erstellt'], 0, 10), $manifest['fassung'], (int) ($manifest['inhalt']['page'] ?? 0), (int) $manifest['medien']));
$tables = $this->checkedData($zip, $manifest);
$this->validateDocuments($tables);
$step('Inhalte prüfen', true, 'Alle Tabellen bekannt, alle Seiten- und Bereichsdokumente gültig.');
} catch (InvalidPage|InvalidDocument $e) {
$step('Paket prüfen', false, $e->getMessage());
return $fail();
}
if ($withUsers && [] === ($tables['cms_user'] ?? [])) {
$step('Konten', false, 'Das Paket enthält keine Konten. Ohne Häkchen bei „Konten übernehmen“ bleiben die Konten dieser Website.');
return $fail();
}
try {
$safety = $this->backup->create('vor Import');
$step('Aktuellen Stand sichern', true, $safety['name'].' – damit lässt sich der Import zurücknehmen (cms:restore).');
} catch (\Throwable $e) {
$step('Aktuellen Stand sichern', false, $e->getMessage().' Ohne Sicherung wird nicht importiert.');
return $fail();
}
$this->updater->maintenance(true);
try {
$media = $this->extractMedia($zip);
$this->write($tables, $withUsers);
$step('Inhalte ersetzen', true, $this->summary($tables, $withUsers));
$moved = $this->replaceMedia($media);
$step('Medien ersetzen', true, sprintf('%d Datei(en) übernommen.', $moved));
} catch (\Throwable $e) {
$step('Inhalte ersetzen', false, $e->getMessage().' Die Datenbank blieb unverändert; die Sicherung liegt bereit.');
return $fail();
} finally {
$this->updater->maintenance(false);
if (isset($media)) {
$this->remove($media);
}
}
} finally {
$zip->close();
}
return ['ok' => true, 'steps' => $steps, 'users' => $withUsers];
}
/** @throws InvalidPage */
private function open(string $file): \ZipArchive
{
if (!class_exists(\ZipArchive::class)) {
throw new InvalidPage('Ohne die PHP-Erweiterung zip lässt sich kein Paket lesen.');
}
$zip = new \ZipArchive();
if (!is_file($file) || true !== $zip->open($file, \ZipArchive::RDONLY)) {
throw new InvalidPage('Das ist kein lesbares Zip-Archiv.');
}
// Jeder Eintrag wird geprüft, bevor irgendetwas gelesen wird: nur die drei bekannten
// Arten, keine Pfade nach oben, keine übergroßen Dateien (Zip-Bombe).
if ($zip->numFiles > self::MAX_FILES + 2) {
$zip->close();
throw new InvalidPage('Das Paket enthält zu viele Dateien.');
}
$total = 0;
for ($i = 0; $i < $zip->numFiles; ++$i) {
$stat = $zip->statIndex($i);
$name = (string) ($stat['name'] ?? '');
$total += (int) ($stat['size'] ?? 0);
$known = self::MANIFEST === $name || self::DATA === $name || self::MEDIA === $name
|| (str_starts_with($name, self::MEDIA) && 1 === preg_match(self::MEDIA_NAME, substr($name, \strlen(self::MEDIA))));
if (!$known || (int) ($stat['size'] ?? 0) > self::MAX_FILE_BYTES) {
$zip->close();
throw new InvalidPage(sprintf('„%s“ gehört nicht in ein Inhaltspaket. Es wird nicht eingespielt.', mb_substr($name, 0, 80)));
}
}
if ($total > self::MAX_TOTAL_BYTES) {
$zip->close();
throw new InvalidPage('Das Paket ist entpackt größer als 1 GB.');
}
return $zip;
}
/** @throws InvalidPage */
private function checkedManifest(\ZipArchive $zip): array
{
$raw = $zip->getFromName(self::MANIFEST);
$manifest = false === $raw ? null : json_decode($raw, true);
if (!\is_array($manifest) || ($manifest['art'] ?? null) !== 'KantSites-Inhaltspaket' || ($manifest['format'] ?? null) !== self::FORMAT) {
throw new InvalidPage('Das ist kein KantSites-Inhaltspaket (paket.json fehlt oder passt nicht).');
}
if (1 !== preg_match('/^\d+\.\d+\.\d+$/D', (string) ($manifest['fassung'] ?? ''))) {
throw new InvalidPage('Im Paket fehlt die Fassung, aus der es stammt.');
}
// Ein Paket aus einer neueren Fassung kann Inhalte tragen, die dieser Kern nicht kennt.
if (version_compare((string) $manifest['fassung'], $this->backup->version(), '>')) {
throw new InvalidPage(sprintf('Das Paket stammt aus Fassung %s, installiert ist %s. Erst diese Website aktualisieren.', $manifest['fassung'], $this->backup->version()));
}
return $manifest;
}
/**
* @return array<string, list<array<string, mixed>>>
*
* @throws InvalidPage
*/
private function checkedData(\ZipArchive $zip, array $manifest): array
{
$stat = $zip->statName(self::DATA);
if (false === $stat || $stat['size'] > self::MAX_DATA_BYTES) {
throw new InvalidPage('inhalt.json fehlt oder ist zu groß.');
}
$raw = (string) $zip->getFromName(self::DATA);
if (!hash_equals((string) ($manifest['sha256'] ?? ''), hash('sha256', $raw))) {
throw new InvalidPage('Die Prüfsumme von inhalt.json stimmt nicht. Das Paket ist beschädigt oder verändert.');
}
$data = json_decode($raw, true, 32);
$tables = \is_array($data) ? ($data['tabellen'] ?? null) : null;
if (!\is_array($tables)) {
throw new InvalidPage('inhalt.json ist nicht lesbar.');
}
$allowed = [...self::CONTENT_TABLES, 'website', 'cms_user'];
foreach ($tables as $table => $rows) {
if (!\in_array($table, $allowed, true) || !\is_array($rows) || !array_is_list($rows)) {
throw new InvalidPage(sprintf('Die Tabelle „%s“ gehört nicht in ein Inhaltspaket.', mb_substr((string) $table, 0, 40)));
}
foreach ($rows as $row) {
if (!\is_array($row) || array_is_list($row) && [] !== $row) {
throw new InvalidPage(sprintf('Eine Zeile der Tabelle „%s“ ist ungültig.', $table));
}
foreach ($row as $column => $value) {
if (1 !== preg_match('/^[a-z_][a-z0-9_]{0,63}$/D', (string) $column) || !(null === $value || \is_scalar($value))) {
throw new InvalidPage(sprintf('Eine Spalte der Tabelle „%s“ ist ungültig.', $table));
}
}
}
}
foreach (self::CONTENT_TABLES as $table) {
if (!isset($tables[$table])) {
throw new InvalidPage(sprintf('Im Paket fehlt die Tabelle „%s“.', $table));
}
}
if (1 !== \count($tables['website'] ?? []) || 'default' !== ($tables['website'][0]['id'] ?? null)) {
throw new InvalidPage('Im Paket fehlen die Angaben zur Website.');
}
return $tables;
}
/** Jede Fassung und jeder Bereich muss ein gültiges Dokument sein - kaputte Inhalte kommen nicht herein. @throws InvalidDocument|InvalidPage */
private function validateDocuments(array $tables): void
{
foreach (['revision', 'partial'] as $table) {
foreach ($tables[$table] as $row) {
$document = json_decode((string) ($row['document'] ?? ''), true);
if (!\is_array($document)) {
throw new InvalidPage(sprintf('Ein Dokument der Tabelle „%s“ ist kein JSON.', $table));
}
// Bereiche brauchen keine Hauptüberschrift, Seiten schon - wie beim Speichern.
$this->validator->validate($document, 'revision' === $table);
}
}
}
/** Ersetzt die Inhalte in einer Transaktion. */
private function write(array $tables, bool $withUsers): void
{
$this->db->transactional(function () use ($tables, $withUsers): void {
// Anfragen behalten ihr Formular: Die Kennung eines Formulars kann sich mit dem Paket
// ändern, die Adresse (slug) bleibt - über sie werden die Anfragen neu zugeordnet.
$slugs = [];
foreach ($this->db->fetchAllAssociative('SELECT id, slug FROM form') as $form) {
$slugs[$form['id']] = $form['slug'];
}
foreach (array_reverse(self::CONTENT_TABLES) as $table) {
$this->db->executeStatement('DELETE FROM '.$table);
}
foreach (self::CONTENT_TABLES as $table) {
$this->insert($table, $tables[$table]);
}
$this->mergeWebsite($tables['website'][0]);
if ($withUsers) {
$this->db->executeStatement('DELETE FROM cms_user');
$this->insert('cms_user', $tables['cms_user']);
}
foreach ($this->db->fetchAllAssociative('SELECT id, slug FROM form') as $form) {
foreach (array_keys($slugs, $form['slug'], true) as $old) {
$this->db->executeStatement('UPDATE form_submission SET form_id = ? WHERE form_id = ?', [$form['id'], $old]);
}
}
});
}
/** Nur Spalten, die es hier gibt: ein Paket einer älteren Fassung kennt neuere Spalten nicht. */
private function insert(string $table, array $rows): void
{
$columns = array_map(static fn ($column): string => strtolower($column->getName()), $this->db->createSchemaManager()->listTableColumns($table));
foreach ($rows as $row) {
$this->db->insert($table, array_intersect_key($row, array_flip($columns)));
}
}
private function mergeWebsite(array $row): void
{
$current = $this->db->fetchAssociative('SELECT settings FROM website WHERE id = ?', ['default']);
$settings = json_decode((string) ($row['settings'] ?? '{}'), true) ?: [];
$own = json_decode((string) ($current['settings'] ?? '{}'), true) ?: [];
foreach (self::KEEP_SETTINGS as $key) {
unset($settings[$key]);
if (\array_key_exists($key, $own)) {
$settings[$key] = $own[$key];
}
}
$values = ['name' => (string) ($row['name'] ?? ''), 'default_locale' => (string) ($row['default_locale'] ?? 'de'), 'theme' => (string) ($row['theme'] ?? '{}'), 'settings' => json_encode($settings, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)];
if (false === $current) {
$this->db->insert('website', ['id' => 'default', ...$values]);
} else {
$this->db->update('website', $values, ['id' => 'default']);
}
}
/** Entpackt die Medien in einen eigenen Ordner neben dem Medienordner. */
private function extractMedia(\ZipArchive $zip): string
{
$target = $this->root.'/var/pakete/entpackt-'.bin2hex(random_bytes(6));
if (!@mkdir($target, 0o775, true)) {
throw new \RuntimeException('Ein Arbeitsordner unter var/pakete ließ sich nicht anlegen.');
}
for ($i = 0; $i < $zip->numFiles; ++$i) {
$name = (string) $zip->getNameIndex($i);
$base = substr($name, \strlen(self::MEDIA));
if (!str_starts_with($name, self::MEDIA) || 1 !== preg_match(self::MEDIA_NAME, $base)) {
continue;
}
// Gelesen und selbst geschrieben, nicht extractTo: der Zielname ist geprüft, nicht der Eintrag.
$stream = $zip->getStream($name);
$out = false === $stream ? false : fopen($target.'/'.$base, 'wb');
if (false === $stream || false === $out) {
throw new \RuntimeException(sprintf('%s ließ sich nicht entpacken.', $base));
}
stream_copy_to_stream($stream, $out, self::MAX_FILE_BYTES);
fclose($stream);
fclose($out);
}
return $target;
}
/** Leert den Medienordner (die Sicherung hat ihn) und legt die Dateien des Pakets hinein. */
private function replaceMedia(string $source): int
{
if (!is_dir($this->mediaDirectory) && !@mkdir($this->mediaDirectory, 0o775, true)) {
throw new \RuntimeException('Der Medienordner ließ sich nicht anlegen.');
}
foreach (scandir($this->mediaDirectory) ?: [] as $name) {
if (1 === preg_match(self::MEDIA_NAME, $name)) {
@unlink($this->mediaDirectory.'/'.$name);
}
}
$count = 0;
foreach (scandir($source) ?: [] as $name) {
if (1 === preg_match(self::MEDIA_NAME, $name) && @copy($source.'/'.$name, $this->mediaDirectory.'/'.$name)) {
++$count;
}
}
return $count;
}
private function summary(array $tables, bool $withUsers): string
{
return sprintf(
'%d Seite(n), %d Fassung(en), %d Bereich(e), %d Mediendatei(en), %d Formular(e). Konten: %s. Anfragen und Statistik dieser Website bleiben.',
\count($tables['page']), \count($tables['revision']), \count($tables['partial']), \count($tables['media']) + \count($tables['document']), \count($tables['form']),
$withUsers ? 'aus dem Paket übernommen ('.\count($tables['cms_user']).')' : 'unverändert',
);
}
private function rows(string $table): array
{
return $this->db->fetchAllAssociative('SELECT * FROM '.$table);
}
private function fileName(string $name): string
{
$name = strtr(mb_strtolower($name), ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss']);
$slug = trim((string) preg_replace('/[^a-z0-9]+/', '-', $name), '-') ?: 'website';
return 'paket-'.substr($slug, 0, 40).'-'.gmdate('Ymd-His').'.zip';
}
private function remove(string $path): void
{
foreach (glob($path.'/*') ?: [] as $entry) {
is_dir($entry) ? $this->remove($entry) : @unlink($entry);
}
@rmdir($path);
}
}
+27 -4
View File
@@ -96,7 +96,7 @@ final class Updater
'git' => $git,
// Der Weg ueber Git steht nur einer Arbeitskopie offen.
'worktree' => $arbeitskopie,
'clean' => null === $status ? null : ('' === $status['output'] && $status['ok']),
'clean' => null === $status ? null : ([] === self::dirty($status['output']) && $status['ok']),
'log' => $this->log(),
];
}
@@ -138,10 +138,10 @@ final class Updater
}
$status = $this->git(['status', '--porcelain']);
$dirty = array_values(array_filter(explode("\n", $status['output'])));
$dirty = self::dirty($status['output']);
$add($checks, 'Keine lokalen Änderungen', [] === $dirty, [] === $dirty
? 'Die Arbeitskopie ist unverändert.'
: sprintf('%d geänderte Datei(en), zum Beispiel %s. Ein Checkout würde sie verwerfen. Erst sichern oder zurücknehmen.', count($dirty), trim(explode("\n", $status['output'])[0])));
: sprintf('%d geänderte Datei(en), zum Beispiel %s. Ein Checkout würde sie verwerfen. Erst sichern oder zurücknehmen.', count($dirty), trim($dirty[0])));
$remote = $this->git(['remote', 'get-url', 'origin']);
$add($checks, 'Herkunft', $remote['ok'], $remote['ok']
@@ -231,6 +231,12 @@ final class Updater
$step($steps, 'Vorprüfung', true, 'Alle Prüfungen bestanden.');
$target ??= $this->availableVersion();
// Ein Tag der Form v1.2.3 und sonst nichts: der Wert geht an git checkout (ADR 116).
if (null !== $target && 1 !== preg_match('/^v\d{1,4}\.\d{1,5}\.\d{1,6}$/D', $target)) {
$step($steps, 'Ziel bestimmen', false, 'Das Ziel hat nicht die Form v1.2.3.');
return ['ok' => false, 'steps' => $steps, 'target' => $target];
}
if (null === $target) {
$step($steps, 'Ziel bestimmen', false, 'Die Herkunft bietet keinen Tag der Form v1.2.3 an.');
@@ -293,8 +299,13 @@ final class Updater
throw new \RuntimeException('Die alte Git-Konfiguration konnte nicht für den Updatewechsel vorbereitet werden.');
}
}
$checkout = $this->git(['checkout', '--force', $target]);
$htaccess = $this->root.'/public/.htaccess';
$htaccessBefore = is_file($htaccess) ? (string) file_get_contents($htaccess) : null;
$checkout = $this->git(['checkout', '--force', $target, '--']);
$this->restoreEnvironment();
if (null !== $htaccessBefore && is_file($htaccess)) {
@file_put_contents($htaccess, HttpsRedirect::keepEnabled((string) file_get_contents($htaccess), $htaccessBefore), LOCK_EX);
}
if (!$step($steps, 'Code wechseln', $checkout['ok'], $checkout['ok'] ? 'Auf '.$target.' gewechselt.' : $checkout['output'])) {
throw new \RuntimeException('checkout');
}
@@ -438,6 +449,18 @@ final class Updater
}
/** Legacy tags tracked .env; preserve the installation even when checking out those tags. */
/**
* Die geänderten Dateien laut `git status --porcelain` - ohne public/.htaccess: Eine dort
* eingeschaltete HTTPS-Umleitung nimmt apply() in die neue Fassung mit (ADR 120), sie ist
* kein Grund, das Update zu verweigern.
*
* @return list<string>
*/
private static function dirty(string $porcelain): array
{
return array_values(array_filter(explode("\n", $porcelain), static fn (string $line): bool => '' !== trim($line) && 1 !== preg_match('#^\s*M{1,2}\s+public/\.htaccess$#', rtrim($line))));
}
private function restoreEnvironment(): void
{
foreach ($this->environmentBefore as $file => $content) {
+1 -1
View File
@@ -33,7 +33,7 @@ final class UserDirectory
public const ROLE_HELP = [
'ROLE_ADMIN' => 'Darf alles, einschließlich Benutzer, Design, Einstellungen, eigene Skripte und endgültiges Löschen.',
'ROLE_PUBLISHER' => 'Darf bearbeiten und veröffentlichen, dazu Bereiche, Menüs, Formulare, Weiterleitungen und die 404-Seite – aber keine Benutzer, kein Design, keine Unternehmensdaten und keine Skripte.',
'ROLE_EDITOR' => 'Darf Entwürfe von Seiten und Beiträgen bearbeiten, Medien hochladen und die Statistik sehen, aber nichts veröffentlichen.',
'ROLE_EDITOR' => 'Darf Entwürfe von Seiten und Beiträgen bearbeiten, Medien hochladen und die Statistik sehen, aber nichts veröffentlichen – auch nicht Adresse, Papierkorb, Bereiche oder Beitragsdaten einer veröffentlichten Seite.',
];
/** Bcrypt silently ignores everything after 72 bytes, so that is the honest upper limit. */
+218
View File
@@ -0,0 +1,218 @@
<?php
namespace App\Twig;
use App\Content\Languages;
use Symfony\Component\HttpFoundation\RequestStack;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
/** Sprachangaben für die Vorlagen (ADR 111). */
final class LanguageExtension extends AbstractExtension
{
/**
* Die festen Texte der öffentlichen Vorlagen, die kein Redakteur pflegt: Sprunglink,
* Beschriftungen von Navigationen, Einwilligung, Beitragslisten. Schlüssel ist der deutsche
* Text. Für Sprachen ohne eigene Liste gilt Englisch - auf einer französischen Seite ist
* „Skip to content“ näher am Besucher als „Zum Inhalt springen“.
*/
public const UI = [
'en' => [
'Zum Inhalt springen' => 'Skip to content',
'Menü' => 'Menu',
'Brotkrumen' => 'Breadcrumb',
'Start' => 'Home',
'Soziale Profile' => 'Social profiles',
'Zum Blättern seitwärts scrollen' => 'Scroll sideways to browse',
'Anbieter:' => 'Provider:',
'Inhalt laden und einwilligen' => 'Load content and consent',
'Einwilligung ändern' => 'Change consent',
'Bildergalerie' => 'Image gallery',
'Bild' => 'Image',
'vergrößern' => 'enlarge',
'Ansehen' => 'View',
'Weiterlesen' => 'Read more',
'Weitere Beiträge' => 'More posts',
'Vorheriger Beitrag' => 'Previous post',
'Nächster Beitrag' => 'Next post',
'Alle akzeptieren' => 'Accept all',
'Nur notwendige' => 'Necessary only',
'Einzeln wählen' => 'Choose individually',
'Auswahl speichern' => 'Save selection',
'Datenschutz' => 'Privacy',
'Impressum' => 'Legal notice',
'Hauptnavigation' => 'Main navigation',
'Zweite Navigation' => 'Secondary navigation',
'Fußzeile' => 'Footer',
'Rechtliches' => 'Legal',
'Navigation' => 'Navigation',
'Nach oben' => 'Back to top',
'Sprache' => 'Language',
'Sprache wählen' => 'Choose language',
// Formulare
'Dieses Formular ist nicht mehr vorhanden.' => 'This form is no longer available.',
'Bitte prüfe 1 Eingabe. Die betroffenen Felder sind unten gekennzeichnet.' => 'Please check 1 entry. The field concerned is marked below.',
'Bitte prüfe %d Eingaben. Die betroffenen Felder sind unten gekennzeichnet.' => 'Please check %d entries. The fields concerned are marked below.',
'Pflichtfeld' => 'required',
'in' => 'in',
'bitte wählen' => 'please choose',
'Zum Absenden dieses Formulars wird JavaScript benötigt. Schreib uns sonst gern direkt eine E-Mail.' => 'Sending this form requires JavaScript. Otherwise, feel free to email us directly.',
'Zusammenfassung' => 'Summary',
'Bitte prüfen Sie Ihre Angaben. Über „Ändern“ kommen Sie zum jeweiligen Schritt zurück.' => 'Please check your details. “Change” takes you back to the step.',
'Schritte des Formulars' => 'Form steps',
'Schritt' => 'Step',
'Zurück' => 'Back',
'Weiter' => 'Next',
'Ändern' => 'Change',
'Ja' => 'Yes',
'Bitte wähle mindestens eine Option.' => 'Please choose at least one option.',
'Absenden' => 'Send',
'Vielen Dank. Deine Nachricht ist angekommen.' => 'Thank you. Your message has arrived.',
// Beiträge und Archive
'Beiträge' => 'Posts',
'Beitrag' => 'post',
'Kategorie' => 'Category',
'Schlagwort' => 'Tag',
'Alle Beiträge, die neuesten zuerst.' => 'All posts, newest first.',
'Beiträge in der Kategorie „%s“.' => 'Posts in the category “%s”.',
'Beiträge zum Schlagwort „%s“.' => 'Posts tagged “%s”.',
'von' => 'by',
'← Neuere' => '← Newer',
'Ältere →' => 'Older →',
'Seite %d von %d' => 'Page %d of %d',
'Externe Inhalte und Einwilligung' => 'External content and consent',
'Diese Website lädt bestimmte Inhalte erst, wenn du zustimmst. Ohne Zustimmung wird die Seite vollständig angezeigt, nur eingebettete Inhalte anderer Anbieter bleiben leer.' => 'This website only loads certain content once you agree. Without consent the page is shown in full; only embedded content from other providers stays empty.',
'Statistik' => 'Statistics',
'Externe Inhalte' => 'External content',
'Messung, wie die Website benutzt wird.' => 'Measuring how the website is used.',
'Inhalte von anderen Anbietern, etwa Karten, Videos, Bewertungen oder ein Chat. Sie sehen die Adresse dieser Seite und deine IP-Adresse, sobald sie geladen werden.' => 'Content from other providers, such as maps, videos, reviews or a chat. They see the address of this page and your IP address as soon as they load.',
// 404 und Einwilligung
'Seite nicht gefunden' => 'Page not found',
'Diese Seite ist nicht mehr verfügbar.' => 'This page is no longer available.',
'Die gesuchte Seite ist nicht verfügbar. Über die Startseite findest du wieder zum richtigen Inhalt.' => 'The page you are looking for is not available. The home page will take you back to the right content.',
'Die Adresse ist möglicherweise veraltet oder wurde falsch eingegeben. Über die Startseite findest du wieder zum richtigen Inhalt.' => 'The address may be out of date or mistyped. The home page will take you back to the right content.',
'Zur Startseite' => 'To the home page',
],
'fr' => [
'Zum Inhalt springen' => 'Aller au contenu',
'Menü' => 'Menu',
'Start' => 'Accueil',
'Datenschutz' => 'Confidentialité',
'Impressum' => 'Mentions légales',
'Hauptnavigation' => 'Navigation principale',
'Nach oben' => 'Haut de page',
'Weiterlesen' => 'Lire la suite',
'Alle akzeptieren' => 'Tout accepter',
'Nur notwendige' => 'Uniquement nécessaires',
'Sprache' => 'Langue',
'Sprache wählen' => 'Choisir la langue',
],
'tr' => [
'Zum Inhalt springen' => 'İçeriğe geç',
'Menü' => 'Menü',
'Start' => 'Ana sayfa',
'Datenschutz' => 'Gizlilik',
'Impressum' => 'Künye',
'Hauptnavigation' => 'Ana menü',
'Nach oben' => 'Yukarı',
'Weiterlesen' => 'Devamını oku',
'Alle akzeptieren' => 'Tümünü kabul et',
'Nur notwendige' => 'Yalnızca gerekli',
'Sprache' => 'Dil',
'Sprache wählen' => 'Dil seçin',
// Vollständig, damit eine türkische Seite nirgends auf Englisch ausweicht (09.10.2026).
'Brotkrumen' => 'Sayfa yolu',
'Soziale Profile' => 'Sosyal medya',
'Zum Blättern seitwärts scrollen' => 'Gezinmek için yana kaydırın',
'Anbieter:' => 'Sağlayıcı:',
'Inhalt laden und einwilligen' => 'İçeriği yükle ve onayla',
'Einwilligung ändern' => 'Onayı değiştir',
'Bildergalerie' => 'Resim galerisi',
'Bild' => 'Resim',
'vergrößern' => 'büyüt',
'Ansehen' => 'İncele',
'Weitere Beiträge' => 'Diğer yazılar',
'Vorheriger Beitrag' => 'Önceki yazı',
'Nächster Beitrag' => 'Sonraki yazı',
'Einzeln wählen' => 'Tek tek seç',
'Auswahl speichern' => 'Seçimi kaydet',
'Zweite Navigation' => 'İkinci menü',
'Fußzeile' => 'Alt bilgi',
'Rechtliches' => 'Yasal bilgiler',
'Navigation' => 'Menü',
'Dieses Formular ist nicht mehr vorhanden.' => 'Bu form artık mevcut değil.',
'Bitte prüfe 1 Eingabe. Die betroffenen Felder sind unten gekennzeichnet.' => 'Lütfen 1 girişi kontrol edin. İlgili alan aşağıda işaretlenmiştir.',
'Bitte prüfe %d Eingaben. Die betroffenen Felder sind unten gekennzeichnet.' => 'Lütfen %d girişi kontrol edin. İlgili alanlar aşağıda işaretlenmiştir.',
'Pflichtfeld' => 'zorunlu alan',
'in' => '',
'bitte wählen' => 'lütfen seçin',
'Zum Absenden dieses Formulars wird JavaScript benötigt. Schreib uns sonst gern direkt eine E-Mail.' => 'Bu formu göndermek için JavaScript gereklidir. Dilerseniz bize doğrudan e-posta gönderebilirsiniz.',
'Zusammenfassung' => 'Özet',
'Bitte prüfen Sie Ihre Angaben. Über „Ändern“ kommen Sie zum jeweiligen Schritt zurück.' => 'Lütfen bilgilerinizi kontrol edin. „Değiştir“ ile ilgili adıma geri dönebilirsiniz.',
'Schritte des Formulars' => 'Form adımları',
'Schritt' => 'Adım',
'Zurück' => 'Geri',
'Weiter' => 'İleri',
'Ändern' => 'Değiştir',
'Ja' => 'Evet',
'Bitte wähle mindestens eine Option.' => 'Lütfen en az bir seçenek seçin.',
'Absenden' => 'Gönder',
'Vielen Dank. Deine Nachricht ist angekommen.' => 'Teşekkürler. Mesajınız bize ulaştı.',
'Beiträge' => 'Yazılar',
'Beitrag' => 'yazı',
'Kategorie' => 'Kategori',
'Schlagwort' => 'Etiket',
'Alle Beiträge, die neuesten zuerst.' => 'Tüm yazılar, en yenisi önce.',
'Beiträge in der Kategorie „%s“.' => '„%s“ kategorisindeki yazılar.',
'Beiträge zum Schlagwort „%s“.' => '„%s“ etiketli yazılar.',
'von' => 'yazan:',
'← Neuere' => '← Daha yeni',
'Ältere →' => 'Daha eski →',
'Seite %d von %d' => 'Sayfa %d / %d',
'Externe Inhalte und Einwilligung' => 'Harici içerikler ve onay',
'Diese Website lädt bestimmte Inhalte erst, wenn du zustimmst. Ohne Zustimmung wird die Seite vollständig angezeigt, nur eingebettete Inhalte anderer Anbieter bleiben leer.' => 'Bu web sitesi bazı içerikleri ancak onayınızdan sonra yükler. Onay vermeseniz de sayfa eksiksiz görüntülenir; yalnızca diğer sağlayıcıların yerleştirilmiş içerikleri boş kalır.',
'Statistik' => 'İstatistik',
'Externe Inhalte' => 'Harici içerikler',
'Messung, wie die Website benutzt wird.' => 'Web sitesinin nasıl kullanıldığının ölçülmesi.',
'Inhalte von anderen Anbietern, etwa Karten, Videos, Bewertungen oder ein Chat. Sie sehen die Adresse dieser Seite und deine IP-Adresse, sobald sie geladen werden.' => 'Harita, video, değerlendirme veya sohbet gibi diğer sağlayıcıların içerikleri. Yüklendiklerinde bu sayfanın adresini ve IP adresinizi görürler.',
'Seite nicht gefunden' => 'Sayfa bulunamadı',
'Diese Seite ist nicht mehr verfügbar.' => 'Bu sayfa artık mevcut değil.',
'Die gesuchte Seite ist nicht verfügbar. Über die Startseite findest du wieder zum richtigen Inhalt.' => 'Aradığınız sayfa mevcut değil. Ana sayfadan doğru içeriğe ulaşabilirsiniz.',
'Die Adresse ist möglicherweise veraltet oder wurde falsch eingegeben. Über die Startseite findest du wieder zum richtigen Inhalt.' => 'Adres eskimiş veya yanlış yazılmış olabilir. Ana sayfadan doğru içeriğe ulaşabilirsiniz.',
'Zur Startseite' => 'Ana sayfaya',
],
];
public function __construct(private RequestStack $requests)
{
}
public function getFunctions(): array
{
return [
new TwigFunction('kant_og_locale', Languages::ogLocale(...)),
];
}
public function getFilters(): array
{
return [
new TwigFilter('kant_t', $this->translate(...)),
];
}
/**
* Ein fester Text in der Sprache der Seite. Ohne weitere Sprache - oder in der Hauptsprache -
* bleibt er, wie er ist; ebenso jeder Text, der nicht in der Liste steht.
*/
public function translate(string $text): string
{
$locale = $this->requests->getCurrentRequest()?->attributes->get('kant_locale');
if (!is_string($locale) || 'de' === $locale) {
return $text;
}
return self::UI[$locale][$text] ?? self::UI['en'][$text] ?? $text;
}
}
+9
View File
@@ -0,0 +1,9 @@
# Dieser Ordner gehoert nicht ins Netz (ADR 116). Zeigt die Domain richtig auf public/, wird diese
# Datei nie gelesen; zeigt sie versehentlich auf den Projektordner, sperrt sie hier alles.
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
+15
View File
@@ -0,0 +1,15 @@
{# Ein Versuch, gleich gebaut für „Letzte Versuche“ und „Alle Wege ausprobiert“: Zustand, worum es
ging, wann - bei Fehlern der Hinweis vorn und die Meldung des Servers zugeklappt dahinter. #}
<li class="mail-entry">
<span class="pill {{ eintrag.ok ? 'pill-live' : 'pill-error' }}">{{ eintrag.ok ? 'Angenommen' : 'Fehlgeschlagen' }}</span>
<div class="mail-entry-text">
<p class="mail-entry-line">
{% if titel is defined %}<strong>{{ titel }}</strong>{% else %}{{ eintrag.anlass }} an <code>{{ eintrag.an }}</code>{% if eintrag.von %} von <code>{{ eintrag.von }}</code>{% endif %}{% if eintrag.weg is defined and eintrag.weg %} <span class="muted">· {{ eintrag.weg }}</span>{% endif %}{% endif %}
</p>
{% if eintrag.zeit is defined %}<p class="mail-entry-time">{{ eintrag.zeit }} UTC</p>{% endif %}
{% if not eintrag.ok %}
{% if eintrag.hinweis %}<p class="mail-entry-hint">{{ eintrag.hinweis }}</p>{% endif %}
<details class="mail-entry-detail"><summary>Meldung des Servers</summary><pre>{{ eintrag.meldung }}</pre></details>
{% endif %}
</div>
</li>
+1 -1
View File
@@ -18,7 +18,7 @@
Importzeile sie kennen muss. Sie steht vor jedem Modul-Skript; spaeter geladen wirkt sie
nicht mehr. #}
<script type="importmap" nonce="{{ csp_nonce() }}">{"imports":{
{%- for modul in ['editor-model', 'media', 'design-panel', 'button-design', 'color-control', 'rich-text', 'canvas-overlay', 'library-previews', 'preview-containers', 'page-picker', 'motion', 'slider', 'live-search', 'icons'] %}
{%- for modul in ['editor-model', 'media', 'design-panel', 'button-design', 'color-control', 'rich-text', 'canvas-overlay', 'library-previews', 'preview-containers', 'page-picker', 'motion', 'slider', 'live-search', 'icons', 'translate'] %}
"/assets/{{ modul }}.js": "/assets/{{ modul }}.js?v={{ kant_version() }}"{{ loop.last ? '' : ',' }}
{%- endfor %}
}}</script>
+4 -1
View File
@@ -22,6 +22,8 @@
<button type="button" data-viewport="tablet" aria-pressed="false" title="Tablet"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="2" width="16" height="20" rx="2"/><path d="M11 18h2"/></svg><span>Tablet</span></button>
<button type="button" data-viewport="mobile" aria-pressed="false" title="Mobil"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="2" width="10" height="20" rx="2"/><path d="M11 18h2"/></svg><span>Mobil</span></button>
</div>
{# Der Sprachumschalter (ADR 111). Er erscheint nur, wenn die Website weitere Sprachen hat. #}
<div id="language-switch" class="language-switch" role="group" aria-label="Sprache der Bearbeitung" hidden></div>
<div class="history-actions">
<button id="undo" class="icon-button" title="Rückgängig (Strg+Z)" aria-label="Rückgängig" disabled><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 14 4 9l5-5"/><path d="M4 9h10a6 6 0 0 1 0 12h-3"/></svg></button>
<button id="redo" class="icon-button" title="Wiederholen (Strg+Umschalt+Z)" aria-label="Wiederholen" disabled><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m15 14 5-5-5-5"/><path d="M20 9H10a6 6 0 0 0 0 12h3"/></svg></button>
@@ -71,6 +73,7 @@
<div class="tree-tools"><button id="expand-all" class="text-button" type="button">Alle öffnen</button><button id="collapse-all" class="text-button" type="button">Einklappen</button><button id="nest" class="text-button" type="button" title="In vorherigen Container verschachteln">⇥ Einrücken</button><button id="outdent" class="text-button" type="button" title="Eine Ebene herausheben">⇤ Ausrücken</button></div>
<nav id="structure" aria-label="Blöcke auswählen"></nav>
</section>
<section class="sidebar-view" data-view="translate" id="translate-panel" aria-label="Übersetzung" hidden></section>
<section class="sidebar-view" data-view="element" hidden>
<div id="properties"><p class="muted">Inhalte werden geladen …</p></div>
</section>
@@ -90,7 +93,7 @@
</main>
<div id="context-menu" class="context-menu" role="menu" aria-label="Elementmenü" hidden></div>
<noscript><p class="notice error">Zum Bearbeiten muss JavaScript aktiviert sein. Die öffentliche Website funktioniert ohne JavaScript.</p></noscript>
<dialog id="publish-dialog" aria-labelledby="publish-title"><h2 id="publish-title">Seite veröffentlichen?</h2><p>Der gespeicherte Entwurf ersetzt den bisher sichtbaren Inhalt dieser Seite.</p><div class="dialog-actions"><button id="cancel-publish" class="secondary">Abbrechen</button><button id="confirm-publish" class="primary">Jetzt veröffentlichen</button></div></dialog>
<dialog id="publish-dialog" aria-labelledby="publish-title"><h2 id="publish-title">Seite veröffentlichen?</h2><p>Der gespeicherte Entwurf ersetzt den bisher sichtbaren Inhalt dieser Seite.</p><p id="publish-languages" hidden>Alle Sprachen dieser Seite gehen gemeinsam online – auch die Übersetzungen im gespeicherten Stand.</p><div class="dialog-actions"><button id="cancel-publish" class="secondary">Abbrechen</button><button id="confirm-publish" class="primary">Jetzt veröffentlichen</button></div></dialog>
<dialog id="preset-dialog" aria-labelledby="preset-title"><h2 id="preset-title">Gestaltung als Vorlage speichern</h2><p>Die Vorlage steht danach für jeden Block auf jeder Seite zur Verfügung.</p><label for="preset-name">Name der Vorlage</label><input id="preset-name" maxlength="60" placeholder="Heller Abschnitt"><p id="preset-error" class="field-error" hidden></p><div class="dialog-actions"><button id="cancel-preset" class="secondary">Abbrechen</button><button id="confirm-preset" class="primary">Vorlage speichern</button></div></dialog>
<dialog id="reload-dialog" aria-labelledby="reload-title"><h2 id="reload-title">Serverstand laden?</h2><p>Lokale Änderungen und dein Undo-Verlauf werden ersetzt. Sichere deine Änderungen vorher als JSON.</p><div class="dialog-actions"><button id="cancel-reload" class="secondary">Abbrechen</button><button id="confirm-reload" class="primary">Serverstand übernehmen</button></div></dialog>
</div>
+81 -37
View File
@@ -4,35 +4,41 @@
Seite dessen Stylesheet, genau wie die Designseite. #}
{% block styles %}<link rel="stylesheet" href="/assets/buttons.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/color-control.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/media-dialog.css?v={{ kant_version() }}">{% endblock %}
{% block scripts %}<script type="module" src="/assets/form-editor.js?v={{ kant_version() }}"></script>{% endblock %}
{% block title %}Formulare{% endblock %}
{% block heading %}Formulare{% endblock %}
{% block lead %}Felder, Empfänger, Einwilligungstext und Aussehen werden hier gepflegt. Auf einer Seite steht nur ein Formularblock mit der Adresse – so kann ein gespeicherter Seiteninhalt niemals ändern, wohin eine Anfrage geht.{% endblock %}
{% block actions %}<a class="primary" href="#neues-formular">Neues Formular</a>{% endblock %}
{% set editing = editing|default(false) %}
{% block title %}{{ editing ? (current ? current.name : 'Neues Formular') ~ ' · ' : '' }}Formulare{% endblock %}
{% block heading %}{{ editing ? (current ? current.name : 'Neues Formular') : 'Formulare' }}{% endblock %}
{% block lead %}{% if editing %}<a class="text-button back-to-list" href="{{ path('admin_forms') }}">← Alle Formulare</a>{% else %}Felder, Empfänger und Aussehen deiner Formulare. Auf einer Seite setzt du sie mit dem Baustein „Formular“ ein.{% endif %}{% endblock %}
{% block actions %}{% if not editing %}<a class="primary" href="{{ path('admin_form_edit', {slug: 'neu'}) }}">Neues Formular</a>{% endif %}{% endblock %}
{# Eine Feldkarte. Der Index steht in den Feldnamen; die Vorlage fuer neue Felder traegt an seiner
Stelle den Platzhalter __n__, den das Skript beim Einfuegen ersetzt. #}
{% macro field(f, n, row, types) %}
{% macro field(f, n, row, types, languages = [], translations = {}) %}
{% set id = 'f' ~ f ~ '-' ~ n %}
<li class="field-card{{ row.label == '' ? ' field-card-new' : '' }}" data-field>
{# Eine Zeile je Feld: Beschriftung, Typ, Pflicht. Alles Weitere steht in den Details, die das
Skript zuklappt - ohne Skript ist die Karte offen und vollständig bedienbar. #}
<div class="field-card-head">
<span class="field-card-index" aria-hidden="true" data-field-index></span>
<div class="field-card-title">
<label for="{{ id }}-label">Beschriftung</label>
<input id="{{ id }}-label" name="fields[{{ n }}][label]" maxlength="120" value="{{ row.label }}" placeholder="{{ row.label == '' ? 'Neues Feld – Beschriftung eingeben' : '' }}" data-field-label>
<label class="visually-hidden" for="{{ id }}-label">Beschriftung</label>
<input id="{{ id }}-label" name="fields[{{ n }}][label]" maxlength="120" value="{{ row.label }}" placeholder="{{ row.label == '' ? 'Neues Feld – Beschriftung eingeben' : 'Beschriftung' }}" data-field-label>
</div>
<div class="field-card-type">
<label for="{{ id }}-type">Typ</label>
<label class="visually-hidden" for="{{ id }}-type">Typ</label>
<select id="{{ id }}-type" name="fields[{{ n }}][type]" data-field-type>
{% for value, label in types %}<option value="{{ value }}"{{ row.type == value ? ' selected' : '' }}>{{ label }}</option>{% endfor %}
</select>
</div>
<label class="check-label field-card-required" for="{{ id }}-required"><input id="{{ id }}-required" type="checkbox" name="fields[{{ n }}][required]" value="1"{{ row.required ? ' checked' : '' }} data-field-required> Pflicht</label>
<button type="button" class="field-toggle" data-field-toggle hidden aria-expanded="true" aria-controls="{{ id }}-body">Details<svg class="form-chevron" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg></button>
<button type="button" class="field-remove" data-remove-field hidden aria-label="Feld entfernen" title="Feld entfernen">{% include 'admin/icon.html.twig' with {name: 'remove'} only %}</button>
</div>
<div class="field-card-body">
<p class="field-card-meta" data-field-meta hidden></p>
<div class="field-card-body" id="{{ id }}-body" data-field-body>
<div>
<label for="{{ id }}-name">Technischer Name</label>
<input id="{{ id }}-name" name="fields[{{ n }}][name]" maxlength="32" value="{{ row.name }}" pattern="[a-z][a-z0-9_]*" placeholder="vorname" data-field-name>
<p class="field-help">Kleinbuchstaben, Ziffern, Unterstrich. Steht in der E-Mail vor der Antwort.</p>
<p class="field-help">Steht in der E-Mail vor der Antwort.</p>
</div>
<div>
<label for="{{ id }}-help">Hilfetext</label>
@@ -46,12 +52,9 @@
<label for="{{ id }}-images">Bild je Option</label>
<textarea id="{{ id }}-images" name="fields[{{ n }}][images]" rows="2" data-field-images-input>{% for option, kennung in row.images|default({}) %}{{ option }} = {{ kennung }}
{% endfor %}</textarea>
<p class="field-help">Eine Zeile je Option: <code>Option = Kennung aus der Mediathek</code>. Sobald eine Option ein Bild hat, wird die Auswahl auf der Website zur Bildauswahl – anklickbare Karten statt Kästchen.</p>
<p class="field-help">Mit Bild wird die Auswahl zu anklickbaren Karten. Ohne Skript: je Zeile <code>Option = Kennung</code>.</p>
</div>
</div>
<div class="field-card-flags">
<label class="check-label" for="{{ id }}-required"><input id="{{ id }}-required" type="checkbox" name="fields[{{ n }}][required]" value="1"{{ row.required ? ' checked' : '' }} data-field-required> Pflichtfeld</label>
</div>
<div class="field-card-number" data-field-number>
<div>
<label for="{{ id }}-min">Untergrenze</label>
@@ -75,25 +78,41 @@
<label for="{{ id }}-showvalues">… einen dieser Werte hat</label>
<input id="{{ id }}-showvalues" name="fields[{{ n }}][showIfValues]" maxlength="500" value="{{ row.showIf.values|default([])|join(', ') }}" placeholder="Wohnung, Haus" data-field-showvalues>
</div>
<p class="field-help">Technischer Name eines Auswahlfeldes weiter oben, Werte durch Komma. Bei einem Schritt gilt die Bedingung für alle seine Felder. Leer: das Feld gilt immer.</p>
<p class="field-help">Technischer Name eines Auswahlfeldes weiter oben, Werte durch Komma. Leer: das Feld gilt immer.</p>
</div>
{# Je weitere Sprache, was Besucher lesen (ADR 111). Leer heißt: wie in der Hauptsprache. #}
{% for sprache in languages %}
{% set tr = translations[sprache.code].fields[row.name]|default({}) %}
<details class="field-translation" data-field-translation="{{ sprache.code }}">
<summary>{{ sprache.name }}{% if tr.label|default('') %} <span class="muted">· {{ tr.label }}</span>{% endif %}</summary>
<div class="fields">
<div><label for="{{ id }}-tr-{{ sprache.code }}-label">Beschriftung {{ sprache.name }}</label><input id="{{ id }}-tr-{{ sprache.code }}-label" name="fields[{{ n }}][tr_{{ sprache.code }}_label]" maxlength="120" lang="{{ sprache.code }}" value="{{ tr.label|default('') }}" placeholder="{{ row.label }}"></div>
<div><label for="{{ id }}-tr-{{ sprache.code }}-help">Hilfetext {{ sprache.name }}</label><input id="{{ id }}-tr-{{ sprache.code }}-help" name="fields[{{ n }}][tr_{{ sprache.code }}_help]" maxlength="200" lang="{{ sprache.code }}" value="{{ tr.help|default('') }}" placeholder="{{ row.help|default('') }}"></div>
<div><label for="{{ id }}-tr-{{ sprache.code }}-options">Optionen {{ sprache.name }}</label><input id="{{ id }}-tr-{{ sprache.code }}-options" name="fields[{{ n }}][tr_{{ sprache.code }}_options]" maxlength="500" lang="{{ sprache.code }}" value="{% for option in row.options|default([]) %}{{ tr.options[option]|default('') }}{{ loop.last ? '' : ', ' }}{% endfor %}" placeholder="{{ row.options|default([])|join(', ') }}"><p class="field-help">Nur bei Auswahlfeldern: in derselben Reihenfolge, durch Komma.</p></div>
<div><label for="{{ id }}-tr-{{ sprache.code }}-unit">Einheit {{ sprache.name }}</label><input id="{{ id }}-tr-{{ sprache.code }}-unit" name="fields[{{ n }}][tr_{{ sprache.code }}_unit]" maxlength="12" lang="{{ sprache.code }}" value="{{ tr.unit|default('') }}" placeholder="{{ row.unit|default('') }}"></div>
</div>
</details>
{% endfor %}
</div>
</li>
{% endmacro %}
{% block content %}
{% import _self as karte %}
{% if editing %}
<div class="form-screens" data-form-schema="{{ schema|json_encode|e('html_attr') }}" data-form-images="{{ images|json_encode|e('html_attr') }}">
{% for form in forms|merge([null]) %}
{% for form in [current] %}
{% set new = form is null %}
{% set f = loop.index %}
{% set rows = new ? [] : form.fields %}
{% set settings = new ? {} : form.settings %}
{% if new %}
<details class="card form-new" id="neues-formular"{{ forms is empty ? ' open' : '' }}>
<summary>Neues Formular anlegen</summary>
<section class="card form-new" id="neues-formular">
{% else %}
<section class="card">
<section class="card form-card" id="formular-{{ form.slug }}">
<div class="form-card-summary">
<span class="form-card-facts">{{ form.fields|length }} {{ form.fields|length == 1 ? 'Feld' : 'Felder' }} · an {{ settings.recipient|default('–') }} · Adresse <code>{{ form.slug }}</code>{% for sprache in languages %}{% if settings.translations[sprache.code]|default(null) %} · {{ sprache.code|upper }}{% endif %}{% endfor %}</span>
</div>
{% endif %}
<form action="{{ path('admin_form_save', {slug: new ? 'neu' : form.slug}) }}" method="post" class="form-def" data-form-editor>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('forms') }}">
@@ -102,15 +121,11 @@
{# Die Zusammenfassung des Details ist keine Ueberschrift; ohne diese springt die
Gliederung von der Seitenueberschrift direkt zu den Abschnittstiteln. #}
<h2 class="visually-hidden">Neues Formular</h2>
{% else %}
<div class="card-head">
<h2>{{ form.name }} <span class="count">{{ form.fields|length }} {{ form.fields|length == 1 ? 'Feld' : 'Felder' }}</span></h2>
<span class="muted">Im Editor über den Formularblock wählbar · Adresse <code>{{ form.slug }}</code></span>
</div>
{% endif %}
<div class="card-body form-editor">
<div class="form-editor-main">
<h3 class="form-part">Zustellung</h3>
<details class="form-section"{{ new ? ' open' : '' }}>
<summary><h3 class="form-part">Zustellung</h3><span class="form-section-hint">{{ new ? '' : 'an ' ~ settings.recipient|default('') ~ ' · Betreff „' ~ settings.subject|default('') ~ '“' }}</span><svg class="form-chevron" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg></summary>
<div class="fields">
<div>
<label for="name-{{ f }}">Name</label>
@@ -130,7 +145,7 @@
<div>
<label for="sender-{{ f }}">Absender</label>
<input id="sender-{{ f }}" name="sender" type="email" value="{{ settings.sender|default('') }}" placeholder="formular@beispiel.de">
<p class="field-help">Leer: die Empfängeradresse. Viele Anbieter stellen nur zu, was von einer Adresse der eigenen Domain kommt.</p>
<p class="field-help">Leer: die Empfängeradresse. Am besten eine Adresse der eigenen Domain.</p>
</div>
<div>
<label for="subject-{{ f }}">Betreff der E-Mail</label>
@@ -147,22 +162,24 @@
</div>
<div class="field-wide">
<label class="check-label" for="summary-{{ f }}"><input id="summary-{{ f }}" type="checkbox" name="summary" value="1"{{ settings.summary|default(false) ? ' checked' : '' }}> Zusammenfassung vor dem Absenden zeigen</label>
<p class="field-help">Wirkt bei Formularen mit Schritten: der letzte Schritt zeigt alle Angaben mit „Ändern“. Ohne Skript im Browser steht das Formular als Ganzes auf der Seite.</p>
<p class="field-help">Nur bei Formularen mit Schritten.</p>
</div>
</div>
</details>
<div class="form-fields-head">
<h3 class="form-part">Felder</h3>
<button type="button" class="secondary" data-add-field hidden>{% include 'admin/icon.html.twig' with {name: 'plus'} only %}Feld hinzufügen</button>
</div>
<ol class="field-list" data-field-list>
{% for row in rows %}{{ karte.field(f, loop.index0, row, types) }}{% endfor %}
{{ karte.field(f, rows|length, {name: '', type: 'text', label: '', required: false, help: '', options: []}, types) }}
{% for row in rows %}{{ karte.field(f, loop.index0, row, types, languages, settings.translations|default({})) }}{% endfor %}
{{ karte.field(f, rows|length, {name: '', type: 'text', label: '', required: false, help: '', options: []}, types, languages) }}
</ol>
<template data-field-template>{{ karte.field(f, '__n__', {name: '', type: 'text', label: '', required: false, help: '', options: []}, types) }}</template>
<p class="field-help" data-fields-help>Ein Feld ohne Beschriftung wird nicht gespeichert – so lässt sich ein Feld auch ohne Skript entfernen. Ein Einwilligungsfeld ist immer ein Pflichtfeld; der Zeitpunkt der Einwilligung wird mitgespeichert. Ein Feld vom Typ „Neuer Schritt“ eröffnet einen Abschnitt: seine Beschriftung ist der Titel, sein Hilfetext die Einleitung; alle Felder bis zum nächsten Schritt gehören dazu. Ein Feld oder ein Schritt kann von einer früheren Auswahl abhängen – dann verlangt der Server es nur, wenn die Bedingung zutrifft.</p>
<template data-field-template>{{ karte.field(f, '__n__', {name: '', type: 'text', label: '', required: false, help: '', options: []}, types, languages) }}</template>
<p class="field-help" data-fields-help>Hilfetext, Optionen, Bedingungen und Übersetzungen stehen unter „Details“. Ein „Neuer Schritt“ beginnt einen Abschnitt; ein Feld ohne Beschriftung wird nicht gespeichert.</p>
<h3 class="form-part">Aussehen</h3>
<details class="form-section">
<summary><h3 class="form-part">Aussehen</h3><span class="form-section-hint">Button „{{ settings.buttonLabel|default('Absenden') }}“</span><svg class="form-chevron" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg></summary>
<div class="fields form-look">
<div>
<label for="buttonLabel-{{ f }}">Beschriftung des Buttons</label>
@@ -175,7 +192,19 @@
</div>
{% endfor %}
</div>
<p class="field-help">Leer heißt: wie die Website. Sonst eine Farbe aus der Palette oder ein Wert wie <code>#1a2b3c</code>. Form und Größe des Buttons kommen aus dem Design.</p>
<p class="field-help">Leer heißt: wie die Website. Form und Größe des Buttons kommen aus dem Design.</p>
</details>
{% for sprache in languages %}
{% set tr = settings.translations[sprache.code]|default({}) %}
<details class="form-section">
<summary><h3 class="form-part">{{ sprache.name }}</h3><span class="form-section-hint">{{ tr ? (tr.fields|default({})|length ~ ' von ' ~ rows|length ~ ' Feldern übersetzt') : 'noch nicht übersetzt' }}</span><svg class="form-chevron" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg></summary>
<p class="field-help">Button und Meldung auf {{ sprache.name }}; die Felder übersetzt du unter „Details“ am Feld. Die E-Mail an dich bleibt deutsch.</p>
<div class="fields">
<div class="field-wide"><label for="success-{{ f }}-{{ sprache.code }}">Meldung nach dem Absenden ({{ sprache.name }})</label><textarea id="success-{{ f }}-{{ sprache.code }}" name="success_{{ sprache.code }}" maxlength="300" rows="2" lang="{{ sprache.code }}" placeholder="{{ settings.success|default('') }}">{{ tr.success|default('') }}</textarea></div>
<div><label for="buttonLabel-{{ f }}-{{ sprache.code }}">Beschriftung des Buttons ({{ sprache.name }})</label><input id="buttonLabel-{{ f }}-{{ sprache.code }}" name="buttonLabel_{{ sprache.code }}" maxlength="40" lang="{{ sprache.code }}" value="{{ tr.buttonLabel|default('') }}" placeholder="{{ settings.buttonLabel|default('Absenden') }}"></div>
</div>
</details>
{% endfor %}
</div>
{# Die Vorschau: dieselben Felder, dieselben Farben, der Standardbutton der Website.
@@ -212,13 +241,27 @@
{% if not new %}<span class="muted">Änderungen gelten sofort auf jeder Seite, die dieses Formular zeigt.</span>{% endif %}
</div>
</form>
{% if new %}
</details>
{% else %}
</section>
{% endif %}
{% endfor %}
</div>
{% else %}
{# Die Übersicht: eine Zeile je Formular, ohne Editor und ohne Vorschau. #}
{% if forms is empty %}
<div class="empty"><h2>Noch kein Formular.</h2><p>Lege ein Formular an und setze es mit dem Baustein „Formular“ auf eine Seite.</p><p><a class="primary" href="{{ path('admin_form_edit', {slug: 'neu'}) }}">Neues Formular</a></p></div>
{% else %}
<div class="rows form-list">
{% for form in forms %}
<article class="row">
<div class="row-main">
<h2 class="form-list-name"><a href="{{ path('admin_form_edit', {slug: form.slug}) }}">{{ form.name }}</a></h2>
<p>{{ form.fields|length }} {{ form.fields|length == 1 ? 'Feld' : 'Felder' }} · an {{ form.settings.recipient|default('–') }} · Adresse <code>{{ form.slug }}</code>{% for sprache in languages %}{% if form.settings.translations[sprache.code]|default(null) %} · {{ sprache.code|upper }} übersetzt{% endif %}{% endfor %}</p>
</div>
<span class="pill">{{ counts[form.id]|default(0) }} {{ counts[form.id]|default(0) == 1 ? 'Einsendung' : 'Einsendungen' }}</span>
<a class="secondary" href="{{ path('admin_form_edit', {slug: form.slug}) }}" aria-label="{{ form.name }} bearbeiten">Bearbeiten</a>
</article>
{% endfor %}
</div>
{% endif %}
<div class="section-head"><h2>Einsendungen <span class="count">{{ submissions.total }}</span></h2><span class="muted">Neueste zuerst.</span></div>
{% if submissions.items is empty %}
@@ -238,5 +281,6 @@
</div>
{% endif %}
{% include 'admin/pager.html.twig' with {result: submissions, route: 'admin_forms', label: 'Seiten der Einsendungen'} only %}
{% endif %}
<p class="footnote">KantSites · Arbeitsstand</p>
{% endblock %}
+147
View File
@@ -0,0 +1,147 @@
{% extends 'admin/shell.html.twig' %}
{% set nav = 'mail' %}
{% block title %}E-Mail-Versand{% endblock %}
{% block heading %}E-Mail-Versand{% endblock %}
{% block lead %}Wie Formulare ihre Mails verschicken – ausprobieren, speichern und sehen, woran es hakt.{% endblock %}
{% block content %}
{# E-Mail-Versand (ADR 121, 122): Weg wählen und ausprobieren, alle Wege des Servers testen,
speichern - und die letzten Versuche mit Ursache. Das SMTP-Passwort steht nie im Formular. #}
{% set f = mailForm ?? {} %}
{% set e = mail.einstellung %}
{% set weg = f.weg ?? e.weg %}
{% set beschreibung = {
smtp: 'Empfohlen. Ein echtes Postfach, unabhängig vom Hoster.',
native: 'Über den Server. Der Absender muss zu einer Domain beim Hoster gehören.',
'sendmail-t': 'Über den Server, falls mail() fehlt.',
'sendmail-bs': 'Über den Server, bei manchen Hostern gesperrt.',
} %}
{% if mailNotice %}
<p class="notice {{ mailNotice.art == 'ok' ? 'ok' : 'error' }}" role="{{ mailNotice.art == 'ok' ? 'status' : 'alert' }}">{{ mailNotice.text }}</p>
{% endif %}
<form class="card mail-form" id="mail" action="{{ path('admin_mail_save') }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
<div class="card-head"><h2>Versandweg</h2><span class="muted">gilt für alle Formulare</span></div>
<div class="card-body">
<div class="mail-current">
<span class="muted">Eingestellt</span>
{% if mail.gueltig %}
<strong>{{ mail.art }}</strong>{% if mail.ziel %} <code>{{ mail.ziel }}</code>{% endif %}{% if mail.benutzer %} <span class="muted">als</span> <code>{{ mail.benutzer }}</code>{% endif %}
{% else %}
<strong class="danger-text">Die Einstellung ist ungültig.</strong>
{% endif %}
<span class="muted mail-current-source">in <code>{{ mail.quelle }}</code> als <code>MAILER_DSN</code>{% if mail.quelle != '.env' %} – hat Vorrang, speichern lässt sich hier nichts, testen schon{% endif %}</span>
</div>
<fieldset class="card-section">
<legend>Weg wählen</legend>
<div class="option-list">
<label class="option" for="weg-smtp"><input id="weg-smtp" type="radio" name="weg" value="smtp"{% if weg == 'smtp' %} checked{% endif %}><span><strong>SMTP mit Postfach</strong><span>{{ beschreibung.smtp }}</span></span></label>
{% for key, w in mail.wege %}
<label class="option" for="weg-{{ key }}"><input id="weg-{{ key }}" type="radio" name="weg" value="{{ key }}"{% if weg == key %} checked{% endif %}><span><strong>{{ w.label }}</strong><span>{{ beschreibung[key] ?? '' }}</span></span></label>
{% endfor %}
</div>
</fieldset>
<fieldset class="card-section smtp-fields">
<legend>SMTP-Zugang</legend>
<div class="mail-grid">
<div>
<label for="mail-server">Server</label>
<input id="mail-server" name="server" type="text" inputmode="url" autocomplete="off" spellcheck="false" maxlength="200" placeholder="smtp.ionos.de" value="{{ f.server ?? e.server }}">
</div>
<div>
<label for="mail-port">Port</label>
<input id="mail-port" name="port" type="number" min="1" max="65535" value="{{ f.port ?? e.port }}" aria-describedby="mail-port-help">
</div>
<div>
<label for="mail-user">Benutzer</label>
<input id="mail-user" name="benutzer" type="text" autocomplete="off" spellcheck="false" maxlength="200" placeholder="formular@meine-firma.de" value="{{ f.benutzer ?? e.benutzer }}">
</div>
<div>
<label for="mail-pass">Passwort</label>
<input id="mail-pass" name="passwort" type="password" autocomplete="new-password" maxlength="500" placeholder="{{ e.passwort ? 'gespeichert – leer lassen zum Behalten' : '' }}">
</div>
</div>
<p class="field-help" id="mail-port-help">587 mit STARTTLS ist üblich (IONOS, Microsoft 365: <code>smtp.office365.com</code>), 465 verschlüsselt von Anfang an. Das Passwort wird nie angezeigt und landet nur in <code>.env</code>.</p>
</fieldset>
<fieldset class="card-section">
<legend>Testmail</legend>
<div class="mail-grid">
<div>
<label for="mail-to">An</label>
<input id="mail-to" name="to" type="email" autocomplete="email" maxlength="200" placeholder="name@beispiel.de" value="{{ f.an ?? (mailResult.an ?? '') }}">
</div>
<div>
<label for="mail-from">Absender</label>
<input id="mail-from" name="from" type="email" maxlength="200" placeholder="leer: dieselbe Adresse" value="{{ f.von ?? mail.absender }}" aria-describedby="mail-from-help">
</div>
</div>
<p class="field-help" id="mail-from-help">Derselbe Absender wie bei den Formularen. Über den Server (mail(), sendmail) muss er zu einer eigenen Domain beim Hoster gehören, bei SMTP ist es meist das angemeldete Postfach.</p>
<div class="button-row">
<button class="secondary" type="submit" name="aktion" value="test">Diesen Weg testen</button>
<button class="secondary" type="submit" name="aktion" value="alle">Alle Wege testen</button>
</div>
<p class="field-help">„Alle Wege testen“ schickt je Weg eine Testmail – bis zu vier.</p>
{% if mailResult %}
<div class="notice {{ mailResult.ok ? 'ok' : 'error' }} mail-result" role="{{ mailResult.ok ? 'status' : 'alert' }}">
<p><strong>{{ mailResult.ok ? 'Testmail an ' ~ mailResult.an ~ ' vom Mailserver angenommen' : 'Testmail an ' ~ mailResult.an ~ ' nicht versendet' }}</strong>{% if mailResult.weg is defined %} ({{ mailResult.weg }}){% endif %}.{% if not mailResult.ok %} {{ mailResult.meldung }}{% endif %}</p>
{% if mailResult.hinweis %}<p>{{ mailResult.hinweis }}</p>{% endif %}
{% if mailResult.dialog %}<details><summary>Antworten des Mailservers</summary><pre>{{ mailResult.dialog }}</pre></details>{% endif %}
</div>
{% endif %}
{% if mailResults %}
<div class="mail-result">
<p class="mail-result-head"><strong>Alle Wege ausprobiert</strong> <span class="muted">– an {{ f.an ?? '' }}. Angenommen heißt noch nicht angekommen: bitte im Postfach nachsehen.</span></p>
<ul class="mail-log">
{% for r in mailResults %}
{% include 'admin/_mail_entry.html.twig' with {eintrag: r, titel: r.weg} only %}
{% endfor %}
</ul>
</div>
{% endif %}
</fieldset>
</div>
<div class="card-foot form-foot">
{% if mail.quelle == '.env' %}
<button class="primary" type="submit" name="aktion" value="speichern">Diesen Weg speichern</button>
<span class="muted">Erst mit „Speichern“ versenden die Formulare auf dem gewählten Weg.</span>
{% else %}
<span class="muted">MAILER_DSN steht in der Server-Umgebung – speichern lässt sich hier nichts.</span>
{% endif %}
</div>
</form>
<div class="card">
<div class="card-head">
<h2>Letzte Versuche</h2>
{% if mail.protokoll is empty %}
<span class="muted">var/log/mail.log</span>
{% else %}
<form action="{{ path('admin_mail_clear') }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
<button class="text-button" type="submit">Protokoll leeren</button>
</form>
{% endif %}
</div>
<div class="card-body">
{% if mail.protokoll is empty %}
<p class="muted">Noch nichts versendet – weder Testmail noch Formular.</p>
{% else %}
<ul class="mail-log">
{% for eintrag in mail.protokoll %}
{% include 'admin/_mail_entry.html.twig' with {eintrag: eintrag} only %}
{% endfor %}
</ul>
<p class="field-help">Die letzten zehn Versuche aus <code>var/log/mail.log</code>, neueste zuerst. Zeiten in UTC.</p>
{% endif %}
</div>
</div>
<p class="footnote">KantSites · Arbeitsstand</p>
{% endblock %}
+3
View File
@@ -9,6 +9,9 @@
<summary><strong class="menu-entry-title">{{ row.label }}</strong><span class="menu-entry-kind">{{ row.type == 'page' ? 'Seitenlink' : 'Eigener Link' }}</span><svg class="menu-chevron" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg></summary>
<div class="menu-entry-fields">
<label for="menu-label-{{ n }}">Beschriftung</label><input id="menu-label-{{ n }}" data-key="label" name="items[{{ n }}][label]" maxlength="80" required value="{{ row.label }}">
{% for sprache in languages|default([]) %}
<label for="menu-label_{{ sprache.code }}-{{ n }}">Beschriftung {{ sprache.name }}</label><input id="menu-label_{{ sprache.code }}-{{ n }}" data-key="label_{{ sprache.code }}" name="items[{{ n }}][label_{{ sprache.code }}]" maxlength="80" lang="{{ sprache.code }}"{{ sprache.rtl ? ' dir="rtl"' : '' }} value="{{ row.labels[sprache.code]|default('') }}" placeholder="Leer: wie „{{ row.label }}“">
{% endfor %}
{% if row.type == 'page' %}
{% include 'admin/content-picker.html.twig' with {id: 'menu-target-' ~ n, name: 'items[' ~ n ~ '][pageId]', label: 'Verknüpfter Inhalt', selected: row.page} %}
<label for="menu-anchor-{{ n }}">Sprungziel (optional)</label><input id="menu-anchor-{{ n }}" data-key="anchor" name="items[{{ n }}][anchor]" maxlength="65" value="{{ row.anchor }}" placeholder="kontakt">
+1 -1
View File
@@ -8,7 +8,7 @@
{% block content %}
{% include 'admin/menu-icons.html.twig' %}
<nav class="menu-slots" aria-label="Menü auswählen">{% for key, label in slots %}<a href="{{ path('admin_menus', {slot: key}) }}"{{ key == slot ? ' aria-current="page"' : '' }}>{{ label }}</a>{% endfor %}</nav>
<form action="{{ path('admin_menu_save', {slot: slot}) }}" method="post" id="menu-editor" class="menu-workspace menu-form" data-icons="{{ menuIcons|json_encode|e('html_attr') }}" data-button-schema="{{ buttonSchema|json_encode|e('html_attr') }}">
<form action="{{ path('admin_menu_save', {slot: slot}) }}" method="post" id="menu-editor" class="menu-workspace menu-form" data-icons="{{ menuIcons|json_encode|e('html_attr') }}" data-button-schema="{{ buttonSchema|json_encode|e('html_attr') }}" data-languages="{{ languages|json_encode|e('html_attr') }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('menus') }}"><input type="hidden" name="expectedVersion" value="{{ menu ? menu.version : 0 }}">
<aside class="menu-add" aria-label="Menüeinträge hinzufügen">
<section class="card card-body"><h2>Seiten und Beiträge</h2>
+27
View File
@@ -0,0 +1,27 @@
{% extends 'admin/shell.html.twig' %}
{% set nav = 'system' %}
{% block title %}Import{% endblock %}
{% block heading %}{{ result.ok ? 'Import abgeschlossen' : 'Import nicht ausgeführt' }}{% endblock %}
{% block lead %}{{ result.ok ? 'Die Inhalte des Pakets stehen jetzt auf dieser Website.' : 'Es wurde nichts ersetzt – oder der Stand davor liegt als Sicherung bereit.' }}{% endblock %}
{% block content %}
<div class="card">
<div class="card-head"><h2>Schritte</h2></div>
<div class="card-body">
<ul class="check-list">
{% for step in result.steps %}
<li class="{{ step.ok ? 'check-ok' : 'check-bad' }}">
<span class="check-mark" aria-hidden="true">{{ step.ok ? '✓' : '!' }}</span>
<span><strong>{{ step.step }}</strong> <span class="visually-hidden">{{ step.ok ? 'erledigt' : 'fehlgeschlagen' }}:</span> {{ step.detail }}</span>
</li>
{% endfor %}
</ul>
{% if result.ok and result.users %}
<p class="notice" role="status">Die Konten kamen aus dem Paket. Ab der nächsten Seite gilt die Anmeldung mit den Zugangsdaten aus dem Paket.</p>
{% endif %}
{% if fontsMissing is not empty %}
<p class="notice" role="status">Die neue Gestaltung nutzt Schriften, die auf diesem Server noch fehlen. Unter <a href="{{ path('admin_theme') }}">Design</a> einmal „Schriften auf diesen Server holen“ drücken – bis dahin zeigt die Website die Ersatzschrift.</p>
{% endif %}
<p><a class="button-link" href="{{ path('admin_system') }}">Zurück zu System</a> · <a class="button-link" href="/" target="_blank" rel="noopener">Website ansehen</a></p>
</div>
</div>
{% endblock %}
+47
View File
@@ -52,6 +52,53 @@
{% else %}<p class="field-help">Die Zuordnung kann von einem Benutzer mit Veröffentlichungsrechten geändert werden.</p>{% endif %}
</div>
</section>
<section class="stack-section" id="sprachen" aria-labelledby="sprachen-title">
<div class="section-head"><h2 id="sprachen-title">Sprachen</h2><span class="muted">Hauptsprache: {{ mainLanguage.name }}</span></div>
<div class="card card-body">
<p>Jede weitere Sprache übersetzt dieselben Seiten: Aufbau und Gestaltung bleiben, je Sprache kommen nur die Texte und eine eigene Adresse dazu – zum Beispiel <code>/en/services</code>. Übersetzt wird im Editor über den Sprachumschalter. Eine Seite erscheint in einer Sprache erst, wenn ihre Übersetzung veröffentlicht ist.</p>
{% if is_granted('ROLE_ADMIN') %}
<form action="{{ path('admin_languages_save') }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('settings') }}">
<fieldset class="check-grid">
<legend>Weitere Sprachen</legend>
{% for sprache in languageCatalog %}
<label class="check-label"><input type="checkbox" name="languages[]" value="{{ sprache.code }}"{{ sprache.code in extraLanguages ? ' checked' : '' }}> {{ sprache.name }} <span class="muted" lang="{{ sprache.code }}">{{ sprache.native }}</span></label>
{% endfor %}
</fieldset>
<p class="field-help">Ohne Häkchen bleibt die Website einsprachig, und nirgends erscheint etwas davon. Wird eine Sprache wieder abgewählt, verschwinden ihre Seiten von der Website; die Übersetzungen bleiben gespeichert. Damit Besucher die Sprache wechseln können, setze den Baustein „Sprachwahl“ in den Kopfbereich.</p>
<button class="primary" type="submit">Sprachen speichern</button>
</form>
{% if extraLanguages %}
<form action="{{ path('admin_language_texts_save') }}" method="post" class="language-texts">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('settings') }}">
<h3>Texte in den weiteren Sprachen</h3>
<p class="field-help">Seiten, Kopf und Fuß übersetzt du im Editor, Menüs unter Menüs, Formulare unter Formulare. Hier stehen die Texte, die zur Website als Ganzes gehören. Leer heißt: wie in {{ mainLanguage.name }}.</p>
{% for code in extraLanguages %}
{% set name = languageCatalog|filter(s => s.code == code)|first.name %}
<fieldset class="language-text-group">
<legend>{{ name }}</legend>
{% if settings.consentBanner %}
<label for="consent-title-{{ code }}">Überschrift des Einwilligungshinweises</label>
<input id="consent-title-{{ code }}" name="consentTexts[{{ code }}][title]" maxlength="80" lang="{{ code }}" value="{{ settings.consentTexts[code].title|default('') }}" placeholder="{{ settings.consentTitle ?: 'Externe Inhalte und Einwilligung' }}">
<label for="consent-text-{{ code }}">Text des Einwilligungshinweises</label>
<textarea id="consent-text-{{ code }}" name="consentTexts[{{ code }}][text]" maxlength="600" rows="3" lang="{{ code }}" placeholder="{{ settings.consentText }}">{{ settings.consentTexts[code].text|default('') }}</textarea>
{% endif %}
{% for term in terms %}
{% set key = term.kind ~ ':' ~ term.slug %}
<label for="term-{{ code }}-{{ term.id }}">{{ term.kind == 'category' ? 'Kategorie' : 'Schlagwort' }} „{{ term.name }}“</label>
<input id="term-{{ code }}-{{ term.id }}" name="termNames[{{ code }}][{{ key }}]" maxlength="120" lang="{{ code }}" value="{{ settings.termNames[code][key]|default('') }}" placeholder="{{ term.name }}">
{% endfor %}
{% if not settings.consentBanner and terms is empty %}<p class="field-help">Ohne Einwilligungshinweis und ohne Kategorien gibt es hier nichts zu übersetzen.</p>{% endif %}
</fieldset>
{% endfor %}
<button class="primary" type="submit">Übersetzungen speichern</button>
</form>
{% endif %}
{% else %}
<p class="field-help">{{ extraLanguages ? 'Weitere Sprachen: ' ~ extraLanguages|map(c => c|upper)|join(', ') ~ '.' : 'Die Website ist einsprachig.' }} Ändern kann das ein Administrator.</p>
{% endif %}
</div>
</section>
<section class="stack-section">
<div class="section-head"><h2>Unternehmensdaten</h2><span class="muted">Grundlage für Organization und LocalBusiness</span></div>
{% if is_granted('ROLE_ADMIN') %}
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff. Show more