- 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>
311 lines
14 KiB
PHP
311 lines
14 KiB
PHP
<?php
|
||
|
||
/**
|
||
* KantSites in einer Datei installieren.
|
||
*
|
||
* Diese Datei per FTP in das Verzeichnis legen, in dem KantSites liegen soll, und im Browser
|
||
* aufrufen. Sie lädt das Releasearchiv, prüft dessen Prüfsumme, entpackt es und löscht sich
|
||
* danach selbst. Anschließend übernimmt der Installer unter /install.
|
||
*
|
||
* Die Regeln, nach denen sie gebaut ist - und warum:
|
||
*
|
||
* - Die Adresse und die Prüfsumme stehen HIER, nicht in einem Formular. Ein Installer, der eine
|
||
* URL entgegennimmt und entpackt, ist eine Einladung, fremden Code auf den Server zu legen.
|
||
* - Ohne passende Prüfsumme wird nicht entpackt. Es gibt kein Häkchen, das daran vorbeiführt.
|
||
* - HTTPS mit Zertifikatsprüfung, kein Rückfall auf HTTP.
|
||
* - Jeder Archiveintrag wird gegen das Zielverzeichnis geprüft, bevor er geschrieben wird. Ein
|
||
* 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
|
||
// gebauten Archivs hier hinein. Ohne beides verweigert die Datei die Arbeit - eine unfertige
|
||
// install.php soll nicht irgendetwas herunterladen.
|
||
const KANT_ARCHIVE_URL = '';
|
||
const KANT_ARCHIVE_SHA256 = '';
|
||
const KANT_VERSION = '';
|
||
|
||
/**
|
||
* Wohin ein Archiveintrag gehört - oder null, wenn er nirgendwohin gehört.
|
||
*
|
||
* Der Namensbestandteil der obersten Ebene (kantsites-1.2.0/) fällt weg, damit die Dateien direkt
|
||
* im Zielverzeichnis landen. Danach wird der Pfad geprüft, und zwar nicht durch Suchen nach „..“,
|
||
* sondern indem der aufgelöste Zielpfad innerhalb des Zielverzeichnisses liegen muss.
|
||
*/
|
||
function kant_target_path(string $entry, string $target): ?string
|
||
{
|
||
$entry = str_replace('\\', '/', $entry);
|
||
if ('' === $entry || str_ends_with($entry, '/')) {
|
||
return null;
|
||
}
|
||
$parts = explode('/', $entry);
|
||
if (count($parts) > 1 && str_starts_with($parts[0], 'kantsites-')) {
|
||
array_shift($parts);
|
||
}
|
||
foreach ($parts as $part) {
|
||
if ('' === $part || '.' === $part || '..' === $part || str_contains($part, "\0") || str_contains($part, ':')) {
|
||
return null;
|
||
}
|
||
}
|
||
if ([] === $parts) {
|
||
return null;
|
||
}
|
||
$base = rtrim(str_replace('\\', '/', $target), '/');
|
||
$path = $base.'/'.implode('/', $parts);
|
||
// Doppelt geprüft: der zusammengesetzte Pfad muss unterhalb des Ziels beginnen.
|
||
return str_starts_with($path, $base.'/') ? $path : null;
|
||
}
|
||
|
||
/** Lädt eine Adresse über HTTPS mit Zertifikatsprüfung in eine Datei. Gibt eine Meldung oder null. */
|
||
function kant_download(string $url, string $file): ?string
|
||
{
|
||
if (!str_starts_with($url, 'https://')) {
|
||
return 'Die Adresse des Archivs ist keine HTTPS-Adresse.';
|
||
}
|
||
$handle = fopen($file, 'wb');
|
||
if (false === $handle) {
|
||
return 'Die heruntergeladene Datei ließ sich nicht anlegen: '.$file;
|
||
}
|
||
try {
|
||
if (function_exists('curl_init')) {
|
||
$curl = curl_init($url);
|
||
curl_setopt_array($curl, [
|
||
CURLOPT_FILE => $handle,
|
||
CURLOPT_FOLLOWLOCATION => true,
|
||
CURLOPT_MAXREDIRS => 3,
|
||
CURLOPT_SSL_VERIFYPEER => true,
|
||
CURLOPT_SSL_VERIFYHOST => 2,
|
||
CURLOPT_CONNECTTIMEOUT => 20,
|
||
CURLOPT_TIMEOUT => 900,
|
||
CURLOPT_USERAGENT => 'KantSites-Installer',
|
||
]);
|
||
$ok = curl_exec($curl);
|
||
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||
$error = curl_error($curl);
|
||
curl_close($curl);
|
||
if (!$ok) {
|
||
return 'Der Download ist gescheitert: '.$error;
|
||
}
|
||
if (200 !== $status) {
|
||
return 'Der Server hat mit Status '.$status.' geantwortet statt mit 200.';
|
||
}
|
||
|
||
return null;
|
||
}
|
||
if (!filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOL)) {
|
||
return 'Weder curl noch allow_url_fopen ist verfügbar. Ohne eines von beidem kann diese Datei nichts herunterladen; dann bleibt der Weg über FTP mit dem entpackten Archiv.';
|
||
}
|
||
$context = stream_context_create(['http' => ['timeout' => 900, 'follow_location' => 1, 'max_redirects' => 3], 'ssl' => ['verify_peer' => true, 'verify_peer_name' => true]]);
|
||
$source = @fopen($url, 'rb', false, $context);
|
||
if (false === $source) {
|
||
return 'Das Archiv ließ sich nicht öffnen. Prüfe, ob dieser Server nach außen verbinden darf.';
|
||
}
|
||
stream_copy_to_stream($source, $handle);
|
||
fclose($source);
|
||
|
||
return null;
|
||
} finally {
|
||
if (is_resource($handle)) {
|
||
fclose($handle);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Entpackt das Archiv; gibt die Anzahl der Dateien zurück oder wirft mit einer lesbaren Meldung. */
|
||
function kant_unpack(string $archive, string $target): int
|
||
{
|
||
if (!class_exists('ZipArchive')) {
|
||
throw new RuntimeException('Die PHP-Erweiterung zip fehlt. Ohne sie lässt sich das Archiv nicht entpacken.');
|
||
}
|
||
$zip = new ZipArchive();
|
||
if (true !== $zip->open($archive)) {
|
||
throw new RuntimeException('Das Archiv ließ sich nicht öffnen. Der Download ist wahrscheinlich unvollständig.');
|
||
}
|
||
$written = 0;
|
||
try {
|
||
for ($index = 0; $index < $zip->numFiles; ++$index) {
|
||
$name = (string) $zip->getNameIndex($index);
|
||
$path = kant_target_path($name, $target);
|
||
if (null === $path) {
|
||
if (str_ends_with($name, '/')) {
|
||
continue;
|
||
}
|
||
throw new RuntimeException('Das Archiv enthält einen Eintrag, der außerhalb des Zielverzeichnisses landen würde: '.$name);
|
||
}
|
||
if (!is_dir(dirname($path)) && !mkdir(dirname($path), 0o775, true) && !is_dir(dirname($path))) {
|
||
throw new RuntimeException('Verzeichnis ließ sich nicht anlegen: '.dirname($path));
|
||
}
|
||
$stream = $zip->getStream($name);
|
||
if (false === $stream) {
|
||
throw new RuntimeException('Eintrag ließ sich nicht lesen: '.$name);
|
||
}
|
||
$out = fopen($path, 'wb');
|
||
if (false === $out) {
|
||
fclose($stream);
|
||
throw new RuntimeException('Datei ließ sich nicht schreiben: '.$path);
|
||
}
|
||
stream_copy_to_stream($stream, $out);
|
||
fclose($stream);
|
||
fclose($out);
|
||
++$written;
|
||
}
|
||
} finally {
|
||
$zip->close();
|
||
}
|
||
|
||
return $written;
|
||
}
|
||
|
||
/** Die Vorprüfung: alles, was fehlschlagen kann, bevor irgendetwas geschrieben wird. */
|
||
function kant_checks(string $target): array
|
||
{
|
||
$checks = [];
|
||
$add = static function (string $label, bool $ok, string $hint) use (&$checks): void {
|
||
$checks[] = ['label' => $label, 'ok' => $ok, 'hint' => $hint];
|
||
};
|
||
$add('PHP-Fassung', PHP_VERSION_ID >= 80200, 'Gefunden: '.PHP_VERSION.'. Gebraucht wird mindestens 8.2 – beim Hoster umstellen.');
|
||
$add('Schreibrecht im Zielverzeichnis', is_writable($target), $target.' ist nicht beschreibbar. Im FTP-Programm die Rechte auf 0775 setzen.');
|
||
$add('ZIP-Erweiterung', class_exists('ZipArchive'), 'Ohne die Erweiterung zip lässt sich das Archiv nicht entpacken.');
|
||
$add('Verbindung nach außen', function_exists('curl_init') || filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOL), 'Weder curl noch allow_url_fopen ist verfügbar; dann bleibt nur der Weg über FTP.');
|
||
$add('Adresse und Prüfsumme eingetragen', '' !== KANT_ARCHIVE_URL && 64 === strlen(KANT_ARCHIVE_SHA256), 'Diese Datei ist unfertig: sie stammt nicht aus einem Release. Lade install.php von der Releaseseite herunter.');
|
||
$free = @disk_free_space($target);
|
||
$add('Freier Platz', false === $free || $free > 80 * 1024 * 1024, 'Es sind weniger als 80 MB frei. Das Archiv braucht Platz zum Herunterladen und zum Entpacken.');
|
||
$add('Verzeichnis ist leer genug', !is_file($target.'/public/index.php') && !is_file($target.'/.env') && !is_file($target.'/.env.local'), 'Hier liegt schon eine Installation. Diese Datei überschreibt nichts – bei einem Update ist der Weg das Releasearchiv von Hand oder der Updater in der Verwaltung.');
|
||
|
||
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
|
||
{
|
||
$archive = $target.'/kantsites-download-'.bin2hex(random_bytes(4)).'.zip';
|
||
try {
|
||
$error = kant_download(KANT_ARCHIVE_URL, $archive);
|
||
if (null !== $error) {
|
||
return [false, $error];
|
||
}
|
||
$hash = hash_file('sha256', $archive);
|
||
if (!hash_equals(KANT_ARCHIVE_SHA256, (string) $hash)) {
|
||
// Nicht entpacken, nicht behalten: eine Datei, die nicht das ist, was sie sein soll,
|
||
// hat auf dem Server nichts verloren.
|
||
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) {
|
||
return [false, $e->getMessage()];
|
||
} finally {
|
||
@unlink($archive);
|
||
}
|
||
}
|
||
|
||
// Im Test wird diese Datei eingebunden, um die Pfadprüfung und das Entpacken zu prüfen, ohne dass
|
||
// etwas heruntergeladen wird.
|
||
if ('cli' === PHP_SAPI) {
|
||
return;
|
||
}
|
||
|
||
$target = __DIR__;
|
||
$checks = kant_checks($target);
|
||
$ready = [] === array_filter($checks, static fn (array $check): bool => !$check['ok']);
|
||
$done = false;
|
||
$message = null;
|
||
if ('POST' === ($_SERVER['REQUEST_METHOD'] ?? 'GET') && $ready) {
|
||
@set_time_limit(0);
|
||
[$done, $message] = kant_run($target);
|
||
if ($done) {
|
||
// Erst jetzt: die Datei hat ihre Arbeit getan und ist ab hier nur noch ein offener Weg,
|
||
// Code auf den Server zu legen.
|
||
@unlink(__FILE__);
|
||
}
|
||
}
|
||
|
||
$e = static fn (?string $value): string => htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
|
||
header('Content-Type: text/html; charset=UTF-8');
|
||
header('X-Robots-Tag: noindex, nofollow');
|
||
?>
|
||
<!doctype html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<meta name="robots" content="noindex, nofollow">
|
||
<title>KantSites installieren</title>
|
||
<style>
|
||
:root { color-scheme: light }
|
||
body { margin: 0; padding: 40px 20px; background: #f6f7f4; color: #1d2320; font: 15px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif }
|
||
main { max-width: 620px; margin: 0 auto; background: #fff; border: 1px solid #e2e7de; border-radius: 14px; padding: 32px }
|
||
h1 { font-size: 23px; margin: 0 0 6px }
|
||
p.lead { color: #5d6b62; margin-top: 0 }
|
||
ul { list-style: none; margin: 22px 0; padding: 0 }
|
||
li { display: flex; gap: 10px; align-items: flex-start; margin-bottom: 10px; font-size: 14px }
|
||
.mark { width: 20px; height: 20px; border-radius: 50%; display: grid; place-items: center; font-size: 12px; font-weight: 700; flex-shrink: 0 }
|
||
.ok .mark { background: #e7f0d6; color: #2c5340 }
|
||
.bad .mark { background: #fff0eb; color: #a3341f }
|
||
.bad { color: #a3341f }
|
||
button { font: inherit; font-weight: 600; padding: 11px 20px; border-radius: 8px; border: 1px solid #2c5340; background: #2c5340; color: #fff; cursor: pointer }
|
||
button[disabled] { opacity: .5; cursor: not-allowed }
|
||
.notice { padding: 14px 16px; border-radius: 10px; background: #fff0eb; color: #a3341f; font-size: 14px }
|
||
.done { background: #e7f0d6; color: #2c5340 }
|
||
code { font-size: 13px; background: #f1f4ee; padding: 1px 5px; border-radius: 4px }
|
||
a { color: #2c5340 }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main>
|
||
<?php if ($done) { ?>
|
||
<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>
|
||
<?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>
|
||
<p class="lead">Diese Datei lädt Fassung <?= $e(KANT_VERSION ?: 'unbekannt') ?> herunter, prüft die Prüfsumme und entpackt sie hierher: <code><?= $e($target) ?></code></p>
|
||
<?php if (null !== $message) { ?><p class="notice"><?= $e($message) ?></p><?php } ?>
|
||
<ul>
|
||
<?php foreach ($checks as $check) { ?>
|
||
<li class="<?= $check['ok'] ? 'ok' : 'bad' ?>">
|
||
<span class="mark" aria-hidden="true"><?= $check['ok'] ? '✓' : '!' ?></span>
|
||
<span><strong><?= $e($check['label']) ?></strong><?= $check['ok'] ? '' : ' – '.$e($check['hint']) ?></span>
|
||
</li>
|
||
<?php } ?>
|
||
</ul>
|
||
<form method="post">
|
||
<button type="submit" <?= $ready ? '' : 'disabled' ?>>Herunterladen und entpacken</button>
|
||
</form>
|
||
<p style="font-size:13px;color:#5d6b62">Das dauert je nach Anbindung eine halbe bis mehrere Minuten. Adresse und Prüfsumme stehen fest in dieser Datei; ein Archiv mit abweichender Prüfsumme wird nicht entpackt.</p>
|
||
<?php } ?>
|
||
</main>
|
||
</body>
|
||
</html>
|