getMessage(); continue; } return array( 'available' => true, 'path' => $real, 'configured' => $configured, 'durability' => $configured ? 'configured_private_directory' : 'system_private_temporary_directory', 'note' => $configured ? __( 'Private backup directory configured by the site owner.', 'affstackdev-image-manager' ) : __( 'Using a private system temporary directory. Download completed backups because the host may purge temporary files.', 'affstackdev-image-manager' ), ); } return array( 'available' => false, 'reason' => $last_reason, 'configured' => $configured, ); } /** * What this host can do for backups: ZIP, encrypted ZIP (a working AES-256 round trip), the storage and the format. * * @return array zip, encrypted_zip, storage, format and format_version. */ public static function capabilities(): array { $zip = class_exists( '\\ZipArchive' ); $storage = self::storage_info( true ); $aes = $zip && ! empty( $storage['available'] ) && self::aes_selftest( $storage['path'] ); return array( 'zip' => $zip, 'encrypted_zip' => $aes, 'storage' => $storage, 'format' => self::FORMAT, 'format_version' => self::FORMAT_VERSION, ); } /** * Whether ZIP AES-256 works here: a random payload written encrypted to a test archive and read back with its password * (once per request). * * @param string $dir The backup folder. * @return bool True when it works. */ private static function aes_selftest( string $dir ): bool { static $result = null; if ( null !== $result ) { return $result; } if ( ! defined( 'ZipArchive::EM_AES_256' ) || ! function_exists( 'openssl_encrypt' ) || ! function_exists( 'openssl_decrypt' ) ) { return $result = false; } $path = rtrim( $dir, '/\\' ) . '/aes-selftest-' . bin2hex( random_bytes( 8 ) ) . '.zip'; $password = 'afsd-im-' . bin2hex( random_bytes( 12 ) ); $payload = random_bytes( 32 ); try { $zip = new \ZipArchive(); if ( true !== $zip->open( $path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE ) ) { return $result = false; } if ( ! $zip->addFromString( 'probe.bin', $payload ) || ! $zip->setEncryptionName( 'probe.bin', \ZipArchive::EM_AES_256, $password ) ) { $zip->close(); return $result = false; } $zip->close(); $check = new \ZipArchive(); if ( true !== $check->open( $path ) ) { return $result = false; } $check->setPassword( $password ); $read = $check->getFromName( 'probe.bin', 64 ); $check->close(); return $result = is_string( $read ) && hash_equals( $payload, $read ); } catch ( \Throwable $e ) { return $result = false; } // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. finally { @Reviewed_Store::unlink( $path ); } } /** * Save the list of backups. * * @param array $index The backups. */ private static function update_index( array $index ): void { $index = array_values( $index ); if ( false === get_option( self::INDEX_OPTION, false ) ) { Reviewed_Store::add_option( self::INDEX_OPTION, $index, '', false ); } else { Reviewed_Store::update_option( self::INDEX_OPTION, $index, false ); } } /** * The saved backups with a valid ID, newest first. * * @return array The backups. */ public static function index(): array { $raw = get_option( self::INDEX_OPTION, array() ); if ( ! is_array( $raw ) ) { return array(); } $out = array(); foreach ( $raw as $item ) { if ( ! is_array( $item ) || ! preg_match( '/^[a-f0-9]{32}$/D', (string) ( $item['id'] ?? '' ) ) ) { continue; } $out[] = $item; } usort( $out, static fn( $a, $b ) => (int) ( $b['created'] ?? 0 ) <=> (int) ( $a['created'] ?? 0 ) ); return $out; } /** * One saved backup. * * @param string $id Its ID. * @return array|null The backup, or null. */ public static function snapshot( string $id ): ?array { foreach ( self::index() as $item ) { if ( hash_equals( (string) $item['id'], $id ) ) { return $item; } } return null; } /** * The backup job, with a default for every field. * * @return array The job. */ public static function job(): array { $j = get_option( self::JOB_OPTION, array() ); if ( ! is_array( $j ) ) { $j = array(); } return array_merge( array( 'id' => '', 'active' => false, 'paused' => false, 'mode' => 'create', 'stage' => 'idle', 'actor' => 0, 'created' => 0, 'updated' => 0, 'scope' => 'all', 'scope_id' => 0, 'scope_label' => __( 'All media', 'affstackdev-image-manager' ), 'profile' => 'essential', 'encrypted' => false, 'wrapped_password' => '', 'cursor' => 0, 'max_id' => 0, 'selected' => 0, 'processed' => 0, 'skipped' => 0, 'errors' => array(), 'offset' => 0, 'archive' => '', 'items_file' => '', 'records_file' => '', 'checksums_file' => '', 'last_error' => '', ), $j ); } /** * Save the backup job with its update time. * * @param array $job The job. */ private static function save_job( array $job ): void { $job['updated'] = time(); if ( false === get_option( self::JOB_OPTION, false ) ) { Reviewed_Store::add_option( self::JOB_OPTION, $job, '', false ); } else { Reviewed_Store::update_option( self::JOB_OPTION, $job, false ); } } /** * A file name safe inside the archive (at most 180 characters; media-file when nothing is left). * * @param string $name The name. * @return string The safe name. */ private static function safe_name( string $name ): string { $name = function_exists( 'sanitize_file_name' ) ? sanitize_file_name( $name ) : preg_replace( '/[^A-Za-z0-9._-]/', '-', $name ); $name = trim( (string) $name, '.-' ); return '' !== $name ? substr( $name, 0, 180 ) : 'media-file'; } /** * A file's path under a root folder, when it is a readable file inside it with no link anywhere on the way. * * @param string $path The file. * @param string $root The root folder. * @return string|null The relative path, or null. */ private static function rel_under( string $path, string $root ): ?string { if ( is_link( $path ) || ! is_file( $path ) || ! is_readable( $path ) ) { return null; } $root_real = realpath( $root ); $real = realpath( $path ); if ( false === $root_real || false === $real ) { return null; } $root_real = rtrim( wp_normalize_path( $root_real ), '/' ); $real = wp_normalize_path( $real ); if ( ! str_starts_with( $real, $root_real . '/' ) ) { return null; } $rel = substr( $real, strlen( $root_real ) + 1 ); if ( '' === $rel || str_contains( $rel, '..' ) || preg_match( '~[:\\\\\x00-\x1f]~', $rel ) ) { return null; } $cursor = $root_real; foreach ( explode( '/', $rel ) as $piece ) { $cursor .= '/' . $piece; if ( is_link( $cursor ) ) { return null; } } return $rel; } /** * The key that protects a job's saved password: SHA-256 of the site's secret keys and salt. * * @return string The key (32 bytes). * @throws \RuntimeException With backup_encryption_unavailable when the site has no secret keys. */ private static function secret_key(): string { $parts = array(); foreach ( array( 'AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY' ) as $c ) { if ( defined( $c ) ) { $parts[] = constant( $c ); } } if ( function_exists( 'wp_salt' ) ) { $parts[] = wp_salt( 'auth' ); } if ( ! $parts ) { throw new \RuntimeException( 'backup_encryption_unavailable' ); } return hash( 'sha256', implode( '|', $parts ), true ); } /** * Encrypt a backup password for the job (AES-256-GCM with the site's key), so it is not saved in plain text. * * @param string $password The password. * @return string The encrypted password, base64. * @throws \RuntimeException With backup_encryption_unavailable when OpenSSL cannot encrypt it. */ private static function wrap_password( string $password ): string { if ( ! function_exists( 'openssl_encrypt' ) ) { throw new \RuntimeException( 'backup_encryption_unavailable' ); } $iv = random_bytes( 12 ); $tag = ''; $cipher = openssl_encrypt( $password, 'aes-256-gcm', self::secret_key(), OPENSSL_RAW_DATA, $iv, $tag, 'afsd-im-backup-job' ); if ( false === $cipher || 16 !== strlen( $tag ) ) { throw new \RuntimeException( 'backup_encryption_unavailable' ); } return base64_encode( $iv . $tag . $cipher ); } /** * The job's backup password, decrypted. * * @param string $wrapped The encrypted password. * @return string The password, or '' when there is none or OpenSSL is missing. * @throws \RuntimeException With backup_encryption_unavailable when it cannot be decrypted. */ private static function unwrap_password( string $wrapped ): string { if ( '' === $wrapped || ! function_exists( 'openssl_decrypt' ) ) { return ''; } $raw = base64_decode( $wrapped, true ); if ( false === $raw || strlen( $raw ) < 29 ) { throw new \RuntimeException( 'backup_encryption_unavailable' ); } $iv = substr( $raw, 0, 12 ); $tag = substr( $raw, 12, 16 ); $cipher = substr( $raw, 28 ); $plain = openssl_decrypt( $cipher, 'aes-256-gcm', self::secret_key(), OPENSSL_RAW_DATA, $iv, $tag, 'afsd-im-backup-job' ); if ( false === $plain ) { throw new \RuntimeException( 'backup_encryption_unavailable' ); } return $plain; } /** * Start a backup job: check the host, the profile (essential or published), the scope (all media, a folder or a * collection) and, for an encrypted backup, the password (at least 8 characters, typed twice); make the job's private * work files and the archive with its README, then schedule it. * * @param array $input The form's fields (backup_profile, backup_scope, backup_scope_id, backup_encrypt, * backup_password, backup_password_confirm). * @return array The job's status. * @throws \RuntimeException With a backup_ reason code when the job cannot start. */ public static function start( array $input ): array { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { if ( self::job()['active'] ) { throw new \RuntimeException( 'backup_job_active' ); } $cap = self::capabilities(); if ( empty( $cap['zip'] ) ) { throw new \RuntimeException( 'backup_zip_unavailable' ); } if ( empty( $cap['storage']['available'] ) ) { throw new \RuntimeException( 'backup_storage_unavailable' ); } $profile = sanitize_key( (string) ( $input['backup_profile'] ?? 'essential' ) ); if ( ! in_array( $profile, array( 'essential', 'published' ), true ) ) { throw new \RuntimeException( 'backup_invalid_profile' ); } $scope = sanitize_key( (string) ( $input['backup_scope'] ?? 'all' ) ); if ( ! in_array( $scope, array( 'all', 'folder', 'collection' ), true ) ) { throw new \RuntimeException( 'backup_invalid_scope' ); } $scope_id = absint( $input['backup_scope_id'] ?? 0 ); $scope_label = __( 'All media', 'affstackdev-image-manager' ); if ( 'folder' === $scope || 'collection' === $scope ) { $taxonomy = 'folder' === $scope ? Library::FOLDER : Library::COLLECTION; $term = $scope_id ? get_term( $scope_id, $taxonomy ) : null; if ( ! $term || is_wp_error( $term ) ) { throw new \RuntimeException( 'backup_scope_missing' ); } /* translators: %s: folder or collection name. */ $scope_label = sprintf( 'folder' === $scope ? __( 'Folder: %s', 'affstackdev-image-manager' ) : __( 'Collection: %s', 'affstackdev-image-manager' ), (string) $term->name ); } $encrypted = ! empty( $input['backup_encrypt'] ); $password = ''; if ( $encrypted ) { if ( empty( $cap['encrypted_zip'] ) ) { throw new \RuntimeException( 'backup_encryption_unavailable' ); } $password = is_string( $input['backup_password'] ?? null ) ? (string) $input['backup_password'] : ''; $again = is_string( $input['backup_password_confirm'] ?? null ) ? (string) $input['backup_password_confirm'] : ''; if ( strlen( $password ) < 8 || ! hash_equals( $password, $again ) ) { throw new \RuntimeException( 'backup_password_invalid' ); } } $id = bin2hex( random_bytes( 16 ) ); $dir = $cap['storage']['path']; global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Keyset snapshot of current attachment IDs and taxonomy membership; prepare() binds every value and table identifier in selection_ids(). $max_id = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT MAX(ID) FROM %i WHERE post_type=%s', $wpdb->posts, 'attachment' ) ); $job = array( 'id' => $id, 'active' => true, 'paused' => false, 'mode' => 'create', 'stage' => 'selecting', 'actor' => get_current_user_id(), 'created' => time(), 'scope' => $scope, 'scope_id' => $scope_id, 'scope_label' => $scope_label, 'profile' => $profile, 'encrypted' => $encrypted, 'wrapped_password' => $encrypted ? self::wrap_password( $password ) : '', 'cursor' => 0, 'max_id' => $max_id, 'selected' => 0, 'processed' => 0, 'skipped' => 0, 'errors' => array(), 'offset' => 0, 'archive' => 'snapshot-' . $id . '.part.zip', 'items_file' => 'job-' . $id . '.items', 'records_file' => 'job-' . $id . '.records.ndjson', 'checksums_file' => 'job-' . $id . '.checksums', 'last_error' => '', ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod,WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. foreach ( array( $job['items_file'], $job['records_file'], $job['checksums_file'] ) as $file ) { Reviewed_Store::file_put_contents( $dir . '/' . $file, '', LOCK_EX ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. @chmod( $dir . '/' . $file, 0600 ); } $zip = new \ZipArchive(); if ( true !== $zip->open( $dir . '/' . $job['archive'], \ZipArchive::CREATE | \ZipArchive::OVERWRITE ) ) { throw new \RuntimeException( 'backup_archive_create_failed' ); } $zip->addFromString( 'README.txt', self::readme_text( $encrypted ) ); if ( $encrypted ) { self::encrypt_entry( $zip, 'README.txt', $password ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. $zip->close(); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. @chmod( $dir . '/' . $job['archive'], 0600 ); self::save_job( $job ); Scheduler::check( false ); self::ensure(); return self::status(); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * The README.txt put in each archive. * * @param bool $encrypted Whether the archive is encrypted. * @return string The text. */ private static function readme_text( bool $encrypted ): string { return "AffStackDev Image Manager portable media backup\nFormat: " . self::FORMAT . ' v' . self::FORMAT_VERSION . "\n" . ( $encrypted ? "Archive entries use ZIP AES-256 encryption. Keep the password separately; AffStackDev does not know it.\n" : "Archive is not password encrypted. Store it appropriately.\n" ) . "manifest.json describes the media and organization. Media files remain standard files inside the archive.\n"; } /** * Encrypt one archive entry with AES-256. * * @param \ZipArchive $zip The archive. * @param string $name The entry. * @param string $password The password. * @throws \RuntimeException With backup_encryption_unavailable when it cannot be encrypted. */ private static function encrypt_entry( \ZipArchive $zip, string $name, string $password ): void { if ( ! defined( 'ZipArchive::EM_AES_256' ) || ! $zip->setEncryptionName( $name, \ZipArchive::EM_AES_256, $password ) ) { throw new \RuntimeException( 'backup_encryption_unavailable' ); } } /** * The next 200 attachment IDs of a job's selection: all attachments, or those in its folder or collection, after * its cursor and up to the highest ID it started with. * * @param array $job The job. * @return array The IDs. */ private static function selection_ids( array $job ): array { global $wpdb; if ( 'all' === $job['scope'] ) { // Keyset snapshot of the current attachment IDs; a cached list would miss new uploads. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- see the note above. return array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( 'SELECT ID FROM %i WHERE post_type=%s AND ID>%d AND ID<=%d ORDER BY ID ASC LIMIT 200', $wpdb->posts, 'attachment', (int) $job['cursor'], (int) $job['max_id'] ) ) ); } // Keyset snapshot of the current attachment IDs and taxonomy membership; a cached list would miss changes. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- see the note above. $ids = $wpdb->get_col( $wpdb->prepare( 'SELECT DISTINCT p.ID FROM %i p INNER JOIN %i tr ON tr.object_id=p.ID INNER JOIN %i tt ON tt.term_taxonomy_id=tr.term_taxonomy_id WHERE p.post_type=%s AND p.ID>%d AND p.ID<=%d AND tt.taxonomy=%s AND tt.term_id=%d ORDER BY p.ID ASC LIMIT 200', $wpdb->posts, $wpdb->term_relationships, $wpdb->term_taxonomy, 'attachment', (int) $job['cursor'], (int) $job['max_id'], 'folder' === $job['scope'] ? Library::FOLDER : Library::COLLECTION, (int) $job['scope_id'] ) ); return array_map( 'intval', (array) $ids ); } /** * One selection step: list the next 200 attachments the job's starter may edit, and move on to packaging after the * last. * * @param array $job The job. * @return array The job. * @throws \RuntimeException With backup_storage_unavailable when the list cannot be written. */ private static function select_tick( array $job ): array { global $wpdb; $ids = self::selection_ids( $job ); $dir = self::storage_info( true )['path']; $path = $dir . '/' . $job['items_file']; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. $fh = fopen( $path, 'ab' ); if ( false === $fh ) { throw new \RuntimeException( 'backup_storage_unavailable' ); } foreach ( $ids as $id ) { $job['cursor'] = max( $job['cursor'], $id ); if ( ! user_can( (int) $job['actor'], 'edit_post', $id ) ) { continue; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. Reviewed_Store::fwrite( $fh, $id . "\n" ); ++$job['selected']; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $fh ); if ( count( $ids ) < 200 ) { $job['stage'] = 'packaging'; $job['offset'] = 0; } return $job; } /** * A value as it can go into the manifest: scalars kept, strings cut at 65,535 bytes, arrays at 4,096 entries and 8 * levels deep, anything else null. * * @param mixed $value The value. * @param int $depth How deep it is. * @return mixed The value. */ private static function portable_value( $value, int $depth = 0 ) { if ( $depth > 8 ) { return null; } if ( is_null( $value ) || is_bool( $value ) || is_int( $value ) || is_float( $value ) ) { return $value; } if ( is_string( $value ) ) { return strlen( $value ) > 65535 ? substr( $value, 0, 65535 ) : $value; } if ( is_array( $value ) ) { $out = array(); $n = 0; foreach ( $value as $k => $v ) { if ( ++$n > 4096 ) { break; } $out[ is_int( $k ) ? $k : substr( (string) $k, 0, 190 ) ] = self::portable_value( $v, $depth + 1 ); } return $out; } return null; } /** * A folder's path from the top (at most 20 levels), each with its name and slug. * * @param int $term_id The folder. * @return array The path. */ private static function folder_path( int $term_id ): array { $out = array(); $seen = array(); $current = $term_id; while ( $current > 0 && count( $out ) < 20 && ! isset( $seen[ $current ] ) ) { $seen[ $current ] = true; $term = get_term( $current, Library::FOLDER ); if ( ! $term || is_wp_error( $term ) ) { break; } array_unshift( $out, array( 'name' => (string) $term->name, 'slug' => (string) $term->slug, ) ); $current = (int) $term->parent; } return $out; } /** * An attachment's folders (each as its path) and collections, by name and slug. * * @param int $id The attachment. * @return array folders and collections. */ private static function organization( int $id ): array { $folder_ids = Library::term_ids( $id, Library::FOLDER ); $folders = array(); foreach ( $folder_ids as $term_id ) { $path = self::folder_path( (int) $term_id ); if ( $path ) { $folders[] = $path; } } $collections = array(); foreach ( Library::term_ids( $id, Library::COLLECTION ) as $term_id ) { $term = get_term( (int) $term_id, Library::COLLECTION ); if ( $term && ! is_wp_error( $term ) ) { $collections[] = array( 'name' => (string) $term->name, 'slug' => (string) $term->slug, ); } } return array( 'folders' => $folders, 'collections' => $collections, ); } /** * The files a backup takes of an attachment: its original (in the uploads folder, not in Image Manager's own folder), * and for the published profile also WordPress's sizes and Image Manager's published copies (at most 128 files). * * @param int $id The attachment. * @param string $profile essential or published. * @return array One record per file: kind, path, relative path and label. * @throws \RuntimeException With backup_source_missing when the original is not a file inside the uploads folder. */ private static function attachment_files( int $id, string $profile ): array { $uploads = wp_get_upload_dir(); $root = realpath( (string) ( $uploads['basedir'] ?? '' ) ); $path = get_attached_file( $id ); if ( false === $root || ! is_string( $path ) ) { throw new \RuntimeException( 'backup_source_missing' ); } $rel = self::rel_under( $path, $root ); if ( null === $rel || str_starts_with( $rel, 'affstackdev-image-manager/' ) ) { throw new \RuntimeException( 'backup_source_missing' ); } $files = array( array( 'kind' => 'original', 'path' => realpath( $path ), 'relative' => $rel, 'archive' => '', 'label' => basename( $rel ), ), ); if ( 'published' === $profile ) { $meta = wp_get_attachment_metadata( $id, true ); if ( is_array( $meta ) && ! empty( $meta['file'] ) && is_string( $meta['file'] ) ) { $dir = dirname( $meta['file'] ); $dir = '.' === $dir ? '' : $dir . '/'; foreach ( (array) ( $meta['sizes'] ?? array() ) as $size_name => $size ) { $name = is_array( $size ) ? ( $size['file'] ?? '' ) : ''; if ( ! is_string( $name ) || basename( $name ) !== $name || str_contains( $name, '..' ) ) { continue; } $candidate = $root . '/' . $dir . $name; $candidate_rel = self::rel_under( $candidate, $root ); if ( null !== $candidate_rel ) { $files[] = array( 'kind' => 'native_size', 'path' => realpath( $candidate ), 'relative' => $candidate_rel, 'archive' => '', 'label' => (string) $size_name, ); } if ( count( $files ) >= 96 ) { break; } } } $row = Repository::attachment( $id ); if ( $row ) { $managed_root = Settings::root( false ); foreach ( self::published_variants( $row['manifest'] ) as $v ) { $name = (string) ( $v['file'] ?? '' ); if ( ! Variants::filename( $name ) ) { continue; } $candidate = $managed_root . '/' . $name; if ( is_file( $candidate ) && ! is_link( $candidate ) && is_readable( $candidate ) ) { $files[] = array( 'kind' => 'managed', 'path' => realpath( $candidate ), 'relative' => $name, 'archive' => '', 'label' => $name, ); } if ( count( $files ) >= 128 ) { break; } } } } return $files; } /** * The copies a manifest publishes, in its variants, responsive sizes and retired copies, each file once. * * @param array $manifest The manifest. * @return array The copies. */ private static function published_variants( array $manifest ): array { $out = array(); foreach ( array( 'variants', 'responsive' ) as $field ) { foreach ( (array) ( $manifest[ $field ] ?? array() ) as $v ) { if ( is_array( $v ) ) { foreach ( Variants::choices( $v ) as $c ) { $out[ $c['file'] ] = $c; } } } } foreach ( (array) ( $manifest['retired'] ?? array() ) as $v ) { if ( is_array( $v ) ) { foreach ( Variants::choices( $v ) as $c ) { $out[ $c['file'] ] = $c; } } } return array_values( $out ); } /** * The manifest record of an attachment: its portable ID, post fields, alt text, WordPress metadata, organization, edit * recipe, optimizer state and files. * * @param int $id The attachment. * @param array $file_records Its files as packaged. * @param string $profile essential or published. * @return array The record. * @throws \RuntimeException With backup_source_missing when the attachment or its original is missing. */ private static function record_for( int $id, array $file_records, string $profile ): array { $post = get_post( $id ); if ( ! $post || 'attachment' !== $post->post_type ) { throw new \RuntimeException( 'backup_source_missing' ); } $original = null; foreach ( $file_records as $f ) { if ( 'original' === $f['kind'] ) { $original = $f; break; } } if ( ! $original ) { throw new \RuntimeException( 'backup_source_missing' ); } $meta = wp_get_attachment_metadata( $id, true ); $org = self::organization( $id ); $row = Repository::attachment( $id ); $edit_recipe = get_post_meta( $id, Editor::RECIPE_META, true ); $edit_source = get_post_meta( $id, Editor::SOURCE_META, true ); $portable = hash( 'sha256', self::site_uuid() . '|' . $id . '|' . $original['sha256'] . '|' . $original['relative'] ); $record = array( 'portable_id' => $portable, 'original_attachment_id' => $id, 'mime' => (string) $post->post_mime_type, 'title' => (string) $post->post_title, 'caption' => (string) $post->post_excerpt, 'description' => (string) $post->post_content, 'parent_id' => (int) $post->post_parent, 'created_gmt' => (string) $post->post_date_gmt, 'alt' => str_starts_with( (string) $post->post_mime_type, 'image/' ) ? (string) get_post_meta( $id, '_wp_attachment_image_alt', true ) : '', 'attached_relative' => $original['relative'], 'original' => $original, 'native_metadata' => self::portable_value( is_array( $meta ) ? $meta : array() ), 'organization' => $org, 'edit' => array( 'source_attachment_id' => absint( $edit_source ), 'recipe' => is_array( $edit_recipe ) ? self::portable_value( $edit_recipe ) : array(), ), 'optimizer' => $row ? array( 'excluded' => (bool) $row['excluded'], 'delivery' => (bool) $row['delivery'], 'recipe' => self::portable_value( (array) ( $row['manifest']['recipe'] ?? array() ) ), ) : array(), 'files' => $file_records, 'profile' => $profile, ); return $record; } /** * Add one attachment to the job's archive: copy each file to the private folder and check it did not change while * copied (at most 1 GB a file and 2 GB in all), add it stored and encrypted when asked, then read every entry back and * compare its hash. * * @param array $job The job. * @param int $id The attachment. * @return array Its manifest record. * @throws \RuntimeException With a backup_ reason code when a file is too large or changed, or cannot be copied, * written or read back. * @throws \Throwable What a step inside threw, passed on after cleaning up. */ private static function package_one( array $job, int $id ): array { if ( ! user_can( (int) $job['actor'], 'edit_post', $id ) ) { throw new \RuntimeException( 'backup_actor_access_changed' ); } $storage = self::storage_info( true ); if ( empty( $storage['available'] ) ) { throw new \RuntimeException( 'backup_storage_unavailable' ); } $dir = $storage['path']; $files = self::attachment_files( $id, $job['profile'] ); $staged = array(); $total = 0; foreach ( $files as $i => $file ) { $src = $file['path']; $bytes = filesize( $src ); if ( false === $bytes || $bytes < 0 || $bytes > 1073741824 ) { throw new \RuntimeException( 'backup_source_too_large' ); } $total += (int) $bytes; if ( $total > 2147483648 ) { throw new \RuntimeException( 'backup_attachment_too_large' ); } $before = hash_file( 'sha256', $src ); $mtime = (int) filemtime( $src ); $tmp = $dir . '/stage-' . $job['id'] . '-' . bin2hex( random_bytes( 8 ) . '' ); if ( ! Reviewed_Store::copy( $src, $tmp ) ) { throw new \RuntimeException( 'backup_copy_failed' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. @chmod( $tmp, 0600 ); $copy_hash = hash_file( 'sha256', $tmp ); clearstatcache( true, $src ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. if ( ! hash_equals( $before, $copy_hash ) || ! is_file( $src ) || hash_file( 'sha256', $src ) !== $before || (int) filemtime( $src ) !== $mtime ) { @Reviewed_Store::unlink( $tmp ); throw new \RuntimeException( 'backup_source_changed' ); } $staged[] = array_merge( $file, array( 'tmp' => $tmp, 'bytes' => (int) $bytes, 'sha256' => $before, 'mtime' => $mtime, ) ); } $original_hash = ''; foreach ( $staged as $s ) { if ( 'original' === $s['kind'] ) { $original_hash = $s['sha256']; break; } } $portable = hash( 'sha256', self::site_uuid() . '|' . $id . '|' . $original_hash . '|' . ( $staged[0]['relative'] ?? '' ) ); $password = $job['encrypted'] ? self::unwrap_password( $job['wrapped_password'] ) : ''; $zip = new \ZipArchive(); $archive = $dir . '/' . $job['archive']; // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. if ( true !== $zip->open( $archive ) ) { foreach ( $staged as $s ) { @Reviewed_Store::unlink( $s['tmp'] ); } throw new \RuntimeException( 'backup_archive_open_failed' ); } $names = array(); $records = array(); try { foreach ( $staged as $s ) { $sub = 'original' === $s['kind'] ? 'original' : ( 'native_size' === $s['kind'] ? 'native-sizes' : 'managed' ); $name = 'media/' . $portable . '/' . $sub . '/' . self::safe_name( basename( $s['relative'] ) ); if ( ! $zip->addFile( $s['tmp'], $name ) ) { throw new \RuntimeException( 'backup_archive_write_failed' ); } if ( method_exists( $zip, 'setCompressionName' ) ) { $zip->setCompressionName( $name, \ZipArchive::CM_STORE ); } if ( $job['encrypted'] ) { self::encrypt_entry( $zip, $name, $password ); } $names[] = $name; $records[] = array( 'kind' => $s['kind'], 'archive_path' => $name, 'relative' => $s['relative'], 'label' => $s['label'], 'bytes' => $s['bytes'], 'sha256' => $s['sha256'], 'mtime' => $s['mtime'], ); } } catch ( \Throwable $e ) { foreach ( $names as $name ) { $zip->deleteName( $name ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. $zip->close(); foreach ( $staged as $s ) { @Reviewed_Store::unlink( $s['tmp'] ); } throw $e; } // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. $zip->close(); foreach ( $staged as $s ) { @Reviewed_Store::unlink( $s['tmp'] ); } // Read back every newly added entry. Creation is not called integrity-checked until the archive copy matches the staged hash. $check = new \ZipArchive(); if ( true !== $check->open( $archive ) ) { throw new \RuntimeException( 'backup_archive_open_failed' ); } if ( $job['encrypted'] ) { $check->setPassword( $password ); } foreach ( $records as $rec ) { $stream = $check->getStream( $rec['archive_path'] ); if ( false === $stream ) { $check->close(); throw new \RuntimeException( 'backup_verify_failed' ); } $ctx = hash_init( 'sha256' ); $read = 0; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose,WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. while ( ! feof( $stream ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. $chunk = fread( $stream, 1048576 ); if ( false === $chunk ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $stream ); $check->close(); throw new \RuntimeException( 'backup_verify_failed' ); } $read += strlen( $chunk ); hash_update( $ctx, $chunk ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $stream ); if ( $read !== (int) $rec['bytes'] || ! hash_equals( $rec['sha256'], hash_final( $ctx ) ) ) { $check->close(); throw new \RuntimeException( 'backup_verify_failed' ); } } $check->close(); return self::record_for( $id, $records, $job['profile'] ); } /** * The next attachment in the job's list, read at its offset. * * @param array $job The job. * @return array The attachment's ID, the next offset, and whether the list has ended. * @throws \RuntimeException With backup_storage_unavailable when the list cannot be read. */ private static function next_item( array $job ): array { $storage = self::storage_info( true ); $path = $storage['path'] . '/' . $job['items_file']; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. $fh = fopen( $path, 'rb' ); if ( false === $fh ) { throw new \RuntimeException( 'backup_storage_unavailable' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fseek( $fh, (int) $job['offset'] ); $line = fgets( $fh ); $next = ftell( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $fh ); if ( false === $line ) { return array( 0, $job['offset'], true ); } return array( absint( trim( $line ) ), (int) $next, false ); } /** * Add an attachment's record to the job's records file and its files' hashes to the checksums file. * * @param array $job The job. * @param array $record The record. * @throws \RuntimeException With backup_manifest_record_too_large for a record over 2 MB, or * backup_archive_write_failed. */ private static function append_record( array $job, array $record ): void { $storage = self::storage_info( true ); $line = wp_json_encode( $record, JSON_UNESCAPED_SLASHES ); if ( ! is_string( $line ) || strlen( $line ) > 2097152 ) { throw new \RuntimeException( 'backup_manifest_record_too_large' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. if ( false === Reviewed_Store::file_put_contents( $storage['path'] . '/' . $job['records_file'], $line . "\n", FILE_APPEND | LOCK_EX ) ) { throw new \RuntimeException( 'backup_archive_write_failed' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. foreach ( $record['files'] as $file ) { if ( false === Reviewed_Store::file_put_contents( $storage['path'] . '/' . $job['checksums_file'], $file['sha256'] . ' ' . $file['archive_path'] . "\n", FILE_APPEND | LOCK_EX ) ) { throw new \RuntimeException( 'backup_archive_write_failed' ); } } } /** * One packaging step: package the next attachment, or count it skipped with its reason (the first 20 kept); move on to * finishing after the last. * * @param array $job The job. * @return array The job. */ private static function package_tick( array $job ): array { [ $id, $next_offset, $eof ] = self::next_item( $job ); if ( $eof ) { $job['stage'] = 'finalizing'; return $job; } $job['offset'] = $next_offset; try { $record = self::package_one( $job, $id ); self::append_record( $job, $record ); ++$job['processed']; } catch ( \Throwable $e ) { ++$job['skipped']; $job['last_error'] = sanitize_key( $e->getMessage() ); $errors = is_array( $job['errors'] ) ? $job['errors'] : array(); if ( count( $errors ) < self::MAX_ERRORS ) { $errors[] = array( 'attachment_id' => $id, 'reason' => sanitize_key( $e->getMessage() ), ); } $job['errors'] = $errors; } return $job; } /** * The manifest's header: format, versions, times, site identity, profile, scope, counts, encryption and restore notes. * * @param array $job The job. * @return array The header. */ private static function manifest_header( array $job ): array { return array( 'format' => self::FORMAT, 'format_version' => self::FORMAT_VERSION, 'plugin_version' => VERSION, 'created_utc' => gmdate( 'c', (int) $job['created'] ), 'completed_utc' => gmdate( 'c' ), 'site_uuid' => self::site_uuid(), 'site_url_hash' => hash( 'sha256', (string) home_url( '/' ) ), 'profile' => $job['profile'], 'scope' => array( 'type' => $job['scope'], 'label' => $job['scope_label'], ), 'selected' => (int) $job['selected'], 'packaged' => (int) $job['processed'], 'skipped' => (int) $job['skipped'], 'encrypted' => (bool) $job['encrypted'], 'portable_ids_not_attachment_ids' => true, 'restore_notes' => array( 'cross_site' => 'Restore as new attachment uses portable IDs and creates new site-local attachment IDs.', 'same_site' => 'In-place restore is allowed only when the site UUID and attachment identity match and a pre-restore recovery point can be created.', 'generated_files' => 'Published profile includes current native/generated files for ownership and recovery access; automatic restore rebuilds optimizer state instead of blindly trusting old generated-file mappings.', ), ); } /** * Write the job's manifest.json in the private folder. * * @param array $job The job. * @param array $header The header. * @return string The file's path. * @throws \RuntimeException With backup_manifest_too_large when it cannot be written or is over 1 MB. */ private static function build_manifest_file( array $job, array $header ): string { $storage = self::storage_info( true ); $path = $storage['path'] . '/manifest-' . $job['id'] . '.json'; $document = array( 'header' => $header, 'records' => 'media.ndjson', ); $json = wp_json_encode( $document, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. if ( ! is_string( $json ) || strlen( $json ) > 1048576 || false === Reviewed_Store::file_put_contents( $path, $json, LOCK_EX ) ) { throw new \RuntimeException( 'backup_manifest_too_large' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. @chmod( $path, 0600 ); return $path; } /** * Finish a backup: add manifest.json, media.ndjson and checksums.sha256 to the archive (encrypted when asked), give it * its final name, keep its records and header beside it, add it to the list of backups and fire * afsd_im_backup_completed. * * @param array $job The job. * @return array The job, complete. * @throws \RuntimeException With a backup_ reason code when the archive cannot be opened, renamed or recorded. */ private static function finalize( array $job ): array { $storage = self::storage_info( true ); $dir = $storage['path']; $header = self::manifest_header( $job ); $manifest = self::build_manifest_file( $job, $header ); $password = $job['encrypted'] ? self::unwrap_password( $job['wrapped_password'] ) : ''; $zip = new \ZipArchive(); $archive = $dir . '/' . $job['archive']; // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. if ( true !== $zip->open( $archive ) ) { @Reviewed_Store::unlink( $manifest ); throw new \RuntimeException( 'backup_archive_open_failed' ); } $zip->addFile( $manifest, 'manifest.json' ); $zip->addFile( $dir . '/' . $job['records_file'], 'media.ndjson' ); $zip->addFile( $dir . '/' . $job['checksums_file'], 'checksums.sha256' ); if ( method_exists( $zip, 'setCompressionName' ) ) { $zip->setCompressionName( 'manifest.json', \ZipArchive::CM_DEFLATE ); $zip->setCompressionName( 'media.ndjson', \ZipArchive::CM_DEFLATE ); $zip->setCompressionName( 'checksums.sha256', \ZipArchive::CM_DEFLATE ); } if ( $job['encrypted'] ) { self::encrypt_entry( $zip, 'manifest.json', $password ); self::encrypt_entry( $zip, 'media.ndjson', $password ); self::encrypt_entry( $zip, 'checksums.sha256', $password ); } $zip->close(); $final = 'snapshot-' . $job['id'] . '.zip'; // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename,WordPress.WP.AlternativeFunctions.unlink_unlink -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. if ( ! Reviewed_Store::rename( $archive, $dir . '/' . $final ) ) { @Reviewed_Store::unlink( $manifest ); throw new \RuntimeException( 'backup_finalize_failed' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. @chmod( $dir . '/' . $final, 0600 ); $bytes = filesize( $dir . '/' . $final ); $hash = hash_file( 'sha256', $dir . '/' . $final ); $records_final = 'snapshot-' . $job['id'] . '.records.ndjson'; $header_final = 'snapshot-' . $job['id'] . '.header.json'; // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. if ( ! Reviewed_Store::rename( $dir . '/' . $job['records_file'], $dir . '/' . $records_final ) ) { throw new \RuntimeException( 'backup_finalize_failed' ); } $header_json = wp_json_encode( $header, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. if ( ! is_string( $header_json ) || false === Reviewed_Store::file_put_contents( $dir . '/' . $header_final, $header_json, LOCK_EX ) ) { throw new \RuntimeException( 'backup_finalize_failed' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. @chmod( $dir . '/' . $records_final, 0600 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. @chmod( $dir . '/' . $header_final, 0600 ); $records_hash = hash_file( 'sha256', $dir . '/' . $records_final ); $header_hash = hash_file( 'sha256', $dir . '/' . $header_final ); if ( ! is_string( $hash ) || ! is_string( $records_hash ) || ! is_string( $header_hash ) ) { throw new \RuntimeException( 'backup_finalize_failed' ); } $item = array( 'id' => $job['id'], 'created' => $job['created'], 'completed' => time(), 'profile' => $job['profile'], 'scope' => $job['scope'], 'scope_label' => $job['scope_label'], 'items' => $job['processed'], 'skipped' => $job['skipped'], 'encrypted' => (bool) $job['encrypted'], 'archive' => $final, 'records' => $records_final, 'header' => $header_final, 'bytes' => (int) $bytes, 'sha256' => $hash, 'records_sha256' => $records_hash, 'header_sha256' => $header_hash, 'integrity' => 'media_entries_readback_verified_archive_checksum_recorded', 'origin' => 'local', 'site_uuid' => self::site_uuid(), 'storage_durability' => $storage['durability'], 'errors' => $job['errors'], ); $index = self::index(); array_unshift( $index, $item ); self::update_index( $index ); /** * Fires after a local backup snapshot is finished, verified and added to the snapshot index. * * @since 1.7.0.4 Earliest release in the source history. * * @param array $item The snapshot's index record (id, times, profile, scope, counts, file names, sizes and checksums, * integrity, origin and site identity). */ do_action( 'afsd_im_backup_completed', $item ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. foreach ( array( $job['items_file'], $job['checksums_file'] ) as $file ) { @Reviewed_Store::unlink( $dir . '/' . $file ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. @Reviewed_Store::unlink( $manifest ); $job['active'] = false; $job['paused'] = false; $job['stage'] = 'complete'; $job['archive'] = $final; $job['wrapped_password'] = ''; $job['last_error'] = ''; return $job; } /** * One step of the backup job: select, package or finish, as its stage says; a job whose starter may no longer upload * files is paused. */ public static function tick(): void { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { $job = self::job(); if ( ! $job['active'] || $job['paused'] ) { return; } if ( ! user_can( (int) $job['actor'], 'upload_files' ) ) { $job['paused'] = true; $job['last_error'] = 'backup_actor_access_changed'; self::save_job( $job ); return; } if ( 'selecting' === $job['stage'] ) { $job = self::select_tick( $job ); } elseif ( 'packaging' === $job['stage'] ) { $job = self::package_tick( $job ); } elseif ( 'finalizing' === $job['stage'] ) { $job = self::finalize( $job ); } else { $job['active'] = false; } self::save_job( $job ); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Run backup steps for a while under the worker's lease (up to 8 steps or 7 seconds from the browser, 4 steps or 4 * seconds in the background); it waits while an image run is working, then keeps the job scheduled. * * @param string $origin browser or background. */ public static function work( string $origin = 'browser' ): void { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { $image = Workflow::snapshot(); if ( ! empty( $image['active'] ) && empty( $image['paused'] ) ) { self::ensure(); return; } $token = Repository::acquire( 'backup', 300 ); if ( ! $token ) { return; } try { $started = microtime( true ); $limit = 'background' === $origin ? 4 : 8; for ( $i = 0; $i < $limit && microtime( true ) - $started < ( 'background' === $origin ? 4.0 : 7.0 ); ++$i ) { $j = self::job(); if ( ! $j['active'] || $j['paused'] ) { break; } self::tick(); } } finally { Repository::release( $token, 'backup' ); } self::ensure(); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * The background step: run the job, never twice at once in one request. */ public static function dispatch(): void { if ( self::$dispatching ) { return; } self::$dispatching = true; try { self::work( 'background' ); } finally { self::$dispatching = false; } } /** * Keep an active, unpaused job scheduled (Action Scheduler or WP-Cron, 15 seconds on); stop the schedule otherwise. */ public static function ensure(): void { $j = self::job(); if ( ! $j['active'] || $j['paused'] ) { self::stop_schedule(); return; } try { if ( 'Action Scheduler' === Scheduler::backend() ) { if ( ! as_has_scheduled_action( self::HOOK, array(), Engine::GROUP ) ) { as_schedule_single_action( time() + 15, self::HOOK, array(), Engine::GROUP, false, 25 ); } Reviewed_Store::wp_clear_scheduled_hook( self::HOOK ); } elseif ( ! wp_next_scheduled( self::HOOK ) ) { Reviewed_Store::wp_schedule_single_event( time() + 15, self::HOOK, array(), true ); } } catch ( \Throwable $e ) { /* The visible PHP fallback remains available. */ } } /** * Remove the job's scheduled steps from WP-Cron and Action Scheduler. */ public static function stop_schedule(): void { Reviewed_Store::wp_clear_scheduled_hook( self::HOOK ); if ( 'Action Scheduler' === Scheduler::backend() && function_exists( 'as_unschedule_all_actions' ) ) { as_unschedule_all_actions( self::HOOK, array(), Engine::GROUP ); } } /** * Pause the active job and stop its schedule. */ public static function pause(): void { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { $j = self::job(); if ( $j['active'] ) { $j['paused'] = true; self::save_job( $j ); self::stop_schedule(); } } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Resume the paused job and schedule it. */ public static function resume(): void { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { $j = self::job(); if ( $j['active'] ) { $j['paused'] = false; self::save_job( $j ); self::ensure(); } } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Stop the active job: delete its unfinished archive and work files and forget its password. */ public static function cancel(): void { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { $j = self::job(); if ( ! $j['active'] ) { return; } $storage = self::storage_info( true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. if ( ! empty( $storage['available'] ) ) { foreach ( array( 'archive', 'items_file', 'records_file', 'checksums_file' ) as $key ) { if ( ! empty( $j[ $key ] ) ) { @Reviewed_Store::unlink( $storage['path'] . '/' . basename( $j[ $key ] ) ); } } @Reviewed_Store::unlink( $storage['path'] . '/manifest-' . $j['id'] . '.json' ); } $j['active'] = false; $j['paused'] = false; $j['stage'] = 'cancelled'; $j['wrapped_password'] = ''; self::save_job( $j ); self::stop_schedule(); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * The backup job's state for the screen: stage, percent, title, detail and counts, and whether the browser should help * it along. * * @return array The status. */ public static function status(): array { $j = self::job(); $selected = (int) $j['selected']; $done = (int) $j['processed'] + (int) $j['skipped']; $percent = 0; if ( 'selecting' === $j['stage'] && $j['max_id'] > 0 ) { $percent = min( 25, (int) floor( 25 * $j['cursor'] / $j['max_id'] ) ); } elseif ( 'packaging' === $j['stage'] ) { $percent = 25 + ( $selected ? min( 70, (int) floor( 70 * $done / $selected ) ) : 0 ); } elseif ( 'finalizing' === $j['stage'] ) { $percent = 98; } if ( ! $j['active'] && 'complete' === $j['stage'] ) { $percent = 100; } $title = __( 'No backup job is running', 'affstackdev-image-manager' ); $detail = __( 'Create a portable media backup when you want one.', 'affstackdev-image-manager' ); if ( $j['active'] ) { $image = Workflow::snapshot(); if ( $j['paused'] ) { $title = __( 'Backup paused', 'affstackdev-image-manager' ); $detail = __( 'Saved progress is retained. Resume when ready.', 'affstackdev-image-manager' ); } elseif ( ! empty( $image['active'] ) && empty( $image['paused'] ) ) { $title = __( 'Backup waiting for image processing', 'affstackdev-image-manager' ); $detail = __( 'Image optimization has priority so the two media-heavy jobs do not compete. Backup progress remains saved.', 'affstackdev-image-manager' ); } elseif ( 'selecting' === $j['stage'] ) { $title = __( 'Selecting media', 'affstackdev-image-manager' ); /* translators: %d: media items selected for the backup so far. */ $detail = sprintf( _n( '%d media items selected so far. New uploads after this job started will not join it.', '%d media items selected so far. New uploads after this job started will not join it.', $selected, 'affstackdev-image-manager' ), $selected ); } elseif ( 'packaging' === $j['stage'] ) { $title = __( 'Building and verifying backup', 'affstackdev-image-manager' ); /* translators: 1: items packaged or skipped; 2: items selected; 3: items skipped. */ $detail = sprintf( _n( '%1$d of %2$d selected items packaged; %3$d skipped.', '%1$d of %2$d selected items packaged; %3$d skipped.', $selected, 'affstackdev-image-manager' ), $done, $selected, (int) $j['skipped'] ); } else { $title = __( 'Finalizing backup', 'affstackdev-image-manager' ); $detail = __( 'Writing the documented manifest and final archive checksum.', 'affstackdev-image-manager' ); } } elseif ( 'complete' === $j['stage'] ) { $title = __( 'Backup complete', 'affstackdev-image-manager' ); /* translators: 1: media items packaged; 2: items skipped. */ $detail = sprintf( _n( '%1$d media items packaged; %2$d skipped. Download the archive for durable off-site possession.', '%1$d media items packaged; %2$d skipped. Download the archive for durable off-site possession.', (int) $j['processed'], 'affstackdev-image-manager' ), (int) $j['processed'], (int) $j['skipped'] ); } elseif ( 'cancelled' === $j['stage'] ) { $title = __( 'Backup stopped', 'affstackdev-image-manager' ); $detail = __( 'The incomplete archive was removed. Existing media and completed backups are unchanged.', 'affstackdev-image-manager' ); } $health = Scheduler::status( $j['active'] && ! $j['paused'] ); return array( 'active' => (bool) $j['active'], 'paused' => (bool) $j['paused'], 'stage' => $j['stage'], 'percent' => $percent, 'title' => $title, 'detail' => $detail, 'selected' => $selected, 'processed' => (int) $j['processed'], 'skipped' => (int) $j['skipped'], 'last_error' => $j['last_error'], 'assist' => $j['active'] && ! $j['paused'] && ! empty( $health['assist'] ), 'watch' => (bool) $j['active'], 'profile' => $j['profile'], 'scope_label' => $j['scope_label'], 'encrypted' => (bool) $j['encrypted'], ); } /** * The paths of a backup's archive, records and header in the private folder. * * @param array $snap The backup. * @return array archive, records and header. * @throws \RuntimeException With backup_storage_unavailable when the folder is not available. */ private static function snapshot_paths( array $snap ): array { $storage = self::storage_info( false ); if ( empty( $storage['available'] ) ) { throw new \RuntimeException( 'backup_storage_unavailable' ); } $dir = $storage['path']; return array( 'archive' => $dir . '/' . basename( (string) $snap['archive'] ), 'records' => $dir . '/' . basename( (string) $snap['records'] ), 'header' => $dir . '/' . basename( (string) $snap['header'] ), ); } /** * Delete a backup: its archive, records and header, and its entry in the list. * * @param string $id The backup's ID. * @throws \RuntimeException With backup_snapshot_missing for an unknown backup. */ public static function delete_snapshot( string $id ): void { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { $snap = self::snapshot( $id ); if ( ! $snap ) { throw new \RuntimeException( 'backup_snapshot_missing' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. $paths = self::snapshot_paths( $snap ); foreach ( $paths as $p ) { if ( is_file( $p ) && ! is_link( $p ) ) { @Reviewed_Store::unlink( $p ); } } self::update_index( array_values( array_filter( self::index(), static fn( $s ) => ! hash_equals( (string) $s['id'], $id ) ) ) ); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Send a backup's archive to the browser as a download, after checking it still has its recorded hash, then end the * request. * * @param string $id The backup's ID. * @throws \RuntimeException With backup_snapshot_missing or backup_archive_changed. */ public static function stream_download( string $id ): void { $snap = self::snapshot( $id ); if ( ! $snap ) { throw new \RuntimeException( 'backup_snapshot_missing' ); } $paths = self::snapshot_paths( $snap ); $path = $paths['archive']; if ( is_link( $path ) || ! is_file( $path ) || ! is_readable( $path ) ) { throw new \RuntimeException( 'backup_snapshot_missing' ); } clearstatcache( true, $path ); $hash = hash_file( 'sha256', $path ); if ( ! hash_equals( (string) $snap['sha256'], $hash ) ) { throw new \RuntimeException( 'backup_archive_changed' ); } nocache_headers(); header( 'Content-Type: application/zip' ); header( 'X-Content-Type-Options: nosniff' ); header( 'Content-Disposition: attachment; filename="affstackdev-media-backup-' . gmdate( 'Ymd-His', (int) $snap['created'] ) . '-' . $id . '.zip"' ); header( 'Content-Length: ' . (string) filesize( $path ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. $fh = fopen( $path, 'rb' ); if ( false === $fh ) { throw new \RuntimeException( 'backup_snapshot_missing' ); } // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped,WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. binary download. while ( ! feof( $fh ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. binary download. $chunk = fread( $fh, 1048576 ); if ( false === $chunk ) { break; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. binary download. } echo $chunk; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $fh ); exit; } /** * Return a verified local snapshot archive for customer-owned transfer/download integrations. * * @param string $id The backup's ID. * @return array id, path, bytes, sha256, created, encrypted, profile and scope_label. * @throws \RuntimeException With backup_snapshot_missing or backup_archive_changed. */ public static function archive_info( string $id ): array { $snap = self::snapshot( $id ); if ( ! $snap ) { throw new \RuntimeException( 'backup_snapshot_missing' ); } $paths = self::snapshot_paths( $snap ); $path = $paths['archive']; if ( is_link( $path ) || ! is_file( $path ) || ! is_readable( $path ) ) { throw new \RuntimeException( 'backup_snapshot_missing' ); } clearstatcache( true, $path ); $bytes = filesize( $path ); $hash = hash_file( 'sha256', $path ); if ( false === $bytes || ! is_string( $hash ) || ! hash_equals( (string) $snap['sha256'], $hash ) ) { throw new \RuntimeException( 'backup_archive_changed' ); } return array( 'id' => $id, 'path' => $path, 'bytes' => (int) $bytes, 'sha256' => $hash, 'created' => (int) $snap['created'], 'encrypted' => ! empty( $snap['encrypted'] ), 'profile' => (string) $snap['profile'], 'scope_label' => (string) $snap['scope_label'], ); } /** * Whether a backup's records or header file is there with its recorded hash. * * @param array $snap The backup. * @param string $field records or header. * @param string $hash_field The field that holds its hash. * @return bool True when it matches. */ private static function sidecar_ok( array $snap, string $field, string $hash_field ): bool { try { $paths = self::snapshot_paths( $snap ); } catch ( \Throwable $e ) { return false; } $path = $paths[ $field ] ?? ''; $expected = (string) ( $snap[ $hash_field ] ?? '' ); return '' !== $expected && is_file( $path ) && ! is_link( $path ) && is_readable( $path ) && is_string( $actual = hash_file( 'sha256', $path ) ) && hash_equals( $expected, $actual ); } /** * One page of a backup's records (only when the records file has its recorded hash). * * @param string $id The backup's ID. * @param int $page The page. * @param int $per_page Records a page. * @return array items, total and pages. */ public static function record_page( string $id, int $page = 1, int $per_page = 25 ): array { $snap = self::snapshot( $id ); if ( ! $snap || ! self::sidecar_ok( $snap, 'records', 'records_sha256' ) ) { return array( 'items' => array(), 'total' => 0, 'pages' => 1, ); } $paths = self::snapshot_paths( $snap ); if ( ! is_file( $paths['records'] ) || is_link( $paths['records'] ) ) { return array( 'items' => array(), 'total' => 0, 'pages' => 1, ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. $start = max( 0, $page - 1 ) * $per_page; $items = array(); $total = 0; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. $fh = fopen( $paths['records'], 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. if ( $fh ) { while ( ( $line = fgets( $fh ) ) !== false ) { $record = json_decode( $line, true ); if ( ! is_array( $record ) ) { continue; } if ( $total >= $start && count( $items ) < $per_page ) { $items[] = $record; } ++$total; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. } fclose( $fh ); } return array( 'items' => $items, 'total' => $total, 'pages' => max( 1, (int) ceil( $total / $per_page ) ), ); } /** * One record of a backup, by its portable ID (only when the records file has its recorded hash). * * @param string $id The backup's ID. * @param string $portable_id The record's portable ID. * @return array|null The record, or null. */ public static function record( string $id, string $portable_id ): ?array { $snap = self::snapshot( $id ); if ( ! $snap || ! self::sidecar_ok( $snap, 'records', 'records_sha256' ) ) { return null; } $paths = self::snapshot_paths( $snap ); if ( ! is_file( $paths['records'] ) || is_link( $paths['records'] ) ) { return null; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. $fh = fopen( $paths['records'], 'rb' ); if ( ! $fh ) { return null; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. while ( ( $line = fgets( $fh ) ) !== false ) { $record = json_decode( $line, true ); if ( is_array( $record ) && hash_equals( (string) ( $record['portable_id'] ?? '' ), $portable_id ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $fh ); return $record; } } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $fh ); return null; } /** * Open a backup's archive after checking its recorded hash, with its password when it is encrypted. * * @param array $snap The backup. * @param string $password The password ('' when it is not encrypted). * @return \ZipArchive The open archive. * @throws \RuntimeException With backup_snapshot_missing, backup_archive_changed, backup_archive_open_failed or * backup_password_required. */ private static function open_archive( array $snap, string $password = '' ): \ZipArchive { $paths = self::snapshot_paths( $snap ); if ( ! is_file( $paths['archive'] ) || is_link( $paths['archive'] ) || ! is_readable( $paths['archive'] ) ) { throw new \RuntimeException( 'backup_snapshot_missing' ); } $actual = hash_file( 'sha256', $paths['archive'] ); if ( ! is_string( $actual ) || ! hash_equals( (string) ( $snap['sha256'] ?? '' ), $actual ) ) { throw new \RuntimeException( 'backup_archive_changed' ); } $zip = new \ZipArchive(); if ( true !== $zip->open( $paths['archive'] ) ) { throw new \RuntimeException( 'backup_archive_open_failed' ); } if ( ! empty( $snap['encrypted'] ) ) { if ( '' === $password ) { $zip->close(); throw new \RuntimeException( 'backup_password_required' ); } $zip->setPassword( $password ); } return $zip; } /** * Extract an item's original from a backup to a private temporary file, checking its size and hash. * * @param array $snap The backup. * @param array $record The item's record. * @param string $password The password. * @return string The temporary file's path. * @throws \RuntimeException With a backup_ reason code when the entry is not valid, the password is wrong, or the file * cannot be read, written or checked. */ private static function restore_temp( array $snap, array $record, string $password ): string { $original = $record['original'] ?? array(); $entry = (string) ( $original['archive_path'] ?? '' ); if ( ! preg_match( '~^media/[a-f0-9]{64}/original/[A-Za-z0-9._-]+$~D', $entry ) ) { throw new \RuntimeException( 'backup_manifest_invalid' ); } $zip = self::open_archive( $snap, $password ); $stream = $zip->getStream( $entry ); if ( false === $stream ) { $zip->close(); throw new \RuntimeException( 'backup_password_wrong_or_file_missing' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. $storage = self::storage_info( true ); $tmp = $storage['path'] . '/restore-' . bin2hex( random_bytes( 16 ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. $out = fopen( $tmp, 'wb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. if ( false === $out ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $stream ); $zip->close(); throw new \RuntimeException( 'backup_storage_unavailable' ); } $ctx = hash_init( 'sha256' ); $bytes = 0; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose,WordPress.WP.AlternativeFunctions.unlink_unlink -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. $expected = (int) ( $original['bytes'] ?? -1 ); if ( $expected < 0 || $expected > 1073741824 ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $stream ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $out ); $zip->close(); @Reviewed_Store::unlink( $tmp ); throw new \RuntimeException( 'backup_manifest_invalid' ); } // Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and // same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose,WordPress.WP.AlternativeFunctions.file_system_operations_fread,WordPress.WP.AlternativeFunctions.file_system_operations_fwrite,WordPress.WP.AlternativeFunctions.unlink_unlink -- see the note above. while ( ! feof( $stream ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. $chunk = fread( $stream, 1048576 ); if ( false === $chunk ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $stream ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $out ); $zip->close(); @Reviewed_Store::unlink( $tmp ); throw new \RuntimeException( 'backup_restore_read_failed' ); } $bytes += strlen( $chunk ); if ( $bytes > $expected || $bytes > 1073741824 ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $stream ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $out ); $zip->close(); @Reviewed_Store::unlink( $tmp ); throw new \RuntimeException( 'backup_restore_integrity_failed' ); } hash_update( $ctx, $chunk ); if ( strlen( $chunk ) && strlen( $chunk ) !== Reviewed_Store::fwrite( $out, $chunk ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $stream ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $out ); $zip->close(); @Reviewed_Store::unlink( $tmp ); throw new \RuntimeException( 'backup_restore_write_failed' ); } } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod,WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $stream ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $out ); $zip->close(); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. @chmod( $tmp, 0600 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. if ( $bytes !== (int) ( $original['bytes'] ?? -1 ) || ! hash_equals( (string) ( $original['sha256'] ?? '' ), hash_final( $ctx ) ) ) { @Reviewed_Store::unlink( $tmp ); throw new \RuntimeException( 'backup_restore_integrity_failed' ); } return $tmp; } /** * Put a restored item in its folders (made again by path when missing) and its collections (made by name when * missing). * * @param int $attachment The restored attachment. * @param array $record Its record. */ private static function ensure_organization( int $attachment, array $record ): void { $org = is_array( $record['organization'] ?? null ) ? $record['organization'] : array(); $folders = array(); foreach ( (array) ( $org['folders'] ?? array() ) as $path ) { if ( ! is_array( $path ) ) { continue; } $parent = 0; $last = 0; foreach ( $path as $piece ) { if ( ! is_array( $piece ) || empty( $piece['name'] ) ) { continue; } $name = sanitize_text_field( (string) $piece['name'] ); $existing = term_exists( $name, Library::FOLDER, $parent ); if ( is_array( $existing ) ) { $last = (int) $existing['term_id']; } elseif ( is_int( $existing ) ) { $last = $existing; } else { $created = Reviewed_Store::wp_insert_term( $name, Library::FOLDER, $parent ? array( 'parent' => $parent ) : array() ); if ( is_wp_error( $created ) ) { break; } $last = (int) $created['term_id']; } $parent = $last; } if ( $last ) { $folders[] = $last; } } Reviewed_Store::wp_set_object_terms( $attachment, array_values( array_unique( $folders ) ), Library::FOLDER, false ); $collections = array(); foreach ( (array) ( $org['collections'] ?? array() ) as $piece ) { if ( ! is_array( $piece ) || empty( $piece['name'] ) ) { continue; } $name = sanitize_text_field( (string) $piece['name'] ); $existing = term_exists( $name, Library::COLLECTION ); if ( is_array( $existing ) ) { $collections[] = (int) $existing['term_id']; } elseif ( is_int( $existing ) ) { $collections[] = $existing; } else { $created = Reviewed_Store::wp_insert_term( $name, Library::COLLECTION ); if ( ! is_wp_error( $created ) ) { $collections[] = (int) $created['term_id']; } } } Reviewed_Store::wp_set_object_terms( $attachment, array_values( array_unique( $collections ) ), Library::COLLECTION, false ); } /** * Give a restored item its title, caption, description and alt text, WordPress's metadata made again from the file, * its organization and its edit recipe. * * @param int $attachment The restored attachment. * @param array $record Its record. * @param string $file The restored file. */ private static function restore_metadata( int $attachment, array $record, string $file ): void { $post = array( 'ID' => $attachment, 'post_title' => sanitize_text_field( (string) ( $record['title'] ?? '' ) ), 'post_excerpt' => sanitize_textarea_field( (string) ( $record['caption'] ?? '' ) ), 'post_content' => wp_kses_post( (string) ( $record['description'] ?? '' ) ), ); Reviewed_Store::wp_update_post( wp_slash( $post ) ); if ( str_starts_with( (string) get_post_mime_type( $attachment ), 'image/' ) ) { Reviewed_Store::update_post_meta( $attachment, '_wp_attachment_image_alt', sanitize_text_field( (string) ( $record['alt'] ?? '' ) ) ); } if ( ! function_exists( 'wp_generate_attachment_metadata' ) && is_file( ABSPATH . 'wp-admin/includes/image.php' ) ) { require_once ABSPATH . 'wp-admin/includes/image.php'; } if ( function_exists( 'wp_generate_attachment_metadata' ) ) { $meta = Reviewed_Store::wp_generate_attachment_metadata( $attachment, $file ); if ( is_array( $meta ) ) { Reviewed_Store::wp_update_attachment_metadata( $attachment, $meta ); } } self::ensure_organization( $attachment, $record ); if ( ! empty( $record['edit']['recipe'] ) && is_array( $record['edit']['recipe'] ) ) { Reviewed_Store::update_post_meta( $attachment, Editor::RECIPE_META, $record['edit']['recipe'] ); } } /** * Check a restored file's type: the record's type must be the one WordPress allows for the file name and, for an * image, the one its content shows. * * @param array $record The item's record. * @param string $tmp The extracted file. * @param string $name The file name. * @return string The type. * @throws \RuntimeException With backup_restore_mime_mismatch when they differ. */ private static function validate_restore_type( array $record, string $tmp, string $name ): string { $declared = (string) ( $record['mime'] ?? '' ); if ( '' === $declared || ! function_exists( 'wp_check_filetype' ) ) { throw new \RuntimeException( 'backup_restore_mime_mismatch' ); } $allowed = function_exists( 'get_allowed_mime_types' ) ? get_allowed_mime_types() : null; $check = wp_check_filetype( $name, $allowed ); $type = is_array( $check ) ? (string) ( $check['type'] ?? '' ) : ''; if ( '' === $type || ! hash_equals( $declared, $type ) ) { throw new \RuntimeException( 'backup_restore_mime_mismatch' ); } if ( function_exists( 'wp_check_filetype_and_ext' ) && str_starts_with( $declared, 'image/' ) ) { $real = wp_check_filetype_and_ext( $tmp, $name, $allowed ); if ( is_array( $real ) && ! empty( $real['type'] ) && ! hash_equals( $declared, (string) $real['type'] ) ) { throw new \RuntimeException( 'backup_restore_mime_mismatch' ); } } return $declared; } /** * Restore an item as a new attachment in the current uploads folder, with its details, organization and optimizer * settings. * * @param array $snap The backup. * @param array $record The item's record. * @param string $tmp The extracted file. * @return int The new attachment's ID. * @throws \RuntimeException With a backup_restore_ reason code when the file cannot be written or the attachment * cannot be made. */ private static function restore_new( array $snap, array $record, string $tmp ): int { $uploads = wp_upload_dir(); if ( ! empty( $uploads['error'] ) || empty( $uploads['path'] ) ) { throw new \RuntimeException( 'backup_restore_destination_unavailable' ); } $name = self::safe_name( basename( (string) ( $record['attached_relative'] ?? 'restored-media' ) ) ); $mime = self::validate_restore_type( $record, $tmp, $name ); $name = wp_unique_filename( $uploads['path'], $name ); $dest = trailingslashit( $uploads['path'] ) . $name; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. if ( ! Reviewed_Store::copy( $tmp, $dest ) ) { throw new \RuntimeException( 'backup_restore_write_failed' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. } @chmod( $dest, 0644 ); $post = array( 'post_mime_type' => $mime, 'post_title' => sanitize_text_field( (string) ( $record['title'] ?? pathinfo( $name, PATHINFO_FILENAME ) ) ), 'post_excerpt' => sanitize_textarea_field( (string) ( $record['caption'] ?? '' ) ), 'post_content' => wp_kses_post( (string) ( $record['description'] ?? '' ) ), 'post_status' => 'inherit', ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. $id = Reviewed_Store::wp_insert_attachment( wp_slash( $post ), $dest, 0, true ); if ( is_wp_error( $id ) || ! $id ) { @Reviewed_Store::unlink( $dest ); throw new \RuntimeException( 'backup_restore_attachment_failed' ); } self::restore_metadata( (int) $id, $record, $dest ); Reviewed_Store::update_post_meta( (int) $id, '_afsd_im_restored_portable_id', (string) $record['portable_id'] ); $row = str_starts_with( (string) get_post_mime_type( (int) $id ), 'image/' ) ? Repository::discover( (int) $id, 5, array( 'surface' => 'backup_restore', 'mapping' => 'restored_new_attachment', ), 'backlog' ) : null; if ( $row && ! empty( $record['optimizer']['recipe'] ) && is_array( $record['optimizer']['recipe'] ) ) { $m = $row['manifest']; $m['recipe'] = Policy::clean( $record['optimizer']['recipe'] ); Repository::update( (int) $row['id'], array( 'manifest' => $m, 'excluded' => ! empty( $record['optimizer']['excluded'] ) ? 1 : 0, 'delivery' => ! empty( $record['optimizer']['delivery'] ) ? 1 : 0, ) ); } return (int) $id; } /** * Make a recovery point of an attachment's original before it is replaced (the attachment checked to be unchanged * meanwhile) and open it again to check it. * * @param int $attachment The attachment. * @return string The recovery point's ID. * @throws \RuntimeException With backup_safety_snapshot_unavailable when it cannot be made. */ public static function safety_snapshot( int $attachment ): string { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { Media_Actions::authorize( $attachment ); $before = Media_Actions::source( $attachment ); $id = self::single_safety_snapshot( $attachment, 'replacement' ); Media_Actions::unchanged( $attachment, $before ); $snapshot = self::snapshot( $id ); if ( ! $snapshot ) { throw new \RuntimeException( 'backup_safety_snapshot_unavailable' ); } // Verify the completed archive again, including its finalized index data. $zip = self::open_archive( $snapshot ); $zip->close(); return $id; } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Make a one-item recovery point of an attachment's original (unencrypted, read back and checked) and add it to the * list of backups. * * @param int $attachment The attachment. * @param string $purpose restore or replacement (for its label). * @return string The recovery point's ID. * @throws \RuntimeException With backup_safety_snapshot_unavailable when it cannot be made or checked. */ private static function single_safety_snapshot( int $attachment, string $purpose = 'restore' ): string { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { $label = 'replacement' === $purpose ? __( 'Automatic pre-replacement recovery point', 'affstackdev-image-manager' ) : __( 'Automatic pre-restore recovery point', 'affstackdev-image-manager' ); $storage = self::storage_info( true ); if ( empty( $storage['available'] ) || ! class_exists( '\\ZipArchive' ) ) { throw new \RuntimeException( 'backup_safety_snapshot_unavailable' ); } $id = bin2hex( random_bytes( 16 ) ); $path = $storage['path'] . '/snapshot-' . $id . '.zip'; $files = self::attachment_files( $attachment, 'essential' ); $src = $files[0]['path']; $hash = hash_file( 'sha256', $src ); $portable = hash( 'sha256', self::site_uuid() . '|' . $attachment . '|' . $hash . '|' . $files[0]['relative'] ); $zip = new \ZipArchive(); if ( true !== $zip->open( $path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE ) ) { throw new \RuntimeException( 'backup_safety_snapshot_unavailable' ); } $name = 'media/' . $portable . '/original/' . self::safe_name( basename( $files[0]['relative'] ) ); $zip->addFile( $src, $name ); if ( method_exists( $zip, 'setCompressionName' ) ) { $zip->setCompressionName( $name, \ZipArchive::CM_STORE ); } $zip->addFromString( 'README.txt', self::readme_text( false ) ); $zip->close(); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. $verify = new \ZipArchive(); if ( true !== $verify->open( $path ) ) { @Reviewed_Store::unlink( $path ); throw new \RuntimeException( 'backup_safety_snapshot_unavailable' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. $stream = $verify->getStream( $name ); if ( false === $stream ) { $verify->close(); @Reviewed_Store::unlink( $path ); throw new \RuntimeException( 'backup_safety_snapshot_unavailable' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose,WordPress.WP.AlternativeFunctions.file_system_operations_fread,WordPress.WP.AlternativeFunctions.unlink_unlink -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. $ctx = hash_init( 'sha256' ); $read = 0; while ( ! feof( $stream ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. $chunk = fread( $stream, 1048576 ); if ( false === $chunk ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $stream ); $verify->close(); @Reviewed_Store::unlink( $path ); throw new \RuntimeException( 'backup_safety_snapshot_unavailable' ); } $read += strlen( $chunk ); hash_update( $ctx, $chunk ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose,WordPress.WP.AlternativeFunctions.unlink_unlink -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $stream ); $verify->close(); if ( $read !== (int) filesize( $src ) || ! hash_equals( $hash, hash_final( $ctx ) ) ) { @Reviewed_Store::unlink( $path ); throw new \RuntimeException( 'backup_safety_snapshot_unavailable' ); } $rec = self::record_for( $attachment, array( array( 'kind' => 'original', 'archive_path' => $name, 'relative' => $files[0]['relative'], 'label' => basename( $files[0]['relative'] ), 'bytes' => (int) filesize( $src ), 'sha256' => $hash, 'mtime' => (int) filemtime( $src ), ), ), 'essential' ); $header = array( 'format' => self::FORMAT, 'format_version' => self::FORMAT_VERSION, 'plugin_version' => VERSION, 'created_utc' => gmdate( 'c' ), 'completed_utc' => gmdate( 'c' ), 'site_uuid' => self::site_uuid(), 'profile' => 'essential', 'scope' => array( 'type' => 'safety', 'label' => $label, ), 'selected' => 1, 'packaged' => 1, 'skipped' => 0, 'encrypted' => false, ); $zip = new \ZipArchive(); $zip->open( $path ); $zip->addFromString( 'manifest.json', wp_json_encode( array( 'header' => $header, 'records' => 'media.ndjson', ), JSON_UNESCAPED_SLASHES ) ); $zip->addFromString( 'media.ndjson', wp_json_encode( $rec, JSON_UNESCAPED_SLASHES ) . "\n" ); $zip->addFromString( 'checksums.sha256', $hash . ' ' . $name . "\n" ); $zip->close(); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. $records = 'snapshot-' . $id . '.records.ndjson'; $headerf = 'snapshot-' . $id . '.header.json'; Reviewed_Store::file_put_contents( $storage['path'] . '/' . $records, wp_json_encode( $rec, JSON_UNESCAPED_SLASHES ) . "\n", LOCK_EX ); Reviewed_Store::file_put_contents( $storage['path'] . '/' . $headerf, wp_json_encode( $header, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ), LOCK_EX ); $item = array( 'id' => $id, 'created' => time(), 'completed' => time(), 'profile' => 'essential', 'scope' => 'safety', 'scope_label' => sprintf( /* translators: 1: "Automatic pre-replacement recovery point" or "Automatic pre-restore recovery point"; 2: attachment ID. */__( '%1$s for attachment #%2$d', 'affstackdev-image-manager' ), $label, $attachment ), 'items' => 1, 'skipped' => 0, 'encrypted' => false, 'archive' => basename( $path ), 'records' => $records, 'header' => $headerf, 'bytes' => (int) filesize( $path ), 'sha256' => hash_file( 'sha256', $path ), 'records_sha256' => hash_file( 'sha256', $storage['path'] . '/' . $records ), 'header_sha256' => hash_file( 'sha256', $storage['path'] . '/' . $headerf ), 'integrity' => 'media_entries_readback_verified_archive_checksum_recorded', 'origin' => 'safety', 'site_uuid' => self::site_uuid(), 'storage_durability' => $storage['durability'], 'errors' => array(), ); $index = self::index(); array_unshift( $index, $item ); self::update_index( $index ); return $id; } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Restore an item over its original attachment, for a backup made on this site: check the type, make a recovery point, * swap the file in, restore its details and optimizer settings, and stop delivering its old copies. * * @param array $snap The backup. * @param array $record The item's record. * @param string $tmp The extracted file. * @return int The attachment's ID. * @throws \RuntimeException With a backup_ reason code when the backup is from another site, the attachment is missing * or of another type, or the file cannot be swapped. */ private static function restore_in_place( array $snap, array $record, string $tmp ): int { if ( empty( $snap['site_uuid'] ) || ! hash_equals( self::site_uuid(), (string) $snap['site_uuid'] ) ) { throw new \RuntimeException( 'backup_cross_site_in_place_forbidden' ); } $id = absint( $record['original_attachment_id'] ?? 0 ); if ( ! $id || 'attachment' !== get_post_type( $id ) || ! current_user_can( 'edit_post', $id ) ) { throw new \RuntimeException( 'backup_restore_target_missing' ); } $current = get_attached_file( $id ); if ( ! is_string( $current ) || ! is_file( $current ) || is_link( $current ) ) { throw new \RuntimeException( 'backup_restore_target_missing' ); } $declared = (string) get_post_mime_type( $id ); if ( '' === $declared || ! hash_equals( $declared, (string) ( $record['mime'] ?? '' ) ) ) { throw new \RuntimeException( 'backup_restore_mime_mismatch' ); } self::validate_restore_type( $record, $tmp, basename( $current ) ); self::single_safety_snapshot( $id ); $hold = $current . '.afsd-pre-' . bin2hex( random_bytes( 6 ) ); $new = $current . '.afsd-restore-' . bin2hex( random_bytes( 6 ) ); if ( ! Reviewed_Store::copy( $tmp, $new ) ) { throw new \RuntimeException( 'backup_restore_write_failed' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename,WordPress.WP.AlternativeFunctions.unlink_unlink -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. if ( ! Reviewed_Store::rename( $current, $hold ) ) { @Reviewed_Store::unlink( $new ); throw new \RuntimeException( 'backup_restore_write_failed' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename,WordPress.WP.AlternativeFunctions.unlink_unlink -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. if ( ! Reviewed_Store::rename( $new, $current ) ) { @Reviewed_Store::rename( $hold, $current ); @Reviewed_Store::unlink( $new ); throw new \RuntimeException( 'backup_restore_write_failed' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. @Reviewed_Store::unlink( $hold ); self::restore_metadata( $id, $record, $current ); if ( str_starts_with( (string) get_post_mime_type( $id ), 'image/' ) ) { Engine::invalidate_attachment( $id, false ); } $row = Repository::attachment( $id ); if ( $row && ! empty( $record['optimizer']['recipe'] ) && is_array( $record['optimizer']['recipe'] ) ) { $m = $row['manifest']; $m['recipe'] = Policy::clean( $record['optimizer']['recipe'] ); Repository::update( (int) $row['id'], array( 'manifest' => $m, 'excluded' => ! empty( $record['optimizer']['excluded'] ) ? 1 : 0, 'delivery' => ! empty( $record['optimizer']['delivery'] ) ? 1 : 0, ) ); } return $id; } /** * Restore one item of a backup, as new media or in place, once the restore is confirmed. * * @param string $snapshot_id The backup's ID. * @param string $portable_id The item's portable ID. * @param string $mode new or in_place. * @param string $password The backup's password ('' when it is not encrypted). * @param bool $confirm Whether the restore was confirmed. * @return int The restored attachment's ID. * @throws \RuntimeException With a backup_ reason code when the item cannot be restored. */ public static function restore( string $snapshot_id, string $portable_id, string $mode, string $password, bool $confirm ): int { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { if ( ! $confirm ) { throw new \RuntimeException( 'backup_restore_confirmation_required' ); } $snap = self::snapshot( $snapshot_id ); if ( ! $snap ) { throw new \RuntimeException( 'backup_snapshot_missing' ); } $record = self::record( $snapshot_id, $portable_id ); if ( ! $record ) { throw new \RuntimeException( 'backup_record_missing' ); } if ( ! in_array( $mode, array( 'new', 'in_place' ), true ) ) { throw new \RuntimeException( 'backup_restore_mode_invalid' ); } $tmp = self::restore_temp( $snap, $record, $password ); try { return 'in_place' === $mode ? self::restore_in_place( $snap, $record, $tmp ) : self::restore_new( $snap, $record, $tmp ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. finally { @Reviewed_Store::unlink( $tmp ); } } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Add an uploaded backup archive (at most the upload size limit) to the list of backups, after checking its manifest * and records. * * @param array $file The upload's $_FILES entry. * @param string $password Its password ('' when it is not encrypted). * @return array The backup's list entry. * @throws \RuntimeException With backup_storage_unavailable or backup_import_upload_failed. */ public static function import_uploaded( array $file, string $password = '' ): array { $storage = self::storage_info( true ); if ( empty( $storage['available'] ) || ! class_exists( '\\ZipArchive' ) ) { throw new \RuntimeException( 'backup_storage_unavailable' ); } if ( (int) ( $file['error'] ?? UPLOAD_ERR_NO_FILE ) !== UPLOAD_ERR_OK || empty( $file['tmp_name'] ) || ! is_string( $file['tmp_name'] ) || ! is_uploaded_file( $file['tmp_name'] ) ) { throw new \RuntimeException( 'backup_import_upload_failed' ); } $bytes = filesize( $file['tmp_name'] ); if ( false === $bytes || $bytes < 1 || $bytes > max( 1048576, (int) wp_max_upload_size() ) ) { throw new \RuntimeException( 'backup_import_upload_failed' ); } $id = bin2hex( random_bytes( 16 ) ); // WordPress's uploader moves the archive into the private backup folder (owner-only), never under uploads; its // records and checksums are checked next, and the uploaded archive is never published. try { $dest = Private_Storage::receive_upload( $file, $storage['path'], 'snapshot-' . $id . '.zip', array( 'zip' => 'application/zip' ) ); } catch ( \RuntimeException $e ) { throw new \RuntimeException( 'backup_import_upload_failed' ); } return self::register_imported_archive( $dest, $id, $password, 'import' ); } /** * Register a trusted private local archive, e.g. one just downloaded from a configured customer-owned destination. * * @param string $source The archive's path. * @param string $password Its password ('' when it is not encrypted). * @param string $origin Where it came from. * @return array The backup's list entry. * @throws \RuntimeException With backup_storage_unavailable, or backup_import_upload_failed when it cannot be copied * whole (at most 1 GB). * @throws \Throwable What a step inside threw, passed on after cleaning up. */ public static function import_file_path( string $source, string $password = '', string $origin = 'remote' ): array { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { $storage = self::storage_info( true ); if ( empty( $storage['available'] ) || ! class_exists( '\\ZipArchive' ) ) { throw new \RuntimeException( 'backup_storage_unavailable' ); } if ( is_link( $source ) || ! is_file( $source ) || ! is_readable( $source ) ) { throw new \RuntimeException( 'backup_import_upload_failed' ); } clearstatcache( true, $source ); $bytes = filesize( $source ); if ( false === $bytes || $bytes < 1 || $bytes > 1073741824 ) { throw new \RuntimeException( 'backup_import_upload_failed' ); } $id = bin2hex( random_bytes( 16 ) ); $dest = $storage['path'] . '/snapshot-' . $id . '.zip'; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. $in = fopen( $source, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. $out = fopen( $dest, 'xb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose,WordPress.WP.AlternativeFunctions.unlink_unlink -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. if ( false === $in || false === $out ) { if ( $in ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $in ); } if ( $out ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $out ); } @Reviewed_Store::unlink( $dest ); throw new \RuntimeException( 'backup_storage_unavailable' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread,WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. $written = 0; try { while ( ! feof( $in ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. $chunk = fread( $in, 1048576 ); if ( false === $chunk ) { throw new \RuntimeException( 'backup_import_upload_failed' ); } if ( '' !== $chunk ) { $n = Reviewed_Store::fwrite( $out, $chunk ); if ( $n !== strlen( $chunk ) ) { throw new \RuntimeException( 'backup_import_upload_failed' ); }$written += $n; if ( $written > $bytes ) { throw new \RuntimeException( 'backup_import_upload_failed' ); } } } } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose,WordPress.WP.AlternativeFunctions.unlink_unlink -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. catch ( \Throwable $e ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $in ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $out ); @Reviewed_Store::unlink( $dest ); throw $e; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod,WordPress.WP.AlternativeFunctions.file_system_operations_fclose,WordPress.WP.AlternativeFunctions.unlink_unlink -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $in ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. @chmod( $dest, 0600 ); if ( $written !== (int) $bytes ) { @Reviewed_Store::unlink( $dest ); throw new \RuntimeException( 'backup_import_upload_failed' ); } return self::register_imported_archive( $dest, $id, $password, sanitize_key( $origin ) ?: 'remote' ); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Check an imported archive and add it to the list of backups: its manifest's format and counts, and each record (at * most 100,000 records of 2 MB, 256 MB in all, as many as the manifest says were packaged); the files themselves are * checked when they are restored. On a failure the archive and its work files are deleted. * * @param string $dest The archive in the private folder. * @param string $id Its new ID. * @param string $password Its password ('' when it is not encrypted). * @param string $origin Where it came from (import, remote, ...). * @return array The backup's list entry. * @throws \RuntimeException With a backup_ reason code when the archive is not a valid backup or the password is * wrong. * @throws \Throwable What a step inside threw, passed on after cleaning up. */ private static function register_imported_archive( string $dest, string $id, string $password, string $origin ): array { $storage = self::storage_info( true ); $records = 'snapshot-' . $id . '.records.ndjson'; $headerf = 'snapshot-' . $id . '.header.json'; try { $zip = new \ZipArchive(); if ( true !== $zip->open( $dest ) ) { throw new \RuntimeException( 'backup_archive_open_failed' ); } if ( '' !== $password ) { $zip->setPassword( $password ); } if ( $zip->numFiles > self::MAX_ARCHIVE_ENTRIES ) { $zip->close(); throw new \RuntimeException( 'backup_manifest_invalid' ); } $manifest = $zip->getFromName( 'manifest.json', 1048576 ); if ( false === $manifest ) { $zip->close(); throw new \RuntimeException( 'backup_password_required_or_wrong' ); } $data = json_decode( $manifest, true ); if ( ! is_array( $data ) || ! is_array( $data['header'] ?? null ) || self::FORMAT !== ( $data['header']['format'] ?? '' ) || self::FORMAT_VERSION !== (int) ( $data['header']['format_version'] ?? 0 ) || 'media.ndjson' !== ( $data['records'] ?? '' ) ) { $zip->close(); throw new \RuntimeException( 'backup_manifest_invalid' ); } $header = $data['header']; $profile = sanitize_key( (string) ( $header['profile'] ?? '' ) ); $declared_selected = (int) ( $header['selected'] ?? -1 ); $declared_packaged = (int) ( $header['packaged'] ?? -1 ); $declared_skipped = (int) ( $header['skipped'] ?? -1 ); if ( ! in_array( $profile, array( 'essential', 'published' ), true ) || $declared_selected < 0 || $declared_packaged < 0 || $declared_skipped < 0 || $declared_packaged > self::MAX_ENTRIES || $declared_selected > self::MAX_ENTRIES || $declared_packaged + $declared_skipped > $declared_selected ) { $zip->close(); throw new \RuntimeException( 'backup_manifest_invalid' ); } $stream = $zip->getStream( 'media.ndjson' ); if ( false === $stream ) { $zip->close(); throw new \RuntimeException( 'backup_password_required_or_wrong' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose,WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. $rf = fopen( $storage['path'] . '/' . $records, 'wb' ); if ( false === $rf ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $stream ); $zip->close(); throw new \RuntimeException( 'backup_storage_unavailable' ); } $count = 0; $record_bytes = 0; while ( ( $line = fgets( $stream, 2097153 ) ) !== false ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. if ( strlen( $line ) > 2097152 || ++$count > self::MAX_ENTRIES ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $rf ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $stream ); $zip->close(); throw new \RuntimeException( 'backup_manifest_invalid' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. $record_bytes += strlen( $line ); if ( $record_bytes > 268435456 ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $rf ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $stream ); $zip->close(); throw new \RuntimeException( 'backup_manifest_invalid' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. $record = json_decode( trim( $line ), true ); if ( ! self::valid_record( $record ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $rf ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $stream ); $zip->close(); throw new \RuntimeException( 'backup_manifest_invalid' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. Reviewed_Store::fwrite( $rf, wp_json_encode( $record, JSON_UNESCAPED_SLASHES ) . "\n" ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $rf ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. fclose( $stream ); $zip->close(); if ( $count !== $declared_packaged ) { throw new \RuntimeException( 'backup_manifest_invalid' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. $header_json = wp_json_encode( $header, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); if ( ! is_string( $header_json ) || false === Reviewed_Store::file_put_contents( $storage['path'] . '/' . $headerf, $header_json, LOCK_EX ) ) { throw new \RuntimeException( 'backup_storage_unavailable' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. @chmod( $storage['path'] . '/' . $records, 0600 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. @chmod( $storage['path'] . '/' . $headerf, 0600 ); $snap = array( 'id' => $id, 'created' => time(), 'completed' => time(), 'profile' => $profile, 'scope' => 'import', 'scope_label' => __( 'Imported portable backup', 'affstackdev-image-manager' ), 'items' => $count, 'skipped' => $declared_skipped, 'encrypted' => (bool) ( $header['encrypted'] ?? false ), 'archive' => basename( $dest ), 'records' => $records, 'header' => $headerf, 'bytes' => (int) filesize( $dest ), 'sha256' => hash_file( 'sha256', $dest ), 'records_sha256' => hash_file( 'sha256', $storage['path'] . '/' . $records ), 'header_sha256' => hash_file( 'sha256', $storage['path'] . '/' . $headerf ), 'integrity' => 'manifest_opened_selected_files_verified_on_restore', 'origin' => $origin, 'site_uuid' => (string) ( $header['site_uuid'] ?? '' ), 'storage_durability' => $storage['durability'], 'errors' => array(), ); $index = self::index(); array_unshift( $index, $snap ); self::update_index( $index ); return $snap; // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Validated private snapshot/staging files use bounded binary/record streams, restrictive modes and same-filesystem atomic publication; remote filesystem transports cannot preserve these semantics. } catch ( \Throwable $e ) { @Reviewed_Store::unlink( $dest ); @Reviewed_Store::unlink( $storage['path'] . '/' . $records ); @Reviewed_Store::unlink( $storage['path'] . '/' . $headerf ); throw $e; } } /** * Whether an imported record has what a restore needs: a portable ID, its original's entry, hash and size (at most 1 * GB), its type and a safe file path. * * @param mixed $record The record. * @return bool True when it does. */ private static function valid_record( $record ): bool { if ( ! is_array( $record ) || ! preg_match( '/^[a-f0-9]{64}$/D', (string) ( $record['portable_id'] ?? '' ) ) || ! is_array( $record['original'] ?? null ) ) { return false; } $original = $record['original']; $bytes = (int) ( $original['bytes'] ?? -1 ); $mime = (string) ( $record['mime'] ?? '' ); $relative = (string) ( $record['attached_relative'] ?? '' ); return preg_match( '~^media/[a-f0-9]{64}/original/[A-Za-z0-9._-]+$~D', (string) ( $original['archive_path'] ?? '' ) ) && preg_match( '/^[a-f0-9]{64}$/D', (string) ( $original['sha256'] ?? '' ) ) && $bytes >= 0 && $bytes <= 1073741824 && '' !== $mime && '' !== $relative && ! str_contains( $relative, '..' ) && ! str_contains( $relative, "\0" ); } /** * The words for a backup reason code. * * @param string $code The reason code. * @return string The translated words. */ public static function human_error( string $code ): string { $map = array( 'backup_job_active' => __( 'A media backup is already running. Finish, pause, or stop it before starting another.', 'affstackdev-image-manager' ), 'backup_confirmation_required' => __( 'Confirm the portable media backup before starting it.', 'affstackdev-image-manager' ), 'backup_delete_confirmation_required' => __( 'Confirm before deleting this local snapshot copy. Media Library files are not deleted by this action.', 'affstackdev-image-manager' ), 'backup_zip_unavailable' => __( 'This host does not provide ZIP support required for portable media backups.', 'affstackdev-image-manager' ), 'backup_storage_unavailable' => __( 'Private backup storage is unavailable. Ask your host for a writable folder outside your public website with verified owner-only access. An existing private backup folder can be configured with AFSD_IM_BACKUP_DIR.', 'affstackdev-image-manager' ), 'backup_invalid_profile' => __( 'Choose Essential recovery or Published media recovery.', 'affstackdev-image-manager' ), 'backup_invalid_scope' => __( 'Choose all media, a folder, or a collection.', 'affstackdev-image-manager' ), 'backup_scope_missing' => __( 'The selected folder or collection no longer exists.', 'affstackdev-image-manager' ), 'backup_encryption_unavailable' => __( 'Password-encrypted ZIP backups are unavailable on this host. Unencrypted private snapshots and downloads can still be used when private storage is available.', 'affstackdev-image-manager' ), 'backup_password_invalid' => __( 'Use matching backup passwords with at least eight characters.', 'affstackdev-image-manager' ), 'backup_archive_create_failed' => __( 'The backup archive could not be created in private storage.', 'affstackdev-image-manager' ), 'backup_archive_open_failed' => __( 'The backup archive could not be opened.', 'affstackdev-image-manager' ), 'backup_archive_write_failed' => __( 'The backup archive could not be updated. Existing media is unchanged.', 'affstackdev-image-manager' ), 'backup_source_missing' => __( 'A selected attachment does not have a readable local source file.', 'affstackdev-image-manager' ), 'backup_source_changed' => __( 'A media file changed while it was being copied. That item was skipped so the snapshot does not mix revisions.', 'affstackdev-image-manager' ), 'backup_source_too_large' => __( 'A selected source exceeded the per-file safety limit for this backup run.', 'affstackdev-image-manager' ), 'backup_attachment_too_large' => __( 'One attachment family exceeded the bounded per-item backup safety limit.', 'affstackdev-image-manager' ), 'backup_copy_failed' => __( 'A media file could not be copied to private staging.', 'affstackdev-image-manager' ), 'backup_verify_failed' => __( 'A newly written archive entry did not match its source checksum.', 'affstackdev-image-manager' ), 'backup_manifest_record_too_large' => __( 'One media record exceeded the portable-manifest safety limit.', 'affstackdev-image-manager' ), 'backup_manifest_too_large' => __( 'The portable manifest exceeded the current safety limit.', 'affstackdev-image-manager' ), 'backup_finalize_failed' => __( 'The completed archive could not be finalized safely.', 'affstackdev-image-manager' ), 'backup_actor_access_changed' => __( 'The user who started this backup no longer has permission to continue it.', 'affstackdev-image-manager' ), 'backup_snapshot_missing' => __( 'That backup snapshot is unavailable in this installation.', 'affstackdev-image-manager' ), 'backup_archive_changed' => __( 'The stored archive no longer matches the checksum recorded when it was completed.', 'affstackdev-image-manager' ), 'backup_password_required' => __( 'Enter the password used for this encrypted backup.', 'affstackdev-image-manager' ), 'backup_password_wrong_or_file_missing' => __( 'The backup password is incorrect or the selected media entry cannot be read.', 'affstackdev-image-manager' ), 'backup_restore_read_failed' => __( 'The selected media could not be read from the backup.', 'affstackdev-image-manager' ), 'backup_restore_integrity_failed' => __( 'The selected media did not match the checksum stored in the backup. Nothing was restored.', 'affstackdev-image-manager' ), 'backup_restore_destination_unavailable' => __( 'WordPress could not provide a writable Media Library destination.', 'affstackdev-image-manager' ), 'backup_restore_write_failed' => __( 'The restored file could not be written safely. Existing media was retained.', 'affstackdev-image-manager' ), 'backup_restore_attachment_failed' => __( 'WordPress could not create the restored Media Library attachment.', 'affstackdev-image-manager' ), 'backup_safety_snapshot_unavailable' => __( 'A pre-restore recovery point could not be created, so in-place restore was refused.', 'affstackdev-image-manager' ), 'backup_cross_site_in_place_forbidden' => __( 'In-place restore is available only for a snapshot created by this same site. Restore as a new Media Library item instead.', 'affstackdev-image-manager' ), 'backup_restore_target_missing' => __( 'The original same-site attachment is missing or you can no longer edit it.', 'affstackdev-image-manager' ), 'backup_restore_mime_mismatch' => __( 'The current attachment type no longer matches this backup revision. Restore it as a new item instead.', 'affstackdev-image-manager' ), 'backup_restore_confirmation_required' => __( 'Confirm the selective restore before changing or creating media.', 'affstackdev-image-manager' ), 'backup_record_missing' => __( 'The selected media record is not present in this snapshot.', 'affstackdev-image-manager' ), 'backup_restore_mode_invalid' => __( 'Choose restore as new media or same-site in-place restore.', 'affstackdev-image-manager' ), 'backup_import_upload_failed' => __( 'The portable backup upload could not be received safely.', 'affstackdev-image-manager' ), 'backup_password_required_or_wrong' => __( 'The archive manifest could not be opened. Enter the correct password if this backup is encrypted.', 'affstackdev-image-manager' ), 'backup_manifest_invalid' => __( 'This archive is not a valid supported AffStackDev Image Manager media backup.', 'affstackdev-image-manager' ), ); return $map[ $code ] ?? __( 'The media backup action could not finish. Existing media and completed backups were retained.', 'affstackdev-image-manager' ); } }