/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>
284 lines
13 KiB
PHP
284 lines
13 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.
|
||
*/
|
||
|
||
// 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.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;
|
||
}
|
||
|
||
/** 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);
|
||
|
||
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>
|
||
<p><a href="install">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>
|