initial endpoints + queries

This commit is contained in:
Brandon Thomas committed 2026-06-08 09:31:59 -04:00
1 parent 62861ecc04
commit b4f525f5e7
49 files changed
+2521 -2

No files matched your search

@@ -0,0 +1,54 @@
use chrono::{DateTime, Utc};
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::folders::FolderToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::users::UserToken;
/// Canonical wire shape for a folder. Used by single-folder GETs, create
/// responses, list rows, and subfolder list rows.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct FolderInfo {
pub token: FolderToken,
pub name: String,
pub owner_user_token: UserToken,
pub maybe_parent_folder_token: Option<FolderToken>,
pub maybe_last_media_file_token_1: Option<MediaFileToken>,
pub maybe_last_media_file_token_2: Option<MediaFileToken>,
pub maybe_last_media_file_token_3: Option<MediaFileToken>,
pub maybe_last_media_file_token_4: Option<MediaFileToken>,
pub maybe_cover_image_custom_media_token: Option<MediaFileToken>,
pub maybe_color_code: Option<String>,
pub has_star: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
/// True when `maybe_parent_folder_token` is set but the referenced
/// parent row is missing or soft-deleted.
pub is_orphaned: bool,
}
/// Canonical wire shape for a media file inside a folder. Lean enough for
/// thumbnail rendering; callers that need richer fields should hit the
/// media-file batch-get endpoint.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct FolderMediaFileInfo {
pub media_file_token: MediaFileToken,
pub added_to_folder_at: DateTime<Utc>,
pub media_type: String,
pub media_class: String,
pub maybe_mime_type: Option<String>,
pub public_bucket_directory_hash: String,
pub maybe_public_bucket_prefix: Option<String>,
pub maybe_public_bucket_extension: Option<String>,
pub maybe_frame_width: Option<i32>,
pub maybe_frame_height: Option<i32>,
pub maybe_duration_millis: Option<i32>,
pub maybe_title: Option<String>,
}
@@ -0,0 +1,93 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use tokens::tokens::folders::FolderToken;
use crate::folders::common::FolderInfo;
// ── POST /v1/folders/create ──
#[derive(Deserialize, ToSchema)]
pub struct CreateFolderRequest {
pub name: String,
pub maybe_parent_folder_token: Option<FolderToken>,
pub maybe_color_code: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct CreateFolderSuccessResponse {
pub success: bool,
pub folder: FolderInfo,
}
// ── GET /v1/folders/list_all ──
#[derive(Deserialize, IntoParams)]
pub struct ListFoldersQueryParams {
pub cursor: Option<String>,
pub limit: Option<u32>,
}
#[derive(Serialize, ToSchema)]
pub struct ListFoldersSuccessResponse {
pub success: bool,
pub folders: Vec<FolderInfo>,
pub maybe_cursor: Option<String>,
}
// ── GET /v1/folders/folder/{folder_token} ──
#[derive(Deserialize, ToSchema)]
pub struct FolderPathInfo {
pub folder_token: String,
}
#[derive(Serialize, ToSchema)]
pub struct GetFolderSuccessResponse {
pub success: bool,
pub folder: FolderInfo,
}
// ── PUT /v1/folders/folder/{folder_token}/rename ──
#[derive(Deserialize, ToSchema)]
pub struct RenameFolderRequest {
pub new_name: String,
}
#[derive(Serialize, ToSchema)]
pub struct RenameFolderSuccessResponse {
pub success: bool,
}
// ── PUT /v1/folders/folder/{folder_token}/star ──
#[derive(Deserialize, ToSchema)]
pub struct SetFolderStarRequest {
pub has_star: bool,
}
#[derive(Serialize, ToSchema)]
pub struct SetFolderStarSuccessResponse {
pub success: bool,
}
// ── PUT /v1/folders/folder/{folder_token}/color_code ──
#[derive(Deserialize, ToSchema)]
pub struct SetFolderColorCodeRequest {
/// `None` clears the color tag.
pub maybe_color_code: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct SetFolderColorCodeSuccessResponse {
pub success: bool,
}
// ── DELETE /v1/folders/folder/{folder_token} ──
#[derive(Serialize, ToSchema)]
pub struct DeleteFolderSuccessResponse {
pub success: bool,
}
@@ -0,0 +1,58 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use tokens::tokens::media_files::MediaFileToken;
use crate::folders::common::FolderMediaFileInfo;
// ── GET /v1/folders/media_files/{folder_token} ──
#[derive(Deserialize, ToSchema)]
pub struct FolderMediaFilesPathInfo {
pub folder_token: String,
}
#[derive(Deserialize, IntoParams)]
pub struct ListFolderMediaFilesQueryParams {
pub cursor: Option<String>,
pub limit: Option<u32>,
}
#[derive(Serialize, ToSchema)]
pub struct ListFolderMediaFilesSuccessResponse {
pub success: bool,
pub media_files: Vec<FolderMediaFileInfo>,
pub maybe_cursor: Option<String>,
}
// ── PUT /v1/folders/media_files/{folder_token}/bulk_add ──
#[derive(Deserialize, ToSchema)]
pub struct BulkAddFolderMediaFilesRequest {
/// Media files to add to the folder. Tokens that don't exist or are
/// soft-deleted are silently skipped. Already-added rows are no-ops
/// (idempotent via INSERT IGNORE on the composite primary key).
pub media_file_tokens: Vec<MediaFileToken>,
}
#[derive(Serialize, ToSchema)]
pub struct BulkAddFolderMediaFilesSuccessResponse {
pub success: bool,
/// Tokens that pre-existed (existed + not deleted). Already-added rows
/// are included since the operation is idempotent.
pub accepted_media_file_tokens: Vec<MediaFileToken>,
}
// ── PUT /v1/folders/media_files/{folder_token}/bulk_remove ──
#[derive(Deserialize, ToSchema)]
pub struct BulkRemoveFolderMediaFilesRequest {
pub media_file_tokens: Vec<MediaFileToken>,
}
#[derive(Serialize, ToSchema)]
pub struct BulkRemoveFolderMediaFilesSuccessResponse {
pub success: bool,
/// How many membership rows were actually hard-deleted.
pub removed_count: u64,
}
@@ -0,0 +1,4 @@
pub mod common;
pub mod folder;
pub mod media_files;
pub mod subfolder;
@@ -0,0 +1,61 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use tokens::tokens::folders::FolderToken;
use crate::folders::common::FolderInfo;
// ── GET /v1/folders/subfolders/{folder_token} ──
#[derive(Deserialize, ToSchema)]
pub struct SubfolderPathInfo {
pub folder_token: String,
}
#[derive(Deserialize, IntoParams)]
pub struct ListSubfoldersQueryParams {
pub cursor: Option<String>,
pub limit: Option<u32>,
}
#[derive(Serialize, ToSchema)]
pub struct ListSubfoldersSuccessResponse {
pub success: bool,
pub subfolders: Vec<FolderInfo>,
pub maybe_cursor: Option<String>,
}
// ── PUT /v1/folders/subfolders/{folder_token}/bulk_add ──
#[derive(Deserialize, ToSchema)]
pub struct BulkAddSubfoldersRequest {
/// The folders to reparent under the URL `folder_token`. Tokens that
/// don't exist, aren't owned by the caller, are soft-deleted, or are
/// the parent itself are silently skipped (idempotent).
pub subfolder_tokens: Vec<FolderToken>,
}
#[derive(Serialize, ToSchema)]
pub struct BulkAddSubfoldersSuccessResponse {
pub success: bool,
/// Tokens that were actually accepted (existed + owned + not deleted +
/// not the parent itself). Already-parented tokens are included here
/// since the operation is idempotent.
pub accepted_subfolder_tokens: Vec<FolderToken>,
}
// ── PUT /v1/folders/subfolders/{folder_token}/bulk_remove ──
#[derive(Deserialize, ToSchema)]
pub struct BulkRemoveSubfoldersRequest {
/// Folders to unparent. Only rows currently parented to the URL
/// `folder_token` are affected; others are silently skipped.
pub subfolder_tokens: Vec<FolderToken>,
}
#[derive(Serialize, ToSchema)]
pub struct BulkRemoveSubfoldersSuccessResponse {
pub success: bool,
/// How many rows were actually unparented.
pub removed_count: u64,
}
@@ -2,6 +2,7 @@ pub mod analytics;
pub mod characters;
pub mod common;
pub mod credits;
pub mod folders;
pub mod generate;
pub mod jobs;
pub mod media_file;
@@ -0,0 +1,27 @@
use chrono::{DateTime, Utc};
use tokens::tokens::folders::FolderToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::users::UserToken;
/// Materialized representation of a `folders` row plus the derived
/// `is_orphaned` flag (true when `maybe_parent_folder_token` is set but the
/// referenced parent row is missing or soft-deleted).
#[derive(Debug, Clone)]
pub struct FolderRow {
pub id: u64,
pub token: FolderToken,
pub name: String,
pub owner_user_token: UserToken,
pub maybe_parent_folder_token: Option<FolderToken>,
pub maybe_last_media_file_token_1: Option<MediaFileToken>,
pub maybe_last_media_file_token_2: Option<MediaFileToken>,
pub maybe_last_media_file_token_3: Option<MediaFileToken>,
pub maybe_last_media_file_token_4: Option<MediaFileToken>,
pub maybe_cover_image_custom_media_token: Option<MediaFileToken>,
pub maybe_color_code: Option<String>,
pub has_star: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub is_orphaned: bool,
}
@@ -0,0 +1,69 @@
use sqlx::MySqlPool;
use tokens::tokens::folders::FolderToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::users::UserToken;
use crate::queries::folders::folder::folder_row::FolderRow;
/// Fetch a single (live) folder by token, scoped to an owner. Returns
/// `Ok(None)` if the folder doesn't exist, is soft-deleted, or is owned
/// by a different user (don't leak existence of others' folders).
pub async fn get_folder_for_owner(
folder_token: &FolderToken,
owner_user_token: &UserToken,
pool: &MySqlPool,
) -> Result<Option<FolderRow>, sqlx::Error> {
let result = sqlx::query!(
r#"
SELECT
f.id as `id: u64`,
f.token as `token: FolderToken`,
f.name,
f.owner_user_token as `owner_user_token: UserToken`,
f.maybe_parent_folder_token as `maybe_parent_folder_token: FolderToken`,
f.maybe_last_media_file_token_1 as `maybe_last_media_file_token_1: MediaFileToken`,
f.maybe_last_media_file_token_2 as `maybe_last_media_file_token_2: MediaFileToken`,
f.maybe_last_media_file_token_3 as `maybe_last_media_file_token_3: MediaFileToken`,
f.maybe_last_media_file_token_4 as `maybe_last_media_file_token_4: MediaFileToken`,
f.maybe_cover_image_custom_media_token as `maybe_cover_image_custom_media_token: MediaFileToken`,
f.maybe_color_code,
f.has_star as `has_star: bool`,
f.created_at,
f.updated_at,
(
f.maybe_parent_folder_token IS NOT NULL
AND (p.token IS NULL OR p.maybe_deleted_at IS NOT NULL)
) as `is_orphaned: bool`
FROM folders f
LEFT JOIN folders p
ON p.token = f.maybe_parent_folder_token
WHERE f.token = ?
AND f.owner_user_token = ?
AND f.maybe_deleted_at IS NULL
LIMIT 1
"#,
folder_token.as_str(),
owner_user_token.as_str(),
)
.fetch_optional(pool)
.await?;
Ok(result.map(|r| FolderRow {
id: r.id,
token: r.token,
name: r.name,
owner_user_token: r.owner_user_token,
maybe_parent_folder_token: r.maybe_parent_folder_token,
maybe_last_media_file_token_1: r.maybe_last_media_file_token_1,
maybe_last_media_file_token_2: r.maybe_last_media_file_token_2,
maybe_last_media_file_token_3: r.maybe_last_media_file_token_3,
maybe_last_media_file_token_4: r.maybe_last_media_file_token_4,
maybe_cover_image_custom_media_token: r.maybe_cover_image_custom_media_token,
maybe_color_code: r.maybe_color_code,
has_star: r.has_star,
created_at: r.created_at,
updated_at: r.updated_at,
is_orphaned: r.is_orphaned,
}))
}
@@ -0,0 +1,37 @@
use sqlx::MySqlPool;
use tokens::tokens::folders::FolderToken;
use tokens::tokens::users::UserToken;
pub struct InsertFolderArgs<'a> {
pub token: &'a FolderToken,
pub name: &'a str,
pub owner_user_token: &'a UserToken,
pub maybe_parent_folder_token: Option<&'a FolderToken>,
pub maybe_color_code: Option<&'a str>,
}
pub async fn insert_folder(
args: InsertFolderArgs<'_>,
pool: &MySqlPool,
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
INSERT INTO folders
SET
token = ?,
name = ?,
owner_user_token = ?,
maybe_parent_folder_token = ?,
maybe_color_code = ?
"#,
args.token.as_str(),
args.name,
args.owner_user_token.as_str(),
args.maybe_parent_folder_token.map(|t| t.as_str()),
args.maybe_color_code,
)
.execute(pool)
.await?;
Ok(())
}
@@ -0,0 +1,141 @@
use sqlx::MySqlPool;
use tokens::tokens::folders::FolderToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::users::UserToken;
use crate::queries::folders::folder::folder_row::FolderRow;
pub struct ListFoldersForUserArgs<'a> {
pub owner_user_token: &'a UserToken,
pub maybe_cursor_id: Option<u64>,
pub limit: u32,
pub pool: &'a MySqlPool,
}
/// List all live folders owned by a user, newest first. Includes the
/// derived `is_orphaned` flag computed by joining the table against itself
/// on `maybe_parent_folder_token` — orphaned means the parent row is
/// missing or soft-deleted.
pub async fn list_folders_for_user(
args: ListFoldersForUserArgs<'_>,
) -> Result<Vec<FolderRow>, sqlx::Error> {
let limit = args.limit as i64;
let rows = match args.maybe_cursor_id {
Some(cursor_id) => {
sqlx::query!(
r#"
SELECT
f.id as `id: u64`,
f.token as `token: FolderToken`,
f.name,
f.owner_user_token as `owner_user_token: UserToken`,
f.maybe_parent_folder_token as `maybe_parent_folder_token: FolderToken`,
f.maybe_last_media_file_token_1 as `maybe_last_media_file_token_1: MediaFileToken`,
f.maybe_last_media_file_token_2 as `maybe_last_media_file_token_2: MediaFileToken`,
f.maybe_last_media_file_token_3 as `maybe_last_media_file_token_3: MediaFileToken`,
f.maybe_last_media_file_token_4 as `maybe_last_media_file_token_4: MediaFileToken`,
f.maybe_cover_image_custom_media_token as `maybe_cover_image_custom_media_token: MediaFileToken`,
f.maybe_color_code,
f.has_star as `has_star: bool`,
f.created_at,
f.updated_at,
(
f.maybe_parent_folder_token IS NOT NULL
AND (p.token IS NULL OR p.maybe_deleted_at IS NOT NULL)
) as `is_orphaned: bool`
FROM folders f
LEFT JOIN folders p
ON p.token = f.maybe_parent_folder_token
WHERE f.owner_user_token = ?
AND f.maybe_deleted_at IS NULL
AND f.id < ?
ORDER BY f.id DESC
LIMIT ?
"#,
args.owner_user_token.as_str(),
cursor_id,
limit,
)
.fetch_all(args.pool)
.await?
.into_iter()
.map(|r| FolderRow {
id: r.id,
token: r.token,
name: r.name,
owner_user_token: r.owner_user_token,
maybe_parent_folder_token: r.maybe_parent_folder_token,
maybe_last_media_file_token_1: r.maybe_last_media_file_token_1,
maybe_last_media_file_token_2: r.maybe_last_media_file_token_2,
maybe_last_media_file_token_3: r.maybe_last_media_file_token_3,
maybe_last_media_file_token_4: r.maybe_last_media_file_token_4,
maybe_cover_image_custom_media_token: r.maybe_cover_image_custom_media_token,
maybe_color_code: r.maybe_color_code,
has_star: r.has_star,
created_at: r.created_at,
updated_at: r.updated_at,
is_orphaned: r.is_orphaned,
})
.collect::<Vec<_>>()
}
None => {
sqlx::query!(
r#"
SELECT
f.id as `id: u64`,
f.token as `token: FolderToken`,
f.name,
f.owner_user_token as `owner_user_token: UserToken`,
f.maybe_parent_folder_token as `maybe_parent_folder_token: FolderToken`,
f.maybe_last_media_file_token_1 as `maybe_last_media_file_token_1: MediaFileToken`,
f.maybe_last_media_file_token_2 as `maybe_last_media_file_token_2: MediaFileToken`,
f.maybe_last_media_file_token_3 as `maybe_last_media_file_token_3: MediaFileToken`,
f.maybe_last_media_file_token_4 as `maybe_last_media_file_token_4: MediaFileToken`,
f.maybe_cover_image_custom_media_token as `maybe_cover_image_custom_media_token: MediaFileToken`,
f.maybe_color_code,
f.has_star as `has_star: bool`,
f.created_at,
f.updated_at,
(
f.maybe_parent_folder_token IS NOT NULL
AND (p.token IS NULL OR p.maybe_deleted_at IS NOT NULL)
) as `is_orphaned: bool`
FROM folders f
LEFT JOIN folders p
ON p.token = f.maybe_parent_folder_token
WHERE f.owner_user_token = ?
AND f.maybe_deleted_at IS NULL
ORDER BY f.id DESC
LIMIT ?
"#,
args.owner_user_token.as_str(),
limit,
)
.fetch_all(args.pool)
.await?
.into_iter()
.map(|r| FolderRow {
id: r.id,
token: r.token,
name: r.name,
owner_user_token: r.owner_user_token,
maybe_parent_folder_token: r.maybe_parent_folder_token,
maybe_last_media_file_token_1: r.maybe_last_media_file_token_1,
maybe_last_media_file_token_2: r.maybe_last_media_file_token_2,
maybe_last_media_file_token_3: r.maybe_last_media_file_token_3,
maybe_last_media_file_token_4: r.maybe_last_media_file_token_4,
maybe_cover_image_custom_media_token: r.maybe_cover_image_custom_media_token,
maybe_color_code: r.maybe_color_code,
has_star: r.has_star,
created_at: r.created_at,
updated_at: r.updated_at,
is_orphaned: r.is_orphaned,
})
.collect::<Vec<_>>()
}
};
Ok(rows)
}
@@ -0,0 +1,8 @@
pub mod folder_row;
pub mod get_folder_for_owner;
pub mod insert_folder;
pub mod list_folders_for_user;
pub mod soft_delete_folder;
pub mod update_folder_color_code;
pub mod update_folder_has_star;
pub mod update_folder_name;
@@ -0,0 +1,29 @@
use sqlx::MySqlPool;
use tokens::tokens::folders::FolderToken;
use tokens::tokens::users::UserToken;
/// Mark a folder as deleted by stamping `maybe_deleted_at`. Children
/// retain their `maybe_parent_folder_token` and become "orphaned" — that
/// state is surfaced by the list query's `is_orphaned` flag.
pub async fn soft_delete_folder(
folder_token: &FolderToken,
owner_user_token: &UserToken,
pool: &MySqlPool,
) -> Result<u64, sqlx::Error> {
let result = sqlx::query!(
r#"
UPDATE folders
SET maybe_deleted_at = NOW()
WHERE token = ?
AND owner_user_token = ?
AND maybe_deleted_at IS NULL
LIMIT 1
"#,
folder_token.as_str(),
owner_user_token.as_str(),
)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
@@ -0,0 +1,29 @@
use sqlx::MySqlPool;
use tokens::tokens::folders::FolderToken;
use tokens::tokens::users::UserToken;
/// Set or clear (`None`) the color code on a folder.
pub async fn update_folder_color_code(
folder_token: &FolderToken,
owner_user_token: &UserToken,
maybe_color_code: Option<&str>,
pool: &MySqlPool,
) -> Result<u64, sqlx::Error> {
let result = sqlx::query!(
r#"
UPDATE folders
SET maybe_color_code = ?
WHERE token = ?
AND owner_user_token = ?
AND maybe_deleted_at IS NULL
LIMIT 1
"#,
maybe_color_code,
folder_token.as_str(),
owner_user_token.as_str(),
)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
@@ -0,0 +1,28 @@
use sqlx::MySqlPool;
use tokens::tokens::folders::FolderToken;
use tokens::tokens::users::UserToken;
pub async fn update_folder_has_star(
folder_token: &FolderToken,
owner_user_token: &UserToken,
has_star: bool,
pool: &MySqlPool,
) -> Result<u64, sqlx::Error> {
let result = sqlx::query!(
r#"
UPDATE folders
SET has_star = ?
WHERE token = ?
AND owner_user_token = ?
AND maybe_deleted_at IS NULL
LIMIT 1
"#,
has_star,
folder_token.as_str(),
owner_user_token.as_str(),
)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
@@ -0,0 +1,31 @@
use sqlx::MySqlPool;
use tokens::tokens::folders::FolderToken;
use tokens::tokens::users::UserToken;
/// Rename a folder. Scoped to owner so callers can't rename a folder they
/// don't own. Returns the number of rows updated (0 if no live folder
/// matched).
pub async fn update_folder_name(
folder_token: &FolderToken,
owner_user_token: &UserToken,
new_name: &str,
pool: &MySqlPool,
) -> Result<u64, sqlx::Error> {
let result = sqlx::query!(
r#"
UPDATE folders
SET name = ?
WHERE token = ?
AND owner_user_token = ?
AND maybe_deleted_at IS NULL
LIMIT 1
"#,
new_name,
folder_token.as_str(),
owner_user_token.as_str(),
)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
@@ -0,0 +1,33 @@
use sqlx::{MySql, MySqlPool, QueryBuilder};
use tokens::tokens::folders::FolderToken;
use tokens::tokens::media_files::MediaFileToken;
/// Hard-delete membership rows for the given folder + media file tokens.
/// Idempotent — rows that don't exist are silently skipped.
///
/// Returns the number of rows actually deleted.
pub async fn bulk_delete_folder_media_files(
folder_token: &FolderToken,
media_file_tokens: &[MediaFileToken],
pool: &MySqlPool,
) -> Result<u64, sqlx::Error> {
if media_file_tokens.is_empty() {
return Ok(0);
}
let mut builder = QueryBuilder::<MySql>::new(
"DELETE FROM folder_media_files WHERE folder_token = ",
);
builder.push_bind(folder_token.as_str());
builder.push(" AND media_file_token IN (");
let mut separated = builder.separated(", ");
for token in media_file_tokens {
separated.push_bind(token.as_str());
}
separated.push_unseparated(")");
let result = builder.build().execute(pool).await?;
Ok(result.rows_affected())
}
@@ -0,0 +1,30 @@
use sqlx::{MySql, MySqlPool, QueryBuilder};
use tokens::tokens::folders::FolderToken;
use tokens::tokens::media_files::MediaFileToken;
/// Insert membership rows for every (folder_token, media_file_token) in
/// the input. Uses `INSERT IGNORE` so already-present rows don't cause
/// duplicate-key errors — idempotent.
///
/// Returns the number of rows actually inserted.
pub async fn bulk_insert_folder_media_files(
folder_token: &FolderToken,
media_file_tokens: &[MediaFileToken],
pool: &MySqlPool,
) -> Result<u64, sqlx::Error> {
if media_file_tokens.is_empty() {
return Ok(0);
}
let mut builder = QueryBuilder::<MySql>::new(
"INSERT IGNORE INTO folder_media_files (folder_token, media_file_token) ",
);
builder.push_values(media_file_tokens, |mut b, media_file_token| {
b.push_bind(folder_token.as_str())
.push_bind(media_file_token.as_str());
});
let result = builder.build().execute(pool).await?;
Ok(result.rows_affected())
}
@@ -0,0 +1,32 @@
use sqlx::{MySql, MySqlPool, QueryBuilder, Row};
use tokens::tokens::media_files::MediaFileToken;
/// Given an input set of candidate media-file tokens, return only those
/// that currently exist and aren't soft-deleted (no user_deleted_at or
/// mod_deleted_at).
pub async fn filter_existing_media_file_tokens(
candidate_tokens: &[MediaFileToken],
pool: &MySqlPool,
) -> Result<Vec<MediaFileToken>, sqlx::Error> {
if candidate_tokens.is_empty() {
return Ok(Vec::new());
}
let mut builder = QueryBuilder::<MySql>::new(
"SELECT token FROM media_files WHERE user_deleted_at IS NULL \
AND mod_deleted_at IS NULL AND token IN (",
);
let mut separated = builder.separated(", ");
for token in candidate_tokens {
separated.push_bind(token.as_str());
}
separated.push_unseparated(")");
let rows = builder.build().fetch_all(pool).await?;
Ok(rows.into_iter()
.map(|row| MediaFileToken::new(row.get::<String, _>(0)))
.collect())
}
@@ -0,0 +1,151 @@
use chrono::{DateTime, Utc};
use sqlx::MySqlPool;
use tokens::tokens::folders::FolderToken;
use tokens::tokens::media_files::MediaFileToken;
#[derive(Debug, Clone)]
pub struct FolderMediaFileRow {
/// The `folder_media_files.id` of the membership row. Use this as the
/// cursor for the next page.
pub membership_id: u64,
/// When the media file was added to the folder.
pub added_to_folder_at: DateTime<Utc>,
pub media_file_token: MediaFileToken,
pub media_type: String,
pub media_class: String,
pub maybe_mime_type: Option<String>,
pub public_bucket_directory_hash: String,
pub maybe_public_bucket_prefix: Option<String>,
pub maybe_public_bucket_extension: Option<String>,
pub maybe_frame_width: Option<i32>,
pub maybe_frame_height: Option<i32>,
pub maybe_duration_millis: Option<i32>,
pub maybe_title: Option<String>,
}
pub struct ListFolderMediaFilesArgs<'a> {
pub folder_token: &'a FolderToken,
pub maybe_cursor_id: Option<u64>,
pub limit: u32,
pub pool: &'a MySqlPool,
}
/// Paginated list of media files in the given folder. Joins `media_files`
/// and filters out rows that are soft-deleted on the media-file side
/// (`user_deleted_at` or `mod_deleted_at` set). Most-recently-added first.
///
/// The caller is expected to have already authorized the folder access
/// (i.e. confirmed the folder is owned by the requesting user).
pub async fn list_folder_media_files(
args: ListFolderMediaFilesArgs<'_>,
) -> Result<Vec<FolderMediaFileRow>, sqlx::Error> {
let limit = args.limit as i64;
let rows = match args.maybe_cursor_id {
Some(cursor_id) => {
sqlx::query!(
r#"
SELECT
fmf.id as `membership_id: u64`,
fmf.created_at as `added_to_folder_at: DateTime<Utc>`,
mf.token as `media_file_token: MediaFileToken`,
mf.media_type,
mf.media_class,
mf.maybe_mime_type,
mf.public_bucket_directory_hash,
mf.maybe_public_bucket_prefix,
mf.maybe_public_bucket_extension,
mf.maybe_frame_width,
mf.maybe_frame_height,
mf.maybe_duration_millis,
mf.maybe_title
FROM folder_media_files fmf
JOIN media_files mf
ON mf.token = fmf.media_file_token
WHERE fmf.folder_token = ?
AND mf.user_deleted_at IS NULL
AND mf.mod_deleted_at IS NULL
AND fmf.id < ?
ORDER BY fmf.id DESC
LIMIT ?
"#,
args.folder_token.as_str(),
cursor_id,
limit,
)
.fetch_all(args.pool)
.await?
.into_iter()
.map(|r| FolderMediaFileRow {
membership_id: r.membership_id,
added_to_folder_at: r.added_to_folder_at,
media_file_token: r.media_file_token,
media_type: r.media_type,
media_class: r.media_class,
maybe_mime_type: r.maybe_mime_type,
public_bucket_directory_hash: r.public_bucket_directory_hash,
maybe_public_bucket_prefix: r.maybe_public_bucket_prefix,
maybe_public_bucket_extension: r.maybe_public_bucket_extension,
maybe_frame_width: r.maybe_frame_width,
maybe_frame_height: r.maybe_frame_height,
maybe_duration_millis: r.maybe_duration_millis,
maybe_title: r.maybe_title,
})
.collect::<Vec<_>>()
}
None => {
sqlx::query!(
r#"
SELECT
fmf.id as `membership_id: u64`,
fmf.created_at as `added_to_folder_at: DateTime<Utc>`,
mf.token as `media_file_token: MediaFileToken`,
mf.media_type,
mf.media_class,
mf.maybe_mime_type,
mf.public_bucket_directory_hash,
mf.maybe_public_bucket_prefix,
mf.maybe_public_bucket_extension,
mf.maybe_frame_width,
mf.maybe_frame_height,
mf.maybe_duration_millis,
mf.maybe_title
FROM folder_media_files fmf
JOIN media_files mf
ON mf.token = fmf.media_file_token
WHERE fmf.folder_token = ?
AND mf.user_deleted_at IS NULL
AND mf.mod_deleted_at IS NULL
ORDER BY fmf.id DESC
LIMIT ?
"#,
args.folder_token.as_str(),
limit,
)
.fetch_all(args.pool)
.await?
.into_iter()
.map(|r| FolderMediaFileRow {
membership_id: r.membership_id,
added_to_folder_at: r.added_to_folder_at,
media_file_token: r.media_file_token,
media_type: r.media_type,
media_class: r.media_class,
maybe_mime_type: r.maybe_mime_type,
public_bucket_directory_hash: r.public_bucket_directory_hash,
maybe_public_bucket_prefix: r.maybe_public_bucket_prefix,
maybe_public_bucket_extension: r.maybe_public_bucket_extension,
maybe_frame_width: r.maybe_frame_width,
maybe_frame_height: r.maybe_frame_height,
maybe_duration_millis: r.maybe_duration_millis,
maybe_title: r.maybe_title,
})
.collect::<Vec<_>>()
}
};
Ok(rows)
}
@@ -0,0 +1,4 @@
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;
@@ -0,0 +1,3 @@
pub mod folder;
pub mod media_files;
pub mod subfolder;
@@ -0,0 +1,39 @@
use sqlx::{MySql, MySqlPool, QueryBuilder};
use tokens::tokens::folders::FolderToken;
use tokens::tokens::users::UserToken;
/// Clear `maybe_parent_folder_token` (set to NULL) on every owned, live
/// folder whose token is in `child_tokens` AND whose current parent is
/// the given `from_parent_token`. The parent guard makes the operation
/// safe to run idempotently — it won't accidentally unparent a folder
/// that's actually a child of a different parent.
///
/// Returns the number of rows affected.
pub async fn bulk_clear_parent_folder(
child_tokens: &[FolderToken],
from_parent_token: &FolderToken,
owner_user_token: &UserToken,
pool: &MySqlPool,
) -> Result<u64, sqlx::Error> {
if child_tokens.is_empty() {
return Ok(0);
}
let mut builder = QueryBuilder::<MySql>::new(
"UPDATE folders SET maybe_parent_folder_token = NULL WHERE owner_user_token = ",
);
builder.push_bind(owner_user_token.as_str());
builder.push(" AND maybe_deleted_at IS NULL AND maybe_parent_folder_token = ");
builder.push_bind(from_parent_token.as_str());
builder.push(" AND token IN (");
let mut separated = builder.separated(", ");
for token in child_tokens {
separated.push_bind(token.as_str());
}
separated.push_unseparated(")");
let result = builder.build().execute(pool).await?;
Ok(result.rows_affected())
}
@@ -0,0 +1,43 @@
use sqlx::{MySql, MySqlPool, QueryBuilder};
use tokens::tokens::folders::FolderToken;
use tokens::tokens::users::UserToken;
/// Set `maybe_parent_folder_token = new_parent_token` on every owned,
/// live folder whose token is in `child_tokens` AND that isn't the parent
/// itself (a folder can't be its own parent).
///
/// Returns the number of rows affected.
///
/// Idempotent: if a row already has the desired parent, MySQL reports it
/// as not-changed but still as matched — `rows_affected()` reflects the
/// number of rows that actually changed.
pub async fn bulk_set_parent_folder(
child_tokens: &[FolderToken],
new_parent_token: &FolderToken,
owner_user_token: &UserToken,
pool: &MySqlPool,
) -> Result<u64, sqlx::Error> {
if child_tokens.is_empty() {
return Ok(0);
}
let mut builder = QueryBuilder::<MySql>::new(
"UPDATE folders SET maybe_parent_folder_token = ",
);
builder.push_bind(new_parent_token.as_str());
builder.push(" WHERE owner_user_token = ");
builder.push_bind(owner_user_token.as_str());
builder.push(" AND maybe_deleted_at IS NULL AND token != ");
builder.push_bind(new_parent_token.as_str());
builder.push(" AND token IN (");
let mut separated = builder.separated(", ");
for token in child_tokens {
separated.push_bind(token.as_str());
}
separated.push_unseparated(")");
let result = builder.build().execute(pool).await?;
Ok(result.rows_affected())
}
@@ -0,0 +1,38 @@
use sqlx::{MySql, MySqlPool, QueryBuilder, Row};
use tokens::tokens::folders::FolderToken;
use tokens::tokens::users::UserToken;
/// Given an input set of candidate folder tokens, return only those that
/// currently exist, are owned by the given user, and are not soft-deleted.
/// Order of the result is unspecified.
///
/// Uses runtime `QueryBuilder` because the IN-list size is dynamic and the
/// macro form can't express that.
pub async fn filter_existing_owned_folder_tokens(
candidate_tokens: &[FolderToken],
owner_user_token: &UserToken,
pool: &MySqlPool,
) -> Result<Vec<FolderToken>, sqlx::Error> {
if candidate_tokens.is_empty() {
return Ok(Vec::new());
}
let mut builder = QueryBuilder::<MySql>::new(
"SELECT token FROM folders WHERE owner_user_token = ",
);
builder.push_bind(owner_user_token.as_str());
builder.push(" AND maybe_deleted_at IS NULL AND token IN (");
let mut separated = builder.separated(", ");
for token in candidate_tokens {
separated.push_bind(token.as_str());
}
separated.push_unseparated(")");
let rows = builder.build().fetch_all(pool).await?;
Ok(rows.into_iter()
.map(|row| FolderToken::new(row.get::<String, _>(0)))
.collect())
}
@@ -0,0 +1,138 @@
use sqlx::MySqlPool;
use tokens::tokens::folders::FolderToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::users::UserToken;
use crate::queries::folders::folder::folder_row::FolderRow;
pub struct ListSubfoldersArgs<'a> {
pub parent_folder_token: &'a FolderToken,
pub owner_user_token: &'a UserToken,
pub maybe_cursor_id: Option<u64>,
pub limit: u32,
pub pool: &'a MySqlPool,
}
/// Paginated list of live folders whose `maybe_parent_folder_token` is
/// the given parent. Scoped to the owner to avoid leaking siblings of
/// folders owned by another user.
///
/// The `is_orphaned` flag will be `false` for all results: by definition,
/// the parent matched here so it exists. (A leftover, soft-deleted parent
/// is still treated as "parent exists" — that case is rare and not the
/// caller's concern for this endpoint.)
pub async fn list_subfolders(
args: ListSubfoldersArgs<'_>,
) -> Result<Vec<FolderRow>, sqlx::Error> {
let limit = args.limit as i64;
let rows = match args.maybe_cursor_id {
Some(cursor_id) => {
sqlx::query!(
r#"
SELECT
f.id as `id: u64`,
f.token as `token: FolderToken`,
f.name,
f.owner_user_token as `owner_user_token: UserToken`,
f.maybe_parent_folder_token as `maybe_parent_folder_token: FolderToken`,
f.maybe_last_media_file_token_1 as `maybe_last_media_file_token_1: MediaFileToken`,
f.maybe_last_media_file_token_2 as `maybe_last_media_file_token_2: MediaFileToken`,
f.maybe_last_media_file_token_3 as `maybe_last_media_file_token_3: MediaFileToken`,
f.maybe_last_media_file_token_4 as `maybe_last_media_file_token_4: MediaFileToken`,
f.maybe_cover_image_custom_media_token as `maybe_cover_image_custom_media_token: MediaFileToken`,
f.maybe_color_code,
f.has_star as `has_star: bool`,
f.created_at,
f.updated_at
FROM folders f
WHERE f.maybe_parent_folder_token = ?
AND f.owner_user_token = ?
AND f.maybe_deleted_at IS NULL
AND f.id < ?
ORDER BY f.id DESC
LIMIT ?
"#,
args.parent_folder_token.as_str(),
args.owner_user_token.as_str(),
cursor_id,
limit,
)
.fetch_all(args.pool)
.await?
.into_iter()
.map(|r| FolderRow {
id: r.id,
token: r.token,
name: r.name,
owner_user_token: r.owner_user_token,
maybe_parent_folder_token: r.maybe_parent_folder_token,
maybe_last_media_file_token_1: r.maybe_last_media_file_token_1,
maybe_last_media_file_token_2: r.maybe_last_media_file_token_2,
maybe_last_media_file_token_3: r.maybe_last_media_file_token_3,
maybe_last_media_file_token_4: r.maybe_last_media_file_token_4,
maybe_cover_image_custom_media_token: r.maybe_cover_image_custom_media_token,
maybe_color_code: r.maybe_color_code,
has_star: r.has_star,
created_at: r.created_at,
updated_at: r.updated_at,
is_orphaned: false,
})
.collect::<Vec<_>>()
}
None => {
sqlx::query!(
r#"
SELECT
f.id as `id: u64`,
f.token as `token: FolderToken`,
f.name,
f.owner_user_token as `owner_user_token: UserToken`,
f.maybe_parent_folder_token as `maybe_parent_folder_token: FolderToken`,
f.maybe_last_media_file_token_1 as `maybe_last_media_file_token_1: MediaFileToken`,
f.maybe_last_media_file_token_2 as `maybe_last_media_file_token_2: MediaFileToken`,
f.maybe_last_media_file_token_3 as `maybe_last_media_file_token_3: MediaFileToken`,
f.maybe_last_media_file_token_4 as `maybe_last_media_file_token_4: MediaFileToken`,
f.maybe_cover_image_custom_media_token as `maybe_cover_image_custom_media_token: MediaFileToken`,
f.maybe_color_code,
f.has_star as `has_star: bool`,
f.created_at,
f.updated_at
FROM folders f
WHERE f.maybe_parent_folder_token = ?
AND f.owner_user_token = ?
AND f.maybe_deleted_at IS NULL
ORDER BY f.id DESC
LIMIT ?
"#,
args.parent_folder_token.as_str(),
args.owner_user_token.as_str(),
limit,
)
.fetch_all(args.pool)
.await?
.into_iter()
.map(|r| FolderRow {
id: r.id,
token: r.token,
name: r.name,
owner_user_token: r.owner_user_token,
maybe_parent_folder_token: r.maybe_parent_folder_token,
maybe_last_media_file_token_1: r.maybe_last_media_file_token_1,
maybe_last_media_file_token_2: r.maybe_last_media_file_token_2,
maybe_last_media_file_token_3: r.maybe_last_media_file_token_3,
maybe_last_media_file_token_4: r.maybe_last_media_file_token_4,
maybe_cover_image_custom_media_token: r.maybe_cover_image_custom_media_token,
maybe_color_code: r.maybe_color_code,
has_star: r.has_star,
created_at: r.created_at,
updated_at: r.updated_at,
is_orphaned: false,
})
.collect::<Vec<_>>()
}
};
Ok(rows)
}
@@ -0,0 +1,4 @@
pub mod bulk_clear_parent_folder;
pub mod bulk_set_parent_folder;
pub mod filter_existing_owned_folder_tokens;
pub mod list_subfolders;
@@ -13,6 +13,7 @@ pub mod database_time;
pub mod email_sender_jobs;
pub mod entity_stats;
pub mod featured_items;
pub mod folders;
pub mod generic_download;
pub mod generic_inference;
pub mod generic_synthetic_ids;
@@ -94,6 +94,25 @@ use crate::http_server::endpoints::moderation::staff_audit_logs::moderator_list_
use crate::http_server::endpoints::moderation::user_bans::moderation_ban_user_handler::*;
use crate::http_server::endpoints::moderation::user_emails::moderator_change_user_email_handler::*;
use crate::http_server::endpoints::moderation::user_emails::moderator_list_email_address_changes_for_user_handler::*;
// Folder endpoints
use crate::http_server::endpoints::media_files::folder::color_code_folder_handler::*;
use crate::http_server::endpoints::media_files::folder::create_folder_handler::*;
use crate::http_server::endpoints::media_files::folder::delete_folder_handler::*;
use crate::http_server::endpoints::media_files::folder::get_folder_handler::*;
use crate::http_server::endpoints::media_files::folder::list_folders_handler::*;
use crate::http_server::endpoints::media_files::folder::rename_folder_handler::*;
use crate::http_server::endpoints::media_files::folder::star_folder_handler::*;
use crate::http_server::endpoints::media_files::media_files::bulk_add_folder_media_files_handler::*;
use crate::http_server::endpoints::media_files::media_files::bulk_remove_folder_media_files_handler::*;
use crate::http_server::endpoints::media_files::media_files::list_folder_media_files_handler::*;
use crate::http_server::endpoints::media_files::subfolder::bulk_add_subfolders_handler::*;
use crate::http_server::endpoints::media_files::subfolder::bulk_remove_subfolders_handler::*;
use crate::http_server::endpoints::media_files::subfolder::list_subfolders_handler::*;
use artcraft_api_defs::folders::common::*;
use artcraft_api_defs::folders::folder::*;
use artcraft_api_defs::folders::media_files::*;
use artcraft_api_defs::folders::subfolder::*;
use artcraft_api_defs::moderation::user_referrals::list_global_user_referrals::*;
use artcraft_api_defs::moderation::user_referrals::list_user_referrals_for_user::*;
use crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_for_user_handler::*;
@@ -535,8 +554,23 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
crate::http_server::endpoints::moderation::user_referrals::moderator_list_global_user_referrals_handler::moderator_list_global_user_referrals_handler,
crate::http_server::endpoints::moderation::user_referrals::moderator_list_user_referrals_for_user_handler::moderator_list_user_referrals_for_user_handler,
crate::http_server::endpoints::moderation::user_bans::moderation_ban_user_handler::moderation_ban_user_handler,
crate::http_server::endpoints::moderation::user_emails::moderator_change_user_email::moderator_change_user_email_handler,
crate::http_server::endpoints::moderation::user_emails::moderator_list_email_address_changes_for_user::moderator_list_email_address_changes_for_user_handler,
crate::http_server::endpoints::moderation::user_emails::moderator_change_user_email_handler::moderator_change_user_email_handler,
crate::http_server::endpoints::moderation::user_emails::moderator_list_email_address_changes_for_user_handler::moderator_list_email_address_changes_for_user_handler,
// Folders
crate::http_server::endpoints::media_files::folder::create_folder_handler::create_folder_handler,
crate::http_server::endpoints::media_files::folder::list_folders_handler::list_folders_handler,
crate::http_server::endpoints::media_files::folder::get_folder_handler::get_folder_handler,
crate::http_server::endpoints::media_files::folder::rename_folder_handler::rename_folder_handler,
crate::http_server::endpoints::media_files::folder::star_folder_handler::star_folder_handler,
crate::http_server::endpoints::media_files::folder::color_code_folder_handler::color_code_folder_handler,
crate::http_server::endpoints::media_files::folder::delete_folder_handler::delete_folder_handler,
crate::http_server::endpoints::media_files::subfolder::list_subfolders_handler::list_subfolders_handler,
crate::http_server::endpoints::media_files::subfolder::bulk_add_subfolders_handler::bulk_add_subfolders_handler,
crate::http_server::endpoints::media_files::subfolder::bulk_remove_subfolders_handler::bulk_remove_subfolders_handler,
crate::http_server::endpoints::media_files::media_files::list_folder_media_files_handler::list_folder_media_files_handler,
crate::http_server::endpoints::media_files::media_files::bulk_add_folder_media_files_handler::bulk_add_folder_media_files_handler,
crate::http_server::endpoints::media_files::media_files::bulk_remove_folder_media_files_handler::bulk_remove_folder_media_files_handler,
crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_for_user_handler::moderator_list_user_session_impersonation_requests_for_user_handler,
crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_handler::moderator_list_user_session_impersonation_requests_handler,
crate::http_server::endpoints::moderation::user_sessions::moderator_user_session_impersonation_request_handler::moderator_user_session_impersonation_request_handler,
@@ -1205,6 +1239,37 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
// User Emails (Moderation)
ModeratorListUserEmailChangesQueryParams,
// Folders
FolderInfo,
FolderMediaFileInfo,
FolderPathInfo,
FolderMediaFilesPathInfo,
SubfolderPathInfo,
CreateFolderRequest,
CreateFolderSuccessResponse,
ListFoldersQueryParams,
ListFoldersSuccessResponse,
GetFolderSuccessResponse,
RenameFolderRequest,
RenameFolderSuccessResponse,
SetFolderStarRequest,
SetFolderStarSuccessResponse,
SetFolderColorCodeRequest,
SetFolderColorCodeSuccessResponse,
DeleteFolderSuccessResponse,
ListSubfoldersQueryParams,
ListSubfoldersSuccessResponse,
BulkAddSubfoldersRequest,
BulkAddSubfoldersSuccessResponse,
BulkRemoveSubfoldersRequest,
BulkRemoveSubfoldersSuccessResponse,
ListFolderMediaFilesQueryParams,
ListFolderMediaFilesSuccessResponse,
BulkAddFolderMediaFilesRequest,
BulkAddFolderMediaFilesSuccessResponse,
BulkRemoveFolderMediaFilesRequest,
BulkRemoveFolderMediaFilesSuccessResponse,
))
)]
pub struct ApiDoc;
@@ -0,0 +1,80 @@
use std::sync::Arc;
use actix_web::web::{Json, Path};
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::folders::folder::{
FolderPathInfo, SetFolderColorCodeRequest, SetFolderColorCodeSuccessResponse,
};
use mysql_queries::queries::folders::folder::update_folder_color_code::update_folder_color_code;
use tokens::tokens::folders::FolderToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::state::server_state::ServerState;
const MAX_COLOR_CODE_LEN: usize = 16;
#[utoipa::path(
put,
tag = "Folders",
path = "/v1/folders/folder/{folder_token}/color_code",
params(("folder_token" = String, description = "Folder token")),
request_body = SetFolderColorCodeRequest,
responses(
(status = 200, body = SetFolderColorCodeSuccessResponse),
(status = 400, body = CommonWebError),
(status = 401, body = CommonWebError),
(status = 404, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn color_code_folder_handler(
http_request: HttpRequest,
path: Path<FolderPathInfo>,
request: Json<SetFolderColorCodeRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<SetFolderColorCodeSuccessResponse>, 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_using_connection(
&http_request, &server_state.session_checker, &mut conn,
).await.map_err(|_| CommonWebError::NotAuthorized)?;
// Normalize: trim, treat empty after trim as `None`. This lets clients
// clear by passing `""` or by omitting the field entirely.
let trimmed: Option<String> = request.maybe_color_code.as_ref().and_then(|s| {
let t = s.trim();
if t.is_empty() { None } else { Some(t.to_string()) }
});
if let Some(code) = trimmed.as_deref() {
if code.len() > MAX_COLOR_CODE_LEN {
return Err(CommonWebError::BadInputWithSimpleMessage(
format!("color_code too long (max {} chars)", MAX_COLOR_CODE_LEN),
));
}
}
let folder_token = FolderToken::new_from_str(path.folder_token.trim());
let rows_affected = update_folder_color_code(
&folder_token,
&user_session.user_token,
trimmed.as_deref(),
&server_state.mysql_pool,
).await.map_err(|err| {
warn!("update_folder_color_code failed: {:?}", err);
CommonWebError::from_error(err)
})?;
if rows_affected == 0 {
return Err(CommonWebError::NotFound);
}
Ok(Json(SetFolderColorCodeSuccessResponse { success: true }))
}
@@ -0,0 +1,100 @@
use std::sync::Arc;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::folders::common::FolderInfo;
use artcraft_api_defs::folders::folder::{CreateFolderRequest, CreateFolderSuccessResponse};
use mysql_queries::queries::folders::folder::get_folder_for_owner::get_folder_for_owner;
use mysql_queries::queries::folders::folder::insert_folder::{insert_folder, InsertFolderArgs};
use tokens::tokens::folders::FolderToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::media_files::folder::folder_info_conversion::folder_row_to_info;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::state::server_state::ServerState;
const MAX_NAME_LEN: usize = 255;
/// Create a new folder owned by the logged-in user.
#[utoipa::path(
post,
tag = "Folders",
path = "/v1/folders/create",
request_body = CreateFolderRequest,
responses(
(status = 200, body = CreateFolderSuccessResponse),
(status = 400, body = CommonWebError),
(status = 401, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn create_folder_handler(
http_request: HttpRequest,
request: Json<CreateFolderRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<CreateFolderSuccessResponse>, 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_using_connection(
&http_request, &server_state.session_checker, &mut conn,
).await.map_err(|_| CommonWebError::NotAuthorized)?;
let name = request.name.trim().to_string();
if name.is_empty() {
return Err(CommonWebError::BadInputWithSimpleMessage("name is empty".to_string()));
}
if name.len() > MAX_NAME_LEN {
return Err(CommonWebError::BadInputWithSimpleMessage(
format!("name too long (max {} chars)", MAX_NAME_LEN),
));
}
// If a parent was supplied, require it to exist + be owned by the caller.
if let Some(parent_token) = &request.maybe_parent_folder_token {
let parent = get_folder_for_owner(parent_token, &user_session.user_token, &server_state.mysql_pool)
.await
.map_err(|err| {
warn!("Parent folder lookup failed: {:?}", err);
CommonWebError::from_error(err)
})?;
if parent.is_none() {
return Err(CommonWebError::BadInputWithSimpleMessage(
"parent folder does not exist".to_string(),
));
}
}
let token = FolderToken::generate();
insert_folder(InsertFolderArgs {
token: &token,
name: &name,
owner_user_token: &user_session.user_token,
maybe_parent_folder_token: request.maybe_parent_folder_token.as_ref(),
maybe_color_code: request.maybe_color_code.as_deref(),
}, &server_state.mysql_pool).await.map_err(|err| {
warn!("insert_folder failed: {:?}", err);
CommonWebError::from_error(err)
})?;
// Re-fetch so we return the canonical FolderInfo including is_orphaned.
let row = get_folder_for_owner(&token, &user_session.user_token, &server_state.mysql_pool)
.await
.map_err(|err| {
warn!("Post-insert folder fetch failed: {:?}", err);
CommonWebError::from_error(err)
})?
.ok_or_else(|| CommonWebError::server_error_with_message("folder vanished after insert"))?;
let folder: FolderInfo = folder_row_to_info(row);
Ok(Json(CreateFolderSuccessResponse {
success: true,
folder,
}))
}
@@ -0,0 +1,59 @@
use std::sync::Arc;
use actix_web::web::{Json, Path};
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::folders::folder::{DeleteFolderSuccessResponse, FolderPathInfo};
use mysql_queries::queries::folders::folder::soft_delete_folder::soft_delete_folder;
use tokens::tokens::folders::FolderToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::state::server_state::ServerState;
/// Soft-delete a folder. Children retain their parent pointer and become
/// "orphaned" — surfaced by the list query's `is_orphaned` flag.
#[utoipa::path(
delete,
tag = "Folders",
path = "/v1/folders/folder/{folder_token}",
params(("folder_token" = String, description = "Folder token")),
responses(
(status = 200, body = DeleteFolderSuccessResponse),
(status = 401, body = CommonWebError),
(status = 404, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn delete_folder_handler(
http_request: HttpRequest,
path: Path<FolderPathInfo>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<DeleteFolderSuccessResponse>, 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_using_connection(
&http_request, &server_state.session_checker, &mut conn,
).await.map_err(|_| CommonWebError::NotAuthorized)?;
let folder_token = FolderToken::new_from_str(path.folder_token.trim());
let rows_affected = soft_delete_folder(
&folder_token,
&user_session.user_token,
&server_state.mysql_pool,
).await.map_err(|err| {
warn!("soft_delete_folder failed: {:?}", err);
CommonWebError::from_error(err)
})?;
if rows_affected == 0 {
return Err(CommonWebError::NotFound);
}
Ok(Json(DeleteFolderSuccessResponse { success: true }))
}
@@ -0,0 +1,39 @@
use artcraft_api_defs::folders::common::{FolderInfo, FolderMediaFileInfo};
use mysql_queries::queries::folders::folder::folder_row::FolderRow;
use mysql_queries::queries::folders::media_files::list_folder_media_files::FolderMediaFileRow;
pub fn folder_row_to_info(row: FolderRow) -> FolderInfo {
FolderInfo {
token: row.token,
name: row.name,
owner_user_token: row.owner_user_token,
maybe_parent_folder_token: row.maybe_parent_folder_token,
maybe_last_media_file_token_1: row.maybe_last_media_file_token_1,
maybe_last_media_file_token_2: row.maybe_last_media_file_token_2,
maybe_last_media_file_token_3: row.maybe_last_media_file_token_3,
maybe_last_media_file_token_4: row.maybe_last_media_file_token_4,
maybe_cover_image_custom_media_token: row.maybe_cover_image_custom_media_token,
maybe_color_code: row.maybe_color_code,
has_star: row.has_star,
created_at: row.created_at,
updated_at: row.updated_at,
is_orphaned: row.is_orphaned,
}
}
pub fn folder_media_file_row_to_info(row: FolderMediaFileRow) -> FolderMediaFileInfo {
FolderMediaFileInfo {
media_file_token: row.media_file_token,
added_to_folder_at: row.added_to_folder_at,
media_type: row.media_type,
media_class: row.media_class,
maybe_mime_type: row.maybe_mime_type,
public_bucket_directory_hash: row.public_bucket_directory_hash,
maybe_public_bucket_prefix: row.maybe_public_bucket_prefix,
maybe_public_bucket_extension: row.maybe_public_bucket_extension,
maybe_frame_width: row.maybe_frame_width,
maybe_frame_height: row.maybe_frame_height,
maybe_duration_millis: row.maybe_duration_millis,
maybe_title: row.maybe_title,
}
}
@@ -0,0 +1,57 @@
use std::sync::Arc;
use actix_web::web::{Json, Path};
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::folders::folder::{FolderPathInfo, GetFolderSuccessResponse};
use mysql_queries::queries::folders::folder::get_folder_for_owner::get_folder_for_owner;
use tokens::tokens::folders::FolderToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::media_files::folder::folder_info_conversion::folder_row_to_info;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::state::server_state::ServerState;
/// Fetch a single folder owned by the logged-in user.
#[utoipa::path(
get,
tag = "Folders",
path = "/v1/folders/folder/{folder_token}",
params(("folder_token" = String, description = "Folder token")),
responses(
(status = 200, body = GetFolderSuccessResponse),
(status = 401, body = CommonWebError),
(status = 404, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn get_folder_handler(
http_request: HttpRequest,
path: Path<FolderPathInfo>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<GetFolderSuccessResponse>, 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_using_connection(
&http_request, &server_state.session_checker, &mut conn,
).await.map_err(|_| CommonWebError::NotAuthorized)?;
let folder_token = FolderToken::new_from_str(path.folder_token.trim());
let row = get_folder_for_owner(&folder_token, &user_session.user_token, &server_state.mysql_pool)
.await
.map_err(|err| {
warn!("get_folder_for_owner failed: {:?}", err);
CommonWebError::from_error(err)
})?
.ok_or(CommonWebError::NotFound)?;
Ok(Json(GetFolderSuccessResponse {
success: true,
folder: folder_row_to_info(row),
}))
}
@@ -0,0 +1,90 @@
use std::sync::Arc;
use actix_web::web::{Json, Query};
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::folders::folder::{
ListFoldersQueryParams, ListFoldersSuccessResponse,
};
use mysql_queries::queries::folders::folder::list_folders_for_user::{
list_folders_for_user, ListFoldersForUserArgs,
};
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::media_files::folder::folder_info_conversion::folder_row_to_info;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::state::server_state::ServerState;
const CURSOR_NAME: &str = "folders_all";
const DEFAULT_LIMIT: u32 = 100;
const MAX_LIMIT: u32 = 1000;
/// List all live folders owned by the logged-in user. Newest first.
/// Includes the derived `is_orphaned` flag.
#[utoipa::path(
get,
tag = "Folders",
path = "/v1/folders/list_all",
params(ListFoldersQueryParams),
responses(
(status = 200, body = ListFoldersSuccessResponse),
(status = 400, body = CommonWebError),
(status = 401, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn list_folders_handler(
http_request: HttpRequest,
query: Query<ListFoldersQueryParams>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ListFoldersSuccessResponse>, 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_using_connection(
&http_request, &server_state.session_checker, &mut conn,
).await.map_err(|_| CommonWebError::NotAuthorized)?;
let limit = query.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
let maybe_cursor_id = match &query.cursor {
None => None,
Some(cursor_str) => {
let decoded = server_state.opaque_cursors
.decode_cursor_expecting_name(CURSOR_NAME, cursor_str)
.map_err(|err| {
warn!("Failed to decode cursor: {:?}", err);
CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string())
})?;
decoded.last_id
}
};
let rows = list_folders_for_user(ListFoldersForUserArgs {
owner_user_token: &user_session.user_token,
maybe_cursor_id,
limit,
pool: &server_state.mysql_pool,
}).await.map_err(|err| {
warn!("list_folders_for_user failed: {:?}", err);
CommonWebError::from_error(err)
})?;
let maybe_cursor = rows.last().map(|last| {
server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.id)
}).transpose().map_err(|err| {
warn!("Failed to encode cursor: {:?}", err);
CommonWebError::server_error_with_message("Failed to encode cursor")
})?;
let folders = rows.into_iter().map(folder_row_to_info).collect();
Ok(Json(ListFoldersSuccessResponse {
success: true,
folders,
maybe_cursor,
}))
}
@@ -0,0 +1,8 @@
pub mod color_code_folder_handler;
pub mod create_folder_handler;
pub mod delete_folder_handler;
pub mod folder_info_conversion;
pub mod get_folder_handler;
pub mod list_folders_handler;
pub mod rename_folder_handler;
pub mod star_folder_handler;
@@ -0,0 +1,78 @@
use std::sync::Arc;
use actix_web::web::{Json, Path};
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::folders::folder::{
FolderPathInfo, RenameFolderRequest, RenameFolderSuccessResponse,
};
use mysql_queries::queries::folders::folder::update_folder_name::update_folder_name;
use tokens::tokens::folders::FolderToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::state::server_state::ServerState;
const MAX_NAME_LEN: usize = 255;
#[utoipa::path(
put,
tag = "Folders",
path = "/v1/folders/folder/{folder_token}/rename",
params(("folder_token" = String, description = "Folder token")),
request_body = RenameFolderRequest,
responses(
(status = 200, body = RenameFolderSuccessResponse),
(status = 400, body = CommonWebError),
(status = 401, body = CommonWebError),
(status = 404, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn rename_folder_handler(
http_request: HttpRequest,
path: Path<FolderPathInfo>,
request: Json<RenameFolderRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<RenameFolderSuccessResponse>, 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_using_connection(
&http_request, &server_state.session_checker, &mut conn,
).await.map_err(|_| CommonWebError::NotAuthorized)?;
let new_name = request.new_name.trim().to_string();
if new_name.is_empty() {
return Err(CommonWebError::BadInputWithSimpleMessage("name is empty".to_string()));
}
if new_name.len() > MAX_NAME_LEN {
return Err(CommonWebError::BadInputWithSimpleMessage(
format!("name too long (max {} chars)", MAX_NAME_LEN),
));
}
let folder_token = FolderToken::new_from_str(path.folder_token.trim());
let rows_affected = update_folder_name(
&folder_token,
&user_session.user_token,
&new_name,
&server_state.mysql_pool,
).await.map_err(|err| {
warn!("update_folder_name failed: {:?}", err);
CommonWebError::from_error(err)
})?;
if rows_affected == 0 {
// No live row matched the (token, owner) pair — either it doesn't
// exist, is owned by someone else, or is soft-deleted. Don't leak
// which.
return Err(CommonWebError::NotFound);
}
Ok(Json(RenameFolderSuccessResponse { success: true }))
}
@@ -0,0 +1,62 @@
use std::sync::Arc;
use actix_web::web::{Json, Path};
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::folders::folder::{
FolderPathInfo, SetFolderStarRequest, SetFolderStarSuccessResponse,
};
use mysql_queries::queries::folders::folder::update_folder_has_star::update_folder_has_star;
use tokens::tokens::folders::FolderToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::state::server_state::ServerState;
#[utoipa::path(
put,
tag = "Folders",
path = "/v1/folders/folder/{folder_token}/star",
params(("folder_token" = String, description = "Folder token")),
request_body = SetFolderStarRequest,
responses(
(status = 200, body = SetFolderStarSuccessResponse),
(status = 401, body = CommonWebError),
(status = 404, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn star_folder_handler(
http_request: HttpRequest,
path: Path<FolderPathInfo>,
request: Json<SetFolderStarRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<SetFolderStarSuccessResponse>, 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_using_connection(
&http_request, &server_state.session_checker, &mut conn,
).await.map_err(|_| CommonWebError::NotAuthorized)?;
let folder_token = FolderToken::new_from_str(path.folder_token.trim());
let rows_affected = update_folder_has_star(
&folder_token,
&user_session.user_token,
request.has_star,
&server_state.mysql_pool,
).await.map_err(|err| {
warn!("update_folder_has_star failed: {:?}", err);
CommonWebError::from_error(err)
})?;
if rows_affected == 0 {
return Err(CommonWebError::NotFound);
}
Ok(Json(SetFolderStarSuccessResponse { success: true }))
}
@@ -0,0 +1,98 @@
use std::sync::Arc;
use actix_web::web::{Json, Path};
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::folders::media_files::{
BulkAddFolderMediaFilesRequest, BulkAddFolderMediaFilesSuccessResponse,
FolderMediaFilesPathInfo,
};
use mysql_queries::queries::folders::folder::get_folder_for_owner::get_folder_for_owner;
use mysql_queries::queries::folders::media_files::bulk_insert_folder_media_files::bulk_insert_folder_media_files;
use mysql_queries::queries::folders::media_files::filter_existing_media_file_tokens::filter_existing_media_file_tokens;
use tokens::tokens::folders::FolderToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::state::server_state::ServerState;
const MAX_BULK: usize = 500;
/// Bulk-add media files to a folder. Idempotent via INSERT IGNORE on the
/// composite primary key. Media-file tokens that don't exist or are
/// soft-deleted are silently skipped; the response lists the accepted
/// tokens.
#[utoipa::path(
put,
tag = "Folders",
path = "/v1/folders/media_files/{folder_token}/bulk_add",
params(("folder_token" = String, description = "Folder token")),
request_body = BulkAddFolderMediaFilesRequest,
responses(
(status = 200, body = BulkAddFolderMediaFilesSuccessResponse),
(status = 400, body = CommonWebError),
(status = 401, body = CommonWebError),
(status = 404, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn bulk_add_folder_media_files_handler(
http_request: HttpRequest,
path: Path<FolderMediaFilesPathInfo>,
request: Json<BulkAddFolderMediaFilesRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<BulkAddFolderMediaFilesSuccessResponse>, 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_using_connection(
&http_request, &server_state.session_checker, &mut conn,
).await.map_err(|_| CommonWebError::NotAuthorized)?;
if request.media_file_tokens.len() > MAX_BULK {
return Err(CommonWebError::BadInputWithSimpleMessage(
format!("too many media files in one request (max {})", MAX_BULK),
));
}
let folder_token = FolderToken::new_from_str(path.folder_token.trim());
// Confirm the folder exists + is owned.
let folder = get_folder_for_owner(&folder_token, &user_session.user_token, &server_state.mysql_pool)
.await
.map_err(|err| {
warn!("Folder lookup failed: {:?}", err);
CommonWebError::from_error(err)
})?;
if folder.is_none() {
return Err(CommonWebError::NotFound);
}
// Filter to media files that actually exist + aren't deleted.
let accepted = filter_existing_media_file_tokens(
&request.media_file_tokens,
&server_state.mysql_pool,
).await.map_err(|err| {
warn!("filter_existing_media_file_tokens failed: {:?}", err);
CommonWebError::from_error(err)
})?;
if !accepted.is_empty() {
bulk_insert_folder_media_files(
&folder_token,
&accepted,
&server_state.mysql_pool,
).await.map_err(|err| {
warn!("bulk_insert_folder_media_files failed: {:?}", err);
CommonWebError::from_error(err)
})?;
}
Ok(Json(BulkAddFolderMediaFilesSuccessResponse {
success: true,
accepted_media_file_tokens: accepted,
}))
}
@@ -0,0 +1,86 @@
use std::sync::Arc;
use actix_web::web::{Json, Path};
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::folders::media_files::{
BulkRemoveFolderMediaFilesRequest, BulkRemoveFolderMediaFilesSuccessResponse,
FolderMediaFilesPathInfo,
};
use mysql_queries::queries::folders::folder::get_folder_for_owner::get_folder_for_owner;
use mysql_queries::queries::folders::media_files::bulk_delete_folder_media_files::bulk_delete_folder_media_files;
use tokens::tokens::folders::FolderToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::state::server_state::ServerState;
const MAX_BULK: usize = 500;
/// Bulk-remove media files from a folder. Hard-deletes the membership
/// rows; idempotent (rows that don't exist are silently skipped).
#[utoipa::path(
put,
tag = "Folders",
path = "/v1/folders/media_files/{folder_token}/bulk_remove",
params(("folder_token" = String, description = "Folder token")),
request_body = BulkRemoveFolderMediaFilesRequest,
responses(
(status = 200, body = BulkRemoveFolderMediaFilesSuccessResponse),
(status = 400, body = CommonWebError),
(status = 401, body = CommonWebError),
(status = 404, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn bulk_remove_folder_media_files_handler(
http_request: HttpRequest,
path: Path<FolderMediaFilesPathInfo>,
request: Json<BulkRemoveFolderMediaFilesRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<BulkRemoveFolderMediaFilesSuccessResponse>, 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_using_connection(
&http_request, &server_state.session_checker, &mut conn,
).await.map_err(|_| CommonWebError::NotAuthorized)?;
if request.media_file_tokens.len() > MAX_BULK {
return Err(CommonWebError::BadInputWithSimpleMessage(
format!("too many media files in one request (max {})", MAX_BULK),
));
}
let folder_token = FolderToken::new_from_str(path.folder_token.trim());
// Confirm the folder exists + is owned. We don't strictly need this for
// correctness (the DELETE's folder_token guard suffices), but it keeps
// the 404 vs 200 semantics consistent with the rest of the API.
let folder = get_folder_for_owner(&folder_token, &user_session.user_token, &server_state.mysql_pool)
.await
.map_err(|err| {
warn!("Folder lookup failed: {:?}", err);
CommonWebError::from_error(err)
})?;
if folder.is_none() {
return Err(CommonWebError::NotFound);
}
let removed_count = bulk_delete_folder_media_files(
&folder_token,
&request.media_file_tokens,
&server_state.mysql_pool,
).await.map_err(|err| {
warn!("bulk_delete_folder_media_files failed: {:?}", err);
CommonWebError::from_error(err)
})?;
Ok(Json(BulkRemoveFolderMediaFilesSuccessResponse {
success: true,
removed_count,
}))
}
@@ -0,0 +1,108 @@
use std::sync::Arc;
use actix_web::web::{Json, Path, Query};
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::folders::media_files::{
FolderMediaFilesPathInfo, ListFolderMediaFilesQueryParams, ListFolderMediaFilesSuccessResponse,
};
use mysql_queries::queries::folders::folder::get_folder_for_owner::get_folder_for_owner;
use mysql_queries::queries::folders::media_files::list_folder_media_files::{
list_folder_media_files, ListFolderMediaFilesArgs,
};
use tokens::tokens::folders::FolderToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::media_files::folder::folder_info_conversion::folder_media_file_row_to_info;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::state::server_state::ServerState;
const CURSOR_NAME: &str = "folder_mf";
const DEFAULT_LIMIT: u32 = 100;
const MAX_LIMIT: u32 = 1000;
#[utoipa::path(
get,
tag = "Folders",
path = "/v1/folders/media_files/{folder_token}",
params(
("folder_token" = String, description = "Folder token"),
ListFolderMediaFilesQueryParams,
),
responses(
(status = 200, body = ListFolderMediaFilesSuccessResponse),
(status = 400, body = CommonWebError),
(status = 401, body = CommonWebError),
(status = 404, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn list_folder_media_files_handler(
http_request: HttpRequest,
path: Path<FolderMediaFilesPathInfo>,
query: Query<ListFolderMediaFilesQueryParams>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ListFolderMediaFilesSuccessResponse>, 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_using_connection(
&http_request, &server_state.session_checker, &mut conn,
).await.map_err(|_| CommonWebError::NotAuthorized)?;
let folder_token = FolderToken::new_from_str(path.folder_token.trim());
// Confirm the folder exists + is owned.
let folder = get_folder_for_owner(&folder_token, &user_session.user_token, &server_state.mysql_pool)
.await
.map_err(|err| {
warn!("Folder lookup failed: {:?}", err);
CommonWebError::from_error(err)
})?;
if folder.is_none() {
return Err(CommonWebError::NotFound);
}
let limit = query.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
let maybe_cursor_id = match &query.cursor {
None => None,
Some(cursor_str) => {
let decoded = server_state.opaque_cursors
.decode_cursor_expecting_name(CURSOR_NAME, cursor_str)
.map_err(|err| {
warn!("Failed to decode cursor: {:?}", err);
CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string())
})?;
decoded.last_id
}
};
let rows = list_folder_media_files(ListFolderMediaFilesArgs {
folder_token: &folder_token,
maybe_cursor_id,
limit,
pool: &server_state.mysql_pool,
}).await.map_err(|err| {
warn!("list_folder_media_files failed: {:?}", err);
CommonWebError::from_error(err)
})?;
let maybe_cursor = rows.last().map(|last| {
server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.membership_id)
}).transpose().map_err(|err| {
warn!("Failed to encode cursor: {:?}", err);
CommonWebError::server_error_with_message("Failed to encode cursor")
})?;
let media_files = rows.into_iter().map(folder_media_file_row_to_info).collect();
Ok(Json(ListFolderMediaFilesSuccessResponse {
success: true,
media_files,
maybe_cursor,
}))
}
@@ -0,0 +1,3 @@
pub mod bulk_add_folder_media_files_handler;
pub mod bulk_remove_folder_media_files_handler;
pub mod list_folder_media_files_handler;
@@ -1,9 +1,12 @@
pub mod common_responses;
pub mod delete;
pub mod edit;
pub mod folder;
pub mod get;
pub mod helpers;
pub mod list;
pub mod media_files;
pub mod search;
pub mod subfolder;
pub mod upload;
pub mod upsert_upload;
@@ -0,0 +1,101 @@
use std::sync::Arc;
use actix_web::web::{Json, Path};
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::folders::subfolder::{
BulkAddSubfoldersRequest, BulkAddSubfoldersSuccessResponse, SubfolderPathInfo,
};
use mysql_queries::queries::folders::folder::get_folder_for_owner::get_folder_for_owner;
use mysql_queries::queries::folders::subfolder::bulk_set_parent_folder::bulk_set_parent_folder;
use mysql_queries::queries::folders::subfolder::filter_existing_owned_folder_tokens::filter_existing_owned_folder_tokens;
use tokens::tokens::folders::FolderToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::state::server_state::ServerState;
const MAX_BULK: usize = 500;
/// Bulk-reparent folders under the URL `folder_token`. Idempotent: tokens
/// that don't exist or are already parented to this folder are silently
/// accepted; the response lists the tokens that "stick".
#[utoipa::path(
put,
tag = "Folders",
path = "/v1/folders/subfolders/{folder_token}/bulk_add",
params(("folder_token" = String, description = "Parent folder token")),
request_body = BulkAddSubfoldersRequest,
responses(
(status = 200, body = BulkAddSubfoldersSuccessResponse),
(status = 400, body = CommonWebError),
(status = 401, body = CommonWebError),
(status = 404, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn bulk_add_subfolders_handler(
http_request: HttpRequest,
path: Path<SubfolderPathInfo>,
request: Json<BulkAddSubfoldersRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<BulkAddSubfoldersSuccessResponse>, 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_using_connection(
&http_request, &server_state.session_checker, &mut conn,
).await.map_err(|_| CommonWebError::NotAuthorized)?;
if request.subfolder_tokens.len() > MAX_BULK {
return Err(CommonWebError::BadInputWithSimpleMessage(
format!("too many subfolders in one request (max {})", MAX_BULK),
));
}
let parent_token = FolderToken::new_from_str(path.folder_token.trim());
// Confirm the parent exists + is owned.
let parent = get_folder_for_owner(&parent_token, &user_session.user_token, &server_state.mysql_pool)
.await
.map_err(|err| {
warn!("Parent folder lookup failed: {:?}", err);
CommonWebError::from_error(err)
})?;
if parent.is_none() {
return Err(CommonWebError::NotFound);
}
// Filter input to tokens that actually exist, are owned, and aren't deleted.
let accepted = filter_existing_owned_folder_tokens(
&request.subfolder_tokens,
&user_session.user_token,
&server_state.mysql_pool,
).await.map_err(|err| {
warn!("filter_existing_owned_folder_tokens failed: {:?}", err);
CommonWebError::from_error(err)
})?;
if !accepted.is_empty() {
bulk_set_parent_folder(
&accepted,
&parent_token,
&user_session.user_token,
&server_state.mysql_pool,
).await.map_err(|err| {
warn!("bulk_set_parent_folder failed: {:?}", err);
CommonWebError::from_error(err)
})?;
}
// Note: `accepted` may include the parent itself; the SQL guard excludes
// it from the UPDATE. We still report it as "accepted" for client
// simplicity — no-op tokens are normal in idempotent bulk endpoints.
Ok(Json(BulkAddSubfoldersSuccessResponse {
success: true,
accepted_subfolder_tokens: accepted,
}))
}
@@ -0,0 +1,74 @@
use std::sync::Arc;
use actix_web::web::{Json, Path};
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::folders::subfolder::{
BulkRemoveSubfoldersRequest, BulkRemoveSubfoldersSuccessResponse, SubfolderPathInfo,
};
use mysql_queries::queries::folders::subfolder::bulk_clear_parent_folder::bulk_clear_parent_folder;
use tokens::tokens::folders::FolderToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::state::server_state::ServerState;
const MAX_BULK: usize = 500;
/// Bulk-unparent folders from the URL `folder_token`. Idempotent: rows
/// that aren't currently parented to this folder are silently skipped.
#[utoipa::path(
put,
tag = "Folders",
path = "/v1/folders/subfolders/{folder_token}/bulk_remove",
params(("folder_token" = String, description = "Parent folder token")),
request_body = BulkRemoveSubfoldersRequest,
responses(
(status = 200, body = BulkRemoveSubfoldersSuccessResponse),
(status = 400, body = CommonWebError),
(status = 401, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn bulk_remove_subfolders_handler(
http_request: HttpRequest,
path: Path<SubfolderPathInfo>,
request: Json<BulkRemoveSubfoldersRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<BulkRemoveSubfoldersSuccessResponse>, 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_using_connection(
&http_request, &server_state.session_checker, &mut conn,
).await.map_err(|_| CommonWebError::NotAuthorized)?;
if request.subfolder_tokens.len() > MAX_BULK {
return Err(CommonWebError::BadInputWithSimpleMessage(
format!("too many subfolders in one request (max {})", MAX_BULK),
));
}
let from_parent_token = FolderToken::new_from_str(path.folder_token.trim());
// No parent existence check — the UPDATE's WHERE clause already gates
// on owner_user_token AND current parent, so this is naturally a no-op
// for rows we shouldn't touch.
let removed_count = bulk_clear_parent_folder(
&request.subfolder_tokens,
&from_parent_token,
&user_session.user_token,
&server_state.mysql_pool,
).await.map_err(|err| {
warn!("bulk_clear_parent_folder failed: {:?}", err);
CommonWebError::from_error(err)
})?;
Ok(Json(BulkRemoveSubfoldersSuccessResponse {
success: true,
removed_count,
}))
}
@@ -0,0 +1,109 @@
use std::sync::Arc;
use actix_web::web::{Json, Path, Query};
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::folders::subfolder::{
ListSubfoldersQueryParams, ListSubfoldersSuccessResponse, SubfolderPathInfo,
};
use mysql_queries::queries::folders::folder::get_folder_for_owner::get_folder_for_owner;
use mysql_queries::queries::folders::subfolder::list_subfolders::{
list_subfolders, ListSubfoldersArgs,
};
use tokens::tokens::folders::FolderToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::media_files::folder::folder_info_conversion::folder_row_to_info;
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
use crate::state::server_state::ServerState;
const CURSOR_NAME: &str = "subfolders";
const DEFAULT_LIMIT: u32 = 100;
const MAX_LIMIT: u32 = 1000;
#[utoipa::path(
get,
tag = "Folders",
path = "/v1/folders/subfolders/{folder_token}",
params(
("folder_token" = String, description = "Parent folder token"),
ListSubfoldersQueryParams,
),
responses(
(status = 200, body = ListSubfoldersSuccessResponse),
(status = 400, body = CommonWebError),
(status = 401, body = CommonWebError),
(status = 404, body = CommonWebError),
(status = 500, body = CommonWebError),
),
)]
pub async fn list_subfolders_handler(
http_request: HttpRequest,
path: Path<SubfolderPathInfo>,
query: Query<ListSubfoldersQueryParams>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ListSubfoldersSuccessResponse>, 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_using_connection(
&http_request, &server_state.session_checker, &mut conn,
).await.map_err(|_| CommonWebError::NotAuthorized)?;
let parent_token = FolderToken::new_from_str(path.folder_token.trim());
// Confirm the parent exists + is owned by the caller before listing.
let parent = get_folder_for_owner(&parent_token, &user_session.user_token, &server_state.mysql_pool)
.await
.map_err(|err| {
warn!("Parent folder lookup failed: {:?}", err);
CommonWebError::from_error(err)
})?;
if parent.is_none() {
return Err(CommonWebError::NotFound);
}
let limit = query.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
let maybe_cursor_id = match &query.cursor {
None => None,
Some(cursor_str) => {
let decoded = server_state.opaque_cursors
.decode_cursor_expecting_name(CURSOR_NAME, cursor_str)
.map_err(|err| {
warn!("Failed to decode cursor: {:?}", err);
CommonWebError::BadInputWithSimpleMessage("Invalid cursor".to_string())
})?;
decoded.last_id
}
};
let rows = list_subfolders(ListSubfoldersArgs {
parent_folder_token: &parent_token,
owner_user_token: &user_session.user_token,
maybe_cursor_id,
limit,
pool: &server_state.mysql_pool,
}).await.map_err(|err| {
warn!("list_subfolders failed: {:?}", err);
CommonWebError::from_error(err)
})?;
let maybe_cursor = rows.last().map(|last| {
server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.id)
}).transpose().map_err(|err| {
warn!("Failed to encode cursor: {:?}", err);
CommonWebError::server_error_with_message("Failed to encode cursor")
})?;
let subfolders = rows.into_iter().map(folder_row_to_info).collect();
Ok(Json(ListSubfoldersSuccessResponse {
success: true,
subfolders,
maybe_cursor,
}))
}
@@ -0,0 +1,3 @@
pub mod bulk_add_subfolders_handler;
pub mod bulk_remove_subfolders_handler;
pub mod list_subfolders_handler;
@@ -5,6 +5,7 @@ use crate::http_server::routes::application_routes::comments_routes::add_comment
use crate::http_server::routes::application_routes::credits_routes::add_credits_routes;
use crate::http_server::routes::application_routes::dev_routes::add_dev_routes;
use crate::http_server::routes::application_routes::featured_item_routes::add_featured_item_routes;
use crate::http_server::routes::application_routes::folders_routes::add_folders_routes;
use crate::http_server::routes::application_routes::generate_routes::add_generate_routes;
use crate::http_server::routes::application_routes::job_routes::add_job_routes;
use crate::http_server::routes::application_routes::media_files_routes::add_media_file_routes;
@@ -55,6 +56,7 @@ where
// Media files routes
app = add_media_file_routes(app); // /v1/media_files/...
app = add_folders_routes(app); // /v1/folders/...
app = add_featured_item_routes(app); // /v1/featured_item/...
app = add_prompts_routes(app); // /v1/prompts/...
@@ -0,0 +1,107 @@
use actix_http::body::MessageBody;
use actix_service::ServiceFactory;
use actix_web::dev::{ServiceRequest, ServiceResponse};
use actix_web::{web, App, Error, HttpResponse};
use crate::http_server::endpoints::media_files::folder::color_code_folder_handler::color_code_folder_handler;
use crate::http_server::endpoints::media_files::folder::create_folder_handler::create_folder_handler;
use crate::http_server::endpoints::media_files::folder::delete_folder_handler::delete_folder_handler;
use crate::http_server::endpoints::media_files::folder::get_folder_handler::get_folder_handler;
use crate::http_server::endpoints::media_files::folder::list_folders_handler::list_folders_handler;
use crate::http_server::endpoints::media_files::folder::rename_folder_handler::rename_folder_handler;
use crate::http_server::endpoints::media_files::folder::star_folder_handler::star_folder_handler;
use crate::http_server::endpoints::media_files::media_files::bulk_add_folder_media_files_handler::bulk_add_folder_media_files_handler;
use crate::http_server::endpoints::media_files::media_files::bulk_remove_folder_media_files_handler::bulk_remove_folder_media_files_handler;
use crate::http_server::endpoints::media_files::media_files::list_folder_media_files_handler::list_folder_media_files_handler;
use crate::http_server::endpoints::media_files::subfolder::bulk_add_subfolders_handler::bulk_add_subfolders_handler;
use crate::http_server::endpoints::media_files::subfolder::bulk_remove_subfolders_handler::bulk_remove_subfolders_handler;
use crate::http_server::endpoints::media_files::subfolder::list_subfolders_handler::list_subfolders_handler;
pub fn add_folders_routes<T, B>(app: App<T>) -> App<T>
where
B: MessageBody,
T: ServiceFactory<
ServiceRequest,
Config = (),
Response = ServiceResponse<B>,
Error = Error,
InitError = (),
>,
{
app.service(
web::scope("/v1/folders")
// Folder CRUD
.service(
web::resource("/create")
.route(web::post().to(create_folder_handler))
.route(web::head().to(|| HttpResponse::Ok())),
)
.service(
web::resource("/list_all")
.route(web::get().to(list_folders_handler))
.route(web::head().to(|| HttpResponse::Ok())),
)
.service(
web::scope("/folder")
.service(
web::resource("/{folder_token}")
.route(web::get().to(get_folder_handler))
.route(web::delete().to(delete_folder_handler))
.route(web::head().to(|| HttpResponse::Ok())),
)
.service(
web::resource("/{folder_token}/rename")
.route(web::put().to(rename_folder_handler))
.route(web::head().to(|| HttpResponse::Ok())),
)
.service(
web::resource("/{folder_token}/star")
.route(web::put().to(star_folder_handler))
.route(web::head().to(|| HttpResponse::Ok())),
)
.service(
web::resource("/{folder_token}/color_code")
.route(web::put().to(color_code_folder_handler))
.route(web::head().to(|| HttpResponse::Ok())),
),
)
// Subfolder membership
.service(
web::scope("/subfolders")
.service(
web::resource("/{folder_token}")
.route(web::get().to(list_subfolders_handler))
.route(web::head().to(|| HttpResponse::Ok())),
)
.service(
web::resource("/{folder_token}/bulk_add")
.route(web::put().to(bulk_add_subfolders_handler))
.route(web::head().to(|| HttpResponse::Ok())),
)
.service(
web::resource("/{folder_token}/bulk_remove")
.route(web::put().to(bulk_remove_subfolders_handler))
.route(web::head().to(|| HttpResponse::Ok())),
),
)
// Media-file membership
.service(
web::scope("/media_files")
.service(
web::resource("/{folder_token}")
.route(web::get().to(list_folder_media_files_handler))
.route(web::head().to(|| HttpResponse::Ok())),
)
.service(
web::resource("/{folder_token}/bulk_add")
.route(web::put().to(bulk_add_folder_media_files_handler))
.route(web::head().to(|| HttpResponse::Ok())),
)
.service(
web::resource("/{folder_token}/bulk_remove")
.route(web::put().to(bulk_remove_folder_media_files_handler))
.route(web::head().to(|| HttpResponse::Ok())),
),
),
)
}
@@ -5,6 +5,7 @@ mod comments_routes;
mod credits_routes;
mod dev_routes;
mod featured_item_routes;
mod folders_routes;
mod generate_routes;
mod job_routes;
mod media_files_routes;