From ceec7d4b77732aedd7d40767189848b7bf600bc3 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 10 Jul 2026 05:08:57 -0400 Subject: [PATCH] fixes --- .../media_files/list_folder_media_files.rs | 210 +++++------------- .../list/media_file_list_row.rs} | 16 +- .../src/queries/media_files/list/mod.rs | 1 + .../tags/delete_links_to_soft_deleted_tags.rs | 48 ++++ .../tags/filter_visible_media_file_tokens.rs | 55 +++++ .../tags/get_media_file_access_fields.rs | 54 +++++ .../tags/get_soft_deleted_tag_by_lowercase.rs | 44 ++++ .../tags/hard_delete_soft_deleted_tag.rs | 44 ++++ .../list_media_files_with_tag_for_user.rs | 35 +-- .../tags/list_tag_tokens_for_media_file.rs | 39 ++++ .../tags/list_tagged_media_files_for_user.rs | 35 +-- .../list_untagged_media_files_for_user.rs | 35 +-- .../mysql_queries/src/queries/tags/mod.rs | 7 +- .../queries/tags/recount_tag_use_counts.rs | 8 + .../media/media_file_list_conversion.rs | 41 ++++ .../http_server/common_responses/media/mod.rs | 1 + .../list_folder_media_files_handler.rs | 55 ++--- .../delete/delete_media_file_handler.rs | 35 +++ .../http_server/endpoints/tags/apply_tags.rs | 17 ++ .../endpoints/tags/bulk_add_tags_handler.rs | 28 ++- .../tags/bulk_list_media_file_tags_handler.rs | 48 ++-- .../endpoints/tags/bulk_set_tags_handler.rs | 11 + .../tags/list_media_file_tags_handler.rs | 47 +++- .../endpoints/tags/rename_tag_handler.rs | 157 +++++++++++-- .../http_server/endpoints/tags/tag_input.rs | 45 +++- .../tags/tag_media_file_list_item.rs | 28 +-- 26 files changed, 781 insertions(+), 363 deletions(-) rename crates/schema/database/mysql_queries/src/queries/{tags/tag_media_file_row.rs => media_files/list/media_file_list_row.rs} (74%) create mode 100644 crates/schema/database/mysql_queries/src/queries/tags/delete_links_to_soft_deleted_tags.rs create mode 100644 crates/schema/database/mysql_queries/src/queries/tags/filter_visible_media_file_tokens.rs create mode 100644 crates/schema/database/mysql_queries/src/queries/tags/get_media_file_access_fields.rs create mode 100644 crates/schema/database/mysql_queries/src/queries/tags/get_soft_deleted_tag_by_lowercase.rs create mode 100644 crates/schema/database/mysql_queries/src/queries/tags/hard_delete_soft_deleted_tag.rs create mode 100644 crates/schema/database/mysql_queries/src/queries/tags/list_tag_tokens_for_media_file.rs create mode 100644 crates/service/web/storyteller_web/src/http_server/common_responses/media/media_file_list_conversion.rs diff --git a/crates/schema/database/mysql_queries/src/queries/folders/media_files/list_folder_media_files.rs b/crates/schema/database/mysql_queries/src/queries/folders/media_files/list_folder_media_files.rs index 0ef4d9eea6..06ca649e61 100644 --- a/crates/schema/database/mysql_queries/src/queries/folders/media_files/list_folder_media_files.rs +++ b/crates/schema/database/mysql_queries/src/queries/folders/media_files/list_folder_media_files.rs @@ -11,51 +11,23 @@ use tokens::tokens::folders::FolderToken; use tokens::tokens::media_files::MediaFileToken; use tokens::tokens::prompts::PromptToken; -/// One folder-media-file row + the joined media-file fields the list -/// endpoint needs to materialize MediaLinks, MediaFileCoverImageDetails, -/// and the rest of the typed response shape. +use crate::queries::media_files::list::media_file_list_row::MediaFileListRow; + +/// One folder-media-file row: the shared media-file list fields plus +/// folder-membership metadata. #[derive(Debug, Clone)] pub struct FolderMediaFileRow { - /// `media_files.id` — used as the pagination cursor. The list is sorted - /// by this (descending) rather than by folder-membership order so the - /// frontend's group-by-date view doesn't interleave across pages. - pub media_file_id: u64, + /// The common media-file list fields (identity, bucket/cover fields, + /// dimensions, timestamps). `media.media_file_id` is the pagination + /// cursor — the list is sorted by it (descending) rather than by + /// folder-membership order so the frontend's group-by-date view + /// doesn't interleave across pages. + pub media: MediaFileListRow, /// When the media file was added to the folder. pub added_to_folder_at: DateTime, - pub media_file_token: MediaFileToken, - pub media_class: MediaFileClass, - pub media_type: MediaFileType, - - pub maybe_batch_token: Option, - - // The media file's own bucket fields (caller uses these to build MediaLinks). - pub public_bucket_directory_hash: String, - pub maybe_public_bucket_prefix: Option, - pub maybe_public_bucket_extension: Option, - - // Cover image's bucket fields, from a LEFT JOIN on - // mf.maybe_cover_image_media_file_token. All three are Some-or-None - // together (matched / unmatched join row). - pub maybe_cover_public_bucket_directory_hash: Option, - pub maybe_cover_public_bucket_prefix: Option, - pub maybe_cover_public_bucket_extension: Option, - - pub creator_set_visibility: Visibility, - pub is_user_upload: bool, pub is_intermediate_system_file: bool, - - pub maybe_title: Option, - pub maybe_prompt_token: Option, - pub maybe_origin_filename: Option, - - pub maybe_duration_millis: Option, - pub maybe_frame_width: Option, - pub maybe_frame_height: Option, - - pub created_at: DateTime, - pub updated_at: DateTime, } pub struct ListFolderMediaFilesArgs<'e, 'c, E> @@ -83,12 +55,14 @@ pub async fn list_folder_media_files<'e, 'c: 'e, E>( 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. (`media_files.id` is a signed + // BIGINT, so the sentinel is i64::MAX rather than u64::MAX.) + let cursor_id = args.maybe_cursor_id.unwrap_or(i64::MAX as u64); let limit = args.limit as i64; - let rows = match args.maybe_cursor_id { - Some(cursor_id) => { - sqlx::query!( - r#" + let rows = sqlx::query!( + r#" SELECT mf.id as `media_file_id: u64`, fmf.created_at as `added_to_folder_at: DateTime`, @@ -132,121 +106,41 @@ WHERE fmf.folder_token = ? AND mf.id < ? ORDER BY mf.id DESC LIMIT ? - "#, - args.folder_token.as_str(), - cursor_id, - limit, - ) - .fetch_all(args.mysql_executor) - .await? - .into_iter() - .map(|r| FolderMediaFileRow { - media_file_id: r.media_file_id, - added_to_folder_at: r.added_to_folder_at, - media_file_token: r.media_file_token, - media_class: r.media_class, - media_type: r.media_type, - maybe_batch_token: r.maybe_batch_token, - public_bucket_directory_hash: r.public_bucket_directory_hash, - maybe_public_bucket_prefix: r.maybe_public_bucket_prefix, - maybe_public_bucket_extension: r.maybe_public_bucket_extension, - maybe_cover_public_bucket_directory_hash: r.maybe_cover_public_bucket_directory_hash, - maybe_cover_public_bucket_prefix: r.maybe_cover_public_bucket_prefix, - maybe_cover_public_bucket_extension: r.maybe_cover_public_bucket_extension, - creator_set_visibility: r.creator_set_visibility, - is_user_upload: r.is_user_upload, - is_intermediate_system_file: r.is_intermediate_system_file, - maybe_title: r.maybe_title, - maybe_prompt_token: r.maybe_prompt_token, - maybe_origin_filename: r.maybe_origin_filename, - maybe_duration_millis: r.maybe_duration_millis, - maybe_frame_width: r.maybe_frame_width, - maybe_frame_height: r.maybe_frame_height, - created_at: r.created_at, - updated_at: r.updated_at, - }) - .collect::>() - } - None => { - sqlx::query!( - r#" -SELECT - mf.id as `media_file_id: u64`, - fmf.created_at as `added_to_folder_at: DateTime`, + "#, + args.folder_token.as_str(), + cursor_id, + limit, + ) + .fetch_all(args.mysql_executor) + .await?; - 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.is_intermediate_system_file as `is_intermediate_system_file: 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 folder_media_files fmf -JOIN media_files mf - ON mf.token = fmf.media_file_token -LEFT JOIN media_files cover - ON cover.token = mf.maybe_cover_image_media_file_token -WHERE fmf.folder_token = ? - AND mf.user_deleted_at IS NULL - AND mf.mod_deleted_at IS NULL -ORDER BY mf.id DESC -LIMIT ? - "#, - args.folder_token.as_str(), - limit, - ) - .fetch_all(args.mysql_executor) - .await? - .into_iter() - .map(|r| FolderMediaFileRow { - media_file_id: r.media_file_id, - added_to_folder_at: r.added_to_folder_at, - media_file_token: r.media_file_token, - media_class: r.media_class, - media_type: r.media_type, - maybe_batch_token: r.maybe_batch_token, - public_bucket_directory_hash: r.public_bucket_directory_hash, - maybe_public_bucket_prefix: r.maybe_public_bucket_prefix, - maybe_public_bucket_extension: r.maybe_public_bucket_extension, - maybe_cover_public_bucket_directory_hash: r.maybe_cover_public_bucket_directory_hash, - maybe_cover_public_bucket_prefix: r.maybe_cover_public_bucket_prefix, - maybe_cover_public_bucket_extension: r.maybe_cover_public_bucket_extension, - creator_set_visibility: r.creator_set_visibility, - is_user_upload: r.is_user_upload, - is_intermediate_system_file: r.is_intermediate_system_file, - maybe_title: r.maybe_title, - maybe_prompt_token: r.maybe_prompt_token, - maybe_origin_filename: r.maybe_origin_filename, - maybe_duration_millis: r.maybe_duration_millis, - maybe_frame_width: r.maybe_frame_width, - maybe_frame_height: r.maybe_frame_height, - created_at: r.created_at, - updated_at: r.updated_at, - }) - .collect::>() - } - }; - - Ok(rows) + Ok(rows.into_iter() + .map(|r| FolderMediaFileRow { + added_to_folder_at: r.added_to_folder_at, + is_intermediate_system_file: r.is_intermediate_system_file, + media: MediaFileListRow { + media_file_id: r.media_file_id, + media_file_token: r.media_file_token, + media_class: r.media_class, + media_type: r.media_type, + maybe_batch_token: r.maybe_batch_token, + public_bucket_directory_hash: r.public_bucket_directory_hash, + maybe_public_bucket_prefix: r.maybe_public_bucket_prefix, + maybe_public_bucket_extension: r.maybe_public_bucket_extension, + maybe_cover_public_bucket_directory_hash: r.maybe_cover_public_bucket_directory_hash, + maybe_cover_public_bucket_prefix: r.maybe_cover_public_bucket_prefix, + maybe_cover_public_bucket_extension: r.maybe_cover_public_bucket_extension, + creator_set_visibility: r.creator_set_visibility, + is_user_upload: r.is_user_upload, + maybe_title: r.maybe_title, + maybe_prompt_token: r.maybe_prompt_token, + maybe_origin_filename: r.maybe_origin_filename, + maybe_duration_millis: r.maybe_duration_millis, + maybe_frame_width: r.maybe_frame_width, + maybe_frame_height: r.maybe_frame_height, + created_at: r.created_at, + updated_at: r.updated_at, + }, + }) + .collect()) } diff --git a/crates/schema/database/mysql_queries/src/queries/tags/tag_media_file_row.rs b/crates/schema/database/mysql_queries/src/queries/media_files/list/media_file_list_row.rs similarity index 74% rename from crates/schema/database/mysql_queries/src/queries/tags/tag_media_file_row.rs rename to crates/schema/database/mysql_queries/src/queries/media_files/list/media_file_list_row.rs index 6d3fbd9e66..fe981bd4a6 100644 --- a/crates/schema/database/mysql_queries/src/queries/tags/tag_media_file_row.rs +++ b/crates/schema/database/mysql_queries/src/queries/media_files/list/media_file_list_row.rs @@ -7,12 +7,18 @@ use tokens::tokens::batch_generations::BatchGenerationToken; use tokens::tokens::media_files::MediaFileToken; use tokens::tokens::prompts::PromptToken; -/// One media file row from the tag-scoped media file lists (untagged / -/// tagged / with-tag), with the joined fields the endpoints need to -/// materialize MediaLinks, MediaFileCoverImageDetails, and the rest of -/// the typed response shape. +/// The common media-file row shape shared by list queries (folder +/// listings, tag listings, etc.): the file's own metadata plus the +/// joined cover-image bucket fields the endpoints need to materialize +/// MediaLinks, MediaFileCoverImageDetails, and the rest of the typed +/// response shape. +/// +/// Queries can `sqlx::query_as!` directly into this struct when they +/// select exactly these columns (alias `media_files.id` to +/// `media_file_id`, and the cover LEFT JOIN fields to the +/// `maybe_cover_*` names). #[derive(Debug, Clone)] -pub struct TagMediaFileRow { +pub struct MediaFileListRow { /// `media_files.id` — used as the pagination cursor. pub media_file_id: u64, diff --git a/crates/schema/database/mysql_queries/src/queries/media_files/list/mod.rs b/crates/schema/database/mysql_queries/src/queries/media_files/list/mod.rs index 03606b3fdf..6da2eeb27e 100644 --- a/crates/schema/database/mysql_queries/src/queries/media_files/list/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/media_files/list/mod.rs @@ -5,3 +5,4 @@ pub mod list_media_files_by_batch_token; pub mod list_media_files_for_elastic_search_backfill_using_cursor; pub mod list_media_files_for_user; pub mod list_session_project_media_files; +pub mod media_file_list_row; diff --git a/crates/schema/database/mysql_queries/src/queries/tags/delete_links_to_soft_deleted_tags.rs b/crates/schema/database/mysql_queries/src/queries/tags/delete_links_to_soft_deleted_tags.rs new file mode 100644 index 0000000000..1f64258191 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/tags/delete_links_to_soft_deleted_tags.rs @@ -0,0 +1,48 @@ +use std::marker::PhantomData; + +use sqlx::{Executor, MySql, QueryBuilder}; + +use tokens::tokens::tags::TagToken; + +pub struct DeleteLinksToSoftDeletedTagsArgs<'e, 'c, E> +where + E: 'e + Executor<'c, Database = MySql>, +{ + pub tag_tokens: &'e [TagToken], + pub mysql_executor: E, + pub phantom: PhantomData<&'c E>, +} + +/// Remove any `media_file_tags` links pointing at tags that are +/// soft-deleted, among the given tag tokens. Guards the apply-tags +/// transaction against a concurrent tag delete: the DELETE is a locking +/// read, so unlike the transaction's snapshot SELECTs it observes a +/// just-committed soft-delete and cleans up links the snapshot happily +/// inserted. Cheap — an IN-list probe on `index_tag_token` plus a +/// primary-key lookup per link. +pub async fn delete_links_to_soft_deleted_tags<'e, 'c: 'e, E>( + args: DeleteLinksToSoftDeletedTagsArgs<'e, 'c, E>, +) -> Result +where + E: 'e + Executor<'c, Database = MySql>, +{ + if args.tag_tokens.is_empty() { + return Ok(0); + } + + let mut builder = QueryBuilder::::new( + "DELETE media_file_tags FROM media_file_tags \ + JOIN tags ON tags.token = media_file_tags.tag_token \ + WHERE tags.maybe_deleted_at IS NOT NULL \ + AND media_file_tags.tag_token IN (", + ); + + let mut separated = builder.separated(", "); + for token in args.tag_tokens { + separated.push_bind(token.as_str()); + } + separated.push_unseparated(")"); + + let result = builder.build().execute(args.mysql_executor).await?; + Ok(result.rows_affected()) +} diff --git a/crates/schema/database/mysql_queries/src/queries/tags/filter_visible_media_file_tokens.rs b/crates/schema/database/mysql_queries/src/queries/tags/filter_visible_media_file_tokens.rs new file mode 100644 index 0000000000..18e459fa0f --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/tags/filter_visible_media_file_tokens.rs @@ -0,0 +1,55 @@ +use std::marker::PhantomData; + +use sqlx::{Executor, MySql, QueryBuilder, Row}; + +use enums::common::visibility::Visibility; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::users::UserToken; + +pub struct FilterVisibleMediaFileTokensArgs<'e, 'c, E> +where + E: 'e + Executor<'c, Database = MySql>, +{ + pub candidate_tokens: &'e [MediaFileToken], + pub requester_user_token: &'e UserToken, + pub mysql_executor: E, + pub phantom: PhantomData<&'c E>, +} + +/// Return only the input tokens whose media file exists, isn't +/// soft-deleted, and whose tags the requester may see: any non-private +/// file (public and hidden files are viewable by anyone with the URL), +/// or the requester's own files. +pub async fn filter_visible_media_file_tokens<'e, 'c: 'e, E>( + args: FilterVisibleMediaFileTokensArgs<'e, 'c, E>, +) -> Result, sqlx::Error> +where + E: 'e + Executor<'c, Database = MySql>, +{ + if args.candidate_tokens.is_empty() { + return Ok(Vec::new()); + } + + let mut builder = QueryBuilder::::new( + "SELECT token FROM media_files \ + WHERE user_deleted_at IS NULL \ + AND mod_deleted_at IS NULL \ + AND (creator_set_visibility != ", + ); + builder.push_bind(Visibility::Private.to_str()); + builder.push(" OR maybe_creator_user_token = "); + builder.push_bind(args.requester_user_token.as_str()); + builder.push(") AND token IN ("); + + let mut separated = builder.separated(", "); + for token in args.candidate_tokens { + separated.push_bind(token.as_str()); + } + separated.push_unseparated(")"); + + let rows = builder.build().fetch_all(args.mysql_executor).await?; + + Ok(rows.into_iter() + .map(|row| MediaFileToken::new(row.get::(0))) + .collect()) +} diff --git a/crates/schema/database/mysql_queries/src/queries/tags/get_media_file_access_fields.rs b/crates/schema/database/mysql_queries/src/queries/tags/get_media_file_access_fields.rs new file mode 100644 index 0000000000..c5b1984168 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/tags/get_media_file_access_fields.rs @@ -0,0 +1,54 @@ +use std::marker::PhantomData; + +use sqlx::{Executor, MySql}; + +use enums::common::visibility::Visibility; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::users::UserToken; + +/// Just enough of a media file to decide whether a requester may see +/// its tags. +#[derive(Debug, Clone)] +pub struct MediaFileAccessFields { + pub maybe_creator_user_token: Option, + pub creator_set_visibility: Visibility, +} + +pub struct GetMediaFileAccessFieldsArgs<'e, 'c, E> +where + E: 'e + Executor<'c, Database = MySql>, +{ + pub media_file_token: &'e MediaFileToken, + pub mysql_executor: E, + pub phantom: PhantomData<&'c E>, +} + +/// Fetch the owner + visibility of a (live) media file. Returns +/// `Ok(None)` if the file doesn't exist or is soft-deleted. +pub async fn get_media_file_access_fields<'e, 'c: 'e, E>( + args: GetMediaFileAccessFieldsArgs<'e, 'c, E>, +) -> Result, sqlx::Error> +where + E: 'e + Executor<'c, Database = MySql>, +{ + let result = sqlx::query!( + r#" +SELECT + maybe_creator_user_token as `maybe_creator_user_token: UserToken`, + creator_set_visibility as `creator_set_visibility: Visibility` +FROM media_files +WHERE token = ? + AND user_deleted_at IS NULL + AND mod_deleted_at IS NULL +LIMIT 1 + "#, + args.media_file_token.as_str(), + ) + .fetch_optional(args.mysql_executor) + .await?; + + Ok(result.map(|r| MediaFileAccessFields { + maybe_creator_user_token: r.maybe_creator_user_token, + creator_set_visibility: r.creator_set_visibility, + })) +} diff --git a/crates/schema/database/mysql_queries/src/queries/tags/get_soft_deleted_tag_by_lowercase.rs b/crates/schema/database/mysql_queries/src/queries/tags/get_soft_deleted_tag_by_lowercase.rs new file mode 100644 index 0000000000..fe76477060 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/tags/get_soft_deleted_tag_by_lowercase.rs @@ -0,0 +1,44 @@ +use std::marker::PhantomData; + +use sqlx::{Executor, MySql}; + +use tokens::tokens::tags::TagToken; +use tokens::tokens::users::UserToken; + +pub struct GetSoftDeletedTagByLowercaseArgs<'e, 'c, E> +where + E: 'e + Executor<'c, Database = MySql>, +{ + pub tag_value_lowercase: &'e str, + pub creator_user_token: &'e UserToken, + pub mysql_executor: E, + pub phantom: PhantomData<&'c E>, +} + +/// Find a user's SOFT-DELETED tag by its lowercased value. Soft-deleted +/// rows still occupy the `(tag_value_lowercase, creator_user_token)` +/// unique key, so a rename onto that value must purge the dead row +/// first — this lookup feeds that purge. +pub async fn get_soft_deleted_tag_by_lowercase<'e, 'c: 'e, E>( + args: GetSoftDeletedTagByLowercaseArgs<'e, 'c, E>, +) -> Result, sqlx::Error> +where + E: 'e + Executor<'c, Database = MySql>, +{ + let result = sqlx::query!( + r#" +SELECT token as `token: TagToken` +FROM tags +WHERE creator_user_token = ? + AND tag_value_lowercase = ? + AND maybe_deleted_at IS NOT NULL +LIMIT 1 + "#, + args.creator_user_token.as_str(), + args.tag_value_lowercase, + ) + .fetch_optional(args.mysql_executor) + .await?; + + Ok(result.map(|r| r.token)) +} diff --git a/crates/schema/database/mysql_queries/src/queries/tags/hard_delete_soft_deleted_tag.rs b/crates/schema/database/mysql_queries/src/queries/tags/hard_delete_soft_deleted_tag.rs new file mode 100644 index 0000000000..3a247783b6 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/tags/hard_delete_soft_deleted_tag.rs @@ -0,0 +1,44 @@ +use std::marker::PhantomData; + +use sqlx::{Executor, MySql}; + +use tokens::tokens::tags::TagToken; +use tokens::tokens::users::UserToken; + +pub struct HardDeleteSoftDeletedTagArgs<'e, 'c, E> +where + E: 'e + Executor<'c, Database = MySql>, +{ + pub tag_token: &'e TagToken, + pub creator_user_token: &'e UserToken, + pub mysql_executor: E, + pub phantom: PhantomData<&'c E>, +} + +/// Hard-delete a tag row that is ALREADY soft-deleted, freeing its +/// `(tag_value_lowercase, creator_user_token)` unique-key slot so a +/// rename can take the value over. Refuses to touch live tags — the +/// `maybe_deleted_at IS NOT NULL` predicate makes a stale token a +/// no-op. Callers should remove the tag's `media_file_tags` links first +/// (`delete_media_file_tags_for_tag`). +pub async fn hard_delete_soft_deleted_tag<'e, 'c: 'e, E>( + args: HardDeleteSoftDeletedTagArgs<'e, 'c, E>, +) -> Result +where + E: 'e + Executor<'c, Database = MySql>, +{ + let result = sqlx::query!( + r#" +DELETE FROM tags +WHERE token = ? + AND creator_user_token = ? + AND maybe_deleted_at IS NOT NULL + "#, + args.tag_token.as_str(), + args.creator_user_token.as_str(), + ) + .execute(args.mysql_executor) + .await?; + + Ok(result.rows_affected()) +} diff --git a/crates/schema/database/mysql_queries/src/queries/tags/list_media_files_with_tag_for_user.rs b/crates/schema/database/mysql_queries/src/queries/tags/list_media_files_with_tag_for_user.rs index 54d6f6890a..6f8e956ab5 100644 --- a/crates/schema/database/mysql_queries/src/queries/tags/list_media_files_with_tag_for_user.rs +++ b/crates/schema/database/mysql_queries/src/queries/tags/list_media_files_with_tag_for_user.rs @@ -11,7 +11,7 @@ use tokens::tokens::prompts::PromptToken; use tokens::tokens::tags::TagToken; use tokens::tokens::users::UserToken; -use crate::queries::tags::tag_media_file_row::TagMediaFileRow; +use crate::queries::media_files::list::media_file_list_row::MediaFileListRow; pub struct ListMediaFilesWithTagForUserArgs<'e, 'c, E> where @@ -36,7 +36,7 @@ where /// the user (`get_tag_for_owner`) so a bad token can 404. pub async fn list_media_files_with_tag_for_user<'e, 'c: 'e, E>( args: ListMediaFilesWithTagForUserArgs<'e, 'c, E>, -) -> Result, sqlx::Error> +) -> Result, sqlx::Error> where E: 'e + Executor<'c, Database = MySql>, { @@ -46,7 +46,8 @@ where let cursor_id = args.maybe_cursor_id.unwrap_or(i64::MAX as u64); let limit = args.limit as i64; - let rows = sqlx::query!( + sqlx::query_as!( + MediaFileListRow, r#" SELECT mf.id as `media_file_id: u64`, @@ -100,31 +101,5 @@ LIMIT ? limit, ) .fetch_all(args.mysql_executor) - .await?; - - Ok(rows.into_iter() - .map(|r| TagMediaFileRow { - media_file_id: r.media_file_id, - media_file_token: r.media_file_token, - media_class: r.media_class, - media_type: r.media_type, - maybe_batch_token: r.maybe_batch_token, - public_bucket_directory_hash: r.public_bucket_directory_hash, - maybe_public_bucket_prefix: r.maybe_public_bucket_prefix, - maybe_public_bucket_extension: r.maybe_public_bucket_extension, - maybe_cover_public_bucket_directory_hash: r.maybe_cover_public_bucket_directory_hash, - maybe_cover_public_bucket_prefix: r.maybe_cover_public_bucket_prefix, - maybe_cover_public_bucket_extension: r.maybe_cover_public_bucket_extension, - creator_set_visibility: r.creator_set_visibility, - is_user_upload: r.is_user_upload, - maybe_title: r.maybe_title, - maybe_prompt_token: r.maybe_prompt_token, - maybe_origin_filename: r.maybe_origin_filename, - maybe_duration_millis: r.maybe_duration_millis, - maybe_frame_width: r.maybe_frame_width, - maybe_frame_height: r.maybe_frame_height, - created_at: r.created_at, - updated_at: r.updated_at, - }) - .collect()) + .await } diff --git a/crates/schema/database/mysql_queries/src/queries/tags/list_tag_tokens_for_media_file.rs b/crates/schema/database/mysql_queries/src/queries/tags/list_tag_tokens_for_media_file.rs new file mode 100644 index 0000000000..0f389b741a --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/tags/list_tag_tokens_for_media_file.rs @@ -0,0 +1,39 @@ +use std::marker::PhantomData; + +use sqlx::{Executor, MySql}; + +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::tags::TagToken; + +pub struct ListTagTokensForMediaFileArgs<'e, 'c, E> +where + E: 'e + Executor<'c, Database = MySql>, +{ + pub media_file_token: &'e MediaFileToken, + pub mysql_executor: E, + pub phantom: PhantomData<&'c E>, +} + +/// The distinct tag tokens linked to a media file, across ALL users +/// (unlike `list_linked_tag_tokens_for_media_files`, which is scoped to +/// one user's links). Used by media-file delete/undelete to know which +/// tags need their `use_count` recounted. +pub async fn list_tag_tokens_for_media_file<'e, 'c: 'e, E>( + args: ListTagTokensForMediaFileArgs<'e, 'c, E>, +) -> Result, sqlx::Error> +where + E: 'e + Executor<'c, Database = MySql>, +{ + let rows = sqlx::query!( + r#" +SELECT DISTINCT tag_token as `tag_token: TagToken` +FROM media_file_tags +WHERE media_file_token = ? + "#, + args.media_file_token.as_str(), + ) + .fetch_all(args.mysql_executor) + .await?; + + Ok(rows.into_iter().map(|r| r.tag_token).collect()) +} diff --git a/crates/schema/database/mysql_queries/src/queries/tags/list_tagged_media_files_for_user.rs b/crates/schema/database/mysql_queries/src/queries/tags/list_tagged_media_files_for_user.rs index 0c7342a63c..08f8fe6583 100644 --- a/crates/schema/database/mysql_queries/src/queries/tags/list_tagged_media_files_for_user.rs +++ b/crates/schema/database/mysql_queries/src/queries/tags/list_tagged_media_files_for_user.rs @@ -10,7 +10,7 @@ use tokens::tokens::media_files::MediaFileToken; use tokens::tokens::prompts::PromptToken; use tokens::tokens::users::UserToken; -use crate::queries::tags::tag_media_file_row::TagMediaFileRow; +use crate::queries::media_files::list::media_file_list_row::MediaFileListRow; pub struct ListTaggedMediaFilesForUserArgs<'e, 'c, E> where @@ -29,7 +29,7 @@ where /// EXISTS probe is a point lookup on `index_media_file_token`. pub async fn list_tagged_media_files_for_user<'e, 'c: 'e, E>( args: ListTaggedMediaFilesForUserArgs<'e, 'c, E>, -) -> Result, sqlx::Error> +) -> Result, sqlx::Error> where E: 'e + Executor<'c, Database = MySql>, { @@ -39,7 +39,8 @@ where let cursor_id = args.maybe_cursor_id.unwrap_or(i64::MAX as u64); let limit = args.limit as i64; - let rows = sqlx::query!( + sqlx::query_as!( + MediaFileListRow, r#" SELECT mf.id as `media_file_id: u64`, @@ -91,31 +92,5 @@ LIMIT ? limit, ) .fetch_all(args.mysql_executor) - .await?; - - Ok(rows.into_iter() - .map(|r| TagMediaFileRow { - media_file_id: r.media_file_id, - media_file_token: r.media_file_token, - media_class: r.media_class, - media_type: r.media_type, - maybe_batch_token: r.maybe_batch_token, - public_bucket_directory_hash: r.public_bucket_directory_hash, - maybe_public_bucket_prefix: r.maybe_public_bucket_prefix, - maybe_public_bucket_extension: r.maybe_public_bucket_extension, - maybe_cover_public_bucket_directory_hash: r.maybe_cover_public_bucket_directory_hash, - maybe_cover_public_bucket_prefix: r.maybe_cover_public_bucket_prefix, - maybe_cover_public_bucket_extension: r.maybe_cover_public_bucket_extension, - creator_set_visibility: r.creator_set_visibility, - is_user_upload: r.is_user_upload, - maybe_title: r.maybe_title, - maybe_prompt_token: r.maybe_prompt_token, - maybe_origin_filename: r.maybe_origin_filename, - maybe_duration_millis: r.maybe_duration_millis, - maybe_frame_width: r.maybe_frame_width, - maybe_frame_height: r.maybe_frame_height, - created_at: r.created_at, - updated_at: r.updated_at, - }) - .collect()) + .await } diff --git a/crates/schema/database/mysql_queries/src/queries/tags/list_untagged_media_files_for_user.rs b/crates/schema/database/mysql_queries/src/queries/tags/list_untagged_media_files_for_user.rs index 871bfcbf38..e1c1b4298f 100644 --- a/crates/schema/database/mysql_queries/src/queries/tags/list_untagged_media_files_for_user.rs +++ b/crates/schema/database/mysql_queries/src/queries/tags/list_untagged_media_files_for_user.rs @@ -10,7 +10,7 @@ use tokens::tokens::media_files::MediaFileToken; use tokens::tokens::prompts::PromptToken; use tokens::tokens::users::UserToken; -use crate::queries::tags::tag_media_file_row::TagMediaFileRow; +use crate::queries::media_files::list::media_file_list_row::MediaFileListRow; pub struct ListUntaggedMediaFilesForUserArgs<'e, 'c, E> where @@ -29,7 +29,7 @@ where /// NOT EXISTS probe is a point lookup on `index_media_file_token`. pub async fn list_untagged_media_files_for_user<'e, 'c: 'e, E>( args: ListUntaggedMediaFilesForUserArgs<'e, 'c, E>, -) -> Result, sqlx::Error> +) -> Result, sqlx::Error> where E: 'e + Executor<'c, Database = MySql>, { @@ -39,7 +39,8 @@ where let cursor_id = args.maybe_cursor_id.unwrap_or(i64::MAX as u64); let limit = args.limit as i64; - let rows = sqlx::query!( + sqlx::query_as!( + MediaFileListRow, r#" SELECT mf.id as `media_file_id: u64`, @@ -91,31 +92,5 @@ LIMIT ? limit, ) .fetch_all(args.mysql_executor) - .await?; - - Ok(rows.into_iter() - .map(|r| TagMediaFileRow { - media_file_id: r.media_file_id, - media_file_token: r.media_file_token, - media_class: r.media_class, - media_type: r.media_type, - maybe_batch_token: r.maybe_batch_token, - public_bucket_directory_hash: r.public_bucket_directory_hash, - maybe_public_bucket_prefix: r.maybe_public_bucket_prefix, - maybe_public_bucket_extension: r.maybe_public_bucket_extension, - maybe_cover_public_bucket_directory_hash: r.maybe_cover_public_bucket_directory_hash, - maybe_cover_public_bucket_prefix: r.maybe_cover_public_bucket_prefix, - maybe_cover_public_bucket_extension: r.maybe_cover_public_bucket_extension, - creator_set_visibility: r.creator_set_visibility, - is_user_upload: r.is_user_upload, - maybe_title: r.maybe_title, - maybe_prompt_token: r.maybe_prompt_token, - maybe_origin_filename: r.maybe_origin_filename, - maybe_duration_millis: r.maybe_duration_millis, - maybe_frame_width: r.maybe_frame_width, - maybe_frame_height: r.maybe_frame_height, - created_at: r.created_at, - updated_at: r.updated_at, - }) - .collect()) + .await } diff --git a/crates/schema/database/mysql_queries/src/queries/tags/mod.rs b/crates/schema/database/mysql_queries/src/queries/tags/mod.rs index fc516222a2..1b6ded9132 100644 --- a/crates/schema/database/mysql_queries/src/queries/tags/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/tags/mod.rs @@ -1,12 +1,18 @@ pub mod bulk_list_tags_for_media_files; pub mod clear_media_file_tags; +pub mod delete_links_to_soft_deleted_tags; pub mod delete_media_file_tags_for_tag; pub mod delete_media_file_tags_not_in_set; pub mod filter_owned_media_file_tokens; +pub mod filter_visible_media_file_tokens; +pub mod get_media_file_access_fields; +pub mod get_soft_deleted_tag_by_lowercase; pub mod get_tag_for_owner; +pub mod hard_delete_soft_deleted_tag; pub mod insert_media_file_tags; pub mod list_linked_tag_tokens_for_media_files; pub mod list_media_files_with_tag_for_user; +pub mod list_tag_tokens_for_media_file; pub mod list_tagged_media_files_for_user; pub mod list_tags_for_media_file; pub mod list_tags_for_user; @@ -16,6 +22,5 @@ pub mod rename_tag; pub mod select_tags_by_lowercase_values; pub mod select_tags_by_tokens_for_owner; pub mod soft_delete_tag; -pub mod tag_media_file_row; pub mod tag_row; pub mod upsert_tags; diff --git a/crates/schema/database/mysql_queries/src/queries/tags/recount_tag_use_counts.rs b/crates/schema/database/mysql_queries/src/queries/tags/recount_tag_use_counts.rs index 89326702b7..68eaa3bb9c 100644 --- a/crates/schema/database/mysql_queries/src/queries/tags/recount_tag_use_counts.rs +++ b/crates/schema/database/mysql_queries/src/queries/tags/recount_tag_use_counts.rs @@ -18,6 +18,10 @@ where /// passing only the affected tag tokens — the correlated COUNT probes /// `index_tag_token`, so cost is proportional to the tags' link counts, /// not table size. +/// +/// Only links to LIVE media files count: media-file deletion is a soft +/// delete that leaves the link rows in place (so undelete restores the +/// user's tags), and those parked links must not inflate the rollup. pub async fn recount_tag_use_counts<'e, 'c: 'e, E>( args: RecountTagUseCountsArgs<'e, 'c, E>, ) -> Result<(), sqlx::Error> @@ -32,6 +36,10 @@ where "UPDATE tags \ SET use_count = ( \ SELECT COUNT(*) FROM media_file_tags \ + JOIN media_files \ + ON media_files.token = media_file_tags.media_file_token \ + AND media_files.user_deleted_at IS NULL \ + AND media_files.mod_deleted_at IS NULL \ WHERE media_file_tags.tag_token = tags.token \ ) \ WHERE tags.token IN (", diff --git a/crates/service/web/storyteller_web/src/http_server/common_responses/media/media_file_list_conversion.rs b/crates/service/web/storyteller_web/src/http_server/common_responses/media/media_file_list_conversion.rs new file mode 100644 index 0000000000..930ac2bbdf --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/common_responses/media/media_file_list_conversion.rs @@ -0,0 +1,41 @@ +use artcraft_api_defs::common::responses::media_links::MediaLinks; +use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath; +use mysql_queries::queries::media_files::list::media_file_list_row::MediaFileListRow; +use server_environment::ServerEnvironment; + +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_links_builder::MediaLinksBuilder; + +/// Build the `MediaLinks` + `MediaFileCoverImageDetails` pair for one +/// media-file list row. Shared by every list endpoint that returns +/// media files (folders, tags, ...) so the CDN-link construction stays +/// in one place. +pub fn build_media_links_and_cover( + row: &MediaFileListRow, + media_domain: MediaDomain, + server_environment: ServerEnvironment, +) -> (MediaLinks, MediaFileCoverImageDetails) { + let bucket_path = MediaFileBucketPath::from_object_hash( + &row.public_bucket_directory_hash, + row.maybe_public_bucket_prefix.as_deref(), + row.maybe_public_bucket_extension.as_deref(), + ); + + let media_links = MediaLinksBuilder::from_media_path_and_env( + media_domain, + server_environment, + &bucket_path, + ); + + let cover_image = MediaFileCoverImageDetails::from_optional_db_fields( + &row.media_file_token, + media_domain, + server_environment, + row.maybe_cover_public_bucket_directory_hash.as_deref(), + row.maybe_cover_public_bucket_prefix.as_deref(), + row.maybe_cover_public_bucket_extension.as_deref(), + ); + + (media_links, cover_image) +} diff --git a/crates/service/web/storyteller_web/src/http_server/common_responses/media/mod.rs b/crates/service/web/storyteller_web/src/http_server/common_responses/media/mod.rs index 99452aeaae..83683bf920 100644 --- a/crates/service/web/storyteller_web/src/http_server/common_responses/media/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/common_responses/media/mod.rs @@ -4,5 +4,6 @@ pub mod cover_image_links_builder; pub mod media_domain; pub mod media_file_cover_image_details; pub mod media_file_cover_image_details_builder; +pub mod media_file_list_conversion; pub mod media_links_builder; pub mod weights_cover_image_details; \ No newline at end of file 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..3c46c2fd7f 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 @@ -12,7 +12,6 @@ use artcraft_api_defs::common::responses::media_links::MediaLinks; use artcraft_api_defs::folders::media_files::{ FolderMediaFilesPathInfo, ListFolderMediaFilesQueryParams, }; -use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath; 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; @@ -30,7 +29,7 @@ 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_links_builder::MediaLinksBuilder; +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; @@ -170,7 +169,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) + server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.media.media_file_id) }).transpose().map_err(|err| { warn!("Failed to encode cursor: {:?}", err); CommonWebError::server_error_with_message("Failed to encode cursor") @@ -195,46 +194,28 @@ fn folder_media_file_row_to_list_item( media_domain: MediaDomain, server_environment: server_environment::ServerEnvironment, ) -> FolderMediaFileListItem { - let bucket_path = MediaFileBucketPath::from_object_hash( - &row.public_bucket_directory_hash, - row.maybe_public_bucket_prefix.as_deref(), - row.maybe_public_bucket_extension.as_deref(), - ); - - let media_links = MediaLinksBuilder::from_media_path_and_env( - media_domain, - server_environment, - &bucket_path, - ); - - let cover_image = MediaFileCoverImageDetails::from_optional_db_fields( - &row.media_file_token, - media_domain, - server_environment, - row.maybe_cover_public_bucket_directory_hash.as_deref(), - row.maybe_cover_public_bucket_prefix.as_deref(), - row.maybe_cover_public_bucket_extension.as_deref(), - ); + let (media_links, cover_image) = + build_media_links_and_cover(&row.media, media_domain, server_environment); FolderMediaFileListItem { - token: row.media_file_token, + token: row.media.media_file_token, added_to_folder_at: row.added_to_folder_at, - media_class: row.media_class, - media_type: row.media_type, - maybe_batch_token: row.maybe_batch_token, + media_class: row.media.media_class, + media_type: row.media.media_type, + maybe_batch_token: row.media.maybe_batch_token, media_links, cover_image, - creator_set_visibility: row.creator_set_visibility, - is_user_upload: row.is_user_upload, + creator_set_visibility: row.media.creator_set_visibility, + is_user_upload: row.media.is_user_upload, is_intermediate_system_file: row.is_intermediate_system_file, - maybe_title: row.maybe_title, - maybe_prompt_token: row.maybe_prompt_token, - maybe_original_filename: row.maybe_origin_filename, + maybe_title: row.media.maybe_title, + maybe_prompt_token: row.media.maybe_prompt_token, + maybe_original_filename: row.media.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, + maybe_duration_millis: row.media.maybe_duration_millis.map(|n| n as u64), + maybe_frame_width: row.media.maybe_frame_width, + maybe_frame_height: row.media.maybe_frame_height, + created_at: row.media.created_at, + updated_at: row.media.updated_at, } } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/delete/delete_media_file_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/delete/delete_media_file_handler.rs index b238d71814..621b70d6f4 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/delete/delete_media_file_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/delete/delete_media_file_handler.rs @@ -15,6 +15,9 @@ use http_server_common::response::serialize_as_json_error::serialize_as_json_err use log::warn; use mysql_queries::queries::media_files::delete::delete_media_file::{delete_media_file_as_mod, delete_media_file_as_user, undelete_media_file_as_mod, undelete_media_file_as_user}; use mysql_queries::queries::media_files::get::get_media_file::get_media_file; +use mysql_queries::queries::tags::list_tag_tokens_for_media_file::{list_tag_tokens_for_media_file, ListTagTokensForMediaFileArgs}; +use mysql_queries::queries::tags::recount_tag_use_counts::{recount_tag_use_counts, RecountTagUseCountsArgs}; +use std::marker::PhantomData; use tokens::tokens::media_files::MediaFileToken; use utoipa::ToSchema; @@ -140,7 +143,39 @@ pub async fn delete_media_file_handler( } }; + // Tag links survive delete/undelete (so an undelete restores the + // user's tags), but `tags.use_count` only counts links to live + // files — refresh the affected tags' counts. Best-effort: the + // delete already succeeded, and a stale count self-heals on the + // tag's next recount. + recount_tags_for_media_file(&path.token, &server_state).await; + Ok(Json(SimpleGenericJsonSuccess{ success: true })) } + +async fn recount_tags_for_media_file( + media_file_token: &MediaFileToken, + server_state: &ServerState, +) { + let linked_tag_tokens = match list_tag_tokens_for_media_file(ListTagTokensForMediaFileArgs { + media_file_token, + mysql_executor: &server_state.mysql_pool, + phantom: PhantomData, + }).await { + Ok(tokens) => tokens, + Err(err) => { + warn!("list_tag_tokens_for_media_file failed after delete/undelete: {:?}", err); + return; + } + }; + + if let Err(err) = recount_tag_use_counts(RecountTagUseCountsArgs { + tag_tokens: &linked_tag_tokens, + mysql_executor: &server_state.mysql_pool, + phantom: PhantomData, + }).await { + warn!("recount_tag_use_counts failed after delete/undelete: {:?}", err); + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/tags/apply_tags.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/tags/apply_tags.rs index 7e6d16faf1..1721da34b0 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/tags/apply_tags.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/tags/apply_tags.rs @@ -7,6 +7,9 @@ use sqlx::{Acquire, MySql, Transaction}; use mysql_queries::queries::tags::clear_media_file_tags::{ clear_media_file_tags, ClearMediaFileTagsArgs, }; +use mysql_queries::queries::tags::delete_links_to_soft_deleted_tags::{ + delete_links_to_soft_deleted_tags, DeleteLinksToSoftDeletedTagsArgs, +}; use mysql_queries::queries::tags::delete_media_file_tags_not_in_set::{ delete_media_file_tags_not_in_set, DeleteMediaFileTagsNotInSetArgs, }; @@ -175,6 +178,20 @@ async fn perform_apply_work( CommonWebError::from_error(err) })?; + // Guard against a concurrent tag delete: our snapshot SELECT above + // may have seen the tag as live while another transaction soft- + // deleted it (hard-deleting its links). This DELETE is a locking + // read, so it observes the committed soft-delete and removes any + // links we just re-inserted, instead of leaving orphans. + delete_links_to_soft_deleted_tags(DeleteLinksToSoftDeletedTagsArgs { + tag_tokens: &tag_tokens, + mysql_executor: &mut **tx, + phantom: PhantomData, + }).await.map_err(|err| { + warn!("delete_links_to_soft_deleted_tags failed: {:?}", err); + CommonWebError::from_error(err) + })?; + // Recount every tag whose link set may have changed: the requested // tags plus any tags that previously linked to these files. let mut recount_tag_tokens = tag_tokens.clone(); diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/tags/bulk_add_tags_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/tags/bulk_add_tags_handler.rs index aff20e1b94..5e0581a568 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/tags/bulk_add_tags_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/tags/bulk_add_tags_handler.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::marker::PhantomData; use std::sync::Arc; @@ -77,6 +78,16 @@ pub async fn bulk_add_tags_handler( CommonWebError::from_error(err) })?; + // Nothing to tag — don't create (or revive) tags that would attach + // to no files. + if accepted.is_empty() { + return Ok(Json(BulkAddTagsSuccessResponse { + success: true, + accepted_media_file_tokens: Vec::new(), + tags: Vec::new(), + })); + } + let outcome = apply_tags_to_media_files( &mut conn, &user_session.user_token, @@ -92,21 +103,24 @@ pub async fn bulk_add_tags_handler( })) } -/// Dedupe (preserving order) and enforce the bulk size cap. Shared with +/// Dedupe (preserving order) and enforce the bulk size cap. Bails out +/// mid-loop the moment the cap is exceeded, so an oversized body costs +/// O(cap) rather than a full pass over an unbounded input. Shared with /// the bulk_set handler. pub fn dedupe_and_cap_media_file_tokens( input: &[MediaFileToken], ) -> Result, CommonWebError> { + let mut seen = HashSet::new(); let mut deduped: Vec = Vec::new(); for token in input { - if !deduped.contains(token) { + if seen.insert(token.as_str()) { + if deduped.len() >= MAX_BULK_MEDIA_FILES { + return Err(CommonWebError::BadInputWithSimpleMessage( + format!("too many media files in one request (max {})", MAX_BULK_MEDIA_FILES), + )); + } deduped.push(token.clone()); } } - if deduped.len() > MAX_BULK_MEDIA_FILES { - return Err(CommonWebError::BadInputWithSimpleMessage( - format!("too many media files in one request (max {})", MAX_BULK_MEDIA_FILES), - )); - } Ok(deduped) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/tags/bulk_list_media_file_tags_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/tags/bulk_list_media_file_tags_handler.rs index d271168706..ab4907cbfa 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/tags/bulk_list_media_file_tags_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/tags/bulk_list_media_file_tags_handler.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::marker::PhantomData; use std::sync::Arc; @@ -13,6 +13,9 @@ use artcraft_api_defs::tags::common::TagDetails; use mysql_queries::queries::tags::bulk_list_tags_for_media_files::{ bulk_list_tags_for_media_files, BulkListTagsForMediaFilesArgs, }; +use mysql_queries::queries::tags::filter_visible_media_file_tokens::{ + filter_visible_media_file_tokens, FilterVisibleMediaFileTokensArgs, +}; use tokens::tokens::media_files::MediaFileToken; use crate::http_server::common_responses::common_web_error::CommonWebError; @@ -22,9 +25,12 @@ use crate::state::server_state::ServerState; const MAX_BULK: usize = 500; /// All tags on each of the supplied media files, in one round-trip. -/// Tags aren't scoped to the caller (per-file tags are public), but a -/// login is required. POST because the token list belongs in a request -/// body — the operation is still a pure read. +/// Follows the same visibility rule as the single-file listing: tags on +/// public and hidden files are readable by anyone; a PRIVATE file's +/// tags only appear for its creator (other requesters see it with an +/// empty tag list, indistinguishable from an untagged file). POST +/// because the token list belongs in a request body — the operation is +/// still a pure read. #[utoipa::path( post, tag = "Tags", @@ -42,13 +48,8 @@ pub async fn bulk_list_media_file_tags_handler( request: Json, 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) - })?; - - require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?; - + // Cheap validation first — oversized requests shouldn't cost a pool + // connection or a session query. if request.media_file_tokens.len() > MAX_BULK { return Err(CommonWebError::BadInputWithSimpleMessage( format!("too many media files in one request (max {})", MAX_BULK), @@ -56,15 +57,33 @@ pub async fn bulk_list_media_file_tags_handler( } // Dedupe, preserving request order. + let mut seen = HashSet::new(); let mut media_file_tokens: Vec = Vec::new(); for token in &request.media_file_tokens { - if !media_file_tokens.contains(token) { + if seen.insert(token.as_str()) { media_file_tokens.push(token.clone()); } } + 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 visible_tokens = filter_visible_media_file_tokens(FilterVisibleMediaFileTokensArgs { + candidate_tokens: &media_file_tokens, + requester_user_token: &user_session.user_token, + mysql_executor: &mut *conn, + phantom: PhantomData, + }).await.map_err(|err| { + warn!("filter_visible_media_file_tokens failed: {:?}", err); + CommonWebError::from_error(err) + })?; + let pair_rows = bulk_list_tags_for_media_files(BulkListTagsForMediaFilesArgs { - media_file_tokens: &media_file_tokens, + media_file_tokens: &visible_tokens, mysql_executor: &mut *conn, phantom: PhantomData, }).await.map_err(|err| { @@ -86,7 +105,8 @@ pub async fn bulk_list_media_file_tags_handler( } // One entry per requested token, in request order, empty list when - // the file has no tags. Tags sorted by value for stable output. + // the file has no tags (or isn't visible to the requester). Tags + // sorted by value for stable output. let media_files = media_file_tokens.into_iter() .map(|media_file_token| { let mut tags = tags_by_media_file.remove(&media_file_token).unwrap_or_default(); diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/tags/bulk_set_tags_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/tags/bulk_set_tags_handler.rs index 81ad6f31a5..176b0edf73 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/tags/bulk_set_tags_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/tags/bulk_set_tags_handler.rs @@ -74,6 +74,17 @@ pub async fn bulk_set_tags_handler( CommonWebError::from_error(err) })?; + // Nothing to update — don't create (or revive) tags that would + // attach to no files. + if accepted.is_empty() { + return Ok(Json(BulkSetTagsSuccessResponse { + success: true, + accepted_media_file_tokens: Vec::new(), + tags: Vec::new(), + removed_count: 0, + })); + } + let outcome = apply_tags_to_media_files( &mut conn, &user_session.user_token, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/tags/list_media_file_tags_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/tags/list_media_file_tags_handler.rs index 6facabcc30..d9f9374762 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/tags/list_media_file_tags_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/tags/list_media_file_tags_handler.rs @@ -2,12 +2,16 @@ use std::marker::PhantomData; use std::sync::Arc; use actix_web::web::{Json, Path}; -use actix_web::web; +use actix_web::{web, HttpRequest}; use log::warn; use artcraft_api_defs::tags::list_media_file_tags::{ ListMediaFileTagsPathInfo, ListMediaFileTagsSuccessResponse, }; +use enums::common::visibility::Visibility; +use mysql_queries::queries::tags::get_media_file_access_fields::{ + get_media_file_access_fields, GetMediaFileAccessFieldsArgs, +}; use mysql_queries::queries::tags::list_tags_for_media_file::{ list_tags_for_media_file, ListTagsForMediaFileArgs, }; @@ -17,8 +21,10 @@ use crate::http_server::common_responses::common_web_error::CommonWebError; use crate::http_server::endpoints::tags::tag_details::tag_row_to_details; use crate::state::server_state::ServerState; -/// All tags on a media file, sorted by tag value. Public — no login -/// required, and not scoped to the tag creator. +/// All tags on a media file, sorted by tag value. No login required for +/// public and hidden media files (anyone with the URL); PRIVATE files +/// only reveal their tags to their creator — everyone else gets a 404, +/// same as a nonexistent file, so private tag vocabulary doesn't leak. #[utoipa::path( get, tag = "Tags", @@ -26,10 +32,12 @@ use crate::state::server_state::ServerState; params(("media_file_token" = MediaFileToken, description = "Media file token")), responses( (status = 200, body = ListMediaFileTagsSuccessResponse), + (status = 404, body = CommonWebError), (status = 500, body = CommonWebError), ), )] pub async fn list_media_file_tags_handler( + http_request: HttpRequest, path: Path, server_state: web::Data>, ) -> Result, CommonWebError> { @@ -38,6 +46,39 @@ pub async fn list_media_file_tags_handler( CommonWebError::from_error(err) })?; + let access_fields = get_media_file_access_fields(GetMediaFileAccessFieldsArgs { + media_file_token: &path.media_file_token, + mysql_executor: &mut *conn, + phantom: PhantomData, + }).await.map_err(|err| { + warn!("Media file access lookup failed: {:?}", err); + CommonWebError::from_error(err) + })?; + let Some(access_fields) = access_fields else { + return Err(CommonWebError::NotFound); + }; + + if access_fields.creator_set_visibility == Visibility::Private { + // Only the creator may see a private file's tags. The session + // lookup is deferred to here so the common (non-private) case + // stays session-free. + let maybe_user_session = server_state.session_checker + .maybe_get_user_session_from_connection(&http_request, &mut conn) + .await + .map_err(|err| { + warn!("Session checker error: {:?}", err); + CommonWebError::from_error(err) + })?; + + let is_creator = match (&maybe_user_session, &access_fields.maybe_creator_user_token) { + (Some(session), Some(creator)) => session.user_token.as_str() == creator.as_str(), + _ => false, + }; + if !is_creator { + return Err(CommonWebError::NotFound); + } + } + let rows = list_tags_for_media_file(ListTagsForMediaFileArgs { media_file_token: &path.media_file_token, mysql_executor: &mut *conn, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/tags/rename_tag_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/tags/rename_tag_handler.rs index 811216f070..b10b524ed3 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/tags/rename_tag_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/tags/rename_tag_handler.rs @@ -3,16 +3,28 @@ use std::sync::Arc; use actix_web::web::{Json, Path}; use actix_web::{web, HttpRequest}; -use log::warn; +use log::{error, warn}; +use sqlx::pool::PoolConnection; +use sqlx::{Acquire, MySql, Transaction}; use artcraft_api_defs::tags::common::TagDetails; use artcraft_api_defs::tags::rename_tag::{ RenameTagPathInfo, RenameTagRequest, RenameTagSuccessResponse, }; use mysql_queries::errors::database_insert_error::DatabaseInsertError; +use mysql_queries::queries::tags::delete_media_file_tags_for_tag::{ + delete_media_file_tags_for_tag, DeleteMediaFileTagsForTagArgs, +}; +use mysql_queries::queries::tags::get_soft_deleted_tag_by_lowercase::{ + get_soft_deleted_tag_by_lowercase, GetSoftDeletedTagByLowercaseArgs, +}; use mysql_queries::queries::tags::get_tag_for_owner::{get_tag_for_owner, GetTagForOwnerArgs}; +use mysql_queries::queries::tags::hard_delete_soft_deleted_tag::{ + hard_delete_soft_deleted_tag, HardDeleteSoftDeletedTagArgs, +}; use mysql_queries::queries::tags::rename_tag::{rename_tag, RenameTagArgs}; use tokens::tokens::tags::TagToken; +use tokens::tokens::users::UserToken; use crate::http_server::common_responses::common_web_error::CommonWebError; use crate::http_server::endpoints::tags::tag_input::MAX_TAG_LENGTH_CHARS; @@ -20,9 +32,10 @@ use crate::http_server::user_lookup::user_session::require_user_session::require use crate::state::server_state::ServerState; /// Rename a tag. Handles both case-only changes (same lowercased value) -/// and wholesale renames. Fails with 400 if the user already has a -/// different tag with the same lowercased value. Only the tag's creator -/// may do this (404 otherwise). +/// and wholesale renames. If the target name is held by one of the +/// user's SOFT-DELETED tags, the dead row is purged so the rename can +/// take the name over; a LIVE tag with the target name fails with 400. +/// Only the tag's creator may do this (404 otherwise). #[utoipa::path( put, tag = "Tags", @@ -49,12 +62,17 @@ pub async fn rename_tag_handler( "new_tag_value must not be empty".to_string(), )); } - if new_tag_value.chars().count() > MAX_TAG_LENGTH_CHARS { + // NB: check the lowercased form too — Unicode lowercasing can EXPAND + // a string (e.g. 'İ' U+0130 → "i\u{307}"), and both columns are + // VARCHAR(255). + let new_tag_value_lowercase = new_tag_value.to_lowercase(); + if new_tag_value.chars().count() > MAX_TAG_LENGTH_CHARS + || new_tag_value_lowercase.chars().count() > MAX_TAG_LENGTH_CHARS + { return Err(CommonWebError::BadInputWithSimpleMessage( format!("tag is too long (max {} characters)", MAX_TAG_LENGTH_CHARS), )); } - let new_tag_value_lowercase = new_tag_value.to_lowercase(); let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| { warn!("MySQL pool error: {:?}", err); @@ -76,16 +94,119 @@ pub async fn rename_tag_handler( return Err(CommonWebError::NotFound); }; - let updated = rename_tag(RenameTagArgs { - tag_token: &path.tag_token, - creator_user_token: &user_session.user_token, + perform_atomic_rename( + &mut conn, + &path.tag_token, + &user_session.user_token, new_tag_value, - new_tag_value_lowercase: &new_tag_value_lowercase, - mysql_executor: &mut *conn, + &new_tag_value_lowercase, + ).await?; + + Ok(Json(RenameTagSuccessResponse { + success: true, + tag: TagDetails { + tag_token: tag.token, + tag_value: new_tag_value.to_string(), + tag_value_lowercase: new_tag_value_lowercase, + use_count: tag.use_count, + }, + })) +} + +/// Purge any soft-deleted tag squatting on the target name, then rename +/// — as one unit, so a failed rename doesn't leave the dead tag purged. +async fn perform_atomic_rename( + conn: &mut PoolConnection, + tag_token: &TagToken, + creator_user_token: &UserToken, + new_tag_value: &str, + new_tag_value_lowercase: &str, +) -> Result<(), CommonWebError> { + let mut tx = conn.begin().await.map_err(|err| { + warn!("Failed to begin rename-tag transaction: {:?}", err); + CommonWebError::from_error(err) + })?; + + let work_result = perform_rename_work( + &mut tx, + tag_token, + creator_user_token, + new_tag_value, + new_tag_value_lowercase, + ).await; + + match work_result { + Ok(()) => { + tx.commit().await.map_err(|err| { + warn!("Failed to commit rename-tag transaction: {:?}", err); + CommonWebError::from_error(err) + })?; + Ok(()) + } + Err(err) => { + if let Err(rollback_err) = tx.rollback().await { + error!( + "Rollback after rename-tag failure also failed: {:?} (original error: {:?})", + rollback_err, err, + ); + } + Err(err) + } + } +} + +async fn perform_rename_work( + tx: &mut Transaction<'_, MySql>, + tag_token: &TagToken, + creator_user_token: &UserToken, + new_tag_value: &str, + new_tag_value_lowercase: &str, +) -> Result<(), CommonWebError> { + // A soft-deleted tag still occupies the (tag_value_lowercase, + // creator_user_token) unique key. The user's tag list doesn't show + // it, so from their perspective the name is free — purge the dead + // row (and any stray links to it) so the rename can take the name. + let maybe_dead_tag_token = get_soft_deleted_tag_by_lowercase(GetSoftDeletedTagByLowercaseArgs { + tag_value_lowercase: new_tag_value_lowercase, + creator_user_token, + mysql_executor: &mut **tx, phantom: PhantomData, }).await.map_err(|err| { - // The (tag_value_lowercase, creator_user_token) unique key rejects - // renames that collide with another of the user's tags. + warn!("get_soft_deleted_tag_by_lowercase failed: {:?}", err); + CommonWebError::from_error(err) + })?; + + if let Some(dead_tag_token) = maybe_dead_tag_token { + delete_media_file_tags_for_tag(DeleteMediaFileTagsForTagArgs { + tag_token: &dead_tag_token, + mysql_executor: &mut **tx, + phantom: PhantomData, + }).await.map_err(|err| { + warn!("delete_media_file_tags_for_tag failed: {:?}", err); + CommonWebError::from_error(err) + })?; + + hard_delete_soft_deleted_tag(HardDeleteSoftDeletedTagArgs { + tag_token: &dead_tag_token, + creator_user_token, + mysql_executor: &mut **tx, + phantom: PhantomData, + }).await.map_err(|err| { + warn!("hard_delete_soft_deleted_tag failed: {:?}", err); + CommonWebError::from_error(err) + })?; + } + + let updated = rename_tag(RenameTagArgs { + tag_token, + creator_user_token, + new_tag_value, + new_tag_value_lowercase, + mysql_executor: &mut **tx, + phantom: PhantomData, + }).await.map_err(|err| { + // A LIVE tag with the target name still rejects the rename via the + // (tag_value_lowercase, creator_user_token) unique key. match DatabaseInsertError::from(err) { DatabaseInsertError::DuplicateKeyError => CommonWebError::BadInputWithSimpleMessage( "you already have a tag with that name".to_string(), @@ -102,13 +223,5 @@ pub async fn rename_tag_handler( return Err(CommonWebError::NotFound); } - Ok(Json(RenameTagSuccessResponse { - success: true, - tag: TagDetails { - tag_token: tag.token, - tag_value: new_tag_value.to_string(), - tag_value_lowercase: new_tag_value_lowercase, - use_count: tag.use_count, - }, - })) + Ok(()) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/tags/tag_input.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/tags/tag_input.rs index d181f0ee64..ef85ced567 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/tags/tag_input.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/tags/tag_input.rs @@ -45,13 +45,18 @@ pub fn parse_tag_input( if trimmed.is_empty() { continue; } - if trimmed.chars().count() > MAX_TAG_LENGTH_CHARS { + // NB: the lowercased form must be length-checked too — Unicode + // lowercasing can EXPAND a string (e.g. 'İ' U+0130 lowercases to + // "i\u{307}", two chars), and both columns are VARCHAR(255). + let lowercase = trimmed.to_lowercase(); + if trimmed.chars().count() > MAX_TAG_LENGTH_CHARS + || lowercase.chars().count() > MAX_TAG_LENGTH_CHARS + { return Err(CommonWebError::BadInputWithSimpleMessage( format!("tag is too long (max {} characters)", MAX_TAG_LENGTH_CHARS), )); } - let lowercase = trimmed.to_lowercase(); if !seen_lowercase.insert(lowercase.clone()) { continue; } @@ -81,3 +86,39 @@ pub fn require_non_empty_tags(new_tags: &[NewTagValue]) -> Result<(), CommonWebE } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_csv_with_trimming_and_case_insensitive_dedupe() { + let tags = parse_tag_input(Some(" Cats , sci-fi ,Wallpaper,, cats "), None).unwrap(); + let values: Vec<&str> = tags.iter().map(|t| t.tag_value.as_str()).collect(); + assert_eq!(values, vec!["Cats", "sci-fi", "Wallpaper"]); + assert_eq!(tags[0].tag_value_lowercase, "cats"); + } + + #[test] + fn rejects_both_or_neither_input() { + assert!(parse_tag_input(Some("a"), Some(&["b".to_string()])).is_err()); + assert!(parse_tag_input(None, None).is_err()); + } + + #[test] + fn rejects_tag_whose_lowercase_form_expands_past_the_limit() { + // 'İ' (U+0130) is one char but lowercases to two ("i\u{307}"), so + // 255 of them pass a naive check while the lowercase form is 510 + // chars — over the VARCHAR(255) limit. + let dotted_capital_i = "İ".repeat(MAX_TAG_LENGTH_CHARS); + assert_eq!(dotted_capital_i.chars().count(), MAX_TAG_LENGTH_CHARS); + assert!(parse_tag_input(Some(&dotted_capital_i), None).is_err()); + } + + #[test] + fn accepts_tag_at_exactly_the_length_limit() { + let max_length_tag = "a".repeat(MAX_TAG_LENGTH_CHARS); + let tags = parse_tag_input(Some(&max_length_tag), None).unwrap(); + assert_eq!(tags.len(), 1); + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/tags/tag_media_file_list_item.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/tags/tag_media_file_list_item.rs index 74e3bd73c1..97438bb334 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/tags/tag_media_file_list_item.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/tags/tag_media_file_list_item.rs @@ -3,11 +3,10 @@ use serde_derive::Serialize; use utoipa::ToSchema; use artcraft_api_defs::common::responses::media_links::MediaLinks; -use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath; 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::tags::tag_media_file_row::TagMediaFileRow; +use mysql_queries::queries::media_files::list::media_file_list_row::MediaFileListRow; use server_environment::ServerEnvironment; use tokens::tokens::batch_generations::BatchGenerationToken; use tokens::tokens::media_files::MediaFileToken; @@ -15,7 +14,7 @@ use tokens::tokens::prompts::PromptToken; 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_links_builder::MediaLinksBuilder; +use crate::http_server::common_responses::media::media_file_list_conversion::build_media_links_and_cover; /// One media file as it appears in the tag-scoped media file lists /// (untagged / tagged / with-tag). Same lean shape as the folder list @@ -65,30 +64,11 @@ pub struct TagMediaFileListItem { } pub fn tag_media_file_row_to_list_item( - row: TagMediaFileRow, + row: MediaFileListRow, media_domain: MediaDomain, server_environment: ServerEnvironment, ) -> TagMediaFileListItem { - let bucket_path = MediaFileBucketPath::from_object_hash( - &row.public_bucket_directory_hash, - row.maybe_public_bucket_prefix.as_deref(), - row.maybe_public_bucket_extension.as_deref(), - ); - - let media_links = MediaLinksBuilder::from_media_path_and_env( - media_domain, - server_environment, - &bucket_path, - ); - - let cover_image = MediaFileCoverImageDetails::from_optional_db_fields( - &row.media_file_token, - media_domain, - server_environment, - row.maybe_cover_public_bucket_directory_hash.as_deref(), - row.maybe_cover_public_bucket_prefix.as_deref(), - row.maybe_cover_public_bucket_extension.as_deref(), - ); + let (media_links, cover_image) = build_media_links_and_cover(&row, media_domain, server_environment); TagMediaFileListItem { token: row.media_file_token,