PluginProbe
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More / 2.2.0
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More v2.2.0
2.3.0 2.2.0 2.1.1 2.1.0 2.0.0 1.10.0 1.9.1 1.9.0 1.2.1 1.2.2 1.3.0 1.3.1 1.3.2 1.3.3 1.4.0 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.6.0 All 59 releases
storeengine / includes / backup / backup-manager.php

backup-manager.php in StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More 2.2.0, at includes/backup/backup-manager.php

521 lines 16.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * StoreEngine full backup — orchestration, table registry & helpers.
4 *
5 * @version 1.0.0
6 */
7
8 namespace StoreEngine\Backup;
9
10 if ( ! defined( 'ABSPATH' ) ) {
11 exit;
12 }
13
14 /**
15 * Central registry + helpers shared by the Exporter, Importer, CLI and AJAX
16 * handler. Knows which `{$wpdb->prefix}storeengine_*` tables exist (discovered
17 * dynamically, never hardcoded), how to classify them into selectable groups,
18 * which tables are transient/ephemeral (never backed up), and where archives
19 * live.
20 */
21 class BackupManager {
22
23 /**
24 * Bump when the archive layout changes in a backward-incompatible way.
25 */
26 const FORMAT_VERSION = 1;
27
28 const CLEANUP_HOOK = 'storeengine/backup/cleanup_archive';
29
30 /** Recurring auto-backup cron hook. */
31 const SCHEDULE_HOOK = 'storeengine/backup/scheduled_run';
32
33 /** Option storing the auto-backup schedule + retention config. */
34 const OPTION = 'storeengine_backup_schedule';
35
36 /**
37 * Table basenames (without the `{$prefix}storeengine_` part) that are
38 * transient/ephemeral and must NEVER be exported or truncated on restore.
39 */
40 const DENYLIST = [
41 'cart',
42 'reserved_stock',
43 'sessions',
44 'otp_verifications',
45 'abandoned_cart',
46 ];
47
48 /**
49 * Log tables — included only when the "logs" group is selected.
50 */
51 const LOG_TABLES = [
52 'logs',
53 'email_log',
54 'download_log',
55 ];
56
57 /**
58 * License-management tables — included with the "licensing" group. License
59 * keys, site activations and usage analytics: small, important data.
60 */
61 const LICENSE_TABLES = [
62 'licenses',
63 'installation_events',
64 'installation_eventmeta',
65 'usage_analytics',
66 ];
67
68 /**
69 * Deployment tables — included with the "deployments" group. Paired with the
70 * (potentially very large) versioned deployment package files, so this group
71 * is kept separate from licensing and defaults off.
72 */
73 const DEPLOYMENT_TABLES = [
74 'deployment_versions',
75 ];
76
77 /** Subdir (under the secure uploads dir) holding deployment package files. */
78 const VERSIONED_FILES_SUBDIR = 'versioned-files';
79
80 public static function init(): void {
81 add_action( self::CLEANUP_HOOK, [ __CLASS__, 'cleanup_archive' ] );
82
83 // Scheduled auto-backups.
84 add_filter( 'cron_schedules', [ __CLASS__, 'register_cron_schedules' ] );
85 add_action( self::SCHEDULE_HOOK, [ __CLASS__, 'run_scheduled' ] );
86 // Self-heal the cron event if it drifts from the saved setting (e.g. after
87 // a reactivation that cleared scheduled hooks).
88 add_action( 'init', [ __CLASS__, 'maybe_reschedule' ] );
89 }
90
91 /* -------------------------------------------------------------------------
92 * Schedule + retention settings
93 * ---------------------------------------------------------------------- */
94
95 public static function default_settings(): array {
96 return [
97 'enabled' => false,
98 'frequency' => 'weekly',
99 'opts' => [
100 'licensing' => true,
101 'deployments' => false,
102 'users' => false,
103 'logs' => false,
104 'files' => false,
105 ],
106 'retention_enabled' => false,
107 'retention_keep' => 5,
108 ];
109 }
110
111 public static function get_settings(): array {
112 $defaults = self::default_settings();
113 $saved = get_option( self::OPTION, [] );
114 if ( ! is_array( $saved ) ) {
115 $saved = [];
116 }
117
118 $settings = wp_parse_args( $saved, $defaults );
119 $settings['opts'] = wp_parse_args(
120 isset( $saved['opts'] ) && is_array( $saved['opts'] ) ? $saved['opts'] : [],
121 $defaults['opts']
122 );
123 $settings['opts'] = array_map( 'boolval', $settings['opts'] );
124 $settings['enabled'] = (bool) $settings['enabled'];
125 $settings['retention_enabled'] = (bool) $settings['retention_enabled'];
126 $settings['retention_keep'] = max( 1, (int) $settings['retention_keep'] );
127 if ( ! in_array( $settings['frequency'], [ 'daily', 'weekly', 'monthly' ], true ) ) {
128 $settings['frequency'] = $defaults['frequency'];
129 }
130
131 return $settings;
132 }
133
134 /**
135 * Persist the schedule config (sanitised) and reconcile the cron event.
136 */
137 public static function save_settings( array $data ): array {
138 $defaults = self::default_settings();
139 $opts_in = isset( $data['opts'] ) && is_array( $data['opts'] ) ? $data['opts'] : [];
140
141 $settings = [
142 'enabled' => ! empty( $data['enabled'] ),
143 'frequency' => in_array( $data['frequency'] ?? '', [ 'daily', 'weekly', 'monthly' ], true )
144 ? $data['frequency']
145 : $defaults['frequency'],
146 'opts' => [
147 'licensing' => ! empty( $opts_in['licensing'] ),
148 'deployments' => ! empty( $opts_in['deployments'] ),
149 'users' => ! empty( $opts_in['users'] ),
150 'logs' => ! empty( $opts_in['logs'] ),
151 'files' => ! empty( $opts_in['files'] ),
152 ],
153 'retention_enabled' => ! empty( $data['retention_enabled'] ),
154 'retention_keep' => max( 1, (int) ( $data['retention_keep'] ?? $defaults['retention_keep'] ) ),
155 ];
156
157 update_option( self::OPTION, $settings, false );
158 self::reschedule( $settings );
159
160 return $settings;
161 }
162
163 /* -------------------------------------------------------------------------
164 * Cron scheduling
165 * ---------------------------------------------------------------------- */
166
167 public static function register_cron_schedules( array $schedules ): array {
168 if ( ! isset( $schedules['weekly'] ) ) {
169 $schedules['weekly'] = [
170 'interval' => WEEK_IN_SECONDS,
171 'display' => __( 'Once Weekly', 'storeengine' ),
172 ];
173 }
174 if ( ! isset( $schedules['monthly'] ) ) {
175 $schedules['monthly'] = [
176 'interval' => MONTH_IN_SECONDS,
177 'display' => __( 'Once Monthly', 'storeengine' ),
178 ];
179 }
180
181 return $schedules;
182 }
183
184 /**
185 * Clear + (re)schedule the recurring event to match the given settings.
186 */
187 public static function reschedule( array $settings ): void {
188 wp_clear_scheduled_hook( self::SCHEDULE_HOOK );
189
190 if ( ! empty( $settings['enabled'] ) ) {
191 $freq = in_array( $settings['frequency'] ?? '', [ 'daily', 'weekly', 'monthly' ], true )
192 ? $settings['frequency']
193 : 'weekly';
194 wp_schedule_event( time() + HOUR_IN_SECONDS, $freq, self::SCHEDULE_HOOK );
195 }
196 }
197
198 /**
199 * Cheap drift check on every load: schedule when enabled-but-missing, clear
200 * when disabled-but-present.
201 */
202 public static function maybe_reschedule(): void {
203 $settings = self::get_settings();
204 $next = wp_next_scheduled( self::SCHEDULE_HOOK );
205
206 if ( ! empty( $settings['enabled'] ) && ! $next ) {
207 wp_schedule_event( time() + HOUR_IN_SECONDS, $settings['frequency'], self::SCHEDULE_HOOK );
208 } elseif ( empty( $settings['enabled'] ) && $next ) {
209 wp_clear_scheduled_hook( self::SCHEDULE_HOOK );
210 }
211 }
212
213 /**
214 * Cron callback — produce a backup with the saved scope, then prune.
215 */
216 public static function run_scheduled(): void {
217 $settings = self::get_settings();
218
219 try {
220 ( new Exporter( $settings['opts'] ) )->run();
221 } catch ( \Throwable $e ) {
222 \StoreEngine\Utils\Helper::log_error( $e );
223 }
224
225 if ( ! empty( $settings['retention_enabled'] ) ) {
226 self::apply_retention( (int) $settings['retention_keep'] );
227 }
228 }
229
230 /**
231 * Keep only the newest `$keep` non-safety archives; delete the rest. Safety
232 * (pre-restore) backups are never auto-deleted.
233 *
234 * @return int Number of archives removed.
235 */
236 public static function apply_retention( int $keep ): int {
237 $keep = max( 1, $keep );
238
239 $deletable = array_values( array_filter(
240 self::list_backups(), // newest first
241 static fn( $b ) => empty( $b['is_safety'] )
242 ) );
243
244 $deleted = 0;
245 foreach ( array_slice( $deletable, $keep ) as $b ) {
246 $path = self::resolve_backup_path( $b['filename'] );
247 if ( $path && is_file( $path ) ) {
248 // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
249 @unlink( $path );
250 $deleted++;
251 }
252 }
253
254 return $deleted;
255 }
256
257 /* -------------------------------------------------------------------------
258 * Paths
259 * ---------------------------------------------------------------------- */
260
261 /**
262 * Private, web-inaccessible directory for archives. Lives inside
263 * STOREENGINE_SECURE_UPLOADS_DIR which already ships a `Require all denied`
264 * .htaccess (and we add belt-and-suspenders protection here too).
265 */
266 public static function backups_dir(): string {
267 return trailingslashit( STOREENGINE_SECURE_UPLOADS_DIR ) . 'backups';
268 }
269
270 public static function ensure_backups_dir(): string {
271 $dir = self::backups_dir();
272 if ( ! is_dir( $dir ) ) {
273 wp_mkdir_p( $dir );
274 }
275
276 // Defence in depth — never serve these over HTTP regardless of server.
277 $htaccess = trailingslashit( $dir ) . '.htaccess';
278 if ( ! file_exists( $htaccess ) ) {
279 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
280 @file_put_contents( $htaccess, "Require all denied\n<IfModule !mod_authz_core.c>\nDeny from all\n</IfModule>\n" );
281 }
282 $index = trailingslashit( $dir ) . 'index.html';
283 if ( ! file_exists( $index ) ) {
284 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
285 @file_put_contents( $index, '' );
286 }
287
288 return $dir;
289 }
290
291 /* -------------------------------------------------------------------------
292 * Table discovery + classification
293 * ---------------------------------------------------------------------- */
294
295 /**
296 * Every `{$prefix}storeengine_*` table currently in the DB (core + whatever
297 * pro addons are active). Returns full table names.
298 *
299 * @return string[]
300 */
301 public static function all_tables(): array {
302 global $wpdb;
303
304 $like = $wpdb->esc_like( $wpdb->prefix . 'storeengine_' ) . '%';
305 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
306 $tables = $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', $like ) );
307
308 return is_array( $tables ) ? $tables : [];
309 }
310
311 /**
312 * Strip the `{$prefix}storeengine_` prefix → bare basename used for grouping.
313 */
314 public static function basename( string $table ): string {
315 global $wpdb;
316
317 return preg_replace( '/^' . preg_quote( $wpdb->prefix . 'storeengine_', '/' ) . '/', '', $table );
318 }
319
320 /**
321 * Which selectable group a table belongs to: 'logs' | 'licensing' |
322 * 'deployments' | 'store'. 'store' is the always-included core/addon data set.
323 */
324 public static function table_group( string $table ): string {
325 $base = self::basename( $table );
326
327 if ( in_array( $base, self::LOG_TABLES, true ) ) {
328 return 'logs';
329 }
330 if ( in_array( $base, self::DEPLOYMENT_TABLES, true ) ) {
331 return 'deployments';
332 }
333 if ( in_array( $base, self::LICENSE_TABLES, true ) ) {
334 return 'licensing';
335 }
336
337 return 'store';
338 }
339
340 /** Absolute path to the deployment package files dir (may not exist). */
341 public static function versioned_files_dir(): ?string {
342 if ( ! defined( 'STOREENGINE_SECURE_UPLOADS_DIR' ) ) {
343 return null;
344 }
345
346 return trailingslashit( STOREENGINE_SECURE_UPLOADS_DIR ) . self::VERSIONED_FILES_SUBDIR;
347 }
348
349 /**
350 * Resolve the set of tables to back up for the given options.
351 *
352 * @param array $opts { licensing:bool(default true), logs:bool(default false) }
353 *
354 * @return string[] Full table names.
355 */
356 public static function tables_for( array $opts ): array {
357 $include_licensing = $opts['licensing'] ?? true;
358 $include_deployments = $opts['deployments'] ?? false;
359 $include_logs = $opts['logs'] ?? false;
360
361 $tables = [];
362 foreach ( self::all_tables() as $table ) {
363 $base = self::basename( $table );
364
365 if ( in_array( $base, self::DENYLIST, true ) ) {
366 continue;
367 }
368
369 $group = self::table_group( $table );
370 if ( 'logs' === $group && ! $include_logs ) {
371 continue;
372 }
373 if ( 'licensing' === $group && ! $include_licensing ) {
374 continue;
375 }
376 if ( 'deployments' === $group && ! $include_deployments ) {
377 continue;
378 }
379
380 $tables[] = $table;
381 }
382
383 /**
384 * Filter the final list of tables to export.
385 *
386 * @param string[] $tables Full table names.
387 * @param array $opts Resolved export options.
388 */
389 return apply_filters( 'storeengine/backup/tables', $tables, $opts );
390 }
391
392 /**
393 * StoreEngine-registered custom post types to back up. Enumerated
394 * dynamically (prefix `storeengine` / `se_`) so no addon CPT is missed;
395 * filterable for anything outside that convention.
396 *
397 * @return string[]
398 */
399 public static function post_types(): array {
400 $types = [];
401 foreach ( get_post_types( [ '_builtin' => false ] ) as $type ) {
402 if ( str_starts_with( $type, 'storeengine' ) || str_starts_with( $type, 'se_' ) ) {
403 $types[] = $type;
404 }
405 }
406
407 /**
408 * Filter the post types included in a backup.
409 *
410 * @param string[] $types
411 */
412 return array_values( array_unique( apply_filters( 'storeengine/backup/post_types', $types ) ) );
413 }
414
415 /**
416 * The `option_name` LIKE patterns whose options are backed up. Single
417 * `storeengine%` already covers `storeengine_pro%`.
418 *
419 * @return string[]
420 */
421 public static function option_like_patterns(): array {
422 return apply_filters( 'storeengine/backup/option_patterns', [ 'storeengine%' ] );
423 }
424
425 /* -------------------------------------------------------------------------
426 * Listing existing backups + filename-based (stable) download
427 * ---------------------------------------------------------------------- */
428
429 /**
430 * Only our own archive filenames are ever downloadable/deletable.
431 */
432 public static function is_valid_backup_name( string $name ): bool {
433 return (bool) preg_match( '/^storeengine-(backup|prerestore)[a-z0-9\-]*\.zip$/i', $name );
434 }
435
436 /**
437 * Resolve a backup filename to an absolute path inside the backups dir, or
438 * null if it's invalid / escapes the dir / doesn't exist. (Path-traversal safe.)
439 */
440 public static function resolve_backup_path( string $name ): ?string {
441 $name = wp_basename( $name );
442 if ( ! self::is_valid_backup_name( $name ) ) {
443 return null;
444 }
445 $real = realpath( trailingslashit( self::backups_dir() ) . $name );
446 $dir = realpath( self::backups_dir() );
447
448 return ( $real && $dir && str_starts_with( $real, $dir ) && is_file( $real ) ) ? $real : null;
449 }
450
451 /**
452 * List the archives currently stored (newest first). Excludes the
453 * `uploads/` (incoming import) and `tmp-*`/`restore-*` working dirs.
454 *
455 * @return array<int,array{filename:string,size:string,bytes:int,created:string,timestamp:int,is_safety:bool}>
456 */
457 public static function list_backups(): array {
458 $dir = self::backups_dir();
459 if ( ! is_dir( $dir ) ) {
460 return [];
461 }
462
463 $out = [];
464 foreach ( (array) glob( trailingslashit( $dir ) . '*.zip' ) as $path ) {
465 $name = basename( $path );
466 if ( ! self::is_valid_backup_name( $name ) ) {
467 continue;
468 }
469 $mtime = (int) filemtime( $path );
470 $out[] = [
471 'filename' => $name,
472 'size' => size_format( (int) filesize( $path ) ),
473 'bytes' => (int) filesize( $path ),
474 'created' => gmdate( 'Y-m-d H:i:s', $mtime ),
475 'timestamp' => $mtime,
476 'is_safety' => str_starts_with( $name, 'storeengine-prerestore-' ),
477 ];
478 }
479
480 usort( $out, static fn( $a, $b ) => $b['timestamp'] <=> $a['timestamp'] );
481
482 return $out;
483 }
484
485 /* -------------------------------------------------------------------------
486 * Download tokens (legacy, optional)
487 * ---------------------------------------------------------------------- */
488
489 public static function issue_download_token( string $filepath ): string {
490 $token = wp_generate_password( 32, false );
491 set_transient( 'storeengine_backup_dl_' . $token, $filepath, HOUR_IN_SECONDS );
492
493 return $token;
494 }
495
496 public static function resolve_download_token( string $token ): ?string {
497 $path = get_transient( 'storeengine_backup_dl_' . $token );
498
499 return $path && is_string( $path ) ? $path : null;
500 }
501
502 /* -------------------------------------------------------------------------
503 * Cleanup
504 * ---------------------------------------------------------------------- */
505
506 /**
507 * Action Scheduler / cron callback — delete an expired archive.
508 */
509 public static function cleanup_archive( string $filepath ): void {
510 $dir = realpath( self::backups_dir() );
511 $real = realpath( $filepath );
512 // Only ever delete inside our own backups dir.
513 if ( $dir && $real && str_starts_with( $real, $dir ) && is_file( $real ) ) {
514 // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
515 @unlink( $real );
516 }
517 }
518 }
519
520 // End of file backup-manager.php.
521