Themes

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.php muss vorhanden sein.
  • metadata.json muss 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.

Quellen und weiterführende Dokumentation