From d8eecbd9ca643b5948325436338b6e80799c259e Mon Sep 17 00:00:00 2001 From: Jared Wolff Date: Thu, 5 Mar 2026 20:42:30 -0500 Subject: [PATCH] fix(nc): enable Nextcloud Android app connectivity and uploads - Add /remote.php/dav discovery endpoint for Android app server detection - Add /index.php/204 connectivity check endpoint (returns 204 No Content) - Redirect login flow to nc:// deep link for mobile credential delivery - Support GET/HEAD on folders (NC clients use as existence checks) - Recursive MKCOL to create missing parent directories - Fix single-file PROPFIND returning empty multistatus response - Strip instance suffix from preview fileId (e.g. "00000326ocnca") - Add recommendations stub endpoint --- src/interfaces/api/handlers/auth_handler.rs | 6 +- src/interfaces/nextcloud/login_v2_handler.rs | 13 +- src/interfaces/nextcloud/ocs_handler.rs | 13 ++ src/interfaces/nextcloud/preview_handler.rs | 6 +- src/interfaces/nextcloud/routes.rs | 28 ++++ src/interfaces/nextcloud/webdav_handler.rs | 156 ++++++++++++++----- 6 files changed, 180 insertions(+), 42 deletions(-) diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index 2f262393..4d66b474 100755 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -621,7 +621,11 @@ async fn oidc_callback( user = %username, "OIDC login completed Nextcloud Login Flow v2 successfully" ); - Ok(Redirect::temporary("/nextcloud-success.html")) + let nc_url = format!( + "nc://login/server:{}&user:{}&password:{}", + base_url, username, app_password + ); + Ok(Redirect::temporary(&nc_url)) } else { tracing::error!( user = %username, diff --git a/src/interfaces/nextcloud/login_v2_handler.rs b/src/interfaces/nextcloud/login_v2_handler.rs index 5b1ceae8..7b247293 100644 --- a/src/interfaces/nextcloud/login_v2_handler.rs +++ b/src/interfaces/nextcloud/login_v2_handler.rs @@ -196,16 +196,21 @@ pub async fn handle_login_submit( base_url = %base_url, "Login Flow v2: flow completed successfully" ); + // Redirect to nc:// deep link so the Nextcloud mobile app receives + // the credentials via Android/iOS intent. Desktop clients use polling + // instead, so they will pick up the result from the poll endpoint. + let nc_url = format!( + "nc://login/server:{}&user:{}&password:{}", + base_url, current_user.username, app_password + ); + axum::response::Redirect::to(&nc_url).into_response() } else { tracing::error!( user = %current_user.username, "Login Flow v2: complete() returned false — flow token not found" ); - return axum::response::Redirect::to("/nextcloud-error.html?type=session-expired") - .into_response(); + axum::response::Redirect::to("/nextcloud-error.html?type=session-expired").into_response() } - - html_with_csp(include_str!("../../../static/nextcloud-success.html")) } /// GET /login/v2/flow/{token}/oidc — Start an OIDC authorization flow that is diff --git a/src/interfaces/nextcloud/ocs_handler.rs b/src/interfaces/nextcloud/ocs_handler.rs index 73cf2dca..8b5a28c8 100644 --- a/src/interfaces/nextcloud/ocs_handler.rs +++ b/src/interfaces/nextcloud/ocs_handler.rs @@ -229,6 +229,19 @@ pub async fn handle_notifications_push() -> Response { Json(ocs_ok(200, json!({}))).into_response() } +/// GET /ocs/v2.php/apps/recommendations/api/v1/recommendations +/// +/// Returns recommended files. Stub that returns an empty list. +pub async fn handle_recommendations() -> Response { + Json(json!({ + "ocs": { + "meta": { "status": "ok", "statuscode": 200, "message": "OK" }, + "data": [] + } + })) + .into_response() +} + /// GET /ocs/v2.php/apps/files_sharing/api/v1/sharees?search={query}&itemType={type} /// /// Returns matching users for the sharing autocomplete UI. diff --git a/src/interfaces/nextcloud/preview_handler.rs b/src/interfaces/nextcloud/preview_handler.rs index 80364b9e..2cf95b7e 100644 --- a/src/interfaces/nextcloud/preview_handler.rs +++ b/src/interfaces/nextcloud/preview_handler.rs @@ -37,8 +37,10 @@ pub async fn handle_preview( user: CurrentUser, Query(params): Query, ) -> impl IntoResponse { - // Parse the Nextcloud file ID (numeric) to get the OxiCloud UUID - let nc_file_id: i64 = match params.file_id.parse() { + // Parse the Nextcloud file ID — the NC app may append an instance suffix + // (e.g. "00000326ocnca"), so strip non-digit characters first. + let numeric_part: String = params.file_id.chars().take_while(|c| c.is_ascii_digit()).collect(); + let nc_file_id: i64 = match numeric_part.parse() { Ok(id) => id, Err(_) => { return Response::builder() diff --git a/src/interfaces/nextcloud/routes.rs b/src/interfaces/nextcloud/routes.rs index 70fcb037..60788e31 100644 --- a/src/interfaces/nextcloud/routes.rs +++ b/src/interfaces/nextcloud/routes.rs @@ -40,6 +40,11 @@ pub fn nextcloud_routes_with_state(state: Arc) -> Router // Public routes — no auth required. let public = Router::new() .route("/status.php", get(status_handler::handle_status)) + // NC connectivity check — app expects 204 to confirm server is reachable. + .route("/index.php/204", get(handle_connectivity_check)) + // Bare /remote.php/dav — NC clients probe this to confirm WebDAV is available. + .route("/remote.php/dav", any(handle_dav_discovery)) + .route("/remote.php/dav/", any(handle_dav_discovery)) .route( "/index.php/login/v2", post(login_v2_handler::handle_login_initiate), @@ -96,6 +101,10 @@ pub fn nextcloud_routes_with_state(state: Arc) -> Router "/ocs/v2.php/apps/notifications/api/v2/push", post(ocs_handler::handle_notifications_push), ) + .route( + "/ocs/v2.php/apps/recommendations/api/v1/recommendations", + get(ocs_handler::handle_recommendations), + ) .route( "/ocs/v2.php/apps/files_sharing/api/v1/sharees", get(ocs_handler::handle_sharees_search), @@ -254,3 +263,22 @@ async fn handle_dav_trashbin_root( .await .map_err(|e| e.into_response()) } + +/// `GET /index.php/204` — NC app connectivity check. Returns 204 No Content. +async fn handle_connectivity_check() -> Response { + Response::builder() + .status(StatusCode::NO_CONTENT) + .body(Body::empty()) + .unwrap() +} + +/// Bare `/remote.php/dav` — NC clients (especially Android) probe this endpoint +/// during server discovery to confirm WebDAV is available. +async fn handle_dav_discovery() -> Response { + Response::builder() + .status(StatusCode::OK) + .header("DAV", "1, 3") + .header("Allow", "OPTIONS, GET, HEAD, PROPFIND") + .body(Body::empty()) + .unwrap() +} diff --git a/src/interfaces/nextcloud/webdav_handler.rs b/src/interfaces/nextcloud/webdav_handler.rs index 5fe4b4e2..ccf66737 100644 --- a/src/interfaces/nextcloud/webdav_handler.rs +++ b/src/interfaces/nextcloud/webdav_handler.rs @@ -277,8 +277,31 @@ async fn handle_get( user: &CurrentUser, subpath: &str, ) -> Result, AppError> { + // GET on root folder — NC clients use this as an existence check + if subpath.is_empty() || subpath == "/" { + return Ok(Response::builder() + .status(StatusCode::OK) + .header("DAV", "1, 3") + .body(Body::empty()) + .unwrap()); + } + let internal_path = nc_to_internal_path(&user.username, subpath)?; let file_service = &state.applications.file_retrieval_service; + let folder_service = &state.applications.folder_service; + + // Check if path is a folder first (NC clients use GET as existence check) + if folder_service + .get_folder_by_path(&internal_path) + .await + .is_ok() + { + return Ok(Response::builder() + .status(StatusCode::OK) + .header("DAV", "1, 3") + .body(Body::empty()) + .unwrap()); + } let file = file_service .get_file_by_path(&internal_path) @@ -311,8 +334,31 @@ async fn handle_head( user: &CurrentUser, subpath: &str, ) -> Result, AppError> { + // HEAD on root folder — NC clients use this as an existence check + if subpath.is_empty() || subpath == "/" { + return Ok(Response::builder() + .status(StatusCode::OK) + .header("DAV", "1, 3") + .body(Body::empty()) + .unwrap()); + } + let internal_path = nc_to_internal_path(&user.username, subpath)?; let file_service = &state.applications.file_retrieval_service; + let folder_service = &state.applications.folder_service; + + // Check if path is a folder (NC clients use HEAD as existence check) + if folder_service + .get_folder_by_path(&internal_path) + .await + .is_ok() + { + return Ok(Response::builder() + .status(StatusCode::OK) + .header("DAV", "1, 3") + .body(Body::empty()) + .unwrap()); + } let file = file_service .get_file_by_path(&internal_path) @@ -552,43 +598,75 @@ async fn handle_mkcol( use crate::application::dtos::folder_dto::CreateFolderDto; let folder_service = &state.applications.folder_service; + let internal_path = nc_to_internal_path(&user.username, subpath)?; - // Split into parent + new folder name. - let (parent_subpath, folder_name) = match subpath.rsplit_once('/') { - Some((parent, name)) => (parent, name), - None => ("", subpath), - }; - - let parent_internal = nc_to_internal_path(&user.username, parent_subpath)?; - - // Resolve parent folder ID. - let parent_folder = folder_service - .get_folder_by_path(&parent_internal) + // If the folder already exists, return 405 per RFC 4918 §9.3.1 + if folder_service + .get_folder_by_path(&internal_path) .await - .map_err(|_| AppError::not_found("Parent folder not found"))?; - - let dto = CreateFolderDto { - name: folder_name.to_string(), - parent_id: Some(parent_folder.id.clone()), - }; - - match folder_service.create_folder(dto).await { - Ok(_) => Ok(Response::builder() - .status(StatusCode::CREATED) + .is_ok() + { + return Ok(Response::builder() + .status(StatusCode::METHOD_NOT_ALLOWED) .body(Body::empty()) - .unwrap()), - Err(e) if e.message.contains("already exists") || e.message.contains("Already Exists") => { - // RFC 4918 §9.3.1: MKCOL on existing resource → 405 - Ok(Response::builder() - .status(StatusCode::METHOD_NOT_ALLOWED) - .body(Body::empty()) - .unwrap()) - } - Err(e) => Err(AppError::internal_error(format!( - "Failed to create folder: {}", - e - ))), + .unwrap()); } + + // Collect path segments that need to be created (walk from root to leaf) + let segments: Vec<&str> = subpath.split('/').filter(|s| !s.is_empty()).collect(); + + let user_root = nc_to_internal_path(&user.username, "")?; + let mut current_path = user_root.clone(); + let mut parent_id = folder_service + .get_folder_by_path(&user_root) + .await + .map_err(|_| AppError::not_found("User root folder not found"))? + .id + .clone(); + + for segment in &segments { + current_path = format!("{}/{}", current_path, segment); + match folder_service.get_folder_by_path(¤t_path).await { + Ok(existing) => { + parent_id = existing.id.clone(); + } + Err(_) => { + let dto = CreateFolderDto { + name: segment.to_string(), + parent_id: Some(parent_id.clone()), + }; + match folder_service.create_folder(dto).await { + Ok(created) => { + parent_id = created.id.clone(); + } + Err(e) + if e.message.contains("already exists") + || e.message.contains("Already Exists") => + { + // Race condition — folder created concurrently + let folder = folder_service + .get_folder_by_path(¤t_path) + .await + .map_err(|_| { + AppError::internal_error("Folder exists but cannot be found") + })?; + parent_id = folder.id.clone(); + } + Err(e) => { + return Err(AppError::internal_error(format!( + "Failed to create folder: {}", + e + ))); + } + } + } + } + } + + Ok(Response::builder() + .status(StatusCode::CREATED) + .body(Body::empty()) + .unwrap()) } // ──────────────────── DELETE ──────────────────── @@ -874,10 +952,18 @@ async fn write_nc_multistatus( )?; } - if depth != "0" { + // When folder is None, files are the target resource itself (single-file + // PROPFIND) and must always be emitted. When folder is Some, files/subfolders + // are children and should only be listed when depth > 0. + let emit_children = folder.is_none() || depth != "0"; + + if emit_children { // Files. for file in files { - let child_sub = if subpath.is_empty() { + let child_sub = if folder.is_none() { + // Single-file PROPFIND — subpath already points to the file. + subpath.to_string() + } else if subpath.is_empty() { file.name.clone() } else { format!("{}/{}", subpath.trim_end_matches('/'), file.name)