self::VERSION, 'crop' => $crop, 'crop_x' => $number( $input['crop_x'] ?? 0 ), 'crop_y' => $number( $input['crop_y'] ?? 0 ), 'crop_width' => $number( $input['crop_width'] ?? 0 ), 'crop_height' => $number( $input['crop_height'] ?? 0 ), 'rotate' => $rotate, 'flip' => $flip, 'resize_width' => $number( $input['resize_width'] ?? 0 ), 'resize_height' => $number( $input['resize_height'] ?? 0 ), ); } /** * The size a resize gives a picture: it keeps the aspect ratio and fits inside the width and height given (0 leaves * one unset), larger or smaller than the picture is now. An enlargement stays within 16,384 pixels a side and 24 * megapixels, so a small picture cannot become one that exhausts the server's memory; a reduction is never refused. * * @param int $width The picture's width after rotation and crop. * @param int $height Its height. * @param int $max_width The width to fit, or 0. * @param int $max_height The height to fit, or 0. * @return array|null The new width and height, or null when the size stays as it is. * @throws \RuntimeException With editor_size_too_large when an enlargement would be larger than that. */ public static function resized( int $width, int $height, int $max_width, int $max_height ): ?array { if ( $width < 1 || $height < 1 || ( $max_width < 1 && $max_height < 1 ) ) { return null; } $scale = min( $max_width > 0 ? $max_width / $width : INF, $max_height > 0 ? $max_height / $height : INF ); $new = array( max( 1, (int) round( $width * $scale ) ), max( 1, (int) round( $height * $scale ) ) ); if ( array( $width, $height ) === $new ) { return null; } if ( $scale > 1 && ( $new[0] > 16384 || $new[1] > 16384 || $new[0] * $new[1] > 24000000 ) ) { throw new \RuntimeException( 'editor_size_too_large' ); } return $new; } /** * Resize in WordPress's image editor: a reduction exactly as WordPress makes it; an enlargement to the size resized() * gave, through WordPress's image_resize_dimensions filter for this one call (WordPress itself only reduces). * * @param \WP_Image_Editor $editor The editor holding the picture. * @param int $width The picture's width now. * @param int $height Its height. * @param array $new The size resized() gave. * @param int $max_width The width to fit, or 0. * @param int $max_height The height to fit, or 0. * @return true|\WP_Error What the editor answered. */ private static function resize( \WP_Image_Editor $editor, int $width, int $height, array $new, int $max_width, int $max_height ) { if ( $new[0] <= $width && $new[1] <= $height ) { return $editor->resize( $max_width ?: null, $max_height ?: null, false ); } $exact = static function ( $out, $orig_w, $orig_h, $dest_w, $dest_h, $crop ) use ( $width, $height, $new ) { if ( ! $crop && (int) $orig_w === $width && (int) $orig_h === $height && (int) $dest_w === $new[0] && (int) $dest_h === $new[1] ) { return array( 0, 0, 0, 0, $new[0], $new[1], $width, $height ); } return $out; }; add_filter( 'image_resize_dimensions', $exact, 10, 6 ); try { return $editor->resize( $new[0], $new[1], false ); } finally { remove_filter( 'image_resize_dimensions', $exact, 10 ); } } /** * Returns x/y/w/h for a centered preset crop, or null when no crop is requested. * * @param int $width The picture's width. * @param int $height Its height. * @param array $recipe The edit recipe. * @return array|null x, y, w and h, or null. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function crop_box( int $width, int $height, array $recipe ): ?array { if ( $width < 1 || $height < 1 ) { return null; } $crop = $recipe['crop'] ?? 'none'; if ( 'none' === $crop ) { return null; } if ( 'custom' === $crop ) { $x = max( 0, (int) ( $recipe['crop_x'] ?? 0 ) ); $y = max( 0, (int) ( $recipe['crop_y'] ?? 0 ) ); $w = (int) ( $recipe['crop_width'] ?? 0 ); $h = (int) ( $recipe['crop_height'] ?? 0 ); if ( $w < 1 || $h < 1 || $x + $w > $width || $y + $h > $height ) { throw new \RuntimeException( 'editor_invalid_crop' ); } return array( $x, $y, $w, $h ); } $ratios = array( '1x1' => array( 1, 1 ), '4x3' => array( 4, 3 ), '3x2' => array( 3, 2 ), '16x9' => array( 16, 9 ), ); if ( ! isset( $ratios[ $crop ] ) ) { return null; } [ $rw, $rh ] = $ratios[ $crop ]; $target = $rw / $rh; $current = $width / $height; if ( $current > $target ) { $h = $height; $w = max( 1, (int) round( $h * $target ) ); $x = (int) floor( ( $width - $w ) / 2 ); $y = 0; } else { $w = $width; $h = max( 1, (int) round( $w / $target ) ); $x = 0; $y = (int) floor( ( $height - $h ) / 2 ); } return array( $x, $y, min( $w, $width ), min( $h, $height ) ); } /** * The private folder for edit drafts, in the temporary folder, refused when it lies inside the WordPress or web root. * * @return string The folder's path. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ private static function draft_dir(): string { // Drafts contain administrator-only work-in-progress images. Refuse a temp // directory that lives beneath the WordPress or web document root rather // than relying on an unguessable filename to make a web-accessible file private. $base = trailingslashit( get_temp_dir() ) . 'affstackdev-image-manager-edits'; try { return Private_Storage::directory( $base ); } catch ( \RuntimeException $e ) { throw new \RuntimeException( 'editor_storage_unavailable' ); } } /** * A draft's file, checked by name and hash. * * @param array $draft The draft. * @return string The file's path. * @throws \RuntimeException With editor_draft_missing when the file is missing or changed. */ private static function draft_path( array $draft ): string { $name = isset( $draft['file'] ) && is_string( $draft['file'] ) ? $draft['file'] : ''; if ( ! preg_match( '/^edit-[a-f0-9]{32}\.(?:jpg|png|webp|avif)$/D', $name ) ) { throw new \RuntimeException( 'editor_draft_missing' ); } $path = self::draft_dir() . '/' . $name; if ( is_link( $path ) || ! is_file( $path ) || ! is_readable( $path ) ) { throw new \RuntimeException( 'editor_draft_missing' ); } return $path; } /** * The attachment's file the edit starts from, for someone who may edit it. * * @param int $attachment The attachment. * @return array Its path, width, height, type, size, hash and time. * @throws \RuntimeException With editor_access_denied when the user may not edit it. */ private static function source( int $attachment ): array { if ( ! self::supported( $attachment ) || ! current_user_can( 'edit_post', $attachment ) ) { throw new \RuntimeException( 'editor_access_denied' ); } $path = get_attached_file( $attachment ); $size = wp_getimagesize( $path ); if ( ! is_array( $size ) || empty( $size[0] ) || empty( $size[1] ) || empty( $size['mime'] ) ) { throw new \RuntimeException( 'editor_source_invalid' ); } $declared = wp_check_filetype( $path ); if ( empty( $declared['type'] ) || ! hash_equals( (string) $declared['type'], (string) $size['mime'] ) || ! hash_equals( (string) get_post_mime_type( $attachment ), (string) $size['mime'] ) ) { throw new \RuntimeException( 'editor_source_invalid' ); } $bytes = filesize( $path ); if ( false === $bytes || $bytes > 67108864 ) { throw new \RuntimeException( 'editor_source_too_large' ); } return array( 'path' => $path, 'width' => (int) $size[0], 'height' => (int) $size[1], 'mime' => (string) $size['mime'], 'bytes' => (int) $bytes, 'hash' => hash_file( 'sha256', $path ), 'mtime' => (int) filemtime( $path ), ); } /** * The attachment's edit draft, for someone who may edit it. * * @param int $attachment The attachment. * @return array The draft, or an empty array. */ public static function draft( int $attachment ): array { if ( ! current_user_can( 'edit_post', $attachment ) ) { return array(); } $draft = get_post_meta( $attachment, self::DRAFT_META, true ); if ( ! is_array( $draft ) || empty( $draft['id'] ) ) { return array(); } if ( ! empty( $draft['created'] ) && (int) $draft['created'] < time() - DAY_IN_SECONDS ) { self::retire_draft_file( $draft ); Reviewed_Store::delete_post_meta( $attachment, self::DRAFT_META ); return array(); } try { self::draft_path( $draft ); } catch ( \Throwable $e ) { Reviewed_Store::delete_post_meta( $attachment, self::DRAFT_META ); return array(); } return $draft; } /** * Prepare a private preview of an edit, through WordPress's image editor. * * @param int $attachment The attachment. * @param array $input The edit recipe. * @param bool $confirm The owner's confirmation. * @return array The draft. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function prepare( int $attachment, array $input, bool $confirm ): array { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { if ( ! $confirm ) { throw new \RuntimeException( 'editor_confirmation_required' ); } $source = self::source( $attachment ); $recipe = self::clean_recipe( $input ); if ( 'none' === $recipe['crop'] && 'none' === $recipe['rotate'] && 'none' === $recipe['flip'] && ! $recipe['resize_width'] && ! $recipe['resize_height'] ) { throw new \RuntimeException( 'editor_no_changes' ); } $old = self::draft( $attachment ); if ( ! function_exists( 'wp_get_image_editor' ) ) { require_once ABSPATH . 'wp-admin/includes/image.php'; } $editor = wp_get_image_editor( $source['path'] ); if ( is_wp_error( $editor ) ) { throw new \RuntimeException( 'editor_backend_unavailable' ); } $size = $editor->get_size(); if ( ! is_array( $size ) ) { throw new \RuntimeException( 'editor_backend_unavailable' ); } if ( 'cw90' === $recipe['rotate'] ) { $r = $editor->rotate( -90 ); } elseif ( 'ccw90' === $recipe['rotate'] ) { $r = $editor->rotate( 90 ); } elseif ( '180' === $recipe['rotate'] ) { $r = $editor->rotate( 180 ); } else { $r = true; } if ( is_wp_error( $r ) ) { throw new \RuntimeException( 'editor_operation_failed' ); } // F-IM-56: WP_Image_Editor::flip( $horz, $vert ) names the axis the picture turns over, so flip( true, false ) turns // it top to bottom. Our choices name how the picture looks: Horizontal mirrors it left to right. if ( 'horizontal' === $recipe['flip'] ) { $r = $editor->flip( false, true ); } elseif ( 'vertical' === $recipe['flip'] ) { $r = $editor->flip( true, false ); } else { $r = true; } if ( is_wp_error( $r ) ) { throw new \RuntimeException( 'editor_operation_failed' ); } $size = $editor->get_size(); $box = self::crop_box( (int) $size['width'], (int) $size['height'], $recipe ); if ( $box ) { $r = $editor->crop( $box[0], $box[1], $box[2], $box[3], $box[2], $box[3], false ); if ( is_wp_error( $r ) ) { throw new \RuntimeException( 'editor_operation_failed' ); } } $size = $editor->get_size(); $new = self::resized( (int) $size['width'], (int) $size['height'], $recipe['resize_width'], $recipe['resize_height'] ); if ( ! $new && ! $box && 'none' === $recipe['rotate'] && 'none' === $recipe['flip'] ) { throw new \RuntimeException( 'editor_no_changes' ); } if ( $new ) { $r = self::resize( $editor, (int) $size['width'], (int) $size['height'], $new, $recipe['resize_width'], $recipe['resize_height'] ); if ( is_wp_error( $r ) ) { throw new \RuntimeException( 'editor_operation_failed' ); } } if ( in_array( $source['mime'], array( 'image/jpeg', 'image/webp', 'image/avif' ), true ) && method_exists( $editor, 'set_quality' ) ) { $editor->set_quality( 95 ); } $ext = array( 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/avif' => 'avif', )[ $source['mime'] ] ?? ''; if ( ! $ext ) { throw new \RuntimeException( 'editor_source_invalid' ); } $id = bin2hex( random_bytes( 16 ) ); $name = 'edit-' . $id . '.' . $ext; $path = self::draft_dir() . '/' . $name; $saved = $editor->save( $path, $source['mime'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Validated private drafts/local attachment paths require image-editor access and atomic publication; native descriptors retain lock and binary-stream semantics. if ( is_wp_error( $saved ) || ! is_file( $path ) ) { @Reviewed_Store::unlink( $path ); throw new \RuntimeException( 'editor_save_failed' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Validated private drafts/local attachment paths require image-editor access and atomic publication; native descriptors retain lock and binary-stream semantics. @chmod( $path, 0600 ); $check = wp_getimagesize( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Validated private drafts/local attachment paths require image-editor access and atomic publication; native descriptors retain lock and binary-stream semantics. if ( ! is_array( $check ) || empty( $check[0] ) || empty( $check[1] ) || ( $check['mime'] ?? '' ) !== $source['mime'] ) { @Reviewed_Store::unlink( $path ); throw new \RuntimeException( 'editor_save_failed' ); } $draft = array( 'id' => $id, 'file' => $name, 'created' => time(), 'source_hash' => $source['hash'], 'source_mtime' => $source['mtime'], 'source_bytes' => $source['bytes'], 'source_width' => $source['width'], 'source_height' => $source['height'], 'mime' => $source['mime'], 'width' => (int) $check[0], 'height' => (int) $check[1], 'output_bytes' => (int) filesize( $path ), 'recipe' => $recipe, ); $stored = Reviewed_Store::update_post_meta( $attachment, self::DRAFT_META, $draft ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Validated private drafts/local attachment paths require image-editor access and atomic publication; native descriptors retain lock and binary-stream semantics. if ( false === $stored ) { @Reviewed_Store::unlink( $path ); throw new \RuntimeException( 'editor_save_failed' ); } if ( $old ) { self::retire_draft_file( $old ); } return $draft; } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Whether the attachment's file is still the one the draft was made from. * * @param int $attachment The attachment. * @param array $draft The draft. * @return bool True when it is. */ private static function unchanged( int $attachment, array $draft ): bool { $source = self::source( $attachment ); return isset( $draft['source_hash'], $draft['source_mtime'], $draft['source_bytes'] ) && hash_equals( (string) $draft['source_hash'], $source['hash'] ) && (int) $draft['source_mtime'] === $source['mtime'] && (int) $draft['source_bytes'] === $source['bytes']; } /** * Save the prepared edit as a new media item, with the source's text and organization; the original stays. * * @param int $attachment The attachment. * @param string $draft_id The draft. * @param bool $confirm The owner's confirmation. * @return int The new attachment. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function save_copy( int $attachment, string $draft_id, bool $confirm ): int { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { if ( ! $confirm ) { throw new \RuntimeException( 'editor_save_confirmation_required' ); } $draft = self::draft( $attachment ); if ( ! $draft || ! hash_equals( (string) $draft['id'], $draft_id ) ) { throw new \RuntimeException( 'editor_draft_missing' ); } if ( ! self::unchanged( $attachment, $draft ) ) { throw new \RuntimeException( 'editor_source_changed' ); } $draft_path = self::draft_path( $draft ); $uploads = wp_upload_dir(); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Validated private drafts/local attachment paths require image-editor access and atomic publication; native descriptors retain lock and binary-stream semantics. if ( ! empty( $uploads['error'] ) || ! is_dir( $uploads['path'] ) || ! is_writable( $uploads['path'] ) ) { throw new \RuntimeException( 'editor_upload_unavailable' ); } $source_file = get_attached_file( $attachment ); $info = pathinfo( $source_file ); $base = sanitize_file_name( ( $info['filename'] ?? 'image' ) . '-edited.' . ( $info['extension'] ?? 'jpg' ) ); $filename = wp_unique_filename( $uploads['path'], $base ); $dest = trailingslashit( $uploads['path'] ) . $filename; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Validated private drafts/local attachment paths require image-editor access and atomic publication; native descriptors retain lock and binary-stream semantics. $in = fopen( $draft_path, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Validated private drafts/local attachment paths require image-editor access and atomic publication; native descriptors retain lock and binary-stream semantics. $out = fopen( $dest . '.tmp-' . bin2hex( random_bytes( 6 ) ), 'xb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private drafts/local attachment paths require image-editor access and atomic publication; native descriptors retain lock and binary-stream semantics. if ( ! $in || ! $out ) { if ( is_resource( $in ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private drafts/local attachment paths require image-editor access and atomic publication; native descriptors retain lock and binary-stream semantics. fclose( $in ); } if ( is_resource( $out ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private drafts/local attachment paths require image-editor access and atomic publication; native descriptors retain lock and binary-stream semantics. fclose( $out ); } throw new \RuntimeException( 'editor_upload_unavailable' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private drafts/local attachment paths require image-editor access and atomic publication; native descriptors retain lock and binary-stream semantics. $tmp = stream_get_meta_data( $out )['uri']; $ok = stream_copy_to_stream( $in, $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private drafts/local attachment paths require image-editor access and atomic publication; native descriptors retain lock and binary-stream semantics. fclose( $in ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Validated private drafts/local attachment paths require image-editor access and atomic publication; native descriptors retain lock and binary-stream semantics. fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename,WordPress.WP.AlternativeFunctions.unlink_unlink -- Validated private drafts/local attachment paths require image-editor access and atomic publication; native descriptors retain lock and binary-stream semantics. if ( false === $ok || ! Reviewed_Store::rename( $tmp, $dest ) ) { @Reviewed_Store::unlink( $tmp ); @Reviewed_Store::unlink( $dest ); throw new \RuntimeException( 'editor_upload_unavailable' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Validated private drafts/local attachment paths require image-editor access and atomic publication; native descriptors retain lock and binary-stream semantics. @chmod( $dest, 0644 ); require_once ABSPATH . 'wp-admin/includes/image.php'; $source_post = get_post( $attachment ); $title = trim( get_the_title( $attachment ) ); $postarr = array( 'post_mime_type' => (string) $draft['mime'], 'post_title' => $title ? $title . ' — edited' : preg_replace( '/\.[^.]+$/', '', $filename ), 'post_excerpt' => $source_post ? (string) $source_post->post_excerpt : '', 'post_content' => $source_post ? (string) $source_post->post_content : '', 'post_status' => 'inherit', 'post_parent' => $source_post ? (int) $source_post->post_parent : 0, ); $new_id = Reviewed_Store::wp_insert_attachment( wp_slash( $postarr ), $dest, (int) $postarr['post_parent'], true ); if ( is_wp_error( $new_id ) ) { Reviewed_Store::wp_delete_file( $dest ); throw new \RuntimeException( 'editor_attachment_failed' ); } $meta = Reviewed_Store::wp_generate_attachment_metadata( (int) $new_id, $dest ); if ( ! is_array( $meta ) || ! $meta ) { Reviewed_Store::wp_delete_attachment( (int) $new_id, true ); throw new \RuntimeException( 'editor_attachment_failed' ); } Reviewed_Store::wp_update_attachment_metadata( (int) $new_id, $meta ); $alt = get_post_meta( $attachment, '_wp_attachment_image_alt', true ); if ( is_string( $alt ) && '' !== $alt ) { Reviewed_Store::update_post_meta( (int) $new_id, '_wp_attachment_image_alt', $alt ); } foreach ( array( Library::FOLDER, Library::COLLECTION ) as $tax ) { $ids = Library::term_ids( $attachment, $tax ); if ( $ids ) { Reviewed_Store::wp_set_object_terms( (int) $new_id, $ids, $tax, false ); } } Reviewed_Store::update_post_meta( (int) $new_id, self::SOURCE_META, $attachment ); Reviewed_Store::update_post_meta( (int) $new_id, self::RECIPE_META, $draft['recipe'] ); $history = get_post_meta( $attachment, self::HISTORY_META, true ); if ( ! is_array( $history ) ) { $history = array(); } $history[] = array( 'attachment_id' => (int) $new_id, 'created' => time(), 'source_hash' => (string) $draft['source_hash'], 'recipe' => $draft['recipe'], 'width' => (int) $draft['width'], 'height' => (int) $draft['height'], ); if ( count( $history ) > 20 ) { $history = array_slice( $history, -20 ); } Reviewed_Store::update_post_meta( $attachment, self::HISTORY_META, $history ); self::discard( $attachment, $draft_id ); return (int) $new_id; } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Delete a draft's file (one that is missing or changed is left alone). * * @param array $draft The draft. */ private static function retire_draft_file( array $draft ): void { try { $path = self::draft_path( $draft ); Reviewed_Store::wp_delete_file( $path ); } catch ( \Throwable $e ) { /* A missing temporary draft is already retired. */ } } /** * Remove a deleted attachment's draft, or schedule that when another writer is busy. * * @param int $attachment The attachment. * @throws \RuntimeException What a step inside threw, passed on after cleaning up. */ public static function attachment_deleted( int $attachment ): void { try { $im_reviewed_owner_lock = Reviewed_Store::enter( 8 ); } catch ( \RuntimeException $e ) { if ( ! Reviewed_Store::busy( $e ) ) { throw $e; } // Never block or break a native media deletion: retire this attachment's temporary draft copy shortly. $draft = get_post_meta( $attachment, self::DRAFT_META, true ); if ( is_array( $draft ) ) { wp_schedule_single_event( time() + 60, self::RETIRE_HOOK, array( $draft, 1 ) ); } return; } try { $draft = get_post_meta( $attachment, self::DRAFT_META, true ); if ( is_array( $draft ) ) { self::retire_draft_file( $draft ); } } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Scheduled retirement of a deleted attachment's draft copy; retried while another writer is busy. * * @param mixed $draft The draft. * @param mixed $attempt Which attempt this is. * @throws \RuntimeException What a step inside threw, passed on after cleaning up. */ public static function retire_deferred( $draft, $attempt = 1 ): void { if ( ! is_array( $draft ) ) { return; } try { $im_reviewed_owner_lock = Reviewed_Store::enter(); } catch ( \RuntimeException $e ) { if ( ! Reviewed_Store::busy( $e ) ) { throw $e; } $attempt = max( 1, (int) $attempt ); if ( $attempt < 12 ) { wp_schedule_single_event( time() + 120 * $attempt, self::RETIRE_HOOK, array( $draft, $attempt + 1 ) ); } return; } try { self::retire_draft_file( $draft ); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Discard the attachment's edit draft. * * @param int $attachment The attachment. * @param string $draft_id The draft, or an empty string for the current one. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function discard( int $attachment, string $draft_id = '' ): void { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { if ( ! current_user_can( 'edit_post', $attachment ) ) { throw new \RuntimeException( 'editor_access_denied' ); } $draft = get_post_meta( $attachment, self::DRAFT_META, true ); if ( ! is_array( $draft ) || empty( $draft['id'] ) ) { return; } if ( $draft_id && ! hash_equals( (string) $draft['id'], $draft_id ) ) { throw new \RuntimeException( 'editor_draft_missing' ); } self::retire_draft_file( $draft ); Reviewed_Store::delete_post_meta( $attachment, self::DRAFT_META ); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * The copies saved from an attachment's edits, for someone who may edit it. * * @param int $attachment The attachment. * @return array The last 20 entries whose saved copy still exists. */ public static function history( int $attachment ): array { if ( ! current_user_can( 'edit_post', $attachment ) ) { return array(); } $history = get_post_meta( $attachment, self::HISTORY_META, true ); if ( ! is_array( $history ) ) { return array(); } return array_values( array_filter( array_slice( $history, -20 ), static function ( $entry ) { return is_array( $entry ) && ! empty( $entry['attachment_id'] ) && 'attachment' === get_post_type( (int) $entry['attachment_id'] ); } ) ); } /** * The address of the editor's source or draft picture (with a nonce). * * @param int $attachment The attachment. * @param string $kind source or draft. * @param string $draft_id The draft, for draft. * @return string The address. */ public static function asset_url( int $attachment, string $kind = 'source', string $draft_id = '' ): string { $args = array( 'action' => 'afsd_im_editor_asset', 'attachment' => $attachment, 'kind' => $kind, '_wpnonce' => wp_create_nonce( 'afsd_im_editor_asset_' . $attachment ), ); if ( $draft_id ) { $args['draft'] = $draft_id; } return add_query_arg( $args, admin_url( 'admin-post.php' ) ); } /** * Send the editor's source or draft picture to someone who may edit the attachment (403 without a valid nonce). * * @throws \RuntimeException Only inside this function, where it is caught; callers get no exception. */ public static function asset(): void { $attachment = isset( $_GET['attachment'] ) && is_scalar( $_GET['attachment'] ) ? absint( $_GET['attachment'] ) : 0; if ( ! $attachment || ! current_user_can( 'edit_post', $attachment ) || ! wp_verify_nonce( isset( $_GET['_wpnonce'] ) && is_string( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : '', 'afsd_im_editor_asset_' . $attachment ) ) { status_header( 403 ); exit; } try { $kind = isset( $_GET['kind'] ) ? sanitize_key( (string) wp_unslash( $_GET['kind'] ) ) : 'source'; if ( 'draft' === $kind ) { $draft = self::draft( $attachment ); $requested = isset( $_GET['draft'] ) && is_string( $_GET['draft'] ) ? sanitize_text_field( wp_unslash( $_GET['draft'] ) ) : ''; if ( ! $draft || ! hash_equals( (string) $draft['id'], $requested ) ) { throw new \RuntimeException( 'editor_draft_missing' ); } $path = self::draft_path( $draft ); $mime = (string) $draft['mime']; } else { $source = self::source( $attachment ); $path = $source['path']; $mime = $source['mime']; } nocache_headers(); header( 'Content-Type: ' . $mime ); header( 'Content-Length: ' . (string) filesize( $path ) ); header( 'Content-Disposition: inline; filename="' . rawurlencode( basename( $path ) ) . '"' ); header( 'X-Content-Type-Options: nosniff' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- Validated private drafts/local attachment paths require image-editor access and atomic publication; native descriptors retain lock and binary-stream semantics. readfile( $path ); exit; } catch ( \Throwable $e ) { status_header( 404 ); exit; } } }