mysql.php 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. <?php
  2. declare(strict_types=1);
  3. // Optional MySQL/MariaDB dump for the backup archive.
  4. //
  5. // PDO only: no exec(), no mysqldump binary, because shared hosting frequently
  6. // blocks shell execution. The dump is streamed to a temp file, so table size is
  7. // bounded by disk rather than memory_limit.
  8. //
  9. // Active only when MANAGE_BACKUP_DATABASE is configured. Projects without a
  10. // database (like the PSA order system) leave it at null and never touch this.
  11. function manageDatabaseConfigured(): bool
  12. {
  13. $config = MANAGE_BACKUP_DATABASE;
  14. return is_array($config) && trim((string) ($config["dsn"] ?? "")) !== "";
  15. }
  16. function manageDatabaseConnect(): PDO
  17. {
  18. $config = MANAGE_BACKUP_DATABASE;
  19. if (!is_array($config)) {
  20. throw new RuntimeException("MANAGE_BACKUP_DATABASE ist nicht konfiguriert.");
  21. }
  22. $dsn = trim((string) ($config["dsn"] ?? ""));
  23. if ($dsn === "") {
  24. throw new RuntimeException("MANAGE_BACKUP_DATABASE benötigt einen DSN.");
  25. }
  26. if (!class_exists("PDO")) {
  27. throw new RuntimeException("Die PHP-PDO-Erweiterung ist nicht verfügbar.");
  28. }
  29. try {
  30. $pdo = new PDO(
  31. $dsn,
  32. (string) ($config["user"] ?? ""),
  33. (string) ($config["password"] ?? ""),
  34. [
  35. PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
  36. PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
  37. ],
  38. );
  39. } catch (PDOException $exception) {
  40. // The DSN may contain a host name but never a password, so it is safe
  41. // to keep out of the message entirely.
  42. throw new RuntimeException("Datenbankverbindung fehlgeschlagen: " . $exception->getMessage());
  43. }
  44. return $pdo;
  45. }
  46. function manageDatabaseName(PDO $pdo): string
  47. {
  48. $config = MANAGE_BACKUP_DATABASE;
  49. $name = is_array($config) ? trim((string) ($config["name"] ?? "")) : "";
  50. if ($name !== "") {
  51. return $name;
  52. }
  53. try {
  54. $value = $pdo->query("SELECT DATABASE()")->fetchColumn();
  55. if (is_string($value) && $value !== "") {
  56. return $value;
  57. }
  58. } catch (Throwable $exception) {
  59. // Fall through to the generic name below.
  60. }
  61. return "database";
  62. }
  63. function manageDatabaseQuoteIdentifier(string $identifier): string
  64. {
  65. return "`" . str_replace("`", "``", $identifier) . "`";
  66. }
  67. function manageDatabaseTables(PDO $pdo): array
  68. {
  69. $tables = [];
  70. foreach ($pdo->query("SHOW FULL TABLES") as $row) {
  71. $values = array_values($row);
  72. $name = (string) ($values[0] ?? "");
  73. $type = strtoupper((string) ($values[1] ?? "BASE TABLE"));
  74. if ($name === "") {
  75. continue;
  76. }
  77. $tables[] = ["name" => $name, "type" => $type];
  78. }
  79. usort($tables, static function (array $left, array $right): int {
  80. return strcmp($left["name"], $right["name"]);
  81. });
  82. return $tables;
  83. }
  84. // Formats one value for the INSERT statement. Binary content is written as a
  85. // hex literal so the dump stays valid ASCII and survives any transport.
  86. function manageDatabaseQuoteValue(PDO $pdo, $value): string
  87. {
  88. if ($value === null) {
  89. return "NULL";
  90. }
  91. if (is_int($value) || is_float($value)) {
  92. return (string) $value;
  93. }
  94. if (is_bool($value)) {
  95. return $value ? "1" : "0";
  96. }
  97. $value = (string) $value;
  98. if ($value !== "" && preg_match('//u', $value) !== 1) {
  99. return "0x" . bin2hex($value);
  100. }
  101. return $pdo->quote($value);
  102. }
  103. /**
  104. * Writes a SQL dump of the configured database to $targetFile.
  105. *
  106. * @return array{tables: int, rows: int, bytes: int, database: string}
  107. */
  108. function manageDatabaseDump(string $targetFile): array
  109. {
  110. $pdo = manageDatabaseConnect();
  111. $config = MANAGE_BACKUP_DATABASE;
  112. $skipDataTables = is_array($config) && is_array($config["skip_data_tables"] ?? null)
  113. ? array_map("strval", $config["skip_data_tables"])
  114. : [];
  115. manageEnsureDir(dirname($targetFile));
  116. $handle = fopen($targetFile, "wb");
  117. if ($handle === false) {
  118. throw new RuntimeException("SQL-Dump konnte nicht erstellt werden.");
  119. }
  120. $database = manageDatabaseName($pdo);
  121. $tableCount = 0;
  122. $rowCount = 0;
  123. try {
  124. fwrite($handle, "-- Manage client database dump\n");
  125. fwrite($handle, "-- Database: " . $database . "\n");
  126. fwrite($handle, "-- Created: " . date(DATE_ATOM) . "\n\n");
  127. fwrite($handle, "SET NAMES utf8mb4;\n");
  128. fwrite($handle, "SET FOREIGN_KEY_CHECKS=0;\n\n");
  129. foreach (manageDatabaseTables($pdo) as $table) {
  130. $name = $table["name"];
  131. $quoted = manageDatabaseQuoteIdentifier($name);
  132. // Views must be recreated after the tables they read from, but a
  133. // single-pass dump with FOREIGN_KEY_CHECKS=0 is enough in practice
  134. // and keeps this readable.
  135. $createRow = $pdo->query("SHOW CREATE TABLE " . $quoted)->fetch();
  136. $create = "";
  137. foreach ((array) $createRow as $key => $value) {
  138. if (stripos((string) $key, "create") === 0) {
  139. $create = (string) $value;
  140. break;
  141. }
  142. }
  143. if ($create === "") {
  144. continue;
  145. }
  146. $tableCount++;
  147. fwrite($handle, "--\n-- Table: " . $name . "\n--\n");
  148. fwrite($handle, "DROP TABLE IF EXISTS " . $quoted . ";\n");
  149. fwrite($handle, "DROP VIEW IF EXISTS " . $quoted . ";\n");
  150. fwrite($handle, $create . ";\n\n");
  151. if ($table["type"] === "VIEW" || in_array($name, $skipDataTables, true)) {
  152. continue;
  153. }
  154. // Chunked reads keep a large table from being buffered as a whole.
  155. $offset = 0;
  156. $chunkSize = 500;
  157. while (true) {
  158. $statement = $pdo->prepare(
  159. "SELECT * FROM " . $quoted . " LIMIT " . $chunkSize . " OFFSET " . $offset,
  160. );
  161. $statement->execute();
  162. $rows = $statement->fetchAll();
  163. if ($rows === []) {
  164. break;
  165. }
  166. foreach ($rows as $row) {
  167. $columns = [];
  168. $values = [];
  169. foreach ($row as $column => $value) {
  170. $columns[] = manageDatabaseQuoteIdentifier((string) $column);
  171. $values[] = manageDatabaseQuoteValue($pdo, $value);
  172. }
  173. fwrite(
  174. $handle,
  175. "INSERT INTO " . $quoted . " (" . implode(", ", $columns) . ") VALUES (" .
  176. implode(", ", $values) . ");\n",
  177. );
  178. $rowCount++;
  179. }
  180. if (count($rows) < $chunkSize) {
  181. break;
  182. }
  183. $offset += $chunkSize;
  184. }
  185. fwrite($handle, "\n");
  186. }
  187. fwrite($handle, "SET FOREIGN_KEY_CHECKS=1;\n");
  188. } catch (Throwable $exception) {
  189. fclose($handle);
  190. @unlink($targetFile);
  191. throw new RuntimeException("Datenbank-Dump fehlgeschlagen: " . $exception->getMessage());
  192. }
  193. fclose($handle);
  194. @chmod($targetFile, 0660);
  195. return [
  196. "tables" => $tableCount,
  197. "rows" => $rowCount,
  198. "bytes" => (int) (filesize($targetFile) ?: 0),
  199. "database" => $database,
  200. ];
  201. }