From d8eecbd9ca643b5948325436338b6e80799c259e Mon Sep 17 00:00:00 2001 From: Jared Wolff Date: Thu, 5 Mar 2026 20:42:30 -0500 Subject: [PATCH 1/2] 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) From 4617c1c1dfdc7abae2917ef47be6311e71e595d6 Mon Sep 17 00:00:00 2001 From: Jared Wolff Date: Thu, 5 Mar 2026 21:07:19 -0500 Subject: [PATCH 2/2] fix(ui): fix list view column spacing, rubber-band selection, and clippy warnings Add column-gap to list header/items, prevent rubber-band selection from swallowing clicks on list headers and file items, preserve rubber-band selection after mouseup, and extract type aliases to resolve clippy type_complexity warnings. --- .../pg/file_blob_read_repository.rs | 27 ++++++++------ .../pg/file_metadata_repository.rs | 37 ++++++++----------- src/interfaces/nextcloud/preview_handler.rs | 6 ++- static/css/components/fileList.css | 4 ++ static/js/app/main.js | 2 + static/js/app/ui.js | 13 ++++++- static/js/features/files/multiSelect.js | 1 + 7 files changed, 53 insertions(+), 37 deletions(-) diff --git a/src/infrastructure/repositories/pg/file_blob_read_repository.rs b/src/infrastructure/repositories/pg/file_blob_read_repository.rs index ce8525a2..9625a3be 100755 --- a/src/infrastructure/repositories/pg/file_blob_read_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_read_repository.rs @@ -7,6 +7,20 @@ //! File paths are resolved by JOINing with `storage.folders.path` (the //! materialized path column), so no recursive CTEs or N+1 queries are needed. +/// Row shape returned by media-file queries (avoids `clippy::type_complexity`). +type MediaFileRow = ( + String, // id + String, // name + Option, // folder_id + Option, // folder path + i64, // size + String, // mime_type + i64, // created_at + i64, // updated_at + Option, // user_id + i64, // sort_date +); + use bytes::Bytes; use futures::{Stream, TryStreamExt}; use moka::sync::Cache; @@ -156,18 +170,7 @@ impl FileBlobReadRepository { before: Option, limit: i64, ) -> Result<(Vec, Vec), DomainError> { - let rows: Vec<( - String, // id - String, // name - Option, // folder_id - Option, // folder path - i64, // size - String, // mime_type - i64, // created_at - i64, // updated_at - Option, // user_id - i64, // sort_date - )> = sqlx::query_as( + let rows: Vec = sqlx::query_as( r#" SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, fi.size, fi.mime_type, diff --git a/src/infrastructure/repositories/pg/file_metadata_repository.rs b/src/infrastructure/repositories/pg/file_metadata_repository.rs index e7c6d305..9f029327 100644 --- a/src/infrastructure/repositories/pg/file_metadata_repository.rs +++ b/src/infrastructure/repositories/pg/file_metadata_repository.rs @@ -10,6 +10,19 @@ use tracing::error; use crate::common::errors::DomainError; use crate::infrastructure::services::exif_service::ExifMetadata; +/// Row shape returned by metadata queries (avoids `clippy::type_complexity`). +type MetadataRow = ( + String, + Option>, + Option, + Option, + Option, + Option, + Option, + Option, + Option, +); + /// Metadata as stored/retrieved from the database. #[derive(Debug, Clone, Serialize)] pub struct StoredMetadata { @@ -73,17 +86,7 @@ impl FileMetadataRepository { /// Get metadata for a single file. pub async fn get(&self, file_id: &str) -> Result, DomainError> { - let row: Option<( - String, - Option>, - Option, - Option, - Option, - Option, - Option, - Option, - Option, - )> = sqlx::query_as( + let row: Option = sqlx::query_as( r#" SELECT file_id::text, captured_at, latitude, longitude, camera_make, camera_model, orientation, width, height @@ -135,17 +138,7 @@ impl FileMetadataRepository { return Ok(HashMap::new()); } - let rows: Vec<( - String, - Option>, - Option, - Option, - Option, - Option, - Option, - Option, - Option, - )> = sqlx::query_as( + let rows: Vec = sqlx::query_as( r#" SELECT file_id::text, captured_at, latitude, longitude, camera_make, camera_model, orientation, width, height diff --git a/src/interfaces/nextcloud/preview_handler.rs b/src/interfaces/nextcloud/preview_handler.rs index 2cf95b7e..5bdebb9f 100644 --- a/src/interfaces/nextcloud/preview_handler.rs +++ b/src/interfaces/nextcloud/preview_handler.rs @@ -39,7 +39,11 @@ pub async fn handle_preview( ) -> impl IntoResponse { // 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 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(_) => { diff --git a/static/css/components/fileList.css b/static/css/components/fileList.css index 4dfb4ceb..61e9477d 100755 --- a/static/css/components/fileList.css +++ b/static/css/components/fileList.css @@ -13,6 +13,7 @@ .list-header { display: grid; grid-template-columns: var(--files-list-columns); + column-gap: 12px; padding: 15px; font-weight: 600; color: #2d3748; @@ -24,6 +25,7 @@ .file-item { display: grid; grid-template-columns: var(--files-list-columns); + column-gap: 12px; padding: 12px 15px; border-bottom: 1px solid #f0f0f0; align-items: center; @@ -332,6 +334,7 @@ border: 2px dashed #ffc107; } + [data-theme="dark"] .files-list-view { background-color: #1e293b; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); @@ -380,3 +383,4 @@ [data-theme="dark"] .file-item.drop-target { background-color: rgba(255, 193, 7, 0.05); } + diff --git a/static/js/app/main.js b/static/js/app/main.js index 21a6496a..510ab653 100755 --- a/static/js/app/main.js +++ b/static/js/app/main.js @@ -517,6 +517,8 @@ function setupEventListeners() { // Deselect all cards when clicking empty area (not on a card, menu, or modal) // Note: multiSelect._hookGlobalDeselect() handles clearing the internal // selection state; this handler only covers the legacy CSS class removal. + // Skip if a rubber-band selection just finished — the click is a side-effect. + if (window.__rubberBandJustFinished) return; if (!e.target.closest('.file-card') && !e.target.closest('.file-item') && !e.target.closest('.context-menu') && !e.target.closest('.about-modal') && !e.target.closest('.batch-action-bar') && !e.target.closest('.list-header.selection-mode')) { document.querySelectorAll('.file-card.selected').forEach(c => c.classList.remove('selected')); document.querySelectorAll('.file-item.selected').forEach(c => c.classList.remove('selected')); diff --git a/static/js/app/ui.js b/static/js/app/ui.js index b07a221e..3a96b29e 100755 --- a/static/js/app/ui.js +++ b/static/js/app/ui.js @@ -1350,9 +1350,11 @@ function initRubberBandSelection() { container.addEventListener('mousedown', (e) => { // Only start if clicking empty area (not on a card, button, menu, input…) if (e.button !== 0) return; // left click only - if (e.target.closest('.file-card') || e.target.closest('.context-menu') || + if (e.target.closest('.file-card') || e.target.closest('.file-item') || + e.target.closest('.context-menu') || e.target.closest('.upload-dropdown') || e.target.closest('button') || - e.target.closest('input') || e.target.closest('.breadcrumb')) return; + e.target.closest('input') || e.target.closest('.breadcrumb') || + e.target.closest('.list-header')) return; active = true; startX = e.clientX; @@ -1420,9 +1422,16 @@ function initRubberBandSelection() { document.addEventListener('mouseup', () => { if (!active) return; active = false; + const hadSelection = selRect.style.display === 'block'; selRect.style.display = 'none'; // Update the batch bar after rubber band selection completes if (window.multiSelect) window.multiSelect._syncUI(); + // Suppress the click event that follows mouseup so the global + // deselect handler doesn't immediately clear the selection. + if (hadSelection) { + window.__rubberBandJustFinished = true; + requestAnimationFrame(() => { window.__rubberBandJustFinished = false; }); + } }); } diff --git a/static/js/features/files/multiSelect.js b/static/js/features/files/multiSelect.js index e9cd54d3..c2c14c80 100755 --- a/static/js/features/files/multiSelect.js +++ b/static/js/features/files/multiSelect.js @@ -532,6 +532,7 @@ const multiSelect = { _hookGlobalDeselect() { document.addEventListener('click', (e) => { + if (window.__rubberBandJustFinished) return; if (e.target.closest('.file-card, .file-item, .context-menu, .batch-action-bar, .list-header.selection-mode, .about-modal, .rename-dialog, .share-dialog, .confirm-dialog, .modal-overlay, input, button')) return; if (this.hasSelection) this.clear(); });