diff --git a/src/application/services/nextcloud_login_flow_service.rs b/src/application/services/nextcloud_login_flow_service.rs index 0e943931..147c1db2 100644 --- a/src/application/services/nextcloud_login_flow_service.rs +++ b/src/application/services/nextcloud_login_flow_service.rs @@ -3,6 +3,7 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use rand_core::RngCore; +use uuid::Uuid; /// Maximum number of concurrent pending login flows to prevent memory exhaustion. const MAX_PENDING_FLOWS: usize = 1000; @@ -30,6 +31,13 @@ pub struct LoginResult { struct PendingFlow { created_at: Instant, poll_token: String, + /// Set after the user authenticates on the login page **and** has more + /// than one root drive — the flow is paused until the user picks a + /// drive on the picker page. Consumed by `take_pending_user` when the + /// picker submission arrives, so the second step is single-use even + /// if the flow token leaks. `None` for single-drive accounts (legacy + /// path goes straight to `completed`). + pending_user_id: Option, completed: Option, } @@ -80,6 +88,7 @@ impl NextcloudLoginFlowService { PendingFlow { created_at: Instant::now(), poll_token: poll_token.clone(), + pending_user_id: None, completed: None, }, ); @@ -101,6 +110,39 @@ impl NextcloudLoginFlowService { state.flows.contains_key(flow_token) } + /// Stash a verified user_id on the flow so a follow-up drive-pick + /// request can prove "this browser just authenticated" without + /// asking for the password again. Returns `false` if the flow + /// token is unknown or expired. + /// + /// Only used on multi-drive accounts — single-drive logins go + /// straight to [`complete`](Self::complete). + pub fn mark_awaiting_drive(&self, flow_token: &str, user_id: Uuid) -> bool { + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + prune_expired(&mut state, self.ttl); + match state.flows.get_mut(flow_token) { + Some(pending) => { + pending.pending_user_id = Some(user_id); + true + } + None => false, + } + } + + /// Consume the stashed user_id (single-use). Returns the user_id + /// when the flow is in "awaiting drive choice" state, or `None` + /// when the flow is unknown, expired, or was never marked. Single- + /// use semantics make this safe even if the flow token leaks: the + /// second drive-pick attempt finds nothing to consume. + pub fn take_pending_user(&self, flow_token: &str) -> Option { + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + prune_expired(&mut state, self.ttl); + state + .flows + .get_mut(flow_token) + .and_then(|pending| pending.pending_user_id.take()) + } + pub fn complete( &self, flow_token: &str, @@ -257,6 +299,35 @@ mod tests { assert!(svc.poll(&info.poll_token).is_none()); } + #[test] + fn test_mark_awaiting_drive_then_take_pending_user() { + let svc = service(); + let info = svc.initiate("https://cloud.example.com").unwrap(); + let flow_token = info.login_url.rsplit('/').next().unwrap(); + let uid = Uuid::new_v4(); + + assert!(svc.mark_awaiting_drive(flow_token, uid)); + // First take consumes the slot. + assert_eq!(svc.take_pending_user(flow_token), Some(uid)); + // Second take must return None (single-use). + assert_eq!(svc.take_pending_user(flow_token), None); + } + + #[test] + fn test_mark_awaiting_drive_unknown_flow_returns_false() { + let svc = service(); + assert!(!svc.mark_awaiting_drive("nonexistent", Uuid::new_v4())); + } + + #[test] + fn test_take_pending_user_without_mark_returns_none() { + let svc = service(); + let info = svc.initiate("https://cloud.example.com").unwrap(); + let flow_token = info.login_url.rsplit('/').next().unwrap(); + // Flow exists but mark_awaiting_drive was never called. + assert_eq!(svc.take_pending_user(flow_token), None); + } + #[test] fn test_max_pending_flows_cap() { let svc = NextcloudLoginFlowService::new(Duration::from_secs(600)); diff --git a/src/interfaces/middleware/trace_span.rs b/src/interfaces/middleware/trace_span.rs index 881cdbb3..95550f1b 100644 --- a/src/interfaces/middleware/trace_span.rs +++ b/src/interfaces/middleware/trace_span.rs @@ -103,7 +103,8 @@ impl MakeSpan for ClientIpMakeSpan { method = %request.method(), uri = %request.uri().path(), user_id = tracing::field::Empty, - // The Nextcloud chroot folder id, set by `basic_auth_middleware`. + + // The Nextcloud chroot folder id, set by `basic_auth_middleware` (will be the Drive Id in the future). chroot_id = tracing::field::Empty, ) } diff --git a/src/interfaces/nextcloud/avatar_handler.rs b/src/interfaces/nextcloud/avatar_handler.rs index e5af8a3a..165ef07f 100644 --- a/src/interfaces/nextcloud/avatar_handler.rs +++ b/src/interfaces/nextcloud/avatar_handler.rs @@ -101,6 +101,11 @@ pub async fn handle_avatar( ) -> Response { let size = size.clamp(16, 1024); + let username = match username.split_once("~") { + None => username, + Some((u, _)) => u.to_string(), + }; + // ── Stored profile image — preferred when present ─────────── if let Some(auth_svc) = state.auth_service.as_ref() && let Ok(user) = auth_svc diff --git a/src/interfaces/nextcloud/basic_auth_middleware.rs b/src/interfaces/nextcloud/basic_auth_middleware.rs index 8486a4a1..726e786f 100644 --- a/src/interfaces/nextcloud/basic_auth_middleware.rs +++ b/src/interfaces/nextcloud/basic_auth_middleware.rs @@ -59,9 +59,43 @@ pub async fn basic_auth_middleware( NextcloudAuthError::Unauthorized })?; - let (username, password) = + let (raw_username, password) = parse_basic_auth(auth_header).ok_or(NextcloudAuthError::Unauthorized)?; + // ── Multi-drive composite-username parse ──────────────────────── + // POC wire shape: `{username}~{drive_marker}` may appear in the + // Basic Auth header. `~` was chosen because it needs no URL + // encoding and doesn't collide with UUID hyphens. The marker + // after `~` is a chroot SELECTOR (handled by `NcSession` via the + // URL `{user}` segment), NOT an auth credential — the password + // is verified against the username PREFIX. The middleware just + // peels the prefix off so the app-password lookup uses the + // canonical name. When no `~` is present, the request is a + // plain single-drive ("home") NC sync. + // + // Reject `name~` (empty marker) and `~marker` (empty username) + // at the auth boundary rather than treating them as "missing + // marker" — they are unambiguous typos that would otherwise + // silently fall into a different code path. + let (username, drive_marker): (String, Option) = match raw_username.split_once('~') { + Some(("", _)) => { + tracing::warn!( + "[NC] 401 malformed composite username (empty prefix): {}", + raw_username + ); + return Err(NextcloudAuthError::Unauthorized); + } + Some((_, "")) => { + tracing::warn!( + "[NC] 401 malformed composite username (empty marker): {}", + raw_username + ); + return Err(NextcloudAuthError::Unauthorized); + } + Some((u, m)) => (u.to_string(), Some(m.to_string())), + None => (raw_username.clone(), None), + }; + // Check account lockout before attempting password verification (saves CPU). // The lockout is per (account, IP), see #323 for rationale. let client_ip = crate::interfaces::middleware::rate_limit::extract_client_ip(&request); @@ -126,12 +160,68 @@ pub async fn basic_auth_middleware( // making it harder to correlate WebDAV / OCS activity to // a specific principal. tracing::Span::current().record("user_id", user_id.to_string()); - request.extensions_mut().insert(Arc::new(CurrentUser { + let current_user = CurrentUser { id: user_id, username: uname, email, role, - })); + }; + + // ── Resolve chroot from the Basic Auth drive marker ───── + // No marker → user's home folder. With a marker → + // `get_folder_with_perms` enforces per-folder access (404 + // anti-enumeration on miss / no-read). Today this is the + // sole chroot source; tomorrow it'll come from the + // app-password row instead. + use crate::application::ports::folder_ports::FolderUseCase; + let chroot = match drive_marker.as_deref() { + None => { + let expected = format!("My Folder - {}", current_user.username); + match state + .applications + .folder_service + .list_folders_with_perms(None, current_user.id) + .await + { + Ok(folders) => folders.into_iter().find(|f| f.name == expected), + Err(_) => None, + } + } + Some(folder_id) => state + .applications + .folder_service + .get_folder_with_perms(folder_id, current_user.id) + .await + .ok(), + }; + if chroot.is_none() { + tracing::warn!( + "[NC] 404 chroot not resolvable: user={} marker={:?}", + current_user.username, + drive_marker + ); + return Err(NextcloudAuthError::Unauthorized); + } + + request + .extensions_mut() + .insert(Arc::new(current_user.clone())); + request.extensions_mut().insert(Arc::new( + crate::interfaces::nextcloud::session::NcSession { + user: current_user, + raw_username: raw_username.clone(), + chroot, + }, + )); + tracing::Span::current().record( + "chroot_id", + request + .extensions() + .get::>() + .and_then(|s| s.chroot.as_ref()) + .map(|c| c.id.to_string()) + .unwrap_or_default(), + ); Ok(next.run(request).await) } Err(_) => { diff --git a/src/interfaces/nextcloud/login_v2_handler.rs b/src/interfaces/nextcloud/login_v2_handler.rs index 9dc4a8b9..5f321cae 100644 --- a/src/interfaces/nextcloud/login_v2_handler.rs +++ b/src/interfaces/nextcloud/login_v2_handler.rs @@ -1,3 +1,4 @@ +use askama::Template; use axum::{ extract::{Path, Query, State}, http::{HeaderMap, StatusCode, header}, @@ -7,8 +8,47 @@ use serde_json::json; use std::collections::HashMap; use std::sync::Arc; +use crate::application::ports::folder_ports::FolderUseCase; use crate::common::di::AppState; use crate::common::errors::DomainError; +use crate::interfaces::middleware::auth::CurrentUser; + +/// Drive option rendered on the picker page. `name` is the folder's +/// display name; `id` is the folder UUID that becomes the `~{marker}` +/// half of the composite Basic-Auth username if the user picks +/// anything other than the first (home) row. +struct DriveOption { + id: String, + name: String, +} + +#[derive(Template)] +#[template(path = "nextcloud/drive_picker.html")] +struct DrivePickerTemplate { + form_action: String, + drives: Vec, +} + +/// Find the index of the user's home folder inside `drives`. +/// +/// Convention: home is the root folder named `"My Folder - {username}"`, +/// set by `FolderService::ensure_home_folder` at registration. Extra +/// root folders (POC drive seeding via direct SQL insert) don't follow +/// this name, so the pattern disambiguates home from sibling drives. +/// Returns `None` if no folder matches — caller decides whether that +/// is fatal or just "treat everything as a non-home drive". +/// +/// Mirrors the same lookup performed in `routes.rs:: +/// verify_url_user_and_resolve_chroot` (legacy no-`~` path); both +/// sites must agree on which row is home or the URL and the auth +/// marker will diverge. +fn find_home_index( + drives: &[crate::application::dtos::folder_dto::FolderDto], + username: &str, +) -> Option { + let expected = format!("My Folder - {}", username); + drives.iter().position(|f| f.name == expected) +} /// Serve an HTML page with a Content-Security-Policy header as defense-in-depth. fn html_with_csp(html: &'static str) -> Response { @@ -175,47 +215,280 @@ pub async fn handle_login_submit( Err(e) => return login_failed_response(e), }; - let app_password = match nextcloud - .app_passwords - .create_nc(current_user.id, "Nextcloud") + // ── Multi-drive fork ───────────────────────────────────────────── + // List the user's root folders. By convention the first row is the + // user's home; additional rows are extra drives (POC seeded by + // direct DB insert until a drive admin surface exists). With 0 or + // 1 drive we go straight to the legacy one-shot completion path so + // the common case stays one click. With ≥2 drives we pause the + // flow, stash the user_id, and render the picker — drive selection + // resumes the flow via `handle_drive_pick`. + let mut drives = match state + .applications + .folder_service + .list_folders_with_perms(None, current_user.id) .await { - Ok((_id, password)) => password, + Ok(d) => d, Err(e) => { - tracing::error!(error = %e, user = %current_user.username, "Login Flow v2: failed to create app password"); + tracing::error!(error = %e, user = %current_user.username, "Login Flow v2: failed to list drives"); return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } }; - let base_url = state.core.config.base_url(); - let completed = - nextcloud + if drives.len() >= 2 { + // Reorder so home is at index 0. The picker template ties + // both the default-checked radio and the "Home" badge to + // `loop.first`, so placing home first is the single point + // that makes the picker UI line up with the home convention. + // Other drives keep their original alphabetical order. + if let Some(idx) = find_home_index(&drives, ¤t_user.username) + && idx != 0 + { + let home = drives.remove(idx); + drives.insert(0, home); + } + // If no home matched the convention, we fall through with the + // raw alphabetical order. The picker will still work but the + // first row gets the badge by default — slightly wrong UX but + // never breaks the auth flow (`handle_drive_pick` re-runs + // `find_home_index` independently). + + if !nextcloud .login_flow - .complete(&token, ¤t_user.username, &base_url, &app_password); + .mark_awaiting_drive(&token, current_user.id) + { + // Flow token vanished (TTL?) between password submit and + // here — extremely unlikely but treat the same as any + // session-expired case. + return axum::response::Redirect::to("/nextcloud-error.html?type=session-expired") + .into_response(); + } + return render_drive_picker(&token, &drives); + } + + complete_flow(&state, &nextcloud.login_flow, &token, ¤t_user, None).await +} + +/// Render the drive picker page. The form posts to +/// `/login/v2/flow/{token}/drive`, carrying only the chosen folder +/// UUID — the authenticated user id is read from the flow's +/// `pending_user_id` slot (consumed by `take_pending_user`). +fn render_drive_picker( + token: &str, + drives: &[crate::application::dtos::folder_dto::FolderDto], +) -> Response { + let template = DrivePickerTemplate { + form_action: format!("/login/v2/flow/{}/drive", token), + drives: drives + .iter() + .map(|f| DriveOption { + id: f.id.clone(), + name: f.name.clone(), + }) + .collect(), + }; + + match template.render() { + Ok(html) => ( + [( + header::CONTENT_SECURITY_POLICY, + "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; form-action 'self'", + )], + Html(html), + ) + .into_response(), + Err(e) => { + tracing::error!(error = %e, "Login Flow v2: drive picker template render failed"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} + +/// Mint an app password, complete the flow, and emit the `nc://` deep +/// link. Shared by the single-drive path (called from +/// `handle_login_submit`) and the post-picker path (called from +/// `handle_drive_pick`). +/// +/// `drive_id` is `None` for the single-drive shortcut and for the +/// home-drive choice on the picker; `Some(uuid)` for any other drive, +/// in which case the NC login name carries the `~{uuid}` marker. +async fn complete_flow( + state: &Arc, + login_flow: &crate::application::services::nextcloud_login_flow_service::NextcloudLoginFlowService, + token: &str, + user: &CurrentUser, + drive_id: Option<&str>, +) -> Response { + let nextcloud = match state.nextcloud.as_ref() { + Some(nc) => nc, + None => return StatusCode::SERVICE_UNAVAILABLE.into_response(), + }; + + let app_password = match nextcloud + .app_passwords + .create_nc(user.id, "Nextcloud") + .await + { + Ok((_id, password)) => password, + Err(e) => { + tracing::error!(error = %e, user = %user.username, "Login Flow v2: failed to create app password"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + + let login_name = match drive_id { + Some(uuid) => format!("{}~{}", user.username, uuid), + None => user.username.clone(), + }; + + let base_url = state.core.config.base_url(); + let completed = login_flow.complete(token, &login_name, &base_url, &app_password); if completed { tracing::info!( - user = %current_user.username, + user = %user.username, + login_name = %login_name, 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 + base_url, login_name, app_password ); axum::response::Redirect::to(&nc_url).into_response() } else { tracing::error!( - user = %current_user.username, + user = %user.username, "Login Flow v2: complete() returned false — flow token not found" ); axum::response::Redirect::to("/nextcloud-error.html?type=session-expired").into_response() } } +/// POST `/login/v2/flow/{token}/drive` — finalise a paused login flow +/// after the user picks a drive on the picker page. +/// +/// Auth model: the route is **public** (no Basic Auth — this is the +/// browser-side leg of Login Flow v2, before the app password is +/// issued). The proof of authentication is the single-use +/// `pending_user_id` slot on the flow, set by `handle_login_submit` +/// after password verification and consumed here. Replay is naturally +/// blocked: a second POST finds nothing to consume. +pub async fn handle_drive_pick( + State(state): State>, + Path(token): Path, + body: String, +) -> Response { + let nextcloud = match state.nextcloud.as_ref() { + Some(nc) => nc, + None => return StatusCode::SERVICE_UNAVAILABLE.into_response(), + }; + + let drive_id = match parse_form_value(&body, "drive") { + Some(v) if !v.is_empty() => v, + _ => return StatusCode::BAD_REQUEST.into_response(), + }; + + let user_id = match nextcloud.login_flow.take_pending_user(&token) { + Some(uid) => uid, + None => { + tracing::warn!( + target: "audit", + event = "nc_login_flow.drive_pick_rejected", + reason = "no_pending_user", + "👮🏻‍♂️ NC drive pick rejected: flow has no pending user (replay or unknown token)" + ); + return axum::response::Redirect::to("/nextcloud-error.html?type=session-expired") + .into_response(); + } + }; + + // Resolve user (for username) and validate drive ownership in one + // service call each. `get_folder_with_perms` enforces that the + // caller can read the folder — covers "drive doesn't exist" and + // "drive belongs to someone else" with the same 404 to defeat + // enumeration. We additionally need to differentiate home vs. + // non-home so the NC login name carries `~{uuid}` only for + // non-home choices. + let auth = match state.auth_service.as_ref() { + Some(a) => a, + None => return StatusCode::SERVICE_UNAVAILABLE.into_response(), + }; + let user_dto = match auth.auth_application_service.get_user_by_id(user_id).await { + Ok(u) => u, + Err(e) => { + tracing::error!(error = %e, %user_id, "Login Flow v2: failed to fetch user for drive pick"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + // Username must be present — only password-login users reach this + // branch, and password login requires a claimed username. Defensive + // check anyway: a username-less user here means an upstream invariant + // broke, not something to silently paper over. + let Some(username) = user_dto.username.clone() else { + tracing::error!(%user_id, "Login Flow v2: pending user has no username — invariant violated"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + }; + let user = CurrentUser { + id: user_id, + username, + email: user_dto.email.clone(), + role: user_dto.role.clone(), + }; + + let _folder = match state + .applications + .folder_service + .get_folder_with_perms(&drive_id, user_id) + .await + { + Ok(f) => f, + Err(_) => { + tracing::warn!( + target: "audit", + event = "nc_login_flow.drive_pick_rejected", + reason = "drive_not_owned_or_missing", + %user_id, + drive_id = %drive_id, + "👮🏻‍♂️ NC drive pick rejected: folder missing or caller has no read access" + ); + return StatusCode::NOT_FOUND.into_response(); + } + }; + + // Determine if the pick is home. The previous "first row of + // list_folders_with_perms" heuristic was wrong: the underlying + // repo query orders by `name`, so any drive named alphabetically + // before "My Folder - {username}" stole the first slot and was + // mis-classified as home — `login_name` then dropped the `~uuid` + // marker and NC desktop rooted at the home folder regardless of + // the user's pick. `find_home_index` keys off the registered + // home-folder name, which extra drives (POC SQL-seeded) don't + // share, so it disambiguates cleanly. + let drives = match state + .applications + .folder_service + .list_folders_with_perms(None, user_id) + .await + { + Ok(d) => d, + Err(e) => { + tracing::error!(error = %e, %user_id, "Login Flow v2: failed to list drives for home detection"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + let home_id = find_home_index(&drives, &user.username).map(|i| drives[i].id.as_str()); + let is_home = home_id == Some(drive_id.as_str()); + let drive_marker = if is_home { + None + } else { + Some(drive_id.as_str()) + }; + + complete_flow(&state, &nextcloud.login_flow, &token, &user, drive_marker).await +} + /// GET /login/v2/flow/{token}/oidc — Start an OIDC authorization flow that is /// tied to a Nextcloud Login Flow v2 session. After successful IdP /// authentication the regular `/api/auth/oidc/callback` endpoint will detect diff --git a/src/interfaces/nextcloud/mod.rs b/src/interfaces/nextcloud/mod.rs index 04ed0330..52eb8153 100644 --- a/src/interfaces/nextcloud/mod.rs +++ b/src/interfaces/nextcloud/mod.rs @@ -5,6 +5,7 @@ pub mod ocs_handler; pub mod preview_handler; pub mod report_handler; pub mod routes; +pub mod session; pub mod status_handler; pub mod trashbin_handler; pub mod uploads_handler; diff --git a/src/interfaces/nextcloud/ocs_handler.rs b/src/interfaces/nextcloud/ocs_handler.rs index f9d0a4a8..e673288b 100644 --- a/src/interfaces/nextcloud/ocs_handler.rs +++ b/src/interfaces/nextcloud/ocs_handler.rs @@ -46,9 +46,12 @@ pub async fn handle_capabilities_v2(State(state): State>) -> Respo Json(payload).into_response() } -pub async fn handle_user_info(State(state): State>, user: AuthUser) -> Response { +pub async fn handle_user_info( + State(state): State>, + session: crate::interfaces::nextcloud::session::NcSession, +) -> Response { let quota: (i64, i64) = match state.storage_usage_service.as_ref() { - Some(service) => match service.get_user_storage_info(user.id).await { + Some(service) => match service.get_user_storage_info(session.user.id).await { Ok((used, total)) => (used, total), Err(_) => (0, 0), }, @@ -62,15 +65,36 @@ pub async fn handle_user_info(State(state): State>, user: AuthUser 0.0 }; + // `id` MUST echo the raw wire username the client used at Basic + // Auth time — NC desktop reads `data.id` from this endpoint and + // splices it into every subsequent WebDAV path it builds + // (`/remote.php/dav/files/{id}/…`). Returning the bare canonical + // username on a `~{uuid}` session would make the client strip + // the marker and revert to the home drive. + // + // Display fields stay short on the default drive (bare + // username); on a marker session we render `username@` + // using the resolved chroot's stored name, which is friendlier + // than the raw UUID the wire form carries. + let id = session.raw_username.clone(); + let displayname = if session.is_home() { + session.user.username.clone() + } else { + match session.chroot.as_ref() { + Some(chroot) => format!("{}@{}", session.user.username, chroot.name), + None => session.user.username.clone(), + } + }; + Json(json!({ "ocs": { "meta": { "status": "ok", "statuscode": 200, "message": "OK" }, "data": { "enabled": true, - "id": user.username, - "display-name": user.username, - "displayname": user.username, - "email": user.email, + "id": id, + "display-name": displayname, + "displayname": displayname, + "email": session.user.email, "quota": { "used": quota.0, "total": quota.1, @@ -404,10 +428,7 @@ pub async fn handle_search( // instead. Correct for D0-provisioned default drives; secondary // drives keep their original root name. for file in &results.files { - let display_path = file - .path - .strip_prefix("Personal/") - .unwrap_or(&file.path); + let display_path = file.path.strip_prefix("Personal/").unwrap_or(&file.path); let display_path = format!("/{}", display_path); let numeric_id = file_id_map.get(&file.id).copied(); diff --git a/src/interfaces/nextcloud/report_handler.rs b/src/interfaces/nextcloud/report_handler.rs index a66cd63a..c5b7e5c5 100644 --- a/src/interfaces/nextcloud/report_handler.rs +++ b/src/interfaces/nextcloud/report_handler.rs @@ -22,7 +22,6 @@ use crate::application::ports::inbound::SearchUseCase; use crate::common::di::AppState; use crate::domain::entities::file::File; use crate::interfaces::errors::AppError; -use crate::interfaces::middleware::auth::CurrentUser; use crate::interfaces::nextcloud::webdav_handler::{ batch_resolve_ids, format_oc_id, nc_href, write_file_response, write_folder_response, }; @@ -35,7 +34,7 @@ use crate::interfaces::nextcloud::webdav_handler::{ pub async fn handle_nc_report( state: Arc, req: Request, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, _subpath: &str, ) -> Result, AppError> { let body_bytes = body::to_bytes(req.into_body(), 64 * 1024) @@ -45,9 +44,9 @@ pub async fn handle_nc_report( let body_str = String::from_utf8_lossy(&body_bytes); if body_str.contains("filter-files") { - handle_filter_files(state, &body_str, user).await + handle_filter_files(state, &body_str, session).await } else if body_str.contains("searchrequest") { - handle_search(state, &body_str, user).await + handle_search(state, &body_str, session).await } else { // Unknown REPORT type -- return empty multistatus. Ok(empty_multistatus()) @@ -59,8 +58,10 @@ pub async fn handle_nc_report( async fn handle_filter_files( state: Arc, _body: &str, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, ) -> Result, AppError> { + let user = &session.user; + let url_user = &session.raw_username; let fav_svc = match state.favorites_service.as_ref() { Some(svc) => svc, None => return Ok(empty_multistatus()), @@ -149,9 +150,13 @@ async fn handle_filter_files( write_multistatus_start(&mut xml)?; + // Keep main's batched-resolution structure (one batch query + // per type, not 2N round-trips). Hrefs use `url_user` so the + // multi-drive `~{drive}` form is echoed back to the client; + // owner-id stays canonical via `&user.username`. for file in &files { let subpath = strip_home_prefix(&file.path, home_prefix); - let href = nc_href(&user.username, subpath); + let href = nc_href(url_user, subpath); let fid = file_id_map.get(&file.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); write_file_response( @@ -168,7 +173,7 @@ async fn handle_filter_files( for folder in &folders { let subpath = strip_home_prefix(&folder.path, home_prefix); - let href = format!("{}/", nc_href(&user.username, subpath)); + let href = format!("{}/", nc_href(url_user, subpath)); let fid = folder_id_map.get(&folder.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); write_folder_response( @@ -199,8 +204,13 @@ async fn handle_filter_files( async fn handle_search( state: Arc, body: &str, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, ) -> Result, AppError> { + let user = &session.user; + // Validate chroot up-front (path-scoped handler); `resolve_scope_folder` + // below re-pulls it from the session for the path-mapping step. + session.require_chroot()?; + let url_user = &session.raw_username; let search_svc = match state.applications.search_service.as_ref() { Some(svc) => svc, None => return Ok(empty_multistatus()), @@ -214,7 +224,7 @@ async fn handle_search( let nresults = parse_nresults(body).unwrap_or(100); // Resolve folder scope from inside . - let folder_id = resolve_scope_folder(&state, body, &user.username, user.id).await; + let folder_id = resolve_scope_folder(&state, body, session).await; let criteria = SearchCriteriaDto { name_contains: Some(term), @@ -257,7 +267,7 @@ async fn handle_search( // Files. for file in &files { let subpath = strip_home_prefix(&file.path, home_prefix); - let href = nc_href(&user.username, subpath); + let href = nc_href(url_user, subpath); let fid = file_id_map.get(&file.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); write_file_response( @@ -275,7 +285,7 @@ async fn handle_search( // Folders. for folder in &folders { let subpath = strip_home_prefix(&folder.path, home_prefix); - let href = format!("{}/", nc_href(&user.username, subpath)); + let href = format!("{}/", nc_href(url_user, subpath)); let fid = folder_id_map.get(&folder.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); write_folder_response( @@ -468,28 +478,40 @@ fn xml_extract_text(body: &str, local_name: &[u8]) -> Option { } /// Resolve a scope href (e.g. `/files/username/Documents`) to a folder ID. +/// +/// Pulls everything it needs from the `NcSession`: the caller's id (so +/// `get_folder_by_path` can be user-scoped — post-D0 paths like +/// `Personal/Docs` are not globally unique), the chroot (provides the +/// path prefix that `nc_to_internal_path` prepends), and the raw wire +/// `{user}` segment (bare or `admin~{uuid}`) so we strip the prefix the +/// NC client actually sent. async fn resolve_scope_folder( state: &AppState, body: &str, - username: &str, - user_id: uuid::Uuid, + session: &crate::interfaces::nextcloud::session::NcSession, ) -> Option { + let user = &session.user; + let chroot = session.require_chroot().ok()?; + let url_user = &session.raw_username; let href = parse_scope_href(body)?; - // The href is typically `/files/{user}/subpath` or `/remote.php/dav/files/{user}/subpath`. - let subpath = extract_subpath_from_scope(&href, username)?; + // The href is typically `/files/{url_user}/subpath` or + // `/remote.php/dav/files/{url_user}/subpath`. On a multi-drive + // session the `{url_user}` segment carries the `~{uuid}` marker, + // so we strip with the composite to find the real subpath. Using + // `user.username` here would fail to match for non-home drives. + let subpath = extract_subpath_from_scope(&href, url_user)?; if subpath.is_empty() { // Root scope -- no folder_id filter needed. return None; } let internal_path = - crate::interfaces::nextcloud::webdav_handler::nc_to_internal_path(username, &subpath) - .ok()?; + crate::interfaces::nextcloud::webdav_handler::nc_to_internal_path(chroot, &subpath).ok()?; let folder_service = &state.applications.folder_service; folder_service - .get_folder_by_path(&internal_path, user_id) + .get_folder_by_path(&internal_path, user.id) .await .ok() .map(|f| f.id) @@ -498,13 +520,16 @@ async fn resolve_scope_folder( /// Extract the subpath portion from a scope href. /// /// Handles both short form `/files/{user}/sub` and full -/// `/remote.php/dav/files/{user}/sub`. -fn extract_subpath_from_scope(href: &str, username: &str) -> Option { +/// `/remote.php/dav/files/{user}/sub`. `url_user` is the literal URL +/// `{user}` segment — bare for legacy single-drive sync, composite +/// `admin~{uuid}` for multi-drive — so this matches whichever shape +/// the NC client actually sent. +fn extract_subpath_from_scope(href: &str, url_user: &str) -> Option { let patterns = [ - format!("/remote.php/dav/files/{}/", username), - format!("/files/{}/", username), - format!("/remote.php/dav/files/{}", username), - format!("/files/{}", username), + format!("/remote.php/dav/files/{}/", url_user), + format!("/files/{}/", url_user), + format!("/remote.php/dav/files/{}", url_user), + format!("/files/{}", url_user), ]; for pat in &patterns { diff --git a/src/interfaces/nextcloud/routes.rs b/src/interfaces/nextcloud/routes.rs index 3e94b557..6ff08867 100644 --- a/src/interfaces/nextcloud/routes.rs +++ b/src/interfaces/nextcloud/routes.rs @@ -10,13 +10,14 @@ use axum::{ use std::sync::Arc; use crate::common::di::AppState; -use crate::interfaces::middleware::auth::{AuthUser, CurrentUser}; +use crate::interfaces::middleware::auth::AuthUser; use crate::interfaces::middleware::rate_limit::{RateLimiter, rate_limit_login}; use crate::interfaces::nextcloud::avatar_handler; use crate::interfaces::nextcloud::basic_auth_middleware::basic_auth_middleware; use crate::interfaces::nextcloud::login_v2_handler; use crate::interfaces::nextcloud::ocs_handler; use crate::interfaces::nextcloud::preview_handler; +use crate::interfaces::nextcloud::session::NcSession; use crate::interfaces::nextcloud::status_handler; use crate::interfaces::nextcloud::trashbin_handler; use crate::interfaces::nextcloud::uploads_handler; @@ -58,6 +59,14 @@ pub fn nextcloud_routes_with_state(state: Arc) -> Router rate_limit_login, )), ) + // Drive picker submission — finalises a multi-drive flow that + // paused after password verification. Public route by design: + // the flow token + single-use `pending_user_id` slot is the + // proof of authentication. See `login_v2_handler::handle_drive_pick`. + .route( + "/login/v2/flow/{token}/drive", + post(login_v2_handler::handle_drive_pick), + ) // OIDC initiation from Nextcloud login page .route( "/login/v2/flow/{token}/oidc", @@ -204,60 +213,46 @@ pub fn nextcloud_routes_with_state(state: Arc) -> Router // ──────────────── Handler glue ──────────────── -/// Reject requests where the URL `{user}` doesn't match the authenticated user. -#[allow(clippy::result_large_err)] -fn verify_url_user(url_user: &str, auth_user: &CurrentUser) -> Result<(), Response> { - if url_user != auth_user.username { - Err(StatusCode::FORBIDDEN.into_response()) - } else { - Ok(()) - } -} - async fn handle_dav_files( State(state): State>, - Path((url_user, subpath)): Path<(String, String)>, - user_ext: AuthUser, + Path((_url_user, subpath)): Path<(String, String)>, + session: NcSession, req: Request, ) -> Result { - verify_url_user(&url_user, &user_ext)?; - webdav_handler::handle_nc_webdav(state, req, user_ext, subpath) + webdav_handler::handle_nc_webdav(state, req, session, subpath) .await .map_err(|e| e.into_response()) } async fn handle_dav_files_root( State(state): State>, - Path(url_user): Path, - user_ext: AuthUser, + Path(_url_user): Path, + session: NcSession, req: Request, ) -> Result { - verify_url_user(&url_user, &user_ext)?; - webdav_handler::handle_nc_webdav(state, req, user_ext, String::new()) + webdav_handler::handle_nc_webdav(state, req, session, String::new()) .await .map_err(|e| e.into_response()) } async fn handle_dav_uploads( State(state): State>, - Path((url_user, upload_id, rest)): Path<(String, String, String)>, - user_ext: AuthUser, + Path((_url_user, upload_id, rest)): Path<(String, String, String)>, + session: NcSession, req: Request, ) -> Result { - verify_url_user(&url_user, &user_ext)?; - uploads_handler::handle_nc_uploads(state, req, user_ext, upload_id, rest) + uploads_handler::handle_nc_uploads(state, req, session, upload_id, rest) .await .map_err(|e| e.into_response()) } async fn handle_dav_uploads_root( State(state): State>, - Path((url_user, upload_id)): Path<(String, String)>, - user_ext: AuthUser, + Path((_url_user, upload_id)): Path<(String, String)>, + session: NcSession, req: Request, ) -> Result { - verify_url_user(&url_user, &user_ext)?; - uploads_handler::handle_nc_uploads(state, req, user_ext, upload_id, String::new()) + uploads_handler::handle_nc_uploads(state, req, session, upload_id, String::new()) .await .map_err(|e| e.into_response()) } @@ -283,24 +278,22 @@ async fn handle_legacy_webdav_root(user_ext: AuthUser) -> Response { async fn handle_dav_trashbin( State(state): State>, - Path((url_user, subpath)): Path<(String, String)>, - user_ext: AuthUser, + Path((_url_user, subpath)): Path<(String, String)>, + session: NcSession, req: Request, ) -> Result { - verify_url_user(&url_user, &user_ext)?; - trashbin_handler::handle_nc_trashbin(state, req, user_ext, subpath) + trashbin_handler::handle_nc_trashbin(state, req, session, subpath) .await .map_err(|e| e.into_response()) } async fn handle_dav_trashbin_root( State(state): State>, - Path(url_user): Path, - user_ext: AuthUser, + Path(_url_user): Path, + session: NcSession, req: Request, ) -> Result { - verify_url_user(&url_user, &user_ext)?; - trashbin_handler::handle_nc_trashbin(state, req, user_ext, String::new()) + trashbin_handler::handle_nc_trashbin(state, req, session, String::new()) .await .map_err(|e| e.into_response()) } diff --git a/src/interfaces/nextcloud/session.rs b/src/interfaces/nextcloud/session.rs new file mode 100644 index 00000000..2e48f198 --- /dev/null +++ b/src/interfaces/nextcloud/session.rs @@ -0,0 +1,134 @@ +//! Per-request NextCloud session context. +//! +//! Bundles WHO the caller is, the raw wire username they presented, +//! and (for path-scoped endpoints) WHERE they're confined to. Built +//! by `basic_auth_middleware` and stashed in request extensions as +//! `Arc`; handlers extract it via the [`FromRequestParts`] +//! impl below — just declare `session: NcSession` in the signature. +//! +//! ## Source of truth +//! +//! - `user`: authenticated identity (id, canonical username, role). +//! - `raw_username`: the opaque wire identifier from the Basic Auth +//! header. Today: plain `user` (single-drive) or `user~{drive_uuid}` +//! (multi-drive POC). May look different again when future auth +//! schemes land. **Handlers MUST NOT parse it** — it's used verbatim +//! only for echoing back into DAV/OCS URLs the client expects to +//! see (notably OCS `cloud/user`'s `id` field, which NC desktop +//! splices into every subsequent DAV path it builds) and for +//! audit logs. +//! - `chroot`: folder the request is jailed inside. `Some` for every +//! authenticated NC request today (the home folder when no drive +//! marker is present, or the resolved drive when one is). `None` +//! is reserved for future routes that don't operate on a single +//! folder (admin / cross-drive queries). +//! +//! ## Why this lives in middleware, not routes.rs +//! +//! The auth step already has every input needed (raw username from +//! header + drive marker after `~` + authenticated user). Resolving +//! the chroot there means every NC handler — DAV, OCS, uploads, +//! trashbin, sharees, … — gets a uniform `NcSession` regardless of +//! whether its URL carries a `{user}` segment. The URL `{user}` +//! segment becomes informational; the auth header is canonical. + +use std::sync::Arc; + +use axum::{ + extract::FromRequestParts, + http::{StatusCode, request::Parts}, + response::{IntoResponse, Response}, +}; + +use crate::application::dtos::folder_dto::FolderDto; +use crate::interfaces::errors::AppError; +use crate::interfaces::middleware::auth::CurrentUser; + +#[derive(Debug, Clone)] +pub struct NcSession { + pub user: CurrentUser, + pub raw_username: String, + pub chroot: Option, +} + +impl NcSession { + /// Return the chroot, or 500 if a path-scoped handler is reached + /// without one. Documents the invariant that every NC route + /// today is path-scoped — if this fires, route wiring is wrong. + pub fn require_chroot(&self) -> Result<&FolderDto, AppError> { + self.chroot.as_ref().ok_or_else(|| { + AppError::internal_error( + "NcSession: path-scoped handler reached without a chroot — route wiring bug", + ) + }) + } + + /// True when the session is scoped to the user's home folder + /// (no drive marker in the Basic Auth username). Useful for + /// handlers that want to render a friendlier display when the + /// user is on their default drive. + pub fn is_home(&self) -> bool { + !self.raw_username.contains('~') + } +} + +/// Pull the `{user}` segment out of a NC DAV URL. +/// +/// Expected URL shapes: +/// - `/remote.php/dav/files/{user}` (root) +/// - `/remote.php/dav/files/{user}/{*subpath}` +/// - `/remote.php/dav/uploads/{user}/{upload_id}[/{*rest}]` +/// - `/remote.php/dav/trashbin/{user}[/{*subpath}]` +/// +/// Returns `None` for anything that doesn't follow this shape (notably +/// the OCS surfaces, where there is no `{user}` segment to compare). +fn extract_url_user(path: &str) -> Option { + let mut segments = path.split('/'); + if !segments.next()?.is_empty() { + return None; + } + if segments.next()? != "remote.php" { + return None; + } + if segments.next()? != "dav" { + return None; + } + let _surface = segments.next()?; // files / uploads / trashbin + let user_seg = segments.next()?; + if user_seg.is_empty() { + return None; + } + urlencoding::decode(user_seg).ok().map(|s| s.into_owned()) +} + +/// Axum extractor: pulls the `Arc` that +/// `basic_auth_middleware` stashed in request extensions and clones +/// it (cheap — one `Arc` increment, no field copy) into an owned +/// `NcSession` for handler use. +/// +/// On path-scoped DAV routes (`/remote.php/dav/{files,uploads, +/// trashbin}/{user}/…`), the URL `{user}` segment is cross-checked +/// against `session.raw_username` and 403'd on mismatch. This is a +/// consistency check, NOT a security boundary — the chroot ACL +/// (`get_folder_with_perms`) is what actually prevents cross-user +/// access. It just surfaces malformed requests early (403) instead +/// of silently letting them through. +impl FromRequestParts for NcSession { + type Rejection = Response; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let session = parts + .extensions + .get::>() + .map(|arc| (**arc).clone()) + .ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?; + + if let Some(url_user) = extract_url_user(parts.uri.path()) + && url_user != session.raw_username + { + return Err(StatusCode::FORBIDDEN.into_response()); + } + + Ok(session) + } +} diff --git a/src/interfaces/nextcloud/trashbin_handler.rs b/src/interfaces/nextcloud/trashbin_handler.rs index f40c71c3..c3e40b33 100644 --- a/src/interfaces/nextcloud/trashbin_handler.rs +++ b/src/interfaces/nextcloud/trashbin_handler.rs @@ -14,7 +14,6 @@ use crate::application::ports::folder_ports::FolderUseCase; use crate::application::ports::trash_ports::TrashUseCase; use crate::common::di::AppState; use crate::interfaces::errors::AppError; -use crate::interfaces::middleware::auth::{AuthUser, CurrentUser}; use crate::interfaces::nextcloud::webdav_handler::{ batch_resolve_ids, extract_nc_subpath_from_dest, format_oc_id, nc_to_internal_path, write_text_element, @@ -28,7 +27,7 @@ const HEADER_DAV: HeaderName = HeaderName::from_static("dav"); pub async fn handle_nc_trashbin( state: Arc, req: Request, - user: AuthUser, + session: crate::interfaces::nextcloud::session::NcSession, subpath: String, ) -> Result, AppError> { let method = req.method().clone(); @@ -37,21 +36,25 @@ pub async fn handle_nc_trashbin( match method.as_str() { "OPTIONS" => handle_options(), "PROPFIND" if subpath_trimmed == "trash" || subpath_trimmed.is_empty() => { - handle_propfind(state, &user).await + handle_propfind(state, &session).await } "MOVE" if subpath_trimmed.starts_with("trash/") => { + // Keep the destination-collision-check feature added on HEAD + // (RFC 4918 §9.9.4: refuse restore with 412 when the + // destination is taken by a live resource). The chroot lookup + // moves into `handle_restore` via the session. let dest_header = req .headers() .get("destination") .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); - handle_restore(state, dest_header, &user, subpath_trimmed).await + handle_restore(state, dest_header, &session, subpath_trimmed).await } "DELETE" if subpath_trimmed == "trash" || subpath_trimmed.is_empty() => { - handle_empty_trash(state, &user).await + handle_empty_trash(state, &session).await } "DELETE" if subpath_trimmed.starts_with("trash/") => { - handle_delete_permanent(state, &user, subpath_trimmed).await + handle_delete_permanent(state, &session, subpath_trimmed).await } _ => Ok(Response::builder() .status(StatusCode::METHOD_NOT_ALLOWED) @@ -75,8 +78,9 @@ fn handle_options() -> Result, AppError> { async fn handle_propfind( state: Arc, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, ) -> Result, AppError> { + let user = &session.user; let trash_svc = state .trash_service .as_ref() @@ -107,9 +111,11 @@ async fn handle_propfind( async fn handle_restore( state: Arc, dest_header: Option, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, subpath: &str, ) -> Result, AppError> { + let user = &session.user; + let chroot = session.require_chroot()?; let id = extract_trash_id(subpath)?; let trash_svc = state @@ -128,7 +134,7 @@ async fn handle_restore( if let Some(dest_header) = dest_header && let Some(dest_subpath) = extract_nc_subpath_from_dest(&dest_header, &user.username) { - let dest_internal = nc_to_internal_path(&user.username, &dest_subpath)?; + let dest_internal = nc_to_internal_path(chroot, &dest_subpath)?; let folder_service = &state.applications.folder_service; let file_service = &state.applications.file_retrieval_service; let dest_taken = file_service.get_file_by_path(&dest_internal).await.is_ok() @@ -184,8 +190,9 @@ async fn handle_restore( async fn handle_empty_trash( state: Arc, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, ) -> Result, AppError> { + let user = &session.user; let trash_svc = state .trash_service .as_ref() @@ -206,9 +213,10 @@ async fn handle_empty_trash( async fn handle_delete_permanent( state: Arc, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, subpath: &str, ) -> Result, AppError> { + let user = &session.user; let id = extract_trash_id(subpath)?; let trash_svc = state diff --git a/src/interfaces/nextcloud/uploads_handler.rs b/src/interfaces/nextcloud/uploads_handler.rs index 193d2e55..c60103ec 100644 --- a/src/interfaces/nextcloud/uploads_handler.rs +++ b/src/interfaces/nextcloud/uploads_handler.rs @@ -9,7 +9,6 @@ use crate::application::ports::file_ports::{FileRetrievalUseCase, FileUploadUseC use crate::common::di::AppState; use crate::common::mime_detect::filename_from_path; use crate::interfaces::errors::AppError; -use crate::interfaces::middleware::auth::{AuthUser, CurrentUser}; use crate::interfaces::upload_ingest::{ discard_ingested, ingest_stream_to_cas, stream_body_to_path, stream_from_files, }; @@ -25,17 +24,17 @@ use crate::interfaces::upload_ingest::{ pub async fn handle_nc_uploads( state: Arc, req: Request, - user: AuthUser, + session: crate::interfaces::nextcloud::session::NcSession, upload_id: String, rest: String, // chunk name or ".file" or empty ) -> Result, AppError> { let method = req.method().clone(); match method.as_str() { - "MKCOL" => handle_mkcol(state, &user, &upload_id).await, - "PUT" => handle_put_chunk(state, req, &user, &upload_id, &rest).await, - "MOVE" => handle_assemble(state, req, &user, &upload_id).await, - "DELETE" => handle_abort(state, &user, &upload_id).await, - "PROPFIND" => handle_propfind_session(state, &user, &upload_id).await, + "MKCOL" => handle_mkcol(state, &session, &upload_id).await, + "PUT" => handle_put_chunk(state, req, &session, &upload_id, &rest).await, + "MOVE" => handle_assemble(state, req, &session, &upload_id).await, + "DELETE" => handle_abort(state, &session, &upload_id).await, + "PROPFIND" => handle_propfind_session(state, &session, &upload_id).await, _ => Ok(Response::builder() .status(StatusCode::METHOD_NOT_ALLOWED) .body(Body::empty()) @@ -60,9 +59,10 @@ pub async fn handle_nc_uploads( /// which matches NC server behaviour. async fn handle_propfind_session( state: Arc, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, upload_id: &str, ) -> Result, AppError> { + let user = &session.user; let nc = state .nextcloud .as_ref() @@ -147,9 +147,10 @@ fn xml_escape(s: &str) -> String { /// MKCOL — create upload session directory. async fn handle_mkcol( state: Arc, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, upload_id: &str, ) -> Result, AppError> { + let user = &session.user; let nc = state .nextcloud .as_ref() @@ -178,10 +179,11 @@ async fn handle_mkcol( async fn handle_put_chunk( state: Arc, req: Request, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, upload_id: &str, chunk_name: &str, ) -> Result, AppError> { + let user = &session.user; let nc = state .nextcloud .as_ref() @@ -216,9 +218,10 @@ async fn handle_put_chunk( async fn handle_assemble( state: Arc, req: Request, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, upload_id: &str, ) -> Result, AppError> { + let user = &session.user; let nc = state .nextcloud .as_ref() @@ -296,7 +299,10 @@ async fn handle_assemble( let parent_internal = parent_internal.trim_end_matches('/'); use crate::application::ports::folder_ports::FolderUseCase; - let parent_folder = match folder_service.get_folder_by_path(parent_internal, user.id).await { + let parent_folder = match folder_service + .get_folder_by_path(parent_internal, user.id) + .await + { Ok(folder) => folder, Err(e) => { discard_ingested(&state.core.dedup_service, &ingested).await; @@ -341,9 +347,10 @@ async fn handle_assemble( /// DELETE — abort an upload session. async fn handle_abort( state: Arc, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, upload_id: &str, ) -> Result, AppError> { + let user = &session.user; let nc = state .nextcloud .as_ref() diff --git a/src/interfaces/nextcloud/webdav_handler.rs b/src/interfaces/nextcloud/webdav_handler.rs index b0aa33e7..7910933e 100644 --- a/src/interfaces/nextcloud/webdav_handler.rs +++ b/src/interfaces/nextcloud/webdav_handler.rs @@ -25,7 +25,6 @@ use crate::common::di::AppState; use crate::common::mime_detect::filename_from_path; use crate::interfaces::api::handlers::webdav_handler::PROPFIND_BATCH_SIZE; use crate::interfaces::errors::AppError; -use crate::interfaces::middleware::auth::{AuthUser, CurrentUser}; use crate::interfaces::range_requests::{not_modified_response, range_response}; use crate::interfaces::upload_ingest::ingest_body_to_cas; @@ -47,30 +46,35 @@ fn timestamp_to_i64(ts: u64) -> i64 { const HEADER_DAV: HeaderName = HeaderName::from_static("dav"); -/// Resolve the internal OxiCloud path from a Nextcloud DAV subpath. +/// Resolve the internal OxiCloud path from a NextCloud DAV subpath +/// and the storage chroot the request is confined to. /// -/// Nextcloud: /remote.php/dav/files/{user}/{subpath} -/// Internal: My Folder - {username}/{subpath} +/// `chroot` is the storage path the request is "jailed" inside — +/// the route glue (`routes.rs::handle_dav_*`) computes it once per +/// request: +/// - Legacy `/files/{user}/…` or explicit `~{home_folder_uuid}` → +/// `"My Folder - {username}"` (no DB lookup needed). +/// - `~{some_other_folder_uuid}` → the folder's stored `path` after +/// a `get_folder_with_perms` check (404 if missing / no access). /// -/// An empty subpath maps to the user's home folder root. -pub fn nc_to_internal_path(_username: &str, subpath: &str) -> Result { - // D0: every default personal drive's root folder is named "Personal" - // (docs/plan/drive.md §3 — the canonical post-D0 default). The NC - // dispatcher chroots into the caller's default drive, so the leading - // segment of the internal path is always the drive's root folder - // name. Hardcoded for now; a follow-up will read it from - // `drives.root_folder_id`'s name to support secondary drives with - // custom root-folder names. - let home = "Personal".to_string(); +/// By the time we get here `chroot` is known to be a legitimate +/// target — validation and permission live in the route layer, not +/// in the path mapper. This function stays sync and free of any +/// folder-service handle. The chroot's `path` is the canonical root +/// segment (e.g. `"Personal"` for default personal drives provisioned +/// by D0, the original sibling-root folder name for secondary drives). +/// Replaces the pre-D0 hardcoded `"My Folder - {username}/"` prefix. +pub fn nc_to_internal_path(chroot: &FolderDto, subpath: &str) -> Result { let subpath = subpath.trim_matches('/'); if subpath.is_empty() { - return Ok(home); + return Ok(chroot.path.clone()); } // Reject path traversal attempts. if subpath.split('/').any(|seg| seg == ".." || seg == ".") { return Err(AppError::bad_request("Invalid path: traversal not allowed")); } - Ok(format!("{}/{}", home, subpath)) + + Ok(format!("{}/{}", chroot.path, subpath)) } /// Build the Nextcloud DAV href for a **collection** (folder). Always @@ -120,26 +124,42 @@ pub fn nc_href(username: &str, subpath: &str) -> String { /// Dispatch Nextcloud WebDAV request to the appropriate handler. /// /// `subpath` is everything after `/remote.php/dav/files/{user}/`. +/// `session.chroot` is the storage path the request is confined to +/// — see [`nc_to_internal_path`] for what gets resolved upstream. +/// `session.raw_username` is the literal wire identifier — bare +/// `admin` for single-drive sync, composite `admin~{drive_uuid}` for +/// multi-drive. **Hrefs in every response MUST be built from +/// `session.raw_username`, not from `session.user.username`** — the +/// NC desktop client validates that PROPFIND/MOVE response hrefs +/// share the requested URL's prefix and aborts the parse otherwise +/// (`Invalid href "<…>" expected starting with ""`). +/// The bare `session.user.username` is still the right value for +/// the storage-side owner identity (`oc:owner-id`). pub async fn handle_nc_webdav( state: Arc, req: Request, - user: AuthUser, + session: crate::interfaces::nextcloud::session::NcSession, subpath: String, ) -> Result, AppError> { + // Validate up-front that we have a chroot — every method below is + // path-scoped, so a missing chroot is a route-wiring bug we want to + // surface as a 500 immediately rather than re-checking inside each + // handler. + session.require_chroot()?; let method = req.method().clone(); match method.as_str() { "OPTIONS" => handle_options(), - "GET" => handle_get(state, &user, &subpath, req.headers()).await, - "PROPFIND" => handle_propfind(state, req, &user, &subpath).await, - "PUT" => handle_put(state, req, &user, &subpath).await, - "MKCOL" => handle_mkcol(state, &user, &subpath).await, - "DELETE" => handle_delete(state, &user, &subpath).await, - "MOVE" => handle_move(state, req, &user, &subpath).await, - "HEAD" => handle_head(state, &user, &subpath).await, - "PROPPATCH" => handle_proppatch(state, req, &user, &subpath).await, + "PROPFIND" => handle_propfind(state, req, &session, &subpath).await, + "GET" => handle_get(state, &session, &subpath, req.headers()).await, + "PUT" => handle_put(state, req, &session, &subpath).await, + "MKCOL" => handle_mkcol(state, &session, &subpath).await, + "DELETE" => handle_delete(state, &session, &subpath).await, + "MOVE" => handle_move(state, req, &session, &subpath).await, + "HEAD" => handle_head(state, &session, &subpath).await, + "PROPPATCH" => handle_proppatch(state, req, &session, &subpath).await, "REPORT" | "SEARCH" => { crate::interfaces::nextcloud::report_handler::handle_nc_report( - state, req, &user, &subpath, + state, req, &session, &subpath, ) .await } @@ -178,9 +198,12 @@ fn handle_options() -> Result, AppError> { async fn handle_propfind( state: Arc, req: Request, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, subpath: &str, ) -> Result, AppError> { + let user = &session.user; + let chroot = session.require_chroot()?; + let url_user = &session.raw_username; let depth = req .headers() .get("depth") @@ -205,23 +228,33 @@ async fn handle_propfind( .map_err(|e| AppError::bad_request(format!("Invalid PROPFIND XML: {}", e)))? }; - let internal_path = nc_to_internal_path(&user.username, subpath)?; + let internal_path = nc_to_internal_path(chroot, subpath)?; + let folder_service = &state.applications.folder_service; let file_service = &state.applications.file_retrieval_service; // Try to resolve as folder first. - let folder_result = folder_service.get_folder_by_path(&internal_path, user.id).await; + let folder_result = folder_service + .get_folder_by_path(&internal_path, user.id) + .await; if let Ok(folder) = folder_result { // It's a folder — stream the multistatus: children are fetched in // pages and serialized chunk by chunk, so memory stays O(batch) // regardless of how many entries the folder holds. + // + // Multi-drive POC: the hrefs in the response must echo the + // wire form (`{user}~{drive}`) the client requested, so we + // pass `url_user` (not `user.username`) as the streaming + // function's username arg. Refining the owner-id usages + // back to the canonical username is deferred to the + // NcSession commit. return Ok(build_nc_streaming_propfind( state.clone(), folder, depth, user.id, - user.username.clone(), + url_user.to_string(), subpath.to_string(), )); } @@ -247,6 +280,7 @@ async fn handle_propfind( write_nc_file_multistatus( &mut buf, &file, + url_user, &user.username, subpath, file_id_svc, @@ -269,10 +303,12 @@ async fn handle_propfind( async fn handle_get( state: Arc, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, subpath: &str, headers: &axum::http::HeaderMap, ) -> Result, AppError> { + let user = &session.user; + let chroot = session.require_chroot()?; // GET on root folder — NC clients use this as an existence check if subpath.is_empty() || subpath == "/" { return Ok(Response::builder() @@ -282,7 +318,7 @@ async fn handle_get( .unwrap()); } - let internal_path = nc_to_internal_path(&user.username, subpath)?; + let internal_path = nc_to_internal_path(chroot, subpath)?; let file_service = &state.applications.file_retrieval_service; let folder_service = &state.applications.folder_service; @@ -347,9 +383,11 @@ async fn handle_get( async fn handle_head( state: Arc, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, subpath: &str, ) -> Result, AppError> { + let user = &session.user; + let chroot = session.require_chroot()?; // HEAD on root folder — NC clients use this as an existence check if subpath.is_empty() || subpath == "/" { return Ok(Response::builder() @@ -359,7 +397,7 @@ async fn handle_head( .unwrap()); } - let internal_path = nc_to_internal_path(&user.username, subpath)?; + let internal_path = nc_to_internal_path(chroot, subpath)?; let file_service = &state.applications.file_retrieval_service; let folder_service = &state.applications.folder_service; @@ -411,9 +449,12 @@ async fn handle_head( async fn handle_proppatch( state: Arc, req: Request, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, subpath: &str, ) -> Result, AppError> { + let user = &session.user; + let chroot = session.require_chroot()?; + let url_user = &session.raw_username; let body_bytes = body::to_bytes(req.into_body(), 64 * 1024) .await .map_err(|e| AppError::bad_request(format!("Failed to read body: {}", e)))?; @@ -435,12 +476,15 @@ async fn handle_proppatch( // PROPPATCH path (no favorite directive in the body) — matches // the prior behaviour. A PROPPATCH that *does* try to set // favorite on a missing resource still returns NotFound. - let internal_path = nc_to_internal_path(&user.username, subpath)?; + let internal_path = nc_to_internal_path(chroot, subpath)?; let file_service = &state.applications.file_retrieval_service; let folder_service = &state.applications.folder_service; let resource = if let Ok(file) = file_service.get_file_by_path(&internal_path).await { Some((file.id, "file")) - } else if let Ok(folder) = folder_service.get_folder_by_path(&internal_path, user.id).await { + } else if let Ok(folder) = folder_service + .get_folder_by_path(&internal_path, user.id) + .await + { Some((folder.id, "folder")) } else { None @@ -479,9 +523,9 @@ async fn handle_proppatch( // type to satisfy the RFC 4918 §5.2 trailing-slash invariant — // see the comment block at the top of this function. let href = if is_collection { - nc_collection_href(&user.username, subpath) + nc_collection_href(url_user, subpath) } else { - nc_href(&user.username, subpath) + nc_href(url_user, subpath) }; let mut buf = Vec::new(); { @@ -617,10 +661,11 @@ fn precondition_failed_response() -> Response { async fn handle_put( state: Arc, req: Request, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, subpath: &str, ) -> Result, AppError> { - let internal_path = nc_to_internal_path(&user.username, subpath)?; + let chroot = session.require_chroot()?; + let internal_path = nc_to_internal_path(chroot, subpath)?; let file_service = &state.applications.file_retrieval_service; let upload_service = &state.applications.file_upload_service; @@ -719,13 +764,15 @@ async fn handle_put( async fn handle_mkcol( state: Arc, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, subpath: &str, ) -> Result, AppError> { + let user = &session.user; + let chroot = session.require_chroot()?; use crate::application::dtos::folder_dto::CreateFolderDto; let folder_service = &state.applications.folder_service; - let internal_path = nc_to_internal_path(&user.username, subpath)?; + let internal_path = nc_to_internal_path(chroot, subpath)?; // RFC 4918 §9.3.1: // - target already exists → 405 Method Not Allowed @@ -758,14 +805,21 @@ async fn handle_mkcol( } let (target_name, parent_segments) = segments.split_last().expect("checked non-empty above"); - let user_root = nc_to_internal_path(&user.username, "")?; + // Take POC's `chroot`-based root resolution (drive-aware mount + // point) but keep HEAD's parent_path lookup pattern — the + // continuation below uses `get_folder_by_path(&parent_path, + // user.id)` (user-scoped lookup added in the D0 rewind). + let user_root = nc_to_internal_path(chroot, "")?; let parent_path = if parent_segments.is_empty() { user_root.clone() } else { format!("{}/{}", user_root, parent_segments.join("/")) }; - let parent_folder = match folder_service.get_folder_by_path(&parent_path, user.id).await { + let parent_folder = match folder_service + .get_folder_by_path(&parent_path, user.id) + .await + { Ok(folder) => folder, Err(_) => { return Ok(Response::builder() @@ -794,17 +848,22 @@ async fn handle_mkcol( async fn handle_delete( state: Arc, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, subpath: &str, ) -> Result, AppError> { - let internal_path = nc_to_internal_path(&user.username, subpath)?; + let user = &session.user; + let chroot = session.require_chroot()?; + let internal_path = nc_to_internal_path(chroot, subpath)?; let folder_service = &state.applications.folder_service; let file_service = &state.applications.file_retrieval_service; // Prefer soft-delete (move to trash) when trash service is available. // This is what Nextcloud clients expect — items appear in the trashbin. if let Some(trash_svc) = state.trash_service.as_ref() { - if let Ok(folder) = folder_service.get_folder_by_path(&internal_path, user.id).await { + if let Ok(folder) = folder_service + .get_folder_by_path(&internal_path, user.id) + .await + { trash_svc .move_to_trash(&folder.id, "folder", user.id) .await @@ -830,7 +889,10 @@ async fn handle_delete( // Fallback: hard delete when trash service is not available. let file_mgmt = &state.applications.file_management_service; - if let Ok(folder) = folder_service.get_folder_by_path(&internal_path, user.id).await { + if let Ok(folder) = folder_service + .get_folder_by_path(&internal_path, user.id) + .await + { folder_service .delete_folder_with_perms(&folder.id, user.id) .await @@ -862,9 +924,12 @@ async fn handle_delete( async fn handle_move( state: Arc, req: Request, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, subpath: &str, ) -> Result, AppError> { + let user = &session.user; + let chroot = session.require_chroot()?; + let url_user = &session.raw_username; let destination = req .headers() .get("destination") @@ -886,10 +951,14 @@ async fn handle_move( .unwrap_or(false); // Parse destination path: extract subpath after /remote.php/dav/files/{user}/ - let dest_subpath = extract_nc_subpath_from_dest(&destination, &user.username) + // — the URL user-segment carries the drive marker on multi-drive + // sessions, so we strip the *composite* prefix to find the real + // subpath. Using `user.username` here would fail to match for any + // request hitting a non-home drive. + let dest_subpath = extract_nc_subpath_from_dest(&destination, url_user) .ok_or_else(|| AppError::bad_request("Invalid Destination URL"))?; - let src_internal = nc_to_internal_path(&user.username, subpath)?; + let src_internal = nc_to_internal_path(chroot, subpath)?; let folder_service = &state.applications.folder_service; let file_service = &state.applications.file_retrieval_service; let file_mgmt = &state.applications.file_management_service; @@ -898,7 +967,7 @@ async fn handle_move( // Resolved once up-front so the file/folder branches below don't // each have to repeat the check. `dest_existed_before` becomes the // 204-vs-201 selector at response time. - let dest_internal_precheck = nc_to_internal_path(&user.username, &dest_subpath)?; + let dest_internal_precheck = nc_to_internal_path(chroot, &dest_subpath)?; let dest_existing_file = file_service .get_file_by_path(&dest_internal_precheck) .await @@ -952,7 +1021,7 @@ async fn handle_move( Some((parent, name)) => (parent, name), None => ("", dest_subpath.as_str()), }; - let dest_parent_internal = nc_to_internal_path(&user.username, dest_parent_sub)?; + let dest_parent_internal = nc_to_internal_path(chroot, dest_parent_sub)?; // Rename if only the name changes (same parent). let src_parent_sub = match subpath.rsplit_once('/') { @@ -988,7 +1057,10 @@ async fn handle_move( } // Return ETag and OC-ETag so Nextcloud clients can track the moved file. - let dest_internal = nc_to_internal_path(&user.username, &dest_subpath)?; + // Take POC's chroot-based path resolution; keep HEAD's + // final_status (201 vs 204 depending on whether the destination + // existed — RFC 4918 §9.9.4 distinguishes create vs overwrite). + let dest_internal = nc_to_internal_path(chroot, &dest_subpath)?; let mut builder = Response::builder().status(final_status); if let Ok(moved) = file_service.get_file_by_path(&dest_internal).await { // Route through `FileDto::etag` so the MOVE response @@ -1004,12 +1076,15 @@ async fn handle_move( } // Try as folder. - if let Ok(folder) = folder_service.get_folder_by_path(&src_internal, user.id).await { + if let Ok(folder) = folder_service + .get_folder_by_path(&src_internal, user.id) + .await + { let (dest_parent_sub, dest_name) = match dest_subpath.rsplit_once('/') { Some((parent, name)) => (parent, name), None => ("", dest_subpath.as_str()), }; - let dest_parent_internal = nc_to_internal_path(&user.username, dest_parent_sub)?; + let dest_parent_internal = nc_to_internal_path(chroot, dest_parent_sub)?; let src_parent_sub = match subpath.rsplit_once('/') { Some((parent, _)) => parent, @@ -1119,6 +1194,7 @@ fn write_nc_multistatus_open(xml: &mut Writer) -> Result<( async fn write_nc_file_multistatus( writer: W, file: &FileDto, + url_user: &str, username: &str, subpath: &str, file_id_svc: Option<&Arc>, @@ -1131,7 +1207,11 @@ async fn write_nc_file_multistatus( write_nc_multistatus_open(&mut xml)?; // Single-file PROPFIND — subpath already points to the file. - let href = nc_href(username, subpath); + // `url_user` is the wire identifier (may carry a `~{drive}` + // marker); the NC client validates that the returned `` + // shares the requested URL's prefix. `username` is the canonical + // identity for the `oc:owner-id` field. + let href = nc_href(url_user, subpath); let file_id = file_id_map.get(&file.id).copied(); let oc_id = file_id.map(|id| format_oc_id(id, file_id_svc)); write_file_response( @@ -1537,39 +1617,78 @@ mod tests { use super::*; // ── nc_to_internal_path ── + // + // The route glue resolves the `chroot` FolderDto once per request + // (legacy/home → user's home folder DTO; explicit `~{folder_uuid}` → + // folder's stored DTO after permission check). These tests cover only + // the path-mapping function itself; the resolver logic lives in + // `routes.rs::verify_url_user_and_resolve_chroot`. - #[test] - fn test_empty_subpath_returns_home() { - assert_eq!( - nc_to_internal_path("alice", "").unwrap(), - "My Folder - alice" - ); + /// Build a stub `FolderDto` carrying only the `path` field (all the + /// path mapper looks at). Keeps the tests focused on path mapping + /// without dragging in folder-construction machinery. + fn stub_folder(path: &str) -> FolderDto { + FolderDto { + id: "00000000-0000-0000-0000-000000000000".to_string(), + name: path.rsplit('/').next().unwrap_or("").to_string(), + path: path.to_string(), + parent_id: None, + owner_id: None, + created_at: 0, + modified_at: 0, + is_root: false, + icon_class: std::sync::Arc::from("fas fa-folder"), + icon_special_class: std::sync::Arc::from("folder-icon"), + category: std::sync::Arc::from("Folder"), + etag: String::new(), + } } #[test] - fn test_subpath_appended() { + fn test_empty_subpath_returns_chroot() { + let home = stub_folder("My Folder - alice"); + assert_eq!(nc_to_internal_path(&home, "").unwrap(), "My Folder - alice"); + } + + #[test] + fn test_subpath_appended_to_chroot() { + let home = stub_folder("My Folder - alice"); assert_eq!( - nc_to_internal_path("alice", "Documents/work").unwrap(), + nc_to_internal_path(&home, "Documents/work").unwrap(), "My Folder - alice/Documents/work" ); } #[test] fn test_strips_surrounding_slashes() { + let home = stub_folder("My Folder - alice"); assert_eq!( - nc_to_internal_path("alice", "/Photos/").unwrap(), + nc_to_internal_path(&home, "/Photos/").unwrap(), "My Folder - alice/Photos" ); } #[test] fn test_rejects_dot_dot_traversal() { - assert!(nc_to_internal_path("alice", "../etc/passwd").is_err()); + let home = stub_folder("My Folder - alice"); + assert!(nc_to_internal_path(&home, "../etc/passwd").is_err()); } #[test] fn test_rejects_single_dot() { - assert!(nc_to_internal_path("alice", "foo/./bar").is_err()); + let home = stub_folder("My Folder - alice"); + assert!(nc_to_internal_path(&home, "foo/./bar").is_err()); + } + + /// Confines a subfolder chroot (the multi-drive form once + /// resolved). Same path-mapping logic — only the chroot differs. + #[test] + fn test_subfolder_chroot_with_subpath() { + let chroot = stub_folder("My Folder - alice/ext"); + assert_eq!( + nc_to_internal_path(&chroot, "report.pdf").unwrap(), + "My Folder - alice/ext/report.pdf" + ); } // ── nc_href ── diff --git a/static/css/views/auth.css b/static/css/views/auth.css index 526eecf7..c5ced6b9 100644 --- a/static/css/views/auth.css +++ b/static/css/views/auth.css @@ -803,3 +803,50 @@ font-weight: var(--weight-medium); color: var(--color-warning-orange-text); } + +/* Nextcloud drive picker — radio list of drives the authenticated user + can select for this app-password binding. */ +.auth-drive-option { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + margin-bottom: 8px; + border: 1px solid var(--color-border); + border-radius: 12px; + cursor: pointer; + transition: + background 0.2s ease, + border-color 0.2s ease; +} + +.auth-drive-option:hover { + background: var(--color-bg-hover); + border-color: var(--color-accent); +} + +.auth-drive-option input[type="radio"] { + margin: 0; + accent-color: var(--color-accent); +} + +.auth-drive-option input[type="radio"]:checked ~ .auth-drive-name { + font-weight: 600; +} + +.auth-drive-name { + flex: 1; + color: var(--color-text); + font-size: 15px; +} + +.auth-drive-badge { + padding: 2px 10px; + border-radius: 999px; + background: var(--color-accent-gradient); + color: var(--color-danger-text); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.5px; + text-transform: uppercase; +} diff --git a/templates/nextcloud/drive_picker.html b/templates/nextcloud/drive_picker.html new file mode 100644 index 00000000..d8f7d405 --- /dev/null +++ b/templates/nextcloud/drive_picker.html @@ -0,0 +1,51 @@ + + + + + + + Choose a drive - OxiCloud + + + + + + +
+
+ + +

Choose a drive

+

+ Your account has access to several drives. Pick the one this Nextcloud client should sync. +

+ +
+ {%- for drive in drives %} + + {%- endfor %} + + +
+
+
+ +