This commit is contained in:
Brandon Thomas committed 2026-07-10 05:08:57 -04:00
1 parent efc8dadcb3
commit ceec7d4b77
26 files changed
+781 -363

No files matched your search

@@ -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<Utc>,
pub media_file_token: MediaFileToken,
pub media_class: MediaFileClass,
pub media_type: MediaFileType,
pub maybe_batch_token: Option<BatchGenerationToken>,
// 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<String>,
pub maybe_public_bucket_extension: Option<String>,
// 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<String>,
pub maybe_cover_public_bucket_prefix: Option<String>,
pub maybe_cover_public_bucket_extension: Option<String>,
pub creator_set_visibility: Visibility,
pub is_user_upload: bool,
pub is_intermediate_system_file: bool,
pub maybe_title: Option<String>,
pub maybe_prompt_token: Option<PromptToken>,
pub maybe_origin_filename: Option<String>,
pub maybe_duration_millis: Option<i32>,
pub maybe_frame_width: Option<i32>,
pub maybe_frame_height: Option<i32>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
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<Utc>`,
@@ -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::<Vec<_>>()
}
None => {
sqlx::query!(
r#"
SELECT
mf.id as `media_file_id: u64`,
fmf.created_at as `added_to_folder_at: DateTime<Utc>`,
"#,
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::<Vec<_>>()
}
};
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())
}
@@ -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,
@@ -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;
@@ -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<u64, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
if args.tag_tokens.is_empty() {
return Ok(0);
}
let mut builder = QueryBuilder::<MySql>::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())
}
@@ -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<Vec<MediaFileToken>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
if args.candidate_tokens.is_empty() {
return Ok(Vec::new());
}
let mut builder = QueryBuilder::<MySql>::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::<String, _>(0)))
.collect())
}
@@ -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<UserToken>,
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<Option<MediaFileAccessFields>, 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,
}))
}
@@ -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<Option<TagToken>, 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))
}
@@ -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<u64, sqlx::Error>
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())
}
@@ -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<Vec<TagMediaFileRow>, sqlx::Error>
) -> Result<Vec<MediaFileListRow>, 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
}
@@ -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<Vec<TagToken>, 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())
}
@@ -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<Vec<TagMediaFileRow>, sqlx::Error>
) -> Result<Vec<MediaFileListRow>, 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
}
@@ -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<Vec<TagMediaFileRow>, sqlx::Error>
) -> Result<Vec<MediaFileListRow>, 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
}
@@ -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;
@@ -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 (",
@@ -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)
}
@@ -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;
@@ -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,
}
}
@@ -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);
}
}
@@ -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();
@@ -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<Vec<MediaFileToken>, CommonWebError> {
let mut seen = HashSet::new();
let mut deduped: Vec<MediaFileToken> = 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)
}
@@ -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<BulkListMediaFileTagsRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<BulkListMediaFileTagsSuccessResponse>, 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<MediaFileToken> = 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();
@@ -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,
@@ -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<ListMediaFileTagsPathInfo>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ListMediaFileTagsSuccessResponse>, 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,
@@ -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<MySql>,
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(())
}
@@ -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);
}
}
@@ -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,