diff --git a/.sqlx/query-4d0a77b9d1ffaf9b9f73d5f6d09635983c46087e33c453a043b7eed3359fcf79.json b/.sqlx/query-4d0a77b9d1ffaf9b9f73d5f6d09635983c46087e33c453a043b7eed3359fcf79.json new file mode 100644 index 0000000..b667e45 --- /dev/null +++ b/.sqlx/query-4d0a77b9d1ffaf9b9f73d5f6d09635983c46087e33c453a043b7eed3359fcf79.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE folders\n SET parent_id_path = $1 || parent_id_path[array_length($2::bytea[], 1) + 1:],\n parent_name_path = $3 || parent_name_path[array_length($2::text[], 1) + 1:]\n WHERE owner_id = $4 AND parent_id_path >= $2 AND parent_id_path < $2 || NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "ByteaArray", + "ByteaArray", + "TextArray", + "Bytea" + ] + }, + "nullable": [] + }, + "hash": "4d0a77b9d1ffaf9b9f73d5f6d09635983c46087e33c453a043b7eed3359fcf79" +} diff --git a/.sqlx/query-03f0b0ccf489a72fb4febcc7fa723be60816fc2df8ff2817fc3bc33bcfd51794.json b/.sqlx/query-86e5645fe0035529323fa1b2b249568ef978d5c5268627388915ac3522dbbcdf.json similarity index 70% rename from .sqlx/query-03f0b0ccf489a72fb4febcc7fa723be60816fc2df8ff2817fc3bc33bcfd51794.json rename to .sqlx/query-86e5645fe0035529323fa1b2b249568ef978d5c5268627388915ac3522dbbcdf.json index 3506657..9e8760e 100644 --- a/.sqlx/query-03f0b0ccf489a72fb4febcc7fa723be60816fc2df8ff2817fc3bc33bcfd51794.json +++ b/.sqlx/query-86e5645fe0035529323fa1b2b249568ef978d5c5268627388915ac3522dbbcdf.json @@ -1,22 +1,22 @@ { "db_name": "PostgreSQL", - "query": "SELECT parent_id_path, parent_name_path, name FROM folders\n WHERE owner_id = $1 AND id = $2", + "query": "SELECT name, parent_id_path, parent_name_path FROM folders\n WHERE owner_id = $1 AND id = $2\n FOR UPDATE", "describe": { "columns": [ { "ordinal": 0, - "name": "parent_id_path", - "type_info": "ByteaArray" + "name": "name", + "type_info": "Text" }, { "ordinal": 1, - "name": "parent_name_path", - "type_info": "TextArray" + "name": "parent_id_path", + "type_info": "ByteaArray" }, { "ordinal": 2, - "name": "name", - "type_info": "Text" + "name": "parent_name_path", + "type_info": "TextArray" } ], "parameters": { @@ -31,5 +31,5 @@ false ] }, - "hash": "03f0b0ccf489a72fb4febcc7fa723be60816fc2df8ff2817fc3bc33bcfd51794" + "hash": "86e5645fe0035529323fa1b2b249568ef978d5c5268627388915ac3522dbbcdf" } diff --git a/.sqlx/query-bdae8b41c094ed6c7a9266bc831eb71416064386f522423ad40064893ff33b7d.json b/.sqlx/query-bdae8b41c094ed6c7a9266bc831eb71416064386f522423ad40064893ff33b7d.json new file mode 100644 index 0000000..0cd4a3d --- /dev/null +++ b/.sqlx/query-bdae8b41c094ed6c7a9266bc831eb71416064386f522423ad40064893ff33b7d.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE files\n SET parent_id_path = $1 || parent_id_path[array_length($2::bytea[], 1) + 1:],\n parent_name_path = $3 || parent_name_path[array_length($2::text[], 1) + 1:]\n WHERE owner_id = $4 AND parent_id_path >= $2 AND parent_id_path < $2 || NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "ByteaArray", + "ByteaArray", + "TextArray", + "Bytea" + ] + }, + "nullable": [] + }, + "hash": "bdae8b41c094ed6c7a9266bc831eb71416064386f522423ad40064893ff33b7d" +} diff --git a/.sqlx/query-d4fdf1f4e1c747a7c4b2f3fbd6fb7118c6b21f23ff074f99308a11a5e9349957.json b/.sqlx/query-d4fdf1f4e1c747a7c4b2f3fbd6fb7118c6b21f23ff074f99308a11a5e9349957.json new file mode 100644 index 0000000..2ea107e --- /dev/null +++ b/.sqlx/query-d4fdf1f4e1c747a7c4b2f3fbd6fb7118c6b21f23ff074f99308a11a5e9349957.json @@ -0,0 +1,37 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE folders\n SET parent_id_path = $1,\n parent_name_path = $2\n WHERE owner_id = $3 AND id = $4\n RETURNING\n name,\n size,\n OLD.parent_id_path AS old_parent_id_path", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "size", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "old_parent_id_path", + "type_info": "ByteaArray" + } + ], + "parameters": { + "Left": [ + "ByteaArray", + "TextArray", + "Bytea", + "Bytea" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "d4fdf1f4e1c747a7c4b2f3fbd6fb7118c6b21f23ff074f99308a11a5e9349957" +} diff --git a/backend/api/db_helpers.rs b/backend/api/db_helpers.rs index edb04d0..03517fb 100644 --- a/backend/api/db_helpers.rs +++ b/backend/api/db_helpers.rs @@ -2,7 +2,12 @@ use sqlx::PgTransaction; -use crate::{crypto::hash_without_salt, db::TxResult, id::Token}; +use crate::{ + api, + crypto::hash_without_salt, + db::{TxError, TxResult}, + id::Token, +}; /// Creates a new user session and returns its token. /// @@ -31,3 +36,36 @@ where Ok(token) } + +/// `SELECT`s a folder's ID and name paths `FOR UPDATE` if the user has permission to modify the +/// folder's contents. +/// +/// # Errors +/// +/// Returns an error if a database query fails, or if the user doesn't have access to the folder. +pub(crate) async fn query_folder_paths_for_update( + tx: &mut PgTransaction<'static>, + user_id: &[u8], + folder_id: &[u8], +) -> TxResult<(Vec>, Vec), api::Error> { + let Some(folder) = sqlx::query!( + "SELECT name, parent_id_path, parent_name_path FROM folders + WHERE owner_id = $1 AND id = $2 + FOR UPDATE", + user_id, + folder_id, + ) + .fetch_optional(tx.as_mut()) + .await? + else { + return Err(TxError::Abort(api::Error::AccessDenied)); + }; + + let mut id_path = folder.parent_id_path; + id_path.push(folder_id.to_vec()); + + let mut name_path = folder.parent_name_path; + name_path.push(folder.name); + + Ok((id_path, name_path)) +} diff --git a/backend/api/routes.rs b/backend/api/routes.rs index 74a5bdc..4451b5d 100644 --- a/backend/api/routes.rs +++ b/backend/api/routes.rs @@ -43,6 +43,10 @@ pub(super) static ROUTER: LazyLock = LazyLock::new(|| { "/folders/{folder_id}/name", put(v0::folders::folder::name::put), ) + .route( + "/folders/{folder_id}/move", + post(v0::folders::folder::r#move::post), + ) .route( "/folders/{folder_id}/share", delete(v0::folders::folder::share::delete).post(v0::folders::folder::share::post), diff --git a/backend/api/routes/v0/files/file/move.rs b/backend/api/routes/v0/files/file/move.rs index 588ebce..80cff14 100644 --- a/backend/api/routes/v0/files/file/move.rs +++ b/backend/api/routes/v0/files/file/move.rs @@ -3,11 +3,11 @@ use axum_macros::debug_handler; use reqwest::StatusCode; use serde::{Deserialize, Serialize}; -use sqlx::PgTransaction; use crate::{ api::{ self, Json, + db_helpers::query_folder_paths_for_update, extract::{AuthToken, Path}, response::Response, }, @@ -51,7 +51,7 @@ pub(crate) async fn post( let (new_parent_id_path, new_parent_name_path) = match &body.parent_folder_id { Some(parent_folder_id) => { - query_folder_paths(tx, &session.user_id, parent_folder_id).await? + query_folder_paths_for_update(tx, &session.user_id, parent_folder_id).await? } None => (vec![], vec![]), }; @@ -118,34 +118,3 @@ pub(crate) async fn post( #[derive(Serialize, Debug)] #[serde(rename_all = "camelCase")] pub(crate) struct PostResponse {} - -/// Obtains a folder's ID and name paths. -/// -/// # Errors -/// -/// Returns an error if a database query fails, or if the user doesn't have access to the folder. -async fn query_folder_paths( - tx: &mut PgTransaction<'static>, - user_id: &[u8], - folder_id: &[u8], -) -> TxResult<(Vec>, Vec), api::Error> { - let Some(folder) = sqlx::query!( - "SELECT parent_id_path, parent_name_path, name FROM folders - WHERE owner_id = $1 AND id = $2", - user_id, - folder_id, - ) - .fetch_optional(tx.as_mut()) - .await? - else { - return Err(TxError::Abort(api::Error::AccessDenied)); - }; - - let mut id_path = folder.parent_id_path; - id_path.push(folder_id.to_vec()); - - let mut name_path = folder.parent_name_path; - name_path.push(folder.name); - - Ok((id_path, name_path)) -} diff --git a/backend/api/routes/v0/folders/folder.rs b/backend/api/routes/v0/folders/folder.rs index 447303b..12551b2 100644 --- a/backend/api/routes/v0/folders/folder.rs +++ b/backend/api/routes/v0/folders/folder.rs @@ -1,4 +1,5 @@ //! A folder. +pub(crate) mod r#move; pub(crate) mod name; pub(crate) mod share; diff --git a/backend/api/routes/v0/folders/folder/move.rs b/backend/api/routes/v0/folders/folder/move.rs new file mode 100644 index 0000000..b4def61 --- /dev/null +++ b/backend/api/routes/v0/folders/folder/move.rs @@ -0,0 +1,158 @@ +//! See [`post`]. + +use axum_macros::debug_handler; +use reqwest::StatusCode; +use serde::{Deserialize, Serialize}; + +use crate::{ + api::{ + self, Json, + db_helpers::query_folder_paths_for_update, + extract::{AuthToken, Path}, + response::Response, + }, + db::{self, TxError, TxResult}, + id::Id, +}; + +/// A request path for this API route. +type PathParams = Path; + +/// A `POST` request body for this API route. +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PostRequest { + /// The new parent folder's ID, or [`None`] for the root directory. + pub parent_folder_id: Option, +} + +/// Changes a folder's parent folder. +/// +/// # Errors +/// +/// See [`crate::api::Error`]. +#[debug_handler] +pub(crate) async fn post( + Path(folder_id): PathParams, + AuthToken(token_hash): AuthToken, + Json(body): Json, +) -> impl Response { + 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 (new_parent_id_path, new_parent_name_path) = match &body.parent_folder_id { + Some(parent_folder_id) => { + query_folder_paths_for_update(tx, &session.user_id, parent_folder_id).await? + } + None => (vec![], vec![]), + }; + + let folder = match sqlx::query!( + "UPDATE folders + SET parent_id_path = $1, + parent_name_path = $2 + WHERE owner_id = $3 AND id = $4 + RETURNING + name, + size, + OLD.parent_id_path AS old_parent_id_path", + new_parent_id_path.as_slice(), + new_parent_name_path.as_slice(), + session.user_id, + folder_id.as_slice(), + ) + .fetch_optional(tx.as_mut()) + .await + { + Err(sqlx::Error::Database(error)) + if error.constraint() == Some("folders_by_name_path") => + { + return Err(TxError::Abort(api::Error::AlreadyExists)); + } + + Err(error) => return Err(error.into()), + + Ok(None) => return Err(TxError::Abort(api::Error::AccessDenied)), + + Ok(Some(folder)) => folder, + }; + + if !folder.old_parent_id_path.is_empty() { + sqlx::query!( + "UPDATE folders + SET size = size - $1 + WHERE id = ANY($2)", + folder.size, + folder.old_parent_id_path.as_slice(), + ) + .execute(tx.as_mut()) + .await?; + } + + if !new_parent_id_path.is_empty() { + sqlx::query!( + "UPDATE folders + SET size = size + $1 + WHERE id = ANY($2)", + folder.size, + new_parent_id_path.as_slice(), + ) + .execute(tx.as_mut()) + .await?; + } + + let mut old_folder_id_path = folder.old_parent_id_path; + old_folder_id_path.push(folder_id.to_vec()); + + let mut new_folder_id_path = new_parent_id_path; + new_folder_id_path.push(folder_id.to_vec()); + + let mut new_folder_name_path = new_parent_name_path; + new_folder_name_path.push(folder.name); + + sqlx::query!( + "UPDATE folders + SET parent_id_path = $1 || parent_id_path[array_length($2::bytea[], 1) + 1:], + parent_name_path = $3 || parent_name_path[array_length($2::text[], 1) + 1:] + WHERE owner_id = $4 AND parent_id_path >= $2 AND parent_id_path < $2 || NULL", + new_folder_id_path.as_slice(), + old_folder_id_path.as_slice(), + new_folder_name_path.as_slice(), + session.user_id, + ) + .execute(tx.as_mut()) + .await?; + + sqlx::query!( + "UPDATE files + SET parent_id_path = $1 || parent_id_path[array_length($2::bytea[], 1) + 1:], + parent_name_path = $3 || parent_name_path[array_length($2::text[], 1) + 1:] + WHERE owner_id = $4 AND parent_id_path >= $2 AND parent_id_path < $2 || NULL", + new_folder_id_path.as_slice(), + old_folder_id_path.as_slice(), + new_folder_name_path.as_slice(), + session.user_id, + ) + .execute(tx.as_mut()) + .await?; + + Ok(()) + }) + .await?; + + Ok((StatusCode::OK, Json(PostResponse {}))) +} + +/// A `POST` response body for this API route. +#[derive(Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PostResponse {}