'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/avif' => 'avif', 'application/pdf' => 'pdf', )[ $info['mime'] ]; $token = bin2hex( random_bytes( 16 ) ); $file = 'draft-' . $token . '.' . $ext; $dest = Media_Actions::directory() . '/' . $file; if ( ! Reviewed_Store::copy( $path, $dest ) ) { throw new \RuntimeException( 'media_draft_write' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Private, capability-checked draft files require local image-editor paths and binary preview output; cleanup targets only the validated draft. @chmod( $dest, 0600 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Private, capability-checked draft files require local image-editor paths and binary preview output; cleanup targets only the validated draft. if ( hash_file( 'sha256', $dest ) !== $info['sha256'] ) { @Reviewed_Store::unlink( $dest ); throw new \RuntimeException( 'media_draft_write' ); } $old = self::get( $id, $kind ); $draft = array( 'id' => $token, 'file' => $file, 'created' => time(), 'source' => $source, 'image' => $info, 'recipe' => $recipe, 'state' => 'prepared', ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Private, capability-checked draft files require local image-editor paths and binary preview output; cleanup targets only the validated draft. if ( ! Reviewed_Store::update_post_meta( $id, self::key( $kind ), wp_slash( $draft ) ) ) { @Reviewed_Store::unlink( $dest ); throw new \RuntimeException( 'media_draft_write' ); } if ( $old ) { self::retire( $old ); } return $draft; } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * A draft's file in the private folder, checked against the draft's hash. * * @param array $draft The draft. * @return string The file's path. * @throws \RuntimeException With media_draft_missing when the file is missing, a link or changed. */ public static function path( array $draft ): string { $name = $draft['file'] ?? ''; if ( ! is_string( $name ) || ! preg_match( '/^draft-[a-f0-9]{32}\.(jpg|png|webp|avif|pdf)$/D', $name ) ) { throw new \RuntimeException( 'media_draft_missing' ); } $path = Media_Actions::directory( false ) . '/' . $name; if ( ! is_file( $path ) || is_link( $path ) || ! is_readable( $path ) || ! hash_equals( (string) ( $draft['image']['sha256'] ?? '' ), hash_file( 'sha256', $path ) ) ) { throw new \RuntimeException( 'media_draft_missing' ); } return $path; } /** * The current user's draft with this token, not expired (a prepared draft lasts a day). * * @param int $id The attachment. * @param string $kind convert, replace or compress. * @param string $token The draft's token. * @return array The draft. * @throws \RuntimeException With media_draft_missing or media_draft_expired. */ public static function checked( int $id, string $kind, string $token ): array { $draft = self::get( $id, $kind ); if ( ! $draft || ! hash_equals( $draft['id'], $token ) ) { throw new \RuntimeException( 'media_draft_missing' ); } if ( 'prepared' === $draft['state'] && (int) $draft['created'] < time() - DAY_IN_SECONDS ) { throw new \RuntimeException( 'media_draft_expired' ); } return $draft; } /** * Save a changed draft and check it was saved. * * @param int $id The attachment. * @param string $kind convert, replace or compress. * @param array $draft The draft. * @throws \RuntimeException With media_draft_write when it was not saved. */ public static function update( int $id, string $kind, array $draft ): void { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { Media_Actions::authorize( $id ); Reviewed_Store::update_post_meta( $id, self::key( $kind ), wp_slash( $draft ) ); if ( self::get( $id, $kind ) !== $draft ) { throw new \RuntimeException( 'media_draft_write' ); } } 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( array $draft ): void { try { Reviewed_Store::wp_delete_file( self::path( $draft ) ); } catch ( \Throwable $e ) { /* Missing or altered files are never followed. */ } } /** * Delete the current user's draft and its file (not while a replacement is being applied). * * @param int $id The attachment. * @param string $kind convert, replace or compress. * @param string $token The draft's token. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function discard( int $id, string $kind, string $token ): void { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { Media_Actions::locked( 'attachment:' . $id, static function () use ( $id, $kind, $token ) { $draft = self::get( $id, $kind ); if ( ! $draft || ! hash_equals( $draft['id'], $token ) ) { throw new \RuntimeException( 'media_draft_missing' ); } if ( 'applying' === $draft['state'] ) { throw new \RuntimeException( 'media_replace_recovery' ); } self::retire( $draft ); Reviewed_Store::delete_post_meta( $id, self::key( $kind ), $draft ); } ); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * The address that shows a draft to its owner (with a nonce). * * @param int $id The attachment. * @param string $kind convert, replace or compress. * @param string $token The draft's token. * @return string The address. */ public static function url( int $id, string $kind, string $token ): string { return add_query_arg( array( 'action' => 'afsd_im_media_asset', 'attachment' => $id, 'kind' => $kind, 'draft' => $token, '_wpnonce' => wp_create_nonce( 'afsd_im_media_asset_' . $id ), ), admin_url( 'admin-post.php' ) ); } /** * Send a draft's file to its owner (403 without a valid nonce or the right to edit the media item, 404 when the draft * is not there). */ public static function asset(): void { $id = isset( $_GET['attachment'] ) && is_scalar( $_GET['attachment'] ) ? absint( $_GET['attachment'] ) : 0; $nonce = isset( $_GET['_wpnonce'] ) && is_string( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : ''; if ( ! $id || ! wp_verify_nonce( $nonce, 'afsd_im_media_asset_' . $id ) ) { status_header( 403 ); exit; } try { Media_Actions::authorize( $id ); } catch ( \RuntimeException $e ) { status_header( 403 ); exit; } try { $kind = isset( $_GET['kind'] ) && is_string( $_GET['kind'] ) ? sanitize_key( wp_unslash( $_GET['kind'] ) ) : ''; $token = isset( $_GET['draft'] ) && is_string( $_GET['draft'] ) ? sanitize_text_field( wp_unslash( $_GET['draft'] ) ) : ''; $draft = self::checked( $id, $kind, $token ); $path = self::path( $draft ); nocache_headers(); header( 'Content-Type: ' . $draft['image']['mime'] ); header( 'X-Content-Type-Options: nosniff' ); header( 'Content-Length: ' . filesize( $path ) ); header( 'Content-Disposition: inline; filename="preview.' . pathinfo( $path, PATHINFO_EXTENSION ) . '"' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- Private, capability-checked draft files require local image-editor paths and binary preview output; cleanup targets only the validated draft. readfile( $path ); exit; } catch ( \Throwable $e ) { status_header( 404 ); exit; } } }