diff --git a/.sqlx/query-3681e931cc87714d71c95da44d6dd92568ce58aea252164ab3d5f8e96491169c.json b/.sqlx/query-3681e931cc87714d71c95da44d6dd92568ce58aea252164ab3d5f8e96491169c.json new file mode 100644 index 0000000000..3be6d4b902 --- /dev/null +++ b/.sqlx/query-3681e931cc87714d71c95da44d6dd92568ce58aea252164ab3d5f8e96491169c.json @@ -0,0 +1,245 @@ +{ + "db_name": "MySQL", + "query": "\nSELECT\n mf.id as `media_file_id: u64`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM media_files mf\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE mf.maybe_creator_user_token = ?\n AND mf.media_class = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND NOT mf.is_intermediate_system_file\n AND NOT EXISTS (\n SELECT 1\n FROM folder_media_files fmf\n JOIN folders f\n ON f.token = fmf.folder_token\n AND f.maybe_deleted_at IS NULL\n WHERE fmf.media_file_token = mf.token\n )\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "media_file_id: u64", + "type_info": { + "type": "LongLong", + "flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT", + "char_set": 63, + "max_size": 20 + } + }, + { + "ordinal": 1, + "name": "media_file_token: MediaFileToken", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 2, + "name": "media_class: MediaFileClass", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | MULTIPLE_KEY | BINARY", + "char_set": 224, + "max_size": 64 + } + }, + { + "ordinal": 3, + "name": "media_type: MediaFileType", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 64 + } + }, + { + "ordinal": 4, + "name": "maybe_batch_token: BatchGenerationToken", + "type_info": { + "type": "VarString", + "flags": "MULTIPLE_KEY | BINARY", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 5, + "name": "public_bucket_directory_hash", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 6, + "name": "maybe_public_bucket_prefix", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 64 + } + }, + { + "ordinal": 7, + "name": "maybe_public_bucket_extension", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 64 + } + }, + { + "ordinal": 8, + "name": "maybe_cover_public_bucket_directory_hash?", + "type_info": { + "type": "VarString", + "flags": "BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 9, + "name": "maybe_cover_public_bucket_prefix?", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 64 + } + }, + { + "ordinal": 10, + "name": "maybe_cover_public_bucket_extension?", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 64 + } + }, + { + "ordinal": 11, + "name": "creator_set_visibility: Visibility", + "type_info": { + "type": "String", + "flags": "NOT_NULL | BINARY | ENUM", + "char_set": 224, + "max_size": 28 + } + }, + { + "ordinal": 12, + "name": "is_user_upload: bool", + "type_info": { + "type": "Tiny", + "flags": "NOT_NULL | MULTIPLE_KEY", + "char_set": 63, + "max_size": 1 + } + }, + { + "ordinal": 13, + "name": "maybe_title", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 1020 + } + }, + { + "ordinal": 14, + "name": "maybe_prompt_token: PromptToken", + "type_info": { + "type": "VarString", + "flags": "MULTIPLE_KEY | BINARY", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 15, + "name": "maybe_origin_filename", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 1020 + } + }, + { + "ordinal": 16, + "name": "maybe_duration_millis", + "type_info": { + "type": "Long", + "flags": "", + "char_set": 63, + "max_size": 10 + } + }, + { + "ordinal": 17, + "name": "maybe_frame_width", + "type_info": { + "type": "Long", + "flags": "", + "char_set": 63, + "max_size": 5 + } + }, + { + "ordinal": 18, + "name": "maybe_frame_height", + "type_info": { + "type": "Long", + "flags": "", + "char_set": 63, + "max_size": 5 + } + }, + { + "ordinal": 19, + "name": "created_at", + "type_info": { + "type": "Timestamp", + "flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP", + "char_set": 63, + "max_size": 19 + } + }, + { + "ordinal": 20, + "name": "updated_at", + "type_info": { + "type": "Timestamp", + "flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP | ON_UPDATE_NOW", + "char_set": 63, + "max_size": 19 + } + } + ], + "parameters": { + "Right": 4 + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + true, + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + false + ] + }, + "hash": "3681e931cc87714d71c95da44d6dd92568ce58aea252164ab3d5f8e96491169c" +} diff --git a/.sqlx/query-6f077c5d35b19319aba4e9f4c59610822334fd304ba9133ba8e099056c426f2a.json b/.sqlx/query-6f077c5d35b19319aba4e9f4c59610822334fd304ba9133ba8e099056c426f2a.json new file mode 100644 index 0000000000..31a80e18ad --- /dev/null +++ b/.sqlx/query-6f077c5d35b19319aba4e9f4c59610822334fd304ba9133ba8e099056c426f2a.json @@ -0,0 +1,245 @@ +{ + "db_name": "MySQL", + "query": "\nSELECT\n mf.id as `media_file_id: u64`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM media_files mf\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE mf.maybe_creator_user_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND NOT mf.is_intermediate_system_file\n AND NOT EXISTS (\n SELECT 1\n FROM folder_media_files fmf\n JOIN folders f\n ON f.token = fmf.folder_token\n AND f.maybe_deleted_at IS NULL\n WHERE fmf.media_file_token = mf.token\n )\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "media_file_id: u64", + "type_info": { + "type": "LongLong", + "flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT", + "char_set": 63, + "max_size": 20 + } + }, + { + "ordinal": 1, + "name": "media_file_token: MediaFileToken", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 2, + "name": "media_class: MediaFileClass", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | MULTIPLE_KEY | BINARY", + "char_set": 224, + "max_size": 64 + } + }, + { + "ordinal": 3, + "name": "media_type: MediaFileType", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 64 + } + }, + { + "ordinal": 4, + "name": "maybe_batch_token: BatchGenerationToken", + "type_info": { + "type": "VarString", + "flags": "MULTIPLE_KEY | BINARY", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 5, + "name": "public_bucket_directory_hash", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 6, + "name": "maybe_public_bucket_prefix", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 64 + } + }, + { + "ordinal": 7, + "name": "maybe_public_bucket_extension", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 64 + } + }, + { + "ordinal": 8, + "name": "maybe_cover_public_bucket_directory_hash?", + "type_info": { + "type": "VarString", + "flags": "BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 9, + "name": "maybe_cover_public_bucket_prefix?", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 64 + } + }, + { + "ordinal": 10, + "name": "maybe_cover_public_bucket_extension?", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 64 + } + }, + { + "ordinal": 11, + "name": "creator_set_visibility: Visibility", + "type_info": { + "type": "String", + "flags": "NOT_NULL | BINARY | ENUM", + "char_set": 224, + "max_size": 28 + } + }, + { + "ordinal": 12, + "name": "is_user_upload: bool", + "type_info": { + "type": "Tiny", + "flags": "NOT_NULL | MULTIPLE_KEY", + "char_set": 63, + "max_size": 1 + } + }, + { + "ordinal": 13, + "name": "maybe_title", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 1020 + } + }, + { + "ordinal": 14, + "name": "maybe_prompt_token: PromptToken", + "type_info": { + "type": "VarString", + "flags": "MULTIPLE_KEY | BINARY", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 15, + "name": "maybe_origin_filename", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 1020 + } + }, + { + "ordinal": 16, + "name": "maybe_duration_millis", + "type_info": { + "type": "Long", + "flags": "", + "char_set": 63, + "max_size": 10 + } + }, + { + "ordinal": 17, + "name": "maybe_frame_width", + "type_info": { + "type": "Long", + "flags": "", + "char_set": 63, + "max_size": 5 + } + }, + { + "ordinal": 18, + "name": "maybe_frame_height", + "type_info": { + "type": "Long", + "flags": "", + "char_set": 63, + "max_size": 5 + } + }, + { + "ordinal": 19, + "name": "created_at", + "type_info": { + "type": "Timestamp", + "flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP", + "char_set": 63, + "max_size": 19 + } + }, + { + "ordinal": 20, + "name": "updated_at", + "type_info": { + "type": "Timestamp", + "flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP | ON_UPDATE_NOW", + "char_set": 63, + "max_size": 19 + } + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + true, + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + false, + false + ] + }, + "hash": "6f077c5d35b19319aba4e9f4c59610822334fd304ba9133ba8e099056c426f2a" +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/folders/media_files.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/folders/media_files.rs index 3a98e06492..ce0725562c 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/folders/media_files.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/folders/media_files.rs @@ -1,6 +1,7 @@ use serde_derive::{Deserialize, Serialize}; use utoipa::{IntoParams, ToSchema}; +use enums::by_table::media_files::media_file_class::MediaFileClass; use tokens::tokens::folders::FolderToken; use tokens::tokens::media_files::MediaFileToken; @@ -22,6 +23,28 @@ pub struct ListFolderMediaFilesQueryParams { pub limit: Option, } +// ── GET /v1/folders/media_files_without_folder ── +// +// NB: `ListMediaFilesWithoutFolderSuccessResponse` and its list item live in +// `storyteller_web`'s handler because the wire shape embeds +// `MediaFileCoverImageDetails` and `MediaLinks` constructors that depend +// on the request's `MediaDomain` + `ServerEnvironment`. + +#[derive(Deserialize, ToSchema, IntoParams)] +pub struct ListMediaFilesWithoutFolderQueryParams { + pub cursor: Option, + pub limit: Option, + + /// Optional filter on the coarse media class. + /// + /// Usage: + /// - `?filter_media_class=image` + /// - `?filter_media_class=video` + /// - `?filter_media_class=mesh` + /// - `?filter_media_class=splat` + pub filter_media_class: Option, +} + // ── PUT /v1/folders/media_files/{folder_token}/bulk_add ── #[derive(Deserialize, ToSchema)] diff --git a/crates/schema/database/mysql_queries/src/queries/folders/media_files/list_media_files_without_folder.rs b/crates/schema/database/mysql_queries/src/queries/folders/media_files/list_media_files_without_folder.rs new file mode 100644 index 0000000000..4e8a74d9c7 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/folders/media_files/list_media_files_without_folder.rs @@ -0,0 +1,216 @@ +use std::marker::PhantomData; + +use sqlx::{Executor, MySql}; + +use enums::by_table::media_files::media_file_class::MediaFileClass; +use enums::by_table::media_files::media_file_type::MediaFileType; +use enums::common::visibility::Visibility; +use tokens::tokens::batch_generations::BatchGenerationToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::prompts::PromptToken; +use tokens::tokens::users::UserToken; + +use crate::queries::media_files::list::media_file_list_row::MediaFileListRow; + +pub struct ListMediaFilesWithoutFolderArgs<'e, 'c, E> +where + E: 'e + Executor<'c, Database = MySql>, +{ + pub owner_user_token: &'e UserToken, + + /// Optional filter on the coarse media class (image / video / mesh / ...). + pub maybe_filter_media_class: Option, + + pub maybe_cursor_id: Option, + pub limit: u32, + + pub mysql_executor: E, + pub phantom: PhantomData<&'c E>, +} + +/// Paginated list of the user's media files that sit in no folder at all. +/// Memberships whose folder was soft-deleted (but whose link row wasn't +/// cleaned up) count as unfoldered. Excludes soft-deleted and intermediate +/// system files. Newest first (`media_files.id` descending; the id doubles +/// as the cursor). +/// +/// The NOT EXISTS probe is a point lookup on +/// `folder_media_files.index_media_file_token` plus a primary-key lookup +/// on `folders` for the soft-delete check — cheap per candidate row. +pub async fn list_media_files_without_folder<'e, 'c: 'e, E>( + args: ListMediaFilesWithoutFolderArgs<'e, 'c, E>, +) -> Result, sqlx::Error> +where + E: 'e + Executor<'c, Database = MySql>, +{ + // No-cursor requests use a sentinel above every possible id, which + // keeps this to a single static query per filter variant. + // (`media_files.id` is a signed BIGINT, so the sentinel is i64::MAX.) + let cursor_id = args.maybe_cursor_id.unwrap_or(i64::MAX as u64); + let limit = args.limit as i64; + + match args.maybe_filter_media_class { + Some(media_class) => { + select_page_with_class_filter( + args.owner_user_token, + media_class, + cursor_id, + limit, + args.mysql_executor, + ).await + } + None => { + select_page_all_classes( + args.owner_user_token, + cursor_id, + limit, + args.mysql_executor, + ).await + } + } +} + +// NB: The two queries below are intentionally duplicated so that sqlx can +// check them at compile time (no QueryBuilder). Keep the SELECT lists and +// predicates in sync; they differ only in the `media_class = ?` filter. + +async fn select_page_all_classes<'e, 'c: 'e, E>( + owner_user_token: &'e UserToken, + cursor_id: u64, + limit: i64, + mysql_executor: E, +) -> Result, sqlx::Error> +where + E: 'e + Executor<'c, Database = MySql>, +{ + sqlx::query_as!( + MediaFileListRow, + r#" +SELECT + mf.id as `media_file_id: u64`, + + mf.token as `media_file_token: MediaFileToken`, + mf.media_class as `media_class: MediaFileClass`, + mf.media_type as `media_type: MediaFileType`, + + mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`, + + mf.public_bucket_directory_hash, + mf.maybe_public_bucket_prefix, + mf.maybe_public_bucket_extension, + + cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`, + cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`, + cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`, + + mf.creator_set_visibility as `creator_set_visibility: Visibility`, + mf.is_user_upload as `is_user_upload: bool`, + + mf.maybe_title, + mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`, + mf.maybe_origin_filename, + + mf.maybe_duration_millis, + mf.maybe_frame_width, + mf.maybe_frame_height, + + mf.created_at, + mf.updated_at +FROM media_files mf +LEFT JOIN media_files cover + ON cover.token = mf.maybe_cover_image_media_file_token +WHERE mf.maybe_creator_user_token = ? + AND mf.user_deleted_at IS NULL + AND mf.mod_deleted_at IS NULL + AND NOT mf.is_intermediate_system_file + AND NOT EXISTS ( + SELECT 1 + FROM folder_media_files fmf + JOIN folders f + ON f.token = fmf.folder_token + AND f.maybe_deleted_at IS NULL + WHERE fmf.media_file_token = mf.token + ) + AND mf.id < ? +ORDER BY mf.id DESC +LIMIT ? + "#, + owner_user_token.as_str(), + cursor_id, + limit, + ) + .fetch_all(mysql_executor) + .await +} + +async fn select_page_with_class_filter<'e, 'c: 'e, E>( + owner_user_token: &'e UserToken, + media_class: MediaFileClass, + cursor_id: u64, + limit: i64, + mysql_executor: E, +) -> Result, sqlx::Error> +where + E: 'e + Executor<'c, Database = MySql>, +{ + sqlx::query_as!( + MediaFileListRow, + r#" +SELECT + mf.id as `media_file_id: u64`, + + mf.token as `media_file_token: MediaFileToken`, + mf.media_class as `media_class: MediaFileClass`, + mf.media_type as `media_type: MediaFileType`, + + mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`, + + mf.public_bucket_directory_hash, + mf.maybe_public_bucket_prefix, + mf.maybe_public_bucket_extension, + + cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`, + cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`, + cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`, + + mf.creator_set_visibility as `creator_set_visibility: Visibility`, + mf.is_user_upload as `is_user_upload: bool`, + + mf.maybe_title, + mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`, + mf.maybe_origin_filename, + + mf.maybe_duration_millis, + mf.maybe_frame_width, + mf.maybe_frame_height, + + mf.created_at, + mf.updated_at +FROM media_files mf +LEFT JOIN media_files cover + ON cover.token = mf.maybe_cover_image_media_file_token +WHERE mf.maybe_creator_user_token = ? + AND mf.media_class = ? + AND mf.user_deleted_at IS NULL + AND mf.mod_deleted_at IS NULL + AND NOT mf.is_intermediate_system_file + AND NOT EXISTS ( + SELECT 1 + FROM folder_media_files fmf + JOIN folders f + ON f.token = fmf.folder_token + AND f.maybe_deleted_at IS NULL + WHERE fmf.media_file_token = mf.token + ) + AND mf.id < ? +ORDER BY mf.id DESC +LIMIT ? + "#, + owner_user_token.as_str(), + media_class.to_str(), + cursor_id, + limit, + ) + .fetch_all(mysql_executor) + .await +} diff --git a/crates/schema/database/mysql_queries/src/queries/folders/media_files/mod.rs b/crates/schema/database/mysql_queries/src/queries/folders/media_files/mod.rs index e237e2c553..32763b1887 100644 --- a/crates/schema/database/mysql_queries/src/queries/folders/media_files/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/folders/media_files/mod.rs @@ -2,4 +2,5 @@ pub mod bulk_delete_folder_media_files; pub mod bulk_insert_folder_media_files; pub mod filter_existing_media_file_tokens; pub mod list_folder_media_files; +pub mod list_media_files_without_folder; pub mod recompute_folder_last_media_files; diff --git a/crates/service/web/storyteller_web/src/docs/api_doc.rs b/crates/service/web/storyteller_web/src/docs/api_doc.rs index 220182405b..09c0bae1cd 100644 --- a/crates/service/web/storyteller_web/src/docs/api_doc.rs +++ b/crates/service/web/storyteller_web/src/docs/api_doc.rs @@ -130,6 +130,7 @@ use crate::http_server::endpoints::folders::media_files::bulk_add_folder_media_f use crate::http_server::endpoints::folders::media_files::bulk_move_folder_media_files_handler::*; use crate::http_server::endpoints::folders::media_files::bulk_remove_folder_media_files_handler::*; use crate::http_server::endpoints::folders::media_files::list_folder_media_files_handler::*; +use crate::http_server::endpoints::folders::media_files::list_media_files_without_folder_handler::*; use crate::http_server::endpoints::folders::subfolder::bulk_add_subfolders_handler::*; use crate::http_server::endpoints::folders::subfolder::bulk_remove_subfolders_handler::*; use crate::http_server::endpoints::folders::subfolder::list_subfolders_handler::*; @@ -683,6 +684,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux crate::http_server::endpoints::folders::subfolder::bulk_add_subfolders_handler::bulk_add_subfolders_handler, crate::http_server::endpoints::folders::subfolder::bulk_remove_subfolders_handler::bulk_remove_subfolders_handler, crate::http_server::endpoints::folders::media_files::list_folder_media_files_handler::list_folder_media_files_handler, + crate::http_server::endpoints::folders::media_files::list_media_files_without_folder_handler::list_media_files_without_folder_handler, crate::http_server::endpoints::folders::media_files::bulk_add_folder_media_files_handler::bulk_add_folder_media_files_handler, crate::http_server::endpoints::folders::media_files::bulk_move_folder_media_files_handler::bulk_move_folder_media_files_handler, crate::http_server::endpoints::folders::media_files::bulk_remove_folder_media_files_handler::bulk_remove_folder_media_files_handler, @@ -1484,6 +1486,8 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux BulkRemoveSubfoldersSuccessResponse, ListFolderMediaFilesQueryParams, ListFolderMediaFilesSuccessResponse, + ListMediaFilesWithoutFolderQueryParams, + ListMediaFilesWithoutFolderSuccessResponse, BulkAddFolderMediaFilesRequest, BulkAddFolderMediaFilesSuccessResponse, BulkRemoveFolderMediaFilesRequest, @@ -1525,6 +1529,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux SetMediaFileTagsSuccessResponse, TagDetails, TagMediaFileListItem, + UnfolderedMediaFileListItem, )) )] pub struct ApiDoc; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/list_folders_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/list_folders_handler.rs index eb9bfed464..f2dcd32206 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/list_folders_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/list_folders_handler.rs @@ -55,13 +55,7 @@ pub async fn list_folders_handler( let maybe_cursor_id = match &query.cursor { None => None, Some(cursor_str) => { - let decoded = server_state.opaque_cursors - .decode_cursor_expecting_name(CURSOR_NAME, cursor_str) - .map_err(|err| { - warn!("Failed to decode cursor: {:?}", err); - CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string()) - })?; - decoded.last_id + Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?) } }; @@ -78,10 +72,7 @@ pub async fn list_folders_handler( let maybe_cursor = rows.last().map(|last| { server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.id) - }).transpose().map_err(|err| { - warn!("Failed to encode cursor: {:?}", err); - CommonWebError::server_error_with_message("Failed to encode cursor") - })?; + }).transpose()?; let media_domain = get_media_domain(&http_request); let server_environment = server_state.server_environment; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/list_folder_media_files_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/list_folder_media_files_handler.rs index 549ee2de7d..3de121672e 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/list_folder_media_files_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/list_folder_media_files_handler.rs @@ -148,13 +148,7 @@ pub async fn list_folder_media_files_handler( let maybe_cursor_id = match &query.cursor { None => None, Some(cursor_str) => { - let decoded = server_state.opaque_cursors - .decode_cursor_expecting_name(CURSOR_NAME, cursor_str) - .map_err(|err| { - warn!("Failed to decode cursor: {:?}", err); - CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string()) - })?; - decoded.last_id + Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?) } }; @@ -171,10 +165,7 @@ pub async fn list_folder_media_files_handler( let maybe_cursor = rows.last().map(|last| { server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.media_file_id) - }).transpose().map_err(|err| { - warn!("Failed to encode cursor: {:?}", err); - CommonWebError::server_error_with_message("Failed to encode cursor") - })?; + }).transpose()?; let media_domain = get_media_domain(&http_request); let server_environment = server_state.server_environment; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/list_media_files_without_folder_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/list_media_files_without_folder_handler.rs new file mode 100644 index 0000000000..9d41736e0b --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/list_media_files_without_folder_handler.rs @@ -0,0 +1,198 @@ +use std::marker::PhantomData; +use std::sync::Arc; + +use actix_web::web::{Json, Query}; +use actix_web::{web, HttpRequest}; +use chrono::{DateTime, Utc}; +use log::warn; +use serde_derive::Serialize; +use utoipa::ToSchema; + +use artcraft_api_defs::common::responses::media_links::MediaLinks; +use artcraft_api_defs::folders::media_files::ListMediaFilesWithoutFolderQueryParams; +use enums::by_table::media_files::media_file_class::MediaFileClass; +use enums::by_table::media_files::media_file_type::MediaFileType; +use enums::common::visibility::Visibility; +use mysql_queries::queries::folders::media_files::list_media_files_without_folder::{ + list_media_files_without_folder, ListMediaFilesWithoutFolderArgs, +}; +use mysql_queries::queries::media_files::list::media_file_list_row::MediaFileListRow; +use tokens::tokens::batch_generations::BatchGenerationToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::prompts::PromptToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::common_responses::media::media_domain::MediaDomain; +use crate::http_server::common_responses::media::media_file_cover_image_details::MediaFileCoverImageDetails; +use crate::http_server::common_responses::media::media_file_list_conversion::build_media_links_and_cover; +use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain; +use crate::http_server::user_lookup::user_session::require_user_session::require_user_session; +use crate::state::server_state::ServerState; + +const CURSOR_NAME: &str = "folders_unfoldered_mf"; +const DEFAULT_LIMIT: u32 = 100; +const MAX_LIMIT: u32 = 1000; + +// ── Response shape ── + +#[derive(Serialize, ToSchema)] +pub struct ListMediaFilesWithoutFolderSuccessResponse { + pub success: bool, + pub media_files: Vec, + + /// Cursor for the next page. Only present when this page was full — + /// a short page means the list is exhausted. + pub maybe_cursor: Option, +} + +/// One media file as it appears in the unfoldered list. Same lean shape as +/// the folder / tag list items. Intermediate system files are filtered out +/// by the query, so the flag isn't part of the wire shape. +#[derive(Serialize, ToSchema)] +pub struct UnfolderedMediaFileListItem { + pub token: MediaFileToken, + + /// Coarse-grained class (image / video / audio / mesh / splat / ...). + pub media_class: MediaFileClass, + + /// Specific format (jpg, png, mp4, glb, etc.) — closer to a MIME type. + pub media_type: MediaFileType, + + /// Link to the prompt + pub maybe_prompt_token: Option, + + /// If this file was generated as part of a batch, the batch token — + /// useful for showing "siblings" in the UI. + pub maybe_batch_token: Option, + + /// Rich CDN links to the media itself (full URL, thumbnail template, + /// video previews when applicable). + pub media_links: MediaLinks, + + /// Cover image details. For files that don't carry their own cover + /// image, this still gives the deterministic default-cover spec + /// (image_index + color_index) the frontend uses for placeholders. + pub cover_image: MediaFileCoverImageDetails, + + pub maybe_title: Option, + pub maybe_original_filename: Option, + + /// Original pixel width / height for image and video files when known. + pub maybe_frame_width: Option, + pub maybe_frame_height: Option, + + /// Duration for audio and video files, if available. Milliseconds. + pub maybe_duration_millis: Option, + + pub creator_set_visibility: Visibility, + pub is_user_upload: bool, + + pub created_at: DateTime, + pub updated_at: DateTime, +} + +// ── Handler ── + +/// Paginated list of the logged-in user's media files that sit in no folder +/// at all (memberships pointing at soft-deleted folders count as +/// unfoldered). Newest first. +/// +/// Use `filter_media_class` to scope the results to a single media class. +#[utoipa::path( + get, + tag = "Folders (Media File Management)", + path = "/v1/folders/media_files_without_folder", + params(ListMediaFilesWithoutFolderQueryParams), + responses( + (status = 200, body = ListMediaFilesWithoutFolderSuccessResponse), + (status = 400, body = CommonWebError), + (status = 401, body = CommonWebError), + (status = 500, body = CommonWebError), + ), +)] +pub async fn list_media_files_without_folder_handler( + http_request: HttpRequest, + query: Query, + server_state: web::Data>, +) -> Result, CommonWebError> { + let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| { + warn!("MySQL pool error: {:?}", err); + CommonWebError::from_error(err) + })?; + + let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?; + + let limit = query.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); + + let maybe_cursor_id = match &query.cursor { + None => None, + Some(cursor_str) => { + Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?) + } + }; + + let rows = list_media_files_without_folder(ListMediaFilesWithoutFolderArgs { + owner_user_token: &user_session.user_token, + maybe_filter_media_class: query.filter_media_class, + maybe_cursor_id, + limit, + mysql_executor: &mut *conn, + phantom: PhantomData, + }).await.map_err(|err| { + warn!("list_media_files_without_folder failed: {:?}", err); + CommonWebError::from_error(err) + })?; + + // Only hand out a next-page cursor when this page was full. A short page + // means the list is exhausted, and emitting a cursor anyway would make + // clients fetch one guaranteed-empty trailing page. + let maybe_cursor = if rows.len() == limit as usize { + rows.last() + .map(|last| server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.media_file_id)) + .transpose()? + } else { + None + }; + + let media_domain = get_media_domain(&http_request); + let server_environment = server_state.server_environment; + + let media_files = rows.into_iter() + .map(|row| unfoldered_media_file_row_to_list_item(row, media_domain, server_environment)) + .collect(); + + Ok(Json(ListMediaFilesWithoutFolderSuccessResponse { + success: true, + media_files, + maybe_cursor, + })) +} + +fn unfoldered_media_file_row_to_list_item( + row: MediaFileListRow, + media_domain: MediaDomain, + server_environment: server_environment::ServerEnvironment, +) -> UnfolderedMediaFileListItem { + let (media_links, cover_image) = + build_media_links_and_cover(&row, media_domain, server_environment); + + UnfolderedMediaFileListItem { + token: row.media_file_token, + media_class: row.media_class, + media_type: row.media_type, + maybe_batch_token: row.maybe_batch_token, + media_links, + cover_image, + creator_set_visibility: row.creator_set_visibility, + is_user_upload: row.is_user_upload, + maybe_title: row.maybe_title, + maybe_prompt_token: row.maybe_prompt_token, + maybe_original_filename: row.maybe_origin_filename, + // Schema stores `INT(10)`; widen to u64 for the wire shape. + maybe_duration_millis: row.maybe_duration_millis.map(|n| n as u64), + maybe_frame_width: row.maybe_frame_width, + maybe_frame_height: row.maybe_frame_height, + created_at: row.created_at, + updated_at: row.updated_at, + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/mod.rs index 4fa97b6329..8b13b8f1da 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/mod.rs @@ -2,3 +2,4 @@ pub mod bulk_add_folder_media_files_handler; pub mod bulk_move_folder_media_files_handler; pub mod bulk_remove_folder_media_files_handler; pub mod list_folder_media_files_handler; +pub mod list_media_files_without_folder_handler; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/subfolder/list_subfolders_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/subfolder/list_subfolders_handler.rs index 50f1a73e3b..8a950cfb8d 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/subfolder/list_subfolders_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/subfolder/list_subfolders_handler.rs @@ -77,13 +77,7 @@ pub async fn list_subfolders_handler( let maybe_cursor_id = match &query.cursor { None => None, Some(cursor_str) => { - let decoded = server_state.opaque_cursors - .decode_cursor_expecting_name(CURSOR_NAME, cursor_str) - .map_err(|err| { - warn!("Failed to decode cursor: {:?}", err); - CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string()) - })?; - decoded.last_id + Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?) } }; @@ -101,10 +95,7 @@ pub async fn list_subfolders_handler( let maybe_cursor = rows.last().map(|last| { server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.id) - }).transpose().map_err(|err| { - warn!("Failed to encode cursor: {:?}", err); - CommonWebError::server_error_with_message("Failed to encode cursor") - })?; + }).transpose()?; let media_domain = get_media_domain(&http_request); let server_environment = server_state.server_environment; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/staff_audit_logs/moderator_list_staff_audit_logs_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/staff_audit_logs/moderator_list_staff_audit_logs_handler.rs index 764f948e0e..f855cd8a13 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/staff_audit_logs/moderator_list_staff_audit_logs_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/staff_audit_logs/moderator_list_staff_audit_logs_handler.rs @@ -83,14 +83,7 @@ pub async fn moderator_list_staff_audit_logs_handler( let maybe_cursor_id = match &query.cursor { None => None, Some(cursor_str) => { - let decoded = server_state.opaque_cursors - .decode_cursor_expecting_name(CURSOR_NAME, cursor_str) - .map_err(|err| { - warn!("Failed to decode cursor: {:?}", err); - CommonWebError::BadInputWithSimpleMessage( - "Invalid cursor".to_string()) - })?; - decoded.last_id + Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?) } }; @@ -108,10 +101,7 @@ pub async fn moderator_list_staff_audit_logs_handler( let maybe_cursor = records.last().map(|last| { server_state.opaque_cursors .encode_last_id_cursor(CURSOR_NAME, last.id) - }).transpose().map_err(|err| { - warn!("Failed to encode cursor: {:?}", err); - CommonWebError::server_error_with_message("Failed to encode cursor") - })?; + }).transpose()?; let audit_logs = records.into_iter().map(|r| { StaffAuditLogResponse { diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_emails/moderator_list_email_address_changes_for_user_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_emails/moderator_list_email_address_changes_for_user_handler.rs index b47fce3bac..2bc3f6dec5 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_emails/moderator_list_email_address_changes_for_user_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_emails/moderator_list_email_address_changes_for_user_handler.rs @@ -123,13 +123,7 @@ pub async fn moderator_list_email_address_changes_for_user_handler( let maybe_cursor_id = match &query.cursor { None => None, Some(cursor_str) => { - let decoded = server_state.opaque_cursors - .decode_cursor_expecting_name(CURSOR_NAME, cursor_str) - .map_err(|err| { - warn!("Failed to decode cursor: {:?}", err); - CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string()) - })?; - decoded.last_id + Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?) } }; @@ -149,10 +143,7 @@ pub async fn moderator_list_email_address_changes_for_user_handler( let maybe_cursor = rows.last().map(|last| { server_state.opaque_cursors .encode_last_id_cursor(CURSOR_NAME, last.id) - }).transpose().map_err(|err| { - warn!("Failed to encode cursor: {:?}", err); - CommonWebError::server_error_with_message("Failed to encode cursor") - })?; + }).transpose()?; let changes = rows.into_iter().map(to_response_item).collect(); diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_referrals/moderator_list_global_user_referrals_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_referrals/moderator_list_global_user_referrals_handler.rs index 7602df67bf..83a14a1a3f 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_referrals/moderator_list_global_user_referrals_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_referrals/moderator_list_global_user_referrals_handler.rs @@ -49,14 +49,7 @@ pub async fn moderator_list_global_user_referrals_handler( let maybe_cursor_id = match &query.cursor { None => None, Some(cursor_str) => { - let decoded = server_state.opaque_cursors - .decode_cursor_expecting_name(CURSOR_NAME, cursor_str) - .map_err(|err| { - warn!("Failed to decode cursor: {:?}", err); - CommonWebError::BadInputWithSimpleMessage( - "Invalid cursor".to_string()) - })?; - decoded.last_id + Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?) } }; @@ -74,10 +67,7 @@ pub async fn moderator_list_global_user_referrals_handler( let maybe_cursor = records.last().map(|last| { server_state.opaque_cursors .encode_last_id_cursor(CURSOR_NAME, last.id) - }).transpose().map_err(|err| { - warn!("Failed to encode cursor: {:?}", err); - CommonWebError::server_error_with_message("Failed to encode cursor") - })?; + }).transpose()?; let referrals = records.into_iter().map(|r| { UserReferralResponse { diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_referrals/moderator_list_user_referrals_for_user_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_referrals/moderator_list_user_referrals_for_user_handler.rs index 6b22e3a18a..bbc8e6cf13 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_referrals/moderator_list_user_referrals_for_user_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_referrals/moderator_list_user_referrals_for_user_handler.rs @@ -54,14 +54,7 @@ pub async fn moderator_list_user_referrals_for_user_handler( let maybe_cursor_id = match &query.cursor { None => None, Some(cursor_str) => { - let decoded = server_state.opaque_cursors - .decode_cursor_expecting_name(CURSOR_NAME, cursor_str) - .map_err(|err| { - warn!("Failed to decode cursor: {:?}", err); - CommonWebError::BadInputWithSimpleMessage( - "Invalid cursor".to_string()) - })?; - decoded.last_id + Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?) } }; @@ -82,10 +75,7 @@ pub async fn moderator_list_user_referrals_for_user_handler( let maybe_cursor = records.last().map(|last| { server_state.opaque_cursors .encode_last_id_cursor(CURSOR_NAME, last.id) - }).transpose().map_err(|err| { - warn!("Failed to encode cursor: {:?}", err); - CommonWebError::server_error_with_message("Failed to encode cursor") - })?; + }).transpose()?; let referrals = records.into_iter().map(|r| { UserReferralResponse { diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_sessions/moderator_list_user_session_impersonation_requests_for_user_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_sessions/moderator_list_user_session_impersonation_requests_for_user_handler.rs index 71ef7f3225..3ac31b77b4 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_sessions/moderator_list_user_session_impersonation_requests_for_user_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_sessions/moderator_list_user_session_impersonation_requests_for_user_handler.rs @@ -88,14 +88,7 @@ pub async fn moderator_list_user_session_impersonation_requests_for_user_handler let maybe_cursor_id = match &query.cursor { None => None, Some(cursor_str) => { - let decoded = server_state.opaque_cursors - .decode_cursor_expecting_name(CURSOR_NAME, cursor_str) - .map_err(|err| { - warn!("Failed to decode cursor: {:?}", err); - CommonWebError::BadInputWithSimpleMessage( - "Invalid cursor".to_string()) - })?; - decoded.last_id + Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?) } }; @@ -114,10 +107,7 @@ pub async fn moderator_list_user_session_impersonation_requests_for_user_handler let maybe_cursor = records.last().map(|last| { server_state.opaque_cursors .encode_last_id_cursor(CURSOR_NAME, last.id) - }).transpose().map_err(|err| { - warn!("Failed to encode cursor: {:?}", err); - CommonWebError::server_error_with_message("Failed to encode cursor") - })?; + }).transpose()?; let impersonation_requests = records.into_iter().map(|r| { UserImpersonationRequestResponse { diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_sessions/moderator_list_user_session_impersonation_requests_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_sessions/moderator_list_user_session_impersonation_requests_handler.rs index 0ddbd5395d..3e3b75f969 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_sessions/moderator_list_user_session_impersonation_requests_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_sessions/moderator_list_user_session_impersonation_requests_handler.rs @@ -82,14 +82,7 @@ pub async fn moderator_list_user_session_impersonation_requests_handler( let maybe_cursor_id = match &query.cursor { None => None, Some(cursor_str) => { - let decoded = server_state.opaque_cursors - .decode_cursor_expecting_name(CURSOR_NAME, cursor_str) - .map_err(|err| { - warn!("Failed to decode cursor: {:?}", err); - CommonWebError::BadInputWithSimpleMessage( - "Invalid cursor".to_string()) - })?; - decoded.last_id + Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?) } }; @@ -107,10 +100,7 @@ pub async fn moderator_list_user_session_impersonation_requests_handler( let maybe_cursor = records.last().map(|last| { server_state.opaque_cursors .encode_last_id_cursor(CURSOR_NAME, last.id) - }).transpose().map_err(|err| { - warn!("Failed to encode cursor: {:?}", err); - CommonWebError::server_error_with_message("Failed to encode cursor") - })?; + }).transpose()?; let impersonation_requests = records.into_iter().map(|r| { AllImpersonationRequestResponse { diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/tags/list_media_files_with_tag_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/tags/list_media_files_with_tag_handler.rs index f6b3614e42..e4abf65d05 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/tags/list_media_files_with_tag_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/tags/list_media_files_with_tag_handler.rs @@ -85,13 +85,7 @@ pub async fn list_media_files_with_tag_handler( let maybe_cursor_id = match &query.cursor { None => None, Some(cursor_str) => { - let decoded = server_state.opaque_cursors - .decode_cursor_expecting_name(CURSOR_NAME, cursor_str) - .map_err(|err| { - warn!("Failed to decode cursor: {:?}", err); - CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string()) - })?; - decoded.last_id + Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?) } }; @@ -109,10 +103,7 @@ pub async fn list_media_files_with_tag_handler( let maybe_cursor = rows.last().map(|last| { server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.media_file_id) - }).transpose().map_err(|err| { - warn!("Failed to encode cursor: {:?}", err); - CommonWebError::server_error_with_message("Failed to encode cursor") - })?; + }).transpose()?; let media_domain = get_media_domain(&http_request); let server_environment = server_state.server_environment; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/tags/list_tagged_media_files_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/tags/list_tagged_media_files_handler.rs index c254edec42..3e37290fad 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/tags/list_tagged_media_files_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/tags/list_tagged_media_files_handler.rs @@ -62,13 +62,7 @@ pub async fn list_tagged_media_files_handler( let maybe_cursor_id = match &query.cursor { None => None, Some(cursor_str) => { - let decoded = server_state.opaque_cursors - .decode_cursor_expecting_name(CURSOR_NAME, cursor_str) - .map_err(|err| { - warn!("Failed to decode cursor: {:?}", err); - CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string()) - })?; - decoded.last_id + Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?) } }; @@ -85,10 +79,7 @@ pub async fn list_tagged_media_files_handler( let maybe_cursor = rows.last().map(|last| { server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.media_file_id) - }).transpose().map_err(|err| { - warn!("Failed to encode cursor: {:?}", err); - CommonWebError::server_error_with_message("Failed to encode cursor") - })?; + }).transpose()?; let media_domain = get_media_domain(&http_request); let server_environment = server_state.server_environment; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/tags/list_tags_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/tags/list_tags_handler.rs index c8a08d51ac..2d041ae52d 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/tags/list_tags_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/tags/list_tags_handler.rs @@ -47,13 +47,7 @@ pub async fn list_tags_handler( let maybe_cursor_id = match &query.cursor { None => None, Some(cursor_str) => { - let decoded = server_state.opaque_cursors - .decode_cursor_expecting_name(CURSOR_NAME, cursor_str) - .map_err(|err| { - warn!("Failed to decode cursor: {:?}", err); - CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string()) - })?; - decoded.last_id + Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?) } }; @@ -70,10 +64,7 @@ pub async fn list_tags_handler( let maybe_cursor = rows.last().map(|last| { server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.id) - }).transpose().map_err(|err| { - warn!("Failed to encode cursor: {:?}", err); - CommonWebError::server_error_with_message("Failed to encode cursor") - })?; + }).transpose()?; Ok(Json(ListTagsSuccessResponse { success: true, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/tags/list_untagged_media_files_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/tags/list_untagged_media_files_handler.rs index 3a9c326776..9b27a0c5dc 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/tags/list_untagged_media_files_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/tags/list_untagged_media_files_handler.rs @@ -62,13 +62,7 @@ pub async fn list_untagged_media_files_handler( let maybe_cursor_id = match &query.cursor { None => None, Some(cursor_str) => { - let decoded = server_state.opaque_cursors - .decode_cursor_expecting_name(CURSOR_NAME, cursor_str) - .map_err(|err| { - warn!("Failed to decode cursor: {:?}", err); - CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string()) - })?; - decoded.last_id + Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?) } }; @@ -85,10 +79,7 @@ pub async fn list_untagged_media_files_handler( let maybe_cursor = rows.last().map(|last| { server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.media_file_id) - }).transpose().map_err(|err| { - warn!("Failed to encode cursor: {:?}", err); - CommonWebError::server_error_with_message("Failed to encode cursor") - })?; + }).transpose()?; let media_domain = get_media_domain(&http_request); let server_environment = server_state.server_environment; diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/folders_routes.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/folders_routes.rs index df23aa8d13..f9c339f18e 100644 --- a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/folders_routes.rs +++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/folders_routes.rs @@ -15,6 +15,7 @@ use crate::http_server::endpoints::folders::media_files::bulk_add_folder_media_f use crate::http_server::endpoints::folders::media_files::bulk_move_folder_media_files_handler::bulk_move_folder_media_files_handler; use crate::http_server::endpoints::folders::media_files::bulk_remove_folder_media_files_handler::bulk_remove_folder_media_files_handler; use crate::http_server::endpoints::folders::media_files::list_folder_media_files_handler::list_folder_media_files_handler; +use crate::http_server::endpoints::folders::media_files::list_media_files_without_folder_handler::list_media_files_without_folder_handler; use crate::http_server::endpoints::folders::subfolder::bulk_add_subfolders_handler::bulk_add_subfolders_handler; use crate::http_server::endpoints::folders::subfolder::bulk_remove_subfolders_handler::bulk_remove_subfolders_handler; use crate::http_server::endpoints::folders::subfolder::list_subfolders_handler::list_subfolders_handler; @@ -92,6 +93,11 @@ where ), ) // Media-file membership + .service( + web::resource("/media_files_without_folder") + .route(web::get().to(list_media_files_without_folder_handler)) + .route(web::head().to(|| HttpResponse::Ok())), + ) .service( web::scope("/media_files") .service( diff --git a/crates/service/web/storyteller_web/src/http_server/web_utils/mod.rs b/crates/service/web/storyteller_web/src/http_server/web_utils/mod.rs index 6121977c03..bb7819e905 100644 --- a/crates/service/web/storyteller_web/src/http_server/web_utils/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/web_utils/mod.rs @@ -13,4 +13,5 @@ pub mod response_success_helpers; pub mod scoped_temp_dir_creator; pub mod serialize_as_json_error; pub mod try_delete_session_cache; +pub mod web_opaque_cursor_encoder_v2; pub mod web_sort_key_crypto; diff --git a/crates/service/web/storyteller_web/src/http_server/web_utils/web_opaque_cursor_encoder_v2.rs b/crates/service/web/storyteller_web/src/http_server/web_utils/web_opaque_cursor_encoder_v2.rs new file mode 100644 index 0000000000..539c7edfc3 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/web_utils/web_opaque_cursor_encoder_v2.rs @@ -0,0 +1,52 @@ +use log::warn; + +use opaque_cursors::v2::opaque_cursor_encoder_v2::OpaqueCursorEncoderV2; + +use crate::http_server::common_responses::common_web_error::CommonWebError; + +/// [`OpaqueCursorEncoderV2`] with a web-facing interface: results map to +/// [`CommonWebError`] so handlers can use `?` directly, with the right HTTP +/// status for each direction. This is the flavor injected into Actix via +/// `ServerState` (same treatment as `WebSortKeyCrypto`). +#[derive(Clone)] +pub struct WebOpaqueCursorEncoderV2 { + encoder: OpaqueCursorEncoderV2, +} + +impl WebOpaqueCursorEncoderV2 { + pub fn new(secret: &str) -> Self { + Self { + encoder: OpaqueCursorEncoderV2::new(secret), + } + } + + /// Encode a last-id cursor into an opaque string. + /// Failure is a genuine server fault (500). + pub fn encode_last_id_cursor(&self, name: &str, id: u64) -> Result { + self.encoder.encode_last_id_cursor(name, id) + .map_err(|err| { + warn!("Failed to encode pagination cursor for {:?}: {:?}", name, err); + CommonWebError::server_error_with_message("Failed to encode cursor") + }) + } + + /// Decode a client-supplied last-id cursor, verifying it belongs to the + /// list named `name` and carries a `last_id`. + /// + /// Cursors arrive from clients verbatim, so garbage, cross-list reuse, + /// and missing ids are all bad input (400), not a server fault — a 500 + /// here would trip the error-alerting middleware on any bot or stale + /// client. + pub fn decode_last_id_cursor(&self, name: &str, cursor: &str) -> Result { + let decoded = self.encoder.decode_cursor_expecting_name(name, cursor) + .map_err(|err| { + warn!("Invalid pagination cursor for {:?}: {:?}", name, err); + CommonWebError::BadInputWithSimpleMessage("Invalid pagination cursor.".to_string()) + })?; + + decoded.last_id.ok_or_else(|| { + warn!("Pagination cursor for {:?} carries no last_id", name); + CommonWebError::BadInputWithSimpleMessage("Invalid pagination cursor.".to_string()) + }) + } +} diff --git a/crates/service/web/storyteller_web/src/startup/build_dependencies.rs b/crates/service/web/storyteller_web/src/startup/build_dependencies.rs index 161f74ae56..46229eb3bf 100644 --- a/crates/service/web/storyteller_web/src/startup/build_dependencies.rs +++ b/crates/service/web/storyteller_web/src/startup/build_dependencies.rs @@ -14,7 +14,7 @@ use memory_caching::arc_ttl_sieve::ArcTtlSieve; use memory_caching::single_item_ttl_cache::SingleItemTtlCache; use mysql_queries::mediators::badge_granter::BadgeGranter; use mysql_queries::mediators::firehose_publisher::FirehosePublisher; -use opaque_cursors::v2::opaque_cursor_encoder_v2::OpaqueCursorEncoderV2; +use crate::http_server::web_utils::web_opaque_cursor_encoder_v2::WebOpaqueCursorEncoderV2; use pager::worker::pager_worker::PagerWorker; use redis::Client; use redis_caching::redis_ttl_cache::RedisTtlCache; @@ -157,7 +157,7 @@ pub async fn setup_dependencies(server_hostname: &str) -> AnyhowResult