diff --git a/e2e/src/tests/storage/objects.rs b/e2e/src/tests/storage/objects.rs index 903fdc917..69c82401e 100644 --- a/e2e/src/tests/storage/objects.rs +++ b/e2e/src/tests/storage/objects.rs @@ -42,7 +42,7 @@ async fn put_get_delete() { ); let unrelated_user = Keypair::random().public_key(); - // PUT and DELETE only operate on file-shaped `/storage` paths. + // PUT still rejects directory-shaped paths let response = session .client() .request(Method::PUT, &directory_url) @@ -53,6 +53,7 @@ async fn put_get_delete() { .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); + // DELETE now succeeds on directory-shaped paths (recursive folder delete) let response = session .client() .request(Method::DELETE, &directory_url) @@ -60,7 +61,7 @@ async fn put_get_delete() { .send() .await .unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(response.status(), StatusCode::NO_CONTENT); let response = session .client() diff --git a/pubky-homeserver/openapi-client.yml b/pubky-homeserver/openapi-client.yml index 04f09f9eb..bad253871 100644 --- a/pubky-homeserver/openapi-client.yml +++ b/pubky-homeserver/openapi-client.yml @@ -568,7 +568,7 @@ paths: delete: tags: - Data - summary: Delete a path-addressed file + summary: Delete a path-addressed file or folder description: | The owner in `/storage/{user_z32}/{path}` is authoritative; any `pubky-host` header or query parameter is accepted for compatibility @@ -576,13 +576,19 @@ paths: The authenticated user must match `user_z32` and have write capability covering the storage path. + + Deleting a file removes that file. Deleting a folder path (trailing `/`) + recursively removes the folder and all its descendants (WebDAV + `Depth: infinity` semantics), emitting one `DEL` event per file. + Recursive deletion is synchronous: very large trees may run into + HTTP timeouts. operationId: deletePathAddressedEntry security: - bearerAuth: [] - cookieAuth: [] responses: '204': - description: File deleted. + description: File or folder deleted. '400': description: Invalid owner public key or storage path. '401': @@ -591,6 +597,85 @@ paths: description: Insufficient permissions or path outside `pub/` and `priv/`. '404': description: File or storage owner not found. + copy: + tags: + - Data + summary: Copy a path-addressed file (WebDAV COPY) + description: | + WebDAV `COPY` (RFC 4918). Copies the file at `{path}` to the + `Destination` header path within the same tenant, replacing the two + client calls (read + write) with one server-side operation. + + The `Destination` header accepts a storage path (`/pub/app/new.txt`) + or a path-addressed URL path (`/storage/{user_z32}/pub/app/new.txt`) + for the same user. An existing destination file is overwritten. + Source and destination must be files under `pub/` or `priv/` covered + by the session's write capability. Emits a `PUT` event for the + destination. + operationId: copyPathAddressedEntry + security: + - bearerAuth: [] + - cookieAuth: [] + parameters: + - name: Destination + in: header + required: true + description: Target storage path on the same tenant. + schema: + type: string + responses: + '201': + description: File copied. + '400': + description: Invalid owner public key, storage path, or Destination header. + '401': + description: No valid session. + '403': + description: Insufficient permissions or path outside `pub/` and `priv/`. + '404': + description: Source file or storage owner not found. + '409': + description: File/folder path collision at the destination. + '507': + description: Storage quota exceeded. + move: + tags: + - Data + summary: Move a path-addressed file (WebDAV MOVE) + description: | + WebDAV `MOVE` (RFC 4918). Moves the file at `{path}` to the + `Destination` header path within the same tenant, replacing the three + client calls (read + write + delete) with one server-side operation. + + Behaves like `COPY` followed by `DELETE` of the source: emits a `PUT` + event for the destination and a `DEL` event for the source. An existing + destination file is overwritten. + operationId: movePathAddressedEntry + security: + - bearerAuth: [] + - cookieAuth: [] + parameters: + - name: Destination + in: header + required: true + description: Target storage path on the same tenant. + schema: + type: string + responses: + '201': + description: File moved. + '400': + description: Invalid owner public key, storage path, or Destination header. + '401': + description: No valid session. + '403': + description: Insufficient permissions or path outside `pub/` and `priv/`. + '404': + description: Source file or storage owner not found. + '409': + description: File/folder path collision at the destination. + '507': + description: Storage quota exceeded. "/{path}": parameters: - name: path @@ -838,12 +923,14 @@ paths: delete: tags: - Data - summary: Delete file + summary: Delete file or folder deprecated: true description: | Deprecated: use `DELETE /storage/{user_z32}/{path}`. - Deletes a file at the given path. Path must be under `/pub/` or `/priv/`. + Deletes a file at the given path. A folder path (trailing `/`) recursively + deletes the folder and all descendants (WebDAV `Depth: infinity` semantics), + emitting one `DEL` event per file. Path must be under `/pub/` or `/priv/`. The authenticated user must match the target tenant and have write capability. operationId: deleteEntry security: @@ -851,13 +938,88 @@ paths: - cookieAuth: [] responses: '204': - description: File deleted + description: File or folder deleted '401': description: No valid session '403': description: Insufficient permissions or path outside `/pub/` and `/priv/` '404': description: File not found + copy: + tags: + - Data + summary: Copy file (WebDAV COPY) + deprecated: true + description: | + Deprecated: use `COPY /storage/{user_z32}/{path}`. + + WebDAV `COPY` (RFC 4918). Copies the file at `{path}` to the `Destination` + header path within the same tenant. An existing destination file is + overwritten. Emits a `PUT` event for the destination. + operationId: copyEntry + security: + - bearerAuth: [] + - cookieAuth: [] + parameters: + - name: Destination + in: header + required: true + description: Target storage path on the same tenant. + schema: + type: string + responses: + '201': + description: File copied + '400': + description: Invalid storage path or Destination header + '401': + description: No valid session + '403': + description: Insufficient permissions or path outside `/pub/` and `/priv/` + '404': + description: Source file not found + '409': + description: File/folder path collision at the destination + '507': + description: Storage quota exceeded + move: + tags: + - Data + summary: Move file (WebDAV MOVE) + deprecated: true + description: | + Deprecated: use `MOVE /storage/{user_z32}/{path}`. + + WebDAV `MOVE` (RFC 4918). Moves the file at `{path}` to the `Destination` + header path within the same tenant: `COPY` followed by `DELETE` of the + source. Emits a `PUT` event for the destination and a `DEL` event for the + source. An existing destination file is overwritten. + operationId: moveEntry + security: + - bearerAuth: [] + - cookieAuth: [] + parameters: + - name: Destination + in: header + required: true + description: Target storage path on the same tenant. + schema: + type: string + responses: + '201': + description: File moved + '400': + description: Invalid storage path or Destination header + '401': + description: No valid session + '403': + description: Insufficient permissions or path outside `/pub/` and `/priv/` + '404': + description: Source file not found + '409': + description: File/folder path collision at the destination + '507': + description: Storage quota exceeded "/events/": get: tags: diff --git a/pubky-homeserver/src/client_server/routes/tenants/mod.rs b/pubky-homeserver/src/client_server/routes/tenants/mod.rs index ea5c25338..3902e1003 100644 --- a/pubky-homeserver/src/client_server/routes/tenants/mod.rs +++ b/pubky-homeserver/src/client_server/routes/tenants/mod.rs @@ -23,14 +23,16 @@ pub fn router() -> Router { get(read::get) .head(read::head) .put(write::put) - .delete(write::delete), + .delete(write::delete) + .fallback(write::webdav_extension_method), ) .route( "/{*path}", get(read::legacy_get) .head(read::legacy_head) .put(write::legacy_put) - .delete(write::legacy_delete), + .delete(write::legacy_delete) + .fallback(write::webdav_extension_method), ) // TODO: different max size for sessions and other routes? .layer(DefaultBodyLimit::max(100 * 1024 * 1024)) diff --git a/pubky-homeserver/src/client_server/routes/tenants/read.rs b/pubky-homeserver/src/client_server/routes/tenants/read.rs index 4abbd1598..ffc8beeeb 100644 --- a/pubky-homeserver/src/client_server/routes/tenants/read.rs +++ b/pubky-homeserver/src/client_server/routes/tenants/read.rs @@ -270,7 +270,7 @@ impl EntryEntity { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use axum::http::{header, HeaderMap, Method, StatusCode}; use axum::Router; use axum_test::TestServer; diff --git a/pubky-homeserver/src/client_server/routes/tenants/write.rs b/pubky-homeserver/src/client_server/routes/tenants/write.rs index 3a99dc700..f9bb469cf 100644 --- a/pubky-homeserver/src/client_server/routes/tenants/write.rs +++ b/pubky-homeserver/src/client_server/routes/tenants/write.rs @@ -1,11 +1,13 @@ use axum::http::HeaderMap; use axum::{ body::Body, - extract::{Path, State}, + extract::{Path, Request, State}, http::StatusCode, response::IntoResponse, }; use futures_util::stream::StreamExt; +use percent_encoding::percent_decode_str; +use std::str::FromStr; use crate::{ client_server::{ @@ -22,7 +24,7 @@ use crate::{ }, services::user_service::FILE_METADATA_SIZE, shared::{ - webdav::{EntryPath, WebDavFilePathAxum}, + webdav::{EntryPath, StoragePath, WebDavFilePathAxum, WebDavPathAxum}, HttpError, HttpResult, }, }; @@ -31,7 +33,7 @@ pub async fn legacy_delete( state: State, session: AuthSession, tenant: RequestTenant, - Path(path): Path, + Path(path): Path, ) -> HttpResult { let entry_path = EntryPath::new(tenant.public_key().clone(), path.inner().to_owned()); delete(state, session, entry_path).await @@ -42,9 +44,6 @@ pub async fn delete( session: AuthSession, entry_path: EntryPath, ) -> HttpResult { - if !entry_path.path().is_file() { - return Err(HttpError::bad_request("Target path must be a file")); - } has_write_permission(&session, entry_path.pubkey(), entry_path.path())?; state @@ -52,7 +51,11 @@ pub async fn delete( .get_or_http_error(entry_path.pubkey(), false) .await?; - state.file_service.delete(&entry_path).await?; + if entry_path.path().is_file() { + state.file_service.delete(&entry_path).await?; + } else { + state.file_service.delete_folder(&entry_path).await?; + } Ok((StatusCode::NO_CONTENT, ())) } @@ -112,6 +115,107 @@ pub async fn put( Ok((StatusCode::CREATED, ())) } +/// Method router fallback for WebDAV extension methods (COPY, MOVE). +/// +/// axum's `MethodFilter` only covers standard HTTP methods, so COPY/MOVE +/// requests land here. Anything else is a 405 — checked before auth so that +/// unsupported methods behave exactly as if no fallback were registered. +pub async fn webdav_extension_method( + State(state): State, + tenant: RequestTenant, + req: Request, +) -> HttpResult { + let is_move = match req.method().as_str() { + "COPY" => false, + "MOVE" => true, + _ => { + return Err(HttpError::method_not_allowed()); + } + }; + + // COPY/MOVE are writes: the authentication middleware must have resolved + // a session into the request extensions. + let session = req + .extensions() + .get::() + .cloned() + .ok_or_else(|| HttpError::unauthorized_with_message("No valid session"))?; + + let from = source_entry_path(&tenant, req.uri().path())?; + if !from.path().is_file() { + return Err(HttpError::bad_request("Source path must be a file")); + } + let to = destination_entry_path(&tenant, &req)?; + + has_write_permission(&session, from.pubkey(), from.path())?; + has_write_permission(&session, to.pubkey(), to.path())?; + + state + .user_service + .get_or_http_error(from.pubkey(), false) + .await?; + + if is_move { + state.file_service.move_file(&from, &to).await?; + } else { + state.file_service.copy(&from, &to).await?; + } + Ok((StatusCode::CREATED, ())) +} + +/// Resolve the COPY/MOVE source path from the request URI and tenant. +fn source_entry_path(tenant: &RequestTenant, uri_path: &str) -> HttpResult { + if let Some(path) = tenant.storage_path() { + // Path-addressed route: tenant middleware already parsed the path. + return Ok(EntryPath::new(tenant.public_key().clone(), path.clone())); + } + // Legacy owner-relative route: parse the URI path ourselves, mirroring the + // percent-decoding the tenant middleware applies to storage routes. + let decoded = percent_decode_str(uri_path.trim_start_matches('/')) + .decode_utf8() + .map_err(|_| HttpError::bad_request("Storage path is not valid UTF-8"))?; + let path = WebDavPathAxum::from_str(&decoded) + .map_err(|e| HttpError::bad_request(format!("Invalid storage path: {e}")))?; + Ok(EntryPath::new( + tenant.public_key().clone(), + path.inner().to_owned(), + )) +} + +/// Parse the WebDAV `Destination` header into an `EntryPath` on the same tenant. +fn destination_entry_path(tenant: &RequestTenant, req: &Request) -> HttpResult { + let raw = req + .headers() + .get("Destination") + .ok_or_else(|| HttpError::bad_request("Missing Destination header"))? + .to_str() + .map_err(|_| HttpError::bad_request("Destination header is not valid UTF-8"))?; + + // Accept either a bare storage path (`/pub/app/file.txt`) or a full + // path-addressed URL path (`/storage/{user_z32}/pub/app/file.txt`). + let path_str = match raw.strip_prefix("/storage/") { + Some(remainder) => { + let (owner, path) = remainder + .split_once('/') + .ok_or_else(|| HttpError::bad_request("Invalid Destination header"))?; + if owner != tenant.public_key().z32() { + return Err(HttpError::bad_request( + "Cross-user COPY/MOVE destinations are not supported", + )); + } + format!("/{path}") + } + None => raw.to_string(), + }; + + let path = StoragePath::normalize(&path_str) + .map_err(|e| HttpError::bad_request(format!("Invalid Destination path: {e}")))?; + if !path.is_file() { + return Err(HttpError::bad_request("Destination path must be a file")); + } + Ok(EntryPath::new(tenant.public_key().clone(), path)) +} + /// Parse the `Content-Length` header into a `u64`, returning `None` if absent or unparseable. fn content_length_from_headers(headers: &HeaderMap) -> Option { headers @@ -265,4 +369,238 @@ mod tests { .await .expect("unlimited quota should accept any size"); } + + mod routes { + use axum::http::{header, Method, StatusCode}; + + use crate::client_server::routes::tenants::read::tests::create_environment; + + fn copy_method() -> Method { + Method::from_bytes(b"COPY").unwrap() + } + + fn move_method() -> Method { + Method::from_bytes(b"MOVE").unwrap() + } + + #[tokio::test] + #[pubky_test_utils::test] + async fn copy_and_move_file() { + let (_, _, server, public_key, cookie) = create_environment().await.unwrap(); + + server + .put(format!("/storage/{}/pub/app/original.txt", public_key.z32()).as_str()) + .add_header(header::COOKIE, cookie.clone()) + .text("content") + .expect_success() + .await; + + // COPY duplicates the file. + server + .method( + copy_method(), + format!("/storage/{}/pub/app/original.txt", public_key.z32()).as_str(), + ) + .add_header(header::COOKIE, cookie.clone()) + .add_header("Destination", "/pub/app/copied.txt") + .expect_success() + .await; + + let resp = server + .get(format!("/storage/{}/pub/app/original.txt", public_key.z32()).as_str()) + .expect_success() + .await; + assert_eq!(resp.text(), "content", "source must remain after COPY"); + let resp = server + .get(format!("/storage/{}/pub/app/copied.txt", public_key.z32()).as_str()) + .expect_success() + .await; + assert_eq!(resp.text(), "content"); + + // MOVE relocates the copy. + server + .method( + move_method(), + format!("/storage/{}/pub/app/copied.txt", public_key.z32()).as_str(), + ) + .add_header(header::COOKIE, cookie.clone()) + .add_header("Destination", "/pub/app/moved.txt") + .expect_success() + .await; + + server + .get(format!("/storage/{}/pub/app/copied.txt", public_key.z32()).as_str()) + .expect_failure() + .await + .assert_status(StatusCode::NOT_FOUND); + let resp = server + .get(format!("/storage/{}/pub/app/moved.txt", public_key.z32()).as_str()) + .expect_success() + .await; + assert_eq!(resp.text(), "content"); + } + + #[tokio::test] + #[pubky_test_utils::test] + async fn copy_requires_destination_header_and_file_paths() { + let (_, _, server, public_key, cookie) = create_environment().await.unwrap(); + + server + .put(format!("/storage/{}/pub/app/file.txt", public_key.z32()).as_str()) + .add_header(header::COOKIE, cookie.clone()) + .text("content") + .expect_success() + .await; + + // Missing Destination header → 400. + server + .method( + copy_method(), + format!("/storage/{}/pub/app/file.txt", public_key.z32()).as_str(), + ) + .add_header(header::COOKIE, cookie.clone()) + .expect_failure() + .await + .assert_status(StatusCode::BAD_REQUEST); + + // Folder-shaped source → 400. + server + .method( + copy_method(), + format!("/storage/{}/pub/app/", public_key.z32()).as_str(), + ) + .add_header(header::COOKIE, cookie.clone()) + .add_header("Destination", "/pub/app/other.txt") + .expect_failure() + .await + .assert_status(StatusCode::BAD_REQUEST); + + // Folder-shaped destination → 400. + server + .method( + copy_method(), + format!("/storage/{}/pub/app/file.txt", public_key.z32()).as_str(), + ) + .add_header(header::COOKIE, cookie.clone()) + .add_header("Destination", "/pub/app/folder/") + .expect_failure() + .await + .assert_status(StatusCode::BAD_REQUEST); + + // Missing source file → 404. + server + .method( + copy_method(), + format!("/storage/{}/pub/app/missing.txt", public_key.z32()).as_str(), + ) + .add_header(header::COOKIE, cookie.clone()) + .add_header("Destination", "/pub/app/other.txt") + .expect_failure() + .await + .assert_status(StatusCode::NOT_FOUND); + } + + #[tokio::test] + #[pubky_test_utils::test] + async fn delete_folder_recursively() { + let (_, _, server, public_key, cookie) = create_environment().await.unwrap(); + + for path in [ + "/pub/app/folder/a.txt", + "/pub/app/folder/sub/b.txt", + "/pub/app/other.txt", + ] { + server + .put(format!("/storage/{}{}", public_key.z32(), path).as_str()) + .add_header(header::COOKIE, cookie.clone()) + .text("x") + .expect_success() + .await; + } + + server + .delete(format!("/storage/{}/pub/app/folder/", public_key.z32()).as_str()) + .add_header(header::COOKIE, cookie.clone()) + .expect_success() + .await; + + for path in ["/pub/app/folder/a.txt", "/pub/app/folder/sub/b.txt"] { + server + .get(format!("/storage/{}{}", public_key.z32(), path).as_str()) + .expect_failure() + .await + .assert_status(StatusCode::NOT_FOUND); + } + server + .get(format!("/storage/{}/pub/app/other.txt", public_key.z32()).as_str()) + .expect_success() + .await; + } + + #[tokio::test] + #[pubky_test_utils::test] + async fn legacy_routes_support_copy_move_and_folder_delete() { + let (_, _, server, public_key, cookie) = create_environment().await.unwrap(); + + server + .put("/pub/app/original.txt") + .add_header("host", public_key.z32()) + .add_header(header::COOKIE, cookie.clone()) + .text("content") + .expect_success() + .await; + + server + .method(copy_method(), "/pub/app/original.txt") + .add_header("host", public_key.z32()) + .add_header(header::COOKIE, cookie.clone()) + .add_header("Destination", "/pub/app/copied.txt") + .expect_success() + .await; + + server + .method(move_method(), "/pub/app/copied.txt") + .add_header("host", public_key.z32()) + .add_header(header::COOKIE, cookie.clone()) + .add_header("Destination", "/pub/app/folder/moved.txt") + .expect_success() + .await; + + server + .delete("/pub/app/folder/") + .add_header("host", public_key.z32()) + .add_header(header::COOKIE, cookie.clone()) + .expect_success() + .await; + + server + .get("/pub/app/folder/moved.txt") + .add_header("host", public_key.z32()) + .expect_failure() + .await + .assert_status(StatusCode::NOT_FOUND); + let resp = server + .get("/pub/app/original.txt") + .add_header("host", public_key.z32()) + .expect_success() + .await; + assert_eq!(resp.text(), "content"); + } + + #[tokio::test] + #[pubky_test_utils::test] + async fn unsupported_methods_are_rejected() { + let (_, _, server, public_key, cookie) = create_environment().await.unwrap(); + + server + .method( + Method::from_bytes(b"PROPFIND").unwrap(), + format!("/storage/{}/pub/app/file.txt", public_key.z32()).as_str(), + ) + .add_header(header::COOKIE, cookie.clone()) + .expect_failure() + .await + .assert_status(StatusCode::METHOD_NOT_ALLOWED); + } + } } diff --git a/pubky-homeserver/src/persistence/files/file/file_service.rs b/pubky-homeserver/src/persistence/files/file/file_service.rs index 1169045e1..8ddb5e6e4 100644 --- a/pubky-homeserver/src/persistence/files/file/file_service.rs +++ b/pubky-homeserver/src/persistence/files/file/file_service.rs @@ -108,6 +108,68 @@ impl FileService { self.opendal.admin_delete(path).await?; Ok(()) } + + /// Copy a file from `from` to `to` (same tenant). + /// + /// Composed from `get_stream` + `write_stream` so that write finalization + /// (quota, entry upsert, PUT event) runs for the destination. Overwrites + /// the destination if it exists. + pub async fn copy(&self, from: &EntryPath, to: &EntryPath) -> Result { + use futures_util::StreamExt; + let stream = self + .get_stream(from) + .await? + .map(|chunk| chunk.map_err(|e| WriteStreamError::Other(e.into()))); + self.write_stream(to, stream).await + } + + /// Move a file from `from` to `to` (same tenant). + /// + /// Composed from `copy` + `delete` so that finalization runs for both the + /// destination (PUT event, quota increase) and the source (DEL event, + /// quota decrease). + pub async fn move_file( + &self, + from: &EntryPath, + to: &EntryPath, + ) -> Result { + let entry = self.copy(from, to).await?; + self.delete(from).await?; + Ok(entry) + } + + /// Recursively delete a folder and all its descendants. + /// + /// Lists the folder's entries page by page and deletes each file + /// individually, so every file goes through delete finalization (entry + /// removal, quota decrease, DEL event). Synchronous: for very large trees + /// the caller may run into an HTTP timeout. + /// + /// Deleting a non-existing or empty folder will NOT return an error. + pub async fn delete_folder(&self, path: &EntryPath) -> Result<(), FileIoError> { + const PAGE_SIZE: u16 = 1000; + // No cursor: deleted entries disappear from subsequent pages, so + // re-listing from the start eventually drains the folder. + loop { + let page = EntryRepository::list_deep( + path, + Some(PAGE_SIZE), + None, + false, + &mut self.db.pool().into(), + ) + .await?; + if page.is_empty() { + return Ok(()); + } + for entry_path in page { + // Unconditional delete: finalization removes the SQL row even + // when the blob is already gone, so every listed entry makes + // progress and the loop always terminates. + self.opendal.delete(&entry_path).await?; + } + } + } } #[cfg(test)] @@ -482,4 +544,170 @@ mod tests { test_data.len() as u64 + FILE_METADATA_SIZE ); } + + #[tokio::test] + #[pubky_test_utils::test] + async fn test_copy_file() { + let context = AppContext::test().await; + let file_service = FileService::new_from_context(&context).unwrap(); + let user_service = context.user_service.clone(); + + let pubkey = pubky_common::crypto::Keypair::random().public_key(); + user_service.create(&pubkey).await.unwrap(); + + let from = EntryPath::new( + pubkey.clone(), + StoragePath::new("/pub/app/from.txt").unwrap(), + ); + let to = EntryPath::new(pubkey.clone(), StoragePath::new("/pub/app/to.txt").unwrap()); + let data = Buffer::from(b"copy me".as_slice()); + + file_service.write(&from, data.clone()).await.unwrap(); + file_service.copy(&from, &to).await.unwrap(); + + // Source untouched, destination has the content. + assert_eq!( + file_service.get(&from).await.unwrap(), + data.to_vec().as_slice() + ); + assert_eq!( + file_service.get(&to).await.unwrap(), + data.to_vec().as_slice() + ); + + // Quota counts both files. + let per_file = data.len() as u64 + FILE_METADATA_SIZE; + assert_eq!( + user_service.get(&pubkey).await.unwrap().used_bytes, + per_file * 2 + ); + } + + #[tokio::test] + #[pubky_test_utils::test] + async fn test_copy_missing_source_is_not_found() { + let context = AppContext::test().await; + let file_service = FileService::new_from_context(&context).unwrap(); + let user_service = context.user_service.clone(); + + let pubkey = pubky_common::crypto::Keypair::random().public_key(); + user_service.create(&pubkey).await.unwrap(); + + let from = EntryPath::new( + pubkey.clone(), + StoragePath::new("/pub/app/none.txt").unwrap(), + ); + let to = EntryPath::new(pubkey.clone(), StoragePath::new("/pub/app/to.txt").unwrap()); + + let err = file_service + .copy(&from, &to) + .await + .expect_err("missing source"); + assert!(matches!(err, FileIoError::NotFound)); + } + + #[tokio::test] + #[pubky_test_utils::test] + async fn test_move_file() { + let context = AppContext::test().await; + let file_service = FileService::new_from_context(&context).unwrap(); + let user_service = context.user_service.clone(); + + let pubkey = pubky_common::crypto::Keypair::random().public_key(); + user_service.create(&pubkey).await.unwrap(); + + let from = EntryPath::new( + pubkey.clone(), + StoragePath::new("/pub/app/from.txt").unwrap(), + ); + let to = EntryPath::new(pubkey.clone(), StoragePath::new("/pub/app/to.txt").unwrap()); + let data = Buffer::from(b"move me".as_slice()); + + file_service.write(&from, data.clone()).await.unwrap(); + file_service.move_file(&from, &to).await.unwrap(); + + // Source gone, destination has the content. + file_service + .get(&from) + .await + .expect_err("source should be deleted after move"); + assert_eq!( + file_service.get(&to).await.unwrap(), + data.to_vec().as_slice() + ); + + // Quota counts only the destination file. + assert_eq!( + user_service.get(&pubkey).await.unwrap().used_bytes, + data.len() as u64 + FILE_METADATA_SIZE + ); + } + + #[tokio::test] + #[pubky_test_utils::test] + async fn test_delete_folder_recursive() { + let context = AppContext::test().await; + let file_service = FileService::new_from_context(&context).unwrap(); + let user_service = context.user_service.clone(); + + let pubkey = pubky_common::crypto::Keypair::random().public_key(); + user_service.create(&pubkey).await.unwrap(); + + let data = Buffer::from(b"x".as_slice()); + let inside = [ + "/pub/app/folder/a.txt", + "/pub/app/folder/sub/b.txt", + "/pub/app/folder/sub/deep/c.txt", + ]; + let outside = "/pub/app/other.txt"; + for p in inside.iter().chain([outside].iter()) { + let path = EntryPath::new(pubkey.clone(), StoragePath::new(p).unwrap()); + file_service.write(&path, data.clone()).await.unwrap(); + } + assert_eq!( + user_service.get(&pubkey).await.unwrap().used_bytes, + 4 * (data.len() as u64 + FILE_METADATA_SIZE) + ); + + let folder = EntryPath::new( + pubkey.clone(), + StoragePath::new("/pub/app/folder/").unwrap(), + ); + file_service.delete_folder(&folder).await.unwrap(); + + for p in inside { + let path = EntryPath::new(pubkey.clone(), StoragePath::new(p).unwrap()); + file_service + .get(&path) + .await + .expect_err("folder contents should be deleted"); + } + let outside_path = EntryPath::new(pubkey.clone(), StoragePath::new(outside).unwrap()); + assert_eq!( + file_service.get(&outside_path).await.unwrap(), + data.to_vec().as_slice(), + "files outside the folder must remain" + ); + assert_eq!( + user_service.get(&pubkey).await.unwrap().used_bytes, + data.len() as u64 + FILE_METADATA_SIZE + ); + } + + #[tokio::test] + #[pubky_test_utils::test] + async fn test_delete_folder_empty_or_missing_is_ok() { + let context = AppContext::test().await; + let file_service = FileService::new_from_context(&context).unwrap(); + let user_service = context.user_service.clone(); + + let pubkey = pubky_common::crypto::Keypair::random().public_key(); + user_service.create(&pubkey).await.unwrap(); + + let folder = EntryPath::new(pubkey.clone(), StoragePath::new("/pub/app/none/").unwrap()); + file_service + .delete_folder(&folder) + .await + .expect("deleting a missing folder should be a no-op"); + } } diff --git a/pubky-homeserver/src/shared/http_error.rs b/pubky-homeserver/src/shared/http_error.rs index fc59c4621..668cbb3be 100644 --- a/pubky-homeserver/src/shared/http_error.rs +++ b/pubky-homeserver/src/shared/http_error.rs @@ -66,6 +66,10 @@ impl HttpError { pub fn unauthorized_with_message(message: impl ToString) -> HttpError { Self::new_with_message(StatusCode::UNAUTHORIZED, message) } + + pub fn method_not_allowed() -> HttpError { + Self::new_with_message(StatusCode::METHOD_NOT_ALLOWED, "Method not allowed") + } } impl IntoResponse for HttpError { diff --git a/pubky-sdk/bindings/js/src/actors/storage/session.rs b/pubky-sdk/bindings/js/src/actors/storage/session.rs index d4c834b87..ee19b8804 100644 --- a/pubky-sdk/bindings/js/src/actors/storage/session.rs +++ b/pubky-sdk/bindings/js/src/actors/storage/session.rs @@ -165,7 +165,8 @@ impl SessionStorage { Ok(()) } - /// Delete a path (file or empty directory). + /// Delete a path. A folder path (trailing `/`) is deleted recursively, + /// removing all descendants. /// /// @param {Path} path /// @returns {Promise} @@ -177,4 +178,36 @@ impl SessionStorage { self.0.delete(path).await?; Ok(()) } + + /// Copy a file from one absolute session path to another (server-side). + /// An existing destination file is overwritten. + /// + /// @param {Path} from Source file path. + /// @param {Path} to Destination file path. + /// @returns {Promise} + #[wasm_bindgen] + pub async fn copy( + &self, + #[wasm_bindgen(unchecked_param_type = "Path")] from: String, + #[wasm_bindgen(unchecked_param_type = "Path")] to: String, + ) -> JsResult<()> { + self.0.copy(from, to).await?; + Ok(()) + } + + /// Move a file from one absolute session path to another (server-side). + /// An existing destination file is overwritten. + /// + /// @param {Path} from Source file path. + /// @param {Path} to Destination file path. + /// @returns {Promise} + #[wasm_bindgen(js_name = "moveFile")] + pub async fn move_file( + &self, + #[wasm_bindgen(unchecked_param_type = "Path")] from: String, + #[wasm_bindgen(unchecked_param_type = "Path")] to: String, + ) -> JsResult<()> { + self.0.move_file(from, to).await?; + Ok(()) + } } diff --git a/pubky-sdk/src/actors/storage/verbs.rs b/pubky-sdk/src/actors/storage/verbs.rs index 3ec80e04f..07cab1477 100644 --- a/pubky-sdk/src/actors/storage/verbs.rs +++ b/pubky-sdk/src/actors/storage/verbs.rs @@ -1,8 +1,11 @@ +use std::str::FromStr; + use reqwest::{Method, RequestBuilder, Response, StatusCode}; use super::core::{PublicStorage, SessionStorage}; use super::resource::{IntoPubkyResource, IntoResourcePath}; use super::stats::ResourceStats; +use crate::errors::RequestError; use crate::{Result, cross_log, util::check_http_status}; /// Interpret the result of a `HEAD` request into a shared outcome used by both @@ -107,6 +110,9 @@ impl SessionStorage { /// HTTP `DELETE` for an **absolute path**. /// + /// Deleting a folder path (trailing `/`) recursively deletes the folder and + /// all its descendants (`WebDAV` `Depth: infinity` semantics). + /// /// # Errors /// - [`crate::errors::Error::Request`] on HTTP transport failures or when the server /// responds with a non-success status (the server message is captured). @@ -116,6 +122,54 @@ impl SessionStorage { let rb = self.request(Method::DELETE, path).await?; send_checked(rb).await } + + /// `WebDAV` `COPY` for an **absolute path**: copies the file at `from` to + /// `to` on the homeserver, replacing a client-side read + write. + /// + /// An existing destination file is overwritten. Both paths must be files + /// covered by the session's write capability. + /// + /// # Errors + /// - [`crate::errors::Error::Request`] on HTTP transport failures or when the server + /// responds with a non-success status (the server message is captured). + /// - [`crate::errors::Error::Parse`] if either path cannot be converted into a valid + /// resource/URL. + pub async fn copy(&self, from: P, to: P) -> Result { + self.copy_or_move(from, to, "COPY").await + } + + /// `WebDAV` `MOVE` for an **absolute path**: moves the file at `from` to + /// `to` on the homeserver, replacing a client-side read + write + delete. + /// + /// An existing destination file is overwritten. Both paths must be files + /// covered by the session's write capability. + /// + /// # Errors + /// - [`crate::errors::Error::Request`] on HTTP transport failures or when the server + /// responds with a non-success status (the server message is captured). + /// - [`crate::errors::Error::Parse`] if either path cannot be converted into a valid + /// resource/URL. + pub async fn move_file(&self, from: P, to: P) -> Result { + self.copy_or_move(from, to, "MOVE").await + } + + /// Shared implementation of the `WebDAV` `COPY` and `MOVE` verbs. + async fn copy_or_move( + &self, + from: P, + to: P, + method_str: &'static str, + ) -> Result { + let to_path: super::resource::ResourcePath = to.into_abs_path()?; + let method = Method::from_str(method_str).map_err(|_e| RequestError::Validation { + message: format!("Invalid method: {method_str}"), + })?; + let rb = self + .request(method, from) + .await? + .header("Destination", to_path.as_str()); + send_checked(rb).await + } } //