chore: initial snapshot of 2meet-data-optimizer v0.1.0

Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
This commit is contained in:
2026-07-31 05:06:36 +08:00
commit d36bb954d1
206 changed files with 66538 additions and 0 deletions
@@ -0,0 +1,431 @@
<?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
*/
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;
}
}
@@ -0,0 +1,198 @@
<?php
/**
* TMDO_Snapshot_Pruner — Daily prune of expired/over-cap snapshots
* (v2.2.0 M1).
*
* Two pruning rules, applied in order:
* 1. Drop catalog rows with `expires_at < NOW()`.
* 2. If backup directory size still exceeds `$size_cap_bytes`, evict the
* oldest non-pre_uninstall / non-pre_v2_upgrade rows until under cap.
*
* pre_uninstall and pre_v2_upgrade snapshots are protected from size-cap
* eviction (they are emergency restore points; only TTL prune touches them).
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Prune helper. Stateless static API.
*/
class TMDO_Snapshot_Pruner {
/** Triggers exempt from size-cap eviction. */
private const PROTECTED_TRIGGERS = array(
'pre_uninstall',
'pre_v2_upgrade',
);
/**
* Run prune.
*
* @param int $older_than_days Only used for the TTL phase logging summary;
* actual TTL is enforced via wp_wpdo_snapshots.expires_at.
* @param int $size_cap_bytes Backup-dir size cap. 0 disables.
* @return array {pruned:int, freed_bytes:int, errors:array, ttl_pruned:int, sizecap_pruned:int}
*/
public static function prune( int $older_than_days, int $size_cap_bytes ): array {
global $wpdb;
$table = $wpdb->prefix . TMDO_Snapshot_Manager::TABLE_SLUG;
$ttl_rows = $wpdb->get_results( // phpcs:ignore WordPress.DB
"SELECT id, snapshot_id, file_path, size_bytes FROM `{$table}` WHERE expires_at IS NOT NULL AND expires_at < UTC_TIMESTAMP()", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is a trusted table name via TMDO_DB::table()
ARRAY_A
);
$result = self::evict_rows( is_array( $ttl_rows ) ? $ttl_rows : array(), 'ttl' );
if ( $size_cap_bytes > 0 ) {
$current_size = self::current_dir_size();
if ( $current_size > $size_cap_bytes ) {
$over = $current_size - $size_cap_bytes;
$candidates = $wpdb->get_results(
$wpdb->prepare( // phpcs:ignore WordPress.DB
"SELECT id, snapshot_id, file_path, size_bytes FROM `{$table}` WHERE trigger_type NOT IN ('" . implode( "','", array_map( 'esc_sql', self::PROTECTED_TRIGGERS ) ) . "') ORDER BY created_at ASC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.QuotedDynamicPlaceholderGeneration -- dynamic IN clause with trusted table name and string-literal enum values
100
),
ARRAY_A
);
$cap_result = self::evict_to_recover( is_array( $candidates ) ? $candidates : array(), $over );
$result['sizecap_pruned'] = $cap_result['pruned'];
$result['freed_bytes'] += $cap_result['freed_bytes'];
$result['pruned'] += $cap_result['pruned'];
$result['errors'] = array_merge( $result['errors'], $cap_result['errors'] );
} else {
$result['sizecap_pruned'] = 0;
}
} else {
$result['sizecap_pruned'] = 0;
}
TMDO_Logger::info(
'snapshot_prune',
array(
'older_than_days' => $older_than_days,
'size_cap_bytes' => $size_cap_bytes,
'pruned' => $result['pruned'],
'freed_bytes' => $result['freed_bytes'],
'ttl_pruned' => $result['ttl_pruned'] ?? 0,
'sizecap_pruned' => $result['sizecap_pruned'],
)
);
return $result;
}
/**
* Cron handler — called from class-tmdo-core.php via the wpdo_daily_health_check
* subroutine (v2.3.0) or its own scheduled hook.
*
* @return void
*/
public static function cron_run(): void {
self::prune( TMDO_Snapshot_Manager::DEFAULT_RETENTION_DAYS, TMDO_Snapshot_Manager::DEFAULT_SIZE_CAP_BYTES );
}
// ─── private ──────────────────────────────────────────────────────────
/**
* Delete rows + their files. Returns prune accounting.
*
* @param array $rows Rows to evict.
* @param string $phase Tag for logs.
* @return array {pruned:int,freed_bytes:int,ttl_pruned?:int,errors:array}
*/
private static function evict_rows( array $rows, string $phase ): array {
global $wpdb;
$table = $wpdb->prefix . TMDO_Snapshot_Manager::TABLE_SLUG;
$pruned = 0;
$freed = 0;
$errors = array();
foreach ( $rows as $row ) {
$path = (string) ( $row['file_path'] ?? '' );
if ( '' !== $path && file_exists( $path ) ) {
$ok = @unlink( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
if ( ! $ok ) {
$errors[] = "unlink failed: {$path}";
}
}
$deleted = $wpdb->delete( $table, array( 'id' => (int) $row['id'] ), array( '%d' ) ); // phpcs:ignore WordPress.DB
if ( false === $deleted ) {
$errors[] = 'db delete failed: ' . $row['snapshot_id'];
continue;
}
++$pruned;
$freed += (int) $row['size_bytes'];
}
$out = array(
'pruned' => $pruned,
'freed_bytes' => $freed,
'errors' => $errors,
);
if ( 'ttl' === $phase ) {
$out['ttl_pruned'] = $pruned;
}
return $out;
}
/**
* Evict from candidates until we've freed `$target_bytes` (or run out).
*
* @param array $candidates Sorted oldest-first.
* @param int $target_bytes Bytes to free.
* @return array {pruned:int,freed_bytes:int,errors:array}
*/
private static function evict_to_recover( array $candidates, int $target_bytes ): array {
global $wpdb;
$table = $wpdb->prefix . TMDO_Snapshot_Manager::TABLE_SLUG;
$pruned = 0;
$freed = 0;
$errors = array();
foreach ( $candidates as $row ) {
if ( $freed >= $target_bytes ) {
break;
}
$path = (string) ( $row['file_path'] ?? '' );
if ( '' !== $path && file_exists( $path ) ) {
$ok = @unlink( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
if ( ! $ok ) {
$errors[] = "unlink failed: {$path}";
}
}
$deleted = $wpdb->delete( $table, array( 'id' => (int) $row['id'] ), array( '%d' ) ); // phpcs:ignore WordPress.DB
if ( false === $deleted ) {
$errors[] = 'db delete failed: ' . $row['snapshot_id'];
continue;
}
++$pruned;
$freed += (int) $row['size_bytes'];
}
return array(
'pruned' => $pruned,
'freed_bytes' => $freed,
'errors' => $errors,
);
}
/**
* Sum all backup directory file sizes.
*
* @return int Bytes.
*/
private static function current_dir_size(): int {
$dir = TMDO_Snapshot_Manager::backup_dir();
if ( ! is_dir( $dir ) ) {
return 0;
}
$total = 0;
$it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ) );
foreach ( $it as $file ) {
if ( $file->isFile() ) {
$total += $file->getSize();
}
}
return $total;
}
}
@@ -0,0 +1,266 @@
<?php
/**
* TMDO_Snapshot_Reader — preview, verify, and apply a stored snapshot
* (v2.2.0 M1).
*
* Loading order:
* 1. Read catalog row from wp_wpdo_snapshots (passed in by manager).
* 2. If `inline_blob`, decompress in-memory.
* 3. Else read file from disk + verify sha256 + ungzip.
* 4. Parse SQL into tables and INSERT statements.
* 5. preview() returns parse summary; apply() executes statements.
*
* Restore policy:
* - DELETE FROM <table> WHERE 1=1 before re-inserting (clean slate per table).
* Caller is responsible for FSM rewind / cache flush around this call.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Snapshot reader: integrity check + apply.
*/
class TMDO_Snapshot_Reader {
/**
* Catalog row from wp_wpdo_snapshots.
*
* @var array<string,mixed>
*/
private array $row;
/**
* Constructor.
*
* @param array $row Catalog row (must contain snapshot_id, storage, file_path, file_sha256, inline_blob, size_bytes).
* @throws InvalidArgumentException When row is missing required keys.
*/
public function __construct( array $row ) {
foreach ( array( 'snapshot_id', 'storage', 'size_bytes' ) as $required ) {
if ( ! array_key_exists( $required, $row ) ) {
throw new InvalidArgumentException( "snapshot row missing key: {$required}" ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output
}
}
$this->row = $row;
}
/**
* Verify sha256 + readability.
*
* @return array {ok:bool, sha256_ok:bool, size_match:bool, error?:string}
*/
public function verify(): array {
$storage = (string) $this->row['storage'];
$expected_size = (int) $this->row['size_bytes'];
$expected_sha = (string) ( $this->row['file_sha256'] ?? '' );
if ( 'inline' === $storage ) {
$blob = (string) ( $this->row['inline_blob'] ?? '' );
$actual_size = strlen( $blob );
// inline blobs aren't sha-checked by default — just check size.
return array(
'ok' => $actual_size === $expected_size,
'sha256_ok' => true,
'size_match' => $actual_size === $expected_size,
'storage' => 'inline',
);
}
$path = (string) ( $this->row['file_path'] ?? '' );
if ( '' === $path || ! file_exists( $path ) ) {
return array(
'ok' => false,
'error' => 'file_missing',
'storage' => 'file',
);
}
if ( ! is_readable( $path ) ) {
return array(
'ok' => false,
'error' => 'file_unreadable',
'storage' => 'file',
);
}
$actual_size = (int) filesize( $path );
$actual_sha = hash_file( 'sha256', $path );
return array(
'ok' => ( $actual_size === $expected_size ) && ( $actual_sha === $expected_sha ),
'sha256_ok' => ( $actual_sha === $expected_sha ),
'size_match' => ( $actual_size === $expected_size ),
'storage' => 'file',
);
}
/**
* Parse SQL stream and return a preview of tables + row counts (no apply).
*
* @return array {tables:array<string,int>, total_rows:int, statements:int, sql_bytes:int}
* @throws RuntimeException When payload cannot be loaded.
*/
public function preview(): array {
$sql = $this->load_sql();
return $this->parse_summary( $sql );
}
/**
* Load + apply (DELETE + INSERT per table).
*
* For file-storage snapshots, sha256 is verified before execution.
* Only tables whose names pass is_safe_name() (prefix-prefixed, alphanum+_)
* are touched — arbitrary table names in the SQL file cannot be applied.
*
* @return array {tables:array<int,string>, rows_restored:int, statements_run:int}
* @throws RuntimeException When payload cannot be loaded or integrity check fails.
*/
public function apply(): array {
global $wpdb;
// Verify sha256 / size for file-storage snapshots before executing any SQL.
if ( 'inline' !== (string) ( $this->row['storage'] ?? 'file' ) ) {
$check = $this->verify();
if ( ! $check['ok'] ) {
$reason = $check['error'] ?? ( $check['sha256_ok'] ? 'size_mismatch' : 'sha256_mismatch' );
throw new RuntimeException( 'snapshot integrity check failed: ' . $reason ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output
}
}
$sql = $this->load_sql();
$summary = $this->parse_summary( $sql );
// Wipe target tables before inserting (clean slate).
foreach ( array_keys( $summary['tables'] ) as $table ) {
if ( $this->is_safe_name( $table ) ) {
$wpdb->query( "DELETE FROM `{$table}`" ); // phpcs:ignore WordPress.DB
}
}
$statements_run = 0;
$rows_restored = 0;
// Naive split on `;\n` — INSERT statements never contain unescaped semicolon-newline.
$lines = preg_split( '/;\s*\n/', $sql );
if ( ! is_array( $lines ) ) {
throw new RuntimeException( 'sql parse failed' );
}
foreach ( $lines as $stmt ) {
$stmt = trim( $stmt );
if ( '' === $stmt || str_starts_with( $stmt, '--' ) ) {
continue;
}
if ( ! preg_match( '/^(INSERT|SET)\b/i', $stmt ) ) {
continue;
}
$result = $wpdb->query( $stmt ); // phpcs:ignore WordPress.DB
if ( false === $result ) {
throw new RuntimeException( 'restore stmt failed: ' . substr( $stmt, 0, 80 ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output
}
if ( stripos( $stmt, 'INSERT' ) === 0 ) {
$rows_restored += (int) $result;
}
++$statements_run;
}
return array(
'tables' => array_keys( $summary['tables'] ),
'rows_restored' => $rows_restored,
'statements_run' => $statements_run,
);
}
// ─── private ──────────────────────────────────────────────────────────
/**
* Load + ungzip the snapshot SQL payload.
*
* @return string Raw SQL.
* @throws RuntimeException When payload is missing or unreadable.
*/
private function load_sql(): string {
$storage = (string) $this->row['storage'];
if ( 'inline' === $storage ) {
$blob = (string) ( $this->row['inline_blob'] ?? '' );
if ( '' === $blob ) {
throw new RuntimeException( 'inline_blob is empty' );
}
return $this->maybe_gunzip( $blob );
}
$path = (string) ( $this->row['file_path'] ?? '' );
if ( '' === $path || ! file_exists( $path ) ) {
throw new RuntimeException( 'snapshot file missing: ' . $path ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output
}
if ( str_ends_with( $path, '.gz' ) ) {
$content = '';
$handle = gzopen( $path, 'rb' );
if ( false === $handle ) {
throw new RuntimeException( 'gzopen failed: ' . $path ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output
}
while ( ! gzeof( $handle ) ) {
$content .= gzread( $handle, 65536 );
}
gzclose( $handle );
return $content;
}
return (string) file_get_contents( $path );
}
/**
* Decompress if first 2 bytes are gzip magic.
*
* @param string $blob Possibly-gzipped data.
* @return string Decompressed.
*/
private function maybe_gunzip( string $blob ): string {
if ( strlen( $blob ) >= 2 && "\x1f\x8b" === substr( $blob, 0, 2 ) ) {
$out = @gzdecode( $blob ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
return false === $out ? $blob : $out;
}
return $blob;
}
/**
* Walk SQL and count INSERT rows per table.
*
* @param string $sql Raw SQL.
* @return array {tables:array<string,int>, total_rows:int, statements:int, sql_bytes:int}
*/
private function parse_summary( string $sql ): array {
$tables = array();
$total = 0;
$statements = 0;
preg_match_all( '/INSERT INTO `([^`]+)` \([^)]*\) VALUES (.+?);\s*\n/s', $sql, $matches, PREG_SET_ORDER );
foreach ( $matches as $m ) {
$table = $m[1];
$body = $m[2];
// Count `(` at the start of value tuples — substring_count of `,(` + 1.
$rows = substr_count( $body, "),\n (" ) + 1;
$tables[ $table ] = ( $tables[ $table ] ?? 0 ) + $rows;
$total += $rows;
++$statements;
}
return array(
'tables' => $tables,
'total_rows' => $total,
'statements' => $statements,
'sql_bytes' => strlen( $sql ),
);
}
/**
* Match safe table names: alphanumeric + underscore, must start with $wpdb->prefix.
*
* Mirrors TMDO_Snapshot_Writer::is_safe_name() so writer and reader apply
* the same gate — an attacker-crafted SQL file cannot target tables outside
* this site's WordPress prefix.
*
* @param string $name Table name.
* @return bool
*/
private function is_safe_name( string $name ): bool {
global $wpdb;
return (bool) preg_match( '/^[a-zA-Z0-9_]+$/', $name )
&& strpos( $name, $wpdb->prefix ) === 0;
}
}
@@ -0,0 +1,331 @@
<?php
/**
* TMDO_Snapshot_Writer — produces a SQL dump of WPDO tables (v2.2.0 M1).
*
* Strategy:
* 1. Resolve which tables to dump from the scope (default = all WPDO-owned tables).
* 2. Iterate each table in 5000-row chunks; emit `INSERT INTO ... VALUES (...)`.
* 3. Stream output to a temp file with gzip; compute sha256 and final size.
* 4. If final size ≤ INLINE_THRESHOLD_BYTES, slurp into memory + delete file
* (so small snapshots travel with `wp db export`).
* 5. Otherwise leave the file under wp-content/uploads/wpdo-backups/.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Streamed dump writer with chunking + gzip + sha256.
*/
class TMDO_Snapshot_Writer {
/** Rows per SELECT chunk. */
private const CHUNK_SIZE = 5000;
/**
* Snapshot ULID assigned by manager.
*
* @var string
*/
private string $snapshot_id;
/**
* Resolved dump scope.
*
* @var array{entities:array,modules:array,tables:array}
*/
private array $scope;
/**
* Writer options (gzip, inline_threshold_bytes, max_tables).
*
* @var array<string,mixed>
*/
private array $opts;
/**
* Constructor.
*
* @param string $snapshot_id Snapshot ULID.
* @param array $scope Resolved scope.
* @param array $opts {gzip:bool, inline_threshold_bytes:int, max_tables:int}.
*/
public function __construct( string $snapshot_id, array $scope, array $opts = array() ) {
$this->snapshot_id = $snapshot_id;
$this->scope = $scope;
$this->opts = $opts;
}
/**
* Run the dump.
*
* @return array {storage,file_path,sha256,size_bytes,row_count,inline_blob}
* @throws RuntimeException When no tables resolved or filesystem unwritable.
*/
public function write(): array {
$tables = $this->resolve_tables();
if ( empty( $tables ) ) {
throw new RuntimeException( 'snapshot scope produced 0 tables' );
}
$gzip = (bool) ( $this->opts['gzip'] ?? true );
$inline_th = (int) ( $this->opts['inline_threshold_bytes'] ?? TMDO_Snapshot_Manager::INLINE_THRESHOLD_BYTES );
$dir = TMDO_Snapshot_Manager::backup_dir();
$ext = $gzip ? '.sql.gz' : '.sql';
$path = trailingslashit( $dir ) . $this->snapshot_id . $ext;
$handle = $gzip ? gzopen( $path, 'wb6' ) : fopen( $path, 'wb' );
if ( false === $handle ) {
throw new RuntimeException( 'cannot open ' . $path . ' for write' ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output
}
$write = static function ( string $chunk ) use ( $handle, $gzip ): void {
if ( $gzip ) {
gzwrite( $handle, $chunk );
} else {
fwrite( $handle, $chunk );
}
};
$row_count = 0;
$header = $this->dump_header( $tables );
$write( $header );
global $wpdb;
foreach ( $tables as $table ) {
$row_count += $this->dump_table( $table, $write );
}
$write( "\n-- end of dump\n" );
if ( $gzip ) {
gzclose( $handle );
} else {
fclose( $handle );
}
$size = (int) filesize( $path );
$sha = hash_file( 'sha256', $path );
// Decide inline vs file storage.
if ( $size <= $inline_th ) {
$blob = file_get_contents( $path );
@unlink( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
return array(
'storage' => 'inline',
'file_path' => null,
'sha256' => $sha,
'size_bytes' => $size,
'row_count' => $row_count,
'inline_blob' => $blob,
);
}
return array(
'storage' => 'file',
'file_path' => $path,
'sha256' => $sha,
'size_bytes' => $size,
'row_count' => $row_count,
'inline_blob' => null,
);
}
/**
* Returns canonical scope (after manager resolution).
*
* @return array
*/
public function get_scope(): array {
return $this->scope;
}
// ─── private ──────────────────────────────────────────────────────────
/**
* Resolve which tables to dump.
*
* Priority:
* 1. Explicit `scope.tables` if provided.
* 2. `scope.entities` → wp_postmeta / wp_usermeta / etc.
* 3. Empty scope → all WPDO-owned tables (zone + system) + wp_postmeta if classifier active.
*
* @return array<int,string> Fully-prefixed table names.
*/
private function resolve_tables(): array {
global $wpdb;
if ( ! empty( $this->scope['tables'] ) ) {
return array_values( array_filter( array_map( 'strval', $this->scope['tables'] ), array( $this, 'is_safe_name' ) ) );
}
$max = (int) ( $this->opts['max_tables'] ?? 200 );
$out = array();
// Always include WPDO system tables.
$wpdo_likes = array(
$wpdb->prefix . 'wpdo_%',
);
foreach ( $wpdo_likes as $like ) {
if ( class_exists( 'WP_SQLite_Driver' ) ) {
$rows = $wpdb->get_col(
$wpdb->prepare( // phpcs:ignore WordPress.DB
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE %s",
$like
)
);
} else {
$rows = $wpdb->get_col(
$wpdb->prepare( // phpcs:ignore WordPress.DB
'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME LIKE %s',
$like
)
);
}
if ( is_array( $rows ) ) {
foreach ( $rows as $t ) {
if ( $this->is_safe_name( (string) $t ) ) {
$out[ $t ] = true;
}
}
}
}
// Skip wp_wpdo_snapshots itself — we don't want a dump of a dump.
unset( $out[ $wpdb->prefix . 'wpdo_snapshots' ] );
// If `entities` includes 'post', also dump wp_postmeta (mainly used for
// pre_v2_upgrade trigger so we have the pre-migration meta).
if ( ! empty( $this->scope['entities'] ) ) {
$entity_to_table = array(
'post' => $wpdb->postmeta,
'user' => $wpdb->usermeta,
'term' => $wpdb->termmeta ?? ( $wpdb->prefix . 'termmeta' ),
'comment' => $wpdb->commentmeta,
);
foreach ( $this->scope['entities'] as $entity ) {
if ( isset( $entity_to_table[ $entity ] ) ) {
$t = $entity_to_table[ $entity ];
if ( $this->is_safe_name( $t ) ) {
$out[ $t ] = true;
}
}
}
}
$tables = array_keys( $out );
if ( count( $tables ) > $max ) {
$tables = array_slice( $tables, 0, $max );
}
sort( $tables );
return $tables;
}
/**
* Validate a table name (alphanumeric + underscore only, must start with $wpdb->prefix).
*
* @param string $name Table name.
* @return bool
*/
private function is_safe_name( string $name ): bool {
global $wpdb;
return (bool) preg_match( '/^[a-zA-Z0-9_]+$/', $name )
&& strpos( $name, $wpdb->prefix ) === 0;
}
/**
* Dump header (timestamp + table list).
*
* @param array $tables Tables that will be dumped.
* @return string
*/
private function dump_header( array $tables ): string {
$out = "-- WPDO snapshot {$this->snapshot_id}\n";
$out .= '-- Generated: ' . gmdate( 'Y-m-d H:i:s' ) . " UTC\n";
$out .= '-- Tables: ' . count( $tables ) . "\n";
$out .= "-- Format: SQL INSERT statements (one per row, batched by table)\n\n";
$out .= "SET NAMES utf8mb4;\n";
$out .= "SET FOREIGN_KEY_CHECKS=0;\n\n";
return $out;
}
/**
* Stream-dump one table.
*
* @param string $table Fully-prefixed table name.
* @param callable $write Stream writer.
* @return int rows dumped.
*/
private function dump_table( string $table, callable $write ): int {
global $wpdb;
$write( "-- Table {$table}\n" );
$count_total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB
if ( 0 === $count_total ) {
$write( "-- (empty)\n\n" );
return 0;
}
$columns = $wpdb->get_col( "DESC `{$table}`" ); // phpcs:ignore WordPress.DB
if ( ! is_array( $columns ) || empty( $columns ) ) {
return 0;
}
$col_list = '`' . implode( '`,`', $columns ) . '`';
$offset = 0;
$rows_dumped = 0;
while ( $offset < $count_total ) {
$chunk = $wpdb->get_results(
$wpdb->prepare( // phpcs:ignore WordPress.DB
"SELECT * FROM `{$table}` LIMIT %d OFFSET %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is a trusted table name via TMDO_DB::table()
self::CHUNK_SIZE,
$offset
),
ARRAY_A
);
if ( ! is_array( $chunk ) || empty( $chunk ) ) {
break;
}
$values = array();
foreach ( $chunk as $row ) {
$row_vals = array();
foreach ( $columns as $col ) {
$row_vals[] = $this->escape_sql_value( $row[ $col ] ?? null );
}
$values[] = '(' . implode( ',', $row_vals ) . ')';
}
$write( "INSERT INTO `{$table}` ({$col_list}) VALUES " . implode( ",\n ", $values ) . ";\n" );
$rows_dumped += count( $chunk );
$offset += self::CHUNK_SIZE;
}
$write( "\n" );
return $rows_dumped;
}
/**
* Escape a single value for SQL INSERT.
*
* @param mixed $v Value.
* @return string SQL-quoted literal.
*/
private function escape_sql_value( $v ): string {
global $wpdb;
if ( null === $v ) {
return 'NULL';
}
if ( is_bool( $v ) ) {
return $v ? '1' : '0';
}
if ( is_int( $v ) || is_float( $v ) ) {
return (string) $v;
}
// String / longtext / blob — wrap with hex notation if non-utf8 bytes.
$s = (string) $v;
// Use mb_check_encoding when available; fall back to a heuristic.
$is_utf8 = function_exists( 'mb_check_encoding' ) ? mb_check_encoding( $s, 'UTF-8' ) : ( @iconv( 'UTF-8', 'UTF-8//IGNORE', $s ) === $s ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
if ( ! $is_utf8 ) {
return '0x' . bin2hex( $s );
}
return "'" . esc_sql( $s ) . "'";
}
}