Tutorial: Einen sicheren Theme-Uploader für Bludit als Plugin bauen
Ein echter ZIP-Uploader für bl-themes mit Adminseite, CSRF, Strukturprüfung, Größenlimit und ZIP-Slip-Schutz.
Bludit dokumentiert die Theme-Installation klassisch: ZIP entpacken, Ordner nach bl-themes/ kopieren, anschließend im Adminbereich aktivieren. Für Administratoren, die keinen FTP-/WebFTP-Schritt möchten, lässt sich diese Lücke mit einem eigenen Admin-Plugin schließen.
Im Projekt ist ein funktionsfähiger Beispiel-Uploader enthalten. Das installierbare Paket liegt zusätzlich unter md-theme-uploader-1.0.0.zip.
Sicherheitsziel vor Funktionsziel
Ein ZIP-Uploader ist sicherheitskritisch: Themes enthalten PHP und können nach Aktivierung Code ausführen. Der Uploader sollte deshalb nur Administratoren offenstehen, CSRF-Schutz nutzen, ausschließlich ZIP akzeptieren, Pfade normalisieren, ZIP-Slip verhindern, Größen begrenzen und vorhandene Themeordner nicht still überschreiben.
Adminseite über das Plugin-System
Seit Bludit 3.13 können Plugins eigene Admin-Controller und Views bereitstellen. Der Endpunkt hat die Form /admin/plugin/<plugin-name>. Mit adminSidebar() lässt sich ein Navigationslink ergänzen.
public function adminController() {
checkRole(array('admin'));
global $layout;
$layout['title'] = 'Theme Uploader | Bludit';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Upload validieren und installieren
}
}
public function adminSidebar() {
$url = HTML_PATH_ADMIN_ROOT.'plugin/'.Text::lowercase(__CLASS__);
return '<a class="nav-link" href="'.$url.'">Theme Uploader</a>';
}
Formular mit CSRF und multipart/form-data
global $security;
$token = $security->getTokenCSRF();
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="tokenCSRF" value="...">
<input type="file" name="packageZip" accept=".zip,application/zip" required>
<button type="submit">Theme hochladen</button>
</form>
ZIP-Slip vermeiden
Verwende nicht blind extractTo() auf ein fremdes Archiv. Ein Eintrag wie ../../config.php könnte sonst außerhalb des Zielordners landen. Der Beispiel-Uploader iteriert deshalb jeden ZIP-Eintrag, ersetzt Backslashes, weist absolute Pfade und .. zurück und schreibt die Datei selbst in einen temporären Workspace.
Theme-Struktur validieren
index.phpmuss vorhanden sein.metadata.jsonmuss existieren und gültiges JSON sein.languages/muss mindestens eine JSON-Sprachdatei enthalten.- Das Paket darf entweder direkt diese Dateien enthalten oder genau einen direkten Theme-Unterordner.
- Existiert
bl-themes/<slug>schon, bricht der Tutorial-Uploader ab statt zu überschreiben.
Vollständige plugin.php des Tutorial-Uploaders
<?php defined('BLUDIT') or die('Bludit CMS.');
class pluginMDThemeUploader extends Plugin
{
private $maxZipBytes = 10485760; // 10 MiB compressed upload
private $maxFiles = 500;
private $maxExpandedBytes = 52428800; // 50 MiB uncompressed
public function init()
{
$this->dbFields = array();
}
public function adminController()
{
checkRole(array('admin'));
global $layout;
$layout['title'] = 'Theme Uploader | Bludit';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
return;
}
$workspace = $this->workspace().'upload-'.bin2hex(random_bytes(6)).DS;
try {
if (!extension_loaded('zip')) {
throw new Exception('Die PHP-Erweiterung ZIP ist nicht installiert.');
}
if (!isset($_FILES['packageZip'])) {
throw new Exception('Es wurde keine ZIP-Datei übertragen.');
}
$file = $_FILES['packageZip'];
if ($file['error'] !== UPLOAD_ERR_OK) {
throw new Exception('Der Upload ist mit Fehlercode '.(int)$file['error'].' fehlgeschlagen.');
}
if ((int)$file['size'] <= 0 || (int)$file['size'] > $this->maxZipBytes) {
throw new Exception('Das ZIP muss zwischen 1 Byte und 10 MiB groß sein.');
}
if (Text::lowercase(pathinfo($file['name'], PATHINFO_EXTENSION)) !== 'zip') {
throw new Exception('Erlaubt sind ausschließlich ZIP-Dateien.');
}
mkdir($workspace, DIR_PERMISSIONS, true);
$this->safeExtract($file['tmp_name'], $workspace);
$packageRoot = $this->findPackageRoot($workspace, 'index.php');
$this->validatePackage($packageRoot, 'index.php', 'Theme');
$slug = basename(rtrim($packageRoot, DS));
if ($packageRoot === rtrim($workspace, DS)) {
$slug = pathinfo($file['name'], PATHINFO_FILENAME);
}
$slug = Text::lowercase($slug);
$slug = preg_replace('/[^a-z0-9_-]+/', '-', $slug);
$slug = trim($slug, '-_');
if ($slug === '' || !preg_match('/^[a-z0-9][a-z0-9_-]*$/', $slug)) {
throw new Exception('Aus dem Paket konnte kein sicherer Verzeichnisname ermittelt werden.');
}
$destination = PATH_THEMES.$slug;
if (Filesystem::directoryExists($destination)) {
throw new Exception('Der Ordner '.$slug.' existiert bereits. Aus Sicherheitsgründen wird nichts überschrieben.');
}
if (!Filesystem::copyRecursive($packageRoot, $destination)) {
throw new Exception('Das Paket konnte nicht in das Zielverzeichnis kopiert werden.');
}
Filesystem::deleteRecursive(rtrim($workspace, DS));
Alert::set('Theme „'.htmlspecialchars($slug, ENT_QUOTES, 'UTF-8').'“ wurde hochgeladen. Jetzt in der '.('Theme'==='Plugin'?'Plugin-':'Theme-').'Verwaltung prüfen und aktivieren.');
Redirect::page('plugin/'.Text::lowercase(__CLASS__));
} catch (Exception $e) {
if (Filesystem::directoryExists(rtrim($workspace, DS))) {
Filesystem::deleteRecursive(rtrim($workspace, DS));
}
Alert::set($e->getMessage(), ALERT_STATUS_FAIL);
}
}
public function adminView()
{
checkRole(array('admin'));
global $security;
$token = htmlspecialchars($security->getTokenCSRF(), ENT_QUOTES, 'UTF-8');
$target = 'PATH_THEMES' === 'PATH_PLUGINS' ? 'bl-plugins/' : 'bl-themes/';
$kind = 'Theme';
$html = '<div class="container-fluid px-0">';
$html .= '<h2>'.$this->name().'</h2>';
$html .= '<p class="text-muted">ZIP hochladen → Struktur prüfen → sicher entpacken → nach <code>'.$target.'</code> kopieren. Vorhandene Ordner werden nie überschrieben.</p>';
if (!extension_loaded('zip')) {
$html .= '<div class="alert alert-danger">PHP-ZIP ist nicht verfügbar. Der Uploader kann auf diesem Server keine Archive lesen.</div></div>';
return $html;
}
$html .= '<div class="alert alert-warning"><strong>Sicherheitsregel:</strong> Installiere nur Pakete aus Quellen, denen du vertraust. Ein '.$kind.' besteht aus PHP/JS/CSS und kann nach der Aktivierung Code auf deiner Website ausführen.</div>';
$html .= '<form method="post" enctype="multipart/form-data" class="card"><div class="card-body">';
$html .= '<input type="hidden" name="tokenCSRF" value="'.$token.'">';
$html .= '<div class="form-group"><label for="packageZip">'.$kind.'-ZIP</label><input class="form-control-file" type="file" id="packageZip" name="packageZip" accept=".zip,application/zip" required><small class="form-text text-muted">Maximal 10 MiB; maximal 500 Dateien und 50 MiB entpackt.</small></div>';
$html .= '<button type="submit" class="btn btn-primary">ZIP prüfen und hochladen</button>';
$html .= '</div></form>';
$html .= '<hr><h3>Was geprüft wird</h3><ul><li>ZIP-Dateiendung und Uploadfehler</li><li>kein absoluter Pfad, kein <code>..</code> und damit Schutz vor ZIP-Slip</li><li>Dateianzahl und entpackte Gesamtgröße</li><li><code>index.php</code>, <code>metadata.json</code> und ein <code>languages</code>-Ordner</li><li>gültige JSON-Metadaten</li><li>kein Überschreiben bestehender Pakete</li></ul>';
$html .= '</div>';
return $html;
}
public function adminSidebar()
{
$url = HTML_PATH_ADMIN_ROOT.'plugin/'.Text::lowercase(__CLASS__);
return '<a class="nav-link" href="'.$url.'">Theme Uploader</a>';
}
private function safeExtract($zipFile, $destination)
{
$zip = new ZipArchive();
if ($zip->open($zipFile) !== true) {
throw new Exception('Das ZIP-Archiv konnte nicht geöffnet werden.');
}
if ($zip->numFiles < 1 || $zip->numFiles > $this->maxFiles) {
$zip->close();
throw new Exception('Das ZIP enthält zu viele oder keine Dateien.');
}
$expanded = 0;
for ($i=0; $i<$zip->numFiles; $i++) {
$stat = $zip->statIndex($i);
$name = isset($stat['name']) ? str_replace('\\', '/', $stat['name']) : '';
if ($name === '' || strpos($name, "\0") !== false || preg_match('#^(?:/|[A-Za-z]:/)#', $name)) {
$zip->close();
throw new Exception('Unsicherer Pfad im ZIP gefunden.');
}
$parts = array();
foreach (explode('/', $name) as $part) {
if ($part === '' || $part === '.') continue;
if ($part === '..') {
$zip->close();
throw new Exception('ZIP-Slip-Versuch mit .. im Dateipfad erkannt.');
}
$parts[] = $part;
}
if (empty($parts)) continue;
$target = rtrim($destination, DS).DS.implode(DS, $parts);
$isDirectory = substr($name, -1) === '/';
if ($isDirectory) {
if (!Filesystem::directoryExists($target) && !mkdir($target, DIR_PERMISSIONS, true)) {
$zip->close();
throw new Exception('Ein Verzeichnis konnte nicht angelegt werden.');
}
continue;
}
$expanded += isset($stat['size']) ? (int)$stat['size'] : 0;
if ($expanded > $this->maxExpandedBytes) {
$zip->close();
throw new Exception('Das Archiv überschreitet 50 MiB entpackte Gesamtgröße.');
}
$dir = dirname($target);
if (!Filesystem::directoryExists($dir) && !mkdir($dir, DIR_PERMISSIONS, true)) {
$zip->close();
throw new Exception('Ein Zielverzeichnis konnte nicht angelegt werden.');
}
$input = $zip->getStream($stat['name']);
if (!$input) {
$zip->close();
throw new Exception('Eine ZIP-Datei konnte nicht gelesen werden.');
}
$output = fopen($target, 'wb');
if (!$output) {
fclose($input); $zip->close();
throw new Exception('Eine entpackte Datei konnte nicht geschrieben werden.');
}
stream_copy_to_stream($input, $output);
fclose($input); fclose($output);
}
$zip->close();
}
private function findPackageRoot($workspace, $requiredFile)
{
$root = rtrim($workspace, DS);
if (is_file($root.DS.$requiredFile) && is_file($root.DS.'metadata.json')) {
return $root;
}
$candidates = array();
foreach (Filesystem::listDirectories($root) as $directory) {
if (is_file($directory.DS.$requiredFile) && is_file($directory.DS.'metadata.json')) {
$candidates[] = $directory;
}
}
if (count($candidates) !== 1) {
throw new Exception('Das ZIP muss genau ein '.$requiredFile.'-Paket im Archivwurzelverzeichnis oder in genau einem direkten Unterordner enthalten.');
}
return $candidates[0];
}
private function validatePackage($root, $requiredFile, $kind)
{
if (!is_file($root.DS.$requiredFile) || !is_file($root.DS.'metadata.json') || !is_dir($root.DS.'languages')) {
throw new Exception($kind.'-Struktur unvollständig: '.$requiredFile.', metadata.json und languages/ werden benötigt.');
}
$metadata = json_decode(file_get_contents($root.DS.'metadata.json'), true);
if (!is_array($metadata) || json_last_error() !== JSON_ERROR_NONE) {
throw new Exception('metadata.json ist kein gültiges JSON.');
}
$languageFiles = glob($root.DS.'languages'.DS.'*.json');
if (!$languageFiles) {
throw new Exception('Im languages-Ordner wurde keine JSON-Sprachdatei gefunden.');
}
}
}
Warum kein automatisches Aktivieren?
Der Uploader kopiert das Theme absichtlich nur in bl-themes/. Anschließend soll ein Administrator die Metadaten prüfen und es bewusst über die Theme-Verwaltung aktivieren. Das trennt Installation und Ausführung.