MiniCMS — Technische Dokumentation
MiniCMS ist ein leichtgewichtiges CMS, das bewusst auf externe Abhängigkeiten verzichtet. Diese Seite richtet sich an Entwickler, Systemadministratoren und technisch versierte Nutzer, die das System verstehen, betreiben oder erweitern wollen.
Designphilosophie
MiniCMS folgt einem einzigen zentralen Grundsatz: Was nicht geladen wird, kann nicht tracken.
Das bedeutet: keine externen JavaScript-Bibliotheken (kein jQuery, kein React, kein Bootstrap), keine CDN-URLs, keine Google Fonts, kein Analytics, kein Feedback-Widget. Jede einzelne Byte, die der Browser des Besuchers lädt, kommt vom eigenen Server.
Ausnahme: Die lokale sortable.min.js — eine MIT-lizenzierte,
lokal eingebettete Drag-&-Drop-Bibliothek, die im Paket enthalten ist.
Kein CDN-Aufruf, keine Verbindung nach außen.
Das CMS ist bewusst dateibasiert: Es braucht keine Datenbank. Alle Inhalte, Layouts und Nutzerdaten liegen als JSON-Dateien auf dem Server. Das senkt die Angriffsfläche, vereinfacht Backups und macht die Daten für Menschen lesbar.
Voraussetzungen
| Komponente | Anforderung | Hinweis |
|---|---|---|
| PHP | 8.1 oder neuer | Typed Properties, Named Arguments |
| Webserver | Apache 2.2 oder 2.4 | Nginx ohne Anpassungen nicht unterstützt |
| mod_rewrite | Erforderlich | Für .htaccess-Umleitungen |
| mod_headers | Erforderlich | Für Sicherheits-Header |
| AllowOverride | All | Im VirtualHost setzen |
| Schreibrechte | /design/, /inhalte/, /gestalt/bild/ | PHP-Prozess muss schreiben können |
| Datenbank | Keine | Dateibasiert (JSON) |
Dateistruktur
Datenspeicherung: Dateisystem statt Datenbank
MiniCMS nutzt keine relationale Datenbank. Alle persistenten Daten sind JSON-Dateien auf dem Dateisystem.
Nutzerdaten
privat/nutzer.json[
{
"rufname": "alice",
"sicherung": "$2y$12$...", // bcrypt-Hash, niemals Klartext
"rang": "admin", // "admin" | "nutzer"
"aktiv": true
}
]
Beiträge
inhalte/[nutzer]/beitraege/mein-beitrag.json{
"titel": "Mein erster Beitrag",
"inhalt": "<p>Text…</p>",
"erstellt": "2024-11-01T10:00:00",
"geaendert": "2024-11-02T14:22:00"
}
Seitenlayout
design/layout.json{
"seite": { "titel": "Meine Website", "stil": "nacht" },
"elemente": [
{ "typ": "textblock", "ueberschrift": "Willkommen", "text": "…" },
{ "typ": "beitragsliste", "max": 5 }
]
}
Backup-Vorteil: Ein einfaches tar -czf backup.tar.gz privat/ inhalte/ design/layout.json
sichert alles, was datenwertig ist. Kein Datenbank-Dump, kein mysqldump, keine Race Conditions.
Authentifizierung
Die Authentifizierung ist vollständig in privat/auth.php zentralisiert.
Kein Skript außer auth.php darf Sessions schreiben.
Passwort-Hashing
Passwörter werden ausschließlich mit password_hash() und
PASSWORD_BCRYPT (Cost 12) gespeichert. Die Klartext-Variante
verlässt zu keinem Zeitpunkt den Arbeitsspeicher.
Session-Konfiguration
session.cookie_httponly = 1 // Kein JS-Zugriff auf Session-Cookie
session.cookie_samesite = Strict // CSRF-Schutz auf Cookie-Ebene
session.use_strict_mode = 1 // Session-Fixation unterbunden
Rang-System
| Rang | Darf |
|---|---|
| admin | Alle Funktionen: Design, Nutzerverwaltung, alle Beiträge einsehen/löschen |
| nutzer | Eigene Beiträge + Fotos verwalten, eigenes Profil ändern |
CSRF-Schutz
Alle zustandsverändernden Formulare (Login, Beitrag speichern, Bild hochladen, Nutzer anlegen, Layout speichern) enthalten ein verstecktes CSRF-Token-Feld. Das Token wird pro Session generiert, serverseitig geprüft und bei Mismatch die Aktion abgebrochen.
Schematisch// Generierung (bei Session-Start)
$_SESSION['csrf'] = bin2hex(random_bytes(32));
// Im Formular
<input type="hidden" name="csrf" value="<?= $_SESSION['csrf'] ?>">
// Prüfung
if (!hash_equals($_SESSION['csrf'], $_POST['csrf'] ?? '')) {
http_response_code(403); exit();
}
Upload-Sicherheit
Datei-Uploads durchlaufen eine zweistufige Validierung:
Schritt 1: Dateiendung
Nur .jpg, .jpeg, .png, .gif, .webp sind erlaubt.
Schritt 2: MIME-Typ via getimagesize()
Die PHP-Funktion getimagesize() liest die tatsächlichen Bilddaten
und prüft den echten MIME-Typ — unabhängig von Dateiendung oder dem
vom Browser gemeldeten Content-Type. Eine als .jpg
umbenannte PHP-Datei wird abgelehnt.
Kein Direktzugriff auf Uploads
Alle Upload-Ordner (/inhalte/*/fotos/, /gestalt/bild/)
werden per .htaccess gesperrt. Bilder werden ausschließlich über
bild.php ausgeliefert, das Pfad-Traversal-Angriffe prüft:
bild.php — Pfadvalidierung$pfad = realpath(__DIR__ . '/inhalte/' . $anfrage);
if (!$pfad || !str_starts_with($pfad, realpath(__DIR__ . '/inhalte/'))) {
http_response_code(403); exit();
}
Apache / .htaccess-Konzept
Jedes sensitive Verzeichnis erhält eine eigene .htaccess, die
direkten Webserver-Zugriff verhindert. Die Dateien unterstützen Apache 2.2
und 2.4 (Fallback-Direktiven).
privat/.htaccess (Schema)# Apache 2.4
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
# Apache 2.2 Fallback
<IfModule !mod_authz_core.c>
Order Deny,Allow
Deny from all
</IfModule>
Gesperrte Verzeichnisse (403 Forbidden bei direktem Aufruf):
| Pfad | Inhalt |
|---|---|
| /privat/ | Nutzerdaten, Session-Auth |
| /inhalte/ | Alle Nutzerinhalte |
| /kern/ | Server-Einstellungen |
| /funktion/ | Plugin-PHP-Dateien |
| /design/elemente/ | Design-Snippets |
| /gestalt/bild/ | Medien-Uploads |
| /stilvorlage/ | Stilvorlagen-Konfiguration |
Sicherheits-Header
Die Haupt-.htaccess setzt HTTP-Sicherheits-Header über
mod_headers:
Gesendete HeaderX-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer
Permissions-Policy: camera=(), microphone=(), geolocation=()
Content-Security-Policy: Noch nicht gesetzt — geplant für eine spätere Version.
Da keine externen Ressourcen geladen werden, ist das Risiko gering, aber eine strikte CSP
wäre eine sinnvolle Ergänzung (default-src 'self').
Plugin-System
Das Plugin-System ist bewusst simpel: Jede .php-Datei im
Verzeichnis /funktion/ wird von index.php
automatisch per require_once eingebunden — in alphabetischer
Reihenfolge. Nummernpräfixe erlauben Kontrolle über die Reihenfolge.
Verfügbare Variablen im Plugin-Scope
| Variable | Typ | Beschreibung |
|---|---|---|
| $alleInhalte | array | Gelesene JSON-Inhalte aus /inhalte/ |
| $layout | array | Aktuelles Layout aus design/layout.json |
| auth_aktiv() | bool | Ist ein Nutzer eingeloggt? |
Explizite Verbote für Plugins
curl, file_get_contents(URL))session_start(), $_SESSION schreiben)/privat/auth_, sitzung_, beitrag_, nutzer_ definierenPlugin-Vorlage
funktion/mein_plugin.php/**
* MiniCMS Plugin: Mein Plugin
* Präfix: mp_
*/
function mp_meine_funktion(): string {
// Nur lokale Operationen
return '<p>Plugin-Ausgabe</p>';
}
// Formularverarbeitung
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['mp_aktion'])) {
// CSRF prüfen! (via auth.php-Funktion)
auth_csrf_pruefen();
// ... Verarbeitung
}
Design-Engine
Das Layout einer Seite ist ein JSON-Array von Element-Objekten.
index.php iteriert über diese Liste und inkludiert
für jeden Eintrag das passende PHP-Snippet aus design/elemente/.
Vereinfachte Logik in index.phpforeach ($layout['elemente'] as $el) {
$snippet = __DIR__ . '/design/elemente/' . $el['typ'] . '.php';
if (is_file($snippet)) {
include $snippet; // erhält $el als lokale Variable
}
}
Verfügbare Element-Typen
| Typ-Schlüssel | Datei | Beschreibung |
|---|---|---|
| textblock | textblock.php | Überschrift + Fließtext (Rich Text) |
| bildblock | bildblock.php | Bild mit Caption, lazy loading |
| zweispaltig | zweispaltig.php | Zweispaltiges Layout |
| spalten | spalten.php | Flexible Spalten |
| trennlinie | trennlinie.php | Horizontale Linie |
| beitragsliste | beitragsliste.php | Automatische Beitragsübersicht |
| bilderkarussell | bilderkarussell.php | Mehrere unabhängige Karussells |
| kontaktformular | kontaktformular.php | E-Mail-Formular via PHP mail() |
Plugins werden nur geladen, wenn der entsprechende Element-Typ im aktiven Layout vorhanden ist. Das Kontaktformular-Plugin erscheint nicht automatisch auf jeder Seite.
CSS-Stilvorlagen (neu in 0.8)
In der Schaltzentrale wählt der Admin eine von fünf Stilvorlagen.
Die Auswahl wird in design/layout.json gespeichert
("stil": "nacht") und beim Seitenaufruf als CSS-Datei eingebunden.
Alle Dateien liegen lokal — kein CDN, keine externe Schriftart.
| Schlüssel | Datei | Charakter |
|---|---|---|
| standard | stil.css | Klassisch, neutral |
| dezent | stil-dezent.css | Zurückhaltend, viel Weißraum |
| nacht | stil-nacht.css | Dunkles Theme |
| zeitung | stil-zeitung.css | Zeitungsoptik, Serifenschriften |
| modern | stil-modern.css | Schatten, klare Kanten |
Drag & Drop — sortable.min.js
Die Drag-&-Drop-Funktionalität im Design-Editor basiert auf einer lokal eingebetteten, für MiniCMS angepassten Version von SortableJS (MIT-Lizenz).
Kritischer Fix in 0.4
In Version 0.3 fing der _onTapStart-Handler jeden
mousedown-Event ab und rief preventDefault() auf,
bevor Eingabefelder reagieren konnten. Ergebnis: Alle <input>-
und <textarea>-Felder im Design-Editor waren unklickbar.
Lösung: Der Handler prüft jetzt, ob das Ziel-Element ein interaktives Element
ist und überspringt dann die Drag-Logik. preventDefault() erfolgt
erst beim echten Drag-Start, nicht beim Klick.
_onTapStart — vereinfacht_onTapStart(evt) {
const ziel = evt.target.closest('input,textarea,select,button,a,label');
if (ziel) return; // Interaktives Element → kein Drag
if (this.options.handle && !evt.target.closest(this.options.handle)) return;
// → Drag starten
}
Go-Live-Checkliste
Dateirechte
find . -type d | xargs chmod 750
find . -type f | xargs chmod 640
chmod 755 gestalt/ # CSS/JS muss ausgeliefert werden
rm setup.php # Pflicht nach Ersteinrichtung
PHP-Konfiguration (php.ini)
display_errors = Off
log_errors = On
expose_php = Off
session.cookie_httponly = 1
session.cookie_samesite = Strict
session.use_strict_mode = 1
upload_max_filesize = 5M
Apache
ServerSignature Off
ServerTokens Prod
# Im VirtualHost:
AllowOverride All
Zugriffstests (alle müssen 403 liefern)
curl -I https://example.com/privat/nutzer.json
curl -I https://example.com/inhalte/
curl -I https://example.com/kern/einstellungen.php
curl -I https://example.com/funktion/kontaktformular.php
HTTPS ist Pflicht. Ohne HTTPS werden Session-Cookies im Klartext übertragen.
Eine HTTP→HTTPS-Weiterleitung gehört in die Haupt-.htaccess.
Backup
Da MiniCMS dateibasiert ist, genügt ein einfaches Tar-Archiv. Es gibt keinen Datenbank-Dump und keine Migrationen.
# Täglich per cron
tar -czf backup-$(date +%Y%m%d).tar.gz \
privat/ inhalte/ design/layout.json funktion/
| Pfad | Inhalt | Priorität |
|---|---|---|
| /privat/nutzer.json | Nutzerdaten + Passwort-Hashes | Kritisch |
| /inhalte/ | Beiträge + Fotos | Kritisch |
| /design/layout.json | Seitenlayout | Hoch |
| /funktion/ | Angepasste Plugins | Mittel |
| /gestalt/ | CSS + JS (aus Paket rekonstruierbar) | Niedrig |
Changelog
0.8
Design-Auswahl in der Schaltzentrale — Unter „Seiteneinstellungen" kann das Design gewählt werden. Vier neue CSS-Stilvorlagen: dezent, nacht, zeitung, modern. Alle 100 % lokal.
0.7
Kontaktformular nur noch sichtbar wenn eingebunden. Karussell-Bug (karBildEntfernen außerhalb
des Script-Blocks). .htaccess für Apache 2.2/2.4 kompatibel gemacht.
Karussell-Hidden-Input-Initialisierung beim Laden. Papierkorb-Sicherheitsprüfung: realpath()
erst nach is_file(). loading="lazy" für Bildblöcke.
0.4
SortableJS-Kernbug behoben: _onTapStart blockierte alle Eingabefelder.
Separater Drag-Griff pro Kachel. Papierkorb findet jetzt auch Beiträge in beitraege/-Unterordner.
Doppelte Funktionsdeklaration bei mehrfacher beitragsliste behoben (function_exists-Guard).
0.3
Einstellungsfelder speichern korrekt (CSS-gesteuertes Panel statt JS-Toggle). Neu angelegte Elemente öffnen Einstellungs-Panel sofort. Bilderkarussell als neues Element. Mehrere unabhängige Karussells pro Seite möglich.
0.2
Mehrere Beiträge pro Nutzer. Beitragsliste-Element. Kontaktformular-Element. Startseite ohne automatisches Formular. Vollständige CSS-Stile für alle Elemente.