feat(setup): Systemcheck und Upload-Paket fuer klassisches Webhosting

Auf Shared Hosting gibt es keine Kommandozeile - ohne Anzeige laesst
sich dort nicht feststellen, ob etwa gd fehlt, die PHP-Version zu alt
ist oder data/ nicht beschreibbar ist. Der Setup-Assistent prueft das
jetzt und listet es auf; im Dashboard meldet sich die Pruefung spaeter
nur noch, wenn etwas nicht stimmt. Fehlende Pflichtbausteine sind
Fehler, fehlende Kuer (intl, imagick, cURL) nur Hinweise mit Angabe,
was dadurch entfaellt.

build-release.sh erzeugt das Upload-Paket: Abhaengigkeiten ohne
Entwicklungspakete, ohne Tests und Docker-Dateien. Dabei faellt eine
16 MB grosse Schriftart aus endroid/qr-code weg, die ausschliesslich
fuer Beschriftungen unter dem QR-Code gebraucht wird - die erzeugt
diese Anwendung nicht. Das Paket schrumpft damit von 18 MB auf 1 MB.
Sollte spaeter doch eine Beschriftung dazukommen, erkennt das Skript
das und behaelt die Datei.

Die Variante ohne eigenen Webroot (alles liegt im Domainverzeichnis,
die .htaccess im Stammverzeichnis leitet nach public/ um) war bisher
gebaut, aber nie erprobt. Sie ist jetzt gegen eine Hoster-Simulation
getestet: Ersteinrichtung, Kurzlinks, Weiterleitung, QR-Codes,
Statistik und die Sperren fuer src/, vendor/, data/ und .env.
This commit is contained in:
2026-08-10 15:32:13 +02:00
parent fa92817a88
commit c65c02cda5
9 changed files with 462 additions and 8 deletions
+11 -1
View File
@@ -28,6 +28,7 @@ use App\Security\RateLimiter;
use App\Security\Session;
use App\Security\SetupToken;
use App\Service\DomainChecker;
use App\Service\SystemCheck;
use Throwable;
/**
@@ -137,9 +138,18 @@ final class App
$this->auth,
new SetupToken($this->config->path($this->config->getString('DATA_DIR', 'data'))),
$this->rateLimiter,
new SystemCheck($this->config),
);
$login = new AuthController($this->config, $this->view, $this->session, $this->csrf, $this->auth, $this->rateLimiter);
$dashboard = new DashboardController($this->view, $this->auth, $this->links, $this->visits, $this->config, $this->domains);
$dashboard = new DashboardController(
$this->view,
$this->auth,
$this->links,
$this->visits,
$this->config,
$this->domains,
new SystemCheck($this->config),
);
$linkController = new LinkController($this->config, $this->view, $this->session, $this->csrf, $this->auth, $this->links, $this->designs, $this->logos, $this->domains);
$stats = new StatsController($this->config, $this->view, $this->auth, $this->links, $this->visits);
$qrController = new QrController($this->config, $this->view, $this->session, $this->csrf, $this->auth, $this->links, $this->designs, $this->logos, $this->qr);
+5
View File
@@ -12,6 +12,7 @@ use App\Repository\LinkRepository;
use App\Repository\VisitRepository;
use App\Security\Auth;
use App\Security\Session;
use App\Service\SystemCheck;
use App\View;
final class DashboardController extends Controller
@@ -23,6 +24,7 @@ final class DashboardController extends Controller
private readonly VisitRepository $visits,
Config $config,
private readonly DomainRepository $domains,
private readonly SystemCheck $systemCheck,
) {
parent::__construct($config, $view);
}
@@ -46,6 +48,9 @@ final class DashboardController extends Controller
'topLinks' => $this->links->all('', 'clicks_desc', 5),
'series' => $this->visits->clicksPerDayGlobal(30),
'domainCount' => $this->domains->count(),
// Nur melden, wenn etwas nicht stimmt - im Normalfall
// soll die Uebersicht nicht mit Technik zugestellt werden.
'systemProblems' => $this->systemCheck->problems(),
'pageTitle' => 'Übersicht',
]);
}
+4
View File
@@ -12,6 +12,7 @@ use App\Security\Csrf;
use App\Security\RateLimiter;
use App\Security\Session;
use App\Security\SetupToken;
use App\Service\SystemCheck;
use App\View;
/**
@@ -37,6 +38,7 @@ final class SetupController extends Controller
private readonly Auth $auth,
private readonly SetupToken $setupToken,
private readonly RateLimiter $rateLimiter,
private readonly SystemCheck $systemCheck,
) {
parent::__construct($config, $view);
}
@@ -126,6 +128,8 @@ final class SetupController extends Controller
'errors' => $errors,
'minLength' => self::MIN_PASSWORD_LENGTH,
'tokenPath' => $this->setupToken->path(),
'systemChecks' => $this->systemCheck->all(),
'systemStatus' => $this->systemCheck->worstStatus(),
], $status, 'layout_plain');
}
}
+221
View File
@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Config;
/**
* Prueft, ob der Server alles mitbringt.
*
* Auf klassischem Webhosting gibt es keine Kommandozeile - ohne diese
* Anzeige liesse sich nicht feststellen, ob etwa gd fehlt oder das
* Datenverzeichnis nicht beschreibbar ist. Die Pruefung laeuft deshalb
* im Setup-Assistenten und meldet sich spaeter im Dashboard nur dann,
* wenn etwas nicht stimmt.
*/
final class SystemCheck
{
public const OK = 'ok';
public const WARNING = 'warning';
public const ERROR = 'error';
public const MIN_PHP = '8.2.0';
public function __construct(private readonly Config $config)
{
}
/**
* @return list<array{label:string,status:string,text:string,required:bool}>
*/
public function all(): array
{
return [
$this->php(),
$this->database(),
$this->extension('gd', 'Bilderzeugung (gd)', true, 'Ohne gd lassen sich keine PNG-QR-Codes erzeugen; SVG funktioniert weiterhin.'),
$this->extension('mbstring', 'Zeichenketten (mbstring)', true, 'Wird für Umlaute in Slugs, URLs und Domains gebraucht.'),
$this->extension('fileinfo', 'Dateityperkennung (fileinfo)', true, 'Wird beim Prüfen hochgeladener Logos gebraucht.'),
$this->dataDirectory(),
$this->uploadLimit(),
$this->extension('intl', 'Umlaut-Domains (intl)', false, 'Ohne intl greift ein eingebauter Punycode-Rückfallweg – Umlaut-Domains funktionieren trotzdem.'),
$this->extension('imagick', 'SVG-Logos in PNG (imagick)', false, 'Ohne imagick erscheinen SVG-Logos nur im SVG-Download. PNG-Logos sind davon nicht betroffen.'),
$this->outgoingRequests(),
];
}
/**
* Fasst zusammen, ob etwas zu tun ist.
*/
public function worstStatus(): string
{
$status = self::OK;
foreach ($this->all() as $check) {
if ($check['status'] === self::ERROR) {
return self::ERROR;
}
if ($check['status'] === self::WARNING) {
$status = self::WARNING;
}
}
return $status;
}
/**
* @return list<array{label:string,status:string,text:string,required:bool}>
*/
public function problems(): array
{
return array_values(array_filter(
$this->all(),
static fn (array $check): bool => $check['status'] !== self::OK,
));
}
/**
* @return array{label:string,status:string,text:string,required:bool}
*/
private function php(): array
{
$ok = version_compare(PHP_VERSION, self::MIN_PHP, '>=');
return [
'label' => 'PHP-Version',
'status' => $ok ? self::OK : self::ERROR,
'text' => $ok
? PHP_VERSION
: sprintf('%s – benötigt wird mindestens %s. Im Hosting-Panel umstellen.', PHP_VERSION, self::MIN_PHP),
'required' => true,
];
}
/**
* @return array{label:string,status:string,text:string,required:bool}
*/
private function database(): array
{
$driver = strtolower($this->config->getString('DB_DRIVER', 'sqlite'));
$needed = $driver === 'mysql' || $driver === 'mariadb' ? 'pdo_mysql' : 'pdo_sqlite';
$ok = extension_loaded('pdo') && extension_loaded($needed);
return [
'label' => 'Datenbank',
'status' => $ok ? self::OK : self::ERROR,
'text' => $ok
? sprintf('%s über %s', $driver === 'sqlite' ? 'SQLite' : 'MySQL/MariaDB', $needed)
: sprintf('Die PHP-Erweiterung %s fehlt.', $needed),
'required' => true,
];
}
/**
* @return array{label:string,status:string,text:string,required:bool}
*/
private function extension(string $name, string $label, bool $required, string $hint): array
{
$loaded = extension_loaded($name);
return [
'label' => $label,
'status' => $loaded ? self::OK : ($required ? self::ERROR : self::WARNING),
'text' => $loaded ? 'vorhanden' : ('nicht vorhanden – ' . $hint),
'required' => $required,
];
}
/**
* @return array{label:string,status:string,text:string,required:bool}
*/
private function dataDirectory(): array
{
$path = $this->config->path($this->config->getString('DATA_DIR', 'data'));
$writable = is_dir($path) && is_writable($path);
return [
'label' => 'Datenverzeichnis',
'status' => $writable ? self::OK : self::ERROR,
'text' => $writable
? $path . ' ist beschreibbar'
: sprintf('%s ist nicht beschreibbar. Rechte auf 755 bzw. 775 setzen.', $path),
'required' => true,
];
}
/**
* @return array{label:string,status:string,text:string,required:bool}
*/
private function uploadLimit(): array
{
$wanted = $this->config->getInt('UPLOAD_MAX_BYTES', 2097152);
$limit = min(
self::toBytes((string) ini_get('upload_max_filesize')),
self::toBytes((string) ini_get('post_max_size')),
);
$ok = $limit <= 0 || $limit >= $wanted;
return [
'label' => 'Upload-Größe',
'status' => $ok ? self::OK : self::WARNING,
'text' => $ok
? sprintf('bis %s je Logo', self::format($limit > 0 ? $limit : $wanted))
: sprintf(
'Der Server erlaubt nur %s, eingestellt sind %s. Logos werden entsprechend früher abgelehnt.',
self::format($limit),
self::format($wanted),
),
'required' => false,
];
}
/**
* @return array{label:string,status:string,text:string,required:bool}
*/
private function outgoingRequests(): array
{
$curl = function_exists('curl_init');
$fopen = (bool) ini_get('allow_url_fopen');
$ok = $curl || $fopen;
return [
'label' => 'Domain-Prüfung',
'status' => $ok ? self::OK : self::WARNING,
'text' => $ok
? ($curl ? 'über cURL möglich' : 'über allow_url_fopen möglich')
: 'Weder cURL noch allow_url_fopen verfügbar – die Domain-Prüfung kann die Zuordnung nicht testen. '
. 'Alles Übrige funktioniert davon unberührt.',
'required' => false,
];
}
private static function toBytes(string $value): int
{
$value = trim($value);
if ($value === '') {
return 0;
}
$unit = strtolower(substr($value, -1));
$number = (int) $value;
return match ($unit) {
'g' => $number * 1024 * 1024 * 1024,
'm' => $number * 1024 * 1024,
'k' => $number * 1024,
default => $number,
};
}
private static function format(int $bytes): string
{
if ($bytes >= 1024 * 1024) {
return round($bytes / 1024 / 1024, 1) . ' MB';
}
return max(1, intdiv($bytes, 1024)) . ' KB';
}
}