diff --git a/backend/.sqlx/query-1234aea9adc718af10d13bb6a757310bbdee8c3d53edd733d93fd30a0ecf3c06.json b/backend/.sqlx/query-1234aea9adc718af10d13bb6a757310bbdee8c3d53edd733d93fd30a0ecf3c06.json new file mode 100644 index 0000000..75be8f6 --- /dev/null +++ b/backend/.sqlx/query-1234aea9adc718af10d13bb6a757310bbdee8c3d53edd733d93fd30a0ecf3c06.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH sub_files AS (\n DELETE FROM files\n WHERE\n owner_id = $1\n AND parent_id_path >= $2\n AND parent_id_path < $2 || NULL::bytea\n RETURNING created_at, modified_at, id, name, parent_id_path, size,\n content_id, type, shared\n )\n INSERT INTO trashed_files\n (trashed_at, id, created_at, modified_at, name, owner_id, original_id,\n parent_id_path, size, content_id, type, was_shared)\n SELECT\n NULL, ($3::bytea[])[row_number() OVER ()], created_at, modified_at, name,\n $1, id, parent_id_path[$4 + 1:], size, content_id, type, shared\n FROM sub_files", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bytea", + "ByteaArray", + "ByteaArray", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "1234aea9adc718af10d13bb6a757310bbdee8c3d53edd733d93fd30a0ecf3c06" +} diff --git a/backend/.sqlx/query-1294b7697008e444b2b2208c7df89cc41a08b2c4ef1585d5e0fad0cad2504c64.json b/backend/.sqlx/query-1294b7697008e444b2b2208c7df89cc41a08b2c4ef1585d5e0fad0cad2504c64.json new file mode 100644 index 0000000..15e49a6 --- /dev/null +++ b/backend/.sqlx/query-1294b7697008e444b2b2208c7df89cc41a08b2c4ef1585d5e0fad0cad2504c64.json @@ -0,0 +1,36 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH file AS (\n DELETE FROM files\n WHERE owner_id = $1 AND id = $2\n RETURNING created_at, modified_at, name, parent_id_path, size, content_id, type,\n shared\n )\n INSERT INTO trashed_files \n (created_at, modified_at, id, name, owner_id, parent_id_path,\n original_parent_id_path, original_id, size, content_id, type, was_shared)\n SELECT\n created_at, modified_at, $3, name, $1, ARRAY[]::bytea[], parent_id_path, $2,\n size, content_id, type, shared\n FROM file\n RETURNING trashed_at, original_parent_id_path, size", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "trashed_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 1, + "name": "original_parent_id_path", + "type_info": "ByteaArray" + }, + { + "ordinal": 2, + "name": "size", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Bytea", + "Bytea", + "Bytea" + ] + }, + "nullable": [ + true, + true, + false + ] + }, + "hash": "1294b7697008e444b2b2208c7df89cc41a08b2c4ef1585d5e0fad0cad2504c64" +} diff --git a/backend/.sqlx/query-4681320fa141523f06aa5665f78fc2804b2437af1443360b8d474a2ec3aa4bed.json b/backend/.sqlx/query-4681320fa141523f06aa5665f78fc2804b2437af1443360b8d474a2ec3aa4bed.json new file mode 100644 index 0000000..28fa6f0 --- /dev/null +++ b/backend/.sqlx/query-4681320fa141523f06aa5665f78fc2804b2437af1443360b8d474a2ec3aa4bed.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) AS \"count!\" FROM files\n WHERE\n owner_id = $1 AND\n parent_id_path >= $2 AND\n parent_id_path < $2 || NULL::bytea", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Bytea", + "ByteaArray" + ] + }, + "nullable": [ + null + ] + }, + "hash": "4681320fa141523f06aa5665f78fc2804b2437af1443360b8d474a2ec3aa4bed" +} diff --git a/backend/.sqlx/query-91a4d18b5b76cf4fa256dae7c8c785611500783cab03a7b612b47795ff091494.json b/backend/.sqlx/query-91a4d18b5b76cf4fa256dae7c8c785611500783cab03a7b612b47795ff091494.json new file mode 100644 index 0000000..8ac4da5 --- /dev/null +++ b/backend/.sqlx/query-91a4d18b5b76cf4fa256dae7c8c785611500783cab03a7b612b47795ff091494.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH folder AS (\n DELETE FROM folders\n WHERE owner_id = $1 AND id = $2\n RETURNING created_at, name, parent_id_path, browse_key, size, shared\n )\n INSERT INTO trashed_folders\n (created_at, id, name, owner_id, parent_id_path, original_parent_id_path,\n browse_key, size, was_shared)\n SELECT\n created_at, $2, name, $1, ARRAY[]::bytea[], parent_id_path, browse_key, size,\n shared\n FROM folder\n RETURNING trashed_at, original_parent_id_path, size", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "trashed_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 1, + "name": "original_parent_id_path", + "type_info": "ByteaArray" + }, + { + "ordinal": 2, + "name": "size", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Bytea", + "Bytea" + ] + }, + "nullable": [ + false, + true, + false + ] + }, + "hash": "91a4d18b5b76cf4fa256dae7c8c785611500783cab03a7b612b47795ff091494" +} diff --git a/backend/.sqlx/query-e290dd8fc3fcf47790f27f58b479ffb25226d8735092fb5f55677eb1b87da2ed.json b/backend/.sqlx/query-e290dd8fc3fcf47790f27f58b479ffb25226d8735092fb5f55677eb1b87da2ed.json new file mode 100644 index 0000000..812be2c --- /dev/null +++ b/backend/.sqlx/query-e290dd8fc3fcf47790f27f58b479ffb25226d8735092fb5f55677eb1b87da2ed.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH sub_folders AS (\n DELETE FROM folders\n WHERE\n owner_id = $1\n AND parent_id_path >= $2\n AND parent_id_path < $2 || NULL::bytea\n RETURNING created_at, id, name, parent_id_path, browse_key, size, shared\n )\n INSERT INTO trashed_folders\n (trashed_at, owner_id, created_at, id, name, parent_id_path, browse_key, size,\n was_shared)\n SELECT\n NULL, $1, created_at, id, name, parent_id_path[$3 + 1:], browse_key, size,\n shared\n FROM sub_folders", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bytea", + "ByteaArray", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "e290dd8fc3fcf47790f27f58b479ffb25226d8735092fb5f55677eb1b87da2ed" +} diff --git a/backend/migrations/20240522023049_initialize.sql b/backend/migrations/20240522023049_initialize.sql index 6b3bf1e..04faa02 100644 --- a/backend/migrations/20240522023049_initialize.sql +++ b/backend/migrations/20240522023049_initialize.sql @@ -218,8 +218,8 @@ CREATE TABLE trashed_files ( name text NOT NULL, owner_id bytea NOT NULL REFERENCES users (id) ON DELETE CASCADE, parent_id_path bytea[] NOT NULL, - original_parent_id_path bytea[], original_id bytea NOT NULL, + original_parent_id_path bytea[], size bigint NOT NULL, content_id bytea NOT NULL REFERENCES file_contents (id), type text NOT NULL, diff --git a/backend/src/api/routes.rs b/backend/src/api/routes.rs index fcb8f74..681ae3c 100644 --- a/backend/src/api/routes.rs +++ b/backend/src/api/routes.rs @@ -39,6 +39,7 @@ pub(super) static ROUTER: LazyLock = LazyLock::new(|| { "/files/{file_id}/share", delete(v0::files::file::share::delete).post(v0::files::file::share::post), ) + .route("/files/{file_id}/trash", post(v0::files::file::trash::post)) .route("/folders", post(v0::folders::post)) .route( "/folders/{folder_id}/name", @@ -52,6 +53,10 @@ pub(super) static ROUTER: LazyLock = LazyLock::new(|| { "/folders/{folder_id}/share", delete(v0::folders::folder::share::delete).post(v0::folders::folder::share::post), ) + .route( + "/folders/{folder_id}/trash", + post(v0::folders::folder::trash::post), + ) .route( "/password-reset", get(v0::password_reset::get).post(v0::password_reset::post), diff --git a/backend/src/api/routes/v0/files/file.rs b/backend/src/api/routes/v0/files/file.rs index 99e79bf..3b3aa0c 100644 --- a/backend/src/api/routes/v0/files/file.rs +++ b/backend/src/api/routes/v0/files/file.rs @@ -17,6 +17,7 @@ use crate::{ pub(crate) mod r#move; pub(crate) mod name; pub(crate) mod share; +pub(crate) mod trash; /// A request path for this API route. type PathParams = Path; diff --git a/backend/src/api/routes/v0/files/file/trash.rs b/backend/src/api/routes/v0/files/file/trash.rs new file mode 100644 index 0000000..7aa88cf --- /dev/null +++ b/backend/src/api/routes/v0/files/file/trash.rs @@ -0,0 +1,125 @@ +//! See [`post`]. + +use axum::http::header::LOCATION; +use axum_macros::debug_handler; +use reqwest::StatusCode; +use serde::Serialize; + +use crate::{ + api::{ + self, Json, + extract::{AuthToken, Path}, + response::Response, + }, + db::{self, TxError, TxResult}, + id::{Id, NewFileId}, +}; + +/// A request path for this API route. +type PathParams = Path; + +/// Moves a file to the current authenticated user's trash. +/// +/// # Errors +/// +/// See [`crate::api::Error`]. +#[debug_handler] +pub(crate) async fn post( + Path(file_id): PathParams, + AuthToken(token_hash): AuthToken, +) -> impl Response { + let (trashed_file_id, trashed_at) = db::transaction!(async |tx| -> TxResult<_, api::Error> { + let Some(session) = sqlx::query!( + "SELECT user_id FROM sessions + WHERE token_hash = $1", + token_hash.as_ref(), + ) + .fetch_optional(tx.as_mut()) + .await? + else { + return Err(TxError::Abort(api::Error::AuthFailed)); + }; + + let trashed_file_id = NewFileId::generate(); + + let trashed_file = match sqlx::query!( + "WITH file AS ( + DELETE FROM files + WHERE owner_id = $1 AND id = $2 + RETURNING created_at, modified_at, name, parent_id_path, size, content_id, type, + shared + ) + INSERT INTO trashed_files + (created_at, modified_at, id, name, owner_id, parent_id_path, + original_parent_id_path, original_id, size, content_id, type, was_shared) + SELECT + created_at, modified_at, $3, name, $1, ARRAY[]::bytea[], parent_id_path, $2, + size, content_id, type, shared + FROM file + RETURNING trashed_at, original_parent_id_path, size", + session.user_id, + file_id.as_slice(), + trashed_file_id.as_slice(), + ) + .fetch_optional(tx.as_mut()) + .await + { + Err(sqlx::Error::Database(error)) + if error.constraint() == Some("trashed_files_pkey") => + { + return Err(TxError::Retry); + } + + Err(error) => return Err(error.into()), + + Ok(None) => return Err(TxError::Abort(api::Error::AccessDenied)), + + Ok(Some(trashed_file)) => trashed_file, + }; + + let original_parent_id_path = trashed_file + .original_parent_id_path + .expect("root trashed files should have `original_parent_id_path`"); + + // TODO: Also cancel replacement file uploads and subtract their size from ancestors. + if !original_parent_id_path.is_empty() { + sqlx::query!( + "UPDATE folders + SET size = size - $1 + WHERE id = ANY($2)", + trashed_file.size, + original_parent_id_path.as_slice() + ) + .execute(tx.as_mut()) + .await?; + } + + Ok(( + trashed_file_id, + trashed_file + .trashed_at + .expect("root trashed files should have `trashed_at`"), + )) + }) + .await?; + + Ok(( + StatusCode::CREATED, + [(LOCATION, format!("/api/v0/TODO/{trashed_file_id}"))], + Json(PostResponse { + id: trashed_file_id, + trashed_at: trashed_at.timestamp_millis(), + }), + )) +} + +/// A `POST` response body for this API route. +#[derive(Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PostResponse { + /// The new trashed file's ID. + id: NewFileId, + + /// The new trashed file's creation timestamp in Unix milliseconds. + trashed_at: i64, +} diff --git a/backend/src/api/routes/v0/folders/folder.rs b/backend/src/api/routes/v0/folders/folder.rs index 12551b2..cf69442 100644 --- a/backend/src/api/routes/v0/folders/folder.rs +++ b/backend/src/api/routes/v0/folders/folder.rs @@ -3,3 +3,4 @@ pub(crate) mod r#move; pub(crate) mod name; pub(crate) mod share; +pub(crate) mod trash; diff --git a/backend/src/api/routes/v0/folders/folder/trash.rs b/backend/src/api/routes/v0/folders/folder/trash.rs new file mode 100644 index 0000000..206e479 --- /dev/null +++ b/backend/src/api/routes/v0/folders/folder/trash.rs @@ -0,0 +1,193 @@ +//! See [`post`]. + +use std::iter; + +use axum::http::header::LOCATION; +use axum_macros::debug_handler; +use reqwest::StatusCode; +use serde::Serialize; + +use crate::{ + api::{ + self, Json, + extract::{AuthToken, Path}, + response::Response, + }, + db::{self, TxError, TxResult}, + id::{Id, NewFileId}, +}; + +/// A request path for this API route. +type PathParams = Path; + +/// Moves a folder to the current authenticated user's trash. +/// +/// # Errors +/// +/// See [`crate::api::Error`]. +#[debug_handler] +pub(crate) async fn post( + Path(folder_id): PathParams, + AuthToken(token_hash): AuthToken, +) -> impl Response { + let trashed_at = db::transaction!(async |tx| -> TxResult<_, api::Error> { + let Some(session) = sqlx::query!( + "SELECT user_id FROM sessions + WHERE token_hash = $1", + token_hash.as_ref(), + ) + .fetch_optional(tx.as_mut()) + .await? + else { + return Err(TxError::Abort(api::Error::AuthFailed)); + }; + + let Some(trashed_folder) = sqlx::query!( + r#"WITH folder AS ( + DELETE FROM folders + WHERE owner_id = $1 AND id = $2 + RETURNING created_at, name, parent_id_path, browse_key, size, shared + ) + INSERT INTO trashed_folders + (created_at, id, name, owner_id, parent_id_path, original_parent_id_path, + browse_key, size, was_shared) + SELECT + created_at, $2, name, $1, ARRAY[]::bytea[], parent_id_path, browse_key, size, + shared + FROM folder + RETURNING trashed_at, original_parent_id_path, size"#, + session.user_id, + folder_id.as_slice(), + ) + .fetch_optional(tx.as_mut()) + .await? + else { + return Err(TxError::Abort(api::Error::AccessDenied)); + }; + + let original_parent_id_path = trashed_folder + .original_parent_id_path + .expect("root trashed folders should have `original_parent_id_path`"); + + if !original_parent_id_path.is_empty() { + sqlx::query!( + "UPDATE folders + SET size = size - $1 + WHERE id = ANY($2)", + trashed_folder.size, + original_parent_id_path.as_slice() + ) + .execute(tx.as_mut()) + .await?; + } + + let original_depth = original_parent_id_path.len(); + + let mut original_id_path = original_parent_id_path; + original_id_path.push(folder_id.to_vec()); + + sqlx::query!( + "WITH sub_folders AS ( + DELETE FROM folders + WHERE + owner_id = $1 + AND parent_id_path >= $2 + AND parent_id_path < $2 || NULL::bytea + RETURNING created_at, id, name, parent_id_path, browse_key, size, shared + ) + INSERT INTO trashed_folders + (trashed_at, owner_id, created_at, id, name, parent_id_path, browse_key, size, + was_shared) + SELECT + NULL, $1, created_at, id, name, parent_id_path[$3 + 1:], browse_key, size, + shared + FROM sub_folders", + session.user_id, + original_id_path.as_slice(), + original_depth as i32, + ) + .execute(tx.as_mut()) + .await?; + + let sub_files = sqlx::query!( + r#"SELECT COUNT(*) AS "count!" FROM files + WHERE + owner_id = $1 AND + parent_id_path >= $2 AND + parent_id_path < $2 || NULL::bytea"#, + session.user_id, + original_id_path.as_slice(), + ) + .fetch_one(tx.as_mut()) + .await?; + + // TODO: Fix race condition from adding a file under the trashed folder after this point. + + if sub_files.count != 0 { + // TODO: Also cancel replacement file uploads. + + let trashed_file_ids: Vec<_> = iter::repeat_with(NewFileId::generate) + // TODO: If transact-rs/sqlx#4357 is completed, replace `to_vec` with `into_inner`. + .map(|id| id.to_vec()) + .take(sub_files.count as usize) + .collect(); + + match sqlx::query!( + "WITH sub_files AS ( + DELETE FROM files + WHERE + owner_id = $1 + AND parent_id_path >= $2 + AND parent_id_path < $2 || NULL::bytea + RETURNING created_at, modified_at, id, name, parent_id_path, size, + content_id, type, shared + ) + INSERT INTO trashed_files + (trashed_at, id, created_at, modified_at, name, owner_id, original_id, + parent_id_path, size, content_id, type, was_shared) + SELECT + NULL, ($3::bytea[])[row_number() OVER ()], created_at, modified_at, name, + $1, id, parent_id_path[$4 + 1:], size, content_id, type, shared + FROM sub_files", + session.user_id, + original_id_path.as_slice(), + trashed_file_ids.as_slice(), + original_depth as i32, + ) + .execute(tx.as_mut()) + .await + { + Err(sqlx::Error::Database(error)) + if error.constraint() == Some("trashed_files_pkey") => + { + return Err(TxError::Retry); + } + + trashed_file => trashed_file?, + }; + } + + Ok(trashed_folder.trashed_at) + }) + .await?; + + Ok(( + StatusCode::CREATED, + [(LOCATION, format!("/api/v0/TODO/{folder_id}"))], + Json(PostResponse { + id: folder_id, + trashed_at: trashed_at.timestamp_millis(), + }), + )) +} + +/// A `POST` response body for this API route. +#[derive(Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PostResponse { + /// The new trashed folder's ID. + id: Id, + + /// The new trashed folder's creation timestamp in Unix milliseconds. + trashed_at: i64, +} diff --git a/backend/src/id.rs b/backend/src/id.rs index d7ab6e2..1b9f447 100644 --- a/backend/src/id.rs +++ b/backend/src/id.rs @@ -20,6 +20,10 @@ pub(crate) type Token = Id<[u8; 128]>; /// The type to create new folder IDs with. Note that existing folder IDs may not fit this type. pub(crate) type NewFolderId = Id<[u8; 9]>; +// TODO: Reconsider using fully random IDs for database performance. +/// The type to create new file IDs with. Note that existing file IDs may not fit this type. +pub(crate) type NewFileId = Id<[u8; 9]>; + /// A folder's browse key. pub(crate) type FolderBrowseKey = Id<[u8; 24]>;