diff --git a/core/migrations/00008_folder_hierarchy.sql b/core/migrations/00008_folder_hierarchy.sql new file mode 100644 index 0000000..82ee770 --- /dev/null +++ b/core/migrations/00008_folder_hierarchy.sql @@ -0,0 +1,19 @@ +-- Existing folders remain roots; names are now unique among siblings. +ALTER TABLE folders + ADD COLUMN parent_id UUID NULL, + DROP CONSTRAINT folders_owner_type_owner_id_name_key, + ADD CONSTRAINT folders_owner_identity_key UNIQUE (id, owner_type, owner_id), + ADD CONSTRAINT folders_parent_owner_fkey + FOREIGN KEY (parent_id, owner_type, owner_id) + REFERENCES folders (id, owner_type, owner_id) ON DELETE RESTRICT, + ADD CONSTRAINT folders_not_own_parent CHECK (parent_id IS DISTINCT FROM id); + +CREATE UNIQUE INDEX folders_root_name_key + ON folders (owner_type, owner_id, name) WHERE parent_id IS NULL; +CREATE UNIQUE INDEX folders_sibling_name_key + ON folders (owner_type, owner_id, parent_id, name) WHERE parent_id IS NOT NULL; +CREATE INDEX folders_parent_idx ON folders (parent_id); + +-- Support root/direct-child pagination without changing legacy all-files queries. +CREATE INDEX files_owner_folder_created_idx + ON files (owner_type, owner_id, folder_id, created_at DESC, id DESC); diff --git a/core/src/drive/file_listing_tests.rs b/core/src/drive/file_listing_tests.rs new file mode 100644 index 0000000..7f2213a --- /dev/null +++ b/core/src/drive/file_listing_tests.rs @@ -0,0 +1,224 @@ +use super::*; +use crate::drive::test_support::{context, setup}; + +async fn list( + state: &AppState, + owner: &Owner, + query: ListQuery, +) -> Result, AppError> { + list_files(context(owner), State(state.clone()), Query(query)) + .await + .map(|response| response.0.items) +} + +#[tokio::test] +#[ignore = "requires PostgreSQL (BOOSKIFF_DATABASE_URL)"] +async fn pg_file_root_and_child_filters_paginate_without_dropping_equal_timestamp_rows() { + let (state, owner) = setup("root-files").await; + let folder: Uuid = sqlx::query_scalar( + "INSERT INTO folders (owner_type, owner_id, name) VALUES ($1, $2, 'folder') RETURNING id", + ) + .bind(&owner.owner_type) + .bind(&owner.owner_id) + .fetch_one(&state.pool) + .await + .unwrap(); + let child: Uuid = sqlx::query_scalar("INSERT INTO folders (owner_type, owner_id, name, parent_id) VALUES ($1, $2, 'child', $3) RETURNING id") + .bind(&owner.owner_type).bind(&owner.owner_id).bind(folder).fetch_one(&state.pool).await.unwrap(); + // One timestamp for every row exercises the id tie-breaker across both pages. + let timestamp = time::OffsetDateTime::now_utc(); + let mut root_ids = Vec::new(); + for i in 0..205 { + let id: Uuid = sqlx::query_scalar("INSERT INTO files (owner_type, owner_id, name, mime_type, size_bytes, created_at) VALUES ($1, $2, $3, 'text/plain', 1, $4) RETURNING id") + .bind(&owner.owner_type).bind(&owner.owner_id).bind(format!("root-{i}")) + .bind(timestamp).fetch_one(&state.pool).await.unwrap(); + root_ids.push(id); + } + for folder_id in [folder, child] { + sqlx::query("INSERT INTO files (owner_type, owner_id, folder_id, name, mime_type, size_bytes, created_at) VALUES ($1, $2, $3, 'nested', 'text/plain', 1, $4)") + .bind(&owner.owner_type).bind(&owner.owner_id).bind(folder_id).bind(timestamp) + .execute(&state.pool).await.unwrap(); + } + let first = list( + &state, + &owner, + ListQuery { + root: Some(true), + limit: Some(200), + ..Default::default() + }, + ) + .await + .unwrap(); + let second = list( + &state, + &owner, + ListQuery { + root: Some(true), + limit: Some(200), + offset: Some(200), + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!(first.len(), 200); + assert_eq!(second.len(), 5); + assert!(first.iter().chain(&second).all(|f| f.folder_id.is_none())); + root_ids.sort_by(|a, b| b.cmp(a)); + assert_eq!( + first + .iter() + .chain(&second) + .map(|f| f.id) + .collect::>(), + root_ids + ); + let direct = list( + &state, + &owner, + ListQuery { + folder_id: Some(folder), + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!(direct.len(), 1); + assert_eq!(direct[0].folder_id, Some(folder)); + let legacy = list(&state, &owner, ListQuery::default()).await.unwrap(); + assert_eq!(legacy.len(), 50); + let total: i64 = sqlx::query_scalar("SELECT count(*) FROM files WHERE owner_id = $1") + .bind(&owner.owner_id) + .fetch_one(&state.pool) + .await + .unwrap(); + assert_eq!(total, 207); + let legacy_tail = list( + &state, + &owner, + ListQuery { + offset: Some(200), + limit: Some(200), + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!(legacy_tail.len(), 7); + let foreign = Owner::new(&owner.owner_type, Uuid::now_v7().to_string()); + assert!( + list( + &state, + &foreign, + ListQuery { + root: Some(true), + ..Default::default() + } + ) + .await + .unwrap() + .is_empty() + ); + for id in [folder, Uuid::now_v7()] { + assert!(matches!( + list( + &state, + &foreign, + ListQuery { + folder_id: Some(id), + ..Default::default() + } + ) + .await, + Err(AppError::NotFound(_)) + )); + } + for query in [ + ListQuery { + root: Some(true), + folder_id: Some(folder), + ..Default::default() + }, + ListQuery { + limit: Some(0), + ..Default::default() + }, + ListQuery { + limit: Some(201), + ..Default::default() + }, + ListQuery { + offset: Some(-1), + ..Default::default() + }, + ] { + assert!(matches!( + list(&state, &owner, query).await, + Err(AppError::Validation(_)) + )); + } + let last = first.last().unwrap(); + // A deletion in an already consumed page shifts legacy offsets, but the + // immutable (created_at, id) boundary must still return every remaining file. + sqlx::query("DELETE FROM files WHERE id = $1") + .bind(first[0].id) + .execute(&state.pool) + .await + .unwrap(); + let after_delete = list( + &state, + &owner, + ListQuery { + root: Some(true), + limit: Some(200), + before_created_at: Some(last.created_at.clone()), + before_id: Some(last.id), + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!( + after_delete.iter().map(|f| f.id).collect::>(), + second.iter().map(|f| f.id).collect::>() + ); + for query in [ + ListQuery { + before_created_at: Some(last.created_at.clone()), + ..Default::default() + }, + ListQuery { + before_id: Some(last.id), + ..Default::default() + }, + ListQuery { + before_created_at: Some("invalid".into()), + before_id: Some(last.id), + ..Default::default() + }, + ListQuery { + before_created_at: Some(last.created_at.clone()), + before_id: Some(last.id), + offset: Some(1), + ..Default::default() + }, + ] { + assert!(matches!( + list(&state, &owner, query).await, + Err(AppError::Validation(_)) + )); + } + sqlx::query("DELETE FROM files WHERE owner_id = $1") + .bind(&owner.owner_id) + .execute(&state.pool) + .await + .unwrap(); + for id in [child, folder] { + sqlx::query("DELETE FROM folders WHERE id = $1") + .bind(id) + .execute(&state.pool) + .await + .unwrap(); + } +} diff --git a/core/src/drive/files.rs b/core/src/drive/files.rs index 1c587ea..2ca0bda 100644 --- a/core/src/drive/files.rs +++ b/core/src/drive/files.rs @@ -57,11 +57,19 @@ pub struct UploadQuery { folder_id: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Default, Deserialize, utoipa::IntoParams)] +#[into_params(parameter_in = Query)] pub struct ListQuery { limit: Option, offset: Option, + /// List immediate children of this folder. folder_id: Option, + /// List only root files. Omitted filters preserve legacy all-files listing. + root: Option, + /// RFC 3339 created_at of the previous page's last file. Requires before_id. + before_created_at: Option, + /// ID of the previous page's last file. Requires before_created_at; incompatible with nonzero offset. + before_id: Option, } #[derive(Debug, FromRow)] @@ -208,12 +216,18 @@ async fn upload_file( response.map(|file| (StatusCode::CREATED, Json(file))) } -#[utoipa::path(get, path = "/v1/files", tag = "files", security(("bearer_auth" = [])), responses((status = 200, body = FileListResponse)))] +#[utoipa::path(get, path = "/v1/files", tag = "files", security(("bearer_auth" = [])), params(ListQuery), responses((status = 200, body = FileListResponse), (status = 400), (status = 404)))] async fn list_files( ctx: AccountContext, State(state): State, Query(query): Query, ) -> Result, AppError> { + let root = query.root.unwrap_or(false); + if root && query.folder_id.is_some() { + return Err(AppError::Validation( + "root and folder_id cannot be combined".into(), + )); + } let limit = query.limit.unwrap_or(DEFAULT_LIST_LIMIT); let offset = query.offset.unwrap_or(0); if !(1..=MAX_LIST_LIMIT).contains(&limit) { @@ -224,17 +238,43 @@ async fn list_files( if offset < 0 { return Err(AppError::Validation("offset must be non-negative".into())); } + let cursor = match (query.before_created_at.as_deref(), query.before_id) { + (None, None) => None, + (Some(created_at), Some(id)) => { + if offset != 0 { + return Err(AppError::Validation( + "cursor and nonzero offset cannot be combined".into(), + )); + } + let created_at = time::OffsetDateTime::parse(created_at, &Rfc3339) + .map_err(|_| AppError::Validation("before_created_at must be RFC 3339".into()))?; + Some((created_at, id)) + } + _ => { + return Err(AppError::Validation( + "before_created_at and before_id must be supplied together".into(), + )); + } + }; + if let Some(folder_id) = query.folder_id { + ensure_owned_folder(&state.pool, &ctx.owner, folder_id).await?; + } let rows = sqlx::query_as::<_, FileRow>( "SELECT id, owner_type, owner_id, name, mime_type, size_bytes, folder_id, is_public, created_at \ FROM files WHERE owner_type = $1 AND owner_id = $2 \ AND ($3::uuid IS NULL OR folder_id = $3) \ - ORDER BY created_at DESC LIMIT $4 OFFSET $5", + AND (NOT $6 OR folder_id IS NULL) \ + AND ($7::timestamptz IS NULL OR (created_at, id) < ($7, $8::uuid)) \ + ORDER BY created_at DESC, id DESC LIMIT $4 OFFSET $5", ) .bind(&ctx.owner.owner_type) .bind(&ctx.owner.owner_id) .bind(query.folder_id) .bind(limit) .bind(offset) + .bind(root) + .bind(cursor.map(|(created_at, _)| created_at)) + .bind(cursor.map(|(_, id)| id)) .fetch_all(&state.pool) .await .map_err(|err| AppError::Internal(format!("list files: {err}")))?; @@ -302,7 +342,7 @@ async fn download_url( State(state): State, Path(id): Path, ) -> Result, AppError> { - load_owned_file(&state.pool, &ctx.owner, id).await?; + let file = load_owned_file(&state.pool, &ctx.owner, id).await?; let key: Option = sqlx::query_scalar( "SELECT storage_key FROM file_objects WHERE file_id = $1 AND object_kind = $2", ) @@ -313,7 +353,7 @@ async fn download_url( .map_err(|err| AppError::Internal(format!("load original object: {err}")))?; let key = key.ok_or_else(|| AppError::NotFound(format!("file object {id}")))?; let ttl = Duration::from_secs(state.config.presigned_get_ttl_secs); - let url = state.s3.presign_get(&key, ttl).await?; + let url = state.s3.presign_download(&key, ttl, &file.name).await?; Ok(Json(UrlResponse { url })) } @@ -818,3 +858,7 @@ mod tests { pool } } + +#[cfg(test)] +#[path = "file_listing_tests.rs"] +mod listing_tests; diff --git a/core/src/drive/folder_hierarchy_tests.rs b/core/src/drive/folder_hierarchy_tests.rs new file mode 100644 index 0000000..e4ce6bd --- /dev/null +++ b/core/src/drive/folder_hierarchy_tests.rs @@ -0,0 +1,330 @@ +//! PostgreSQL-backed handler and migration tests for the hierarchy contract. +use super::*; +use crate::drive::test_support::{context, setup}; + +async fn create( + state: &AppState, + owner: &Owner, + name: &str, + parent_id: Option, +) -> FolderResponse { + create_folder( + context(owner), + State(state.clone()), + Json(CreateFolderRequest { + name: name.into(), + parent_id, + }), + ) + .await + .unwrap() + .1 + .0 +} + +async fn remove( + state: &AppState, + owner: &Owner, + id: Uuid, + require_empty: bool, +) -> Result { + delete_folder( + context(owner), + State(state.clone()), + Path(id), + Query(DeleteFolderQuery { + require_empty: Some(require_empty), + }), + ) + .await +} + +async fn list( + state: &AppState, + owner: &Owner, + root: Option, + parent_id: Option, +) -> Result, AppError> { + list_folders( + context(owner), + State(state.clone()), + Query(ListFoldersQuery { root, parent_id }), + ) + .await + .map(|result| result.0.items) +} + +#[tokio::test] +#[ignore = "requires PostgreSQL (BOOSKIFF_DATABASE_URL)"] +async fn pg_hierarchy_lists_direct_children_and_scopes_sibling_names() { + let (state, owner) = setup("hierarchy").await; + let a = create(&state, &owner, "A", None).await; + let b = create(&state, &owner, "B", None).await; + let child = create(&state, &owner, "documents", Some(a.id)).await; + let same_name = create(&state, &owner, "documents", Some(b.id)).await; + let grandchild = create(&state, &owner, "design", Some(child.id)).await; + assert_eq!(child.parent_id, Some(a.id)); + let roots = list(&state, &owner, Some(true), None).await.unwrap(); + assert_eq!( + roots.iter().map(|f| f.id).collect::>(), + vec![a.id, b.id] + ); + let children = list(&state, &owner, None, Some(a.id)).await.unwrap(); + assert_eq!(children.len(), 1); + assert_eq!(children[0].id, child.id); + assert_eq!(list(&state, &owner, None, None).await.unwrap().len(), 5); + assert_eq!( + list(&state, &owner, Some(false), None).await.unwrap().len(), + 5 + ); + assert!(matches!( + list(&state, &owner, Some(true), Some(a.id)).await, + Err(AppError::Validation(_)) + )); + for parent_id in [None, Some(a.id)] { + let name = if parent_id.is_some() { + "documents" + } else { + "A" + }; + assert!(matches!( + create_folder( + context(&owner), + State(state.clone()), + Json(CreateFolderRequest { + name: name.into(), + parent_id + }) + ) + .await, + Err(AppError::Conflict(_)) + )); + } + let sibling = create(&state, &owner, "sibling", Some(a.id)).await; + assert!(matches!( + rename_folder( + context(&owner), + State(state.clone()), + Path(sibling.id), + Json(RenameFolderRequest { + name: "documents".into() + }) + ) + .await, + Err(AppError::Conflict(_)) + )); + // Renaming preserves the parent; a same-name folder in a different parent is valid. + let renamed = rename_folder( + context(&owner), + State(state.clone()), + Path(sibling.id), + Json(RenameFolderRequest { + name: "design".into(), + }), + ) + .await + .unwrap() + .0; + assert_eq!(renamed.parent_id, Some(a.id)); + for id in [ + grandchild.id, + sibling.id, + child.id, + same_name.id, + a.id, + b.id, + ] { + assert_eq!( + remove(&state, &owner, id, true).await.unwrap(), + StatusCode::NO_CONTENT + ); + } +} + +#[tokio::test] +#[ignore = "requires PostgreSQL (BOOSKIFF_DATABASE_URL)"] +async fn pg_hierarchy_hides_foreign_parents_and_enforces_database_ownership() { + let (state, owner) = setup("owner-check").await; + let parent = create(&state, &owner, "parent", None).await; + let foreign = Owner::new(&owner.owner_type, Uuid::now_v7().to_string()); + for parent_id in [parent.id, Uuid::now_v7()] { + assert!(matches!( + create_folder( + context(&foreign), + State(state.clone()), + Json(CreateFolderRequest { + name: "child".into(), + parent_id: Some(parent_id) + }) + ) + .await, + Err(AppError::NotFound(_)) + )); + assert!(matches!( + list(&state, &foreign, None, Some(parent_id)).await, + Err(AppError::NotFound(_)) + )); + assert!(matches!( + remove(&state, &foreign, parent_id, true).await, + Err(AppError::NotFound(_)) + )); + } + let error = sqlx::query("INSERT INTO folders (owner_type, owner_id, name, parent_id) VALUES ($1, $2, 'foreign', $3)") + .bind(&foreign.owner_type).bind(&foreign.owner_id).bind(parent.id).execute(&state.pool).await.unwrap_err(); + assert_eq!( + error.as_database_error().unwrap().code().as_deref(), + Some("23503") + ); + let error = sqlx::query("UPDATE folders SET parent_id = id WHERE id = $1") + .bind(parent.id) + .execute(&state.pool) + .await + .unwrap_err(); + assert_eq!( + error.as_database_error().unwrap().code().as_deref(), + Some("23514") + ); + remove(&state, &owner, parent.id, true).await.unwrap(); +} + +#[tokio::test] +#[ignore = "requires PostgreSQL (BOOSKIFF_DATABASE_URL)"] +async fn pg_safe_delete_rejects_contents_and_legacy_delete_unlinks_files() { + let (state, owner) = setup("safe-delete").await; + let parent = create(&state, &owner, "parent", None).await; + let child = create(&state, &owner, "child", Some(parent.id)).await; + // Both APIs reject folders containing subfolders; neither recursively removes them. + for require_empty in [false, true] { + assert!(matches!( + remove(&state, &owner, parent.id, require_empty).await, + Err(AppError::Conflict(_)) + )); + } + let file: Uuid = sqlx::query_scalar("INSERT INTO files (owner_type, owner_id, folder_id, name, mime_type, size_bytes) VALUES ($1, $2, $3, 'f', 'text/plain', 0) RETURNING id") + .bind(&owner.owner_type).bind(&owner.owner_id).bind(child.id).fetch_one(&state.pool).await.unwrap(); + assert!(matches!( + remove(&state, &owner, child.id, true).await, + Err(AppError::Conflict(_)) + )); + remove(&state, &owner, child.id, false).await.unwrap(); + let folder_id: Option = sqlx::query_scalar("SELECT folder_id FROM files WHERE id = $1") + .bind(file) + .fetch_one(&state.pool) + .await + .unwrap(); + assert!(folder_id.is_none()); + sqlx::query("DELETE FROM files WHERE id = $1") + .bind(file) + .execute(&state.pool) + .await + .unwrap(); + remove(&state, &owner, parent.id, true).await.unwrap(); +} + +#[tokio::test] +#[ignore = "requires PostgreSQL (BOOSKIFF_DATABASE_URL)"] +async fn pg_child_creation_and_safe_delete_have_atomic_outcomes() { + let (state, owner) = setup("child-race").await; + for round in 0..16 { + let parent = create(&state, &owner, &format!("parent-{round}"), None).await; + let (created, deleted) = tokio::join!( + create_folder( + context(&owner), + State(state.clone()), + Json(CreateFolderRequest { + name: "child".into(), + parent_id: Some(parent.id) + }) + ), + remove(&state, &owner, parent.id, true), + ); + match (created, deleted) { + (Ok((_, Json(child))), Err(AppError::Conflict(_))) => { + remove(&state, &owner, child.id, true).await.unwrap(); + remove(&state, &owner, parent.id, true).await.unwrap(); + } + (Err(AppError::NotFound(_)), Ok(StatusCode::NO_CONTENT)) => {} + other => panic!("non-atomic child/delete result: {other:?}"), + } + } +} + +#[tokio::test] +#[ignore = "requires PostgreSQL (BOOSKIFF_DATABASE_URL)"] +async fn pg_file_insertion_and_safe_delete_never_silently_move_files_to_root() { + let (state, owner) = setup("file-race").await; + for round in 0..16 { + let parent = create(&state, &owner, &format!("parent-{round}"), None).await; + // This INSERT acquires exactly the FK key-share lock used by upload persistence. + let insert = sqlx::query_scalar::<_, Uuid>("INSERT INTO files (owner_type, owner_id, folder_id, name, mime_type, size_bytes) VALUES ($1, $2, $3, 'f', 'text/plain', 0) RETURNING id") + .bind(&owner.owner_type).bind(&owner.owner_id).bind(parent.id).fetch_one(&state.pool); + let (created, deleted) = tokio::join!(insert, remove(&state, &owner, parent.id, true)); + match (created, deleted) { + (Ok(id), Err(AppError::Conflict(_))) => { + let folder_id: Option = + sqlx::query_scalar("SELECT folder_id FROM files WHERE id = $1") + .bind(id) + .fetch_one(&state.pool) + .await + .unwrap(); + assert_eq!(folder_id, Some(parent.id)); + sqlx::query("DELETE FROM files WHERE id = $1") + .bind(id) + .execute(&state.pool) + .await + .unwrap(); + remove(&state, &owner, parent.id, true).await.unwrap(); + } + (Err(error), Ok(StatusCode::NO_CONTENT)) => assert_eq!( + error.as_database_error().unwrap().code().as_deref(), + Some("23503") + ), + other => panic!("non-atomic file/delete result: {other:?}"), + } + } +} + +#[tokio::test] +#[ignore = "requires PostgreSQL (BOOSKIFF_DATABASE_URL)"] +async fn pg_hierarchy_migration_preserves_existing_root_folders_and_files() { + let (state, _) = setup("migration").await; + let mut tx = state.pool.begin().await.unwrap(); + let schema = format!("migration_{}", Uuid::now_v7().simple()); + // Identifier contains only a fixed prefix and generated hexadecimal UUID. + sqlx::raw_sql(sqlx::AssertSqlSafe(format!( + "CREATE SCHEMA {schema}; SET LOCAL search_path TO {schema}" + ))) + .execute(&mut *tx) + .await + .unwrap(); + sqlx::raw_sql(include_str!("../../migrations/00001_folders.sql")) + .execute(&mut *tx) + .await + .unwrap(); + sqlx::raw_sql(include_str!("../../migrations/00002_files.sql")) + .execute(&mut *tx) + .await + .unwrap(); + let folder: Uuid = sqlx::query_scalar("INSERT INTO folders (owner_type, owner_id, name) VALUES ('account', 'alice', 'before') RETURNING id").fetch_one(&mut *tx).await.unwrap(); + let file: Uuid = sqlx::query_scalar("INSERT INTO files (owner_type, owner_id, folder_id, name, mime_type, size_bytes) VALUES ('account', 'alice', $1, 'before.txt', 'text/plain', 12) RETURNING id").bind(folder).fetch_one(&mut *tx).await.unwrap(); + sqlx::raw_sql(include_str!("../../migrations/00008_folder_hierarchy.sql")) + .execute(&mut *tx) + .await + .unwrap(); + let row: (Option, String) = + sqlx::query_as("SELECT parent_id, name FROM folders WHERE id = $1") + .bind(folder) + .fetch_one(&mut *tx) + .await + .unwrap(); + assert_eq!(row, (None, "before".into())); + let row: (Option, i64) = + sqlx::query_as("SELECT folder_id, size_bytes FROM files WHERE id = $1") + .bind(file) + .fetch_one(&mut *tx) + .await + .unwrap(); + assert_eq!(row, (Some(folder), 12)); + // The test schema and all fixtures disappear together. + tx.rollback().await.unwrap(); +} diff --git a/core/src/drive/folders.rs b/core/src/drive/folders.rs index 6f492fc..78a21ae 100644 --- a/core/src/drive/folders.rs +++ b/core/src/drive/folders.rs @@ -1,6 +1,6 @@ //! Folder CRUD handlers. -use axum::extract::{Path, State}; +use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use axum::routing::{get, post}; use axum::{Json, Router}; @@ -14,16 +14,35 @@ use crate::error::AppError; use crate::model::Owner; use crate::state::AppState; -#[derive(Serialize, ToSchema)] +#[derive(Debug, Serialize, ToSchema)] pub struct FolderResponse { pub id: Uuid, pub name: String, pub created_at: String, + pub parent_id: Option, } #[derive(Deserialize, ToSchema)] pub struct CreateFolderRequest { pub name: String, + /// Omitted or null creates a root folder. + pub parent_id: Option, +} + +#[derive(Debug, Default, Deserialize, utoipa::IntoParams)] +#[into_params(parameter_in = Query)] +pub struct ListFoldersQuery { + /// Only root folders. Cannot be combined with parent_id. + pub root: Option, + /// Only immediate children of this folder; omitted filters preserve legacy all-folders listing. + pub parent_id: Option, +} + +#[derive(Debug, Default, Deserialize, utoipa::IntoParams)] +#[into_params(parameter_in = Query)] +pub struct DeleteFolderQuery { + /// Reject deletion if the folder contains files. Child folders always prevent deletion. + pub require_empty: Option, } #[derive(Deserialize, ToSchema)] @@ -41,6 +60,7 @@ struct FolderRow { id: Uuid, name: String, created_at: OffsetDateTime, + parent_id: Option, } /// Builds the authenticated folder CRUD router. @@ -53,37 +73,60 @@ pub fn folders_router() -> Router { ) } -#[utoipa::path(post, path = "/v1/folders", tag = "folders", security(("bearer_auth" = [])))] +#[utoipa::path(post, path = "/v1/folders", tag = "folders", security(("bearer_auth" = [])), request_body = CreateFolderRequest, responses((status = 201, body = FolderResponse), (status = 400), (status = 404), (status = 409)))] async fn create_folder( context: AccountContext, State(state): State, Json(request): Json, ) -> Result<(StatusCode, Json), AppError> { validate_folder_name(&request.name)?; + let mut tx = state.pool.begin().await.map_err(internal_database_error)?; + if let Some(parent_id) = request.parent_id { + // Share the parent key lock with the FK check through commit. A concurrent + // deletion either waits for this child, or wins and returns a clean 404. + lock_owned_folder(&mut tx, &context.owner, parent_id, false).await?; + } let row = sqlx::query_as::<_, FolderRow>( - "INSERT INTO folders (owner_type, owner_id, name) VALUES ($1, $2, $3) \ - RETURNING id, name, created_at", + "INSERT INTO folders (owner_type, owner_id, name, parent_id) VALUES ($1, $2, $3, $4) \ + RETURNING id, name, created_at, parent_id", ) .bind(&context.owner.owner_type) .bind(&context.owner.owner_id) .bind(&request.name) - .fetch_one(&state.pool) + .bind(request.parent_id) + .fetch_one(&mut *tx) .await .map_err(map_database_error)?; + tx.commit().await.map_err(internal_database_error)?; Ok((StatusCode::CREATED, Json(folder_response(row)?))) } -#[utoipa::path(get, path = "/v1/folders", tag = "folders", security(("bearer_auth" = [])))] +#[utoipa::path(get, path = "/v1/folders", tag = "folders", security(("bearer_auth" = [])), params(ListFoldersQuery), responses((status = 200, body = FolderListResponse), (status = 400), (status = 404)))] async fn list_folders( context: AccountContext, State(state): State, + Query(query): Query, ) -> Result, AppError> { + let root = query.root.unwrap_or(false); + if root && query.parent_id.is_some() { + return Err(AppError::Validation( + "root and parent_id cannot be combined".into(), + )); + } + if let Some(parent_id) = query.parent_id { + find_folder(&state.pool, &context.owner, parent_id).await?; + } let rows = sqlx::query_as::<_, FolderRow>( - "SELECT id, name, created_at FROM folders \ - WHERE owner_type = $1 AND owner_id = $2 ORDER BY created_at ASC", + "SELECT id, name, created_at, parent_id FROM folders \ + WHERE owner_type = $1 AND owner_id = $2 \ + AND (NOT $3 OR parent_id IS NULL) \ + AND ($4::uuid IS NULL OR parent_id = $4) \ + ORDER BY created_at ASC, id ASC", ) .bind(&context.owner.owner_type) .bind(&context.owner.owner_id) + .bind(root) + .bind(query.parent_id) .fetch_all(&state.pool) .await .map_err(internal_database_error)?; @@ -94,7 +137,7 @@ async fn list_folders( Ok(Json(FolderListResponse { items })) } -#[utoipa::path(get, path = "/v1/folders/{id}", tag = "folders", security(("bearer_auth" = [])))] +#[utoipa::path(get, path = "/v1/folders/{id}", tag = "folders", security(("bearer_auth" = [])), params(("id" = Uuid, Path)), responses((status = 200, body = FolderResponse), (status = 404)))] async fn get_folder( context: AccountContext, State(state): State, @@ -104,7 +147,7 @@ async fn get_folder( Ok(Json(folder_response(row)?)) } -#[utoipa::path(patch, path = "/v1/folders/{id}", tag = "folders", security(("bearer_auth" = [])))] +#[utoipa::path(patch, path = "/v1/folders/{id}", tag = "folders", security(("bearer_auth" = [])), params(("id" = Uuid, Path)), request_body = RenameFolderRequest, responses((status = 200, body = FolderResponse), (status = 400), (status = 404), (status = 409)))] async fn rename_folder( context: AccountContext, State(state): State, @@ -114,7 +157,7 @@ async fn rename_folder( validate_folder_name(&request.name)?; let row = sqlx::query_as::<_, FolderRow>( "UPDATE folders SET name = $1 WHERE id = $2 AND owner_type = $3 AND owner_id = $4 \ - RETURNING id, name, created_at", + RETURNING id, name, created_at, parent_id", ) .bind(&request.name) .bind(id) @@ -127,29 +170,64 @@ async fn rename_folder( Ok(Json(folder_response(row)?)) } -#[utoipa::path(delete, path = "/v1/folders/{id}", tag = "folders", security(("bearer_auth" = [])))] +#[utoipa::path(delete, path = "/v1/folders/{id}", tag = "folders", security(("bearer_auth" = [])), params(("id" = Uuid, Path), DeleteFolderQuery), responses((status = 204), (status = 404), (status = 409)))] async fn delete_folder( context: AccountContext, State(state): State, Path(id): Path, + Query(query): Query, ) -> Result { - let result = - sqlx::query("DELETE FROM folders WHERE id = $1 AND owner_type = $2 AND owner_id = $3") - .bind(id) - .bind(&context.owner.owner_type) - .bind(&context.owner.owner_id) - .execute(&state.pool) - .await - .map_err(internal_database_error)?; - if result.rows_affected() == 0 { - return Err(AppError::NotFound("folder not found".into())); + let mut tx = state.pool.begin().await.map_err(internal_database_error)?; + // FOR UPDATE conflicts with the FK key-share lock taken by both child + // creation and file insertion. Checking emptiness after acquiring it makes + // require_empty atomic, including concurrent uploads. + lock_owned_folder(&mut tx, &context.owner, id, true).await?; + let nonempty: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM folders WHERE parent_id = $1) \ + OR ($2 AND EXISTS(SELECT 1 FROM files WHERE folder_id = $1))", + ) + .bind(id) + .bind(query.require_empty.unwrap_or(false)) + .fetch_one(&mut *tx) + .await + .map_err(internal_database_error)?; + if nonempty { + return Err(AppError::Conflict("folder is not empty".into())); } + sqlx::query("DELETE FROM folders WHERE id = $1") + .bind(id) + .execute(&mut *tx) + .await + .map_err(map_database_error)?; + tx.commit().await.map_err(internal_database_error)?; Ok(StatusCode::NO_CONTENT) } +async fn lock_owned_folder( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + owner: &Owner, + id: Uuid, + exclusive: bool, +) -> Result<(), AppError> { + let query = if exclusive { + "SELECT id FROM folders WHERE id = $1 AND owner_type = $2 AND owner_id = $3 FOR UPDATE" + } else { + "SELECT id FROM folders WHERE id = $1 AND owner_type = $2 AND owner_id = $3 FOR KEY SHARE" + }; + sqlx::query_scalar::<_, Uuid>(query) + .bind(id) + .bind(&owner.owner_type) + .bind(&owner.owner_id) + .fetch_optional(&mut **tx) + .await + .map_err(internal_database_error)? + .ok_or_else(|| AppError::NotFound("folder not found".into()))?; + Ok(()) +} + async fn find_folder(pool: &sqlx::PgPool, owner: &Owner, id: Uuid) -> Result { sqlx::query_as::<_, FolderRow>( - "SELECT id, name, created_at FROM folders \ + "SELECT id, name, created_at, parent_id FROM folders \ WHERE id = $1 AND owner_type = $2 AND owner_id = $3", ) .bind(id) @@ -170,6 +248,7 @@ fn folder_response(row: FolderRow) -> Result { id: row.id, name: row.name, created_at, + parent_id: row.parent_id, }) } @@ -184,6 +263,11 @@ fn map_database_error(error: sqlx::Error) -> AppError { { AppError::Conflict("folder name already exists".into()) } + sqlx::Error::Database(database_error) + if database_error.code().as_deref() == Some("23503") => + { + AppError::Conflict("folder is not empty".into()) + } _ => internal_database_error(error), } } @@ -279,3 +363,7 @@ mod tests { .expect("cleanup file"); } } + +#[cfg(test)] +#[path = "folder_hierarchy_tests.rs"] +mod hierarchy_tests; diff --git a/core/src/drive/mod.rs b/core/src/drive/mod.rs index e3eeacf..5073ad6 100644 --- a/core/src/drive/mod.rs +++ b/core/src/drive/mod.rs @@ -48,3 +48,6 @@ mod tests { } } } + +#[cfg(test)] +mod test_support; diff --git a/core/src/drive/test_support.rs b/core/src/drive/test_support.rs new file mode 100644 index 0000000..c084c0e --- /dev/null +++ b/core/src/drive/test_support.rs @@ -0,0 +1,47 @@ +//! Shared PostgreSQL fixtures for drive handler tests; no network S3 operations. +use crate::auth::extractor::AccountContext; +use crate::config::Config; +use crate::model::{Limits, Owner}; +use crate::state::AppState; +use crate::storage::Storage; +use uuid::Uuid; + +pub(super) fn context(owner: &Owner) -> AccountContext { + AccountContext { + owner: owner.clone(), + limits: Limits { + storage_quota_bytes: 1024 * 1024, + max_file_bytes: 1024 * 1024, + rate_limit_rpm: 100, + }, + } +} + +pub(super) async fn setup(label: &str) -> (AppState, Owner) { + let url = std::env::var("BOOSKIFF_DATABASE_URL") + .unwrap_or_else(|_| "postgres://booskiff:booskiff@127.0.0.1:5432/booskiff".into()); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(4) + .connect(&url) + .await + .unwrap(); + sqlx::migrate!("./migrations").run(&pool).await.unwrap(); + let config = Config::default(); + let state = AppState { + billing_cache: std::sync::Arc::new(crate::billing::cache::BillingCache::new( + config.billing_cache_ttl_secs, + )), + jwks_cache: crate::auth::jwks::JwksCache::new(config.jwt_trusted_issuers.clone()), + s3: Storage::build(&config).await.unwrap(), + pool, + config, + rate_limiters: std::sync::Arc::new(crate::state::RateLimiters::default()), + public_rate_limiter: std::sync::Arc::new(crate::auth::rate_limit::PublicRateLimiter::new( + 300, + )), + }; + ( + state, + Owner::new(format!("test-{label}"), Uuid::now_v7().to_string()), + ) +} diff --git a/core/src/storage.rs b/core/src/storage.rs index 229be9e..853481a 100644 --- a/core/src/storage.rs +++ b/core/src/storage.rs @@ -154,11 +154,33 @@ impl Storage { /// external clients get reachable URLs while the client keeps /// talking to the internal endpoint. pub async fn presign_get(&self, key: &str, ttl: Duration) -> Result { + self.presign_get_with_disposition(key, ttl, None).await + } + + /// Sign an attachment response override so browser downloads retain the + /// original UTF-8 filename even across a cross-origin redirect. + pub async fn presign_download( + &self, + key: &str, + ttl: Duration, + name: &str, + ) -> Result { + self.presign_get_with_disposition(key, ttl, Some(attachment_disposition(name))) + .await + } + + async fn presign_get_with_disposition( + &self, + key: &str, + ttl: Duration, + disposition: Option, + ) -> Result { let presigned = self .client .get_object() .bucket(&self.bucket) .key(key) + .set_response_content_disposition(disposition) .presigned( PresigningConfig::builder() .expires_in(ttl) @@ -342,6 +364,33 @@ fn rewrite_endpoint(url: &str, from: &str, to: &str) -> String { } } +/// RFC 6266 / 8187: ASCII fallback plus percent-encoded UTF-8 filename*. +/// Never place user-controlled quotes, path separators or controls in the +/// quoted fallback; the extended parameter contains only safe ASCII bytes. +fn attachment_disposition(name: &str) -> String { + let fallback: String = name + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, ' ' | '.' | '-' | '_') { + c + } else { + '_' + } + }) + .collect(); + let encoded: String = name + .bytes() + .map(|b| { + if b.is_ascii_alphanumeric() || b"!#$&+-.^_`|~".contains(&b) { + char::from(b).to_string() + } else { + format!("%{b:02X}") + } + }) + .collect(); + format!("attachment; filename=\"{fallback}\"; filename*=UTF-8''{encoded}") +} + #[cfg(test)] mod tests { use super::*; @@ -445,3 +494,78 @@ mod tests { } } } + +#[cfg(test)] +mod attachment_tests { + use super::*; + + #[test] + fn attachment_filename_is_utf8_and_cannot_inject_headers() { + let disposition = attachment_disposition("資料 \"A\"\r\n/\\.txt"); + assert_eq!( + disposition, + "attachment; filename=\"__ _A_____.txt\"; filename*=UTF-8''%E8%B3%87%E6%96%99%20%22A%22%0D%0A%2F%5C.txt" + ); + assert!(axum::http::HeaderValue::from_str(&disposition).is_ok()); + } + + #[tokio::test] + async fn download_url_signs_the_attachment_response_override() { + let storage = Storage::build(&Config::default()).await.unwrap(); + let url = storage + .presign_download("test/name", Duration::from_secs(60), "資料.txt") + .await + .unwrap(); + let parsed = reqwest::Url::parse(&url).unwrap(); + let params: std::collections::HashMap<_, _> = parsed.query_pairs().collect(); + assert_eq!( + params.get("response-content-disposition").unwrap().as_ref(), + attachment_disposition("資料.txt") + ); + assert!(params.contains_key("X-Amz-Signature")); + let regular = storage + .presign_get("test/name", Duration::from_secs(60)) + .await + .unwrap(); + assert!(!regular.contains("response-content-disposition")); + } + + #[tokio::test] + #[ignore = "requires MinIO (BOOSKIFF_TEST_S3_ENDPOINT or 127.0.0.1:9000)"] + async fn minio_attachment_download_returns_original_utf8_name() { + let config = Config { + s3_endpoint: std::env::var("BOOSKIFF_TEST_S3_ENDPOINT") + .unwrap_or_else(|_| "http://127.0.0.1:9000".into()), + ..Config::default() + }; + let storage = Storage::build(&config).await.unwrap(); + storage.ensure_bucket().await.unwrap(); + let key = format!("test/attachment/{}", uuid::Uuid::now_v7()); + storage + .put_streaming( + &key, + ByteStream::from_static(b"attachment").into_inner(), + 10, + "text/plain", + ) + .await + .unwrap(); + let url = storage + .presign_download(&key, Duration::from_secs(60), "資料.txt") + .await + .unwrap(); + let response = reqwest::get(url).await.unwrap(); + assert!(response.status().is_success()); + assert_eq!( + response + .headers() + .get("content-disposition") + .unwrap() + .to_str() + .unwrap(), + attachment_disposition("資料.txt") + ); + assert_eq!(response.text().await.unwrap(), "attachment"); + storage.delete_object(&key).await.unwrap(); + } +} diff --git a/core/tests/e2e.rs b/core/tests/e2e.rs index ebc5e7d..09b52a2 100644 --- a/core/tests/e2e.rs +++ b/core/tests/e2e.rs @@ -783,7 +783,7 @@ async fn scenario_s5(client: &Client, server: &CoreServer) { ) .await; - let after_delete = expect_status( + expect_error_code( client .get(format!( "{}/v1/files?folder_id={}", @@ -793,16 +793,32 @@ async fn scenario_s5(client: &Client, server: &CoreServer) { .send() .await .expect("S5 list files after folder delete"), - StatusCode::OK, + StatusCode::NOT_FOUND, + "not_found", "S5 list files after folder delete", ) + .await; + + let root_files = expect_status( + client + .get(format!("{}/v1/files?root=true", server.base_url)) + .bearer_auth(&jwt) + .send() + .await + .expect("S5 list root files after folder delete"), + StatusCode::OK, + "S5 list root files after folder delete", + ) .await .json::() .await - .expect("S5 parse folder file list after delete"); + .expect("S5 parse root file list after delete"); assert!( - !after_delete.items.iter().any(|item| item.id == file.id), - "S5 deleted folder must not list its former files" + root_files + .items + .iter() + .any(|item| item.id == file.id && item.folder_id.is_none()), + "S5 deleted folder's former file must appear in the root listing" ); let unlinked = expect_status( diff --git a/docs/drive/folders.md b/docs/drive/folders.md new file mode 100644 index 0000000..229127a --- /dev/null +++ b/docs/drive/folders.md @@ -0,0 +1,52 @@ +# フォルダ階層とファイル一覧 + +フォルダは `parent_id` による親子構造を持ちます。`parent_id: null` はルートです。 +既存フォルダは migration `00008_folder_hierarchy.sql` により ID と内容を保ったまま +ルートフォルダになります。フォルダ移動 API はありません。 + +## 作成と一覧 + +`POST /v1/folders` は `{"name":"資料","parent_id":"親フォルダのUUID"}` を受け付けます。 +`parent_id` の省略または `null` はルートへの作成です。親は同じ所有者のフォルダに限り、 +存在しない親と他の所有者の親はどちらも 404 を返します。 +同じ所有者・同じ親に同名のフォルダがある場合は 409、異なる親では同名を許可します。 +名前変更にも同じ一意性制約が適用されます。 + +| API | 対象 | +| --- | --- | +| `GET /v1/folders?root=true` | ルートフォルダだけ | +| `GET /v1/folders?parent_id=` | 指定したフォルダの直下のフォルダだけ | +| `GET /v1/files?root=true` | ルートのファイルだけ | +| `GET /v1/files?folder_id=` | 指定したフォルダの直下のファイルだけ | + +`root=true` と親/フォルダ ID の同時指定は 400 です。指定したフォルダが存在しない、 +または他の所有者に属する場合は 404 です。フィルタを省略した従来のリクエストは、 +所有者の全フォルダ・全ファイルを階層によらず返します。`root=false` も省略と同じです。 + +ファイル一覧の `limit` は既定 50、最大 200、`offset` は既定 0 です。 +`created_at DESC, id DESC` 順で返します。クライアントは続きのページも取得してください。 +続きには直前ページ末尾の `created_at` と `id` を、`before_created_at`(RFC 3339)と +`before_id` としてそのまま送るカーソル方式を推奨します。境界より古いタプルを返すため、 +前のページのファイルが別のタブから削除されても続きのファイルを飛ばしません。 +両方のカーソル値が必要で、片方だけ、時刻の形式が不正、`offset` が 0 以外の場合は 400 です。 +新しいファイルを先頭に反映するには一覧を再読込してください。 +既存の offset 方式も維持しますが、ページ取得の間に作成・削除があると位置がずれます。 +フォルダ一覧はページ分割せず、`created_at ASC, id ASC` 順で返します。 + +## フォルダ削除 + +UI からの削除には `DELETE /v1/folders/?require_empty=true` を使用します。 +直下にファイルまたは子フォルダがある場合は 409 を返します。 +空判定から削除まで親レコードをロックするため、同時に開始された子フォルダ作成や +アップロードの保存で、ファイルが意図せずルートへ移ることはありません。 +削除が先に完了した場合、作成・保存は 404 になります。 + +互換性のため `require_empty` の省略または `false` は、直下ファイルをルートに戻す +従来の削除動作を維持します。子フォルダがある場合は常に 409 です。再帰削除はしません。 + +## ダウンロード + +`GET /v1/files//download-url` の署名付き URL は、`Content-Disposition: attachment` +と元ファイル名を含むレスポンス上書きパラメータを署名します。ファイル名は ASCII の +フォールバックと RFC 8187 の UTF-8 `filename*` で指定するため、日本語名にも対応します。 +保存の進捗・完了はブラウザが管理します。 diff --git a/e2e/Minio.Containerfile b/e2e/Minio.Containerfile new file mode 100644 index 0000000..e897e6e --- /dev/null +++ b/e2e/Minio.Containerfile @@ -0,0 +1,16 @@ +# syntax=docker/dockerfile:1 +# The upstream container registries and binary archive are no longer public. +# Keep the E2E S3 server on an immutable upstream MinIO release instead. +FROM golang:1.25.13-bookworm AS builder +ADD --checksum=sha256:8b11129f6a6830768bcdebab569290ad956618f5ff6ef4857b633acb64f95126 \ + https://codeload.github.com/minio/minio/tar.gz/01ce918d8279a20e4706b96a64396146894adee4 /tmp/minio.tar.gz +RUN mkdir /src && tar -xzf /tmp/minio.tar.gz --strip-components=1 -C /src +WORKDIR /src +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOTOOLCHAIN=local go build -trimpath -o /out/minio . + +FROM alpine:3.22.2 +COPY --from=builder /out/minio /usr/local/bin/minio +COPY --from=builder /src/LICENSE /licenses/minio/LICENSE +ENTRYPOINT ["minio"] diff --git a/e2e/compose.yml b/e2e/compose.yml new file mode 100644 index 0000000..f9ec696 --- /dev/null +++ b/e2e/compose.yml @@ -0,0 +1,40 @@ +# E2E-only infrastructure. The core creates its bucket through ensure_bucket; +# no external mc image or bucket bootstrap is needed. +services: + postgres: + image: postgres:16 + environment: + POSTGRES_USER: booskiff + POSTGRES_PASSWORD: booskiff + POSTGRES_DB: booskiff + ports: + - "127.0.0.1:5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U booskiff -d booskiff"] + interval: 2s + timeout: 3s + retries: 30 + + minio: + build: + context: . + dockerfile: Minio.Containerfile + command: server /data + environment: + MINIO_ROOT_USER: booskiff + MINIO_ROOT_PASSWORD: booskiff-secret + ports: + - "127.0.0.1:9000:9000" + volumes: + - miniodata:/data + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:9000/minio/health/live"] + interval: 2s + timeout: 3s + retries: 30 + +volumes: + pgdata: + miniodata: diff --git a/e2e/run-e2e.sh b/e2e/run-e2e.sh index 7383922..81d55ab 100755 --- a/e2e/run-e2e.sh +++ b/e2e/run-e2e.sh @@ -3,6 +3,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SERVER_LOG="${ROOT_DIR}/e2e/core-server.log" +COMPOSE_FILE="${ROOT_DIR}/e2e/compose.yml" HEALTH_TIMEOUT_SECONDS=90 cleanup() { @@ -16,7 +17,7 @@ cleanup() { fi fi if [[ "${BOOSKIFF_E2E_TEARDOWN:-0}" == "1" ]]; then - docker compose -f "${ROOT_DIR}/compose.yml" down -v + docker compose -f "${COMPOSE_FILE}" down -v fi exit "${status}" } @@ -25,24 +26,22 @@ trap cleanup EXIT cd "${ROOT_DIR}" : >"${SERVER_LOG}" -docker compose up -d +docker compose -f "${COMPOSE_FILE}" up -d --build deadline=$((SECONDS + HEALTH_TIMEOUT_SECONDS)) while true; do - postgres_id="$(docker compose ps -q postgres)" - minio_id="$(docker compose ps -q minio)" - mc_init_id="$(docker compose ps -aq mc-init)" + postgres_id="$(docker compose -f "${COMPOSE_FILE}" ps -q postgres)" + minio_id="$(docker compose -f "${COMPOSE_FILE}" ps -q minio)" postgres_health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "${postgres_id}" 2>/dev/null || true)" minio_health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "${minio_id}" 2>/dev/null || true)" - mc_init_state="$(docker inspect --format '{{.State.Status}}:{{.State.ExitCode}}' "${mc_init_id}" 2>/dev/null || true)" - if [[ "${postgres_health}" == "healthy" && "${minio_health}" == "healthy" && "${mc_init_state}" == "exited:0" ]]; then + if [[ "${postgres_health}" == "healthy" && "${minio_health}" == "healthy" ]]; then break fi if (( SECONDS >= deadline )); then - echo "Timed out waiting for compose services: postgres=${postgres_health}, minio=${minio_health}, mc-init=${mc_init_state}" >&2 - docker compose logs --tail=200 >&2 + echo "Timed out waiting for compose services: postgres=${postgres_health}, minio=${minio_health}" >&2 + docker compose -f "${COMPOSE_FILE}" logs --tail=200 >&2 exit 1 fi sleep 1 diff --git a/openapi.json b/openapi.json index 118a56a..2a74934 100644 --- a/openapi.json +++ b/openapi.json @@ -1 +1 @@ -{"openapi":"3.1.0","info":{"title":"Booskiff","description":"Booskiff drive API: per-owner file and folder storage with streaming uploads, publishing, and download URLs, built on the billing policy foundation (plans, rules, quotas) that governs storage limits.","license":{"name":"MIT","identifier":"MIT"},"version":"0.1.0"},"paths":{"/healthz":{"get":{"tags":["health"],"operationId":"healthz","responses":{"200":{"description":""}}}},"/public/{key}":{"get":{"tags":["public"],"operationId":"get_public_file","parameters":[{"name":"key","in":"path","description":"Public key of a published file","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The file content, served with immutable caching"},"404":{"description":"Unknown or unpublished key"}}}},"/readyz":{"get":{"tags":["health"],"operationId":"ready","responses":{"200":{"description":""},"503":{"description":""}}}},"/v1/admin/billing/rules":{"get":{"tags":["admin"],"operationId":"list_billing_rules","responses":{"200":{"description":"All billing rules, global layer first","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingRuleList"}}}},"401":{"description":"Missing, unknown, or revoked admin token"}},"security":[{"admin_token":[]}]},"post":{"tags":["admin"],"operationId":"create_billing_rule","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRuleRequest"}}},"required":true},"responses":{"200":{"description":"Rule inserted or updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingRuleItem"}}}},"400":{"description":"Partial owner scope, unknown key, or invalid value"},"401":{"description":"Missing, unknown, or revoked admin token"}},"security":[{"admin_token":[]}]}},"/v1/admin/billing/rules/{id}":{"delete":{"tags":["admin"],"operationId":"delete_billing_rule","parameters":[{"name":"id","in":"path","description":"Billing rule id","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Rule deleted"},"401":{"description":"Missing, unknown, or revoked admin token"},"404":{"description":"Unknown rule id"}},"security":[{"admin_token":[]}]}},"/v1/admin/owners/{owner_type}/{owner_id}/plan":{"get":{"tags":["admin"],"operationId":"get_plan","parameters":[{"name":"owner_type","in":"path","description":"Owner type, e.g. account","required":true,"schema":{"type":"string"}},{"name":"owner_id","in":"path","description":"Owner id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Assigned plan; default when unassigned","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlanResponse"}}}},"401":{"description":"Missing, unknown, or revoked admin token"}},"security":[{"admin_token":[]}]},"put":{"tags":["admin"],"operationId":"set_plan","parameters":[{"name":"owner_type","in":"path","description":"Owner type, e.g. account","required":true,"schema":{"type":"string"}},{"name":"owner_id","in":"path","description":"Owner id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetPlanRequest"}}},"required":true},"responses":{"204":{"description":"Plan assigned, replacing any previous assignment"},"400":{"description":"Unknown plan name"},"401":{"description":"Missing, unknown, or revoked admin token"}},"security":[{"admin_token":[]}]},"delete":{"tags":["admin"],"operationId":"delete_plan","parameters":[{"name":"owner_type","in":"path","description":"Owner type, e.g. account","required":true,"schema":{"type":"string"}},{"name":"owner_id","in":"path","description":"Owner id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Assignment removed"},"401":{"description":"Missing, unknown, or revoked admin token"},"404":{"description":"No assignment for this owner"}},"security":[{"admin_token":[]}]}},"/v1/admin/owners/{owner_type}/{owner_id}/usage":{"get":{"tags":["admin"],"operationId":"get_usage","parameters":[{"name":"owner_type","in":"path","description":"Owner type, e.g. account","required":true,"schema":{"type":"string"}},{"name":"owner_id","in":"path","description":"Owner id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Metered received bytes; 0 when never uploaded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UsageResponse"}}}},"401":{"description":"Missing, unknown, or revoked admin token"}},"security":[{"admin_token":[]}]}},"/v1/admin/tokens":{"get":{"tags":["admin"],"operationId":"list_tokens","responses":{"200":{"description":"All admin tokens without secret material","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminTokenList"}}}},"401":{"description":"Missing, unknown, or revoked admin token"}},"security":[{"admin_token":[]}]},"post":{"tags":["admin"],"operationId":"create_token","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTokenRequest"}}},"required":true},"responses":{"201":{"description":"Token created; the raw token is returned exactly once","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatedAdminToken"}}}},"401":{"description":"Missing, unknown, or revoked admin token"}},"security":[{"admin_token":[]}]}},"/v1/admin/tokens/{id}":{"delete":{"tags":["admin"],"operationId":"revoke_token","parameters":[{"name":"id","in":"path","description":"Admin token id","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Revoked; the token authenticates no longer"},"401":{"description":"Missing, unknown, or revoked admin token"},"404":{"description":"Unknown token id"}},"security":[{"admin_token":[]}]}},"/v1/billing/status":{"get":{"tags":["billing"],"operationId":"get_billing_status","responses":{"200":{"description":"Usage and effective limits for the authenticated owner","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingStatusResponse"}}}}},"security":[{"bearer_auth":[]}]}},"/v1/files":{"get":{"tags":["files"],"operationId":"list_files","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FileListResponse"}}}}},"security":[{"bearer_auth":[]}]},"post":{"tags":["files"],"operationId":"upload_file","responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FileResponse"}}}}},"security":[{"bearer_auth":[]}]}},"/v1/files/{id}":{"get":{"tags":["files"],"operationId":"get_file","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FileResponse"}}}},"404":{"description":""}},"security":[{"bearer_auth":[]}]},"delete":{"tags":["files"],"operationId":"delete_file","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""},"404":{"description":""}},"security":[{"bearer_auth":[]}]}},"/v1/files/{id}/download-url":{"get":{"tags":["files"],"operationId":"download_url","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UrlResponse"}}}},"404":{"description":""}},"security":[{"bearer_auth":[]}]}},"/v1/files/{id}/publish":{"post":{"tags":["files"],"operationId":"publish_file","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UrlResponse"}}}},"404":{"description":""}},"security":[{"bearer_auth":[]}]},"delete":{"tags":["files"],"operationId":"unpublish_file","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""},"404":{"description":""}},"security":[{"bearer_auth":[]}]}},"/v1/folders":{"get":{"tags":["folders"],"operationId":"list_folders","responses":{},"security":[{"bearer_auth":[]}]},"post":{"tags":["folders"],"operationId":"create_folder","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateFolderRequest"}}},"required":true},"responses":{},"security":[{"bearer_auth":[]}]}},"/v1/folders/{id}":{"get":{"tags":["folders"],"operationId":"get_folder","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{},"security":[{"bearer_auth":[]}]},"delete":{"tags":["folders"],"operationId":"delete_folder","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{},"security":[{"bearer_auth":[]}]},"patch":{"tags":["folders"],"operationId":"rename_folder","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenameFolderRequest"}}},"required":true},"responses":{},"security":[{"bearer_auth":[]}]}}},"components":{"schemas":{"AdminTokenItem":{"type":"object","description":"One row of `GET /v1/admin/tokens`; carries no secret material.","required":["id","name","created_at"],"properties":{"created_at":{"type":"string"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"revoked_at":{"type":["string","null"]}}},"AdminTokenList":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/AdminTokenItem"}}}},"BillingRuleItem":{"type":"object","description":"One billing rule as served by the admin API.","required":["id","key","value","enabled","created_at"],"properties":{"created_at":{"type":"string"},"enabled":{"type":"boolean"},"id":{"type":"string","format":"uuid"},"key":{"type":"string"},"owner_id":{"type":["string","null"]},"owner_type":{"type":["string","null"]},"value":{}}},"BillingRuleList":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/BillingRuleItem"}}}},"BillingStatusResponse":{"type":"object","description":"Storage usage and effective limits of the authenticated owner.","required":["used_bytes","storage_quota_bytes","max_file_bytes","rate_limit_rpm"],"properties":{"max_file_bytes":{"type":"integer","format":"int64"},"rate_limit_rpm":{"type":"integer","format":"int32","minimum":0},"storage_quota_bytes":{"type":"integer","format":"int64"},"used_bytes":{"type":"integer","format":"int64"}}},"CreateFolderRequest":{"type":"object","required":["name"],"properties":{"name":{"type":"string"}}},"CreateRuleRequest":{"type":"object","required":["key","value","enabled"],"properties":{"enabled":{"type":"boolean"},"key":{"type":"string"},"owner_id":{"type":["string","null"]},"owner_type":{"type":["string","null"]},"value":{}}},"CreateTokenRequest":{"type":"object","required":["name"],"properties":{"name":{"type":"string"}}},"CreatedAdminToken":{"type":"object","description":"Response of `POST /v1/admin/tokens`. `token` is the raw secret, returned\nexactly once; only its SHA-256 digest is ever persisted.","required":["id","name","token","created_at"],"properties":{"created_at":{"type":"string"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"token":{"type":"string"}}},"ErrorBody":{"type":"object","description":"JSON body shape: `{\"error\":{\"code\":...,\"message\":...}}`.","required":["error"],"properties":{"error":{"$ref":"#/components/schemas/ErrorDetail"}}},"ErrorDetail":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string"},"message":{"type":"string"}}},"FileListResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/FileResponse"}}}},"FileResponse":{"type":"object","required":["id","name","mime_type","size_bytes","is_public","created_at"],"properties":{"created_at":{"type":"string"},"folder_id":{"type":["string","null"],"format":"uuid"},"id":{"type":"string","format":"uuid"},"is_public":{"type":"boolean"},"mime_type":{"type":"string"},"name":{"type":"string"},"size_bytes":{"type":"integer","format":"int64"}}},"FolderListResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/FolderResponse"}}}},"FolderResponse":{"type":"object","required":["id","name","created_at"],"properties":{"created_at":{"type":"string"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"}}},"Limits":{"type":"object","description":"Effective limits for an owner after plan/billing resolution.","required":["storage_quota_bytes","max_file_bytes","rate_limit_rpm"],"properties":{"max_file_bytes":{"type":"integer","format":"int64"},"rate_limit_rpm":{"type":"integer","format":"int32","minimum":0},"storage_quota_bytes":{"type":"integer","format":"int64"}}},"Owner":{"type":"object","description":"Polymorphic owner reference (`owner_type:owner_id`).","required":["owner_type","owner_id"],"properties":{"owner_id":{"type":"string"},"owner_type":{"type":"string"}}},"Plan":{"type":"string","description":"Billing plan tiers.","enum":["Default","Premium"]},"PlanResponse":{"type":"object","required":["plan"],"properties":{"plan":{"type":"string"}}},"RenameFolderRequest":{"type":"object","required":["name"],"properties":{"name":{"type":"string"}}},"SetPlanRequest":{"type":"object","required":["plan"],"properties":{"plan":{"type":"string"}}},"UrlResponse":{"type":"object","required":["url"],"properties":{"url":{"type":"string"}}},"UsageResponse":{"type":"object","required":["used_bytes"],"properties":{"used_bytes":{"type":"integer","format":"int64"}}}},"securitySchemes":{"admin_token":{"type":"apiKey","in":"header","name":"x-admin-token","description":"Admin API token created via /v1/admin/tokens"},"bearer_auth":{"type":"http","scheme":"bearer","bearerFormat":"JWT"}}}} \ No newline at end of file +{"openapi":"3.1.0","info":{"title":"Booskiff","description":"Booskiff drive API: per-owner file and folder storage with streaming uploads, publishing, and download URLs, built on the billing policy foundation (plans, rules, quotas) that governs storage limits.","license":{"name":"MIT","identifier":"MIT"},"version":"0.1.0"},"paths":{"/healthz":{"get":{"tags":["health"],"operationId":"healthz","responses":{"200":{"description":""}}}},"/public/{key}":{"get":{"tags":["public"],"operationId":"get_public_file","parameters":[{"name":"key","in":"path","description":"Public key of a published file","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The file content, served with immutable caching"},"404":{"description":"Unknown or unpublished key"}}}},"/readyz":{"get":{"tags":["health"],"operationId":"ready","responses":{"200":{"description":""},"503":{"description":""}}}},"/v1/admin/billing/rules":{"get":{"tags":["admin"],"operationId":"list_billing_rules","responses":{"200":{"description":"All billing rules, global layer first","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingRuleList"}}}},"401":{"description":"Missing, unknown, or revoked admin token"}},"security":[{"admin_token":[]}]},"post":{"tags":["admin"],"operationId":"create_billing_rule","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRuleRequest"}}},"required":true},"responses":{"200":{"description":"Rule inserted or updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingRuleItem"}}}},"400":{"description":"Partial owner scope, unknown key, or invalid value"},"401":{"description":"Missing, unknown, or revoked admin token"}},"security":[{"admin_token":[]}]}},"/v1/admin/billing/rules/{id}":{"delete":{"tags":["admin"],"operationId":"delete_billing_rule","parameters":[{"name":"id","in":"path","description":"Billing rule id","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Rule deleted"},"401":{"description":"Missing, unknown, or revoked admin token"},"404":{"description":"Unknown rule id"}},"security":[{"admin_token":[]}]}},"/v1/admin/owners/{owner_type}/{owner_id}/plan":{"get":{"tags":["admin"],"operationId":"get_plan","parameters":[{"name":"owner_type","in":"path","description":"Owner type, e.g. account","required":true,"schema":{"type":"string"}},{"name":"owner_id","in":"path","description":"Owner id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Assigned plan; default when unassigned","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlanResponse"}}}},"401":{"description":"Missing, unknown, or revoked admin token"}},"security":[{"admin_token":[]}]},"put":{"tags":["admin"],"operationId":"set_plan","parameters":[{"name":"owner_type","in":"path","description":"Owner type, e.g. account","required":true,"schema":{"type":"string"}},{"name":"owner_id","in":"path","description":"Owner id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetPlanRequest"}}},"required":true},"responses":{"204":{"description":"Plan assigned, replacing any previous assignment"},"400":{"description":"Unknown plan name"},"401":{"description":"Missing, unknown, or revoked admin token"}},"security":[{"admin_token":[]}]},"delete":{"tags":["admin"],"operationId":"delete_plan","parameters":[{"name":"owner_type","in":"path","description":"Owner type, e.g. account","required":true,"schema":{"type":"string"}},{"name":"owner_id","in":"path","description":"Owner id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Assignment removed"},"401":{"description":"Missing, unknown, or revoked admin token"},"404":{"description":"No assignment for this owner"}},"security":[{"admin_token":[]}]}},"/v1/admin/owners/{owner_type}/{owner_id}/usage":{"get":{"tags":["admin"],"operationId":"get_usage","parameters":[{"name":"owner_type","in":"path","description":"Owner type, e.g. account","required":true,"schema":{"type":"string"}},{"name":"owner_id","in":"path","description":"Owner id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Metered received bytes; 0 when never uploaded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UsageResponse"}}}},"401":{"description":"Missing, unknown, or revoked admin token"}},"security":[{"admin_token":[]}]}},"/v1/admin/tokens":{"get":{"tags":["admin"],"operationId":"list_tokens","responses":{"200":{"description":"All admin tokens without secret material","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminTokenList"}}}},"401":{"description":"Missing, unknown, or revoked admin token"}},"security":[{"admin_token":[]}]},"post":{"tags":["admin"],"operationId":"create_token","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTokenRequest"}}},"required":true},"responses":{"201":{"description":"Token created; the raw token is returned exactly once","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatedAdminToken"}}}},"401":{"description":"Missing, unknown, or revoked admin token"}},"security":[{"admin_token":[]}]}},"/v1/admin/tokens/{id}":{"delete":{"tags":["admin"],"operationId":"revoke_token","parameters":[{"name":"id","in":"path","description":"Admin token id","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Revoked; the token authenticates no longer"},"401":{"description":"Missing, unknown, or revoked admin token"},"404":{"description":"Unknown token id"}},"security":[{"admin_token":[]}]}},"/v1/billing/status":{"get":{"tags":["billing"],"operationId":"get_billing_status","responses":{"200":{"description":"Usage and effective limits for the authenticated owner","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingStatusResponse"}}}}},"security":[{"bearer_auth":[]}]}},"/v1/files":{"get":{"tags":["files"],"operationId":"list_files","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int64"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","format":"int64"}},{"name":"folder_id","in":"query","description":"List immediate children of this folder.","required":false,"schema":{"type":"string","format":"uuid"}},{"name":"root","in":"query","description":"List only root files. Omitted filters preserve legacy all-files listing.","required":false,"schema":{"type":"boolean"}},{"name":"before_created_at","in":"query","description":"RFC 3339 created_at of the previous page's last file. Requires before_id.","required":false,"schema":{"type":"string"}},{"name":"before_id","in":"query","description":"ID of the previous page's last file. Requires before_created_at; incompatible with nonzero offset.","required":false,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FileListResponse"}}}},"400":{"description":""},"404":{"description":""}},"security":[{"bearer_auth":[]}]},"post":{"tags":["files"],"operationId":"upload_file","responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FileResponse"}}}}},"security":[{"bearer_auth":[]}]}},"/v1/files/{id}":{"get":{"tags":["files"],"operationId":"get_file","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FileResponse"}}}},"404":{"description":""}},"security":[{"bearer_auth":[]}]},"delete":{"tags":["files"],"operationId":"delete_file","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""},"404":{"description":""}},"security":[{"bearer_auth":[]}]}},"/v1/files/{id}/download-url":{"get":{"tags":["files"],"operationId":"download_url","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UrlResponse"}}}},"404":{"description":""}},"security":[{"bearer_auth":[]}]}},"/v1/files/{id}/publish":{"post":{"tags":["files"],"operationId":"publish_file","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UrlResponse"}}}},"404":{"description":""}},"security":[{"bearer_auth":[]}]},"delete":{"tags":["files"],"operationId":"unpublish_file","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""},"404":{"description":""}},"security":[{"bearer_auth":[]}]}},"/v1/folders":{"get":{"tags":["folders"],"operationId":"list_folders","parameters":[{"name":"root","in":"query","description":"Only root folders. Cannot be combined with parent_id.","required":false,"schema":{"type":"boolean"}},{"name":"parent_id","in":"query","description":"Only immediate children of this folder; omitted filters preserve legacy all-folders listing.","required":false,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FolderListResponse"}}}},"400":{"description":""},"404":{"description":""}},"security":[{"bearer_auth":[]}]},"post":{"tags":["folders"],"operationId":"create_folder","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateFolderRequest"}}},"required":true},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FolderResponse"}}}},"400":{"description":""},"404":{"description":""},"409":{"description":""}},"security":[{"bearer_auth":[]}]}},"/v1/folders/{id}":{"get":{"tags":["folders"],"operationId":"get_folder","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FolderResponse"}}}},"404":{"description":""}},"security":[{"bearer_auth":[]}]},"delete":{"tags":["folders"],"operationId":"delete_folder","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"require_empty","in":"query","description":"Reject deletion if the folder contains files. Child folders always prevent deletion.","required":false,"schema":{"type":"boolean"}}],"responses":{"204":{"description":""},"404":{"description":""},"409":{"description":""}},"security":[{"bearer_auth":[]}]},"patch":{"tags":["folders"],"operationId":"rename_folder","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenameFolderRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FolderResponse"}}}},"400":{"description":""},"404":{"description":""},"409":{"description":""}},"security":[{"bearer_auth":[]}]}}},"components":{"schemas":{"AdminTokenItem":{"type":"object","description":"One row of `GET /v1/admin/tokens`; carries no secret material.","required":["id","name","created_at"],"properties":{"created_at":{"type":"string"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"revoked_at":{"type":["string","null"]}}},"AdminTokenList":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/AdminTokenItem"}}}},"BillingRuleItem":{"type":"object","description":"One billing rule as served by the admin API.","required":["id","key","value","enabled","created_at"],"properties":{"created_at":{"type":"string"},"enabled":{"type":"boolean"},"id":{"type":"string","format":"uuid"},"key":{"type":"string"},"owner_id":{"type":["string","null"]},"owner_type":{"type":["string","null"]},"value":{}}},"BillingRuleList":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/BillingRuleItem"}}}},"BillingStatusResponse":{"type":"object","description":"Storage usage and effective limits of the authenticated owner.","required":["used_bytes","storage_quota_bytes","max_file_bytes","rate_limit_rpm"],"properties":{"max_file_bytes":{"type":"integer","format":"int64"},"rate_limit_rpm":{"type":"integer","format":"int32","minimum":0},"storage_quota_bytes":{"type":"integer","format":"int64"},"used_bytes":{"type":"integer","format":"int64"}}},"CreateFolderRequest":{"type":"object","required":["name"],"properties":{"name":{"type":"string"},"parent_id":{"type":["string","null"],"format":"uuid","description":"Omitted or null creates a root folder."}}},"CreateRuleRequest":{"type":"object","required":["key","value","enabled"],"properties":{"enabled":{"type":"boolean"},"key":{"type":"string"},"owner_id":{"type":["string","null"]},"owner_type":{"type":["string","null"]},"value":{}}},"CreateTokenRequest":{"type":"object","required":["name"],"properties":{"name":{"type":"string"}}},"CreatedAdminToken":{"type":"object","description":"Response of `POST /v1/admin/tokens`. `token` is the raw secret, returned\nexactly once; only its SHA-256 digest is ever persisted.","required":["id","name","token","created_at"],"properties":{"created_at":{"type":"string"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"token":{"type":"string"}}},"ErrorBody":{"type":"object","description":"JSON body shape: `{\"error\":{\"code\":...,\"message\":...}}`.","required":["error"],"properties":{"error":{"$ref":"#/components/schemas/ErrorDetail"}}},"ErrorDetail":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string"},"message":{"type":"string"}}},"FileListResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/FileResponse"}}}},"FileResponse":{"type":"object","required":["id","name","mime_type","size_bytes","is_public","created_at"],"properties":{"created_at":{"type":"string"},"folder_id":{"type":["string","null"],"format":"uuid"},"id":{"type":"string","format":"uuid"},"is_public":{"type":"boolean"},"mime_type":{"type":"string"},"name":{"type":"string"},"size_bytes":{"type":"integer","format":"int64"}}},"FolderListResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/FolderResponse"}}}},"FolderResponse":{"type":"object","required":["id","name","created_at"],"properties":{"created_at":{"type":"string"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"parent_id":{"type":["string","null"],"format":"uuid"}}},"Limits":{"type":"object","description":"Effective limits for an owner after plan/billing resolution.","required":["storage_quota_bytes","max_file_bytes","rate_limit_rpm"],"properties":{"max_file_bytes":{"type":"integer","format":"int64"},"rate_limit_rpm":{"type":"integer","format":"int32","minimum":0},"storage_quota_bytes":{"type":"integer","format":"int64"}}},"Owner":{"type":"object","description":"Polymorphic owner reference (`owner_type:owner_id`).","required":["owner_type","owner_id"],"properties":{"owner_id":{"type":"string"},"owner_type":{"type":"string"}}},"Plan":{"type":"string","description":"Billing plan tiers.","enum":["Default","Premium"]},"PlanResponse":{"type":"object","required":["plan"],"properties":{"plan":{"type":"string"}}},"RenameFolderRequest":{"type":"object","required":["name"],"properties":{"name":{"type":"string"}}},"SetPlanRequest":{"type":"object","required":["plan"],"properties":{"plan":{"type":"string"}}},"UrlResponse":{"type":"object","required":["url"],"properties":{"url":{"type":"string"}}},"UsageResponse":{"type":"object","required":["used_bytes"],"properties":{"used_bytes":{"type":"integer","format":"int64"}}}},"securitySchemes":{"admin_token":{"type":"apiKey","in":"header","name":"x-admin-token","description":"Admin API token created via /v1/admin/tokens"},"bearer_auth":{"type":"http","scheme":"bearer","bearerFormat":"JWT"}}}} \ No newline at end of file