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/.htaccess — DirectoryIndex 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 / password — sofort ä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.sql → content.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(), keinsystem()— 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