WP Transformer — Codebase-Dokumentation

Für RZ-Meister und technische Mitarbeiter: erklärt jede Datei, jede Klasse und kritische
Code-Stellen mit Zeilennummern. Kein Framework, kein Composer — reines PHP 8.1+.


1. Systemüberblick

WP Transformer ist eine Multi-Tenant-Web-App: Kunden-WordPress-Datenbank-Dumps werden
hochgeladen, geparst und als statische HTML-Sites ausgegeben — ohne laufende
WordPress-Installation.

SQL-Dump (Browser-Upload)
    │
    ▼
WPParser          — liest INSERT-Statements direkt aus dem SQL-Text (kein MySQL nötig)
    │
    ▼
wpt_posts-Tabelle — editierbare Zwischenstufe in der eigenen DB
    │
    ▼
StaticBuilder     — generiert index.html, {slug}.html, feed.json, .htaccess
    │
    ├── ZIP-Download  →  Kunde bekommt fertiges Paket
    └── FTP-Deploy    →  direkt auf Kunden-Server hochladen

Jeder Schritt hat einen manuellen Kontrollpunkt im Browser-Dashboard.


2. Verzeichnisstruktur

www.wptransformer.org/
│
├── config.php            Globale Konstanten + lädt config.local.php
├── config.local.php      DB-Zugangsdaten — NICHT im Git, NICHT per Browser abrufbar
├── init.sql              DB-Schema + Demo-Daten — einmalig in phpMyAdmin importieren
├── .htaccess             Apache-Zugriffsschutz für das gesamte Verzeichnis
├── .gitignore            Schließt Secrets, Tenant-Daten und Uploads aus Git aus
│
├── index.php             Öffentliche Landing Page (wptransformer.org)
├── service.php           Öffentliche Leistungen & Preise Seite
├── impressum.php         Impressum (Pflichtseite)
├── datenschutz.php       Datenschutzerklärung (Pflichtseite)
│
├── login.php             Login-Controller + Redirect-Logik
├── logout.php            Logout (nur POST, GET wird ignoriert)
├── dashboard.php         Mandanten-Übersicht (geschützt)
├── tenant.php            Mandant-Controller: Upload / Parse / Build / Deploy
├── posts.php             Post-Editor pro Mandant
├── setup.php             Erstinstallations-Assistent
│
├── classes/
│   ├── Auth.php          Session, Login, Logout, Passwort-Änderung
│   ├── Csrf.php          CSRF-Token erzeugen und prüfen
│   ├── Db.php            PDO-Singleton (eine DB-Verbindung pro Request)
│   ├── WPParser.php      SQL-Dump-Parser ohne MySQL
│   ├── TenantManager.php CRUD für Mandanten (wpt_tenants) + Status-Check
│   ├── StaticBuilder.php HTML-Generator aus Post-Daten
│   └── Deployer.php      ZIP-Export und FTP-Upload
│
├── templates/
│   ├── layout.php        App-Grundgerüst (Header, Nav, Footer)
│   ├── login.php         Login-Formular
│   ├── dashboard.php     Mandanten-Kacheln
│   ├── tenant.php        Pipeline-Steuerung pro Mandant
│   ├── tenant_new.php    Formular: Neuer Mandant
│   ├── posts.php         Post-Liste + Editor
│   └── themes/
│       └── corporate/
│           ├── index.php Tailwind-Startseite für Kunden-Site
│           └── post.php  Tailwind-Einzelseite für Kunden-Site
│
├── assets/
│   ├── css/
│   │   ├── app.css       Tailwind-CSS für App-Innenbereich (Dashboard, Editor)
│   │   └── landing.css   Tailwind-CSS für öffentliche Seiten (index, service, …)
│   └── fonts/            Lokale Schriftarten
│
├── tenants/              Mandanten-Daten — NICHT im Git
│   └── {slug}/
│       ├── source.sql    Hochgeladener WP-Dump
│       ├── content.json  Geparste Inhalte als JSON-Sicherung
│       └── public/       Fertige statische Site (Apache-Subdomain-Ziel)
│
└── uploads/              Temporäre Uploads — NICHT im Git

3. Öffentliche Seiten

Diese Seiten brauchen keinen Login und sind für potenzielle Kunden sichtbar.

index.php — Landing Page

Bindet assets/css/landing.css ein. Enthält:
- Hero-Abschnitt mit Headline und CTA-Buttons
- Showcase-Abschnitt mit Terminal-Mockup (anonymisiertes Beispiel)
- Kurz-Snippet der Leistungen mit Link zu service.php
- CTA-Abschnitt am Ende

service.php — Leistungen & Preise

Ebenfalls landing.css. Enthält:
- Hero
- Drei-Spalten-Feature-Grid (ab md:-Breakpoint, d.h. ab 768 px)
- Drei Pricing-Cards (Starter / Professional / Enterprise)
- Ghost-Buttons mit bg-signal/10 als Füllfarbe (Tailwind-Custom-Farbe)

impressum.php / datenschutz.php

Reine HTML-Textseiten, ebenfalls landing.css. Keine PHP-Logik.


4. App-Kern

Alle App-Seiten folgen dem gleichen Muster:

require 'config.php';          // Konstanten + DB-Klasse laden
require 'classes/Auth.php';    // Session-Klasse laden
Auth::start();                 // Session starten (idempotent)
Auth::check();                 // Nicht eingeloggt? → Redirect zu /login.php

login.php

Zeile  6  — Kein config.local.php? → Redirect zu setup.php
Zeile  7  — Schon eingeloggt? → Redirect zu dashboard.php
Zeile 10  — Nur bei POST: Auth::login() aufrufen
Zeile 15  — Fehler: sleep(1) — künstliche Verzögerung gegen Brute-Force
Zeile 18  — Template rendern

logout.php

Akzeptiert nur POST-Requests. GET-Requests auf /logout.php werden still ignoriert —
schützt gegen CSRF-basiertes Zwangs-Logout über <img src="/logout.php">.

dashboard.php

Zeile  8  — Alle Mandanten aus DB laden
Zeile  9  — Status jedes Mandanten prüfen (SQL vorhanden? Geparst? Build vorhanden?)
Zeile 13  — Flash-Nachricht aus Session lesen und sofort löschen (einmalige Anzeige)
Zeile 14  — Output-Buffer: Template in $content rendern, dann layout.php drüber legen

Das Output-Buffer-Muster (ob_start / ob_get_clean) wird in allen App-Seiten
verwendet: das Template schreibt in einen Buffer, layout.php bettet $content ein.

tenant.php — Der zentrale Controller

Verarbeitet alle Pipeline-Aktionen eines Mandanten über ?action=:

Action Methode Was passiert
new GET Formular für neuen Mandanten anzeigen
create POST Mandant anlegen, Verzeichnis erstellen, Redirect
upload POST .sql-Datei speichern, DB-Prefix auto-erkennen
parse POST WPParser ausführen → content.json + DB-Insert
build POST StaticBuilder ausführen → public/ befüllen
zip POST ZIP erzeugen → Browser-Download
ftp POST FTP-Upload auf Kunden-Server
update_config POST Brand- und Deploy-Felder speichern

Wichtig — Zeile 11: Slug-Sanitierung vor jeder Aktion:

$slug = preg_replace('/[^a-z0-9\-]/', '', strtolower($_GET['slug'] ?? ''));

Entfernt alle Zeichen außer a-z, 0-9 und - — verhindert Path-Traversal-Angriffe
(../../etc/passwd wird zu leerem String).

Zeile 43: CSRF-Prüfung bei ALLEN POST-Requests, bevor irgendetwas passiert.

Zeile 125 (FTP-Action): FTP-Passwort wird absichtlich nicht in der DB gespeichert:

$cfg['deploy']['ftp'] = array_merge(..., ['host'=>..., 'user'=>..., 'path'=>...]);
// 'pass' fehlt bewusst — nur für diesen Request verwendet

Parse-Action (Zeilen 67–70): Nach dem Parsen werden alte Posts gelöscht und neu
eingefügt. Das stellt sicher, dass die DB immer den Stand des aktuellen Dumps widerspiegelt:

$pdo->prepare("DELETE FROM wpt_posts WHERE tenant_slug=?")->execute([$slug]);
// dann INSERT für jeden geparsten Post

Build-Action (Zeile 79–98): Build liest bevorzugt aus der DB (wpt_posts), nicht
aus content.json — damit manuelle Edits im Post-Editor in den Build einfließen. Nur wenn
die DB leer ist, wird content.json als Fallback verwendet.

posts.php — Post-Editor

Zeigt alle Posts eines Mandanten als Liste. Bei ?action=edit&id=X öffnet sich der
Editor für einen einzelnen Post.

Zeile 19 (save-Action): Kategorien kommen als Komma-String aus dem Formular und
werden zu einem sauberen Array verarbeitet:

$cats = array_values(array_filter(array_map('trim', explode(',', $_POST['categories']))));
// "WordPress, PHP , " → ["WordPress", "PHP"]

Gespeichert wird als JSON-String in der DB (["WordPress","PHP"]).


5. PHP-Klassen

classes/Db.php — Datenbank-Singleton

private static ?PDO $pdo = null;   // Zeile 3: null bis erste Verwendung

public static function pdo(): PDO {
    if (!self::$pdo) {             // Zeile 6: Verbindung nur einmal aufbauen
        self::$pdo = new PDO(..., [
            PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,  // DB-Fehler → Exception
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,        // immer assoziative Arrays
        ]);
    }
    return self::$pdo;
}

Das Singleton-Muster stellt sicher: egal wie viele Klassen Db::pdo() aufrufen —
es gibt nur eine MySQL-Verbindung pro HTTP-Request.


classes/Auth.php — Session & Login

// start() — Zeile 3: Session nur einmal starten, egal wie oft aufgerufen
if (session_status() === PHP_SESSION_NONE) { ... }

// check() — Zeile 10: Gate für alle geschützten Seiten
if (empty($_SESSION['wpt_auth'])) { header('Location: /login.php'); exit; }

// login() — Zeile 16: bcrypt-Vergleich
$row = Db::pdo()->prepare('SELECT id,pass_hash FROM wpt_users WHERE username=? LIMIT 1');
// password_verify($pass, $row['pass_hash']) — bcrypt-Vergleich (timing-safe by design)
// Bei Erfolg: drei Session-Variablen setzen: wpt_auth, wpt_user, wpt_uid

password_verify() ist von Natur aus timing-safe — bcrypt vergleicht immer den
vollständigen Hash, unabhängig davon, wo ein Unterschied auftritt.


classes/Csrf.php — CSRF-Schutz

Wie CSRF-Angriffe funktionieren: Eine fremde Website lässt den Browser des Opfers
einen POST-Request an unsere App schicken (z.B. über ein verstecktes Formular). Der
Browser schickt dabei automatisch die Session-Cookies mit — die App erkennt den User
als eingeloggt und führt die Aktion aus.

Gegenmaßnahme: Jedes Formular enthält ein geheimes Token, das nur unsere eigene
Seite kennt. Die fremde Website kann dieses Token nicht kennen.

// token() — Zeile 3: 32 Byte Zufall → 64-stelliger Hex-String, einmalig pro Session
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));

// field() — Zeile 10: als Hidden-Input in jedes Formular eingebettet
'<input type="hidden" name="csrf_token" value="...">'

// verify() — Zeile 14: timing-sicherer Vergleich
if (!hash_equals(self::token(), $submitted)) { http_response_code(403); exit; }

hash_equals() verhindert Timing-Attacks: ein normaler String-Vergleich (===)
bricht ab beim ersten falschen Zeichen und gibt damit über die Laufzeit einen Hinweis,
wie viele Zeichen schon korrekt waren. hash_equals() vergleicht immer die volle Länge.


classes/WPParser.php — SQL-Dump-Parser

Das technisch anspruchsvollste Modul. Liest WordPress-SQL-Dumps als Text
ohne MySQL-Verbindung.

Strategie: Sucht INSERT INTO \wp_posts`` u.a. im Rohtext und parst die
Werte-Tupel Zeichen für Zeichen.

parseTable() — Zeilen 15–49

Scanner-Zustandsmaschine mit drei Flags:

Flag Bedeutung
$depth Klammer-Tiefe — bei 0 ist ein Tupel komplett
$inStr Wir befinden uns innerhalb eines '...'-String-Literals
$escape Das nächste Zeichen ist escaped (\')
INSERT INTO `wp_posts` VALUES (1,'Hallo Welt','...'),(2,'Zweiter',...);
                               ^depth=1               ^depth=1
                                                      ^depth=0 → Tupel komplett

parseRow() — Zeilen 65–81

Parst ein einzelnes Tupel in ein PHP-Array. Behandelt:
- MySQL-Escape-Sequenzen: \n → Newline, \r → CR, \t → Tab
- NULL (ohne Anführungszeichen) → PHP null
- Doppelte Anführungszeichen ('' innerhalb Strings) → einzelnes '

addPost() — Zeile 83

Filtert direkt beim Einlesen:
- $c[7] !== 'publish' → Entwürfe und Papierkorb werden übersprungen
- $c[20] muss 'post' oder 'page' sein → Custom Post Types werden ignoriert

Spalten-Index entspricht der WordPress-Tabellenstruktur:
id(0), date(2), content(4), title(5), excerpt(6), status(7), slug(11), type(20)

assemble() — Zeilen 105–123

Verknüpft Posts mit Kategorien über einen 3-Tabellen-Join (in PHP, nicht SQL):

wpt_term_relationships: post_id → term_taxonomy_id
wpt_term_taxonomy:      term_taxonomy_id → term_id + taxonomy ('category'/'tag')
wpt_terms:              term_id → name

clean() — Zeilen 126–133

Bereinigt WordPress-spezifisches Markup aus dem HTML-Content:

Regex Entfernt
<!--\s*\/?wp:[^>]*--> Gutenberg-Block-Kommentare
\[\/?\s*[a-z_][^\]]*\] Shortcodes wie [gallery], [contact-form-7 ...]
style=(?:"[^"]*"\|'[^']*') Alle style="..."-Attribute
<(figure\|picture\|video\|audio\|iframe)[\s>].*?<\/\1> Medien-Container
<img[^>]*> Bilder (keine lokalen Assets auf Ziel-Server)
<p>\s*<\/p> Leere Paragraphen

detectPrefix() — Zeilen 136–148

Zwei Erkennungs-Strategien hintereinander:
1. Erste 50 Zeilen: sucht # Table prefix: wp_ (UpdraftPlus-Export-Header)
2. Erste 10.000 Zeilen: sucht CREATE TABLE \wp_posts`→ Prefix ist alles vorposts`


classes/TenantManager.php — Mandanten-CRUD

// all() / get() — liest aus wpt_tenants
// decode() — Zeile 62: brand und deploy sind als JSON-Text gespeichert → Array
$r['brand']  = json_decode($r['brand']  ?? '{}', true) ?: [];
$r['deploy'] = json_decode($r['deploy'] ?? '{}', true) ?: [];

// save() — Zeilen 25–36: UPSERT-Logik
if (self::get($slug)) {
    // UPDATE — Mandant existiert bereits
} else {
    // INSERT — neuer Mandant
}
mkdir($dir, 0755, true);  // Zeile 36: tenants/{slug}/ anlegen falls nicht vorhanden

// status() — Zeilen 39–56: liefert vier boolesche Flags für Dashboard-Badges
'has_sql'     => file_exists($dir . '/source.sql'),
'has_content' => $cnt > 0 || file_exists($dir . '/content.json'),
'has_build'   => is_dir($dir . '/public') && (bool)glob($dir . '/public/*.html'),
'post_count'  => $cnt,   // bevorzugt aus DB, Fallback content.json

classes/StaticBuilder.php — HTML-Generator

// build() — Zeile 13: Einstiegspunkt
$themeDir = '.../templates/themes/' . $theme;
if ($theme && is_dir($themeDir)) {
    return $this->buildTheme($themeDir);  // Theme-Modus: PHP-Templates
}
// sonst: Standard-Modus mit inline styles

// renderTheme() — Zeile 77: PHP-Output-Buffer-Trick
extract($vars);   // $cfg, $posts, $pages, $articles, $navy, $signal, ... als Variablen
ob_start();
include $tplFile; // Template wird ausgeführt, Output geht in Buffer
return ob_get_clean();

// themeVars() — Zeile 84: trennt Posts nach Typ
'pages'    => array_filter($posts, fn($p) => $p['type'] === 'page'),
'articles' => array_filter($posts, fn($p) => $p['type'] === 'post'),

// css() — Zeile 111: app.css inline einbetten (kein separater Request nötig)
return '<style>' . file_get_contents('.../assets/css/app.css') . '</style>';

Generierte Dateien pro Mandant:
- public/index.html — Übersichtsseite mit allen Posts als Kacheln
- public/{slug}.html — eine Seite pro Post/Page
- public/feed.json — maschinenlesbare Inhaltsübersicht
- public/.htaccessDirectoryIndex index.html + Options -Indexes


classes/Deployer.php — Export & Upload

// zip() — Zeile 3: rekursiv alle Dateien aus public/ in ZIP packen
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, SKIP_DOTS));
foreach ($it as $f) {
    $zip->addFile($f->getPathname(), str_replace($dir.'/', '', $f->getPathname()));
    // relativer Pfad im ZIP: "index.html", nicht "/home/.../public/index.html"
}

// ftp() — Zeile 18: erst SSL-FTP versuchen, dann plain FTP als Fallback
$conn = @ftp_ssl_connect(...) ?: @ftp_connect(...);
ftp_pasv($conn, true);  // Passive Mode: Firewall-kompatibel

// ftpMkdir() — Zeile 40: Verzeichnisse rekursiv anlegen
// Strategie: chdir zum Pfad versuchen; schlägt fehl → mkdir ausführen
foreach (explode('/', ltrim($path,'/')) as $p) {
    if (!@ftp_chdir($conn, $cur)) @ftp_mkdir($conn, $cur);
}

Das @-Prefix vor FTP-Funktionen unterdrückt PHP-Warnungen — Fehler werden über den
Rückgabewert false behandelt, nicht über PHP-Fehlermeldungen.


6. Konfiguration & Datenbank

config.php

ini_set('display_errors', 0);   // Zeile 2: keine Fehlermeldungen im Browser (Produktion)
error_reporting(0);              // Zeile 3: Fehler still loggen (Apache error.log)

define('TENANT_DIR', __DIR__ . '/tenants');  // absoluter Pfad, unabhängig von cwd
define('MAX_UPLOAD_MB', 64);

// Zeile 13–17: Graceful Degradation
if (file_exists(__DIR__ . '/config.local.php')) {
    require_once __DIR__ . '/config.local.php';
} else {
    define('DB_HOST', ''); // ... leere Konstanten statt Fatal Error
}
// Zeile 19: Db.php ist die einzige Klasse die config.php automatisch lädt
require_once __DIR__ . '/classes/Db.php';

config.local.php

Enthält die DB-Zugangsdaten. Wird von .htaccess per Browser gesperrt und von
.gitignore aus Git ausgeschlossen. Muss manuell per FTP auf den Server hochgeladen
werden:

<?php
define('DB_HOST', 'localhost');
define('DB_NAME', 'dein_db_name');
define('DB_USER', 'dein_db_user');
define('DB_PASS', 'dein_db_passwort');

init.sql — Datenbankschema

Drei Tabellen, alle InnoDB, utf8mb4:

wpt_users — App-Benutzer

Spalte Typ Beschreibung
id INT UNSIGNED AUTO_INCREMENT PK
username VARCHAR(64) UNIQUE Login-Name
pass_hash VARCHAR(255) bcrypt-Hash (cost 12)
created_at DATETIME Angelegt am
last_login DATETIME Letzter Login (wird bei jedem Login aktualisiert)

Standardpasswort nach init.sql: admin / passwordsofort ändern!

wpt_tenants — Mandanten-Konfiguration

Spalte Typ Beschreibung
slug VARCHAR(64) UNIQUE URL-sicherer Kurzname (z.B. merkur)
name VARCHAR(128) Anzeigename
source_url VARCHAR(255) Original-WordPress-URL
db_prefix VARCHAR(32) WP-DB-Prefix (Standard: wp_)
brand TEXT JSON: Farben, Logo, Kontakt, Theme
deploy TEXT JSON: FTP-Host, User, Pfad (kein Passwort!)
status VARCHAR(32) pending / parsed / built / deployed
last_parsed/built/deployed DATETIME Zeitstempel letzter Pipeline-Schritt

brand und deploy sind als JSON-Text gespeichert (nicht normalisiert), weil die
Felder sich häufig ändern und eine separate Tabelle keinen Mehrwert bringen würde.

wpt_posts — Bearbeitbare Post-Inhalte

Spalte Typ Beschreibung
tenant_slug VARCHAR(64) INDEX Fremdschlüssel zu wpt_tenants.slug
wp_id INT UNSIGNED Original WordPress Post-ID
title / content / excerpt VARCHAR / LONGTEXT / TEXT Inhalte
slug VARCHAR(255) URL-Slug für die HTML-Datei
type VARCHAR(32) post oder page
categories TEXT JSON-Array: ["WordPress","PHP"]
updated_at DATETIME Auto-Update bei jeder Änderung

7. CSS & Assets

Zwei CSS-Dateien, zwei Verwendungszwecke

Datei Eingebunden in Inhalt
assets/css/app.css Dashboard, tenant.php, posts.php App-UI: Tabellen, Formulare, Buttons
assets/css/landing.css index.php, service.php, impressum.php, datenschutz.php Landing Page: Hero, Cards, Pricing

Beide Dateien sind minifizierter, kompilierter Tailwind-Output — kein CDN, kein
JavaScript-Build-Tool zur Laufzeit.

Tailwind-Kompilierung

Die tailwind.config.js liegt außerhalb des Projekts, in ~/Retro_PWD_Reset/,
da sie projektübergreifend verwendet wird.

landing.css neu kompilieren (nach Änderungen an öffentlichen Seiten):

npx tailwindcss \
  -c ~/Retro_PWD_Reset/tailwind.config.js \
  -i assets/css/landing.css \
  -o assets/css/landing.css \
  --minify \
  --content "./index.php,./service.php,./impressum.php,./datenschutz.php"

Wichtig: Tailwind's JIT-Compiler scannt die content-Dateien nach verwendeten
CSS-Klassen. Klassen die in keiner der aufgelisteten Dateien vorkommen, werden aus
dem Output entfernt (PurgeCSS). Neue Utility-Klassen müssen daher in mindestens einer
der content-Dateien stehen, bevor sie im Browser funktionieren.

Bekannte Einschränkung: Responsive-Präfixe wie sm:grid-cols-2 werden zu
md:grid-cols-2 verwendet (768 px Breakpoint), weil sm: (640 px) auf typischen
Tablet-Displays zu früh greift und das Layout bricht.


8. DevOps & Deployment

Server-Setup (ALL-INKL)

Apache-Webroot: /home/branko/www.wptransformer.org/

Das Arbeitsverzeichnis ist der Webroot. Änderungen an Dateien sind sofort live
kein Deployment-Schritt, kein Restart nötig. Apache liefert PHP-Dateien direkt aus.

PHP-Limits gesetzt via .htaccess:

php_value upload_max_filesize 64M
php_value post_max_size       68M   # etwas mehr als upload_max für Multipart-Overhead
php_value memory_limit        256M
php_value max_execution_time  120   # für große SQL-Dumps beim Parsen

Git-Remote

origin: https://git.crumbforest.org/branko/wptransformeer-OZMv0.git

Self-hosted Gitea-Instanz. Git wird für Versionierung verwendet — kein Auto-Deploy.
Änderungen auf dem Server und im Repo müssen manuell synchron gehalten werden.

Deployment-Workflow (ALL-INKL)

ALL-INKL unterstützt kein Git-Pull. Neuer Code geht so auf den Server:

1. Lokal entwickeln und testen
2. git push origin main
3. Geänderte Dateien per FTP auf den Server hochladen
4. Browser neu laden (Ctrl+Shift+R für Hard-Refresh, CSS-Cache umgehen)

.gitignore-Logik

config.local.php          — DB-Zugangsdaten: nie im Repo
tenants/*/source.sql      — Kunden-Dumps: oft mehrere hundert MB
tenants/*/content.json    — aus SQL regenerierbar
tenants/*/public/         — aus content.json regenerierbar
tenants/*/*.zip           — temporäre ZIP-Exporte
uploads/                  — temporäre Upload-Dateien

Alles was aus dem Repo wiederhergestellt werden kann (source.sqlcontent.json
public/) wird nicht versioniert. Nur Code und Schema liegen im Repo.

Subdomains (KAS-Panel)

Jeder Mandant bekommt eine Subdomain, die auf sein public/-Verzeichnis zeigt:

ozm.wptransformer.org    → tenants/ozm/public/
merkur.wptransformer.org → tenants/merkur/public/

Einrichten im KAS-Panel: Domains → Subdomain hinzufügen → Zielverzeichnis setzen.


9. Sicherheitsarchitektur

.htaccess — Zugriffsschutz auf Dateisystem-Ebene

# Kein Directory Listing
Options -Indexes

# Rohdateien gesperrt
<FilesMatch "\.(sql|sh|md)$">
  Require all denied
</FilesMatch>

# DB-Zugangsdaten gesperrt
<FilesMatch "^config\.local\.php$">
  Require all denied
</FilesMatch>

# Tenant-Verzeichnis gesperrt — außer public/
RewriteRule ^tenants/(?!([^/]+/public/)) - [F,L]

Die letzte Regel verwendet einen negativen Lookahead ((?!...)):
tenants/ wird geblockt, AUSSER wenn danach {irgendwas}/public/ folgt.
D.h. tenants/ozm/public/index.html ist erreichbar, tenants/ozm/source.sql nicht.

Passwort-Hashing

bcrypt mit Cost-Factor 12. password_hash($pw, PASSWORD_BCRYPT) erzeugt einen
60-stelligen Hash inkl. Salt. password_verify() vergleicht timing-safe.

Standard-Hash in init.sql entspricht password — muss nach Erstinstallation
sofort geändert werden:

UPDATE wpt_users SET pass_hash=? WHERE username='admin';
-- Hash generieren: php -r "echo password_hash('neues_pw', PASSWORD_BCRYPT);"

CSRF-Schutz

Alle POST-Formulare in der App (tenant.php, posts.php, logout.php) sind mit
Csrf::field() / Csrf::verify() geschützt. Öffentliche Seiten haben keine Formulare.

Input-Validierung

  • Slug: preg_replace('/[^a-z0-9\-]/', '', ...) — whitelist-basiert
  • Post-Typ: in_array($type, ['post','page']) — nur erlaubte Werte
  • Upload: Extension-Check (strtolower(pathinfo(..., PATHINFO_EXTENSION)) !== 'sql')
  • Kein eval(), kein system() — keine Shell-Ausführung im gesamten Codebase

Fehler-Handling

display_errors = 0 in config.php — PHP-Fehler gehen ins Apache-Errorlog, nicht
in den Browser. Das verhindert Information Disclosure (Stack Traces, Pfade, DB-Namen).


WP Transformer v1.0.0 · Mai 2026 · git.crumbforest.org/branko/wptransformeer-OZMv0