Compare commits

...
Author SHA1 Message Date
Brandon Thomas 895d72fd37 codegen db 2026-07-10 05:20:25 -04:00
Brandon Thomas ee0afabc66 codegen 2026-07-10 05:19:57 -04:00
Brandon Thomas 4c3f0b11c9 fixes 2026-07-10 05:18:29 -04:00
Brandon Thomas ceec7d4b77 fixes 2026-07-10 05:08:57 -04:00
Brandon Thomas efc8dadcb3 new tags endpoints 2026-07-10 04:26:46 -04:00
88 changed files with 5281 additions and 27 deletions
@@ -0,0 +1,25 @@
{
"db_name": "MySQL",
"query": "\nSELECT DISTINCT tag_token as `tag_token: TagToken`\nFROM media_file_tags\nWHERE media_file_token = ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "tag_token: TagToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "05033f7f4588cc2d68d9db22236f2be323731ee120217192fa29fe60af776e19"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "DELETE FROM media_file_tags WHERE tag_token = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "3495f03338e7ee4c2ae9787204d521aefa787312463d392e45d02a693d9afb83"
}
@@ -0,0 +1,245 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM media_files mf\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE mf.maybe_creator_user_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND NOT mf.is_intermediate_system_file\n AND EXISTS (\n SELECT 1 FROM media_file_tags mft\n WHERE mft.media_file_token = mf.token\n )\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "media_file_id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "media_file_token: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "media_class: MediaFileClass",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 3,
"name": "media_type: MediaFileType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 4,
"name": "maybe_batch_token: BatchGenerationToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "public_bucket_directory_hash",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "maybe_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 7,
"name": "maybe_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 8,
"name": "maybe_cover_public_bucket_directory_hash?",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "maybe_cover_public_bucket_prefix?",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 10,
"name": "maybe_cover_public_bucket_extension?",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 11,
"name": "creator_set_visibility: Visibility",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | ENUM",
"char_set": 224,
"max_size": 28
}
},
{
"ordinal": 12,
"name": "is_user_upload: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL | MULTIPLE_KEY",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 13,
"name": "maybe_title",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 14,
"name": "maybe_prompt_token: PromptToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 15,
"name": "maybe_origin_filename",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 16,
"name": "maybe_duration_millis",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 17,
"name": "maybe_frame_width",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
}
},
{
"ordinal": 18,
"name": "maybe_frame_height",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
}
},
{
"ordinal": 19,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 20,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
false,
false,
true,
false,
true,
true,
true,
true,
true,
false,
false,
true,
true,
true,
true,
true,
true,
false,
false
]
},
"hash": "76bcf698f40d852558f071a6909b05b1c960c1cf236831ae009f90de46d3aefb"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE tags\nSET tag_value = ?,\n tag_value_lowercase = ?\nWHERE token = ?\n AND creator_user_token = ?\n AND maybe_deleted_at IS NULL\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "8938fc564dda214e383a8d30532db6ef964c03f7e1fd6d9561bca5e8b56834cf"
}
@@ -0,0 +1,36 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n maybe_creator_user_token as `maybe_creator_user_token: UserToken`,\n creator_set_visibility as `creator_set_visibility: Visibility`\nFROM media_files\nWHERE token = ?\n AND user_deleted_at IS NULL\n AND mod_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "maybe_creator_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "creator_set_visibility: Visibility",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | ENUM",
"char_set": 224,
"max_size": 28
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false
]
},
"hash": "a114fab486689fd2b16197cbe3a3ebb41d3d10937542832e15dd652200b6d419"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE tags\nSET maybe_deleted_at = NOW(),\n use_count = 0\nWHERE token = ?\n AND creator_user_token = ?\n AND maybe_deleted_at IS NULL\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "a58fc9050c1fd160e03d6e4932c1b777ea7ab8cbfddca5834faab3e2963d816d"
}
@@ -0,0 +1,245 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM media_files mf\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE mf.maybe_creator_user_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND NOT mf.is_intermediate_system_file\n AND NOT EXISTS (\n SELECT 1 FROM media_file_tags mft\n WHERE mft.media_file_token = mf.token\n )\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "media_file_id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "media_file_token: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "media_class: MediaFileClass",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 3,
"name": "media_type: MediaFileType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 4,
"name": "maybe_batch_token: BatchGenerationToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "public_bucket_directory_hash",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "maybe_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 7,
"name": "maybe_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 8,
"name": "maybe_cover_public_bucket_directory_hash?",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "maybe_cover_public_bucket_prefix?",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 10,
"name": "maybe_cover_public_bucket_extension?",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 11,
"name": "creator_set_visibility: Visibility",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | ENUM",
"char_set": 224,
"max_size": 28
}
},
{
"ordinal": 12,
"name": "is_user_upload: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL | MULTIPLE_KEY",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 13,
"name": "maybe_title",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 14,
"name": "maybe_prompt_token: PromptToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 15,
"name": "maybe_origin_filename",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 16,
"name": "maybe_duration_millis",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 17,
"name": "maybe_frame_width",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
}
},
{
"ordinal": 18,
"name": "maybe_frame_height",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
}
},
{
"ordinal": 19,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 20,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
false,
false,
true,
false,
true,
true,
true,
true,
true,
false,
false,
true,
true,
true,
true,
true,
true,
false,
false
]
},
"hash": "aa1e693babe6997e75935ec893e6eed46351ec72ce02a9144fd11d2fcfd37fdf"
}
@@ -0,0 +1,69 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n t.id as `id: u64`,\n t.token as `token: TagToken`,\n t.tag_value,\n t.tag_value_lowercase,\n t.use_count as `use_count: u32`\nFROM media_file_tags mft\nJOIN tags t\n ON t.token = mft.tag_token\nWHERE mft.media_file_token = ?\n AND t.maybe_deleted_at IS NULL\nORDER BY t.tag_value_lowercase ASC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "token: TagToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "tag_value",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "tag_value_lowercase",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 4,
"name": "use_count: u32",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 10
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "bbfed9e861b1a7cd0cdb2413e2a985aaa79aa9c885408bf5e104d1ea80bdaf49"
}
@@ -0,0 +1,25 @@
{
"db_name": "MySQL",
"query": "\nSELECT token as `token: TagToken`\nFROM tags\nWHERE creator_user_token = ?\n AND tag_value_lowercase = ?\n AND maybe_deleted_at IS NOT NULL\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: TagToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false
]
},
"hash": "cbfe9172d6227175aa6dfd276a4d1a6676d1d1b6bdc3a35927a5d71be38219c0"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nDELETE FROM tags\nWHERE token = ?\n AND creator_user_token = ?\n AND maybe_deleted_at IS NOT NULL\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "e50d11df0606a12059e13f36daa41fc5aa59d700168425c406e545b535d6d2ba"
}
@@ -0,0 +1,69 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n id as `id: u64`,\n token as `token: TagToken`,\n tag_value,\n tag_value_lowercase,\n use_count as `use_count: u32`\nFROM tags\nWHERE creator_user_token = ?\n AND maybe_deleted_at IS NULL\n AND id < ?\nORDER BY id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "token: TagToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "tag_value",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "tag_value_lowercase",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 4,
"name": "use_count: u32",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 10
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "eafaf1a9ed2e27dec201ea66d23a65662492ca2dbdac6333629b82defc4b5111"
}
@@ -0,0 +1,69 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n id as `id: u64`,\n token as `token: TagToken`,\n tag_value,\n tag_value_lowercase,\n use_count as `use_count: u32`\nFROM tags\nWHERE token = ?\n AND creator_user_token = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "token: TagToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "tag_value",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "tag_value_lowercase",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 4,
"name": "use_count: u32",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 10
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "ecc3848cd186f1298a2a60c810c69b7a8715719a0c7622122d7e1c5747fab06a"
}
@@ -0,0 +1,245 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM media_file_tags mft\nJOIN media_files mf\n ON mf.token = mft.media_file_token\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE mft.tag_token = ?\n AND mft.user_token = ?\n AND mf.maybe_creator_user_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND NOT mf.is_intermediate_system_file\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "media_file_id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "media_file_token: MediaFileToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "media_class: MediaFileClass",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 3,
"name": "media_type: MediaFileType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 4,
"name": "maybe_batch_token: BatchGenerationToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "public_bucket_directory_hash",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "maybe_public_bucket_prefix",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 7,
"name": "maybe_public_bucket_extension",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 8,
"name": "maybe_cover_public_bucket_directory_hash?",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "maybe_cover_public_bucket_prefix?",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 10,
"name": "maybe_cover_public_bucket_extension?",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 11,
"name": "creator_set_visibility: Visibility",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | ENUM",
"char_set": 224,
"max_size": 28
}
},
{
"ordinal": 12,
"name": "is_user_upload: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL | MULTIPLE_KEY",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 13,
"name": "maybe_title",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 14,
"name": "maybe_prompt_token: PromptToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 15,
"name": "maybe_origin_filename",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 16,
"name": "maybe_duration_millis",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 17,
"name": "maybe_frame_width",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
}
},
{
"ordinal": 18,
"name": "maybe_frame_height",
"type_info": {
"type": "Long",
"flags": "",
"char_set": 63,
"max_size": 5
}
},
{
"ordinal": 19,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 20,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 5
},
"nullable": [
false,
false,
false,
false,
true,
false,
true,
true,
true,
true,
true,
false,
false,
true,
true,
true,
true,
true,
true,
false,
false
]
},
"hash": "f36c7238231353882b5b2ac39616dcdc6adebf7c7e1198d9c2af4674ba5d982c"
}
@@ -13,6 +13,7 @@ pub mod omni_gen;
pub mod prompts;
pub mod stripe_artcraft;
pub mod subscriptions;
pub mod tags;
pub mod user_referral_codes;
pub mod users;
pub mod utils;
@@ -0,0 +1,35 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::media_files::MediaFileToken;
use crate::tags::common::TagDetails;
// ── POST /v1/tags/media_file/add/{media_file_token} ──
#[derive(Deserialize, ToSchema)]
pub struct AddMediaFileTagsPathInfo {
pub media_file_token: MediaFileToken,
}
/// Exactly one of `maybe_tags` / `maybe_tags_list` must be set (400 if
/// both or neither). Tag text is trimmed and deduped case-insensitively;
/// empty entries are dropped. Must sanitize to at least one tag (400
/// otherwise).
#[derive(Deserialize, ToSchema)]
pub struct AddMediaFileTagsRequest {
/// Comma-separated tags, e.g. `"cats, Sci-Fi, wallpaper"`.
pub maybe_tags: Option<String>,
/// Tags as a list. Entries are still trimmed.
pub maybe_tags_list: Option<Vec<String>>,
}
#[derive(Serialize, ToSchema)]
pub struct AddMediaFileTagsSuccessResponse {
pub success: bool,
/// The tags from this request after upsert, with canonical tokens and
/// fresh use counts. Tags already on the file are absorbed (no error).
pub tags: Vec<TagDetails>,
}
@@ -0,0 +1,38 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::media_files::MediaFileToken;
use crate::tags::common::TagDetails;
// ── POST /v1/tags/bulk_add ──
/// Exactly one of `maybe_tags` / `maybe_tags_list` must be set (400 if
/// both or neither). Tag text is trimmed and deduped case-insensitively;
/// empty entries are dropped. Must sanitize to at least one tag (400
/// otherwise). Media file tokens are deduped; tokens the user doesn't
/// own (or that are deleted) are silently skipped.
#[derive(Deserialize, ToSchema)]
pub struct BulkAddTagsRequest {
/// Media files to tag.
pub media_file_tokens: Vec<MediaFileToken>,
/// Comma-separated tags, e.g. `"cats, Sci-Fi, wallpaper"`.
pub maybe_tags: Option<String>,
/// Tags as a list. Entries are still trimmed.
pub maybe_tags_list: Option<Vec<String>>,
}
#[derive(Serialize, ToSchema)]
pub struct BulkAddTagsSuccessResponse {
pub success: bool,
/// The subset of the input tokens that were actually tagged: files
/// that exist, aren't deleted, and are owned by the caller.
pub accepted_media_file_tokens: Vec<MediaFileToken>,
/// The tags from this request after upsert, with canonical tokens and
/// fresh use counts.
pub tags: Vec<TagDetails>,
}
@@ -0,0 +1,33 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::media_files::MediaFileToken;
use crate::tags::common::TagDetails;
// ── POST /v1/tags/media_files/bulk_list_tags ──
//
// POST rather than GET: the token list belongs in a request body, and
// GET request bodies are non-standard. The operation is still a pure
// read.
#[derive(Deserialize, ToSchema)]
pub struct BulkListMediaFileTagsRequest {
/// Media files to look up. Tokens with no tags come back with an
/// empty `tags` list.
pub media_file_tokens: Vec<MediaFileToken>,
}
#[derive(Serialize, ToSchema)]
pub struct MediaFileTagsEntry {
pub media_file_token: MediaFileToken,
pub tags: Vec<TagDetails>,
}
#[derive(Serialize, ToSchema)]
pub struct BulkListMediaFileTagsSuccessResponse {
pub success: bool,
/// One entry per requested media file token (deduped), in request order.
pub media_files: Vec<MediaFileTagsEntry>,
}
@@ -0,0 +1,44 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::media_files::MediaFileToken;
use crate::tags::common::TagDetails;
// ── POST /v1/tags/bulk_set ──
/// Exactly one of `maybe_tags` / `maybe_tags_list` must be set (400 if
/// both or neither). Tag text is trimmed and deduped case-insensitively;
/// empty entries are dropped. Unlike `bulk_add`, sanitizing down to zero
/// tags is allowed — it clears all tags from the listed media files.
/// Media file tokens are deduped; tokens the user doesn't own (or that
/// are deleted) are silently skipped.
#[derive(Deserialize, ToSchema)]
pub struct BulkSetTagsRequest {
/// Media files whose tag sets will be replaced.
pub media_file_tokens: Vec<MediaFileToken>,
/// Comma-separated tags, e.g. `"cats, Sci-Fi, wallpaper"`.
pub maybe_tags: Option<String>,
/// Tags as a list. Entries are still trimmed.
pub maybe_tags_list: Option<Vec<String>>,
}
#[derive(Serialize, ToSchema)]
pub struct BulkSetTagsSuccessResponse {
pub success: bool,
/// The subset of the input tokens that were actually updated: files
/// that exist, aren't deleted, and are owned by the caller.
pub accepted_media_file_tokens: Vec<MediaFileToken>,
/// The full tag set now on every accepted media file, with canonical
/// tokens and fresh use counts.
pub tags: Vec<TagDetails>,
/// How many previously-attached tag links were removed across all the
/// accepted media files because they weren't mentioned in the request.
/// (Orphaned tags are not deleted.)
pub removed_count: u64,
}
@@ -0,0 +1,20 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::media_files::MediaFileToken;
// ── POST /v1/tags/media_file/clear/{media_file_token} ──
#[derive(Deserialize, ToSchema)]
pub struct ClearMediaFileTagsPathInfo {
pub media_file_token: MediaFileToken,
}
#[derive(Serialize, ToSchema)]
pub struct ClearMediaFileTagsSuccessResponse {
pub success: bool,
/// How many tag links were removed from the media file. (Orphaned
/// tags are not deleted.)
pub removed_count: u64,
}
@@ -0,0 +1,20 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::tags::TagToken;
/// One tag as returned by every tags endpoint.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct TagDetails {
pub tag_token: TagToken,
/// The display value of the tag, as entered by its creator.
pub tag_value: String,
/// Lowercased form of `tag_value`. This is the tag's unique key
/// within the creator's account.
pub tag_value_lowercase: String,
/// Rollup statistic: how many media files currently carry this tag.
pub use_count: u32,
}
@@ -0,0 +1,20 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::tags::TagToken;
// ── DELETE /v1/tags/{tag_token} ──
#[derive(Deserialize, ToSchema)]
pub struct DeleteTagPathInfo {
pub tag_token: TagToken,
}
#[derive(Serialize, ToSchema)]
pub struct DeleteTagSuccessResponse {
pub success: bool,
/// How many media-file links were hard-deleted along with the tag.
/// (The tag record itself is soft-deleted.)
pub removed_link_count: u64,
}
@@ -0,0 +1,21 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::media_files::MediaFileToken;
use crate::tags::common::TagDetails;
// ── GET /v1/tags/media_file/list/{media_file_token} ──
#[derive(Deserialize, ToSchema)]
pub struct ListMediaFileTagsPathInfo {
pub media_file_token: MediaFileToken,
}
#[derive(Serialize, ToSchema)]
pub struct ListMediaFileTagsSuccessResponse {
pub success: bool,
/// All (live) tags on the media file, sorted by tag value.
pub tags: Vec<TagDetails>,
}
@@ -0,0 +1,22 @@
use serde_derive::Deserialize;
use utoipa::{IntoParams, ToSchema};
use tokens::tokens::tags::TagToken;
// ── GET /v1/tags/media_files/with_tag/{tag_token} ──
//
// NB: The success response lives in `storyteller_web`'s handler because
// the wire shape embeds `MediaLinks` / `MediaFileCoverImageDetails`
// constructors that depend on the request's `MediaDomain` +
// `ServerEnvironment`.
#[derive(Deserialize, ToSchema)]
pub struct ListMediaFilesWithTagPathInfo {
pub tag_token: TagToken,
}
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct ListMediaFilesWithTagQueryParams {
pub cursor: Option<String>,
pub limit: Option<u32>,
}
@@ -0,0 +1,15 @@
use serde_derive::Deserialize;
use utoipa::{IntoParams, ToSchema};
// ── GET /v1/tags/media_files/list_tagged ──
//
// NB: The success response lives in `storyteller_web`'s handler because
// the wire shape embeds `MediaLinks` / `MediaFileCoverImageDetails`
// constructors that depend on the request's `MediaDomain` +
// `ServerEnvironment`.
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct ListTaggedMediaFilesQueryParams {
pub cursor: Option<String>,
pub limit: Option<u32>,
}
@@ -0,0 +1,23 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use crate::tags::common::TagDetails;
// ── GET /v1/tags/list ──
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct ListTagsQueryParams {
pub cursor: Option<String>,
pub limit: Option<u32>,
}
#[derive(Serialize, ToSchema)]
pub struct ListTagsSuccessResponse {
pub success: bool,
/// The logged-in user's (live) tags, newest first.
pub tags: Vec<TagDetails>,
/// Present when there may be more results; pass back as `cursor`.
pub maybe_cursor: Option<String>,
}
@@ -0,0 +1,15 @@
use serde_derive::Deserialize;
use utoipa::{IntoParams, ToSchema};
// ── GET /v1/tags/media_files/list_untagged ──
//
// NB: The success response lives in `storyteller_web`'s handler because
// the wire shape embeds `MediaLinks` / `MediaFileCoverImageDetails`
// constructors that depend on the request's `MediaDomain` +
// `ServerEnvironment`.
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct ListUntaggedMediaFilesQueryParams {
pub cursor: Option<String>,
pub limit: Option<u32>,
}
@@ -0,0 +1,14 @@
pub mod add_media_file_tags;
pub mod bulk_add_tags;
pub mod bulk_list_media_file_tags;
pub mod bulk_set_tags;
pub mod clear_media_file_tags;
pub mod common;
pub mod delete_tag;
pub mod list_media_file_tags;
pub mod list_media_files_with_tag;
pub mod list_tagged_media_files;
pub mod list_tags;
pub mod list_untagged_media_files;
pub mod rename_tag;
pub mod set_media_file_tags;
@@ -0,0 +1,29 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::tags::TagToken;
use crate::tags::common::TagDetails;
// ── PUT /v1/tags/rename/{tag_token} ──
#[derive(Deserialize, ToSchema)]
pub struct RenameTagPathInfo {
pub tag_token: TagToken,
}
#[derive(Deserialize, ToSchema)]
pub struct RenameTagRequest {
/// The new tag text (trimmed server-side). Both case-only changes and
/// wholesale renames are allowed. Fails with 400 if the caller already
/// has a different tag with the same lowercased value.
pub new_tag_value: String,
}
#[derive(Serialize, ToSchema)]
pub struct RenameTagSuccessResponse {
pub success: bool,
/// The tag after the rename.
pub tag: TagDetails,
}
@@ -0,0 +1,39 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::media_files::MediaFileToken;
use crate::tags::common::TagDetails;
// ── POST /v1/tags/media_file/set/{media_file_token} ──
#[derive(Deserialize, ToSchema)]
pub struct SetMediaFileTagsPathInfo {
pub media_file_token: MediaFileToken,
}
/// Exactly one of `maybe_tags` / `maybe_tags_list` must be set (400 if
/// both or neither). Tag text is trimmed and deduped case-insensitively;
/// empty entries are dropped. Unlike `add`, sanitizing down to zero tags
/// is allowed — it clears all tags from the media file.
#[derive(Deserialize, ToSchema)]
pub struct SetMediaFileTagsRequest {
/// Comma-separated tags, e.g. `"cats, Sci-Fi, wallpaper"`.
pub maybe_tags: Option<String>,
/// Tags as a list. Entries are still trimmed.
pub maybe_tags_list: Option<Vec<String>>,
}
#[derive(Serialize, ToSchema)]
pub struct SetMediaFileTagsSuccessResponse {
pub success: bool,
/// The media file's full tag set after the operation, with canonical
/// tokens and fresh use counts.
pub tags: Vec<TagDetails>,
/// How many previously-attached tag links were removed because they
/// weren't mentioned in the request. (Orphaned tags are not deleted.)
pub removed_count: u64,
}
@@ -55,7 +55,7 @@ pub async fn delete_all_anonymous_user_images(_args: &Args, mysql: &Pool<MySql>)
}
info!("Deleting image file: {:?}", &file.token);
delete_media_file_as_mod(&file.token, ECHELON_USER_TOKEN, &mysql).await?;
delete_media_file_as_mod(&file.token, ECHELON_USER_TOKEN, mysql).await?;
}
page_index += 1;
@@ -60,7 +60,7 @@ pub async fn delete_all_files(username: &str, mysql: &Pool<MySql>) -> AnyhowResu
for file in media_files.records {
info!("Deleting file: {:?}", &file.token);
delete_media_file_as_mod(&file.token, ECHELON_USER_TOKEN, &mysql).await?;
delete_media_file_as_mod(&file.token, ECHELON_USER_TOKEN, mysql).await?;
}
page_index += 1;
@@ -1,12 +1,15 @@
use sqlx::MySqlPool;
use sqlx::{Executor, MySql};
use errors::AnyhowResult;
use tokens::tokens::media_files::MediaFileToken;
pub async fn delete_media_file_as_user(
pub async fn delete_media_file_as_user<'e, 'c: 'e, E>(
media_file_token: &MediaFileToken,
mysql_pool: &MySqlPool
) -> AnyhowResult<()> {
mysql_executor: E,
) -> AnyhowResult<()>
where
E: 'e + Executor<'c, Database = MySql>,
{
let _r = sqlx::query!(
r#"
UPDATE media_files
@@ -18,16 +21,19 @@ LIMIT 1
"#,
media_file_token,
)
.execute(mysql_pool)
.execute(mysql_executor)
.await?;
Ok(())
}
pub async fn delete_media_file_as_mod(
pub async fn delete_media_file_as_mod<'e, 'c: 'e, E>(
media_file_token: &MediaFileToken,
mod_user_token: &str,
mysql_pool: &MySqlPool
) -> AnyhowResult<()> {
mysql_executor: E,
) -> AnyhowResult<()>
where
E: 'e + Executor<'c, Database = MySql>,
{
let _r = sqlx::query!(
r#"
UPDATE media_files
@@ -42,15 +48,18 @@ LIMIT 1
mod_user_token,
media_file_token,
)
.execute(mysql_pool)
.execute(mysql_executor)
.await?;
Ok(())
}
pub async fn undelete_media_file_as_user(
pub async fn undelete_media_file_as_user<'e, 'c: 'e, E>(
media_file_token: &MediaFileToken,
mysql_pool: &MySqlPool
) -> AnyhowResult<()> {
mysql_executor: E,
) -> AnyhowResult<()>
where
E: 'e + Executor<'c, Database = MySql>,
{
let _r = sqlx::query!(
r#"
UPDATE media_files
@@ -62,16 +71,19 @@ LIMIT 1
"#,
media_file_token,
)
.execute(mysql_pool)
.execute(mysql_executor)
.await?;
Ok(())
}
pub async fn undelete_media_file_as_mod(
pub async fn undelete_media_file_as_mod<'e, 'c: 'e, E>(
media_file_token: &MediaFileToken,
mod_user_token: &str,
mysql_pool: &MySqlPool
) -> AnyhowResult<()> {
mysql_executor: E,
) -> AnyhowResult<()>
where
E: 'e + Executor<'c, Database = MySql>,
{
let _r = sqlx::query!(
r#"
UPDATE media_files
@@ -85,7 +97,7 @@ LIMIT 1
mod_user_token,
media_file_token,
)
.execute(mysql_pool)
.execute(mysql_executor)
.await?;
Ok(())
}
@@ -0,0 +1,56 @@
use chrono::{DateTime, Utc};
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::visibility::Visibility;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::prompts::PromptToken;
/// 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 MediaFileListRow {
/// `media_files.id` — used as the pagination cursor.
pub media_file_id: u64,
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 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>,
}
@@ -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;
@@ -32,6 +32,7 @@ pub mod prompts;
pub mod public_event_feed;
pub mod staff_audit_logs;
pub mod stats;
pub mod tags;
pub mod trending_model_analytics;
pub mod tts;
pub mod twitch;
@@ -0,0 +1,65 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql, QueryBuilder, Row};
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::tags::TagToken;
/// One (media file, tag) pairing from the bulk lookup. The same tag can
/// appear under many media files.
#[derive(Debug, Clone)]
pub struct MediaFileTagPairRow {
pub media_file_token: MediaFileToken,
pub tag_token: TagToken,
pub tag_value: String,
pub tag_value_lowercase: String,
pub use_count: u32,
}
pub struct BulkListTagsForMediaFilesArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub media_file_tokens: &'e [MediaFileToken],
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// All (live) tags on each of the given media files, in one round-trip.
/// Media files with no tags simply produce no rows. Not scoped to any
/// user: per-file tags are publicly visible.
pub async fn bulk_list_tags_for_media_files<'e, 'c: 'e, E>(
args: BulkListTagsForMediaFilesArgs<'e, 'c, E>,
) -> Result<Vec<MediaFileTagPairRow>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
if args.media_file_tokens.is_empty() {
return Ok(Vec::new());
}
let mut builder = QueryBuilder::<MySql>::new(
"SELECT mft.media_file_token, t.token, t.tag_value, t.tag_value_lowercase, t.use_count \
FROM media_file_tags mft \
JOIN tags t ON t.token = mft.tag_token \
WHERE t.maybe_deleted_at IS NULL AND mft.media_file_token IN (",
);
let mut separated = builder.separated(", ");
for token in args.media_file_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| MediaFileTagPairRow {
media_file_token: MediaFileToken::new(row.get::<String, _>(0)),
tag_token: TagToken::new(row.get::<String, _>(1)),
tag_value: row.get::<String, _>(2),
tag_value_lowercase: row.get::<String, _>(3),
use_count: row.get::<u32, _>(4),
})
.collect())
}
@@ -0,0 +1,46 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql, QueryBuilder};
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::users::UserToken;
pub struct ClearMediaFileTagsArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub media_file_tokens: &'e [MediaFileToken],
pub user_token: &'e UserToken,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Hard-delete ALL of the user's tag links on the given media files.
/// Backs the clear endpoint, and the set endpoints when the requested
/// tag set is empty. Returns the number of links deleted. (Tags orphaned
/// by this are not deleted.)
pub async fn clear_media_file_tags<'e, 'c: 'e, E>(
args: ClearMediaFileTagsArgs<'e, 'c, E>,
) -> Result<u64, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
if args.media_file_tokens.is_empty() {
return Ok(0);
}
let mut builder = QueryBuilder::<MySql>::new(
"DELETE FROM media_file_tags WHERE user_token = ",
);
builder.push_bind(args.user_token.as_str());
builder.push(" AND media_file_token IN (");
let mut separated = builder.separated(", ");
for token in args.media_file_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,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,33 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use tokens::tokens::tags::TagToken;
pub struct DeleteMediaFileTagsForTagArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub tag_token: &'e TagToken,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Hard-delete every media-file link for a tag, across all media files.
/// Used when the tag itself is being deleted. Returns the number of
/// links deleted.
pub async fn delete_media_file_tags_for_tag<'e, 'c: 'e, E>(
args: DeleteMediaFileTagsForTagArgs<'e, 'c, E>,
) -> Result<u64, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let result = sqlx::query!(
r#"DELETE FROM media_file_tags WHERE tag_token = ?"#,
args.tag_token.as_str(),
)
.execute(args.mysql_executor)
.await?;
Ok(result.rows_affected())
}
@@ -0,0 +1,58 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql, QueryBuilder};
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::tags::TagToken;
use tokens::tokens::users::UserToken;
pub struct DeleteMediaFileTagsNotInSetArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub media_file_tokens: &'e [MediaFileToken],
pub keep_tag_tokens: &'e [TagToken],
pub user_token: &'e UserToken,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Hard-delete the user's tag links on the given media files, EXCEPT
/// links whose tag is in `keep_tag_tokens`. This is the "remove what
/// wasn't mentioned" half of the set-tags endpoints. Returns the number
/// of links deleted. (Tags orphaned by this are not deleted.)
///
/// `keep_tag_tokens` must be non-empty — to remove everything, use
/// `clear_media_file_tags` instead (an empty NOT IN list is invalid SQL).
pub async fn delete_media_file_tags_not_in_set<'e, 'c: 'e, E>(
args: DeleteMediaFileTagsNotInSetArgs<'e, 'c, E>,
) -> Result<u64, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
if args.media_file_tokens.is_empty() || args.keep_tag_tokens.is_empty() {
return Ok(0);
}
let mut builder = QueryBuilder::<MySql>::new(
"DELETE FROM media_file_tags WHERE user_token = ",
);
builder.push_bind(args.user_token.as_str());
builder.push(" AND media_file_token IN (");
let mut separated = builder.separated(", ");
for token in args.media_file_tokens {
separated.push_bind(token.as_str());
}
separated.push_unseparated(")");
builder.push(" AND tag_token NOT IN (");
let mut separated = builder.separated(", ");
for token in args.keep_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,51 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql, QueryBuilder, Row};
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::users::UserToken;
pub struct FilterOwnedMediaFileTokensArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub candidate_tokens: &'e [MediaFileToken],
pub owner_user_token: &'e UserToken,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Return only the input tokens that exist, aren't soft-deleted, and are
/// owned (created) by the given user. Tagging endpoints run their inputs
/// through this — for now only a media file's creator may tag it.
pub async fn filter_owned_media_file_tokens<'e, 'c: 'e, E>(
args: FilterOwnedMediaFileTokensArgs<'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 maybe_creator_user_token = ",
);
builder.push_bind(args.owner_user_token.as_str());
builder.push(
" AND user_deleted_at IS NULL AND mod_deleted_at IS NULL 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,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,56 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use tokens::tokens::tags::TagToken;
use tokens::tokens::users::UserToken;
use crate::queries::tags::tag_row::TagRow;
pub struct GetTagForOwnerArgs<'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>,
}
/// Fetch a single (live) tag by token, scoped to its creator. Returns
/// `Ok(None)` if the tag doesn't exist, is soft-deleted, or belongs to a
/// different user (don't leak existence of others' tags).
pub async fn get_tag_for_owner<'e, 'c: 'e, E>(
args: GetTagForOwnerArgs<'e, 'c, E>,
) -> Result<Option<TagRow>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let result = sqlx::query!(
r#"
SELECT
id as `id: u64`,
token as `token: TagToken`,
tag_value,
tag_value_lowercase,
use_count as `use_count: u32`
FROM tags
WHERE token = ?
AND creator_user_token = ?
AND maybe_deleted_at IS NULL
LIMIT 1
"#,
args.tag_token.as_str(),
args.creator_user_token.as_str(),
)
.fetch_optional(args.mysql_executor)
.await?;
Ok(result.map(|r| TagRow {
id: r.id,
token: r.token,
tag_value: r.tag_value,
tag_value_lowercase: r.tag_value_lowercase,
use_count: r.use_count,
}))
}
@@ -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())
}
@@ -0,0 +1,56 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql, QueryBuilder};
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::tags::TagToken;
use tokens::tokens::users::UserToken;
pub struct InsertMediaFileTagsArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub media_file_tokens: &'e [MediaFileToken],
pub tag_tokens: &'e [TagToken],
pub user_token: &'e UserToken,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Attach every tag to every media file (full cartesian product) in a
/// single multi-row statement. Uses `INSERT IGNORE` so already-attached
/// pairs are absorbed without error — idempotent, which is what makes
/// the add/set endpoints upsert-friendly. Returns the number of rows
/// actually inserted.
///
/// Callers must cap the input sizes; the row count is
/// `media_file_tokens.len() * tag_tokens.len()`.
pub async fn insert_media_file_tags<'e, 'c: 'e, E>(
args: InsertMediaFileTagsArgs<'e, 'c, E>,
) -> Result<u64, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
if args.media_file_tokens.is_empty() || args.tag_tokens.is_empty() {
return Ok(0);
}
let user_token_str = args.user_token.as_str();
let pairs = args.media_file_tokens.iter()
.flat_map(|media_file_token| {
args.tag_tokens.iter()
.map(move |tag_token| (media_file_token, tag_token))
});
let mut builder = QueryBuilder::<MySql>::new(
"INSERT IGNORE INTO media_file_tags (media_file_token, tag_token, user_token) ",
);
builder.push_values(pairs, |mut b, (media_file_token, tag_token)| {
b.push_bind(media_file_token.as_str())
.push_bind(tag_token.as_str())
.push_bind(user_token_str);
});
let result = builder.build().execute(args.mysql_executor).await?;
Ok(result.rows_affected())
}
@@ -0,0 +1,49 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql, QueryBuilder, Row};
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::tags::TagToken;
use tokens::tokens::users::UserToken;
pub struct ListLinkedTagTokensForMediaFilesArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub media_file_tokens: &'e [MediaFileToken],
pub user_token: &'e UserToken,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// The distinct tag tokens the user currently has linked to the given
/// media files. Called before a destructive set/clear so we know which
/// tags need their `use_count` recounted afterwards.
pub async fn list_linked_tag_tokens_for_media_files<'e, 'c: 'e, E>(
args: ListLinkedTagTokensForMediaFilesArgs<'e, 'c, E>,
) -> Result<Vec<TagToken>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
if args.media_file_tokens.is_empty() {
return Ok(Vec::new());
}
let mut builder = QueryBuilder::<MySql>::new(
"SELECT DISTINCT tag_token FROM media_file_tags WHERE user_token = ",
);
builder.push_bind(args.user_token.as_str());
builder.push(" AND media_file_token IN (");
let mut separated = builder.separated(", ");
for token in args.media_file_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| TagToken::new(row.get::<String, _>(0)))
.collect())
}
@@ -0,0 +1,105 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::visibility::Visibility;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::tags::TagToken;
use tokens::tokens::users::UserToken;
use crate::queries::media_files::list::media_file_list_row::MediaFileListRow;
pub struct ListMediaFilesWithTagForUserArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub tag_token: &'e TagToken,
pub owner_user_token: &'e UserToken,
pub maybe_cursor_id: Option<u64>,
pub limit: u32,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Paginated list of the user's media files carrying the given tag.
/// Excludes soft-deleted and intermediate system files. Newest first
/// (`media_files.id` descending; the id doubles as the cursor). The
/// join enters through `index_tag_token_and_user_token`, and the unique
/// key on `(media_file_token, tag_token, user_token)` guarantees at most
/// one link row per media file — no DISTINCT needed.
///
/// The caller is expected to have already verified the tag belongs to
/// 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<MediaFileListRow>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
// No-cursor requests use a sentinel above every possible id, which
// keeps this to a single static query. (`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;
sqlx::query_as!(
MediaFileListRow,
r#"
SELECT
mf.id as `media_file_id: u64`,
mf.token as `media_file_token: MediaFileToken`,
mf.media_class as `media_class: MediaFileClass`,
mf.media_type as `media_type: MediaFileType`,
mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,
mf.public_bucket_directory_hash,
mf.maybe_public_bucket_prefix,
mf.maybe_public_bucket_extension,
cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,
cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,
cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,
mf.creator_set_visibility as `creator_set_visibility: Visibility`,
mf.is_user_upload as `is_user_upload: bool`,
mf.maybe_title,
mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,
mf.maybe_origin_filename,
mf.maybe_duration_millis,
mf.maybe_frame_width,
mf.maybe_frame_height,
mf.created_at,
mf.updated_at
FROM media_file_tags mft
JOIN media_files mf
ON mf.token = mft.media_file_token
LEFT JOIN media_files cover
ON cover.token = mf.maybe_cover_image_media_file_token
WHERE mft.tag_token = ?
AND mft.user_token = ?
AND mf.maybe_creator_user_token = ?
AND mf.user_deleted_at IS NULL
AND mf.mod_deleted_at IS NULL
AND NOT mf.is_intermediate_system_file
AND mf.id < ?
ORDER BY mf.id DESC
LIMIT ?
"#,
args.tag_token.as_str(),
args.owner_user_token.as_str(),
args.owner_user_token.as_str(),
cursor_id,
limit,
)
.fetch_all(args.mysql_executor)
.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())
}
@@ -0,0 +1,96 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::visibility::Visibility;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
use crate::queries::media_files::list::media_file_list_row::MediaFileListRow;
pub struct ListTaggedMediaFilesForUserArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub owner_user_token: &'e UserToken,
pub maybe_cursor_id: Option<u64>,
pub limit: u32,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Paginated list of the user's media files that carry at least one tag.
/// Excludes soft-deleted and intermediate system files. Newest first
/// (`media_files.id` descending; the id doubles as the cursor). The
/// 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<MediaFileListRow>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
// No-cursor requests use a sentinel above every possible id, which
// keeps this to a single static query. (`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;
sqlx::query_as!(
MediaFileListRow,
r#"
SELECT
mf.id as `media_file_id: u64`,
mf.token as `media_file_token: MediaFileToken`,
mf.media_class as `media_class: MediaFileClass`,
mf.media_type as `media_type: MediaFileType`,
mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,
mf.public_bucket_directory_hash,
mf.maybe_public_bucket_prefix,
mf.maybe_public_bucket_extension,
cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,
cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,
cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,
mf.creator_set_visibility as `creator_set_visibility: Visibility`,
mf.is_user_upload as `is_user_upload: bool`,
mf.maybe_title,
mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,
mf.maybe_origin_filename,
mf.maybe_duration_millis,
mf.maybe_frame_width,
mf.maybe_frame_height,
mf.created_at,
mf.updated_at
FROM media_files mf
LEFT JOIN media_files cover
ON cover.token = mf.maybe_cover_image_media_file_token
WHERE mf.maybe_creator_user_token = ?
AND mf.user_deleted_at IS NULL
AND mf.mod_deleted_at IS NULL
AND NOT mf.is_intermediate_system_file
AND EXISTS (
SELECT 1 FROM media_file_tags mft
WHERE mft.media_file_token = mf.token
)
AND mf.id < ?
ORDER BY mf.id DESC
LIMIT ?
"#,
args.owner_user_token.as_str(),
cursor_id,
limit,
)
.fetch_all(args.mysql_executor)
.await
}
@@ -0,0 +1,57 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::tags::TagToken;
use crate::queries::tags::tag_row::TagRow;
pub struct ListTagsForMediaFileArgs<'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>,
}
/// All (live) tags on a media file, sorted by tag value. Not paginated —
/// tag counts per file are small. Not scoped to any user: per-file tags
/// are publicly visible.
pub async fn list_tags_for_media_file<'e, 'c: 'e, E>(
args: ListTagsForMediaFileArgs<'e, 'c, E>,
) -> Result<Vec<TagRow>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let rows = sqlx::query!(
r#"
SELECT
t.id as `id: u64`,
t.token as `token: TagToken`,
t.tag_value,
t.tag_value_lowercase,
t.use_count as `use_count: u32`
FROM media_file_tags mft
JOIN tags t
ON t.token = mft.tag_token
WHERE mft.media_file_token = ?
AND t.maybe_deleted_at IS NULL
ORDER BY t.tag_value_lowercase ASC
"#,
args.media_file_token.as_str(),
)
.fetch_all(args.mysql_executor)
.await?;
Ok(rows.into_iter()
.map(|r| TagRow {
id: r.id,
token: r.token,
tag_value: r.tag_value,
tag_value_lowercase: r.tag_value_lowercase,
use_count: r.use_count,
})
.collect())
}
@@ -0,0 +1,65 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use tokens::tokens::tags::TagToken;
use tokens::tokens::users::UserToken;
use crate::queries::tags::tag_row::TagRow;
pub struct ListTagsForUserArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub creator_user_token: &'e UserToken,
pub maybe_cursor_id: Option<u64>,
pub limit: u32,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Paginated list of the user's (live) tags, newest first
/// (`tags.id` descending; the id doubles as the cursor).
pub async fn list_tags_for_user<'e, 'c: 'e, E>(
args: ListTagsForUserArgs<'e, 'c, E>,
) -> Result<Vec<TagRow>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
// No-cursor requests use a sentinel of u64::MAX — every id matches,
// which keeps this to a single static query.
let cursor_id = args.maybe_cursor_id.unwrap_or(u64::MAX);
let limit = args.limit as i64;
let rows = sqlx::query!(
r#"
SELECT
id as `id: u64`,
token as `token: TagToken`,
tag_value,
tag_value_lowercase,
use_count as `use_count: u32`
FROM tags
WHERE creator_user_token = ?
AND maybe_deleted_at IS NULL
AND id < ?
ORDER BY id DESC
LIMIT ?
"#,
args.creator_user_token.as_str(),
cursor_id,
limit,
)
.fetch_all(args.mysql_executor)
.await?;
Ok(rows.into_iter()
.map(|r| TagRow {
id: r.id,
token: r.token,
tag_value: r.tag_value,
tag_value_lowercase: r.tag_value_lowercase,
use_count: r.use_count,
})
.collect())
}
@@ -0,0 +1,96 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::visibility::Visibility;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
use crate::queries::media_files::list::media_file_list_row::MediaFileListRow;
pub struct ListUntaggedMediaFilesForUserArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub owner_user_token: &'e UserToken,
pub maybe_cursor_id: Option<u64>,
pub limit: u32,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Paginated list of the user's media files that carry no tags at all.
/// Excludes soft-deleted and intermediate system files. Newest first
/// (`media_files.id` descending; the id doubles as the cursor). The
/// NOT EXISTS probe is a point lookup on `index_media_file_token`.
pub async fn list_untagged_media_files_for_user<'e, 'c: 'e, E>(
args: ListUntaggedMediaFilesForUserArgs<'e, 'c, E>,
) -> Result<Vec<MediaFileListRow>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
// No-cursor requests use a sentinel above every possible id, which
// keeps this to a single static query. (`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;
sqlx::query_as!(
MediaFileListRow,
r#"
SELECT
mf.id as `media_file_id: u64`,
mf.token as `media_file_token: MediaFileToken`,
mf.media_class as `media_class: MediaFileClass`,
mf.media_type as `media_type: MediaFileType`,
mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,
mf.public_bucket_directory_hash,
mf.maybe_public_bucket_prefix,
mf.maybe_public_bucket_extension,
cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,
cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,
cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,
mf.creator_set_visibility as `creator_set_visibility: Visibility`,
mf.is_user_upload as `is_user_upload: bool`,
mf.maybe_title,
mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,
mf.maybe_origin_filename,
mf.maybe_duration_millis,
mf.maybe_frame_width,
mf.maybe_frame_height,
mf.created_at,
mf.updated_at
FROM media_files mf
LEFT JOIN media_files cover
ON cover.token = mf.maybe_cover_image_media_file_token
WHERE mf.maybe_creator_user_token = ?
AND mf.user_deleted_at IS NULL
AND mf.mod_deleted_at IS NULL
AND NOT mf.is_intermediate_system_file
AND NOT EXISTS (
SELECT 1 FROM media_file_tags mft
WHERE mft.media_file_token = mf.token
)
AND mf.id < ?
ORDER BY mf.id DESC
LIMIT ?
"#,
args.owner_user_token.as_str(),
cursor_id,
limit,
)
.fetch_all(args.mysql_executor)
.await
}
@@ -0,0 +1,26 @@
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;
pub mod list_untagged_media_files_for_user;
pub mod recount_tag_use_counts;
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_row;
pub mod upsert_tags;
@@ -0,0 +1,56 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql, QueryBuilder};
use tokens::tokens::tags::TagToken;
pub struct RecountTagUseCountsArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub tag_tokens: &'e [TagToken],
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Recompute `tags.use_count` for the given tags from the actual
/// `media_file_tags` link counts. Call after any add/remove of links,
/// 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>
where
E: 'e + Executor<'c, Database = MySql>,
{
if args.tag_tokens.is_empty() {
return Ok(());
}
let mut builder = QueryBuilder::<MySql>::new(
"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 (",
);
let mut separated = builder.separated(", ");
for token in args.tag_tokens {
separated.push_bind(token.as_str());
}
separated.push_unseparated(")");
builder.build().execute(args.mysql_executor).await?;
Ok(())
}
@@ -0,0 +1,50 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use tokens::tokens::tags::TagToken;
use tokens::tokens::users::UserToken;
pub struct RenameTagArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub tag_token: &'e TagToken,
pub creator_user_token: &'e UserToken,
pub new_tag_value: &'e str,
pub new_tag_value_lowercase: &'e str,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Rename a tag, scoped to its creator. Handles both case-only changes
/// (same lowercase) and wholesale renames with the one UPDATE. If the
/// user already has a different tag with the same
/// `tag_value_lowercase`, the unique key rejects the rename — callers
/// should map that duplicate-key `sqlx::Error` to a 400. Returns the
/// number of rows updated (0 = not found / not owned / already deleted).
pub async fn rename_tag<'e, 'c: 'e, E>(
args: RenameTagArgs<'e, 'c, E>,
) -> Result<u64, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let result = sqlx::query!(
r#"
UPDATE tags
SET tag_value = ?,
tag_value_lowercase = ?
WHERE token = ?
AND creator_user_token = ?
AND maybe_deleted_at IS NULL
"#,
args.new_tag_value,
args.new_tag_value_lowercase,
args.tag_token.as_str(),
args.creator_user_token.as_str(),
)
.execute(args.mysql_executor)
.await?;
Ok(result.rows_affected())
}
@@ -0,0 +1,57 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql, QueryBuilder, Row};
use tokens::tokens::tags::TagToken;
use tokens::tokens::users::UserToken;
use crate::queries::tags::tag_row::TagRow;
pub struct SelectTagsByLowercaseValuesArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub tag_values_lowercase: &'e [String],
pub creator_user_token: &'e UserToken,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Look up a user's (live) tags by their lowercased values. Used after
/// `upsert_tags` to resolve the canonical tokens for the batch.
pub async fn select_tags_by_lowercase_values<'e, 'c: 'e, E>(
args: SelectTagsByLowercaseValuesArgs<'e, 'c, E>,
) -> Result<Vec<TagRow>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
if args.tag_values_lowercase.is_empty() {
return Ok(Vec::new());
}
let mut builder = QueryBuilder::<MySql>::new(
"SELECT id, token, tag_value, tag_value_lowercase, use_count \
FROM tags \
WHERE creator_user_token = ",
);
builder.push_bind(args.creator_user_token.as_str());
builder.push(" AND maybe_deleted_at IS NULL AND tag_value_lowercase IN (");
let mut separated = builder.separated(", ");
for value in args.tag_values_lowercase {
separated.push_bind(value.as_str());
}
separated.push_unseparated(")");
let rows = builder.build().fetch_all(args.mysql_executor).await?;
Ok(rows.into_iter()
.map(|row| TagRow {
id: row.get::<u64, _>(0),
token: TagToken::new(row.get::<String, _>(1)),
tag_value: row.get::<String, _>(2),
tag_value_lowercase: row.get::<String, _>(3),
use_count: row.get::<u32, _>(4),
})
.collect())
}
@@ -0,0 +1,57 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql, QueryBuilder, Row};
use tokens::tokens::tags::TagToken;
use tokens::tokens::users::UserToken;
use crate::queries::tags::tag_row::TagRow;
pub struct SelectTagsByTokensForOwnerArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub tag_tokens: &'e [TagToken],
pub creator_user_token: &'e UserToken,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Look up a user's (live) tags by token. Used to re-read fresh
/// `use_count` values for a response after a recount.
pub async fn select_tags_by_tokens_for_owner<'e, 'c: 'e, E>(
args: SelectTagsByTokensForOwnerArgs<'e, 'c, E>,
) -> Result<Vec<TagRow>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
if args.tag_tokens.is_empty() {
return Ok(Vec::new());
}
let mut builder = QueryBuilder::<MySql>::new(
"SELECT id, token, tag_value, tag_value_lowercase, use_count \
FROM tags \
WHERE creator_user_token = ",
);
builder.push_bind(args.creator_user_token.as_str());
builder.push(" AND maybe_deleted_at IS NULL AND token IN (");
let mut separated = builder.separated(", ");
for token in args.tag_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| TagRow {
id: row.get::<u64, _>(0),
token: TagToken::new(row.get::<String, _>(1)),
tag_value: row.get::<String, _>(2),
tag_value_lowercase: row.get::<String, _>(3),
use_count: row.get::<u32, _>(4),
})
.collect())
}
@@ -0,0 +1,45 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use tokens::tokens::tags::TagToken;
use tokens::tokens::users::UserToken;
pub struct SoftDeleteTagArgs<'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>,
}
/// Soft-delete a tag, scoped to its creator. The caller is expected to
/// have already hard-deleted the tag's media-file links
/// (`delete_media_file_tags_for_tag`), so `use_count` is zeroed here.
/// Returns the number of rows updated (0 = not found / not owned /
/// already deleted).
pub async fn soft_delete_tag<'e, 'c: 'e, E>(
args: SoftDeleteTagArgs<'e, 'c, E>,
) -> Result<u64, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let result = sqlx::query!(
r#"
UPDATE tags
SET maybe_deleted_at = NOW(),
use_count = 0
WHERE token = ?
AND creator_user_token = ?
AND maybe_deleted_at IS NULL
"#,
args.tag_token.as_str(),
args.creator_user_token.as_str(),
)
.execute(args.mysql_executor)
.await?;
Ok(result.rows_affected())
}
@@ -0,0 +1,20 @@
use tokens::tokens::tags::TagToken;
/// One (live) tag row, as returned by the tags list/select queries.
#[derive(Debug, Clone)]
pub struct TagRow {
/// `tags.id` — used as the pagination cursor for user tag lists.
pub id: u64,
pub token: TagToken,
/// The display value of the tag, as entered by its creator.
pub tag_value: String,
/// Lowercased form of `tag_value` — the tag's unique key within the
/// creator's account.
pub tag_value_lowercase: String,
/// Rollup statistic: how many media files currently carry this tag.
pub use_count: u32,
}
@@ -0,0 +1,62 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql, QueryBuilder};
use tokens::tokens::tags::TagToken;
use tokens::tokens::users::UserToken;
/// One sanitized tag to upsert. The caller is responsible for trimming,
/// lowercasing, and case-insensitive deduping.
#[derive(Debug, Clone)]
pub struct NewTagValue {
/// The display value, original casing preserved.
pub tag_value: String,
/// Lowercased `tag_value` — the unique key within the creator's account.
pub tag_value_lowercase: String,
}
pub struct UpsertTagsArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub new_tags: &'e [NewTagValue],
pub creator_user_token: &'e UserToken,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Upsert tags for a user. New tags are created with freshly generated
/// tokens; tags that already exist (matched on the
/// `(tag_value_lowercase, creator_user_token)` unique key) are absorbed,
/// keeping their existing token and display casing. Previously
/// soft-deleted tags are revived (`maybe_deleted_at` reset to NULL) in
/// the same statement.
///
/// Callers should follow up with `select_tags_by_lowercase_values` to
/// learn the canonical tokens.
pub async fn upsert_tags<'e, 'c: 'e, E>(
args: UpsertTagsArgs<'e, 'c, E>,
) -> Result<(), sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
if args.new_tags.is_empty() {
return Ok(());
}
let creator_user_token_str = args.creator_user_token.as_str();
let mut builder = QueryBuilder::<MySql>::new(
"INSERT INTO tags (token, tag_value, tag_value_lowercase, creator_user_token) ",
);
builder.push_values(args.new_tags, |mut b, new_tag| {
b.push_bind(TagToken::generate().0)
.push_bind(new_tag.tag_value.as_str())
.push_bind(new_tag.tag_value_lowercase.as_str())
.push_bind(creator_user_token_str);
});
builder.push(" ON DUPLICATE KEY UPDATE maybe_deleted_at = NULL");
builder.build().execute(args.mysql_executor).await?;
Ok(())
}
@@ -143,6 +143,24 @@ use artcraft_api_defs::moderation::user_daily_spends::user_daily_spends_list::*;
use artcraft_api_defs::moderation::top_spenders::list::*;
use artcraft_api_defs::moderation::user_spend_events::list::*;
use artcraft_api_defs::folders::subfolder::*;
use artcraft_api_defs::tags::add_media_file_tags::*;
use artcraft_api_defs::tags::bulk_add_tags::*;
use artcraft_api_defs::tags::bulk_list_media_file_tags::*;
use artcraft_api_defs::tags::bulk_set_tags::*;
use artcraft_api_defs::tags::clear_media_file_tags::*;
use artcraft_api_defs::tags::common::*;
use artcraft_api_defs::tags::delete_tag::*;
use artcraft_api_defs::tags::list_media_file_tags::*;
use artcraft_api_defs::tags::list_media_files_with_tag::*;
use artcraft_api_defs::tags::list_tagged_media_files::*;
use artcraft_api_defs::tags::list_tags::*;
use artcraft_api_defs::tags::list_untagged_media_files::*;
use artcraft_api_defs::tags::rename_tag::*;
use artcraft_api_defs::tags::set_media_file_tags::*;
use crate::http_server::endpoints::tags::list_media_files_with_tag_handler::*;
use crate::http_server::endpoints::tags::list_tagged_media_files_handler::*;
use crate::http_server::endpoints::tags::list_untagged_media_files_handler::*;
use crate::http_server::endpoints::tags::tag_media_file_list_item::*;
use artcraft_api_defs::moderation::user_referrals::list_global_user_referrals::*;
use artcraft_api_defs::moderation::user_referrals::list_user_referrals_for_user::*;
use artcraft_api_defs::moderation::user_stripe_data::moderator_get_user_stripe_customer_ids::*;
@@ -663,6 +681,22 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
crate::http_server::endpoints::folders::media_files::bulk_add_folder_media_files_handler::bulk_add_folder_media_files_handler,
crate::http_server::endpoints::folders::media_files::bulk_move_folder_media_files_handler::bulk_move_folder_media_files_handler,
crate::http_server::endpoints::folders::media_files::bulk_remove_folder_media_files_handler::bulk_remove_folder_media_files_handler,
// Tags
crate::http_server::endpoints::tags::list_tags_handler::list_tags_handler,
crate::http_server::endpoints::tags::list_media_file_tags_handler::list_media_file_tags_handler,
crate::http_server::endpoints::tags::list_untagged_media_files_handler::list_untagged_media_files_handler,
crate::http_server::endpoints::tags::list_tagged_media_files_handler::list_tagged_media_files_handler,
crate::http_server::endpoints::tags::list_media_files_with_tag_handler::list_media_files_with_tag_handler,
crate::http_server::endpoints::tags::bulk_list_media_file_tags_handler::bulk_list_media_file_tags_handler,
crate::http_server::endpoints::tags::add_media_file_tags_handler::add_media_file_tags_handler,
crate::http_server::endpoints::tags::set_media_file_tags_handler::set_media_file_tags_handler,
crate::http_server::endpoints::tags::clear_media_file_tags_handler::clear_media_file_tags_handler,
crate::http_server::endpoints::tags::bulk_add_tags_handler::bulk_add_tags_handler,
crate::http_server::endpoints::tags::bulk_set_tags_handler::bulk_set_tags_handler,
crate::http_server::endpoints::tags::delete_tag_handler::delete_tag_handler,
crate::http_server::endpoints::tags::rename_tag_handler::rename_tag_handler,
crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_for_user_handler::moderator_list_user_session_impersonation_requests_for_user_handler,
crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_handler::moderator_list_user_session_impersonation_requests_handler,
crate::http_server::endpoints::moderation::user_sessions::moderator_user_session_impersonation_request_handler::moderator_user_session_impersonation_request_handler,
@@ -1448,6 +1482,41 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
BulkRemoveFolderMediaFilesSuccessResponse,
BulkMoveFolderMediaFilesRequest,
BulkMoveFolderMediaFilesSuccessResponse,
// Tags
AddMediaFileTagsPathInfo,
AddMediaFileTagsRequest,
AddMediaFileTagsSuccessResponse,
BulkAddTagsRequest,
BulkAddTagsSuccessResponse,
BulkListMediaFileTagsRequest,
BulkListMediaFileTagsSuccessResponse,
BulkSetTagsRequest,
BulkSetTagsSuccessResponse,
ClearMediaFileTagsPathInfo,
ClearMediaFileTagsSuccessResponse,
DeleteTagPathInfo,
DeleteTagSuccessResponse,
ListMediaFileTagsPathInfo,
ListMediaFileTagsSuccessResponse,
ListMediaFilesWithTagPathInfo,
ListMediaFilesWithTagQueryParams,
ListMediaFilesWithTagSuccessResponse,
ListTaggedMediaFilesQueryParams,
ListTaggedMediaFilesSuccessResponse,
ListTagsQueryParams,
ListTagsSuccessResponse,
ListUntaggedMediaFilesQueryParams,
ListUntaggedMediaFilesSuccessResponse,
MediaFileTagsEntry,
RenameTagPathInfo,
RenameTagRequest,
RenameTagSuccessResponse,
SetMediaFileTagsPathInfo,
SetMediaFileTagsRequest,
SetMediaFileTagsSuccessResponse,
TagDetails,
TagMediaFileListItem,
))
)]
pub struct ApiDoc;
@@ -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;
@@ -14,7 +14,13 @@ use artcraft_api_defs::media_file::delete_media_file::{DeleteMediaFilePathInfo,
use http_server_common::response::serialize_as_json_error::serialize_as_json_error;
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::media_files::get::get_media_file::get_media_file_with_transactor;
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 mysql_queries::utils::transactor::Transactor;
use sqlx::pool::PoolConnection;
use sqlx::MySql;
use std::marker::PhantomData;
use tokens::tokens::media_files::MediaFileToken;
use utoipa::ToSchema;
@@ -42,9 +48,16 @@ pub async fn delete_media_file_handler(
request: Json<DeleteMediaFileRequest>,
server_state: web::Data<Arc<ServerState>>
) -> Result<Json<SimpleGenericJsonSuccess>, CommonWebError> {
// One connection for the whole request — never hand helpers the raw
// pool, or each call self-acquires and adds pool pressure.
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
warn!("MySQL pool error: {:?}", err);
CommonWebError::from_error(err)
})?;
let maybe_user_session = server_state
.session_checker
.maybe_get_user_session(&http_request, &server_state.mysql_pool)
.maybe_get_user_session_from_connection(&http_request, &mut conn)
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
@@ -61,10 +74,10 @@ pub async fn delete_media_file_handler(
let is_mod = user_session.can_ban_users;
let media_file_lookup_result = get_media_file(
let media_file_lookup_result = get_media_file_with_transactor(
&path.token,
is_mod,
&server_state.mysql_pool,
Transactor::for_connection(&mut *conn),
).await;
let media_file = match media_file_lookup_result {
@@ -98,14 +111,14 @@ pub async fn delete_media_file_handler(
DeleteRole::AsUser => {
delete_media_file_as_user(
&path.token,
&server_state.mysql_pool
&mut *conn,
).await
}
DeleteRole::AsMod => {
delete_media_file_as_mod(
&path.token,
user_session.user_token.as_str(),
&server_state.mysql_pool
&mut *conn,
).await
}
}
@@ -119,14 +132,14 @@ pub async fn delete_media_file_handler(
// NB: Technically only mods can see their own media_files
undelete_media_file_as_user(
&path.token,
&server_state.mysql_pool
&mut *conn,
).await
}
DeleteRole::AsMod => {
undelete_media_file_as_mod(
&path.token,
user_session.user_token.as_str(),
&server_state.mysql_pool
&mut *conn,
).await
}
}
@@ -140,7 +153,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, &mut conn).await;
Ok(Json(SimpleGenericJsonSuccess{
success: true
}))
}
async fn recount_tags_for_media_file(
media_file_token: &MediaFileToken,
conn: &mut PoolConnection<MySql>,
) {
let linked_tag_tokens = match list_tag_tokens_for_media_file(ListTagTokensForMediaFileArgs {
media_file_token,
mysql_executor: &mut **conn,
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: &mut **conn,
phantom: PhantomData,
}).await {
warn!("recount_tag_use_counts failed after delete/undelete: {:?}", err);
}
}
@@ -23,6 +23,7 @@ pub mod prompts;
pub mod service;
pub mod stats;
pub mod subscriptions;
pub mod tags;
pub mod trending;
pub mod tts;
pub mod user_bookmarks;
@@ -0,0 +1,86 @@
use std::marker::PhantomData;
use std::slice;
use std::sync::Arc;
use actix_web::web::{Json, Path};
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::tags::add_media_file_tags::{
AddMediaFileTagsPathInfo, AddMediaFileTagsRequest, AddMediaFileTagsSuccessResponse,
};
use mysql_queries::queries::tags::filter_owned_media_file_tokens::{
filter_owned_media_file_tokens, FilterOwnedMediaFileTokensArgs,
};
use tokens::tokens::media_files::MediaFileToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::tags::apply_tags::apply_tags_to_media_files;
use crate::http_server::endpoints::tags::tag_details::tag_row_to_details;
use crate::http_server::endpoints::tags::tag_input::{parse_tag_input, require_non_empty_tags};
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
/// Add tags to a media file. Upsert semantics: tags that don't exist
/// are created (reviving soft-deleted ones), tags already on the file
/// are absorbed without error. Tags not mentioned are left alone. Only
/// the media file's creator may tag it (404 otherwise).
#[utoipa::path(
post,
tag = "Tags",
path = "/v1/tags/media_file/add/{media_file_token}",
params(("media_file_token" = MediaFileToken, description = "Media file token")),
request_body = AddMediaFileTagsRequest,
responses(
(status = 200, body = AddMediaFileTagsSuccessResponse),
(status = 400, body = CommonWebError),
(status = 401, body = CommonWebError),
(status = 404, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn add_media_file_tags_handler(
http_request: HttpRequest,
path: Path<AddMediaFileTagsPathInfo>,
request: Json<AddMediaFileTagsRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<AddMediaFileTagsSuccessResponse>, CommonWebError> {
let new_tags = parse_tag_input(
request.maybe_tags.as_deref(),
request.maybe_tags_list.as_deref(),
)?;
require_non_empty_tags(&new_tags)?;
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 owned = filter_owned_media_file_tokens(FilterOwnedMediaFileTokensArgs {
candidate_tokens: slice::from_ref(&path.media_file_token),
owner_user_token: &user_session.user_token,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("Media file ownership check failed: {:?}", err);
CommonWebError::from_error(err)
})?;
if owned.is_empty() {
return Err(CommonWebError::NotFound);
}
let outcome = apply_tags_to_media_files(
&mut conn,
&user_session.user_token,
&owned,
&new_tags,
/* remove_unmentioned= */ false,
).await?;
Ok(Json(AddMediaFileTagsSuccessResponse {
success: true,
tags: outcome.tags.into_iter().map(tag_row_to_details).collect(),
}))
}
@@ -0,0 +1,227 @@
use std::marker::PhantomData;
use log::{error, warn};
use sqlx::pool::PoolConnection;
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,
};
use mysql_queries::queries::tags::insert_media_file_tags::{
insert_media_file_tags, InsertMediaFileTagsArgs,
};
use mysql_queries::queries::tags::list_linked_tag_tokens_for_media_files::{
list_linked_tag_tokens_for_media_files, ListLinkedTagTokensForMediaFilesArgs,
};
use mysql_queries::queries::tags::recount_tag_use_counts::{
recount_tag_use_counts, RecountTagUseCountsArgs,
};
use mysql_queries::queries::tags::select_tags_by_lowercase_values::{
select_tags_by_lowercase_values, SelectTagsByLowercaseValuesArgs,
};
use mysql_queries::queries::tags::select_tags_by_tokens_for_owner::{
select_tags_by_tokens_for_owner, SelectTagsByTokensForOwnerArgs,
};
use mysql_queries::queries::tags::tag_row::TagRow;
use mysql_queries::queries::tags::upsert_tags::{upsert_tags, NewTagValue, UpsertTagsArgs};
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::tags::TagToken;
use tokens::tokens::users::UserToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
pub struct ApplyTagsOutcome {
/// The requested tags after upsert, with canonical tokens and fresh
/// use counts. Empty for pure clear operations.
pub tags: Vec<TagRow>,
/// How many existing tag links were removed (always 0 when
/// `remove_unmentioned` is false).
pub removed_count: u64,
}
/// The shared engine behind the add / set / clear / bulk_add / bulk_set
/// endpoints: upsert the requested tags (reviving soft-deleted ones),
/// attach them to every media file, optionally remove links that weren't
/// mentioned ("set" semantics), and recount `use_count` for every tag
/// whose link set changed — all in one transaction.
///
/// The caller must have already ownership-filtered `media_file_tokens`
/// and sanitized `new_tags`. On any failure the transaction is
/// explicitly rolled back before the original error is re-raised.
pub async fn apply_tags_to_media_files(
conn: &mut PoolConnection<MySql>,
user_token: &UserToken,
media_file_tokens: &[MediaFileToken],
new_tags: &[NewTagValue],
remove_unmentioned: bool,
) -> Result<ApplyTagsOutcome, CommonWebError> {
let mut tx = conn.begin().await.map_err(|err| {
warn!("Failed to begin apply-tags transaction: {:?}", err);
CommonWebError::from_error(err)
})?;
let work_result = perform_apply_work(
&mut tx,
user_token,
media_file_tokens,
new_tags,
remove_unmentioned,
).await;
match work_result {
Ok(outcome) => {
tx.commit().await.map_err(|err| {
warn!("Failed to commit apply-tags transaction: {:?}", err);
CommonWebError::from_error(err)
})?;
Ok(outcome)
}
Err(err) => {
if let Err(rollback_err) = tx.rollback().await {
error!(
"Rollback after apply-tags failure also failed: {:?} (original error: {:?})",
rollback_err, err,
);
}
Err(err)
}
}
}
async fn perform_apply_work(
tx: &mut Transaction<'_, MySql>,
user_token: &UserToken,
media_file_tokens: &[MediaFileToken],
new_tags: &[NewTagValue],
remove_unmentioned: bool,
) -> Result<ApplyTagsOutcome, CommonWebError> {
// Tags already linked to these files may lose links below, so their
// use counts need recounting too. Only "set" semantics remove links.
let previously_linked_tag_tokens = if remove_unmentioned {
list_linked_tag_tokens_for_media_files(ListLinkedTagTokensForMediaFilesArgs {
media_file_tokens,
user_token,
mysql_executor: &mut **tx,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("list_linked_tag_tokens_for_media_files failed: {:?}", err);
CommonWebError::from_error(err)
})?
} else {
Vec::new()
};
upsert_tags(UpsertTagsArgs {
new_tags,
creator_user_token: user_token,
mysql_executor: &mut **tx,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("upsert_tags failed: {:?}", err);
CommonWebError::from_error(err)
})?;
let lowercase_values: Vec<String> = new_tags.iter()
.map(|t| t.tag_value_lowercase.clone())
.collect();
let tag_rows = select_tags_by_lowercase_values(SelectTagsByLowercaseValuesArgs {
tag_values_lowercase: &lowercase_values,
creator_user_token: user_token,
mysql_executor: &mut **tx,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("select_tags_by_lowercase_values failed: {:?}", err);
CommonWebError::from_error(err)
})?;
let tag_tokens: Vec<TagToken> = tag_rows.iter().map(|row| row.token.clone()).collect();
let removed_count = if remove_unmentioned {
let removed = if tag_tokens.is_empty() {
clear_media_file_tags(ClearMediaFileTagsArgs {
media_file_tokens,
user_token,
mysql_executor: &mut **tx,
phantom: PhantomData,
}).await
} else {
delete_media_file_tags_not_in_set(DeleteMediaFileTagsNotInSetArgs {
media_file_tokens,
keep_tag_tokens: &tag_tokens,
user_token,
mysql_executor: &mut **tx,
phantom: PhantomData,
}).await
};
removed.map_err(|err| {
warn!("Removing unmentioned tag links failed: {:?}", err);
CommonWebError::from_error(err)
})?
} else {
0
};
insert_media_file_tags(InsertMediaFileTagsArgs {
media_file_tokens,
tag_tokens: &tag_tokens,
user_token,
mysql_executor: &mut **tx,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("insert_media_file_tags failed: {:?}", err);
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();
for token in previously_linked_tag_tokens {
if !recount_tag_tokens.contains(&token) {
recount_tag_tokens.push(token);
}
}
recount_tag_use_counts(RecountTagUseCountsArgs {
tag_tokens: &recount_tag_tokens,
mysql_executor: &mut **tx,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("recount_tag_use_counts failed: {:?}", err);
CommonWebError::from_error(err)
})?;
// Re-read the requested tags so the response carries fresh use counts.
let fresh_tag_rows = select_tags_by_tokens_for_owner(SelectTagsByTokensForOwnerArgs {
tag_tokens: &tag_tokens,
creator_user_token: user_token,
mysql_executor: &mut **tx,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("select_tags_by_tokens_for_owner failed: {:?}", err);
CommonWebError::from_error(err)
})?;
Ok(ApplyTagsOutcome {
tags: fresh_tag_rows,
removed_count,
})
}
@@ -0,0 +1,126 @@
use std::collections::HashSet;
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::tags::bulk_add_tags::{BulkAddTagsRequest, BulkAddTagsSuccessResponse};
use mysql_queries::queries::tags::filter_owned_media_file_tokens::{
filter_owned_media_file_tokens, FilterOwnedMediaFileTokensArgs,
};
use tokens::tokens::media_files::MediaFileToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::tags::apply_tags::apply_tags_to_media_files;
use crate::http_server::endpoints::tags::tag_details::tag_row_to_details;
use crate::http_server::endpoints::tags::tag_input::{parse_tag_input, require_non_empty_tags};
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
pub const MAX_BULK_MEDIA_FILES: usize = 500;
/// The link insert is one multi-row statement over the full
/// files × tags cartesian product; cap the product so a maxed-out
/// request can't build an absurd statement.
pub const MAX_LINK_PRODUCT: usize = 25_000;
/// Add the same tag set to many media files at once (pairwise: every
/// tag goes on every file). Upsert semantics throughout — existing
/// tags and existing links are absorbed. Tags not mentioned are left
/// alone. Input tokens the user doesn't own (or that are deleted) are
/// silently skipped; the response lists the accepted tokens.
#[utoipa::path(
post,
tag = "Tags",
path = "/v1/tags/bulk_add",
request_body = BulkAddTagsRequest,
responses(
(status = 200, body = BulkAddTagsSuccessResponse),
(status = 400, body = CommonWebError),
(status = 401, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn bulk_add_tags_handler(
http_request: HttpRequest,
request: Json<BulkAddTagsRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<BulkAddTagsSuccessResponse>, CommonWebError> {
let new_tags = parse_tag_input(
request.maybe_tags.as_deref(),
request.maybe_tags_list.as_deref(),
)?;
require_non_empty_tags(&new_tags)?;
let media_file_tokens = dedupe_and_cap_media_file_tokens(&request.media_file_tokens)?;
if media_file_tokens.len() * new_tags.len() > MAX_LINK_PRODUCT {
return Err(CommonWebError::BadInputWithSimpleMessage(
format!("media_files × tags is too large (max {} pairs)", MAX_LINK_PRODUCT),
));
}
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 accepted = filter_owned_media_file_tokens(FilterOwnedMediaFileTokensArgs {
candidate_tokens: &media_file_tokens,
owner_user_token: &user_session.user_token,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("Media file ownership check failed: {:?}", err);
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,
&accepted,
&new_tags,
/* remove_unmentioned= */ false,
).await?;
Ok(Json(BulkAddTagsSuccessResponse {
success: true,
accepted_media_file_tokens: accepted,
tags: outcome.tags.into_iter().map(tag_row_to_details).collect(),
}))
}
/// 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 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());
}
}
Ok(deduped)
}
@@ -0,0 +1,122 @@
use std::collections::{HashMap, HashSet};
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::tags::bulk_list_media_file_tags::{
BulkListMediaFileTagsRequest, BulkListMediaFileTagsSuccessResponse, MediaFileTagsEntry,
};
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;
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
const MAX_BULK: usize = 500;
/// All tags on each of the supplied media files, in one round-trip.
/// 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",
path = "/v1/tags/media_files/bulk_list_tags",
request_body = BulkListMediaFileTagsRequest,
responses(
(status = 200, body = BulkListMediaFileTagsSuccessResponse),
(status = 400, body = CommonWebError),
(status = 401, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn bulk_list_media_file_tags_handler(
http_request: HttpRequest,
request: Json<BulkListMediaFileTagsRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<BulkListMediaFileTagsSuccessResponse>, CommonWebError> {
// 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),
));
}
// 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 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: &visible_tokens,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("bulk_list_tags_for_media_files failed: {:?}", err);
CommonWebError::from_error(err)
})?;
let mut tags_by_media_file: HashMap<MediaFileToken, Vec<TagDetails>> = HashMap::new();
for pair in pair_rows {
tags_by_media_file
.entry(pair.media_file_token)
.or_default()
.push(TagDetails {
tag_token: pair.tag_token,
tag_value: pair.tag_value,
tag_value_lowercase: pair.tag_value_lowercase,
use_count: pair.use_count,
});
}
// One entry per requested token, in request order, empty list when
// 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();
tags.sort_by(|a, b| a.tag_value_lowercase.cmp(&b.tag_value_lowercase));
MediaFileTagsEntry { media_file_token, tags }
})
.collect();
Ok(Json(BulkListMediaFileTagsSuccessResponse {
success: true,
media_files,
}))
}
@@ -0,0 +1,102 @@
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::tags::bulk_set_tags::{BulkSetTagsRequest, BulkSetTagsSuccessResponse};
use mysql_queries::queries::tags::filter_owned_media_file_tokens::{
filter_owned_media_file_tokens, FilterOwnedMediaFileTokensArgs,
};
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::tags::apply_tags::apply_tags_to_media_files;
use crate::http_server::endpoints::tags::bulk_add_tags_handler::{
dedupe_and_cap_media_file_tokens, MAX_LINK_PRODUCT,
};
use crate::http_server::endpoints::tags::tag_details::tag_row_to_details;
use crate::http_server::endpoints::tags::tag_input::parse_tag_input;
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
/// Replace the tag set on many media files at once (pairwise: every
/// file ends up with exactly the mentioned tags). Mentioned tags are
/// upserted and attached like `bulk_add`; previously-attached tags that
/// weren't mentioned are unlinked (orphaned tags are not deleted). An
/// empty tag set clears every listed file. Input tokens the user
/// doesn't own (or that are deleted) are silently skipped; the
/// response lists the accepted tokens.
#[utoipa::path(
post,
tag = "Tags",
path = "/v1/tags/bulk_set",
request_body = BulkSetTagsRequest,
responses(
(status = 200, body = BulkSetTagsSuccessResponse),
(status = 400, body = CommonWebError),
(status = 401, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn bulk_set_tags_handler(
http_request: HttpRequest,
request: Json<BulkSetTagsRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<BulkSetTagsSuccessResponse>, CommonWebError> {
// Empty (after trimming) is allowed here: "set to nothing" clears.
let new_tags = parse_tag_input(
request.maybe_tags.as_deref(),
request.maybe_tags_list.as_deref(),
)?;
let media_file_tokens = dedupe_and_cap_media_file_tokens(&request.media_file_tokens)?;
if media_file_tokens.len() * new_tags.len() > MAX_LINK_PRODUCT {
return Err(CommonWebError::BadInputWithSimpleMessage(
format!("media_files × tags is too large (max {} pairs)", MAX_LINK_PRODUCT),
));
}
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 accepted = filter_owned_media_file_tokens(FilterOwnedMediaFileTokensArgs {
candidate_tokens: &media_file_tokens,
owner_user_token: &user_session.user_token,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("Media file ownership check failed: {:?}", err);
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,
&accepted,
&new_tags,
/* remove_unmentioned= */ true,
).await?;
Ok(Json(BulkSetTagsSuccessResponse {
success: true,
accepted_media_file_tokens: accepted,
tags: outcome.tags.into_iter().map(tag_row_to_details).collect(),
removed_count: outcome.removed_count,
}))
}
@@ -0,0 +1,77 @@
use std::marker::PhantomData;
use std::slice;
use std::sync::Arc;
use actix_web::web::{Json, Path};
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::tags::clear_media_file_tags::{
ClearMediaFileTagsPathInfo, ClearMediaFileTagsSuccessResponse,
};
use mysql_queries::queries::tags::filter_owned_media_file_tokens::{
filter_owned_media_file_tokens, FilterOwnedMediaFileTokensArgs,
};
use tokens::tokens::media_files::MediaFileToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::tags::apply_tags::apply_tags_to_media_files;
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
/// Remove ALL tags from a media file (hard delete of the links — the
/// orphaned tags themselves are not deleted, and their use counts are
/// recounted). Only the media file's creator may do this (404
/// otherwise).
#[utoipa::path(
post,
tag = "Tags",
path = "/v1/tags/media_file/clear/{media_file_token}",
params(("media_file_token" = MediaFileToken, description = "Media file token")),
responses(
(status = 200, body = ClearMediaFileTagsSuccessResponse),
(status = 400, body = CommonWebError),
(status = 401, body = CommonWebError),
(status = 404, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn clear_media_file_tags_handler(
http_request: HttpRequest,
path: Path<ClearMediaFileTagsPathInfo>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ClearMediaFileTagsSuccessResponse>, CommonWebError> {
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
warn!("MySQL pool error: {:?}", err);
CommonWebError::from_error(err)
})?;
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
let owned = filter_owned_media_file_tokens(FilterOwnedMediaFileTokensArgs {
candidate_tokens: slice::from_ref(&path.media_file_token),
owner_user_token: &user_session.user_token,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("Media file ownership check failed: {:?}", err);
CommonWebError::from_error(err)
})?;
if owned.is_empty() {
return Err(CommonWebError::NotFound);
}
// Clearing is just "set to the empty tag set".
let outcome = apply_tags_to_media_files(
&mut conn,
&user_session.user_token,
&owned,
/* new_tags= */ &[],
/* remove_unmentioned= */ true,
).await?;
Ok(Json(ClearMediaFileTagsSuccessResponse {
success: true,
removed_count: outcome.removed_count,
}))
}
@@ -0,0 +1,140 @@
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::web::{Json, Path};
use actix_web::{web, HttpRequest};
use log::{error, warn};
use sqlx::pool::PoolConnection;
use sqlx::{Acquire, MySql, Transaction};
use artcraft_api_defs::tags::delete_tag::{DeleteTagPathInfo, DeleteTagSuccessResponse};
use mysql_queries::queries::tags::delete_media_file_tags_for_tag::{
delete_media_file_tags_for_tag, DeleteMediaFileTagsForTagArgs,
};
use mysql_queries::queries::tags::get_tag_for_owner::{get_tag_for_owner, GetTagForOwnerArgs};
use mysql_queries::queries::tags::soft_delete_tag::{soft_delete_tag, SoftDeleteTagArgs};
use tokens::tokens::tags::TagToken;
use tokens::tokens::users::UserToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
/// Delete a tag: hard-deletes every media-file link, then soft-deletes
/// the tag record itself (its token can be revived later by re-adding
/// the same tag text). Only the tag's creator may do this (404
/// otherwise).
#[utoipa::path(
delete,
tag = "Tags",
path = "/v1/tags/{tag_token}",
params(("tag_token" = TagToken, description = "Tag token")),
responses(
(status = 200, body = DeleteTagSuccessResponse),
(status = 401, body = CommonWebError),
(status = 404, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn delete_tag_handler(
http_request: HttpRequest,
path: Path<DeleteTagPathInfo>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<DeleteTagSuccessResponse>, CommonWebError> {
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
warn!("MySQL pool error: {:?}", err);
CommonWebError::from_error(err)
})?;
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
let tag = get_tag_for_owner(GetTagForOwnerArgs {
tag_token: &path.tag_token,
creator_user_token: &user_session.user_token,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("Tag lookup failed: {:?}", err);
CommonWebError::from_error(err)
})?;
if tag.is_none() {
return Err(CommonWebError::NotFound);
}
let removed_link_count = perform_atomic_delete(
&mut conn,
&path.tag_token,
&user_session.user_token,
).await?;
Ok(Json(DeleteTagSuccessResponse {
success: true,
removed_link_count,
}))
}
/// Run the link hard-delete and the tag soft-delete as one unit.
async fn perform_atomic_delete(
conn: &mut PoolConnection<MySql>,
tag_token: &TagToken,
creator_user_token: &UserToken,
) -> Result<u64, CommonWebError> {
let mut tx = conn.begin().await.map_err(|err| {
warn!("Failed to begin delete-tag transaction: {:?}", err);
CommonWebError::from_error(err)
})?;
let work_result = perform_delete_work(&mut tx, tag_token, creator_user_token).await;
match work_result {
Ok(removed_link_count) => {
tx.commit().await.map_err(|err| {
warn!("Failed to commit delete-tag transaction: {:?}", err);
CommonWebError::from_error(err)
})?;
Ok(removed_link_count)
}
Err(err) => {
if let Err(rollback_err) = tx.rollback().await {
error!(
"Rollback after delete-tag failure also failed: {:?} (original error: {:?})",
rollback_err, err,
);
}
Err(err)
}
}
}
async fn perform_delete_work(
tx: &mut Transaction<'_, MySql>,
tag_token: &TagToken,
creator_user_token: &UserToken,
) -> Result<u64, CommonWebError> {
let removed_link_count = delete_media_file_tags_for_tag(DeleteMediaFileTagsForTagArgs {
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)
})?;
let updated = soft_delete_tag(SoftDeleteTagArgs {
tag_token,
creator_user_token,
mysql_executor: &mut **tx,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("soft_delete_tag failed: {:?}", err);
CommonWebError::from_error(err)
})?;
// The pre-check passed but the tag vanished mid-flight (concurrent
// delete). The rollback keeps the links intact.
if updated == 0 {
return Err(CommonWebError::NotFound);
}
Ok(removed_link_count)
}
@@ -0,0 +1,95 @@
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::web::{Json, Path};
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,
};
use tokens::tokens::media_files::MediaFileToken;
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. 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",
path = "/v1/tags/media_file/list/{media_file_token}",
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> {
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
warn!("MySQL pool error: {:?}", err);
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,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("list_tags_for_media_file failed: {:?}", err);
CommonWebError::from_error(err)
})?;
Ok(Json(ListMediaFileTagsSuccessResponse {
success: true,
tags: rows.into_iter().map(tag_row_to_details).collect(),
}))
}
@@ -0,0 +1,129 @@
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::web::{Json, Path, Query};
use actix_web::{web, HttpRequest};
use log::warn;
use serde_derive::Serialize;
use utoipa::ToSchema;
use artcraft_api_defs::tags::list_media_files_with_tag::{
ListMediaFilesWithTagPathInfo, ListMediaFilesWithTagQueryParams,
};
use mysql_queries::queries::tags::get_tag_for_owner::{get_tag_for_owner, GetTagForOwnerArgs};
use mysql_queries::queries::tags::list_media_files_with_tag_for_user::{
list_media_files_with_tag_for_user, ListMediaFilesWithTagForUserArgs,
};
use tokens::tokens::tags::TagToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::http_server::endpoints::tags::tag_media_file_list_item::{
tag_media_file_row_to_list_item, TagMediaFileListItem,
};
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
const CURSOR_NAME: &str = "tags_with_tag_mf";
const DEFAULT_LIMIT: u32 = 100;
const MAX_LIMIT: u32 = 1000;
#[derive(Serialize, ToSchema)]
pub struct ListMediaFilesWithTagSuccessResponse {
pub success: bool,
pub media_files: Vec<TagMediaFileListItem>,
pub maybe_cursor: Option<String>,
}
/// Paginated list of the logged-in user's media files carrying the
/// given tag. The tag must belong to the user (404 otherwise).
/// Newest first.
#[utoipa::path(
get,
tag = "Tags",
path = "/v1/tags/media_files/with_tag/{tag_token}",
params(
("tag_token" = TagToken, description = "Tag token"),
ListMediaFilesWithTagQueryParams,
),
responses(
(status = 200, body = ListMediaFilesWithTagSuccessResponse),
(status = 400, body = CommonWebError),
(status = 401, body = CommonWebError),
(status = 404, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn list_media_files_with_tag_handler(
http_request: HttpRequest,
path: Path<ListMediaFilesWithTagPathInfo>,
query: Query<ListMediaFilesWithTagQueryParams>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ListMediaFilesWithTagSuccessResponse>, CommonWebError> {
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
warn!("MySQL pool error: {:?}", err);
CommonWebError::from_error(err)
})?;
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
let tag = get_tag_for_owner(GetTagForOwnerArgs {
tag_token: &path.tag_token,
creator_user_token: &user_session.user_token,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("Tag lookup failed: {:?}", err);
CommonWebError::from_error(err)
})?;
if tag.is_none() {
return Err(CommonWebError::NotFound);
}
let limit = query.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
let maybe_cursor_id = match &query.cursor {
None => None,
Some(cursor_str) => {
let decoded = server_state.opaque_cursors
.decode_cursor_expecting_name(CURSOR_NAME, cursor_str)
.map_err(|err| {
warn!("Failed to decode cursor: {:?}", err);
CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string())
})?;
decoded.last_id
}
};
let rows = list_media_files_with_tag_for_user(ListMediaFilesWithTagForUserArgs {
tag_token: &path.tag_token,
owner_user_token: &user_session.user_token,
maybe_cursor_id,
limit,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("list_media_files_with_tag_for_user failed: {:?}", err);
CommonWebError::from_error(err)
})?;
let maybe_cursor = rows.last().map(|last| {
server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.media_file_id)
}).transpose().map_err(|err| {
warn!("Failed to encode cursor: {:?}", err);
CommonWebError::server_error_with_message("Failed to encode cursor")
})?;
let media_domain = get_media_domain(&http_request);
let server_environment = server_state.server_environment;
let media_files = rows.into_iter()
.map(|row| tag_media_file_row_to_list_item(row, media_domain, server_environment))
.collect();
Ok(Json(ListMediaFilesWithTagSuccessResponse {
success: true,
media_files,
maybe_cursor,
}))
}
@@ -0,0 +1,105 @@
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::web::{Json, Query};
use actix_web::{web, HttpRequest};
use log::warn;
use serde_derive::Serialize;
use utoipa::ToSchema;
use artcraft_api_defs::tags::list_tagged_media_files::ListTaggedMediaFilesQueryParams;
use mysql_queries::queries::tags::list_tagged_media_files_for_user::{
list_tagged_media_files_for_user, ListTaggedMediaFilesForUserArgs,
};
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::http_server::endpoints::tags::tag_media_file_list_item::{
tag_media_file_row_to_list_item, TagMediaFileListItem,
};
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
const CURSOR_NAME: &str = "tags_tagged_mf";
const DEFAULT_LIMIT: u32 = 100;
const MAX_LIMIT: u32 = 1000;
#[derive(Serialize, ToSchema)]
pub struct ListTaggedMediaFilesSuccessResponse {
pub success: bool,
pub media_files: Vec<TagMediaFileListItem>,
pub maybe_cursor: Option<String>,
}
/// Paginated list of the logged-in user's media files that carry at
/// least one tag. Newest first.
#[utoipa::path(
get,
tag = "Tags",
path = "/v1/tags/media_files/list_tagged",
params(ListTaggedMediaFilesQueryParams),
responses(
(status = 200, body = ListTaggedMediaFilesSuccessResponse),
(status = 400, body = CommonWebError),
(status = 401, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn list_tagged_media_files_handler(
http_request: HttpRequest,
query: Query<ListTaggedMediaFilesQueryParams>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ListTaggedMediaFilesSuccessResponse>, CommonWebError> {
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
warn!("MySQL pool error: {:?}", err);
CommonWebError::from_error(err)
})?;
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
let limit = query.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
let maybe_cursor_id = match &query.cursor {
None => None,
Some(cursor_str) => {
let decoded = server_state.opaque_cursors
.decode_cursor_expecting_name(CURSOR_NAME, cursor_str)
.map_err(|err| {
warn!("Failed to decode cursor: {:?}", err);
CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string())
})?;
decoded.last_id
}
};
let rows = list_tagged_media_files_for_user(ListTaggedMediaFilesForUserArgs {
owner_user_token: &user_session.user_token,
maybe_cursor_id,
limit,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("list_tagged_media_files_for_user failed: {:?}", err);
CommonWebError::from_error(err)
})?;
let maybe_cursor = rows.last().map(|last| {
server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.media_file_id)
}).transpose().map_err(|err| {
warn!("Failed to encode cursor: {:?}", err);
CommonWebError::server_error_with_message("Failed to encode cursor")
})?;
let media_domain = get_media_domain(&http_request);
let server_environment = server_state.server_environment;
let media_files = rows.into_iter()
.map(|row| tag_media_file_row_to_list_item(row, media_domain, server_environment))
.collect();
Ok(Json(ListTaggedMediaFilesSuccessResponse {
success: true,
media_files,
maybe_cursor,
}))
}
@@ -0,0 +1,83 @@
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::web::{Json, Query};
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::tags::list_tags::{ListTagsQueryParams, ListTagsSuccessResponse};
use mysql_queries::queries::tags::list_tags_for_user::{list_tags_for_user, ListTagsForUserArgs};
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::tags::tag_details::tag_row_to_details;
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
const CURSOR_NAME: &str = "tags_list";
const DEFAULT_LIMIT: u32 = 100;
const MAX_LIMIT: u32 = 1000;
/// Paginated list of the logged-in user's tags, newest first.
#[utoipa::path(
get,
tag = "Tags",
path = "/v1/tags/list",
params(ListTagsQueryParams),
responses(
(status = 200, body = ListTagsSuccessResponse),
(status = 400, body = CommonWebError),
(status = 401, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn list_tags_handler(
http_request: HttpRequest,
query: Query<ListTagsQueryParams>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ListTagsSuccessResponse>, CommonWebError> {
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
warn!("MySQL pool error: {:?}", err);
CommonWebError::from_error(err)
})?;
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
let limit = query.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
let maybe_cursor_id = match &query.cursor {
None => None,
Some(cursor_str) => {
let decoded = server_state.opaque_cursors
.decode_cursor_expecting_name(CURSOR_NAME, cursor_str)
.map_err(|err| {
warn!("Failed to decode cursor: {:?}", err);
CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string())
})?;
decoded.last_id
}
};
let rows = list_tags_for_user(ListTagsForUserArgs {
creator_user_token: &user_session.user_token,
maybe_cursor_id,
limit,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("list_tags_for_user failed: {:?}", err);
CommonWebError::from_error(err)
})?;
let maybe_cursor = rows.last().map(|last| {
server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.id)
}).transpose().map_err(|err| {
warn!("Failed to encode cursor: {:?}", err);
CommonWebError::server_error_with_message("Failed to encode cursor")
})?;
Ok(Json(ListTagsSuccessResponse {
success: true,
tags: rows.into_iter().map(tag_row_to_details).collect(),
maybe_cursor,
}))
}
@@ -0,0 +1,105 @@
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::web::{Json, Query};
use actix_web::{web, HttpRequest};
use log::warn;
use serde_derive::Serialize;
use utoipa::ToSchema;
use artcraft_api_defs::tags::list_untagged_media_files::ListUntaggedMediaFilesQueryParams;
use mysql_queries::queries::tags::list_untagged_media_files_for_user::{
list_untagged_media_files_for_user, ListUntaggedMediaFilesForUserArgs,
};
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::http_server::endpoints::tags::tag_media_file_list_item::{
tag_media_file_row_to_list_item, TagMediaFileListItem,
};
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
const CURSOR_NAME: &str = "tags_untagged_mf";
const DEFAULT_LIMIT: u32 = 100;
const MAX_LIMIT: u32 = 1000;
#[derive(Serialize, ToSchema)]
pub struct ListUntaggedMediaFilesSuccessResponse {
pub success: bool,
pub media_files: Vec<TagMediaFileListItem>,
pub maybe_cursor: Option<String>,
}
/// Paginated list of the logged-in user's media files that carry no
/// tags at all. Newest first.
#[utoipa::path(
get,
tag = "Tags",
path = "/v1/tags/media_files/list_untagged",
params(ListUntaggedMediaFilesQueryParams),
responses(
(status = 200, body = ListUntaggedMediaFilesSuccessResponse),
(status = 400, body = CommonWebError),
(status = 401, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn list_untagged_media_files_handler(
http_request: HttpRequest,
query: Query<ListUntaggedMediaFilesQueryParams>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ListUntaggedMediaFilesSuccessResponse>, CommonWebError> {
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
warn!("MySQL pool error: {:?}", err);
CommonWebError::from_error(err)
})?;
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
let limit = query.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
let maybe_cursor_id = match &query.cursor {
None => None,
Some(cursor_str) => {
let decoded = server_state.opaque_cursors
.decode_cursor_expecting_name(CURSOR_NAME, cursor_str)
.map_err(|err| {
warn!("Failed to decode cursor: {:?}", err);
CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string())
})?;
decoded.last_id
}
};
let rows = list_untagged_media_files_for_user(ListUntaggedMediaFilesForUserArgs {
owner_user_token: &user_session.user_token,
maybe_cursor_id,
limit,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("list_untagged_media_files_for_user failed: {:?}", err);
CommonWebError::from_error(err)
})?;
let maybe_cursor = rows.last().map(|last| {
server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.media_file_id)
}).transpose().map_err(|err| {
warn!("Failed to encode cursor: {:?}", err);
CommonWebError::server_error_with_message("Failed to encode cursor")
})?;
let media_domain = get_media_domain(&http_request);
let server_environment = server_state.server_environment;
let media_files = rows.into_iter()
.map(|row| tag_media_file_row_to_list_item(row, media_domain, server_environment))
.collect();
Ok(Json(ListUntaggedMediaFilesSuccessResponse {
success: true,
media_files,
maybe_cursor,
}))
}
@@ -0,0 +1,20 @@
pub mod add_media_file_tags_handler;
pub mod bulk_add_tags_handler;
pub mod bulk_list_media_file_tags_handler;
pub mod bulk_set_tags_handler;
pub mod clear_media_file_tags_handler;
pub mod delete_tag_handler;
pub mod list_media_file_tags_handler;
pub mod list_media_files_with_tag_handler;
pub mod list_tagged_media_files_handler;
pub mod list_tags_handler;
pub mod list_untagged_media_files_handler;
pub mod rename_tag_handler;
pub mod set_media_file_tags_handler;
// Public: `TagMediaFileListItem` is registered in `api_doc.rs`.
pub mod tag_media_file_list_item;
mod apply_tags;
mod tag_details;
mod tag_input;
@@ -0,0 +1,227 @@
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::web::{Json, Path};
use actix_web::{web, HttpRequest};
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;
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
/// Rename a tag. Handles both case-only changes (same lowercased value)
/// 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",
path = "/v1/tags/rename/{tag_token}",
params(("tag_token" = TagToken, description = "Tag token")),
request_body = RenameTagRequest,
responses(
(status = 200, body = RenameTagSuccessResponse),
(status = 400, body = CommonWebError),
(status = 401, body = CommonWebError),
(status = 404, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn rename_tag_handler(
http_request: HttpRequest,
path: Path<RenameTagPathInfo>,
request: Json<RenameTagRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<RenameTagSuccessResponse>, CommonWebError> {
let new_tag_value = request.new_tag_value.trim();
if new_tag_value.is_empty() {
return Err(CommonWebError::BadInputWithSimpleMessage(
"new_tag_value must not be empty".to_string(),
));
}
// 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 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 tag = get_tag_for_owner(GetTagForOwnerArgs {
tag_token: &path.tag_token,
creator_user_token: &user_session.user_token,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("Tag lookup failed: {:?}", err);
CommonWebError::from_error(err)
})?;
let Some(tag) = tag else {
return Err(CommonWebError::NotFound);
};
perform_atomic_rename(
&mut conn,
&path.tag_token,
&user_session.user_token,
new_tag_value,
&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| {
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(),
),
other => {
warn!("rename_tag failed: {:?}", other);
CommonWebError::server_error_with_message("Failed to rename tag")
}
}
})?;
// The pre-check passed but the tag vanished mid-flight.
if updated == 0 {
return Err(CommonWebError::NotFound);
}
Ok(())
}
@@ -0,0 +1,88 @@
use std::marker::PhantomData;
use std::slice;
use std::sync::Arc;
use actix_web::web::{Json, Path};
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::tags::set_media_file_tags::{
SetMediaFileTagsPathInfo, SetMediaFileTagsRequest, SetMediaFileTagsSuccessResponse,
};
use mysql_queries::queries::tags::filter_owned_media_file_tokens::{
filter_owned_media_file_tokens, FilterOwnedMediaFileTokensArgs,
};
use tokens::tokens::media_files::MediaFileToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::tags::apply_tags::apply_tags_to_media_files;
use crate::http_server::endpoints::tags::tag_details::tag_row_to_details;
use crate::http_server::endpoints::tags::tag_input::parse_tag_input;
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
use crate::state::server_state::ServerState;
/// Replace a media file's tag set. Mentioned tags are upserted and
/// attached exactly like `add`; previously-attached tags that weren't
/// mentioned are unlinked (hard delete of the link — orphaned tags are
/// not deleted). An empty tag set clears the file. Only the media
/// file's creator may do this (404 otherwise).
#[utoipa::path(
post,
tag = "Tags",
path = "/v1/tags/media_file/set/{media_file_token}",
params(("media_file_token" = MediaFileToken, description = "Media file token")),
request_body = SetMediaFileTagsRequest,
responses(
(status = 200, body = SetMediaFileTagsSuccessResponse),
(status = 400, body = CommonWebError),
(status = 401, body = CommonWebError),
(status = 404, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn set_media_file_tags_handler(
http_request: HttpRequest,
path: Path<SetMediaFileTagsPathInfo>,
request: Json<SetMediaFileTagsRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<SetMediaFileTagsSuccessResponse>, CommonWebError> {
// Empty (after trimming) is allowed here: "set to nothing" clears.
let new_tags = parse_tag_input(
request.maybe_tags.as_deref(),
request.maybe_tags_list.as_deref(),
)?;
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 owned = filter_owned_media_file_tokens(FilterOwnedMediaFileTokensArgs {
candidate_tokens: slice::from_ref(&path.media_file_token),
owner_user_token: &user_session.user_token,
mysql_executor: &mut *conn,
phantom: PhantomData,
}).await.map_err(|err| {
warn!("Media file ownership check failed: {:?}", err);
CommonWebError::from_error(err)
})?;
if owned.is_empty() {
return Err(CommonWebError::NotFound);
}
let outcome = apply_tags_to_media_files(
&mut conn,
&user_session.user_token,
&owned,
&new_tags,
/* remove_unmentioned= */ true,
).await?;
Ok(Json(SetMediaFileTagsSuccessResponse {
success: true,
tags: outcome.tags.into_iter().map(tag_row_to_details).collect(),
removed_count: outcome.removed_count,
}))
}
@@ -0,0 +1,11 @@
use artcraft_api_defs::tags::common::TagDetails;
use mysql_queries::queries::tags::tag_row::TagRow;
pub fn tag_row_to_details(row: TagRow) -> TagDetails {
TagDetails {
tag_token: row.token,
tag_value: row.tag_value,
tag_value_lowercase: row.tag_value_lowercase,
use_count: row.use_count,
}
}
@@ -0,0 +1,124 @@
use std::collections::HashSet;
use mysql_queries::queries::tags::upsert_tags::NewTagValue;
use crate::http_server::common_responses::common_web_error::CommonWebError;
/// Cap on distinct tags in a single request (after dedupe).
pub const MAX_TAGS_PER_REQUEST: usize = 100;
/// `tags.tag_value` is a VARCHAR(255).
pub const MAX_TAG_LENGTH_CHARS: usize = 255;
/// Parse the "either a comma-separated string or a list" tag input shape
/// shared by the add/set endpoints. Exactly one of the two fields must be
/// set (400 otherwise). Each entry is trimmed; empty entries are dropped;
/// duplicates are deduped case-insensitively (first casing wins).
///
/// An empty RESULT is not an error here — "set to nothing" is a valid
/// clear operation. Endpoints where zero tags makes no sense (the adds)
/// should follow up with [`require_non_empty_tags`].
pub fn parse_tag_input(
maybe_tags: Option<&str>,
maybe_tags_list: Option<&[String]>,
) -> Result<Vec<NewTagValue>, CommonWebError> {
let raw_values: Vec<&str> = match (maybe_tags, maybe_tags_list) {
(Some(_), Some(_)) => {
return Err(CommonWebError::BadInputWithSimpleMessage(
"supply either maybe_tags or maybe_tags_list, not both".to_string(),
));
}
(None, None) => {
return Err(CommonWebError::BadInputWithSimpleMessage(
"supply one of maybe_tags or maybe_tags_list".to_string(),
));
}
(Some(csv), None) => csv.split(',').collect(),
(None, Some(list)) => list.iter().map(|s| s.as_str()).collect(),
};
let mut seen_lowercase = HashSet::new();
let mut new_tags = Vec::new();
for raw_value in raw_values {
let trimmed = raw_value.trim();
if trimmed.is_empty() {
continue;
}
// 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),
));
}
if !seen_lowercase.insert(lowercase.clone()) {
continue;
}
new_tags.push(NewTagValue {
tag_value: trimmed.to_string(),
tag_value_lowercase: lowercase,
});
}
if new_tags.len() > MAX_TAGS_PER_REQUEST {
return Err(CommonWebError::BadInputWithSimpleMessage(
format!("too many tags in one request (max {})", MAX_TAGS_PER_REQUEST),
));
}
Ok(new_tags)
}
/// 400 if sanitization produced zero tags. For the add endpoints, where
/// "add nothing" is a client bug rather than a clear operation.
pub fn require_non_empty_tags(new_tags: &[NewTagValue]) -> Result<(), CommonWebError> {
if new_tags.is_empty() {
return Err(CommonWebError::BadInputWithSimpleMessage(
"no tags supplied after trimming".to_string(),
));
}
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);
}
}
@@ -0,0 +1,92 @@
use chrono::{DateTime, Utc};
use serde_derive::Serialize;
use utoipa::ToSchema;
use artcraft_api_defs::common::responses::media_links::MediaLinks;
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::media_files::list::media_file_list_row::MediaFileListRow;
use server_environment::ServerEnvironment;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
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_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
/// item. Intermediate system files are filtered out by the queries, so
/// the flag isn't part of the wire shape.
#[derive(Serialize, ToSchema)]
pub struct TagMediaFileListItem {
pub token: MediaFileToken,
/// Coarse-grained class (image / video / audio / dimensional).
pub media_class: MediaFileClass,
/// Specific format (jpg, png, mp4, etc.) — closer to a MIME type.
pub media_type: MediaFileType,
/// Link to the prompt
pub maybe_prompt_token: Option<PromptToken>,
/// If this file was generated as part of a batch, the batch token —
/// useful for showing "siblings" in the UI.
pub maybe_batch_token: Option<BatchGenerationToken>,
/// Rich CDN links to the media itself (full URL, thumbnail template,
/// video previews when applicable).
pub media_links: MediaLinks,
/// Cover image details. For files that don't carry their own cover
/// image, this still gives the deterministic default-cover spec
/// (image_index + color_index) the frontend uses for placeholders.
pub cover_image: MediaFileCoverImageDetails,
pub maybe_title: Option<String>,
pub maybe_original_filename: Option<String>,
/// Original pixel width / height for image and video files when known.
pub maybe_frame_width: Option<i32>,
pub maybe_frame_height: Option<i32>,
/// Duration for audio and video files, if available. Milliseconds.
pub maybe_duration_millis: Option<u64>,
pub creator_set_visibility: Visibility,
pub is_user_upload: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
pub fn tag_media_file_row_to_list_item(
row: MediaFileListRow,
media_domain: MediaDomain,
server_environment: ServerEnvironment,
) -> TagMediaFileListItem {
let (media_links, cover_image) = build_media_links_and_cover(&row, media_domain, server_environment);
TagMediaFileListItem {
token: row.media_file_token,
media_class: row.media_class,
media_type: row.media_type,
maybe_batch_token: row.maybe_batch_token,
media_links,
cover_image,
creator_set_visibility: row.creator_set_visibility,
is_user_upload: row.is_user_upload,
maybe_title: row.maybe_title,
maybe_prompt_token: row.maybe_prompt_token,
maybe_original_filename: row.maybe_origin_filename,
// Schema stores `INT(10)`; widen to u64 for the wire shape.
maybe_duration_millis: row.maybe_duration_millis.map(|n| n as u64),
maybe_frame_width: row.maybe_frame_width,
maybe_frame_height: row.maybe_frame_height,
created_at: row.created_at,
updated_at: row.updated_at,
}
}
@@ -16,6 +16,7 @@ use crate::http_server::routes::application_routes::omni_gen_routes::add_omni_ge
use crate::http_server::routes::application_routes::prompts_routes::add_prompts_routes;
use crate::http_server::routes::application_routes::stripe_artcraft_routes::add_stripe_artcraft_routes;
use crate::http_server::routes::application_routes::subscription_routes::add_subscription_routes;
use crate::http_server::routes::application_routes::tag_routes::add_tag_routes;
use crate::http_server::routes::application_routes::user_bookmarks_routes::add_user_bookmarks_routes;
use crate::http_server::routes::application_routes::user_rating_routes::add_user_rating_routes;
use crate::http_server::routes::application_routes::user_referral_code_routes::add_user_referral_code_routes;
@@ -59,6 +60,7 @@ where
// Media files routes
app = add_media_file_routes(app); // /v1/media_files/...
app = add_folders_routes(app); // /v1/folders/...
app = add_tag_routes(app); // /v1/tags/...
app = add_api_keys_routes(app); // /v1/api_keys/...
app = add_featured_item_routes(app); // /v1/featured_item/...
app = add_prompts_routes(app); // /v1/prompts/...
@@ -16,6 +16,7 @@ mod omni_gen_routes;
mod prompts_routes;
mod stripe_artcraft_routes;
mod subscription_routes;
mod tag_routes;
mod user_bookmarks_routes;
mod user_rating_routes;
mod user_referral_code_routes;
@@ -0,0 +1,110 @@
use actix_http::body::MessageBody;
use actix_service::ServiceFactory;
use actix_web::dev::{ServiceRequest, ServiceResponse};
use actix_web::{web, App, Error, HttpResponse};
use crate::http_server::endpoints::tags::add_media_file_tags_handler::add_media_file_tags_handler;
use crate::http_server::endpoints::tags::bulk_add_tags_handler::bulk_add_tags_handler;
use crate::http_server::endpoints::tags::bulk_list_media_file_tags_handler::bulk_list_media_file_tags_handler;
use crate::http_server::endpoints::tags::bulk_set_tags_handler::bulk_set_tags_handler;
use crate::http_server::endpoints::tags::clear_media_file_tags_handler::clear_media_file_tags_handler;
use crate::http_server::endpoints::tags::delete_tag_handler::delete_tag_handler;
use crate::http_server::endpoints::tags::list_media_file_tags_handler::list_media_file_tags_handler;
use crate::http_server::endpoints::tags::list_media_files_with_tag_handler::list_media_files_with_tag_handler;
use crate::http_server::endpoints::tags::list_tagged_media_files_handler::list_tagged_media_files_handler;
use crate::http_server::endpoints::tags::list_tags_handler::list_tags_handler;
use crate::http_server::endpoints::tags::list_untagged_media_files_handler::list_untagged_media_files_handler;
use crate::http_server::endpoints::tags::rename_tag_handler::rename_tag_handler;
use crate::http_server::endpoints::tags::set_media_file_tags_handler::set_media_file_tags_handler;
pub fn add_tag_routes<T, B>(app: App<T>) -> App<T>
where
B: MessageBody,
T: ServiceFactory<
ServiceRequest,
Config = (),
Response = ServiceResponse<B>,
Error = Error,
InitError = (),
>,
{
app.service(
web::scope("/v1/tags")
// The user's tags
.service(
web::resource("/list")
.route(web::get().to(list_tags_handler))
.route(web::head().to(|| HttpResponse::Ok())),
)
.service(
web::resource("/rename/{tag_token}")
.route(web::put().to(rename_tag_handler))
.route(web::head().to(|| HttpResponse::Ok())),
)
// Tagging many media files at once
.service(
web::resource("/bulk_add")
.route(web::post().to(bulk_add_tags_handler))
.route(web::head().to(|| HttpResponse::Ok())),
)
.service(
web::resource("/bulk_set")
.route(web::post().to(bulk_set_tags_handler))
.route(web::head().to(|| HttpResponse::Ok())),
)
// Tag operations on a single media file
.service(
web::scope("/media_file")
.service(
web::resource("/list/{media_file_token}")
.route(web::get().to(list_media_file_tags_handler))
.route(web::head().to(|| HttpResponse::Ok())),
)
.service(
web::resource("/add/{media_file_token}")
.route(web::post().to(add_media_file_tags_handler))
.route(web::head().to(|| HttpResponse::Ok())),
)
.service(
web::resource("/set/{media_file_token}")
.route(web::post().to(set_media_file_tags_handler))
.route(web::head().to(|| HttpResponse::Ok())),
)
.service(
web::resource("/clear/{media_file_token}")
.route(web::post().to(clear_media_file_tags_handler))
.route(web::head().to(|| HttpResponse::Ok())),
),
)
// Media-file listings by tag state
.service(
web::scope("/media_files")
.service(
web::resource("/list_untagged")
.route(web::get().to(list_untagged_media_files_handler))
.route(web::head().to(|| HttpResponse::Ok())),
)
.service(
web::resource("/list_tagged")
.route(web::get().to(list_tagged_media_files_handler))
.route(web::head().to(|| HttpResponse::Ok())),
)
.service(
web::resource("/with_tag/{tag_token}")
.route(web::get().to(list_media_files_with_tag_handler))
.route(web::head().to(|| HttpResponse::Ok())),
)
.service(
web::resource("/bulk_list_tags")
.route(web::post().to(bulk_list_media_file_tags_handler))
.route(web::head().to(|| HttpResponse::Ok())),
),
)
// NB: registered last so the literal segments above win the match.
.service(
web::resource("/{tag_token}")
.route(web::delete().to(delete_tag_handler))
.route(web::head().to(|| HttpResponse::Ok())),
),
)
}