backups.php

main 186 lines · 4.9 KB Raw
Alex Alex Commit Initial commit 01/10/2026 09:20
1<?php
2
3const ZNOTE_BACKUP_DIR = 'engine/backups';
4const ZNOTE_BACKUP_ROWS_PER_CHUNK = 500;
5
6function znote_backups_root(): string {
7 return dirname(__DIR__, 2) . '/' . ZNOTE_BACKUP_DIR;
8}
9
10function znote_backups_prepare_dir(): bool {
11 $dir = znote_backups_root();
12 if (!is_dir($dir) && !@mkdir($dir, 0750, true) && !is_dir($dir)) {
13 return false;
14 }
15
16 $deny = $dir . '/.htaccess';
17 if (!is_file($deny)) {
18 @file_put_contents($deny, "Require all denied\nDeny from all\n");
19 }
20
21 return is_writable($dir);
22}
23
24/** Existing backups, newest first. */
25function znote_backups_list(): array {
26 $dir = znote_backups_root();
27 if (!is_dir($dir)) {
28 return array();
29 }
30
31 $out = array();
32 foreach (glob($dir . '/*.sql.gz') ?: array() as $file) {
33 $out[] = array(
34 'name' => basename($file),
35 'size' => (int)filesize($file),
36 'time' => (int)filemtime($file),
37 );
38 }
39
40 usort($out, static fn(array $a, array $b): int => $b['time'] <=> $a['time']);
41
42 return $out;
43}
44
45function znote_backups_safe_name(string $name): string {
46 $name = basename($name);
47 return preg_match('/^znotex-backup-\d{8}-\d{6}\.sql\.gz$/', $name) ? $name : '';
48}
49
50function znote_backups_path(string $name): ?string {
51 $safe = znote_backups_safe_name($name);
52 if ($safe === '') {
53 return null;
54 }
55
56 $path = znote_backups_root() . '/' . $safe;
57 return is_file($path) ? $path : null;
58}
59
60function znote_backups_delete(string $name): bool {
61 $path = znote_backups_path($name);
62 return $path !== null && @unlink($path);
63}
64
65function znote_backups_prune(int $keep): int {
66 if ($keep <= 0) {
67 return 0;
68 }
69
70 $all = znote_backups_list();
71 $extra = array_slice($all, $keep);
72 $removed = 0;
73
74 foreach ($extra as $backup) {
75 if (znote_backups_delete($backup['name'])) {
76 $removed++;
77 }
78 }
79
80 return $removed;
81}
82
83function znote_backups_sql_value(mysqli $link, $value): string {
84 if ($value === null) {
85 return 'NULL';
86 }
87
88 return "'" . $link->real_escape_string((string)$value) . "'";
89}
90
91function znote_backups_write(mysqli $link, $handle): string {
92 fwrite($handle, "-- ZnoteX backup - " . gmdate('Y-m-d H:i:s') . " UTC\n");
93 fwrite($handle, "SET NAMES utf8mb4;\nSET FOREIGN_KEY_CHECKS=0;\n\n");
94
95 $tables = array();
96 $result = $link->query('SHOW TABLES;');
97 if ($result === false) {
98 return 'Could not list tables: ' . $link->error;
99 }
100 while ($row = $result->fetch_array(MYSQLI_NUM)) {
101 $tables[] = $row[0];
102 }
103
104 foreach ($tables as $table) {
105 $escapedTable = '`' . str_replace('`', '``', $table) . '`';
106
107 $createResult = $link->query('SHOW CREATE TABLE ' . $escapedTable . ';');
108 if ($createResult === false) {
109 return 'Could not read structure of ' . $table . ': ' . $link->error;
110 }
111 $createRow = $createResult->fetch_assoc();
112 $createSql = $createRow['Create Table'] ?? null;
113 if ($createSql === null) {
114 continue;
115 }
116
117 fwrite($handle, "\n-- ----------------------------\n-- Table: {$table}\n-- ----------------------------\n");
118 fwrite($handle, "DROP TABLE IF EXISTS {$escapedTable};\n{$createSql};\n\n");
119
120 $countResult = $link->query('SELECT COUNT(*) AS c FROM ' . $escapedTable . ';');
121 $total = $countResult !== false ? (int)($countResult->fetch_assoc()['c'] ?? 0) : 0;
122 if ($total === 0) {
123 continue;
124 }
125
126 for ($offset = 0; $offset < $total; $offset += ZNOTE_BACKUP_ROWS_PER_CHUNK) {
127 $dataResult = $link->query('SELECT * FROM ' . $escapedTable . ' LIMIT ' . ZNOTE_BACKUP_ROWS_PER_CHUNK . ' OFFSET ' . $offset . ';');
128 if ($dataResult === false) {
129 return 'Could not read data from ' . $table . ': ' . $link->error;
130 }
131
132 $columns = null;
133 $rowsSql = array();
134 while ($row = $dataResult->fetch_assoc()) {
135 if ($columns === null) {
136 $columns = '`' . implode('`,`', array_map(static fn($c) => str_replace('`', '``', $c), array_keys($row))) . '`';
137 }
138 $values = array_map(static fn($v) => znote_backups_sql_value($link, $v), array_values($row));
139 $rowsSql[] = '(' . implode(',', $values) . ')';
140 }
141
142 if ($rowsSql) {
143 fwrite($handle, "INSERT INTO {$escapedTable} ({$columns}) VALUES\n" . implode(",\n", $rowsSql) . ";\n");
144 }
145 }
146 }
147
148 fwrite($handle, "\nSET FOREIGN_KEY_CHECKS=1;\n");
149
150 return '';
151}
152
153function znote_backups_create(): array {
154 if (!znote_backups_prepare_dir()) {
155 return array(false, 'Could not create/write to ' . ZNOTE_BACKUP_DIR . '/.');
156 }
157
158 if (!class_exists('mysqli') || !function_exists('gzopen')) {
159 return array(false, 'PHP needs the mysqli and zlib extensions for backups.');
160 }
161
162 $name = 'znotex-backup-' . date('Ymd-His') . '.sql.gz';
163 $path = znote_backups_root() . '/' . $name;
164
165 $handle = @gzopen($path, 'wb9');
166 if ($handle === false) {
167 return array(false, 'Could not open ' . $name . ' for writing.');
168 }
169
170 $link = db()->connection();
171 $error = znote_backups_write($link, $handle);
172 gzclose($handle);
173
174 if ($error !== '') {
175 @unlink($path);
176 return array(false, $error);
177 }
178
179 if (!is_file($path) || filesize($path) < 1) {
180 @unlink($path);
181 return array(false, 'The backup file ended up empty.');
182 }
183
184 return array(true, $name);
185}
186
Top