67108864 || ! is_array( $dim ) || empty( $dim[0] ) || empty( $dim[1] ) || $dim[0] > 16384 || $dim[1] > 16384 || $dim[0] * $dim[1] > 24000000 || ! in_array( $dim['mime'] ?? '', array( 'image/jpeg', 'image/png', 'image/webp', 'image/avif' ), true ) ) { throw new \RuntimeException( 'media_action_image' ); } return array( 'width' => (int) $dim[0], 'height' => (int) $dim[1], 'mime' => $dim['mime'], 'bytes' => (int) $bytes, 'sha256' => hash_file( 'sha256', $file ), ); } /** * An attachment's own file, checked: the current user may change it, the file is local and its type matches. * * @param int $attachment The attachment. * @param string $mime The type it must have ('' for any). * @return array Its path, MIME type, SHA-256, bytes and a hash of its file metadata. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function source( int $attachment, string $mime = '' ): array { self::authorize( $attachment ); $path = get_attached_file( $attachment, true ); if ( ! is_string( $path ) ) { throw new \RuntimeException( 'media_action_source' ); } $path = self::local_file( $path ); clearstatcache( true, $path ); $type = (string) get_post_mime_type( $attachment ); $declared = wp_check_filetype( $path ); if ( ( $mime && $mime !== $type ) || ( $declared['type'] ?? '' ) !== $type || filesize( $path ) > 67108864 ) { throw new \RuntimeException( 'media_action_source' ); } return array( 'path' => $path, 'mime' => $type, 'sha256' => hash_file( 'sha256', $path ), 'bytes' => (int) filesize( $path ), 'metadata_hash' => hash( 'sha256', serialize( array( get_post_meta( $attachment, '_wp_attached_file', true ), get_post_meta( $attachment, '_wp_attachment_metadata', true ), get_post_meta( $attachment, '_wp_attachment_backup_sizes', true ) ) ) ), ); } /** * Check an attachment's file and metadata are still what they were when a draft was prepared. * * @param int $attachment The attachment. * @param array $source Its source() answer then. * @throws \RuntimeException With media_action_changed when they differ. */ public static function unchanged( int $attachment, array $source ): void { $now = self::source( $attachment ); if ( $now !== $source ) { throw new \RuntimeException( 'media_action_changed' ); } } /** * Check a folder chosen for uploads exists. * * @param int $folder The folder's term ID, or 0 for none. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function folder( int $folder ): void { if ( ! $folder ) { return; } $term = get_term( $folder, Library::FOLDER ); if ( ! $term || is_wp_error( $term ) ) { throw new \RuntimeException( 'media_action_folder' ); } } /** * The folder names of one file's place in an uploaded folder ("Summer/Beach"), cleaned as the Folders screen * cleans a name (1.9.0, D-500 gap 9). At most eight levels; "." and ".." and empty names are left out. * * @param mixed $path The file's folder path relative to the chosen folder, "/"-separated. * @return array The names, outermost first. * @throws \RuntimeException When the path is not a string, too long or too deep. */ private static function path_names( $path ): array { if ( ! is_string( $path ) || strlen( $path ) > 1024 ) { throw new \RuntimeException( 'media_action_upload' ); } $names = array(); foreach ( explode( '/', str_replace( '\\', '/', $path ) ) as $part ) { $part = trim( sanitize_text_field( $part ) ); if ( '' === $part || '.' === $part || '..' === $part ) { continue; } if ( strlen( $part ) > 120 ) { throw new \RuntimeException( 'media_action_upload' ); } $names[] = $part; } if ( count( $names ) > 8 ) { throw new \RuntimeException( 'media_action_upload' ); } return $names; } /** * The folder for these names inside $root, each level found by its name or created (1.9.0). * * @param int $root The chosen folder, or 0 for the top level. * @param array $names The names from path_names(). * @return int The innermost folder. * @throws \RuntimeException When a folder cannot be made. */ private static function folder_for( int $root, array $names ): int { $parent = $root; foreach ( $names as $name ) { $found = get_terms( array( 'taxonomy' => Library::FOLDER, 'hide_empty' => false, 'parent' => $parent, 'name' => $name, 'fields' => 'ids', 'number' => 1, ) ); if ( is_array( $found ) && $found ) { $parent = (int) $found[0]; continue; } $made = Library::create_term( Library::FOLDER, $name, $parent ); if ( is_wp_error( $made ) ) { $existing = $made->get_error_data( 'term_exists' ); if ( ! is_numeric( $existing ) ) { throw new \RuntimeException( 'media_action_folder' ); } $parent = (int) $existing; continue; } $parent = (int) $made['term_id']; } return $parent; } /** * Normal WordPress uploads, with a per-file receipt stored on the attachment. 1.9.0: $paths, when given, holds * each file's folder path inside $folder (a folder chosen from the computer); its folders are found or made. * * @param array $files PHP's upload fields (name, tmp_name, error, size, type), one to eight files. * @param int $folder The chosen folder, or 0. * @param string $request The form's request ID (32 hexadecimal characters). * @param array $paths Optional: one folder path per file, in the same order. * @return array ids and errors. * @throws \RuntimeException When the request is not valid. */ public static function upload( array $files, int $folder, string $request, array $paths = array() ): array { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { self::authorize(); self::folder( $folder ); if ( ! preg_match( '/^[a-f0-9]{32}$/D', $request ) || ! isset( $files['name'] ) || ! is_array( $files['name'] ) || count( $files['name'] ) < 1 || count( $files['name'] ) > 8 ) { throw new \RuntimeException( 'media_action_upload' ); } if ( $paths && count( $paths ) !== count( $files['name'] ) ) { throw new \RuntimeException( 'media_action_upload' ); } $names = array_map( array( self::class, 'path_names' ), array_values( $paths ) ); return self::locked( 'upload:' . get_current_user_id(), static function () use ( $files, $folder, $request, $names ) { require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Preserve and restore the prior PHP upload slot around media_handle_upload(); Admin::perform() verifies capability and nonce before this helper. Save this existing upload slot solely to restore it in finally; its contents are not interpreted or used as a filesystem path. $ids = array(); $errors = array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.NonceVerification.Missing -- Preserve and restore the prior PHP upload slot around media_handle_upload(); Admin::perform() verifies capability and nonce before this helper. Save this existing upload slot solely to restore it in finally; its contents are not interpreted or used as a filesystem path. $previous = $_FILES['afsd_im_upload_one'] ?? null; try { foreach ( array_values( $files['name'] ) as $i => $name ) { $file = array(); foreach ( array( 'name', 'tmp_name', 'error', 'size', 'type' ) as $field ) { if ( ! isset( $files[ $field ][ $i ] ) || ! is_scalar( $files[ $field ][ $i ] ) ) { throw new \RuntimeException( 'media_action_upload' ); } $file[ $field ] = $files[ $field ][ $i ]; } $receipt = get_current_user_id() . ':' . $request . ':' . $i; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Bounded administrator media listing or per-file upload receipt lookup; taxonomy/metadata selection is required for the requested folder/collection and retry identity. $existing = get_posts( array( 'post_type' => 'attachment', 'post_status' => 'inherit', 'posts_per_page' => 1, 'fields' => 'ids', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Bounded administrator media listing or per-file upload receipt lookup; taxonomy/metadata selection is required for the requested folder/collection and retry identity. 'meta_key' => '_afsd_im_upload_receipt', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Bounded administrator media listing or per-file upload receipt lookup; taxonomy/metadata selection is required for the requested folder/collection and retry identity. 'meta_value' => $receipt, ) ); if ( $existing && current_user_can( 'edit_post', $existing[0] ) ) { $id = (int) $existing[0]; } else { $_FILES['afsd_im_upload_one'] = $file; $id = media_handle_upload( 'afsd_im_upload_one', 0, array( 'meta_input' => array( '_afsd_im_upload_receipt' => $receipt ) ), array( 'test_form' => false ) ); if ( is_wp_error( $id ) ) { $errors[] = sanitize_file_name( (string) $name ) . ': ' . wp_strip_all_tags( $id->get_error_message() ); continue; } } if ( $existing && get_post_meta( $id, '_afsd_im_upload_complete', true ) === $receipt ) { $ids[] = $id; continue; } $complete = true; try { $target = empty( $names[ $i ] ) ? $folder : self::folder_for( $folder, $names[ $i ] ); } catch ( \RuntimeException $e ) { $target = 0; $complete = false; /* translators: %d: attachment ID of the uploaded media item. */ $errors[] = sprintf( __( 'Media #%d was uploaded, but its folder could not be assigned.', 'affstackdev-image-manager' ), $id ); } if ( $target ) { $assigned = Reviewed_Store::wp_set_object_terms( $id, array( $target ), Library::FOLDER, false ); if ( is_wp_error( $assigned ) ) { /* translators: %d: attachment ID of the uploaded media item. */ $errors[] = sprintf( __( 'Media #%d was uploaded, but its folder could not be assigned.', 'affstackdev-image-manager' ), $id ); $complete = false; } } if ( $complete ) { Reviewed_Store::update_post_meta( $id, '_afsd_im_upload_complete', $receipt ); } $ids[] = $id; } } finally { if ( null === $previous ) { unset( $_FILES['afsd_im_upload_one'] ); } else { $_FILES['afsd_im_upload_one'] = $previous; } } return array( 'ids' => $ids, 'errors' => $errors, ); } ); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * The words for a media action's reason code. * * @param string $code The reason code. * @return string The translated words. */ public static function human_error( string $code ): string { $messages = array( 'media_action_access' => __( 'You do not have permission to change this media item.', 'affstackdev-image-manager' ), 'media_action_storage' => __( 'This operation needs a private temporary folder outside your public website with verified owner-only access. Ask your host to check its location and permissions.', 'affstackdev-image-manager' ), 'media_action_source' => __( 'A readable local source with a matching file type is required. Remote and indirect paths cannot be changed here.', 'affstackdev-image-manager' ), 'media_action_image' => __( 'Use a valid JPEG, PNG, WebP or AVIF up to 64 MiB, 24 megapixels and 16,384 pixels per side.', 'affstackdev-image-manager' ), 'media_action_changed' => __( 'The source or its metadata changed after the preview. Prepare a new preview before continuing.', 'affstackdev-image-manager' ), 'media_action_folder' => __( 'That folder no longer exists. Choose an existing folder and try again.', 'affstackdev-image-manager' ), 'media_action_upload' => __( 'Choose one to eight files within your site’s upload limit, then submit the upload form again.', 'affstackdev-image-manager' ), ); return $messages[ $code ] ?? __( 'The media operation could not finish. Reload its saved status before trying again.', 'affstackdev-image-manager' ); } }