MiniCMS — Technische Dokumentation

PHP 8.1+ Kein CDN Kein Tracking Dateibasiert Apache + mod_rewrite Version 0.8

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

KomponenteAnforderungHinweis
PHP8.1 oder neuerTyped Properties, Named Arguments
WebserverApache 2.2 oder 2.4Nginx ohne Anpassungen nicht unterstützt
mod_rewriteErforderlichFür .htaccess-Umleitungen
mod_headersErforderlichFür Sicherheits-Header
AllowOverrideAllIm VirtualHost setzen
Schreibrechte/design/, /inhalte/, /gestalt/bild/PHP-Prozess muss schreiben können
DatenbankKeineDateibasiert (JSON)

Dateistruktur

/
  index.php — Startseite, liest Design-Elemente
  anmelden.php / abmelden.php
  nutzer.php — Nutzerbereich (Beiträge + Fotos)
  verwaltung.php — Admin-Dashboard
  design.php — Design-Editor (nur Admin)
  bild.php — Bild-Proxy (gesicherter Auslieferer)
  setup.php — Einmalige Ersteinrichtung → danach löschen!
  stilvorlage.php — Design-Auswahl (neu in 0.8)
 
  design/
    layout.json — Aktives Seitenlayout
    elemente/ — PHP-Snippets: textblock, bildblock, karussell …
 
  gestalt/
    stil.css — Standard-Stylesheet
    stil-dezent.css / stil-nacht.css / stil-zeitung.css / stil-modern.css — Neu in 0.8
    js/
      sortable.min.js — Lokal, MIT-Lizenz
      richtext.js — Kleiner Inline-Editor
    bild/ — Medien (per .htaccess kein Direktzugriff)
 
  inhalte/ — Alle Nutzerinhalte (per .htaccess gesperrt)
    [nutzername]/
      beitraege/ — JSON-Dateien je Beitrag
      fotos/
      _papierkorb/
 
  privat/ — Auth + Nutzerdaten (per .htaccess gesperrt)
    auth.php
    nutzer.json — Nutzerliste mit bcrypt-Hashes
 
  kern/ — Server-Einstellungen (per .htaccess gesperrt)
  funktion/ — Plugin-Verzeichnis (per .htaccess gesperrt)
  stilvorlage/ — Stilvorlagen-JSON (per .htaccess gesperrt)

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

RangDarf
adminAlle Funktionen: Design, Nutzerverwaltung, alle Beiträge einsehen/löschen
nutzerEigene 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):

PfadInhalt
/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

VariableTypBeschreibung
$alleInhaltearrayGelesene JSON-Inhalte aus /inhalte/
$layoutarrayAktuelles Layout aus design/layout.json
auth_aktiv()boolIst ein Nutzer eingeloggt?

Explizite Verbote für Plugins

VERBOTEN Externe HTTP-Requests (curl, file_get_contents(URL))
VERBOTEN Session-Manipulation (session_start(), $_SESSION schreiben)
VERBOTEN Externe Skripte oder Stylesheets laden
VERBOTEN Datenbankverbindungen
VERBOTEN Schreiben in /privat/
VERBOTEN Funktionen mit den Präfixen auth_, sitzung_, beitrag_, nutzer_ definieren

Plugin-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üsselDateiBeschreibung
textblocktextblock.phpÜberschrift + Fließtext (Rich Text)
bildblockbildblock.phpBild mit Caption, lazy loading
zweispaltigzweispaltig.phpZweispaltiges Layout
spaltenspalten.phpFlexible Spalten
trennlinietrennlinie.phpHorizontale Linie
beitragslistebeitragsliste.phpAutomatische Beitragsübersicht
bilderkarussellbilderkarussell.phpMehrere unabhängige Karussells
kontaktformularkontaktformular.phpE-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üsselDateiCharakter
standardstil.cssKlassisch, neutral
dezentstil-dezent.cssZurückhaltend, viel Weißraum
nachtstil-nacht.cssDunkles Theme
zeitungstil-zeitung.cssZeitungsoptik, Serifenschriften
modernstil-modern.cssSchatten, 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/
PfadInhaltPriorität
/privat/nutzer.jsonNutzerdaten + Passwort-HashesKritisch
/inhalte/Beiträge + FotosKritisch
/design/layout.jsonSeitenlayoutHoch
/funktion/Angepasste PluginsMittel
/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.