bin2hex( random_bytes( 16 ) ), 'requested' => true, 'complete' => false, 'recipe' => $recipe, 'created' => time(), ); if ( ! Repository::update( $id, array( 'manifest' => $m ), (int) $row['revision'] ) ) { throw new \RuntimeException( 'stale_job' ); } Repository::request( $id ); Scheduler::check(); } finally { Engine::publication_unlock( $guard ); Repository::release( $token ); } Scheduler::needed(); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Only unpublished draft copies not referenced by active/retired manifests may be removed. * * @param array $row The source row. * @param array $m Its manifest with the draft. */ private static function retire_draft( array $row, array &$m ): void { $keep = $m; unset( $keep['draft'] ); $names = array_column( Variants::flatten( $keep, true ), 'file' ); foreach ( Variants::flatten( $m['draft'] ?? array(), true ) as $v ) { if ( in_array( $v['file'], $names, true ) ) { continue; } if ( ! Engine::remove_variant( Sources::prefix( $row ), $v ) ) { $m['retired'][] = $v; } } unset( $m['draft'] ); } /** * A source's draft with this key. * * @param array $row The source row. * @param string $key The draft's key. * @return array The draft. * @throws \RuntimeException With preview_expired when the source has no such draft. */ private static function draft( array $row, string $key ): array { $d = $row['manifest']['draft'] ?? array(); if ( ! preg_match( '/^[a-f0-9]{32}$/D', $key ) || empty( $d['id'] ) || ! hash_equals( (string) $d['id'], $key ) ) { throw new \RuntimeException( 'preview_expired' ); } return $d; } /** * Shared read-only validation used by native approval and reviewed preparation. * * @param array $row The source row. * @param array $d The draft. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function assert_ready( array $row, array $d ): void { if ( empty( $d['complete'] ) || ! empty( $d['requested'] ) || $row['requested'] || ( empty( $d['variants'] ) && empty( $d['responsive'] ) && empty( $d['watermarked'] ) ) ) { throw new \RuntimeException( 'preview_not_ready' ); } $files = Sources::row_files( $row ); if ( (int) ( $d['revision'] ?? 0 ) !== (int) $row['revision'] || ! hash_equals( $d['meta_hash'] ?? '', Sources::row_meta_hash( $row ) ) || ( $d['fingerprints'] ?? array() ) !== Sources::fingerprint( (int) $row['attachment_id'], $files ) ) { throw new \RuntimeException( 'preview_source_changed' ); } // 1.9.0 (D-500 gap 6): watermarked copies are checked as the others, each against the file it belongs to. $sets = array( array_merge( $d['variants'] ?? array(), $d['responsive'] ?? array() ), $d['watermarked'] ?? array() ); foreach ( $sets as $set ) { foreach ( $set as $rel => $v ) { if ( ! empty( $v['resized'] ) || ! empty( $v['watermarked'] ) ) { $rel = $v['parent_relative']; } if ( ! isset( $files[ $rel ] ) ) { throw new \RuntimeException( 'preview_source_changed' ); } foreach ( Variants::choices( $v ) as $c ) { if ( ! Delivery::usable( $c, $files[ $rel ] ) || ! hash_equals( $c['source_hash'], hash_file( 'sha256', $files[ $rel ]['path'] ) ) || ! hash_equals( $c['output_hash'], hash_file( 'sha256', Settings::root() . '/' . $c['file'] ) ) ) { throw new \RuntimeException( 'preview_source_changed' ); } } } } } /** * Publish a finished draft: its copies become the source's manifest; the earlier copies are retired. * * @param int $id The source row. * @param string $key The draft's key. * @param bool $confirmed The owner's approval. * @param string $owned_token The worker's lease, when the caller holds it. * @param int $actor The user approving (0 for the current user). * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function apply( int $id, string $key, bool $confirmed, string $owned_token = '', int $actor = 0 ): void { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { if ( ! $confirmed ) { throw new \RuntimeException( 'preview_approval_required' ); } $token = $owned_token ?: Repository::acquire(); if ( ! $token || ! Repository::owns( $token ) ) { throw new \RuntimeException( 'worker_busy' ); } $guard = null; try { $guard = Engine::publication_lock(); $row = $actor ? Batch::authorized_row( $id, $actor ) : self::row( $id ); self::eligible( $row ); $d = self::draft( $row, $key ); self::assert_ready( $row, $d ); // Keep previously published files for cached URLs. Never delete them as a side effect of approval. $old = $row['manifest']; unset( $old['draft'] ); $retired = Variants::flatten( $old, true ); $new_names = array_column( Variants::flatten( $d ), 'file' ); $d['retired'] = array_values( array_filter( $retired, static fn( $v ) => ! in_array( $v['file'], $new_names, true ) ) ); if ( count( $d['retired'] ) > 512 ) { throw new \RuntimeException( 'preview_history_limit' ); } unset( $d['id'], $d['complete'], $d['requested'], $d['finished'], $d['state'], $d['reason'], $d['created'] ); $rev = (int) $row['revision'] + 1; $d['revision'] = $rev; $details = $row['details']; $details['compression'] = Policy::clean( $d['recipe'] ); if ( ! empty( $d['batch_id'] ) ) { $details['last_batch'] = $d['batch_id']; } unset( $details['delivery_check'] ); if ( ! Repository::update( $id, array( 'manifest' => $d, 'details' => $details, 'revision' => $rev, 'state' => 'ready', 'requested' => 0, 'attempts' => 0, 'reason' => 'Approved compression copies prepared. Delivery controls are unchanged.', ), (int) $row['revision'] ) ) { throw new \RuntimeException( 'stale_job' ); } } finally { Engine::publication_unlock( $guard ); if ( ! $owned_token ) { Repository::release( $token ); } } } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Discard a draft and the copy files only it uses. * * @param int $id The source row. * @param string $key The draft's key. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function discard( int $id, string $key ): void { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { $token = Repository::acquire(); if ( ! $token ) { throw new \RuntimeException( 'worker_busy' ); }$guard = null; try { $guard = Engine::publication_lock(); $row = self::row( $id ); self::draft( $row, $key ); $m = $row['manifest']; self::retire_draft( $row, $m ); $state = ( ! empty( $m['variants'] ) || ! empty( $m['responsive'] ) || ! empty( $m['watermarked'] ) ) ? 'ready' : ( $m['preview_prior_state'] ?? 'discovered' ); unset( $m['preview_prior_state'] ); Repository::update( $id, array( 'manifest' => $m, 'state' => $state, 'requested' => 0, 'attempts' => 0, 'reason' => 'Comparison discarded. Existing delivery retained.', ), (int) $row['revision'] ); } finally { Engine::publication_unlock( $guard ); Repository::release( $token ); } } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * The address of a preview file (with a nonce). * * @param int $id The source row. * @param string $size The SHA-256 of the file's path (which of the picture's files). * @param string $which original, or candidate for the draft's copy. * @param string $key The draft's key, for draft files. * @param string $mime The copy's type, for copies. * @return string The address. */ public static function url( int $id, string $size, string $which, string $key = '', string $mime = '' ): string { return add_query_arg( array( 'action' => 'afsd_im_preview_media', 'source' => $id, 'size' => $size, 'which' => $which, 'draft' => $key, 'format' => $mime, '_wpnonce' => wp_create_nonce( 'afsd_im_preview_' . $id ), ), admin_url( 'admin-post.php' ) ); } /** * No caller-supplied paths. Every media request rechecks source permissions, public eligibility, and revision. * * @param int $id The source row. * @param string $size The SHA-256 of the file's path (which of the picture's files). * @param string $which original, or candidate for the draft's copy. * @param string $key The draft's key, for draft files. * @param string $mime The copy's type, for copies. * @return array The file's path, type and size in bytes. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function media_file( int $id, string $size, string $which, string $key = '', string $mime = '' ): array { $row = self::row( $id ); self::eligible( $row ); $files = Sources::row_files( $row ); $files = Responsive::preview_files( $row, $files ); $file = null; $relative = ''; foreach ( $files as $rel => $f ) { if ( hash_equals( hash( 'sha256', $rel ), $size ) ) { $file = $f; $relative = $rel; break; } } if ( ! $file ) { throw new \RuntimeException( 'preview_expired' ); } if ( 'original' === $which ) { $info = wp_getimagesize( $file['path'] ); $path = $file['path']; } elseif ( 'candidate' === $which ) { $d = self::draft( $row, $key ); $v = $d['variants'][ $relative ] ?? $d['responsive'][ $relative ] ?? array(); $chosen = null; if ( empty( $d['complete'] ) || (int) ( $d['revision'] ?? 0 ) !== (int) $row['revision'] || ( $d['meta_hash'] ?? '' ) !== Sources::row_meta_hash( $row ) ) { throw new \RuntimeException( 'preview_not_ready' ); } foreach ( Variants::choices( $v ) as $c ) { if ( ! $mime || Variants::mime( $c ) === $mime ) { $chosen = $c; break; } } if ( ! $chosen || ! Delivery::usable( $chosen, $file ) || ! hash_equals( $chosen['source_hash'], hash_file( 'sha256', $file['path'] ) ) ) { throw new \RuntimeException( 'preview_source_changed' ); } $path = Settings::root() . '/' . $chosen['file']; if ( ! hash_equals( $chosen['output_hash'], hash_file( 'sha256', $path ) ) ) { throw new \RuntimeException( 'preview_source_changed' ); }$info = wp_getimagesize( $path ); } else { throw new \RuntimeException( 'preview_expired' ); } if ( ! $info || ! in_array( $info['mime'] ?? '', array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif' ), true ) || is_link( $path ) || ! is_file( $path ) || filesize( $path ) > Settings::get()['max_bytes'] ) { throw new \RuntimeException( 'preview_expired' ); } return array( 'path' => $path, 'mime' => $info['mime'], 'bytes' => filesize( $path ), ); } /** * Send a preview file: a GET with a valid nonce for a file of a source the current user may preview. * * @throws \RuntimeException Only inside this function, where it is caught; callers get no exception. */ public static function media(): void { try { if ( ! current_user_can( 'manage_options' ) || ! current_user_can( 'upload_files' ) ) { throw new \RuntimeException( 'image_access_denied' ); } if ( 'GET' !== ( isset( $_SERVER['REQUEST_METHOD'] ) && is_string( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '' ) ) { throw new \RuntimeException( 'preview_expired' ); } $get = Plugin::fields( array( 'source', '_wpnonce', 'draft', 'format', 'size', 'which' ), true ); $id = isset( $get['source'] ) && is_scalar( $get['source'] ) ? absint( $get['source'] ) : 0; if ( ! isset( $get['_wpnonce'] ) || ! is_string( $get['_wpnonce'] ) || ! wp_verify_nonce( $get['_wpnonce'], 'afsd_im_preview_' . $id ) ) { throw new \RuntimeException( 'preview_expired' ); } foreach ( array( 'size', 'which', 'draft', 'format' ) as $key ) { if ( isset( $get[ $key ] ) && ! is_string( $get[ $key ] ) ) { throw new \RuntimeException( 'preview_expired' ); } } $f = self::media_file( $id, $get['size'] ?? '', $get['which'] ?? '', $get['draft'] ?? '', $get['format'] ?? '' ); nocache_headers(); header( 'Cache-Control: private, no-store, max-age=0' ); header( 'X-Content-Type-Options: nosniff' ); header( 'Content-Type: ' . $f['mime'] ); header( 'Content-Length: ' . (string) $f['bytes'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile,WordPress.WP.AlternativeFunctions.file_system_read_readfile -- Nonce- and capability-checked preview serves a bounded validated image with binary MIME/nosniff headers. bounded validated local image stream. readfile( $f['path'] ); exit; } catch ( \Throwable $e ) { wp_die( esc_html__( 'This comparison is unavailable or your access has expired. Return to Image Manager.', 'affstackdev-image-manager' ), '', array( 'response' => 403 ) ); } } }