Files
2026-09-16 10:59:24 +02:00

298 lines
11 KiB
PHP

<?php
/**
* ============================================================
* db-bridge.php - HTTP-Brücke für JSON Studio & MariaDB Workbench
* ============================================================
*
* Ein Browser kann sich nicht direkt mit MariaDB/MySQL verbinden.
* Dieses Skript nimmt die Anfragen der Weboberfläche entgegen und
* reicht sie an die Datenbank weiter.
*
* ------------------------------------------------------------
* VORAUSSETZUNG
* ------------------------------------------------------------
* PHP 8.0 oder neuer (verwendet str_contains und Arrow-Funktionen)
* sowie die PDO-MySQL-Erweiterung.
*
* ------------------------------------------------------------
* EINRICHTUNG
* ------------------------------------------------------------
* 1. Unten im Abschnitt KONFIGURATION die Zugangsdaten eintragen.
* 2. Einen eigenen API-Schlüssel setzen (beliebige lange Zeichenfolge).
* 3. ALLOWED_ORIGIN auf die Adresse setzen, unter der die
* HTML-Datei ausgeliefert wird.
* 4. Datei auf den Server legen, Adresse im Profil der
* Weboberfläche eintragen.
*
* ------------------------------------------------------------
* SICHERHEIT - bitte lesen
* ------------------------------------------------------------
* - Lege einen eigenen Datenbankbenutzer an, der nur auf die
* benötigte Datenbank zugreifen darf. Niemals 'root' verwenden.
* - Betreibe das Skript ausschließlich über HTTPS, sonst gehen
* API-Schlüssel und Daten im Klartext über die Leitung.
* - Der API-Schlüssel liegt im Browser-Speicher des Nutzers.
* Behandle ihn wie ein Passwort und tausche ihn aus, wenn ein
* Rechner abhandenkommt.
* - Schreibende Aktionen sind über WRITES_ALLOWED abschaltbar.
* Wenn du nur lesen willst, setze es auf false.
* - 'query' erlaubt bewusst nur SELECT-Abfragen.
*
* ------------------------------------------------------------
* PROTOKOLL
* ------------------------------------------------------------
* POST application/json, Header X-Api-Key
*
* {"action":"tables"}
* -> {"tables":["kunden","artikel"]}
*
* {"action":"columns","table":"kunden"}
* -> {"columns":[{"name":"id","type":"int(11)"}]}
*
* {"action":"select","table":"kunden","limit":500}
* -> {"rows":[{...}]}
*
* {"action":"query","sql":"SELECT ...","limit":500}
* -> {"rows":[{...}]}
*
* {"action":"insert","table":"kunden","rows":[{...}],
* "mode":"append|replace|upsert","createIfMissing":true,
* "createSql":"CREATE TABLE ..."}
* -> {"inserted":42}
*
* Fehler: {"error":"Beschreibung"}
* ============================================================
*/
/* ===================== KONFIGURATION ===================== */
const DB_HOST = '127.0.0.1';
const DB_PORT = 3306;
const DB_NAME = 'meine_datenbank'; // Vorgabe, per "database" im Request überschreibbar
const DB_USER = 'workbench';
const DB_PASS = 'HIER_PASSWORT_EINTRAGEN';
const API_KEY = 'HIER_EINEN_LANGEN_ZUFAELLIGEN_SCHLUESSEL_EINTRAGEN';
// Adresse, unter der die HTML-Datei läuft. '*' nur zum Ausprobieren verwenden.
const ALLOWED_ORIGIN = 'https://tools.example.de';
// Schreibende Aktionen (insert) erlauben?
const WRITES_ALLOWED = true;
// Nur diese Datenbanken dürfen angesprochen werden. Leer = nur DB_NAME.
const ALLOWED_DATABASES = [];
// Obergrenzen
const MAX_LIMIT = 10000;
const MAX_ROWS_PER_INSERT = 20000;
/* =================== AB HIER NICHTS MEHR ================= */
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: ' . ALLOWED_ORIGIN);
header('Access-Control-Allow-Headers: Content-Type, X-Api-Key');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('X-Content-Type-Options: nosniff');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
function fail(string $message, int $code = 400): void {
http_response_code($code);
echo json_encode(['error' => $message], JSON_UNESCAPED_UNICODE);
exit;
}
function ok(array $payload): void {
echo json_encode($payload, JSON_UNESCAPED_UNICODE);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
fail('Nur POST wird unterstützt.', 405);
}
// Schlüsselprüfung in konstanter Zeit
$sentKey = $_SERVER['HTTP_X_API_KEY'] ?? '';
if (!hash_equals(API_KEY, $sentKey)) {
fail('Ungültiger oder fehlender API-Schlüssel.', 401);
}
$raw = file_get_contents('php://input');
$req = json_decode($raw, true);
if (!is_array($req)) {
fail('Anfrage ist kein gültiges JSON.');
}
$action = $req['action'] ?? '';
// Zieldatenbank bestimmen
$database = trim((string)($req['database'] ?? '')) ?: DB_NAME;
$allowed = ALLOWED_DATABASES ?: [DB_NAME];
if (!in_array($database, $allowed, true)) {
fail('Diese Datenbank ist nicht freigegeben: ' . $database, 403);
}
try {
$pdo = new PDO(
sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', DB_HOST, DB_PORT, $database),
DB_USER,
DB_PASS,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
} catch (PDOException $e) {
fail('Verbindung fehlgeschlagen: ' . $e->getMessage(), 500);
}
/** Tabellen-/Spaltennamen dürfen nicht als Parameter gebunden werden - daher streng prüfen. */
function safeIdentifier(string $name): string {
if (!preg_match('/^[A-Za-z_][A-Za-z0-9_$]*$/', $name)) {
fail('Unzulässiger Bezeichner: ' . $name);
}
return '`' . $name . '`';
}
function clampLimit($value): int {
$limit = (int)($value ?? 500);
if ($limit < 1) $limit = 1;
if ($limit > MAX_LIMIT) $limit = MAX_LIMIT;
return $limit;
}
/** Werte für die Datenbank aufbereiten: Arrays/Objekte werden zu JSON-Text. */
function normalizeValue($value) {
if (is_array($value) || is_object($value)) {
return json_encode($value, JSON_UNESCAPED_UNICODE);
}
if (is_bool($value)) return $value ? 1 : 0;
return $value;
}
try {
switch ($action) {
case 'tables': {
$rows = $pdo->query('SHOW TABLES')->fetchAll(PDO::FETCH_NUM);
ok(['tables' => array_map(fn($r) => $r[0], $rows)]);
}
case 'columns': {
$table = safeIdentifier((string)($req['table'] ?? ''));
$rows = $pdo->query("SHOW COLUMNS FROM $table")->fetchAll();
ok(['columns' => array_map(
fn($r) => ['name' => $r['Field'], 'type' => $r['Type'], 'null' => $r['Null'], 'key' => $r['Key']],
$rows
)]);
}
case 'select': {
$table = safeIdentifier((string)($req['table'] ?? ''));
$limit = clampLimit($req['limit'] ?? null);
$stmt = $pdo->query("SELECT * FROM $table LIMIT $limit");
ok(['rows' => $stmt->fetchAll()]);
}
case 'query': {
$sql = trim((string)($req['sql'] ?? ''));
if ($sql === '') fail('Keine Abfrage übergeben.');
// Bewusst nur lesende Abfragen zulassen
if (!preg_match('/^\s*(SELECT|SHOW|DESCRIBE|EXPLAIN)\b/i', $sql)) {
fail('Über "query" sind nur lesende Abfragen erlaubt (SELECT, SHOW, DESCRIBE, EXPLAIN).');
}
if (str_contains($sql, ';') && !preg_match('/;\s*$/', $sql)) {
fail('Mehrere Anweisungen in einer Abfrage sind nicht erlaubt.');
}
$sql = rtrim(rtrim($sql), ';');
$limit = clampLimit($req['limit'] ?? null);
if (!preg_match('/\bLIMIT\s+\d+/i', $sql)) {
$sql .= " LIMIT $limit";
}
ok(['rows' => $pdo->query($sql)->fetchAll()]);
}
case 'insert': {
if (!WRITES_ALLOWED) fail('Schreibende Zugriffe sind auf diesem Server abgeschaltet.', 403);
$tableName = (string)($req['table'] ?? '');
$table = safeIdentifier($tableName);
$rows = $req['rows'] ?? [];
$mode = $req['mode'] ?? 'append';
if (!is_array($rows) || count($rows) === 0) fail('Keine Zeilen übergeben.');
if (count($rows) > MAX_ROWS_PER_INSERT) {
fail('Zu viele Zeilen auf einmal (Grenze: ' . MAX_ROWS_PER_INSERT . ').');
}
if (!in_array($mode, ['append', 'replace', 'upsert'], true)) fail('Unbekannter Modus: ' . $mode);
// Tabelle bei Bedarf anlegen
$exists = $pdo->prepare('SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = ? AND table_name = ?');
$exists->execute([$database, $tableName]);
if ((int)$exists->fetchColumn() === 0) {
if (empty($req['createIfMissing'])) {
fail('Tabelle existiert nicht: ' . $tableName);
}
$createSql = (string)($req['createSql'] ?? '');
if (!preg_match('/^\s*CREATE\s+TABLE\b/i', $createSql)) {
fail('Kein gültiges CREATE TABLE übermittelt.');
}
$pdo->exec($createSql);
}
// Spalten aus der ersten Zeile ableiten und gegen die echte Tabelle prüfen
$firstRow = $rows[0];
if (!is_array($firstRow)) fail('Zeilen müssen Objekte mit Spaltennamen sein.');
$tableCols = array_column($pdo->query("SHOW COLUMNS FROM $table")->fetchAll(), 'Field');
$columns = array_values(array_filter(array_keys($firstRow), fn($c) => in_array($c, $tableCols, true)));
if (count($columns) === 0) fail('Keine der übergebenen Spalten existiert in der Tabelle.');
$quoted = array_map('safeIdentifier', $columns);
$placeholder = '(' . implode(', ', array_fill(0, count($columns), '?')) . ')';
$pdo->beginTransaction();
try {
if ($mode === 'replace') {
$pdo->exec("TRUNCATE TABLE $table");
}
$sql = "INSERT INTO $table (" . implode(', ', $quoted) . ") VALUES $placeholder";
if ($mode === 'upsert') {
$updates = array_map(fn($c) => "$c = VALUES($c)", $quoted);
$sql .= ' ON DUPLICATE KEY UPDATE ' . implode(', ', $updates);
}
$stmt = $pdo->prepare($sql);
$inserted = 0;
foreach ($rows as $row) {
if (!is_array($row)) continue;
$values = [];
foreach ($columns as $col) {
$values[] = normalizeValue($row[$col] ?? null);
}
$stmt->execute($values);
$inserted++;
}
$pdo->commit();
ok(['inserted' => $inserted, 'columns' => $columns, 'mode' => $mode]);
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
}
default:
fail('Unbekannte Aktion: ' . $action);
}
} catch (PDOException $e) {
fail('Datenbankfehler: ' . $e->getMessage(), 500);
} catch (Throwable $e) {
fail('Fehler: ' . $e->getMessage(), 500);
}