Installer: sechs Bildschirme, und eine Datei davor
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

/install fuehrt von einer leeren Installation zu einer Website:
Systempruefung, Datenbank (SQLite oder MySQL, Verbindung sofort geprueft),
Migration im Prozess ohne Konsole, Website mit Startseite, Bereichen, Menue
sowie Impressum und Datenschutzgeruest, erstes Konto, Abschlussliste.

Jeder Schritt schreibt sein Ergebnis sofort, damit ein Neuladen nichts
verliert. Gesperrt wird ueber den Zustand der Datenbank - gibt es ein
Konto, antwortet /install mit 404. Eine Datei koennte jemand loeschen.

Solange nichts installiert ist, fuehrt jede Adresse zum Installer statt zu
einem 500 aus einer fehlenden Tabelle. Und weil ohne APP_SECRET nicht
einmal die Seite bootet, die das erklaeren wuerde, erzeugt der erste Start
das Geheimnis selbst.

scripts/install.php ist die einzelne Datei davor: Adresse und SHA-256
stehen darin (vom Buildskript eingetragen), ohne passende Pruefsumme wird
nicht entpackt, jeder Archiveintrag wird gegen das Zielverzeichnis
geprueft, und danach loescht sie sich selbst.

Geprueft: 379 Tests unter PHP 8.2 und 8.4, darunter der ganze Ablauf durch
die HTTP-Schicht, neun ZIP-Slip-Varianten, axe ohne Verstoss auf allen
Installationsbildschirmen, und das entpackte Releasearchiv startet in den
Installer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 23:11:50 +02:00
1 parent c3bff7d936
commit e34b4dca67
25 files changed
+1937 -7

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
{% extends 'install/base.html.twig' %}
{% block heading %}Dein Zugang{% endblock %}
{% block lead %}
<p class="muted">Das erste Konto ist ein Administratorkonto: es darf alles, auch weitere Konten anlegen. Mit ihm endet die Installation – danach antwortet <code>/install</code> nicht mehr.</p>
{% endblock %}
{% block content %}
<form method="post" action="{{ path('install_account') }}">
<input type="hidden" name="issued" value="{{ issued }}">
<input type="hidden" name="signature" value="{{ signature }}">
<label for="username">Benutzername</label>
<input id="username" name="username" value="{{ username }}" required maxlength="100" autocomplete="username" autofocus>
<p class="muted">3–100 Zeichen: Kleinbuchstaben, Ziffern, Punkt, Unterstrich, Bindestrich.</p>
<label for="password">Passwort</label>
<input id="password" name="password" type="password" required minlength="14" maxlength="72" autocomplete="new-password">
<label for="repeat">Passwort wiederholen</label>
<input id="repeat" name="repeat" type="password" required minlength="14" maxlength="72" autocomplete="new-password">
<p class="muted">Mindestens 14 Zeichen. Länge hilft mehr als Sonderzeichen – drei, vier zufällige Wörter sind ein gutes Passwort. Es wird nirgends angezeigt und lässt sich nicht wiederherstellen, nur ersetzen.</p>
<div class="sheet-actions">
<button class="primary" type="submit">Konto anlegen und fertigstellen <span aria-hidden="true">→</span></button>
</div>
</form>
{% endblock %}
+25
View File
@@ -0,0 +1,25 @@
{% extends 'admin/base.html.twig' %}
{% set steps = ['Systemprüfung', 'Datenbank', 'Migration', 'Website', 'Konto', 'Fertig'] %}
{% block title %}{{ steps[step - 1] }} · Installation{% endblock %}
{% block body_class %}install-body{% endblock %}
{% block body %}
<main id="admin-main" class="install-shell" tabindex="-1">
<p class="eyebrow">KANTSITES INSTALLIEREN</p>
<ol class="install-steps">
{% for index, name in steps %}
<li class="{{ index + 1 < step ? 'done' : (index + 1 == step ? 'current' : 'todo') }}">
<span class="install-step-number" aria-hidden="true">{{ index + 1 < step ? '✓' : index + 1 }}</span>
<span>{{ name }}{% if index + 1 == step %} <span class="visually-hidden">– aktueller Schritt</span>{% endif %}</span>
</li>
{% endfor %}
</ol>
<div class="card">
<div class="card-head"><h1>{% block heading %}{% endblock %}</h1></div>
<div class="card-body">
{% block lead %}{% endblock %}
{% if error is defined and error %}<p class="notice error" role="alert">{{ error }}</p>{% endif %}
{% block content %}{% endblock %}
</div>
</div>
</main>
{% endblock %}
+28
View File
@@ -0,0 +1,28 @@
{% extends 'install/base.html.twig' %}
{% block heading %}Passt dieser Server?{% endblock %}
{% block lead %}
<p class="muted">Geprüft wird, was diese Installation tatsächlich braucht. Jede fehlgeschlagene Prüfung sagt, was zu tun ist – und wo. Punkte ohne Sternchen sind Empfehlungen: ohne sie fehlt eine Funktion, nicht die Website.</p>
{% endblock %}
{% block content %}
<ul class="check-list">
{% for check in checks %}
<li class="{{ check.ok ? 'check-ok' : (check.required ? 'check-bad' : 'check-warn') }}">
<span class="check-mark" aria-hidden="true">{{ check.ok ? '✓' : (check.required ? '!' : '·') }}</span>
<span>
<strong>{{ check.label }}{% if check.required %} <abbr title="Pflicht">*</abbr>{% endif %}</strong>
<span class="visually-hidden">{{ check.ok ? 'erfüllt' : 'nicht erfüllt' }}:</span>
{% if check.ok %}{{ check.found ?: 'in Ordnung' }}{% else %}{{ check.hint }}{% endif %}
</span>
</li>
{% endfor %}
</ul>
<div class="sheet-actions">
{% if ready %}
<a class="primary button-link" href="{{ path('install_database') }}">Weiter zur Datenbank <span aria-hidden="true">→</span></a>
{% else %}
<a class="secondary button-link" href="{{ path('install') }}">Erneut prüfen</a>
{% endif %}
</div>
{% if not ready %}<p class="muted">Solange eine Pflichtprüfung fehlschlägt, geht es nicht weiter. Das ist Absicht: eine Installation, die auf halbem Weg scheitert, hinterlässt mehr Arbeit als eine, die gar nicht erst beginnt.</p>{% endif %}
{% endblock %}
+43
View File
@@ -0,0 +1,43 @@
{% extends 'install/base.html.twig' %}
{% block heading %}Wohin mit den Inhalten?{% endblock %}
{% block lead %}
<p class="muted">SQLite ist eine Datei auf diesem Server – nichts einzurichten, gut für eine Website mit überschaubarem Verkehr. MySQL brauchst du, wenn der Hoster es vorgibt oder mehrere Websites dieselbe Datenbank nutzen sollen. Die Verbindung wird sofort geprüft, nicht erst beim Speichern.</p>
{% endblock %}
{% block content %}
<form method="post" action="{{ path('install_database') }}">
<input type="hidden" name="issued" value="{{ issued }}">
<input type="hidden" name="signature" value="{{ signature }}">
<fieldset class="field-set">
<legend>Art der Datenbank</legend>
<label class="check-label">
<input type="radio" name="driver" value="sqlite" {{ driver != 'mysql' ? 'checked' : '' }} {{ sqlite ? '' : 'disabled' }}>
<span><strong>SQLite</strong> – eine Datei unter <code>var/</code>. Empfohlen, wenn nichts dagegen spricht.{% if not sqlite %} <em>Auf diesem Server nicht verfügbar.</em>{% endif %}</span>
</label>
<label class="check-label">
<input type="radio" name="driver" value="mysql" {{ driver == 'mysql' ? 'checked' : '' }} {{ mysql ? '' : 'disabled' }}>
<span><strong>MySQL / MariaDB</strong> – mit den Zugangsdaten aus dem Kundenmenü deines Hosters.{% if not mysql %} <em>Auf diesem Server nicht verfügbar.</em>{% endif %}</span>
</label>
</fieldset>
<label for="name">Name der Datenbank <span class="muted">(bei SQLite der Dateiname, ohne Endung)</span></label>
<input id="name" name="name" value="{{ values.name|default('cms') }}" required maxlength="64">
<details class="install-details" {{ driver == 'mysql' ? 'open' : '' }}>
<summary>Zugangsdaten für MySQL</summary>
<label for="host">Host</label>
<input id="host" name="host" value="{{ values.host|default('127.0.0.1') }}" maxlength="120" autocomplete="off">
<label for="port">Port</label>
<input id="port" name="port" value="{{ values.port|default('3306') }}" inputmode="numeric" maxlength="5" autocomplete="off">
<label for="user">Benutzer</label>
<input id="user" name="user" value="{{ values.user|default('') }}" maxlength="120" autocomplete="off">
<label for="password">Passwort</label>
<input id="password" name="password" type="password" maxlength="200" autocomplete="off">
<p class="muted">Diese Angaben landen in <code>.env.local</code> im Projektstamm – nicht in der Datenbank und in keinem Protokoll. Die Datei liegt außerhalb von <code>public/</code> und ist über das Netz nicht erreichbar.</p>
</details>
<div class="sheet-actions">
<button class="primary" type="submit">Verbindung prüfen und weiter <span aria-hidden="true">→</span></button>
</div>
</form>
{% endblock %}
+20
View File
@@ -0,0 +1,20 @@
{% extends 'install/base.html.twig' %}
{% block heading %}Fertig.{% endblock %}
{% block lead %}
<p class="muted">Die Installation ist abgeschlossen und <code>/install</code> antwortet ab sofort mit 404. Melde dich als <strong>{{ username }}</strong> an.</p>
{% endblock %}
{% block content %}
<div class="sheet-actions">
<a class="primary button-link" href="/admin/login">Zur Anmeldung <span aria-hidden="true">→</span></a>
<a class="secondary button-link" href="/">Website ansehen</a>
</div>
<h2>Was jetzt noch fehlt</h2>
<ul class="check-list">
<li class="check-warn"><span class="check-mark" aria-hidden="true">·</span><span><strong>Impressum und Datenschutzerklärung ausfüllen.</strong> Beide Seiten sind angelegt und veröffentlicht; die Pflichtangaben stehen als <code>NOCH AUSZUFÜLLEN</code> darin. Solange sie so dastehen, sind sie eine Baustelle mit Ansage – aber keine Erklärung.</span></li>
<li class="check-warn"><span class="check-mark" aria-hidden="true">·</span><span><strong>Die Datei <code>install.php</code> löschen</strong>, falls du über sie installiert hast. Sie löscht sich selbst, aber nachsehen kostet nichts.</span></li>
<li class="check-warn"><span class="check-mark" aria-hidden="true">·</span><span><strong>Einen täglichen Cronjob einrichten:</strong> <code>php bin/console cms:backup</code> für die Sicherung und <code>php bin/console cms:stats:prune</code> für die Statistik. Ohne Sicherung gibt es keinen Rückweg.</span></li>
<li class="check-warn"><span class="check-mark" aria-hidden="true">·</span><span><strong>HTTPS prüfen.</strong> Eine Anmeldung über <code>http://</code> überträgt das Passwort im Klartext.</span></li>
<li class="check-warn"><span class="check-mark" aria-hidden="true">·</span><span><strong>Inhalt und Gestaltung:</strong> Startseite bearbeiten, unter „Design“ Farben und Schriften setzen, unter „Einstellungen“ die Angaben zum Unternehmen ergänzen.</span></li>
</ul>
{% endblock %}
+26
View File
@@ -0,0 +1,26 @@
{% extends 'install/base.html.twig' %}
{% block heading %}Die Tabellen anlegen{% endblock %}
{% block lead %}
<p class="muted">Die Verbindung steht. Jetzt werden die Migrationen ausgeführt – dieselben, die später ein Update einspielt, und in derselben Reihenfolge. Das läuft in diesem Prozess, ohne Konsole.</p>
{% endblock %}
{% block content %}
{% if pending is not empty %}
<p><strong>{{ pending|length }}</strong> Migration{{ pending|length == 1 ? '' : 'en' }} stehen an:</p>
<ul class="check-list">
{% for name in pending|slice(0, 40) %}
<li class="check-ok"><span class="check-mark" aria-hidden="true">·</span><span><code>{{ name|split('\\')|last }}</code></span></li>
{% endfor %}
</ul>
{% else %}
<p class="muted">Es steht nichts an – entweder ist die Datenbank schon eingerichtet, oder die Liste ließ sich nicht lesen. Der Knopf unten schadet in beiden Fällen nicht: eine bereits angewandte Migration wird nicht wiederholt.</p>
{% endif %}
<form method="post" action="{{ path('install_migration') }}">
<input type="hidden" name="issued" value="{{ issued }}">
<input type="hidden" name="signature" value="{{ signature }}">
<div class="sheet-actions">
<button class="primary" type="submit">Migrationen ausführen <span aria-hidden="true">→</span></button>
</div>
</form>
<p class="muted">Dauert das zu lange und bricht der Browser ab, ist das kein Schaden: angewandte Migrationen sind vermerkt, ein erneuter Aufruf macht dort weiter.</p>
{% endblock %}
+28
View File
@@ -0,0 +1,28 @@
{% extends 'install/base.html.twig' %}
{% block heading %}Deine Website{% endblock %}
{% block lead %}
<p class="muted">Damit entstehen eine Startseite, Kopf- und Fußbereich mit Menü sowie Impressum und Datenschutzerklärung als Gerüst. Die beiden rechtlichen Seiten sind keine Option – ihr Text ist eine Erklärung über dein Unternehmen, deshalb legt der Installer die Struktur an und markiert jede Stelle, die noch auszufüllen ist.</p>
{% endblock %}
{% block content %}
<form method="post" action="{{ path('install_website') }}">
<input type="hidden" name="issued" value="{{ issued }}">
<input type="hidden" name="signature" value="{{ signature }}">
<label for="name">Name der Website</label>
<input id="name" name="name" value="{{ values.name|default('') }}" required maxlength="120" autofocus placeholder="Meine Firma">
<label for="origin">Adresse</label>
<input id="origin" name="origin" type="url" value="{{ values.origin|default(origin) }}" required maxlength="200">
<p class="muted">Vollständig, mit <code>https://</code>. Sie steht in Sitemap, Feed und strukturierten Daten – eine falsche Adresse dort fällt erst Wochen später auf.</p>
<label for="locale">Sprache</label>
<select id="locale" name="locale">
<option value="de" {{ values.locale|default('de') == 'de' ? 'selected' : '' }}>Deutsch</option>
<option value="en" {{ values.locale|default('') == 'en' ? 'selected' : '' }}>Englisch</option>
</select>
<div class="sheet-actions">
<button class="primary" type="submit">Website anlegen <span aria-hidden="true">→</span></button>
</div>
</form>
{% endblock %}