diff --git a/partials/landingpages/index.php b/partials/landingpages/index.php
index 13313b7..0f119ae 100755
--- a/partials/landingpages/index.php
+++ b/partials/landingpages/index.php
@@ -50,7 +50,12 @@ $modules = array_values(array_filter(
Module mit Login-Pflicht erscheinen erst nach passender Anmeldung.
- Module verwalten
+
diff --git a/public/index.php b/public/index.php
index 33715c0..21983c7 100755
--- a/public/index.php
+++ b/public/index.php
@@ -38,7 +38,7 @@ $publicPaths = [
'auth/me',
'module/pi_control/terminal_info',
];
-$requiresGlobalAuth = in_array($uriPath, ['settings', 'users', 'modules', 'modules/install', 'debug'], true)
+$requiresGlobalAuth = in_array($uriPath, ['settings', 'users', 'modules', 'modules/install', 'debug', 'exports/database.sql'], true)
|| str_starts_with($uriPath, 'modules/setup/');
if (defined('APP_AUTH_ENABLED') && APP_AUTH_ENABLED && $requiresGlobalAuth && !in_array($uriPath, $publicPaths, true)) {
$user = auth_user();
@@ -76,6 +76,23 @@ if ($uriPath === 'auth/me') {
exit;
}
+if ($uriPath === 'exports/database.sql') {
+ require_admin();
+ $pdo = app()->basePdo() ?: app()->pdo();
+ if (!$pdo instanceof PDO) {
+ http_response_code(500);
+ exit('Keine Datenbankverbindung fuer den Export verfuegbar.');
+ }
+
+ $filename = 'nexus-export-' . gmdate('Ymd-His') . '.sql';
+ $sql = (new \App\SqlDataExporter())->export($pdo, 'nexus');
+ header('Content-Type: application/sql; charset=utf-8');
+ header('Content-Disposition: attachment; filename="' . $filename . '"');
+ header('X-Content-Type-Options: nosniff');
+ echo $sql;
+ exit;
+}
+
if (preg_match('~^api/module-auth/([a-zA-Z0-9_-]+)$~', $uriPath, $moduleAuthMatches)) {
$moduleName = $moduleAuthMatches[1];
$moduleMeta = app()->modules()->get($moduleName);
diff --git a/src/App/SqlDataExporter.php b/src/App/SqlDataExporter.php
new file mode 100644
index 0000000..909729c
--- /dev/null
+++ b/src/App/SqlDataExporter.php
@@ -0,0 +1,285 @@
+getAttribute(PDO::ATTR_DRIVER_NAME);
+ $tables = $this->orderedTables($pdo, $driver);
+ $lines = [
+ '-- Nexus SQL data export',
+ '-- Generated at: ' . gmdate('c'),
+ '-- Driver: ' . $driver,
+ '-- Label: ' . $label,
+ '-- Restore target: empty database with existing schema/migrations.',
+ '',
+ 'BEGIN;',
+ '',
+ ];
+
+ foreach ($tables as $table) {
+ $columns = $this->columns($pdo, $driver, $table);
+ if ($columns === []) {
+ continue;
+ }
+
+ $lines[] = '-- Table: ' . $table;
+ $quotedTable = $this->quoteIdentifier($table, $driver);
+ $quotedColumns = array_map(fn(string $column): string => $this->quoteIdentifier($column, $driver), $columns);
+ $select = 'SELECT ' . implode(', ', $quotedColumns) . ' FROM ' . $quotedTable;
+
+ $count = 0;
+ $stmt = $pdo->query($select);
+ while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
+ $values = [];
+ foreach ($columns as $column) {
+ $values[] = $this->quoteValue($pdo, $row[$column] ?? null);
+ }
+
+ $lines[] = 'INSERT INTO ' . $quotedTable . ' (' . implode(', ', $quotedColumns) . ') VALUES (' . implode(', ', $values) . ');';
+ $count++;
+ }
+
+ $lines[] = '-- Rows: ' . $count;
+ foreach ($this->sequenceSetvalLines($pdo, $driver, $table, $columns) as $sequenceLine) {
+ $lines[] = $sequenceLine;
+ }
+ $lines[] = '';
+ }
+
+ $lines[] = 'COMMIT;';
+ $lines[] = '';
+
+ return implode("\n", $lines);
+ }
+
+ private function orderedTables(PDO $pdo, string $driver): array
+ {
+ $tables = $this->tables($pdo, $driver);
+ $dependencies = $this->tableDependencies($pdo, $driver, $tables);
+ $ordered = [];
+ $remaining = array_fill_keys($tables, true);
+
+ while ($remaining !== []) {
+ $progress = false;
+ foreach (array_keys($remaining) as $table) {
+ $parents = array_filter(
+ $dependencies[$table] ?? [],
+ static fn(string $parent): bool => isset($remaining[$parent])
+ );
+ if ($parents !== []) {
+ continue;
+ }
+
+ $ordered[] = $table;
+ unset($remaining[$table]);
+ $progress = true;
+ }
+
+ if (!$progress) {
+ foreach (array_keys($remaining) as $table) {
+ $ordered[] = $table;
+ unset($remaining[$table]);
+ }
+ }
+ }
+
+ return $ordered;
+ }
+
+ private function tables(PDO $pdo, string $driver): array
+ {
+ if ($driver === 'pgsql') {
+ $stmt = $pdo->query(
+ "SELECT table_name
+ FROM information_schema.tables
+ WHERE table_schema = current_schema()
+ AND table_type = 'BASE TABLE'
+ ORDER BY table_name"
+ );
+ return array_values(array_map('strval', $stmt->fetchAll(PDO::FETCH_COLUMN)));
+ }
+
+ if ($driver === 'mysql') {
+ $stmt = $pdo->query(
+ "SELECT table_name
+ FROM information_schema.tables
+ WHERE table_schema = DATABASE()
+ AND table_type = 'BASE TABLE'
+ ORDER BY table_name"
+ );
+ return array_values(array_map('strval', $stmt->fetchAll(PDO::FETCH_COLUMN)));
+ }
+
+ if ($driver === 'sqlite') {
+ $stmt = $pdo->query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name");
+ return array_values(array_map('strval', $stmt->fetchAll(PDO::FETCH_COLUMN)));
+ }
+
+ throw new \RuntimeException('SQL export supports pgsql, mysql and sqlite only.');
+ }
+
+ private function columns(PDO $pdo, string $driver, string $table): array
+ {
+ if ($driver === 'pgsql') {
+ $stmt = $pdo->prepare(
+ "SELECT column_name
+ FROM information_schema.columns
+ WHERE table_schema = current_schema()
+ AND table_name = :table
+ ORDER BY ordinal_position"
+ );
+ $stmt->execute(['table' => $table]);
+ return array_values(array_map('strval', $stmt->fetchAll(PDO::FETCH_COLUMN)));
+ }
+
+ if ($driver === 'mysql') {
+ $stmt = $pdo->prepare(
+ "SELECT column_name
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = :table
+ ORDER BY ordinal_position"
+ );
+ $stmt->execute(['table' => $table]);
+ return array_values(array_map('strval', $stmt->fetchAll(PDO::FETCH_COLUMN)));
+ }
+
+ if ($driver === 'sqlite') {
+ $stmt = $pdo->query('PRAGMA table_info(' . $this->quoteIdentifier($table, $driver) . ')');
+ $columns = [];
+ foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
+ $columns[] = (string)$row['name'];
+ }
+ return $columns;
+ }
+
+ return [];
+ }
+
+ private function tableDependencies(PDO $pdo, string $driver, array $tables): array
+ {
+ $known = array_fill_keys($tables, true);
+ $dependencies = [];
+ foreach ($tables as $table) {
+ $dependencies[$table] = [];
+ }
+
+ if ($driver === 'pgsql') {
+ $stmt = $pdo->query(
+ "SELECT tc.table_name AS child_table, ccu.table_name AS parent_table
+ FROM information_schema.table_constraints tc
+ JOIN information_schema.constraint_column_usage ccu
+ ON ccu.constraint_name = tc.constraint_name
+ AND ccu.constraint_schema = tc.constraint_schema
+ WHERE tc.constraint_type = 'FOREIGN KEY'
+ AND tc.table_schema = current_schema()
+ AND ccu.table_schema = current_schema()"
+ );
+ foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
+ $child = (string)$row['child_table'];
+ $parent = (string)$row['parent_table'];
+ if (isset($known[$child], $known[$parent]) && $child !== $parent) {
+ $dependencies[$child][] = $parent;
+ }
+ }
+ }
+
+ if ($driver === 'mysql') {
+ $stmt = $pdo->query(
+ "SELECT table_name AS child_table, referenced_table_name AS parent_table
+ FROM information_schema.key_column_usage
+ WHERE table_schema = DATABASE()
+ AND referenced_table_name IS NOT NULL"
+ );
+ foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
+ $child = (string)$row['child_table'];
+ $parent = (string)$row['parent_table'];
+ if (isset($known[$child], $known[$parent]) && $child !== $parent) {
+ $dependencies[$child][] = $parent;
+ }
+ }
+ }
+
+ if ($driver === 'sqlite') {
+ foreach ($tables as $table) {
+ $stmt = $pdo->query('PRAGMA foreign_key_list(' . $this->quoteIdentifier($table, $driver) . ')');
+ foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
+ $parent = (string)($row['table'] ?? '');
+ if (isset($known[$parent]) && $parent !== $table) {
+ $dependencies[$table][] = $parent;
+ }
+ }
+ }
+ }
+
+ foreach ($dependencies as $table => $parents) {
+ $dependencies[$table] = array_values(array_unique($parents));
+ }
+
+ return $dependencies;
+ }
+
+ private function quoteIdentifier(string $identifier, string $driver): string
+ {
+ $parts = explode('.', $identifier);
+ $quote = $driver === 'mysql' ? '`' : '"';
+ return implode('.', array_map(
+ static fn(string $part): string => $quote . str_replace($quote, $quote . $quote, $part) . $quote,
+ $parts
+ ));
+ }
+
+ private function sequenceSetvalLines(PDO $pdo, string $driver, string $table, array $columns): array
+ {
+ if ($driver !== 'pgsql') {
+ return [];
+ }
+
+ $lines = [];
+ foreach ($columns as $column) {
+ $sequenceStmt = $pdo->prepare('SELECT pg_get_serial_sequence(:table, :column)');
+ $sequenceStmt->execute(['table' => $table, 'column' => $column]);
+ $sequence = (string)($sequenceStmt->fetchColumn() ?: '');
+ if ($sequence === '') {
+ continue;
+ }
+
+ $maxStmt = $pdo->query(
+ 'SELECT MAX(' . $this->quoteIdentifier($column, $driver) . ') FROM ' . $this->quoteIdentifier($table, $driver)
+ );
+ $max = $maxStmt->fetchColumn();
+ $maxValue = is_numeric($max) ? (int)$max : 0;
+ if ($maxValue > 0) {
+ $lines[] = 'SELECT setval(' . $this->quoteValue($pdo, $sequence) . ', ' . $maxValue . ', true);';
+ } else {
+ $lines[] = 'SELECT setval(' . $this->quoteValue($pdo, $sequence) . ', 1, false);';
+ }
+ }
+
+ return $lines;
+ }
+
+ private function quoteValue(PDO $pdo, mixed $value): string
+ {
+ if ($value === null) {
+ return 'NULL';
+ }
+ if (is_bool($value)) {
+ return $value ? 'TRUE' : 'FALSE';
+ }
+ if (is_int($value) || is_float($value)) {
+ return is_finite((float)$value) ? (string)$value : 'NULL';
+ }
+ if (is_resource($value)) {
+ $value = stream_get_contents($value);
+ }
+
+ return $pdo->quote((string)$value);
+ }
+}