folderless endpoint (#1779)

This commit is contained in:
Brandon Thomas
2026-07-12 15:23:58 -04:00
committed by GitHub
parent 9898c6dbcf
commit 348a603cae
26 changed files with 1023 additions and 152 deletions
@@ -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"
}
@@ -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)]
@@ -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;
@@ -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;
@@ -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;
@@ -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,
}
}
@@ -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;
@@ -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;
@@ -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 {
@@ -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();
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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;
@@ -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;
@@ -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,
@@ -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;
@@ -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;
@@ -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,