76c01e44df
對齊 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
434 lines
14 KiB
PHP
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;
|
|
}
|
|
}
|