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:
+11
-1
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user