Files
2meet-data-optimizer/includes/snapshots/class-tmdo-snapshot-manager.php
wpdev 76c01e44df refactor: 全部 128 個生產檔加入 declare(strict_types=1)(PR-H)
對齊 A v3.2.0。型別強制會把隱式轉換變成 TypeError,所以一次全檔加入
並跑完整測試(unit 451 / integration 398 全綠,無迴歸)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
2026-07-31 06:13:33 +08:00

434 lines
14 KiB
PHP

<?php
/**
* TMDO_Snapshot_Manager — Facade for WPDO backup/restore (v2.2.0 M1).
*
* Catalog row lives in `wp_wpdo_snapshots`. Payload lives either inline
* (≤5MB) or as a gzipped SQL dump under wp-content/uploads/wpdo-backups/.
* Triggered by FSM transitions, V2 upgrades, scheduled prune drills, or
* explicit admin/CLI calls.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Backup catalog facade. All public methods return arrays describing the
* outcome (never throw on expected failure paths — log + return error key).
*/
class TMDO_Snapshot_Manager {
/** Table slug (without prefix). */
public const TABLE_SLUG = 'wpdo_snapshots';
/** Subdirectory under wp-content/uploads/. */
public const BACKUP_DIR_NAME = 'wpdo-backups';
/** Inline-vs-file storage threshold (5 MB). */
public const INLINE_THRESHOLD_BYTES = 5_000_000;
/** Default retention window. */
public const DEFAULT_RETENTION_DAYS = 30;
/** Default size cap for the whole backup directory (1 GB). */
public const DEFAULT_SIZE_CAP_BYTES = 1_073_741_824;
/** Allowed trigger types (must match wpdo_snapshots.trigger_type values). */
public const VALID_TRIGGERS = array(
'manual',
'pre_fsm_transition',
'pre_v2_upgrade',
'scheduled',
'pre_uninstall',
);
/**
* Create a snapshot.
*
* @param string $trigger One of VALID_TRIGGERS.
* @param array $scope {entities:[],modules:[],tables:[]} — leave empty to back up all WPDO + meta tables.
* @param array $opts {notes:string,retention_days:int,gzip:bool,inline_threshold_bytes:int}.
* @return array {ok:bool, snapshot_id?:string, error?:string, size_bytes?:int, row_count?:int}
*/
public static function create( string $trigger, array $scope = array(), array $opts = array() ): array {
global $wpdb;
if ( ! in_array( $trigger, self::VALID_TRIGGERS, true ) ) {
return array(
'ok' => 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<int,array<string,mixed>>
*/
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<string,mixed>|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,
"<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n<IfModule !mod_authz_core.c>\n Order allow,deny\n Deny from all\n</IfModule>\n"
);
}
// Empty index.php — prevents directory listing on Apache without Options -Indexes.
$index = $base . 'index.php';
if ( ! file_exists( $index ) ) {
file_put_contents( $index, "<?php\n// Silence is golden.\n" );
}
// Nginx guidance file — for server admins running nginx (which ignores .htaccess).
$nginx_note = $base . 'README-NGINX.txt';
if ( ! file_exists( $nginx_note ) ) {
file_put_contents(
$nginx_note,
"IMPORTANT: This directory contains database snapshots.\n" .
"Nginx does not read .htaccess. Add the following to your site config:\n\n" .
" location ~* /wpdo-backups/ {\n deny all;\n }\n\n" .
"Without this rule the snapshot files are publicly accessible.\n"
);
}
return is_writable( $dir );
}
// ─── helpers ──────────────────────────────────────────────────────────
/**
* Generate ULID-like snapshot ID (sortable + unique). Falls back to
* uniqid when random_bytes is unavailable.
*
* @return string
*/
private static function generate_id(): string {
$ts = base_convert( (string) ( time() * 1000 ), 10, 36 );
try {
$rand = bin2hex( random_bytes( 8 ) );
} catch ( Throwable $e ) {
$rand = substr( md5( uniqid( '', true ) ), 0, 16 );
}
return 'wpdo_' . $ts . '_' . $rand;
}
/**
* Resolve scope to canonical form. Empty/malformed scope = "everything".
*
* @param array $scope User-provided scope.
* @return array {entities:array,modules:array,tables:array}
*/
private static function resolve_scope( array $scope ): array {
return array(
'entities' => 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<string,string>
*/
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;
}
}