PluginProbe
Trinity Backup – Backup, Migrate, Restore, Clone & Schedule Backups / 2.0.5
Trinity Backup – Backup, Migrate, Restore, Clone & Schedule Backups v2.0.5
trunk 2.0.10 2.0.3 2.0.4 2.0.5 2.0.6 2.0.7 2.0.8 2.0.9
trinity-backup / src / Core / StateManager.php

StateManager.php in Trinity Backup – Backup, Migrate, Restore, Clone & Schedule Backups 2.0.5, at src/Core/StateManager.php

291 lines 8.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace TrinityBackup\Core;
6
7 if (!\defined('ABSPATH')) {
8 exit;
9 }
10
11 final class StateManager
12 {
13 private const OPTION_PREFIX = 'trinity_backup_state_';
14 private const OPTION_CURRENT = 'trinity_backup_current_job';
15
16 public function create(): string
17 {
18 $jobId = $this->generateBackupName();
19 $this->directSave(self::OPTION_CURRENT, $jobId);
20 $this->saveCurrentJobIdToFile($jobId);
21
22 return $jobId;
23 }
24
25 /**
26 * Generate a backup name in style: domain-YYYYMMDD-HHMMSS-random
27 * Example: devtrinitybackup-local-20260113-123251-arfl3vsrff6q
28 */
29 public function generateBackupName(): string
30 {
31 // Get domain from site URL (strip protocol and path)
32 $siteUrl = site_url();
33 $parsed = wp_parse_url($siteUrl);
34 $host = $parsed['host'] ?? 'backup';
35
36 // Clean domain: remove www., convert dots/special chars to hyphens
37 $domain = preg_replace('/^www\./', '', $host);
38 $domain = preg_replace('/[^a-z0-9]+/i', '-', $domain);
39 $domain = strtolower(trim($domain, '-'));
40
41 if ($domain === '' || $domain === 'localhost') {
42 $domain = 'backup';
43 }
44
45 // Date and time
46 $date = gmdate('Ymd');
47 $time = gmdate('His');
48
49 // Random suffix (12 chars, lowercase alphanumeric)
50 $random = $this->generateRandomSuffix(12);
51
52 return sprintf('%s-%s-%s-%s', $domain, $date, $time, $random);
53 }
54
55 /**
56 * Generate random lowercase alphanumeric suffix.
57 */
58 private function generateRandomSuffix(int $length): string
59 {
60 $chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
61 $result = '';
62 $bytes = random_bytes($length);
63
64 for ($i = 0; $i < $length; $i++) {
65 $result .= $chars[ord($bytes[$i]) % 36];
66 }
67
68 return $result;
69 }
70
71 public function save(string $jobId, array $state): void
72 {
73 // For import jobs, also save to file (survives database replacement)
74 if (($state['job_type'] ?? '') === 'import') {
75 $this->saveToFile($jobId, $state);
76 }
77 $this->directSave(self::OPTION_PREFIX . $jobId, $state);
78 }
79
80 public function load(string $jobId): ?array
81 {
82 // First try database
83 $state = $this->directLoad(self::OPTION_PREFIX . $jobId);
84 if (is_array($state)) {
85 return $state;
86 }
87
88 // Fallback to file (for import jobs after database was replaced)
89 return $this->loadFromFile($jobId);
90 }
91
92 public function getCurrentJobId(): ?string
93 {
94 $jobId = $this->directLoad(self::OPTION_CURRENT);
95 if (is_string($jobId) && $jobId !== '') {
96 return $jobId;
97 }
98
99 // Fallback to file
100 return $this->loadCurrentJobIdFromFile();
101 }
102
103 public function loadCurrent(): ?array
104 {
105 $jobId = $this->getCurrentJobId();
106 if ($jobId === null) {
107 return null;
108 }
109
110 return $this->load($jobId);
111 }
112
113 public function forget(string $jobId): void
114 {
115 $this->directDelete(self::OPTION_PREFIX . $jobId);
116 $this->forgetFile($jobId);
117 }
118
119 /**
120 * Direct database save bypassing WordPress object cache.
121 * Critical during import when we execute DROP/INSERT statements
122 * that can corrupt or invalidate the object cache.
123 */
124 private function directSave(string $optionName, mixed $value): void
125 {
126 global $wpdb;
127
128 $serialized = maybe_serialize($value);
129
130 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Must bypass cache during import operations
131 $exists = $wpdb->get_var(
132 $wpdb->prepare(
133 "SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name = %s",
134 $optionName
135 )
136 );
137
138 if ((int) $exists > 0) {
139 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Must bypass cache during import operations
140 $wpdb->update(
141 $wpdb->options,
142 ['option_value' => $serialized],
143 ['option_name' => $optionName],
144 ['%s'],
145 ['%s']
146 );
147 } else {
148 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Must bypass cache during import operations
149 $wpdb->insert(
150 $wpdb->options,
151 [
152 'option_name' => $optionName,
153 'option_value' => $serialized,
154 'autoload' => 'no',
155 ],
156 ['%s', '%s', '%s']
157 );
158 }
159
160 // Also update WP cache to keep it consistent
161 wp_cache_set($optionName, $value, 'options');
162 }
163
164 /**
165 * Direct database load bypassing WordPress object cache.
166 */
167 private function directLoad(string $optionName): mixed
168 {
169 global $wpdb;
170
171 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Must bypass cache during import operations
172 $row = $wpdb->get_row(
173 $wpdb->prepare(
174 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
175 $optionName
176 )
177 );
178
179 if ($row === null) {
180 return null;
181 }
182
183 return maybe_unserialize($row->option_value);
184 }
185
186 /**
187 * Direct database delete bypassing WordPress object cache.
188 */
189 private function directDelete(string $optionName): void
190 {
191 global $wpdb;
192
193 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Must bypass cache during import operations
194 $wpdb->delete(
195 $wpdb->options,
196 ['option_name' => $optionName],
197 ['%s']
198 );
199
200 wp_cache_delete($optionName, 'options');
201 }
202
203 /**
204 * Get file path for state storage.
205 */
206 private function getStateFilePath(string $jobId): string
207 {
208 $uploads = wp_upload_dir();
209 $dir = trailingslashit($uploads['basedir']) . 'trinity-backup';
210 if (!is_dir($dir)) {
211 wp_mkdir_p($dir);
212 }
213 return $dir . '/' . $jobId . '_state.json';
214 }
215
216 /**
217 * Get file path for current job ID storage.
218 */
219 private function getCurrentJobFilePath(): string
220 {
221 $uploads = wp_upload_dir();
222 $dir = trailingslashit($uploads['basedir']) . 'trinity-backup';
223 if (!is_dir($dir)) {
224 wp_mkdir_p($dir);
225 }
226 return $dir . '/_current_job.txt';
227 }
228
229 /**
230 * Save state to file (survives database replacement during import).
231 */
232 private function saveToFile(string $jobId, array $state): void
233 {
234 $path = $this->getStateFilePath($jobId);
235 file_put_contents($path, json_encode($state, JSON_PRETTY_PRINT));
236 }
237
238 /**
239 * Load state from file.
240 */
241 private function loadFromFile(string $jobId): ?array
242 {
243 $path = $this->getStateFilePath($jobId);
244 if (!is_file($path)) {
245 return null;
246 }
247
248 $content = file_get_contents($path);
249 if ($content === false) {
250 return null;
251 }
252
253 $state = json_decode($content, true);
254 return is_array($state) ? $state : null;
255 }
256
257 /**
258 * Save current job ID to file.
259 */
260 private function saveCurrentJobIdToFile(string $jobId): void
261 {
262 $path = $this->getCurrentJobFilePath();
263 file_put_contents($path, $jobId);
264 }
265
266 /**
267 * Load current job ID from file.
268 */
269 private function loadCurrentJobIdFromFile(): ?string
270 {
271 $path = $this->getCurrentJobFilePath();
272 if (!is_file($path)) {
273 return null;
274 }
275
276 $jobId = trim((string) file_get_contents($path));
277 return $jobId !== '' ? $jobId : null;
278 }
279
280 /**
281 * Delete state file.
282 */
283 public function forgetFile(string $jobId): void
284 {
285 $path = $this->getStateFilePath($jobId);
286 if (is_file($path)) {
287 wp_delete_file($path);
288 }
289 }
290 }
291