get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s ORDER BY option_id LIMIT 100", $wpdb->esc_like( self::PREFIX ) . '%' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- bounded cleanup scan of this plugin's expiring staged-input option rows by prefix foreach ( $names as $name ) { $r = Reviewed_Store::raw( $name ); if ( ! is_array( $r ) || ( $r['expires'] ?? 0 ) >= time() ) { continue; } foreach ( (array) ( $r['files'] ?? array() ) as $f ) { if ( is_string( $f['stored'] ?? null ) && preg_match( '/^[a-f0-9]{32}-[0-7]-[^\/\\\\]+$/D', $f['stored'] ) ) { try { $path = self::directory() . '/' . $f['stored']; if ( ! is_link( $path ) && is_file( $path ) ) { wp_delete_file( $path ); } } catch ( \Throwable $ignored ) { // A file that cannot be deleted is left in place; its expired record is removed below either way. } } } delete_option( $name ); } $left = $wpdb->get_var( $wpdb->prepare( "SELECT option_id FROM {$wpdb->options} WHERE option_name LIKE %s LIMIT 1", $wpdb->esc_like( self::PREFIX ) . '%' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- bounded existence probe of this plugin's staged-input option rows by prefix to reschedule cleanup if ( $left && ! wp_next_scheduled( 'afsd_im_reviewed_input_cleanup' ) ) { wp_schedule_single_event( time() + HOUR_IN_SECONDS, 'afsd_im_reviewed_input_cleanup' ); } } /** * Add the Image Manager AI inputs screen under Media, for administrators. */ public static function menu(): void { add_submenu_page( 'upload.php', __( 'Image Manager AI inputs', 'affstackdev-image-manager' ), __( 'Image Manager AI inputs', 'affstackdev-image-manager' ), 'manage_options', 'afsd-im-reviewed-inputs', array( __CLASS__, 'render' ) ); } /** * The inputs screen's address. * * @return string The address. */ public static function url(): string { return admin_url( 'upload.php?page=afsd-im-reviewed-inputs' ); } /** * This site's identity for inputs (the AI connector's context when it is there). * * @return string The identity. */ public static function site(): string { return class_exists( '\AFSD_Connector_Store' ) ? \AFSD_Connector_Store::context() : hash( 'sha256', home_url( '/' ) . '|' . get_current_blog_id() . '|' . wp_salt( 'auth' ) ); } /** * The private folder for staged files, in the system's temporary folder. * * @param bool $create Whether to create it when it is missing. * @return string The folder's path. */ private static function directory( bool $create = false ): string { return Private_Storage::directory( rtrim( sys_get_temp_dir(), '/' ) . '/afsd-im-reviewed-inputs-' . substr( hash( 'sha256', ABSPATH . '|' . get_current_blog_id() ), 0, 20 ), $create ); } /** * The secret inputs that code extending Image Manager adds to the screen (none by default). * * @return array Kind => array( 'label' => its name in the purpose list, 'field' => optional label of the secret * field, 'max' => the longest value in bytes, at most 1024 ). */ private static function extra_kinds(): array { /** * Filters the secret inputs that code extending Image Manager adds to the AI inputs screen. * * @since 1.9.0 * * @param array $kinds Kind => array( 'label' => its name in the purpose list, 'field' => optional label of the * secret field, 'max' => the longest value in bytes, at most 1024 ). */ $kinds = apply_filters( 'afsd_im_secret_kinds', array() ); $out = array(); foreach ( is_array( $kinds ) ? $kinds : array() as $kind => $spec ) { if ( is_string( $kind ) && preg_match( '/^[a-z][a-z0-9_]{0,31}$/D', $kind ) && ! in_array( $kind, self::KINDS, true ) && is_array( $spec ) && is_string( $spec['label'] ?? null ) ) { $spec['max'] = max( 1, min( 1024, (int) ( $spec['max'] ?? 1024 ) ) ); $out[ $kind ] = $spec; } } return $out; } /** * Encrypt a secret with AES-256-GCM under a key derived from the site's auth salt. * * @param array $secret The secret's fields. * @return string The sealed secret. * @throws \RuntimeException With reviewed_secret_unavailable when PHP has no OpenSSL. */ private static function seal( array $secret ): string { if ( ! function_exists( 'openssl_encrypt' ) ) { throw new \RuntimeException( 'reviewed_secret_unavailable' ); } $iv = random_bytes( 12 ); $tag = ''; $data = openssl_encrypt( wp_json_encode( $secret ), 'aes-256-gcm', hash( 'sha256', wp_salt( 'auth' ) . '|im-reviewed-input-v1', true ), OPENSSL_RAW_DATA, $iv, $tag ); if ( false === $data || strlen( $tag ) !== 16 ) { throw new \RuntimeException( 'reviewed_secret_unavailable' ); } return base64_encode( $iv . $tag . $data ); } /** * An input of the current administrator on this site, of this kind and not expired. * * @param string $id The input's reference. * @param string $kind Its kind. * @return array The input. * @throws \RuntimeException With reviewed_input_missing when there is no such input. */ public static function get( string $id, string $kind ): array { if ( ! preg_match( '/^[a-f0-9]{32}$/D', $id ) ) { throw new \RuntimeException( 'reviewed_input_missing' ); } $r = Reviewed_Store::raw( self::PREFIX . $id ); if ( ! is_array( $r ) || $r['kind'] !== $kind || $r['actor'] !== get_current_user_id() || $r['site'] !== self::site() || $r['expires'] < time() ) { throw new \RuntimeException( 'reviewed_input_missing' ); } if ( ! empty( $r['marker'] ) && ! hash_equals( $r['marker'], Reviewed_Store::execution_marker() ) ) { throw new \RuntimeException( 'reviewed_input_claimed' ); } foreach ( $r['files'] as $f ) { $p = self::directory() . '/' . $f['stored']; if ( is_link( $p ) || ! is_file( $p ) || filesize( $p ) !== $f['bytes'] || ! hash_equals( $f['sha256'], hash_file( 'sha256', $p ) ) ) { throw new \RuntimeException( 'reviewed_input_changed' ); } } return $r; } /** * What a prepared change records about an input (never the secret itself). * * @param string $id The input's reference. * @param string $kind Its kind. * @return array Reference, kind, actor, site, expiry, the files' names, sizes, types and hashes, whether a secret is * there, and a fingerprint of the input. */ public static function identity( string $id, string $kind ): array { $r = self::get( $id, $kind ); return array( 'reference' => $id, 'kind' => $kind, 'actor' => $r['actor'], 'site' => $r['site'], 'expires' => $r['expires'], 'files' => array_map( static fn( $f ) => array( 'name' => $f['name'], 'bytes' => $f['bytes'], 'mime' => $f['mime'], 'sha256' => $f['sha256'], ), $r['files'] ), 'secret_ready' => ! empty( $r['secret'] ), 'fingerprint' => Reviewed_Store::hash( $r ), ); } /** * An input's secret, decrypted. * * @param string $id The input's reference. * @param string $kind Its kind. * @return array The secret's fields. * @throws \RuntimeException With reviewed_input_missing or reviewed_input_changed when it cannot be read. */ public static function secret( string $id, string $kind ): array { $r = self::get( $id, $kind ); $raw = base64_decode( $r['secret'], true ); if ( ! is_string( $raw ) || strlen( $raw ) < 29 ) { throw new \RuntimeException( 'reviewed_input_missing' ); } $plain = openssl_decrypt( substr( $raw, 28 ), 'aes-256-gcm', hash( 'sha256', wp_salt( 'auth' ) . '|im-reviewed-input-v1', true ), OPENSSL_RAW_DATA, substr( $raw, 0, 12 ), substr( $raw, 12, 16 ) ); $out = is_string( $plain ) ? json_decode( $plain, true ) : null; if ( ! is_array( $out ) ) { throw new \RuntimeException( 'reviewed_input_changed' ); }return $out; } /** * One staged file of an input, with its path in the private folder. * * @param string $id The input's reference. * @param string $kind Its kind. * @param int $index Which file. * @return array The file's record with its path. * @throws \RuntimeException With reviewed_input_missing when the input has no such file. */ public static function file( string $id, string $kind, int $index = 0 ): array { $r = self::get( $id, $kind ); if ( ! isset( $r['files'][ $index ] ) ) { throw new \RuntimeException( 'reviewed_input_missing' ); }$f = $r['files'][ $index ]; $f['path'] = self::directory() . '/' . $f['stored']; return $f; } /** * Claim an input for one execution (its marker); another execution cannot use it. * * @param string $id The input's reference. * @param string $kind Its kind. * @param string $marker The execution's marker. * @throws \RuntimeException With reviewed_input_claimed when another execution claimed it. */ public static function consume( string $id, string $kind, string $marker ): void { Reviewed_Store::guard(); $r = self::get( $id, $kind ); if ( ! empty( $r['marker'] ) && ! hash_equals( $r['marker'], $marker ) ) { throw new \RuntimeException( 'reviewed_input_claimed' ); } $r['marker'] = $marker; update_option( self::PREFIX . $id, $r, false ); if ( Reviewed_Store::raw( self::PREFIX . $id ) !== $r ) { throw new \RuntimeException( 'reviewed_input_claimed' ); } } /** * Native WordPress sideload from a validated human staging reference, never a model-supplied path or URL. * * @param string $ref The input's reference. * @param int $folder The folder for the uploads (0 for none). * @param string $marker The execution's marker. * @return array The new attachments' IDs. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function upload( string $ref, int $folder, string $marker ): array { $r = self::get( $ref, 'media_upload' ); Media_Actions::folder( $folder ); self::consume( $ref, 'media_upload', $marker ); require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; $ids = array(); foreach ( $r['files'] as $i => $file ) { Reviewed_Store::guard(); $f = self::file( $ref, 'media_upload', $i ); $tmp = self::directory( true ) . '/transfer-' . bin2hex( random_bytes( 12 ) ) . '-' . $f['name']; if ( ! copy( $f['path'], $tmp ) ) { throw new \RuntimeException( 'reviewed_input_changed' ); } $id = media_handle_sideload( array( 'name' => $f['name'], 'tmp_name' => $tmp, 'error' => 0, 'size' => $f['bytes'], ), 0, null, array( 'meta_input' => array( '_afsd_im_reviewed_marker' => $marker ) ) ); if ( is_wp_error( $id ) ) { wp_delete_file( $tmp ); throw new \RuntimeException( 'reviewed_upload_failed' ); } $ids[] = (int) $id; if ( $folder ) { Reviewed_Store::guard(); $assigned = wp_set_object_terms( $id, array( $folder ), Library::FOLDER, false ); if ( is_wp_error( $assigned ) ) { throw new \RuntimeException( 'reviewed_upload_folder_failed' ); } } } return array( 'ids' => $ids ); } /** * Prepare a replacement draft from a staged file. * * @param int $id The attachment. * @param string $ref The input's reference. * @param string $marker The execution's marker. * @return array The draft. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function replacement( int $id, string $ref, string $marker ): array { $f = self::file( $ref, 'replacement' ); self::consume( $ref, 'replacement', $marker ); return Media_Actions::locked( 'attachment:' . $id, static function () use ( $id, $f ) { $revision = Media_Replacement::revision( $id ); if ( in_array( $revision['state'] ?? '', array( 'applying', 'undoing' ), true ) ) { throw new \RuntimeException( 'media_replace_recovery' ); } $source = Media_Actions::source( $id ); Media_Actions::image_info( $source['path'] ); $info = Media_Actions::image_info( $f['path'] ); if ( $source['mime'] !== $info['mime'] ) { throw new \RuntimeException( 'media_replace_type' ); } Reviewed_Store::guard(); return Media_Draft::store( $id, 'replace', $f['path'], $source, array( 'operation' => 'same_id_new_file', 'filename' => $f['name'], ) ); } ); } /** A missing capability is a plain refusal, never an uncaught exception (F-IM-02). */ private static function permitted(): void { try { Media_Actions::authorize(); } catch ( \RuntimeException $e ) { wp_die( esc_html( Media_Actions::human_error( $e->getMessage() ) ), esc_html__( 'Image Manager inputs', 'affstackdev-image-manager' ), array( 'response' => 403 ) ); } } /** * Print the inputs screen: the form to stage files or enter a secret. */ public static function render(): void { self::permitted(); echo '

' . esc_html__( 'Image Manager AI inputs', 'affstackdev-image-manager' ) . '

' . esc_html__( 'Stage files or enter secrets here before asking the assistant to prepare their use. Only the reference goes into the AI project. Staging does not publish media, connect an account or apply a plugin change.', 'affstackdev-image-manager' ) . '

'; wp_nonce_field( 'afsd_im_reviewed_input' ); $kinds = array( 'media_upload' => __( 'Media upload (one to eight files)', 'affstackdev-image-manager' ), 'replacement' => __( 'Replacement image (one file)', 'affstackdev-image-manager' ), 'backup_import' => __( 'Portable backup ZIP (one file)', 'affstackdev-image-manager' ), 'backup_password' => __( 'Backup password', 'affstackdev-image-manager' ), 'remote_credentials' => __( 'Cloud credentials / scheduled password', 'affstackdev-image-manager' ), ); $field = __( 'Backup password', 'affstackdev-image-manager' ); foreach ( self::extra_kinds() as $kind => $spec ) { $kinds[ $kind ] = $spec['label']; $field = is_string( $spec['field'] ?? null ) ? $spec['field'] : $field; } echo '

'; foreach ( array( 'google_client_secret' => __( 'Google client secret', 'affstackdev-image-manager' ), 's3_access_key' => __( 'S3 access key', 'affstackdev-image-manager' ), 's3_secret_key' => __( 'S3 secret key', 'affstackdev-image-manager' ), 'remote_schedule_password' => __( 'Scheduled archive password', 'affstackdev-image-manager' ), 'remote_schedule_password_confirm' => __( 'Confirm scheduled password', 'affstackdev-image-manager' ), ) as $k => $v ) { echo '

'; } echo '

' . esc_html__( 'Use cloud fields only for Cloud credentials. Blank credential fields retain their existing value when applied. File references expire after 24 hours; secret references expire after 10 minutes. A reference belongs to this WordPress account and site.', 'affstackdev-image-manager' ) . '

'; } /** * Stage the files or secret the form sent (a POST with a valid nonce) and show its reference. * * @throws \RuntimeException Only inside this function, where it is caught; callers get no exception. */ public static function handle(): void { self::permitted(); if ( ! isset( $_SERVER['REQUEST_METHOD'] ) || 'POST' !== $_SERVER['REQUEST_METHOD'] ) { wp_die( esc_html__( 'Native POST required.', 'affstackdev-image-manager' ), '', array( 'response' => 405 ) ); }check_admin_referer( 'afsd_im_reviewed_input' ); $d = Plugin::fields( array( 'kind', 'secret_value', 'secret_confirm', 'google_client_secret', 's3_access_key', 's3_secret_key', 'remote_schedule_password', 'remote_schedule_password_confirm' ) ); $kind = is_string( $d['kind'] ?? null ) ? $d['kind'] : ''; $extra = self::extra_kinds(); if ( ! in_array( $kind, self::KINDS, true ) && ! isset( $extra[ $kind ] ) ) { wp_die( esc_html__( 'Choose a supported input purpose.', 'affstackdev-image-manager' ) ); } $id = bin2hex( random_bytes( 16 ) ); $files = array(); $secret = array(); try { if ( in_array( $kind, array( 'media_upload', 'replacement', 'backup_import' ), true ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- upload array is validated per file below: is_uploaded_file(), UPLOAD_ERR_OK, size limits, sanitize_file_name() and wp_check_filetype_and_ext() $upload = $_FILES['input_files'] ?? array(); $names = $upload['name'] ?? array(); $max = 'media_upload' === $kind ? 8 : 1; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- upload array is validated per file below: is_uploaded_file(), UPLOAD_ERR_OK, size limits, sanitize_file_name() and wp_check_filetype_and_ext() if ( ! is_array( $names ) || count( $names ) < 1 || count( $names ) > $max ) { throw new \RuntimeException( 'file_count' ); } $dir = self::directory( true ); foreach ( array_values( $names ) as $i => $name ) { $tmp = $upload['tmp_name'][ $i ] ?? ''; if ( ! is_string( $name ) || ! is_string( $tmp ) || (int) ( $upload['error'][ $i ] ?? -1 ) !== UPLOAD_ERR_OK || ! is_uploaded_file( $tmp ) || is_link( $tmp ) ) { throw new \RuntimeException( 'upload' ); } $bytes = filesize( $tmp ); $limit = 'backup_import' === $kind ? 2147483648 : 67108864; if ( ! $bytes || $bytes > $limit || $bytes > wp_max_upload_size() ) { throw new \RuntimeException( 'file_size' ); } $name = sanitize_file_name( $name ); $type = wp_check_filetype_and_ext( $tmp, $name ); $mime = (string) ( $type['type'] ?? '' ); if ( 'replacement' === $kind ) { $info = Media_Actions::image_info( $tmp ); if ( $mime !== $info['mime'] ) { throw new \RuntimeException( 'file_type' ); } } elseif ( 'backup_import' === $kind ) { if ( ! str_ends_with( strtolower( $name ), '.zip' ) || ! class_exists( '\ZipArchive' ) ) { throw new \RuntimeException( 'file_type' ); }$z = new \ZipArchive(); if ( true !== $z->open( $tmp, \ZipArchive::CHECKCONS ) ) { throw new \RuntimeException( 'file_type' ); }$z->close(); $mime = 'application/zip'; } elseif ( '' === $mime || ! in_array( $mime, array_values( get_allowed_mime_types() ), true ) ) { throw new \RuntimeException( 'file_type' ); } $stored = $id . '-' . $i . '-' . $name; try { // WordPress's uploader moves it into the private staging folder, never under uploads. Private_Storage::receive_upload( array( 'name' => $stored, 'type' => $mime, 'tmp_name' => $tmp, 'error' => UPLOAD_ERR_OK, 'size' => (int) $bytes, ), $dir, $stored, 'backup_import' === $kind ? array( 'zip' => 'application/zip' ) : null ); } catch ( \RuntimeException $e ) { throw new \RuntimeException( 'upload' ); } $files[] = array( 'stored' => $stored, 'name' => $name, 'bytes' => (int) $bytes, 'mime' => $mime, 'sha256' => hash_file( 'sha256', $dir . '/' . $stored ), ); } } elseif ( 'remote_credentials' === $kind ) { foreach ( array( 'google_client_secret', 's3_access_key', 's3_secret_key', 'remote_schedule_password', 'remote_schedule_password_confirm' ) as $k ) { if ( is_string( $d[ $k ] ?? null ) && '' !== $d[ $k ] ) { if ( strlen( $d[ $k ] ) > 1024 ) { throw new \RuntimeException( 'secret' ); }$secret[ $k ] = $d[ $k ]; } }if ( ! $secret ) { throw new \RuntimeException( 'secret' ); }if ( isset( $secret['remote_schedule_password'] ) && ( strlen( $secret['remote_schedule_password'] ) < 8 || $secret['remote_schedule_password'] !== ( $secret['remote_schedule_password_confirm'] ?? '' ) ) ) { throw new \RuntimeException( 'secret' ); } } else { $v = $d['secret_value'] ?? null; if ( ! is_string( $v ) || '' === $v || strlen( $v ) > ( $extra[ $kind ]['max'] ?? 1024 ) ) { throw new \RuntimeException( 'secret' ); }if ( 'backup_password' === $kind && ( strlen( $v ) < 8 || $v !== ( $d['secret_confirm'] ?? '' ) ) ) { throw new \RuntimeException( 'secret' ); }$secret['value'] = $v; } $r = array( 'schema' => 1, 'kind' => $kind, 'actor' => get_current_user_id(), 'site' => self::site(), 'expires' => time() + ( $files ? DAY_IN_SECONDS : 10 * MINUTE_IN_SECONDS ), 'files' => $files, 'secret' => $secret ? self::seal( $secret ) : '', 'marker' => '', ); if ( ! add_option( self::PREFIX . $id, $r, '', false ) ) { throw new \RuntimeException( 'storage' ); } if ( ! wp_next_scheduled( 'afsd_im_reviewed_input_cleanup' ) ) { wp_schedule_single_event( time() + HOUR_IN_SECONDS, 'afsd_im_reviewed_input_cleanup' ); } unset( $d, $secret, $v ); nocache_headers(); header( 'X-Robots-Tag: noindex, nofollow' ); wp_die( '

' . esc_html__( 'Secure input staged. Give this reference to the assistant and review its proposed use before applying:', 'affstackdev-image-manager' ) . '

' . esc_html( $id ) . '

' . esc_html__( 'Return to Image Manager AI inputs', 'affstackdev-image-manager' ) . '

', esc_html__( 'Secure Image Manager input', 'affstackdev-image-manager' ), array( 'response' => 200 ) ); } catch ( \Throwable $e ) { foreach ( $files as $f ) { try { wp_delete_file( self::directory() . '/' . $f['stored'] ); } catch ( \Throwable $ignored ) { // Cleaning up after a failed staging is best effort; the failure is reported below either way. } }wp_die( esc_html__( 'The input could not be staged. Check the purpose, file count/type/size, matching password and private temporary storage.', 'affstackdev-image-manager' ), esc_html__( 'Image Manager inputs', 'affstackdev-image-manager' ), array( 'response' => 400 ) ); } } }