false, 'error' => 'invalid_trigger', ); } if ( ! self::ensure_backup_dir() ) { return array( 'ok' => false, 'error' => 'backup_dir_unwritable', ); } $snapshot_id = self::generate_id(); $writer = new TMDO_Snapshot_Writer( $snapshot_id, self::resolve_scope( $scope ), $opts ); try { $result = $writer->write(); } catch ( Throwable $e ) { TMDO_Logger::error( 'snapshots', 'create', $e->getMessage(), array( 'snapshot_id' => $snapshot_id, 'trigger' => $trigger, ), '' ); return array( 'ok' => false, 'error' => 'writer_failed', 'message' => $e->getMessage(), ); } $retention_days = (int) ( $opts['retention_days'] ?? self::DEFAULT_RETENTION_DAYS ); $expires_at = $retention_days > 0 ? gmdate( 'Y-m-d H:i:s', time() + $retention_days * DAY_IN_SECONDS ) : null; $row = array( 'snapshot_id' => $snapshot_id, 'trigger_type' => $trigger, 'scope' => wp_json_encode( $writer->get_scope() ), 'size_bytes' => (int) $result['size_bytes'], 'row_count' => (int) $result['row_count'], 'storage' => (string) $result['storage'], 'file_path' => $result['file_path'] ?? null, 'file_sha256' => $result['sha256'] ?? null, 'inline_blob' => $result['inline_blob'] ?? null, 'fsm_states' => wp_json_encode( self::capture_fsm_states() ), 'notes' => isset( $opts['notes'] ) ? (string) $opts['notes'] : null, 'created_at' => current_time( 'mysql', true ), 'expires_at' => $expires_at, ); $inserted = $wpdb->insert( $wpdb->prefix . self::TABLE_SLUG, $row ); // phpcs:ignore WordPress.DB if ( false === $inserted ) { // Insert failed — clean up the written file to avoid orphan. if ( ! empty( $result['file_path'] ) && file_exists( $result['file_path'] ) ) { @unlink( $result['file_path'] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors } return array( 'ok' => false, 'error' => 'db_insert_failed', 'message' => $wpdb->last_error, ); } TMDO_Logger::info( 'snapshot_created', array( 'snapshot_id' => $snapshot_id, 'trigger' => $trigger, 'size_bytes' => $result['size_bytes'], 'row_count' => $result['row_count'], 'storage' => $result['storage'], ) ); do_action( 'wpdo_after_snapshot_create', $snapshot_id, $trigger, $row ); return array( 'ok' => true, 'snapshot_id' => $snapshot_id, 'size_bytes' => (int) $result['size_bytes'], 'row_count' => (int) $result['row_count'], 'storage' => (string) $result['storage'], ); } /** * Restore a snapshot. Default dry-run; pass false to actually replay. * * @param string $snapshot_id Snapshot ULID. * @param bool $dry_run When true, return preview without applying. * @return array {ok:bool, preview?:array, restored?:array, error?:string} */ public static function restore( string $snapshot_id, bool $dry_run = true ): array { $row = self::get( $snapshot_id ); if ( null === $row ) { return array( 'ok' => false, 'error' => 'not_found', ); } try { $reader = new TMDO_Snapshot_Reader( $row ); } catch ( Throwable $e ) { return array( 'ok' => false, 'error' => 'reader_init_failed', 'message' => $e->getMessage(), ); } try { if ( $dry_run ) { $preview = $reader->preview(); return array( 'ok' => true, 'preview' => $preview, ); } $applied = $reader->apply(); TMDO_Logger::warning( 'snapshot_restored', array( 'snapshot_id' => $snapshot_id, 'tables' => $applied['tables'] ?? array(), 'rows' => $applied['rows_restored'] ?? 0, ) ); do_action( 'wpdo_after_snapshot_restore', $snapshot_id, $applied ); return array( 'ok' => true, 'restored' => $applied, ); } catch ( Throwable $e ) { TMDO_Logger::error( 'snapshots', 'restore', $e->getMessage(), array( 'snapshot_id' => $snapshot_id ), '' ); return array( 'ok' => false, 'error' => 'restore_failed', 'message' => $e->getMessage(), ); } } /** * Verify integrity (sha256, file existence/readability). * * @param string $snapshot_id Snapshot ULID. * @return array {ok:bool, sha256_ok?:bool, size_match?:bool, error?:string} */ public static function verify( string $snapshot_id ): array { $row = self::get( $snapshot_id ); if ( null === $row ) { return array( 'ok' => false, 'error' => 'not_found', ); } try { $reader = new TMDO_Snapshot_Reader( $row ); return $reader->verify(); } catch ( Throwable $e ) { return array( 'ok' => false, 'error' => 'verify_failed', 'message' => $e->getMessage(), ); } } /** * List recent snapshots (catalog rows only — no payload). * * @param int $limit Max rows. * @param string|null $trigger_filter Only this trigger_type if set. * @return array> */ public static function list_recent( int $limit = 50, ?string $trigger_filter = null ): array { global $wpdb; $table = $wpdb->prefix . self::TABLE_SLUG; $limit = max( 1, min( 1000, $limit ) ); if ( null !== $trigger_filter ) { $rows = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB "SELECT id, snapshot_id, trigger_type, size_bytes, row_count, storage, file_path, notes, created_at, expires_at FROM `{$table}` WHERE trigger_type = %s ORDER BY created_at DESC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is a trusted table name via TMDO_DB::table() $trigger_filter, $limit ), ARRAY_A ); } else { $rows = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB "SELECT id, snapshot_id, trigger_type, size_bytes, row_count, storage, file_path, notes, created_at, expires_at FROM `{$table}` ORDER BY created_at DESC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is a trusted table name via TMDO_DB::table() $limit ), ARRAY_A ); } return is_array( $rows ) ? $rows : array(); } /** * Prune expired or oldest-first snapshots up to a size cap. * * @param int $older_than_days Default retention window. * @param int $size_cap_bytes When backup dir exceeds this, evict oldest. * @return array {pruned:int, freed_bytes:int, errors:array} */ public static function prune( int $older_than_days = self::DEFAULT_RETENTION_DAYS, int $size_cap_bytes = self::DEFAULT_SIZE_CAP_BYTES ): array { return TMDO_Snapshot_Pruner::prune( $older_than_days, $size_cap_bytes ); } /** * Read a single catalog row by snapshot_id (includes inline_blob). * * @param string $snapshot_id Snapshot ULID. * @return array|null */ public static function get( string $snapshot_id ): ?array { global $wpdb; $table = $wpdb->prefix . self::TABLE_SLUG; $row = $wpdb->get_row( $wpdb->prepare( // phpcs:ignore WordPress.DB "SELECT * FROM `{$table}` WHERE snapshot_id = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is a trusted table name via TMDO_DB::table() $snapshot_id ), ARRAY_A ); return is_array( $row ) ? $row : null; } /** * Delete one snapshot (catalog row + file). Idempotent. * * @param string $snapshot_id Snapshot ULID. * @return bool true on row+file deletion (or row absent), false on DB error. */ public static function delete( string $snapshot_id ): bool { global $wpdb; $row = self::get( $snapshot_id ); if ( null === $row ) { return true; } if ( ! empty( $row['file_path'] ) && file_exists( $row['file_path'] ) ) { @unlink( $row['file_path'] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors } $table = $wpdb->prefix . self::TABLE_SLUG; $deleted = $wpdb->delete( $table, array( 'snapshot_id' => $snapshot_id ), array( '%s' ) ); // phpcs:ignore WordPress.DB TMDO_Logger::info( 'snapshot_deleted', array( 'snapshot_id' => $snapshot_id ) ); return false !== $deleted; } /** * Resolve absolute path to wp-content/uploads/wpdo-backups/. * * Multisite (v2.14.0): `wp_upload_dir()` automatically returns the * current site's uploads basedir — i.e. `/wp-content/uploads/sites/N/` * for sub-sites and `/wp-content/uploads/` for the main site. So the * backup directory is naturally per-site isolated; no extra handling * needed. When a site is deleted via Network → Sites → Delete, the * entire `sites/N/` tree is removed by WP, taking the snapshots with it. * * @return string */ public static function backup_dir(): string { $uploads = wp_upload_dir(); $base = $uploads['basedir'] ?? WP_CONTENT_DIR . '/uploads'; return trailingslashit( $base ) . self::BACKUP_DIR_NAME; } /** * Ensure wp-content/uploads/wpdo-backups/ exists with access-denial guards. * * Apache: .htaccess "Deny from all" + modern "Require all denied". * Nginx: nginx does not read .htaccess; add a location block to site config: * location ~* /wpdo-backups/ { deny all; } * The README-NGINX.txt placed here documents this for server admins. * * @return bool */ public static function ensure_backup_dir(): bool { $dir = self::backup_dir(); if ( ! file_exists( $dir ) ) { if ( ! wp_mkdir_p( $dir ) ) { return false; } } $base = trailingslashit( $dir ); // Apache — "Deny from all" (Apache 2.2) + "Require all denied" (Apache 2.4). $htaccess = $base . '.htaccess'; if ( ! file_exists( $htaccess ) ) { file_put_contents( $htaccess, "\n Require all denied\n\n\n Order allow,deny\n Deny from all\n\n" ); } // Empty index.php — prevents directory listing on Apache without Options -Indexes. $index = $base . 'index.php'; if ( ! file_exists( $index ) ) { file_put_contents( $index, " isset( $scope['entities'] ) && is_array( $scope['entities'] ) ? array_values( array_map( 'strval', $scope['entities'] ) ) : array(), 'modules' => isset( $scope['modules'] ) && is_array( $scope['modules'] ) ? array_values( array_map( 'strval', $scope['modules'] ) ) : array(), 'tables' => isset( $scope['tables'] ) && is_array( $scope['tables'] ) ? array_values( array_map( 'strval', $scope['tables'] ) ) : array(), ); } /** * Capture per-module FSM states at snapshot creation time (for context * during restore — admin can decide whether to also rewind FSM). * * @return array */ private static function capture_fsm_states(): array { if ( ! class_exists( 'TMDO_Feature_Flags' ) ) { return array(); } $out = array(); $modules = array_merge( TMDO_Feature_Flags::HPCT_MODULES, TMDO_Feature_Flags::ZONE_MODULES ); foreach ( $modules as $m ) { $out[ $m ] = TMDO_Feature_Flags::get( $m ); } return $out; } }