mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
folderless endpoint (#1779)
This commit is contained in:
+245
@@ -0,0 +1,245 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM media_files mf\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE mf.maybe_creator_user_token = ?\n AND mf.media_class = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND NOT mf.is_intermediate_system_file\n AND NOT EXISTS (\n SELECT 1\n FROM folder_media_files fmf\n JOIN folders f\n ON f.token = fmf.folder_token\n AND f.maybe_deleted_at IS NULL\n WHERE fmf.media_file_token = mf.token\n )\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "media_file_id: u64",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "media_file_token: MediaFileToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "media_class: MediaFileClass",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "media_type: MediaFileType",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "maybe_batch_token: BatchGenerationToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "public_bucket_directory_hash",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "maybe_public_bucket_prefix",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "maybe_public_bucket_extension",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "maybe_cover_public_bucket_directory_hash?",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "maybe_cover_public_bucket_prefix?",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "maybe_cover_public_bucket_extension?",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "creator_set_visibility: Visibility",
|
||||
"type_info": {
|
||||
"type": "String",
|
||||
"flags": "NOT_NULL | BINARY | ENUM",
|
||||
"char_set": 224,
|
||||
"max_size": 28
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "is_user_upload: bool",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "maybe_title",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "maybe_prompt_token: PromptToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "maybe_origin_filename",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"name": "maybe_duration_millis",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "maybe_frame_width",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "",
|
||||
"char_set": 63,
|
||||
"max_size": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"name": "maybe_frame_height",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "",
|
||||
"char_set": 63,
|
||||
"max_size": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "created_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "updated_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP | ON_UPDATE_NOW",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 4
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "3681e931cc87714d71c95da44d6dd92568ce58aea252164ab3d5f8e96491169c"
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM media_files mf\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE mf.maybe_creator_user_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND NOT mf.is_intermediate_system_file\n AND NOT EXISTS (\n SELECT 1\n FROM folder_media_files fmf\n JOIN folders f\n ON f.token = fmf.folder_token\n AND f.maybe_deleted_at IS NULL\n WHERE fmf.media_file_token = mf.token\n )\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "media_file_id: u64",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "media_file_token: MediaFileToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "media_class: MediaFileClass",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "media_type: MediaFileType",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "maybe_batch_token: BatchGenerationToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "public_bucket_directory_hash",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "maybe_public_bucket_prefix",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "maybe_public_bucket_extension",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "maybe_cover_public_bucket_directory_hash?",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "maybe_cover_public_bucket_prefix?",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "maybe_cover_public_bucket_extension?",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "creator_set_visibility: Visibility",
|
||||
"type_info": {
|
||||
"type": "String",
|
||||
"flags": "NOT_NULL | BINARY | ENUM",
|
||||
"char_set": 224,
|
||||
"max_size": 28
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "is_user_upload: bool",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "maybe_title",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "maybe_prompt_token: PromptToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "maybe_origin_filename",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"name": "maybe_duration_millis",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "maybe_frame_width",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "",
|
||||
"char_set": 63,
|
||||
"max_size": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"name": "maybe_frame_height",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "",
|
||||
"char_set": 63,
|
||||
"max_size": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "created_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "updated_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP | ON_UPDATE_NOW",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "6f077c5d35b19319aba4e9f4c59610822334fd304ba9133ba8e099056c426f2a"
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use enums::by_table::media_files::media_file_class::MediaFileClass;
|
||||
use tokens::tokens::folders::FolderToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
@@ -22,6 +23,28 @@ pub struct ListFolderMediaFilesQueryParams {
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
// ── GET /v1/folders/media_files_without_folder ──
|
||||
//
|
||||
// NB: `ListMediaFilesWithoutFolderSuccessResponse` and its list item live in
|
||||
// `storyteller_web`'s handler because the wire shape embeds
|
||||
// `MediaFileCoverImageDetails` and `MediaLinks` constructors that depend
|
||||
// on the request's `MediaDomain` + `ServerEnvironment`.
|
||||
|
||||
#[derive(Deserialize, ToSchema, IntoParams)]
|
||||
pub struct ListMediaFilesWithoutFolderQueryParams {
|
||||
pub cursor: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
|
||||
/// Optional filter on the coarse media class.
|
||||
///
|
||||
/// Usage:
|
||||
/// - `?filter_media_class=image`
|
||||
/// - `?filter_media_class=video`
|
||||
/// - `?filter_media_class=mesh`
|
||||
/// - `?filter_media_class=splat`
|
||||
pub filter_media_class: Option<MediaFileClass>,
|
||||
}
|
||||
|
||||
// ── PUT /v1/folders/media_files/{folder_token}/bulk_add ──
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use enums::by_table::media_files::media_file_class::MediaFileClass;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use enums::common::visibility::Visibility;
|
||||
use tokens::tokens::batch_generations::BatchGenerationToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::queries::media_files::list::media_file_list_row::MediaFileListRow;
|
||||
|
||||
pub struct ListMediaFilesWithoutFolderArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub owner_user_token: &'e UserToken,
|
||||
|
||||
/// Optional filter on the coarse media class (image / video / mesh / ...).
|
||||
pub maybe_filter_media_class: Option<MediaFileClass>,
|
||||
|
||||
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 sit in no folder at all.
|
||||
/// Memberships whose folder was soft-deleted (but whose link row wasn't
|
||||
/// cleaned up) count as unfoldered. Excludes soft-deleted and intermediate
|
||||
/// system files. Newest first (`media_files.id` descending; the id doubles
|
||||
/// as the cursor).
|
||||
///
|
||||
/// The NOT EXISTS probe is a point lookup on
|
||||
/// `folder_media_files.index_media_file_token` plus a primary-key lookup
|
||||
/// on `folders` for the soft-delete check — cheap per candidate row.
|
||||
pub async fn list_media_files_without_folder<'e, 'c: 'e, E>(
|
||||
args: ListMediaFilesWithoutFolderArgs<'e, 'c, E>,
|
||||
) -> Result<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 per filter variant.
|
||||
// (`media_files.id` is a signed BIGINT, so the sentinel is i64::MAX.)
|
||||
let cursor_id = args.maybe_cursor_id.unwrap_or(i64::MAX as u64);
|
||||
let limit = args.limit as i64;
|
||||
|
||||
match args.maybe_filter_media_class {
|
||||
Some(media_class) => {
|
||||
select_page_with_class_filter(
|
||||
args.owner_user_token,
|
||||
media_class,
|
||||
cursor_id,
|
||||
limit,
|
||||
args.mysql_executor,
|
||||
).await
|
||||
}
|
||||
None => {
|
||||
select_page_all_classes(
|
||||
args.owner_user_token,
|
||||
cursor_id,
|
||||
limit,
|
||||
args.mysql_executor,
|
||||
).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NB: The two queries below are intentionally duplicated so that sqlx can
|
||||
// check them at compile time (no QueryBuilder). Keep the SELECT lists and
|
||||
// predicates in sync; they differ only in the `media_class = ?` filter.
|
||||
|
||||
async fn select_page_all_classes<'e, 'c: 'e, E>(
|
||||
owner_user_token: &'e UserToken,
|
||||
cursor_id: u64,
|
||||
limit: i64,
|
||||
mysql_executor: E,
|
||||
) -> Result<Vec<MediaFileListRow>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
sqlx::query_as!(
|
||||
MediaFileListRow,
|
||||
r#"
|
||||
SELECT
|
||||
mf.id as `media_file_id: u64`,
|
||||
|
||||
mf.token as `media_file_token: MediaFileToken`,
|
||||
mf.media_class as `media_class: MediaFileClass`,
|
||||
mf.media_type as `media_type: MediaFileType`,
|
||||
|
||||
mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,
|
||||
|
||||
mf.public_bucket_directory_hash,
|
||||
mf.maybe_public_bucket_prefix,
|
||||
mf.maybe_public_bucket_extension,
|
||||
|
||||
cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,
|
||||
cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,
|
||||
cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,
|
||||
|
||||
mf.creator_set_visibility as `creator_set_visibility: Visibility`,
|
||||
mf.is_user_upload as `is_user_upload: bool`,
|
||||
|
||||
mf.maybe_title,
|
||||
mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,
|
||||
mf.maybe_origin_filename,
|
||||
|
||||
mf.maybe_duration_millis,
|
||||
mf.maybe_frame_width,
|
||||
mf.maybe_frame_height,
|
||||
|
||||
mf.created_at,
|
||||
mf.updated_at
|
||||
FROM media_files mf
|
||||
LEFT JOIN media_files cover
|
||||
ON cover.token = mf.maybe_cover_image_media_file_token
|
||||
WHERE mf.maybe_creator_user_token = ?
|
||||
AND mf.user_deleted_at IS NULL
|
||||
AND mf.mod_deleted_at IS NULL
|
||||
AND NOT mf.is_intermediate_system_file
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM folder_media_files fmf
|
||||
JOIN folders f
|
||||
ON f.token = fmf.folder_token
|
||||
AND f.maybe_deleted_at IS NULL
|
||||
WHERE fmf.media_file_token = mf.token
|
||||
)
|
||||
AND mf.id < ?
|
||||
ORDER BY mf.id DESC
|
||||
LIMIT ?
|
||||
"#,
|
||||
owner_user_token.as_str(),
|
||||
cursor_id,
|
||||
limit,
|
||||
)
|
||||
.fetch_all(mysql_executor)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn select_page_with_class_filter<'e, 'c: 'e, E>(
|
||||
owner_user_token: &'e UserToken,
|
||||
media_class: MediaFileClass,
|
||||
cursor_id: u64,
|
||||
limit: i64,
|
||||
mysql_executor: E,
|
||||
) -> Result<Vec<MediaFileListRow>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
sqlx::query_as!(
|
||||
MediaFileListRow,
|
||||
r#"
|
||||
SELECT
|
||||
mf.id as `media_file_id: u64`,
|
||||
|
||||
mf.token as `media_file_token: MediaFileToken`,
|
||||
mf.media_class as `media_class: MediaFileClass`,
|
||||
mf.media_type as `media_type: MediaFileType`,
|
||||
|
||||
mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,
|
||||
|
||||
mf.public_bucket_directory_hash,
|
||||
mf.maybe_public_bucket_prefix,
|
||||
mf.maybe_public_bucket_extension,
|
||||
|
||||
cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,
|
||||
cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,
|
||||
cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,
|
||||
|
||||
mf.creator_set_visibility as `creator_set_visibility: Visibility`,
|
||||
mf.is_user_upload as `is_user_upload: bool`,
|
||||
|
||||
mf.maybe_title,
|
||||
mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,
|
||||
mf.maybe_origin_filename,
|
||||
|
||||
mf.maybe_duration_millis,
|
||||
mf.maybe_frame_width,
|
||||
mf.maybe_frame_height,
|
||||
|
||||
mf.created_at,
|
||||
mf.updated_at
|
||||
FROM media_files mf
|
||||
LEFT JOIN media_files cover
|
||||
ON cover.token = mf.maybe_cover_image_media_file_token
|
||||
WHERE mf.maybe_creator_user_token = ?
|
||||
AND mf.media_class = ?
|
||||
AND mf.user_deleted_at IS NULL
|
||||
AND mf.mod_deleted_at IS NULL
|
||||
AND NOT mf.is_intermediate_system_file
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM folder_media_files fmf
|
||||
JOIN folders f
|
||||
ON f.token = fmf.folder_token
|
||||
AND f.maybe_deleted_at IS NULL
|
||||
WHERE fmf.media_file_token = mf.token
|
||||
)
|
||||
AND mf.id < ?
|
||||
ORDER BY mf.id DESC
|
||||
LIMIT ?
|
||||
"#,
|
||||
owner_user_token.as_str(),
|
||||
media_class.to_str(),
|
||||
cursor_id,
|
||||
limit,
|
||||
)
|
||||
.fetch_all(mysql_executor)
|
||||
.await
|
||||
}
|
||||
@@ -2,4 +2,5 @@ pub mod bulk_delete_folder_media_files;
|
||||
pub mod bulk_insert_folder_media_files;
|
||||
pub mod filter_existing_media_file_tokens;
|
||||
pub mod list_folder_media_files;
|
||||
pub mod list_media_files_without_folder;
|
||||
pub mod recompute_folder_last_media_files;
|
||||
|
||||
@@ -130,6 +130,7 @@ use crate::http_server::endpoints::folders::media_files::bulk_add_folder_media_f
|
||||
use crate::http_server::endpoints::folders::media_files::bulk_move_folder_media_files_handler::*;
|
||||
use crate::http_server::endpoints::folders::media_files::bulk_remove_folder_media_files_handler::*;
|
||||
use crate::http_server::endpoints::folders::media_files::list_folder_media_files_handler::*;
|
||||
use crate::http_server::endpoints::folders::media_files::list_media_files_without_folder_handler::*;
|
||||
use crate::http_server::endpoints::folders::subfolder::bulk_add_subfolders_handler::*;
|
||||
use crate::http_server::endpoints::folders::subfolder::bulk_remove_subfolders_handler::*;
|
||||
use crate::http_server::endpoints::folders::subfolder::list_subfolders_handler::*;
|
||||
@@ -683,6 +684,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
crate::http_server::endpoints::folders::subfolder::bulk_add_subfolders_handler::bulk_add_subfolders_handler,
|
||||
crate::http_server::endpoints::folders::subfolder::bulk_remove_subfolders_handler::bulk_remove_subfolders_handler,
|
||||
crate::http_server::endpoints::folders::media_files::list_folder_media_files_handler::list_folder_media_files_handler,
|
||||
crate::http_server::endpoints::folders::media_files::list_media_files_without_folder_handler::list_media_files_without_folder_handler,
|
||||
crate::http_server::endpoints::folders::media_files::bulk_add_folder_media_files_handler::bulk_add_folder_media_files_handler,
|
||||
crate::http_server::endpoints::folders::media_files::bulk_move_folder_media_files_handler::bulk_move_folder_media_files_handler,
|
||||
crate::http_server::endpoints::folders::media_files::bulk_remove_folder_media_files_handler::bulk_remove_folder_media_files_handler,
|
||||
@@ -1484,6 +1486,8 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
BulkRemoveSubfoldersSuccessResponse,
|
||||
ListFolderMediaFilesQueryParams,
|
||||
ListFolderMediaFilesSuccessResponse,
|
||||
ListMediaFilesWithoutFolderQueryParams,
|
||||
ListMediaFilesWithoutFolderSuccessResponse,
|
||||
BulkAddFolderMediaFilesRequest,
|
||||
BulkAddFolderMediaFilesSuccessResponse,
|
||||
BulkRemoveFolderMediaFilesRequest,
|
||||
@@ -1525,6 +1529,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
SetMediaFileTagsSuccessResponse,
|
||||
TagDetails,
|
||||
TagMediaFileListItem,
|
||||
UnfolderedMediaFileListItem,
|
||||
))
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
|
||||
+2
-11
@@ -55,13 +55,7 @@ pub async fn list_folders_handler(
|
||||
let maybe_cursor_id = match &query.cursor {
|
||||
None => None,
|
||||
Some(cursor_str) => {
|
||||
let decoded = server_state.opaque_cursors
|
||||
.decode_cursor_expecting_name(CURSOR_NAME, cursor_str)
|
||||
.map_err(|err| {
|
||||
warn!("Failed to decode cursor: {:?}", err);
|
||||
CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string())
|
||||
})?;
|
||||
decoded.last_id
|
||||
Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -78,10 +72,7 @@ pub async fn list_folders_handler(
|
||||
|
||||
let maybe_cursor = rows.last().map(|last| {
|
||||
server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.id)
|
||||
}).transpose().map_err(|err| {
|
||||
warn!("Failed to encode cursor: {:?}", err);
|
||||
CommonWebError::server_error_with_message("Failed to encode cursor")
|
||||
})?;
|
||||
}).transpose()?;
|
||||
|
||||
let media_domain = get_media_domain(&http_request);
|
||||
let server_environment = server_state.server_environment;
|
||||
|
||||
+2
-11
@@ -148,13 +148,7 @@ pub async fn list_folder_media_files_handler(
|
||||
let maybe_cursor_id = match &query.cursor {
|
||||
None => None,
|
||||
Some(cursor_str) => {
|
||||
let decoded = server_state.opaque_cursors
|
||||
.decode_cursor_expecting_name(CURSOR_NAME, cursor_str)
|
||||
.map_err(|err| {
|
||||
warn!("Failed to decode cursor: {:?}", err);
|
||||
CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string())
|
||||
})?;
|
||||
decoded.last_id
|
||||
Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -171,10 +165,7 @@ pub async fn list_folder_media_files_handler(
|
||||
|
||||
let maybe_cursor = rows.last().map(|last| {
|
||||
server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.media_file_id)
|
||||
}).transpose().map_err(|err| {
|
||||
warn!("Failed to encode cursor: {:?}", err);
|
||||
CommonWebError::server_error_with_message("Failed to encode cursor")
|
||||
})?;
|
||||
}).transpose()?;
|
||||
|
||||
let media_domain = get_media_domain(&http_request);
|
||||
let server_environment = server_state.server_environment;
|
||||
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::{Json, Query};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use chrono::{DateTime, Utc};
|
||||
use log::warn;
|
||||
use serde_derive::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use artcraft_api_defs::common::responses::media_links::MediaLinks;
|
||||
use artcraft_api_defs::folders::media_files::ListMediaFilesWithoutFolderQueryParams;
|
||||
use enums::by_table::media_files::media_file_class::MediaFileClass;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use enums::common::visibility::Visibility;
|
||||
use mysql_queries::queries::folders::media_files::list_media_files_without_folder::{
|
||||
list_media_files_without_folder, ListMediaFilesWithoutFolderArgs,
|
||||
};
|
||||
use mysql_queries::queries::media_files::list::media_file_list_row::MediaFileListRow;
|
||||
use tokens::tokens::batch_generations::BatchGenerationToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::common_responses::media::media_domain::MediaDomain;
|
||||
use crate::http_server::common_responses::media::media_file_cover_image_details::MediaFileCoverImageDetails;
|
||||
use crate::http_server::common_responses::media::media_file_list_conversion::build_media_links_and_cover;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const CURSOR_NAME: &str = "folders_unfoldered_mf";
|
||||
const DEFAULT_LIMIT: u32 = 100;
|
||||
const MAX_LIMIT: u32 = 1000;
|
||||
|
||||
// ── Response shape ──
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ListMediaFilesWithoutFolderSuccessResponse {
|
||||
pub success: bool,
|
||||
pub media_files: Vec<UnfolderedMediaFileListItem>,
|
||||
|
||||
/// Cursor for the next page. Only present when this page was full —
|
||||
/// a short page means the list is exhausted.
|
||||
pub maybe_cursor: Option<String>,
|
||||
}
|
||||
|
||||
/// One media file as it appears in the unfoldered list. Same lean shape as
|
||||
/// the folder / tag list items. Intermediate system files are filtered out
|
||||
/// by the query, so the flag isn't part of the wire shape.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct UnfolderedMediaFileListItem {
|
||||
pub token: MediaFileToken,
|
||||
|
||||
/// Coarse-grained class (image / video / audio / mesh / splat / ...).
|
||||
pub media_class: MediaFileClass,
|
||||
|
||||
/// Specific format (jpg, png, mp4, glb, etc.) — closer to a MIME type.
|
||||
pub media_type: MediaFileType,
|
||||
|
||||
/// Link to the prompt
|
||||
pub maybe_prompt_token: Option<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>,
|
||||
}
|
||||
|
||||
// ── Handler ──
|
||||
|
||||
/// Paginated list of the logged-in user's media files that sit in no folder
|
||||
/// at all (memberships pointing at soft-deleted folders count as
|
||||
/// unfoldered). Newest first.
|
||||
///
|
||||
/// Use `filter_media_class` to scope the results to a single media class.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
tag = "Folders (Media File Management)",
|
||||
path = "/v1/folders/media_files_without_folder",
|
||||
params(ListMediaFilesWithoutFolderQueryParams),
|
||||
responses(
|
||||
(status = 200, body = ListMediaFilesWithoutFolderSuccessResponse),
|
||||
(status = 400, body = CommonWebError),
|
||||
(status = 401, body = CommonWebError),
|
||||
(status = 500, body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
pub async fn list_media_files_without_folder_handler(
|
||||
http_request: HttpRequest,
|
||||
query: Query<ListMediaFilesWithoutFolderQueryParams>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ListMediaFilesWithoutFolderSuccessResponse>, CommonWebError> {
|
||||
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
|
||||
warn!("MySQL pool error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
|
||||
let limit = query.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
|
||||
|
||||
let maybe_cursor_id = match &query.cursor {
|
||||
None => None,
|
||||
Some(cursor_str) => {
|
||||
Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?)
|
||||
}
|
||||
};
|
||||
|
||||
let rows = list_media_files_without_folder(ListMediaFilesWithoutFolderArgs {
|
||||
owner_user_token: &user_session.user_token,
|
||||
maybe_filter_media_class: query.filter_media_class,
|
||||
maybe_cursor_id,
|
||||
limit,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("list_media_files_without_folder failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
// Only hand out a next-page cursor when this page was full. A short page
|
||||
// means the list is exhausted, and emitting a cursor anyway would make
|
||||
// clients fetch one guaranteed-empty trailing page.
|
||||
let maybe_cursor = if rows.len() == limit as usize {
|
||||
rows.last()
|
||||
.map(|last| server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.media_file_id))
|
||||
.transpose()?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let media_domain = get_media_domain(&http_request);
|
||||
let server_environment = server_state.server_environment;
|
||||
|
||||
let media_files = rows.into_iter()
|
||||
.map(|row| unfoldered_media_file_row_to_list_item(row, media_domain, server_environment))
|
||||
.collect();
|
||||
|
||||
Ok(Json(ListMediaFilesWithoutFolderSuccessResponse {
|
||||
success: true,
|
||||
media_files,
|
||||
maybe_cursor,
|
||||
}))
|
||||
}
|
||||
|
||||
fn unfoldered_media_file_row_to_list_item(
|
||||
row: MediaFileListRow,
|
||||
media_domain: MediaDomain,
|
||||
server_environment: server_environment::ServerEnvironment,
|
||||
) -> UnfolderedMediaFileListItem {
|
||||
let (media_links, cover_image) =
|
||||
build_media_links_and_cover(&row, media_domain, server_environment);
|
||||
|
||||
UnfolderedMediaFileListItem {
|
||||
token: row.media_file_token,
|
||||
media_class: row.media_class,
|
||||
media_type: row.media_type,
|
||||
maybe_batch_token: row.maybe_batch_token,
|
||||
media_links,
|
||||
cover_image,
|
||||
creator_set_visibility: row.creator_set_visibility,
|
||||
is_user_upload: row.is_user_upload,
|
||||
maybe_title: row.maybe_title,
|
||||
maybe_prompt_token: row.maybe_prompt_token,
|
||||
maybe_original_filename: row.maybe_origin_filename,
|
||||
// Schema stores `INT(10)`; widen to u64 for the wire shape.
|
||||
maybe_duration_millis: row.maybe_duration_millis.map(|n| n as u64),
|
||||
maybe_frame_width: row.maybe_frame_width,
|
||||
maybe_frame_height: row.maybe_frame_height,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
}
|
||||
}
|
||||
+1
@@ -2,3 +2,4 @@ pub mod bulk_add_folder_media_files_handler;
|
||||
pub mod bulk_move_folder_media_files_handler;
|
||||
pub mod bulk_remove_folder_media_files_handler;
|
||||
pub mod list_folder_media_files_handler;
|
||||
pub mod list_media_files_without_folder_handler;
|
||||
|
||||
+2
-11
@@ -77,13 +77,7 @@ pub async fn list_subfolders_handler(
|
||||
let maybe_cursor_id = match &query.cursor {
|
||||
None => None,
|
||||
Some(cursor_str) => {
|
||||
let decoded = server_state.opaque_cursors
|
||||
.decode_cursor_expecting_name(CURSOR_NAME, cursor_str)
|
||||
.map_err(|err| {
|
||||
warn!("Failed to decode cursor: {:?}", err);
|
||||
CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string())
|
||||
})?;
|
||||
decoded.last_id
|
||||
Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -101,10 +95,7 @@ pub async fn list_subfolders_handler(
|
||||
|
||||
let maybe_cursor = rows.last().map(|last| {
|
||||
server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.id)
|
||||
}).transpose().map_err(|err| {
|
||||
warn!("Failed to encode cursor: {:?}", err);
|
||||
CommonWebError::server_error_with_message("Failed to encode cursor")
|
||||
})?;
|
||||
}).transpose()?;
|
||||
|
||||
let media_domain = get_media_domain(&http_request);
|
||||
let server_environment = server_state.server_environment;
|
||||
|
||||
+2
-12
@@ -83,14 +83,7 @@ pub async fn moderator_list_staff_audit_logs_handler(
|
||||
let maybe_cursor_id = match &query.cursor {
|
||||
None => None,
|
||||
Some(cursor_str) => {
|
||||
let decoded = server_state.opaque_cursors
|
||||
.decode_cursor_expecting_name(CURSOR_NAME, cursor_str)
|
||||
.map_err(|err| {
|
||||
warn!("Failed to decode cursor: {:?}", err);
|
||||
CommonWebError::BadInputWithSimpleMessage(
|
||||
"Invalid cursor".to_string())
|
||||
})?;
|
||||
decoded.last_id
|
||||
Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -108,10 +101,7 @@ pub async fn moderator_list_staff_audit_logs_handler(
|
||||
let maybe_cursor = records.last().map(|last| {
|
||||
server_state.opaque_cursors
|
||||
.encode_last_id_cursor(CURSOR_NAME, last.id)
|
||||
}).transpose().map_err(|err| {
|
||||
warn!("Failed to encode cursor: {:?}", err);
|
||||
CommonWebError::server_error_with_message("Failed to encode cursor")
|
||||
})?;
|
||||
}).transpose()?;
|
||||
|
||||
let audit_logs = records.into_iter().map(|r| {
|
||||
StaffAuditLogResponse {
|
||||
|
||||
+2
-11
@@ -123,13 +123,7 @@ pub async fn moderator_list_email_address_changes_for_user_handler(
|
||||
let maybe_cursor_id = match &query.cursor {
|
||||
None => None,
|
||||
Some(cursor_str) => {
|
||||
let decoded = server_state.opaque_cursors
|
||||
.decode_cursor_expecting_name(CURSOR_NAME, cursor_str)
|
||||
.map_err(|err| {
|
||||
warn!("Failed to decode cursor: {:?}", err);
|
||||
CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string())
|
||||
})?;
|
||||
decoded.last_id
|
||||
Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -149,10 +143,7 @@ pub async fn moderator_list_email_address_changes_for_user_handler(
|
||||
let maybe_cursor = rows.last().map(|last| {
|
||||
server_state.opaque_cursors
|
||||
.encode_last_id_cursor(CURSOR_NAME, last.id)
|
||||
}).transpose().map_err(|err| {
|
||||
warn!("Failed to encode cursor: {:?}", err);
|
||||
CommonWebError::server_error_with_message("Failed to encode cursor")
|
||||
})?;
|
||||
}).transpose()?;
|
||||
|
||||
let changes = rows.into_iter().map(to_response_item).collect();
|
||||
|
||||
|
||||
+2
-12
@@ -49,14 +49,7 @@ pub async fn moderator_list_global_user_referrals_handler(
|
||||
let maybe_cursor_id = match &query.cursor {
|
||||
None => None,
|
||||
Some(cursor_str) => {
|
||||
let decoded = server_state.opaque_cursors
|
||||
.decode_cursor_expecting_name(CURSOR_NAME, cursor_str)
|
||||
.map_err(|err| {
|
||||
warn!("Failed to decode cursor: {:?}", err);
|
||||
CommonWebError::BadInputWithSimpleMessage(
|
||||
"Invalid cursor".to_string())
|
||||
})?;
|
||||
decoded.last_id
|
||||
Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -74,10 +67,7 @@ pub async fn moderator_list_global_user_referrals_handler(
|
||||
let maybe_cursor = records.last().map(|last| {
|
||||
server_state.opaque_cursors
|
||||
.encode_last_id_cursor(CURSOR_NAME, last.id)
|
||||
}).transpose().map_err(|err| {
|
||||
warn!("Failed to encode cursor: {:?}", err);
|
||||
CommonWebError::server_error_with_message("Failed to encode cursor")
|
||||
})?;
|
||||
}).transpose()?;
|
||||
|
||||
let referrals = records.into_iter().map(|r| {
|
||||
UserReferralResponse {
|
||||
|
||||
+2
-12
@@ -54,14 +54,7 @@ pub async fn moderator_list_user_referrals_for_user_handler(
|
||||
let maybe_cursor_id = match &query.cursor {
|
||||
None => None,
|
||||
Some(cursor_str) => {
|
||||
let decoded = server_state.opaque_cursors
|
||||
.decode_cursor_expecting_name(CURSOR_NAME, cursor_str)
|
||||
.map_err(|err| {
|
||||
warn!("Failed to decode cursor: {:?}", err);
|
||||
CommonWebError::BadInputWithSimpleMessage(
|
||||
"Invalid cursor".to_string())
|
||||
})?;
|
||||
decoded.last_id
|
||||
Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -82,10 +75,7 @@ pub async fn moderator_list_user_referrals_for_user_handler(
|
||||
let maybe_cursor = records.last().map(|last| {
|
||||
server_state.opaque_cursors
|
||||
.encode_last_id_cursor(CURSOR_NAME, last.id)
|
||||
}).transpose().map_err(|err| {
|
||||
warn!("Failed to encode cursor: {:?}", err);
|
||||
CommonWebError::server_error_with_message("Failed to encode cursor")
|
||||
})?;
|
||||
}).transpose()?;
|
||||
|
||||
let referrals = records.into_iter().map(|r| {
|
||||
UserReferralResponse {
|
||||
|
||||
+2
-12
@@ -88,14 +88,7 @@ pub async fn moderator_list_user_session_impersonation_requests_for_user_handler
|
||||
let maybe_cursor_id = match &query.cursor {
|
||||
None => None,
|
||||
Some(cursor_str) => {
|
||||
let decoded = server_state.opaque_cursors
|
||||
.decode_cursor_expecting_name(CURSOR_NAME, cursor_str)
|
||||
.map_err(|err| {
|
||||
warn!("Failed to decode cursor: {:?}", err);
|
||||
CommonWebError::BadInputWithSimpleMessage(
|
||||
"Invalid cursor".to_string())
|
||||
})?;
|
||||
decoded.last_id
|
||||
Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -114,10 +107,7 @@ pub async fn moderator_list_user_session_impersonation_requests_for_user_handler
|
||||
let maybe_cursor = records.last().map(|last| {
|
||||
server_state.opaque_cursors
|
||||
.encode_last_id_cursor(CURSOR_NAME, last.id)
|
||||
}).transpose().map_err(|err| {
|
||||
warn!("Failed to encode cursor: {:?}", err);
|
||||
CommonWebError::server_error_with_message("Failed to encode cursor")
|
||||
})?;
|
||||
}).transpose()?;
|
||||
|
||||
let impersonation_requests = records.into_iter().map(|r| {
|
||||
UserImpersonationRequestResponse {
|
||||
|
||||
+2
-12
@@ -82,14 +82,7 @@ pub async fn moderator_list_user_session_impersonation_requests_handler(
|
||||
let maybe_cursor_id = match &query.cursor {
|
||||
None => None,
|
||||
Some(cursor_str) => {
|
||||
let decoded = server_state.opaque_cursors
|
||||
.decode_cursor_expecting_name(CURSOR_NAME, cursor_str)
|
||||
.map_err(|err| {
|
||||
warn!("Failed to decode cursor: {:?}", err);
|
||||
CommonWebError::BadInputWithSimpleMessage(
|
||||
"Invalid cursor".to_string())
|
||||
})?;
|
||||
decoded.last_id
|
||||
Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -107,10 +100,7 @@ pub async fn moderator_list_user_session_impersonation_requests_handler(
|
||||
let maybe_cursor = records.last().map(|last| {
|
||||
server_state.opaque_cursors
|
||||
.encode_last_id_cursor(CURSOR_NAME, last.id)
|
||||
}).transpose().map_err(|err| {
|
||||
warn!("Failed to encode cursor: {:?}", err);
|
||||
CommonWebError::server_error_with_message("Failed to encode cursor")
|
||||
})?;
|
||||
}).transpose()?;
|
||||
|
||||
let impersonation_requests = records.into_iter().map(|r| {
|
||||
AllImpersonationRequestResponse {
|
||||
|
||||
+2
-11
@@ -85,13 +85,7 @@ pub async fn list_media_files_with_tag_handler(
|
||||
let maybe_cursor_id = match &query.cursor {
|
||||
None => None,
|
||||
Some(cursor_str) => {
|
||||
let decoded = server_state.opaque_cursors
|
||||
.decode_cursor_expecting_name(CURSOR_NAME, cursor_str)
|
||||
.map_err(|err| {
|
||||
warn!("Failed to decode cursor: {:?}", err);
|
||||
CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string())
|
||||
})?;
|
||||
decoded.last_id
|
||||
Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -109,10 +103,7 @@ pub async fn list_media_files_with_tag_handler(
|
||||
|
||||
let maybe_cursor = rows.last().map(|last| {
|
||||
server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.media_file_id)
|
||||
}).transpose().map_err(|err| {
|
||||
warn!("Failed to encode cursor: {:?}", err);
|
||||
CommonWebError::server_error_with_message("Failed to encode cursor")
|
||||
})?;
|
||||
}).transpose()?;
|
||||
|
||||
let media_domain = get_media_domain(&http_request);
|
||||
let server_environment = server_state.server_environment;
|
||||
|
||||
+2
-11
@@ -62,13 +62,7 @@ pub async fn list_tagged_media_files_handler(
|
||||
let maybe_cursor_id = match &query.cursor {
|
||||
None => None,
|
||||
Some(cursor_str) => {
|
||||
let decoded = server_state.opaque_cursors
|
||||
.decode_cursor_expecting_name(CURSOR_NAME, cursor_str)
|
||||
.map_err(|err| {
|
||||
warn!("Failed to decode cursor: {:?}", err);
|
||||
CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string())
|
||||
})?;
|
||||
decoded.last_id
|
||||
Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -85,10 +79,7 @@ pub async fn list_tagged_media_files_handler(
|
||||
|
||||
let maybe_cursor = rows.last().map(|last| {
|
||||
server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.media_file_id)
|
||||
}).transpose().map_err(|err| {
|
||||
warn!("Failed to encode cursor: {:?}", err);
|
||||
CommonWebError::server_error_with_message("Failed to encode cursor")
|
||||
})?;
|
||||
}).transpose()?;
|
||||
|
||||
let media_domain = get_media_domain(&http_request);
|
||||
let server_environment = server_state.server_environment;
|
||||
|
||||
+2
-11
@@ -47,13 +47,7 @@ pub async fn list_tags_handler(
|
||||
let maybe_cursor_id = match &query.cursor {
|
||||
None => None,
|
||||
Some(cursor_str) => {
|
||||
let decoded = server_state.opaque_cursors
|
||||
.decode_cursor_expecting_name(CURSOR_NAME, cursor_str)
|
||||
.map_err(|err| {
|
||||
warn!("Failed to decode cursor: {:?}", err);
|
||||
CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string())
|
||||
})?;
|
||||
decoded.last_id
|
||||
Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -70,10 +64,7 @@ pub async fn list_tags_handler(
|
||||
|
||||
let maybe_cursor = rows.last().map(|last| {
|
||||
server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.id)
|
||||
}).transpose().map_err(|err| {
|
||||
warn!("Failed to encode cursor: {:?}", err);
|
||||
CommonWebError::server_error_with_message("Failed to encode cursor")
|
||||
})?;
|
||||
}).transpose()?;
|
||||
|
||||
Ok(Json(ListTagsSuccessResponse {
|
||||
success: true,
|
||||
|
||||
+2
-11
@@ -62,13 +62,7 @@ pub async fn list_untagged_media_files_handler(
|
||||
let maybe_cursor_id = match &query.cursor {
|
||||
None => None,
|
||||
Some(cursor_str) => {
|
||||
let decoded = server_state.opaque_cursors
|
||||
.decode_cursor_expecting_name(CURSOR_NAME, cursor_str)
|
||||
.map_err(|err| {
|
||||
warn!("Failed to decode cursor: {:?}", err);
|
||||
CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string())
|
||||
})?;
|
||||
decoded.last_id
|
||||
Some(server_state.opaque_cursors.decode_last_id_cursor(CURSOR_NAME, cursor_str)?)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -85,10 +79,7 @@ pub async fn list_untagged_media_files_handler(
|
||||
|
||||
let maybe_cursor = rows.last().map(|last| {
|
||||
server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.media_file_id)
|
||||
}).transpose().map_err(|err| {
|
||||
warn!("Failed to encode cursor: {:?}", err);
|
||||
CommonWebError::server_error_with_message("Failed to encode cursor")
|
||||
})?;
|
||||
}).transpose()?;
|
||||
|
||||
let media_domain = get_media_domain(&http_request);
|
||||
let server_environment = server_state.server_environment;
|
||||
|
||||
+6
@@ -15,6 +15,7 @@ use crate::http_server::endpoints::folders::media_files::bulk_add_folder_media_f
|
||||
use crate::http_server::endpoints::folders::media_files::bulk_move_folder_media_files_handler::bulk_move_folder_media_files_handler;
|
||||
use crate::http_server::endpoints::folders::media_files::bulk_remove_folder_media_files_handler::bulk_remove_folder_media_files_handler;
|
||||
use crate::http_server::endpoints::folders::media_files::list_folder_media_files_handler::list_folder_media_files_handler;
|
||||
use crate::http_server::endpoints::folders::media_files::list_media_files_without_folder_handler::list_media_files_without_folder_handler;
|
||||
use crate::http_server::endpoints::folders::subfolder::bulk_add_subfolders_handler::bulk_add_subfolders_handler;
|
||||
use crate::http_server::endpoints::folders::subfolder::bulk_remove_subfolders_handler::bulk_remove_subfolders_handler;
|
||||
use crate::http_server::endpoints::folders::subfolder::list_subfolders_handler::list_subfolders_handler;
|
||||
@@ -92,6 +93,11 @@ where
|
||||
),
|
||||
)
|
||||
// Media-file membership
|
||||
.service(
|
||||
web::resource("/media_files_without_folder")
|
||||
.route(web::get().to(list_media_files_without_folder_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok())),
|
||||
)
|
||||
.service(
|
||||
web::scope("/media_files")
|
||||
.service(
|
||||
|
||||
@@ -13,4 +13,5 @@ pub mod response_success_helpers;
|
||||
pub mod scoped_temp_dir_creator;
|
||||
pub mod serialize_as_json_error;
|
||||
pub mod try_delete_session_cache;
|
||||
pub mod web_opaque_cursor_encoder_v2;
|
||||
pub mod web_sort_key_crypto;
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
use log::warn;
|
||||
|
||||
use opaque_cursors::v2::opaque_cursor_encoder_v2::OpaqueCursorEncoderV2;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
|
||||
/// [`OpaqueCursorEncoderV2`] with a web-facing interface: results map to
|
||||
/// [`CommonWebError`] so handlers can use `?` directly, with the right HTTP
|
||||
/// status for each direction. This is the flavor injected into Actix via
|
||||
/// `ServerState` (same treatment as `WebSortKeyCrypto`).
|
||||
#[derive(Clone)]
|
||||
pub struct WebOpaqueCursorEncoderV2 {
|
||||
encoder: OpaqueCursorEncoderV2,
|
||||
}
|
||||
|
||||
impl WebOpaqueCursorEncoderV2 {
|
||||
pub fn new(secret: &str) -> Self {
|
||||
Self {
|
||||
encoder: OpaqueCursorEncoderV2::new(secret),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a last-id cursor into an opaque string.
|
||||
/// Failure is a genuine server fault (500).
|
||||
pub fn encode_last_id_cursor(&self, name: &str, id: u64) -> Result<String, CommonWebError> {
|
||||
self.encoder.encode_last_id_cursor(name, id)
|
||||
.map_err(|err| {
|
||||
warn!("Failed to encode pagination cursor for {:?}: {:?}", name, err);
|
||||
CommonWebError::server_error_with_message("Failed to encode cursor")
|
||||
})
|
||||
}
|
||||
|
||||
/// Decode a client-supplied last-id cursor, verifying it belongs to the
|
||||
/// list named `name` and carries a `last_id`.
|
||||
///
|
||||
/// Cursors arrive from clients verbatim, so garbage, cross-list reuse,
|
||||
/// and missing ids are all bad input (400), not a server fault — a 500
|
||||
/// here would trip the error-alerting middleware on any bot or stale
|
||||
/// client.
|
||||
pub fn decode_last_id_cursor(&self, name: &str, cursor: &str) -> Result<u64, CommonWebError> {
|
||||
let decoded = self.encoder.decode_cursor_expecting_name(name, cursor)
|
||||
.map_err(|err| {
|
||||
warn!("Invalid pagination cursor for {:?}: {:?}", name, err);
|
||||
CommonWebError::BadInputWithSimpleMessage("Invalid pagination cursor.".to_string())
|
||||
})?;
|
||||
|
||||
decoded.last_id.ok_or_else(|| {
|
||||
warn!("Pagination cursor for {:?} carries no last_id", name);
|
||||
CommonWebError::BadInputWithSimpleMessage("Invalid pagination cursor.".to_string())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ use memory_caching::arc_ttl_sieve::ArcTtlSieve;
|
||||
use memory_caching::single_item_ttl_cache::SingleItemTtlCache;
|
||||
use mysql_queries::mediators::badge_granter::BadgeGranter;
|
||||
use mysql_queries::mediators::firehose_publisher::FirehosePublisher;
|
||||
use opaque_cursors::v2::opaque_cursor_encoder_v2::OpaqueCursorEncoderV2;
|
||||
use crate::http_server::web_utils::web_opaque_cursor_encoder_v2::WebOpaqueCursorEncoderV2;
|
||||
use pager::worker::pager_worker::PagerWorker;
|
||||
use redis::Client;
|
||||
use redis_caching::redis_ttl_cache::RedisTtlCache;
|
||||
@@ -157,7 +157,7 @@ pub async fn setup_dependencies(server_hostname: &str) -> AnyhowResult<SetupResu
|
||||
|
||||
let sort_key_crypto_secret = easyenv::get_env_string_or_default("SORT_KEY_SECRET", "webscale");
|
||||
let sort_key_crypto = WebSortKeyCrypto::new(&sort_key_crypto_secret);
|
||||
let opaque_cursor_encoder = OpaqueCursorEncoderV2::new(&sort_key_crypto_secret);
|
||||
let opaque_cursor_encoder = WebOpaqueCursorEncoderV2::new(&sort_key_crypto_secret);
|
||||
|
||||
let health_check_interval = easyenv::get_env_duration_seconds_or_default(
|
||||
"HEALTH_CHECK_INTERVAL_SECS", Duration::from_secs(3));
|
||||
|
||||
@@ -31,7 +31,7 @@ use mysql_queries::mediators::firehose_publisher::FirehosePublisher;
|
||||
use mysql_queries::queries::generic_inference::web::get_pending_inference_job_count::InferenceQueueLengthResult;
|
||||
use mysql_queries::queries::media_files::list::list_featured_media_files::FeaturedMediaFileListPage;
|
||||
use mysql_queries::queries::model_categories::list_categories_query_builder::CategoryList;
|
||||
use opaque_cursors::v2::opaque_cursor_encoder_v2::OpaqueCursorEncoderV2;
|
||||
use crate::http_server::web_utils::web_opaque_cursor_encoder_v2::WebOpaqueCursorEncoderV2;
|
||||
use pager::client::pager::Pager;
|
||||
use redis::Client;
|
||||
use redis_caching::redis_ttl_cache::RedisTtlCache;
|
||||
@@ -99,7 +99,7 @@ pub struct ServerState {
|
||||
pub audio_uploads_bucket_root: String,
|
||||
|
||||
pub sort_key_crypto: WebSortKeyCrypto,
|
||||
pub opaque_cursors: OpaqueCursorEncoderV2,
|
||||
pub opaque_cursors: WebOpaqueCursorEncoderV2,
|
||||
|
||||
pub ip_ban_list: IpBanList,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user