mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-11 20:32:12 +00:00
fixes
This commit is contained in:
1 parent
efc8dadcb3
commit
ceec7d4b77
26 files changed
+781
-363
No files matched your search
+52
-158
@@ -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())
|
||||
}
|
||||
+11
-5
@@ -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;
|
||||
+48
@@ -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())
|
||||
}
|
||||
+55
@@ -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,
|
||||
}))
|
||||
}
|
||||
+44
@@ -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())
|
||||
}
|
||||
+5
-30
@@ -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
|
||||
}
|
||||
+39
@@ -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())
|
||||
}
|
||||
+5
-30
@@ -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
|
||||
}
|
||||
+5
-30
@@ -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 (",
|
||||
|
||||
+41
@@ -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;
|
||||
+18
-37
@@ -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,
|
||||
}
|
||||
}
|
||||
+35
@@ -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();
|
||||
|
||||
+21
-7
@@ -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)
|
||||
}
|
||||
+34
-14
@@ -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();
|
||||
|
||||
+11
@@ -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,
|
||||
|
||||
+44
-3
@@ -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,
|
||||
|
||||
+135
-22
@@ -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);
|
||||
}
|
||||
}
|
||||
+4
-24
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user