298 lines
11 KiB
PHP
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);
|
|
}
|