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__( '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' ) . '
' . 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 ) ); } } }