Files
KantSites/src/Ops/Download.php
T
kantarciandClaude Opus 5 c8a8fa7460 Rebuild DEEG as a fifth installation, add stepped forms and a plugin system, and clear the review backlog
Forms gained steps, conditions, image choices, number fields with units and a
summary step; the DEEG price finder uses all of it. Plugins are manifest plus
Twig: blocks, settings, containers, routes, presets and theme packs, switched
per installation. The review backlog is done: archive pages render through the
normal page pipeline, forms and post lists work inside areas and sections,
repeated sections get their own ids, breakpoints and colours come from the
palette, backup and update work on MySQL and lock each other out.

The task worker no longer shortens a process that has no time limit:
set_time_limit() sets the limit instead of extending it, which turned an
unlimited CLI run into eighty seconds and killed the full test suite far away
from the scheduler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 07:32:35 +02:00

191 lines
8.1 KiB
PHP

<?php
namespace App\Ops;
/**
* Holt eine Datei über HTTPS - über curl, ersatzweise über den Dateizeiger.
*
* Zwei Wege, weil auf einem Webhosting mal das eine da ist und mal das andere: curl fehlt auf
* manchen Paketen, `allow_url_fopen` ist auf anderen abgeschaltet. Dieselbe Doppelung steht im
* Installer und beim Laden der Schriften.
*
* Eine eigene Klasse ist das aus zwei Gründen: Die Prüfung des Zertifikats und die Sperre für
* alles außer HTTPS stehen an genau einer Stelle - und der Updater lässt sich prüfen, ohne dass
* ein Test ins Netz geht.
*/
class Download
{
/**
* Die curl-Fehler, die „ich kann das Zertifikat nicht prüfen" heißen.
*
* Als Zahlen und nicht als Konstanten: `CURLE_PEER_FAILED_VERIFICATION` gibt es nicht in
* jedem PHP-Bau, und ein fehlender Konstantenname wäre ein Absturz genau an der Stelle, an
* der eigentlich eine hilfreiche Meldung stehen soll.
*/
private const ZERTIFIKATSFEHLER = [51, 60, 77];
/** Warum der letzte Versuch nichts gebracht hat - in einem Satz, der jemandem weiterhilft. */
private string $grund = '';
public function __construct(
private int $connectTimeout = 15,
private int $timeout = 900,
) {
}
/**
* Der Grund des letzten Fehlschlags.
*
* Ohne ihn steht in der Verwaltung „die Releaseseite antwortet nicht", und das ist in drei
* Fällen dieselbe Meldung: kein Weg nach draußen, kein Zertifikatsspeicher, falsche Adresse.
* Der mittlere ist auf einem Webhosting der häufigste und der einzige, den man in einer
* Minute behebt - er muss also dastehen.
*/
public function reason(): string
{
return $this->grund;
}
/**
* Holt die Adresse direkt in eine Datei - fuer Archive, die nicht als Zeichenkette in den
* Speicher passen sollen. Ohne curl geht es ueber get(); dann ist der Speicher die Grenze.
*/
public function toFile(string $url, string $path): bool
{
$this->grund = '';
if (!str_starts_with($url, 'https://')) {
$this->grund = 'Nur HTTPS-Adressen werden geholt.';
return false;
}
if (function_exists('curl_init')) {
$datei = @fopen($path, 'w');
if (!is_resource($datei)) {
$this->grund = 'Die Zieldatei ließ sich nicht anlegen: '.$path;
return false;
}
$curl = curl_init($url);
curl_setopt_array($curl, [
CURLOPT_FILE => $datei,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_CONNECTTIMEOUT => $this->connectTimeout,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_USERAGENT => 'KantSites-Updater',
]);
$ok = curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$fehler = curl_errno($curl);
$text = curl_error($curl);
curl_close($curl);
fclose($datei);
if (true === $ok && 200 === $status && (int) filesize($path) > 0) {
return true;
}
@unlink($path);
if (!in_array($fehler, self::ZERTIFIKATSFEHLER, true)) {
$this->grund = match (true) {
0 !== $fehler => 'Die Verbindung kam nicht zustande: '.$text,
0 === $status => 'Der Server hat nicht geantwortet.',
default => sprintf('Die Adresse antwortet mit %d statt 200.', $status),
};
return false;
}
}
$inhalt = $this->get($url);
if (null === $inhalt || '' === $inhalt) {
return false;
}
return false !== file_put_contents($path, $inhalt);
}
/** Den Inhalt der Adresse, oder null wenn sie nicht mit 200 antwortet. */
public function get(string $url): ?string
{
$this->grund = '';
// Kein http, kein file, kein ftp: ein Update, das über eine unverschlüsselte Verbindung
// kommt, kann unterwegs ausgetauscht worden sein.
if (!str_starts_with($url, 'https://')) {
$this->grund = 'Nur HTTPS-Adressen werden geholt.';
return null;
}
if (function_exists('curl_init')) {
$curl = curl_init($url);
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
// Auch eine Weiterleitung bleibt bei HTTPS: sonst pruefte die erste Zeile nur die
// erste Adresse, und die Gegenstelle koennte auf http:// umlenken.
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_CONNECTTIMEOUT => $this->connectTimeout,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_USERAGENT => 'KantSites-Updater',
]);
$antwort = curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$fehler = curl_errno($curl);
$text = curl_error($curl);
curl_close($curl);
if (is_string($antwort) && 200 === $status) {
return $antwort;
}
// Ein fehlender Zertifikatsspeicher ist keine kaputte Leitung, sondern eine
// Einstellung - und er sieht sonst genauso aus wie eine gesperrte Verbindung.
$this->grund = match (true) {
in_array($fehler, self::ZERTIFIKATSFEHLER, true) => 'Das Zertifikat der Gegenstelle lässt sich nicht prüfen: Diesem PHP fehlt ein Zertifikatsspeicher (curl.cainfo in der php.ini). Geholt wird trotzdem nur mit Prüfung - ein Update ohne sie könnte unterwegs ausgetauscht worden sein.',
0 !== $fehler => 'Die Verbindung kam nicht zustande: '.$text,
0 === $status => 'Der Server hat nicht geantwortet.',
default => sprintf('Die Adresse antwortet mit %d statt 200.', $status),
};
// Bei einem Zertifikatsproblem lohnt der zweite Weg: Der Dateizeiger benutzt die
// Einstellungen von OpenSSL, und die sind manchmal gesetzt, wo curl nichts findet.
if (!in_array($fehler, self::ZERTIFIKATSFEHLER, true)) {
return null;
}
}
if (!filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOL)) {
$this->grund = '' !== $this->grund ? $this->grund : 'Dieser Server darf keine Adressen abrufen: curl fehlt und allow_url_fopen ist abgeschaltet.';
return null;
}
// Der Dateizeiger folgt Weiterleitungen auch auf http://; deshalb hier von Hand, und
// jede Station muss HTTPS sein.
$antwort = false;
for ($hop = 0; $hop <= 3 && str_starts_with($url, 'https://'); ++$hop) {
$context = stream_context_create([
'http' => ['timeout' => $this->timeout, 'follow_location' => 0, 'ignore_errors' => true, 'header' => "User-Agent: KantSites-Updater\r\n"],
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
]);
$antwort = @file_get_contents($url, false, $context);
$kopf = implode("\n", $http_response_header ?? []);
if (!is_string($antwort) || 1 !== preg_match('/^HTTP\/\S+ 3\d\d/m', $kopf) || 1 !== preg_match('/^Location:\s*(\S+)/mi', $kopf, $ziel)) {
break;
}
$url = $ziel[1];
$antwort = false;
}
if (is_string($antwort) && 1 === preg_match('/^HTTP\/\S+ 200/m', implode("\n", $http_response_header ?? []))) {
$this->grund = '';
return $antwort;
}
if ('' === $this->grund) {
$this->grund = 'Die Adresse war über den Dateizeiger nicht erreichbar.';
}
return null;
}
}