Merge pull request #479 from EdouardVanbelle/feat/drive-impl
feat/drive impl
This commit is contained in:
@@ -417,6 +417,7 @@ impl ChunkedUploadHandler {
|
||||
parts.folder_id.clone(),
|
||||
ingested.content_type.clone(),
|
||||
ingested.stored(),
|
||||
auth_user.id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
//! `GET /api/drives` — list every drive the caller can read.
|
||||
//!
|
||||
//! D0 ships the read-only listing; D2 adds shared-drive membership
|
||||
//! mutations (`POST/DELETE/PUT /api/drives/{id}/members`), D3 adds the
|
||||
//! create-shared-drive flow, etc.
|
||||
//!
|
||||
//! The handler resolves the caller's expanded subject set through the
|
||||
//! engine (so group-mediated drive grants surface — the foundation for
|
||||
//! D2/D3) and asks the `DriveRepository` for every drive that set can
|
||||
//! read. Authorization is purely the subject-expansion step: no
|
||||
//! `require(...)` call here, because "your accessible drives" is a
|
||||
//! listing query, not a permission decision on a specific drive.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
|
||||
use tracing::error;
|
||||
|
||||
use crate::application::dtos::drive_dto::DriveDto;
|
||||
use crate::common::di::AppState;
|
||||
use crate::domain::repositories::drive_repository::DriveRepository;
|
||||
use crate::domain::services::authorization::Subject;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::AuthUser;
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/drives",
|
||||
responses(
|
||||
(status = 200, description = "Drives the caller can read", body = Vec<DriveDto>),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "drives"
|
||||
)]
|
||||
pub async fn list_drives(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
) -> impl IntoResponse {
|
||||
let caller_id = auth_user.id;
|
||||
|
||||
// Expand the caller's `Subject::User` into the `(types, ids)` pair
|
||||
// that includes every group the user transitively belongs to. The
|
||||
// engine caches this expansion in its Moka cache; if the caller
|
||||
// just ran a permission check, this is a hit.
|
||||
let (subject_types, subject_ids) = match state
|
||||
.authorization
|
||||
.expand_subject_for_listing(Subject::User(caller_id))
|
||||
.await
|
||||
{
|
||||
Ok(pair) => pair,
|
||||
Err(e) => {
|
||||
error!("list_drives: subject expansion failed: {e}");
|
||||
return AppError::from(e).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
match state
|
||||
.drive_repo
|
||||
.list_for_subjects(&subject_types, &subject_ids)
|
||||
.await
|
||||
{
|
||||
Ok(drives) => {
|
||||
let dtos: Vec<DriveDto> = drives.into_iter().map(DriveDto::from).collect();
|
||||
(StatusCode::OK, Json(dtos)).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("list_drives: repo lookup failed: {e}");
|
||||
AppError::internal_error(format!("Failed to list drives: {e}")).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -262,12 +262,20 @@ pub async fn list_favorites_resources(
|
||||
path,
|
||||
parent_id: row.parent_id.map(|u| u.to_string()),
|
||||
owner_id: Some(row.owner_id.to_string()),
|
||||
// Listing handler — drive_id is informational
|
||||
// and the favorites row doesn't currently
|
||||
// SELECT it. Path-based lookups never enter
|
||||
// this code path.
|
||||
drive_id: uuid::Uuid::nil(),
|
||||
created_at: row.resource_created_at.timestamp() as u64,
|
||||
modified_at: row.modified_at.timestamp() as u64,
|
||||
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"),
|
||||
// §14 provenance not selected by the favorites query.
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
};
|
||||
FavoritesResourceItemDto {
|
||||
resource_type: ResourceTypeDto::Folder,
|
||||
@@ -311,6 +319,9 @@ pub async fn list_favorites_resources(
|
||||
sort_date: None,
|
||||
content_hash,
|
||||
etag,
|
||||
// §14 provenance not selected by the favorites query.
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
};
|
||||
FavoritesResourceItemDto {
|
||||
resource_type: ResourceTypeDto::File,
|
||||
|
||||
@@ -284,6 +284,7 @@ impl FileHandler {
|
||||
folder_id,
|
||||
ingested.content_type.clone(),
|
||||
ingested.stored(),
|
||||
auth_user.id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -753,12 +753,19 @@ pub async fn list_folder_resources(
|
||||
path: String::new(), // cleared — share recipients must not see hierarchy
|
||||
parent_id: row.parent_id.map(|u| u.to_string()),
|
||||
owner_id: Some(row.owner_id.to_string()),
|
||||
// Resources listing — drive_id is informational
|
||||
// here; not selected by the underlying query.
|
||||
// Path-based lookups never enter this code path.
|
||||
drive_id: uuid::Uuid::nil(),
|
||||
created_at: row.created_at.timestamp() as u64,
|
||||
modified_at: row.modified_at.timestamp() as u64,
|
||||
is_root: false,
|
||||
icon_class: Arc::from("fas fa-folder"),
|
||||
icon_special_class: Arc::from("folder-icon"),
|
||||
category: Arc::from("Folder"),
|
||||
// §14 provenance not selected by the resources query.
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
};
|
||||
FolderResourceItemDto {
|
||||
resource_type: ResourceTypeDto::Folder,
|
||||
@@ -801,6 +808,9 @@ pub async fn list_folder_resources(
|
||||
sort_date: None,
|
||||
content_hash,
|
||||
etag,
|
||||
// §14 provenance not selected by the resources query.
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
};
|
||||
FolderResourceItemDto {
|
||||
resource_type: ResourceTypeDto::File,
|
||||
|
||||
@@ -719,6 +719,13 @@ pub async fn list_shared_with_me(
|
||||
summary.resource_id
|
||||
),
|
||||
},
|
||||
// Drive grants don't appear in the file/folder "Shared with me"
|
||||
// listing — they're surfaced through `GET /api/drives` (D0).
|
||||
// Silently skipping here is the right behaviour: a drive grant
|
||||
// discovered by `list_incoming_resources_paged` is not a stale
|
||||
// grant, just a different resource type with a different
|
||||
// listing surface.
|
||||
ResourceKind::Drive => continue,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -953,6 +960,10 @@ pub async fn list_my_shares(
|
||||
summary.resource_id
|
||||
),
|
||||
},
|
||||
// Drive grants are surfaced via `GET /api/drives` (D0), not
|
||||
// through the My Shares outgoing-resources surface. Silently
|
||||
// skip — symmetric with the `list_shared_with_me` arm above.
|
||||
ResourceKind::Drive => continue,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod contacts_handler;
|
||||
pub mod dedup_handler;
|
||||
pub mod delta_upload_handler;
|
||||
pub mod device_auth_handler;
|
||||
pub mod drive_handler;
|
||||
pub mod favorites_handler;
|
||||
pub mod file_handler;
|
||||
pub mod folder_handler;
|
||||
|
||||
@@ -292,12 +292,20 @@ pub async fn list_recent_resources(
|
||||
path,
|
||||
parent_id: row.parent_id.map(|u| u.to_string()),
|
||||
owner_id: Some(row.owner_id.to_string()),
|
||||
// Listing handler — drive_id is informational
|
||||
// and the recents row doesn't currently SELECT
|
||||
// it. Path-based lookups never enter this code
|
||||
// path.
|
||||
drive_id: uuid::Uuid::nil(),
|
||||
created_at: row.resource_created_at.timestamp() as u64,
|
||||
modified_at: row.modified_at.timestamp() as u64,
|
||||
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"),
|
||||
// §14 provenance not selected by the recents query.
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
};
|
||||
RecentResourceItemDto {
|
||||
resource_type: ResourceTypeDto::Folder,
|
||||
@@ -339,6 +347,9 @@ pub async fn list_recent_resources(
|
||||
sort_date: None,
|
||||
content_hash,
|
||||
etag,
|
||||
// §14 provenance not selected by the recents query.
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
};
|
||||
RecentResourceItemDto {
|
||||
resource_type: ResourceTypeDto::File,
|
||||
|
||||
@@ -27,6 +27,7 @@ use crate::application::ports::storage_ports::StorageUsagePort;
|
||||
use crate::application::services::file_retrieval_service::FileRetrievalService;
|
||||
use crate::application::services::folder_service::FolderService;
|
||||
use crate::common::di::AppState;
|
||||
use crate::domain::repositories::drive_repository::DriveRepository;
|
||||
use crate::infrastructure::services::path_resolver_service::ResolvedResource;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::{AuthUser, CurrentUser};
|
||||
@@ -247,6 +248,29 @@ async fn resolve_webdav_path(state: &Arc<AppState>, user_id: Uuid, path: &str) -
|
||||
}
|
||||
}
|
||||
|
||||
/// Native WebDAV protocol entry: resolve the caller's default drive
|
||||
/// once per handler so every downstream path-based lookup
|
||||
/// (`get_folder_by_path`, `get_file_by_path`, `update_file_streaming`)
|
||||
/// can pass the same `drive_id` scope.
|
||||
///
|
||||
/// Post-D0 `storage.{folders,files}.path` repeats across drives — the
|
||||
/// scope is mandatory. Native WebDAV today lives in a single-drive
|
||||
/// surface (one default drive per user), so the lookup is unambiguous.
|
||||
/// Multi-drive support via path segments (`/webdav/drives/<uuid>/…`)
|
||||
/// is tracked separately and will derive `drive_id` directly from the
|
||||
/// URL instead of going through `find_default_for_user`.
|
||||
async fn resolve_drive_id_for_native_webdav(
|
||||
state: &Arc<AppState>,
|
||||
user_id: Uuid,
|
||||
) -> Result<Uuid, AppError> {
|
||||
state
|
||||
.drive_repo
|
||||
.find_default_for_user(user_id)
|
||||
.await
|
||||
.map(|d| d.drive.id)
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to resolve default drive: {:?}", e)))
|
||||
}
|
||||
|
||||
async fn handle_webdav_dispatch(
|
||||
state: Arc<AppState>,
|
||||
req: Request<Body>,
|
||||
@@ -405,12 +429,18 @@ async fn handle_propfind(
|
||||
path: "".to_string(),
|
||||
parent_id: None,
|
||||
owner_id: None,
|
||||
// Synthetic root folder for PROPFIND on `/`; not an
|
||||
// actual DB row, so drive_id has no meaningful value.
|
||||
drive_id: Uuid::nil(),
|
||||
created_at: Utc::now().timestamp() as u64,
|
||||
modified_at: Utc::now().timestamp() as u64,
|
||||
is_root: true,
|
||||
icon_class: Arc::from("fas fa-folder"),
|
||||
icon_special_class: Arc::from("folder-icon"),
|
||||
category: Arc::from("Folder"),
|
||||
// §14 provenance not applicable to the synthetic root.
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
};
|
||||
|
||||
return build_streaming_propfind_response(
|
||||
@@ -468,8 +498,11 @@ async fn handle_propfind(
|
||||
Err(_) => {}
|
||||
}
|
||||
} else {
|
||||
// Fallback: legacy double-query path when PathResolver is unavailable
|
||||
if let Ok(folder) = folder_service.get_folder_by_path(&path).await {
|
||||
// Fallback: legacy double-query path when PathResolver is unavailable.
|
||||
// `drive_id` is mandatory post-D0 for path-based lookups — derive
|
||||
// the caller's default drive once and reuse it for both probes.
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
if let Ok(folder) = folder_service.get_folder_by_path(&path, drive_id).await {
|
||||
assert_owner(folder.owner_id.as_deref(), &user.id.to_string(), &path)?;
|
||||
let folder_id = folder.id.clone();
|
||||
return build_streaming_propfind_response(
|
||||
@@ -484,7 +517,10 @@ async fn handle_propfind(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if let Ok(file) = file_retrieval_service.get_file_by_path(&path).await {
|
||||
if let Ok(file) = file_retrieval_service
|
||||
.get_file_by_path(&path, drive_id)
|
||||
.await
|
||||
{
|
||||
assert_owner(file.owner_id.as_deref(), &user.id.to_string(), &path)?;
|
||||
let mut buf = Vec::with_capacity(1024);
|
||||
{
|
||||
@@ -656,7 +692,7 @@ async fn handle_proppatch(
|
||||
req: Request<Body>,
|
||||
path: String,
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let _user = extract_user(&req)?;
|
||||
let user = extract_user(&req)?;
|
||||
|
||||
// Active-lock guard (RFC 4918 §9.10.4): PROPPATCH writes properties,
|
||||
// so a lock on the target must release them via `If:`. Captured
|
||||
@@ -688,10 +724,11 @@ async fn handle_proppatch(
|
||||
let is_collection = if path.is_empty() || path == "/" {
|
||||
true
|
||||
} else {
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
state
|
||||
.applications
|
||||
.folder_service
|
||||
.get_folder_by_path(&path)
|
||||
.get_folder_by_path(&path, drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
};
|
||||
@@ -776,9 +813,12 @@ async fn handle_get(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Legacy fallback — fetch + ownership check
|
||||
// Legacy fallback — fetch + ownership check. `drive_id` is the
|
||||
// path-lookup scope post-D0 (`storage.files.path` repeats across
|
||||
// drives), derived once from the caller's default drive.
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
let f = file_retrieval_service
|
||||
.get_file_by_path(&path)
|
||||
.get_file_by_path(&path, drive_id)
|
||||
.await
|
||||
.map_err(|_e| AppError::not_found(format!("File not found: {}", path)))?;
|
||||
assert_owner(f.owner_id.as_deref(), &user.id.to_string(), &path)?;
|
||||
@@ -876,8 +916,11 @@ async fn handle_head(
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: legacy double-query path (with ownership check)
|
||||
if let Ok(folder) = folder_service.get_folder_by_path(&path).await {
|
||||
// Fallback: legacy double-query path (with ownership check).
|
||||
// `drive_id` is the path-lookup scope post-D0 — derive once and
|
||||
// reuse for both the folder and file probes.
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
if let Ok(folder) = folder_service.get_folder_by_path(&path, drive_id).await {
|
||||
assert_owner(folder.owner_id.as_deref(), &user.id.to_string(), &path)?;
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
@@ -890,7 +933,7 @@ async fn handle_head(
|
||||
|
||||
// Try as file — use metadata only, never load content for HEAD
|
||||
let file = file_retrieval_service
|
||||
.get_file_by_path(&path)
|
||||
.get_file_by_path(&path, drive_id)
|
||||
.await
|
||||
.map_err(|_e| AppError::not_found(format!("Resource not found: {}", path)))?;
|
||||
assert_owner(file.owner_id.as_deref(), &user.id.to_string(), &path)?;
|
||||
@@ -939,15 +982,27 @@ async fn resolve_or_legacy(
|
||||
return Some(r);
|
||||
}
|
||||
|
||||
// Path-lookup scope post-D0 — derive the caller's default drive
|
||||
// for both legacy probes. `find_default_for_user` returning Err
|
||||
// (e.g. external user, or boot before the lifecycle hook fired)
|
||||
// means no fallback resolution is possible: return None.
|
||||
let drive_id = state
|
||||
.drive_repo
|
||||
.find_default_for_user(user_id)
|
||||
.await
|
||||
.ok()?
|
||||
.drive
|
||||
.id;
|
||||
|
||||
let user_id_str = user_id.to_string();
|
||||
let folder_service = &state.applications.folder_service;
|
||||
if let Ok(folder) = folder_service.get_folder_by_path(path).await
|
||||
if let Ok(folder) = folder_service.get_folder_by_path(path, drive_id).await
|
||||
&& folder.owner_id.as_deref() == Some(&user_id_str)
|
||||
{
|
||||
return Some(ResolvedResource::Folder(folder));
|
||||
}
|
||||
let file_retrieval = &state.applications.file_retrieval_service;
|
||||
if let Ok(file) = file_retrieval.get_file_by_path(path).await
|
||||
if let Ok(file) = file_retrieval.get_file_by_path(path, drive_id).await
|
||||
&& file.owner_id.as_deref() == Some(&user_id_str)
|
||||
{
|
||||
return Some(ResolvedResource::File(file));
|
||||
@@ -1143,8 +1198,16 @@ async fn handle_put(
|
||||
|
||||
// ── Atomic store: swap the file row onto the ingested blob ──
|
||||
let content_type = ingested.content_type.clone();
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
let result = file_upload_service
|
||||
.update_file_streaming(&path, ingested.stored(), &content_type, None)
|
||||
.update_file_streaming(
|
||||
&path,
|
||||
drive_id,
|
||||
ingested.stored(),
|
||||
&content_type,
|
||||
None,
|
||||
user.id,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
@@ -1198,6 +1261,9 @@ async fn handle_mkcol(
|
||||
// Path is already translated by dispatch (e.g. "My Folder - jared/03/01").
|
||||
// Walk each segment: the first is the home folder (already exists),
|
||||
// subsequent segments are created as needed with proper parent_id.
|
||||
// `drive_id` scopes each per-segment path probe to the caller's default
|
||||
// drive (post-D0 invariant: `storage.folders.path` repeats across drives).
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
||||
let mut parent_id: Option<String> = None;
|
||||
let mut accumulated_path = String::new();
|
||||
@@ -1208,7 +1274,10 @@ async fn handle_mkcol(
|
||||
}
|
||||
accumulated_path.push_str(segment);
|
||||
|
||||
match folder_service.get_folder_by_path(&accumulated_path).await {
|
||||
match folder_service
|
||||
.get_folder_by_path(&accumulated_path, drive_id)
|
||||
.await
|
||||
{
|
||||
Ok(existing) => {
|
||||
parent_id = Some(existing.id);
|
||||
}
|
||||
@@ -1392,6 +1461,11 @@ async fn handle_move(
|
||||
let file_management_service = &state.applications.file_management_service;
|
||||
let folder_service = &state.applications.folder_service;
|
||||
|
||||
// `drive_id` scopes every path-based lookup below to the caller's
|
||||
// default drive (post-D0 invariant: `storage.{files,folders}.path`
|
||||
// repeats across drives).
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
|
||||
// Check if destination already exists (for Overwrite header compliance)
|
||||
if !overwrite {
|
||||
let dest_exists = if let Some(resolver) = &state.path_resolver {
|
||||
@@ -1401,11 +1475,11 @@ async fn handle_move(
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
folder_service
|
||||
.get_folder_by_path(&destination_path)
|
||||
.get_folder_by_path(&destination_path, drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
|| file_retrieval_service
|
||||
.get_file_by_path(&destination_path)
|
||||
.get_file_by_path(&destination_path, drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
};
|
||||
@@ -1443,7 +1517,9 @@ async fn handle_move(
|
||||
let move_dto = crate::application::dtos::folder_dto::MoveFolderDto {
|
||||
parent_id: if dest_parent_path.is_empty() {
|
||||
None
|
||||
} else if let Ok(parent) = folder_service.get_folder_by_path(dest_parent_path).await
|
||||
} else if let Ok(parent) = folder_service
|
||||
.get_folder_by_path(dest_parent_path, drive_id)
|
||||
.await
|
||||
{
|
||||
assert_owner(
|
||||
parent.owner_id.as_deref(),
|
||||
@@ -1483,7 +1559,7 @@ async fn handle_move(
|
||||
None
|
||||
} else {
|
||||
let parent = folder_service
|
||||
.get_folder_by_path(dest_parent_path)
|
||||
.get_folder_by_path(dest_parent_path, drive_id)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
AppError::not_found(format!(
|
||||
@@ -1601,6 +1677,11 @@ async fn handle_copy(
|
||||
let file_retrieval_service = &state.applications.file_retrieval_service;
|
||||
let folder_service = &state.applications.folder_service;
|
||||
|
||||
// `drive_id` scopes every path-based lookup below to the caller's
|
||||
// default drive (post-D0 invariant: `storage.{files,folders}.path`
|
||||
// repeats across drives).
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
|
||||
// Check if destination already exists (for Overwrite header compliance)
|
||||
if !overwrite {
|
||||
let dest_exists = if let Some(resolver) = &state.path_resolver {
|
||||
@@ -1610,11 +1691,11 @@ async fn handle_copy(
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
folder_service
|
||||
.get_folder_by_path(&destination_path)
|
||||
.get_folder_by_path(&destination_path, drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
|| file_retrieval_service
|
||||
.get_file_by_path(&destination_path)
|
||||
.get_file_by_path(&destination_path, drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
};
|
||||
@@ -1644,7 +1725,10 @@ async fn handle_copy(
|
||||
|
||||
let target_parent_id = if dest_parent_path.is_empty() {
|
||||
None
|
||||
} else if let Ok(parent) = folder_service.get_folder_by_path(dest_parent_path).await {
|
||||
} else if let Ok(parent) = folder_service
|
||||
.get_folder_by_path(dest_parent_path, drive_id)
|
||||
.await
|
||||
{
|
||||
assert_owner(
|
||||
parent.owner_id.as_deref(),
|
||||
&user.id.to_string(),
|
||||
@@ -1740,10 +1824,11 @@ async fn handle_lock(
|
||||
let is_collection = if path.is_empty() || path == "/" {
|
||||
true
|
||||
} else {
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
state
|
||||
.applications
|
||||
.folder_service
|
||||
.get_folder_by_path(&path)
|
||||
.get_folder_by_path(&path, drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ use std::sync::Arc;
|
||||
use crate::application::ports::file_ports::{FileRetrievalUseCase, FileUploadUseCase};
|
||||
use crate::application::services::wopi_lock_service::WopiLockService;
|
||||
use crate::application::services::wopi_token_service::WopiTokenService;
|
||||
use crate::domain::repositories::drive_repository::DriveRepository;
|
||||
use crate::infrastructure::services::wopi_discovery_service::WopiDiscoveryService;
|
||||
|
||||
/// Shared state for WOPI handlers.
|
||||
@@ -233,11 +234,38 @@ async fn put_file(
|
||||
};
|
||||
|
||||
// ── Atomic store: swap the file row onto the ingested blob ──
|
||||
// `drive_id` scopes the path-based lookups in `update_file_streaming`
|
||||
// post-D0. WOPI tokens carry the user UUID in `claims.sub`; we resolve
|
||||
// that to the caller's default drive (WOPI today is a single-drive
|
||||
// editing surface — no drive marker travels in the token).
|
||||
let claims_sub_uuid = match uuid::Uuid::parse_str(&claims.sub) {
|
||||
Ok(u) => u,
|
||||
Err(_) => return StatusCode::UNAUTHORIZED.into_response(),
|
||||
};
|
||||
let drive_id = match state
|
||||
.app_state
|
||||
.drive_repo
|
||||
.find_default_for_user(claims_sub_uuid)
|
||||
.await
|
||||
{
|
||||
Ok(d) => d.drive.id,
|
||||
Err(e) => {
|
||||
tracing::error!("WOPI PutFile: default-drive lookup failed: {:?}", e);
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
};
|
||||
let result = state
|
||||
.app_state
|
||||
.applications
|
||||
.file_upload_service
|
||||
.update_file_streaming(&file.path, ingested.stored(), &content_type, None)
|
||||
.update_file_streaming(
|
||||
&file.path,
|
||||
drive_id,
|
||||
ingested.stored(),
|
||||
&content_type,
|
||||
None,
|
||||
claims_sub_uuid,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
|
||||
@@ -13,6 +13,7 @@ use utoipa::{Modify, OpenApi};
|
||||
use crate::application::dtos::contact_dto::{
|
||||
AddressDto, ContactDto, ContactGroupDto, EmailDto, PhoneDto,
|
||||
};
|
||||
use crate::application::dtos::drive_dto::{DriveDto, DriveKindDto};
|
||||
use crate::application::dtos::favorites_dto::{
|
||||
BatchFavoritesResult, BatchFavoritesStats, FavoriteItemDto, FavoritesResourceItemDto,
|
||||
};
|
||||
@@ -165,6 +166,8 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
||||
// Photos handler (free function)
|
||||
handlers::photos_handler::list_photos,
|
||||
handlers::photos_handler::list_photos_geo,
|
||||
// Drive handler (free function)
|
||||
handlers::drive_handler::list_drives,
|
||||
// Batch handlers (free functions)
|
||||
handlers::batch_handler::move_files_batch,
|
||||
handlers::batch_handler::copy_files_batch,
|
||||
@@ -359,6 +362,9 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
||||
SharedWithMeDto,
|
||||
SharedWithMeItemDto,
|
||||
OutgoingResourceItemDto,
|
||||
// Drive schemas
|
||||
DriveDto,
|
||||
DriveKindDto,
|
||||
// Subject-group (ReBAC named groups) schemas
|
||||
handlers::subject_group_handler::CreateGroupRequest,
|
||||
handlers::subject_group_handler::UpdateGroupRequest,
|
||||
|
||||
@@ -440,6 +440,19 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
router = router.nest("/photos", photos_router);
|
||||
}
|
||||
|
||||
// Drives — every drive the caller can read. D0 ships the read-only
|
||||
// listing; D2 adds the membership API + shared-drive endpoints under
|
||||
// `/api/drives/{id}/members`.
|
||||
{
|
||||
use crate::interfaces::api::handlers::drive_handler;
|
||||
|
||||
let drives_router = Router::new()
|
||||
.route("/", get(drive_handler::list_drives))
|
||||
.with_state(app_state.clone());
|
||||
|
||||
router = router.nest("/drives", drives_router);
|
||||
}
|
||||
|
||||
// People (faces) routes — mounted only when OXICLOUD_ENABLE_FACES is on.
|
||||
if app_state.people_service.is_some() {
|
||||
use crate::interfaces::api::handlers::people_handler;
|
||||
|
||||
@@ -103,7 +103,8 @@ impl<B> MakeSpan<B> 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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String>) = 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,81 @@ 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 → caller's default personal drive's root folder
|
||||
// (post-D0 every internal user has one — provisioned by the
|
||||
// lifecycle hook via the atomic four-write transaction in
|
||||
// §3 of docs/plan/drive.md). 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.
|
||||
//
|
||||
// Pre-D0 this lookup name-matched `"My Folder - <username>"`
|
||||
// against the user's root folders; that broke after the
|
||||
// wrapper was renamed to `"Personal"` and shared across all
|
||||
// users — name-matching was the wrong axis. The drive lookup
|
||||
// is the right one: name-independent, secondary-drive-safe.
|
||||
use crate::application::ports::folder_ports::FolderUseCase;
|
||||
use crate::domain::repositories::drive_repository::DriveRepository;
|
||||
let chroot = match drive_marker.as_deref() {
|
||||
None => {
|
||||
match state
|
||||
.drive_repo
|
||||
.find_default_for_user(current_user.id)
|
||||
.await
|
||||
{
|
||||
Ok(drive_with_name) => state
|
||||
.applications
|
||||
.folder_service
|
||||
.get_folder(&drive_with_name.drive.root_folder_id.to_string())
|
||||
.await
|
||||
.ok(),
|
||||
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::<Arc<crate::interfaces::nextcloud::session::NcSession>>()
|
||||
.and_then(|s| s.chroot.as_ref())
|
||||
.map(|c| c.id.to_string())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
Err(_) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::{HeaderMap, StatusCode, header},
|
||||
@@ -7,8 +8,32 @@ 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<DriveOption>,
|
||||
}
|
||||
|
||||
// Home identification is via `position_of_user_home_root_folder` from
|
||||
// `domain::repositories::drive_repository` — a generic helper that
|
||||
// keys off `drives.default_for_user == user_id` rather than folder
|
||||
// name, so user renames of the home folder don't silently break the
|
||||
// picker UX.
|
||||
|
||||
/// Serve an HTML page with a Content-Security-Policy header as defense-in-depth.
|
||||
fn html_with_csp(html: &'static str) -> Response {
|
||||
@@ -175,47 +200,294 @@ 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) =
|
||||
crate::domain::repositories::drive_repository::position_of_user_home_root_folder(
|
||||
state.drive_repo.as_ref(),
|
||||
current_user.id,
|
||||
&drives,
|
||||
|f| uuid::Uuid::parse_str(&f.id).ok(),
|
||||
)
|
||||
.await
|
||||
&& 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<AppState>,
|
||||
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<Arc<AppState>>,
|
||||
Path(token): Path<String>,
|
||||
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 = crate::domain::repositories::drive_repository::position_of_user_home_root_folder(
|
||||
state.drive_repo.as_ref(),
|
||||
user.id,
|
||||
&drives,
|
||||
|f| uuid::Uuid::parse_str(&f.id).ok(),
|
||||
)
|
||||
.await
|
||||
.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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -46,9 +46,12 @@ pub async fn handle_capabilities_v2(State(state): State<Arc<AppState>>) -> Respo
|
||||
Json(payload).into_response()
|
||||
}
|
||||
|
||||
pub async fn handle_user_info(State(state): State<Arc<AppState>>, user: AuthUser) -> Response {
|
||||
pub async fn handle_user_info(
|
||||
State(state): State<Arc<AppState>>,
|
||||
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<Arc<AppState>>, 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@<drive>`
|
||||
// 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,
|
||||
@@ -399,11 +423,12 @@ pub async fn handle_search(
|
||||
let mut entries: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
// Map file results
|
||||
// TODO(D1): drop the hardcoded "Personal/" prefix and read the
|
||||
// caller's default-drive root folder name from `drives.root_folder_id`
|
||||
// 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(&format!("My Folder - {}/", user.username))
|
||||
.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();
|
||||
@@ -427,11 +452,11 @@ pub async fn handle_search(
|
||||
}));
|
||||
}
|
||||
|
||||
// Map folder results
|
||||
// Map folder results — same TODO(D1) as above.
|
||||
for folder in &results.folders {
|
||||
let display_path = folder
|
||||
.path
|
||||
.strip_prefix(&format!("My Folder - {}/", user.username))
|
||||
.strip_prefix("Personal/")
|
||||
.unwrap_or(&folder.path);
|
||||
let display_path = format!("/{}", display_path);
|
||||
|
||||
|
||||
@@ -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<AppState>,
|
||||
req: Request<Body>,
|
||||
user: &CurrentUser,
|
||||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||||
_subpath: &str,
|
||||
) -> Result<Response<Body>, 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<AppState>,
|
||||
_body: &str,
|
||||
user: &CurrentUser,
|
||||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||||
) -> Result<Response<Body>, 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()),
|
||||
@@ -83,7 +84,11 @@ async fn handle_filter_files(
|
||||
// All items in this response are favorites.
|
||||
let favorite_ids: HashSet<String> = favorites.iter().map(|f| f.item_id.clone()).collect();
|
||||
|
||||
let home_prefix = format!("My Folder - {}/", user.username);
|
||||
// TODO(D1): replace the hardcoded "Personal/" prefix with the
|
||||
// caller's default-drive root folder name read from
|
||||
// `drives.root_folder_id`. Correct for D0-provisioned default
|
||||
// drives; secondary drives keep their original root name.
|
||||
let home_prefix = "Personal/";
|
||||
|
||||
// Pass 1: resolve the favorited DTOs in two batch queries (was one
|
||||
// get_* per favorite — up to N serial round-trips on a sync client's
|
||||
@@ -145,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 subpath = strip_home_prefix(&file.path, home_prefix);
|
||||
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(
|
||||
@@ -163,8 +172,8 @@ 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 subpath = strip_home_prefix(&folder.path, home_prefix);
|
||||
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(
|
||||
@@ -195,8 +204,13 @@ async fn handle_filter_files(
|
||||
async fn handle_search(
|
||||
state: Arc<AppState>,
|
||||
body: &str,
|
||||
user: &CurrentUser,
|
||||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||||
) -> Result<Response<Body>, 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()),
|
||||
@@ -210,7 +224,7 @@ async fn handle_search(
|
||||
let nresults = parse_nresults(body).unwrap_or(100);
|
||||
|
||||
// Resolve folder scope from <d:href> inside <d:scope>.
|
||||
let folder_id = resolve_scope_folder(&state, body, &user.username).await;
|
||||
let folder_id = resolve_scope_folder(&state, body, session).await;
|
||||
|
||||
let criteria = SearchCriteriaDto {
|
||||
name_contains: Some(term),
|
||||
@@ -227,7 +241,10 @@ async fn handle_search(
|
||||
|
||||
let nc = state.nextcloud.as_ref();
|
||||
let file_id_svc = nc.map(|n| &n.file_ids);
|
||||
let home_prefix = format!("My Folder - {}/", user.username);
|
||||
// TODO(D1): same as the favorites pass above — replace the
|
||||
// hardcoded "Personal/" with the caller's actual default-drive
|
||||
// root folder name from `drives.root_folder_id`.
|
||||
let home_prefix = "Personal/";
|
||||
|
||||
// No favorite checking for search results -- pass an empty set.
|
||||
let favorite_ids: HashSet<String> = HashSet::new();
|
||||
@@ -249,8 +266,8 @@ 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 subpath = strip_home_prefix(&file.path, home_prefix);
|
||||
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(
|
||||
@@ -267,8 +284,8 @@ 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 subpath = strip_home_prefix(&folder.path, home_prefix);
|
||||
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(
|
||||
@@ -327,6 +344,9 @@ fn file_dto_from_search(fr: &crate::application::dtos::search_dto::SearchFileRes
|
||||
sort_date: None,
|
||||
content_hash: fr.blob_hash.clone(),
|
||||
etag,
|
||||
// §14 provenance not selected by the search result DTO.
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,12 +361,19 @@ fn folder_dto_from_search(
|
||||
path: sr.path.clone(),
|
||||
parent_id: sr.parent_id.clone(),
|
||||
owner_id: None,
|
||||
// Search result — drive_id is informational. The search row
|
||||
// doesn't currently SELECT it, and path-based lookups never
|
||||
// enter this code path.
|
||||
drive_id: uuid::Uuid::nil(),
|
||||
created_at: sr.created_at,
|
||||
modified_at: sr.modified_at,
|
||||
is_root: sr.is_root,
|
||||
icon_class: Arc::from("fas fa-folder"),
|
||||
icon_special_class: Arc::from("folder-icon"),
|
||||
category: Arc::from("Folder"),
|
||||
// §14 provenance not selected by search results.
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,23 +488,39 @@ fn xml_extract_text(body: &str, local_name: &[u8]) -> Option<String> {
|
||||
}
|
||||
|
||||
/// Resolve a scope href (e.g. `/files/username/Documents`) to a folder ID.
|
||||
async fn resolve_scope_folder(state: &AppState, body: &str, username: &str) -> Option<String> {
|
||||
///
|
||||
/// 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,
|
||||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||||
) -> Option<String> {
|
||||
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)
|
||||
.get_folder_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
.ok()
|
||||
.map(|f| f.id)
|
||||
@@ -486,13 +529,16 @@ async fn resolve_scope_folder(state: &AppState, body: &str, username: &str) -> O
|
||||
/// 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<String> {
|
||||
/// `/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<String> {
|
||||
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 {
|
||||
|
||||
@@ -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<AppState>) -> Router<Arc<AppState>
|
||||
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<AppState>) -> Router<Arc<AppState>
|
||||
|
||||
// ──────────────── 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<Arc<AppState>>,
|
||||
Path((url_user, subpath)): Path<(String, String)>,
|
||||
user_ext: AuthUser,
|
||||
Path((_url_user, subpath)): Path<(String, String)>,
|
||||
session: NcSession,
|
||||
req: Request<Body>,
|
||||
) -> Result<Response, Response> {
|
||||
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<Arc<AppState>>,
|
||||
Path(url_user): Path<String>,
|
||||
user_ext: AuthUser,
|
||||
Path(_url_user): Path<String>,
|
||||
session: NcSession,
|
||||
req: Request<Body>,
|
||||
) -> Result<Response, Response> {
|
||||
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<Arc<AppState>>,
|
||||
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<Body>,
|
||||
) -> Result<Response, Response> {
|
||||
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<Arc<AppState>>,
|
||||
Path((url_user, upload_id)): Path<(String, String)>,
|
||||
user_ext: AuthUser,
|
||||
Path((_url_user, upload_id)): Path<(String, String)>,
|
||||
session: NcSession,
|
||||
req: Request<Body>,
|
||||
) -> Result<Response, Response> {
|
||||
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<Arc<AppState>>,
|
||||
Path((url_user, subpath)): Path<(String, String)>,
|
||||
user_ext: AuthUser,
|
||||
Path((_url_user, subpath)): Path<(String, String)>,
|
||||
session: NcSession,
|
||||
req: Request<Body>,
|
||||
) -> Result<Response, Response> {
|
||||
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<Arc<AppState>>,
|
||||
Path(url_user): Path<String>,
|
||||
user_ext: AuthUser,
|
||||
Path(_url_user): Path<String>,
|
||||
session: NcSession,
|
||||
req: Request<Body>,
|
||||
) -> Result<Response, Response> {
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -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<NcSession>`; 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<FolderDto>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<NcSession>` 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<S: Send + Sync> FromRequestParts<S> for NcSession {
|
||||
type Rejection = Response;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
let session = parts
|
||||
.extensions
|
||||
.get::<Arc<NcSession>>()
|
||||
.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)
|
||||
}
|
||||
}
|
||||
@@ -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<AppState>,
|
||||
req: Request<Body>,
|
||||
user: AuthUser,
|
||||
session: crate::interfaces::nextcloud::session::NcSession,
|
||||
subpath: String,
|
||||
) -> Result<Response<Body>, 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<Response<Body>, AppError> {
|
||||
|
||||
async fn handle_propfind(
|
||||
state: Arc<AppState>,
|
||||
user: &CurrentUser,
|
||||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||||
) -> Result<Response<Body>, 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<AppState>,
|
||||
dest_header: Option<String>,
|
||||
user: &CurrentUser,
|
||||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||||
subpath: &str,
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let user = &session.user;
|
||||
let chroot = session.require_chroot()?;
|
||||
let id = extract_trash_id(subpath)?;
|
||||
|
||||
let trash_svc = state
|
||||
@@ -128,12 +134,15 @@ 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()
|
||||
let dest_taken = file_service
|
||||
.get_file_by_path(&dest_internal, chroot.drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
|| folder_service
|
||||
.get_folder_by_path(&dest_internal)
|
||||
.get_folder_by_path(&dest_internal, chroot.drive_id)
|
||||
.await
|
||||
.is_ok();
|
||||
if dest_taken {
|
||||
@@ -184,8 +193,9 @@ async fn handle_restore(
|
||||
|
||||
async fn handle_empty_trash(
|
||||
state: Arc<AppState>,
|
||||
user: &CurrentUser,
|
||||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let user = &session.user;
|
||||
let trash_svc = state
|
||||
.trash_service
|
||||
.as_ref()
|
||||
@@ -206,9 +216,10 @@ async fn handle_empty_trash(
|
||||
|
||||
async fn handle_delete_permanent(
|
||||
state: Arc<AppState>,
|
||||
user: &CurrentUser,
|
||||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||||
subpath: &str,
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let user = &session.user;
|
||||
let id = extract_trash_id(subpath)?;
|
||||
|
||||
let trash_svc = state
|
||||
@@ -248,11 +259,18 @@ fn mime_from_name(name: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Strip the "My Folder - {username}/" prefix from an original path to produce
|
||||
/// the Nextcloud-relative original location.
|
||||
fn strip_home_prefix<'a>(original_path: &'a str, username: &str) -> &'a str {
|
||||
let prefix = format!("My Folder - {}/", username);
|
||||
original_path.strip_prefix(&prefix).unwrap_or(original_path)
|
||||
/// Strip the home-folder prefix from an original path to produce the
|
||||
/// Nextcloud-relative original location.
|
||||
///
|
||||
/// TODO(D1): replace the hardcoded "Personal/" with the caller's actual
|
||||
/// default-drive root folder name read from `drives.root_folder_id`.
|
||||
/// Correct for D0-provisioned default drives; secondary drives keep
|
||||
/// their original root name. The `_username` arg stays for now so the
|
||||
/// upcoming dynamic lookup has a way to identify the caller.
|
||||
fn strip_home_prefix<'a>(original_path: &'a str, _username: &str) -> &'a str {
|
||||
original_path
|
||||
.strip_prefix("Personal/")
|
||||
.unwrap_or(original_path)
|
||||
}
|
||||
|
||||
// ────────────── Trashbin PROPFIND XML Generation ──────────────
|
||||
|
||||
@@ -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<AppState>,
|
||||
req: Request<Body>,
|
||||
user: AuthUser,
|
||||
session: crate::interfaces::nextcloud::session::NcSession,
|
||||
upload_id: String,
|
||||
rest: String, // chunk name or ".file" or empty
|
||||
) -> Result<Response<Body>, 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<AppState>,
|
||||
user: &CurrentUser,
|
||||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||||
upload_id: &str,
|
||||
) -> Result<Response<Body>, 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<AppState>,
|
||||
user: &CurrentUser,
|
||||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||||
upload_id: &str,
|
||||
) -> Result<Response<Body>, 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<AppState>,
|
||||
req: Request<Body>,
|
||||
user: &CurrentUser,
|
||||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||||
upload_id: &str,
|
||||
chunk_name: &str,
|
||||
) -> Result<Response<Body>, 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<AppState>,
|
||||
req: Request<Body>,
|
||||
user: &CurrentUser,
|
||||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||||
upload_id: &str,
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let user = &session.user;
|
||||
let nc = state
|
||||
.nextcloud
|
||||
.as_ref()
|
||||
@@ -256,11 +259,19 @@ async fn handle_assemble(
|
||||
let file_service = &state.applications.file_retrieval_service;
|
||||
let folder_service = &state.applications.folder_service;
|
||||
|
||||
let internal_path = format!(
|
||||
"My Folder - {}/{}",
|
||||
user.username,
|
||||
dest_subpath.trim_matches('/')
|
||||
);
|
||||
// Path-based lookups below scope by `drive_id`. The NC session's
|
||||
// chroot is always populated for path-scoped handlers (see
|
||||
// `NcSession::require_chroot`); the FolderDto carries `drive_id`
|
||||
// post-D0.
|
||||
let chroot = session.require_chroot()?;
|
||||
let drive_id = chroot.drive_id;
|
||||
|
||||
// TODO(D1): read the caller's default-drive root folder name from
|
||||
// `drives.root_folder_id` instead of hardcoding "Personal". The
|
||||
// constant is correct for every default personal drive provisioned
|
||||
// by the D0 lifecycle hook, but secondary drives (M2 backfill from
|
||||
// SQL-created sibling root folders) keep their original name.
|
||||
let internal_path = format!("Personal/{}", dest_subpath.trim_matches('/'));
|
||||
|
||||
let filename = filename_from_path(&dest_subpath).to_string();
|
||||
let ingested = ingest_stream_to_cas(
|
||||
@@ -275,11 +286,20 @@ async fn handle_assemble(
|
||||
let content_type = ingested.content_type.clone();
|
||||
|
||||
// Check if file exists (update vs create).
|
||||
let existing = file_service.get_file_by_path(&internal_path).await;
|
||||
let existing = file_service
|
||||
.get_file_by_path(&internal_path, drive_id)
|
||||
.await;
|
||||
|
||||
let etag: Option<String> = if existing.is_ok() {
|
||||
let dto = upload_service
|
||||
.update_file_streaming(&internal_path, ingested.stored(), &content_type, oc_mtime)
|
||||
.update_file_streaming(
|
||||
&internal_path,
|
||||
drive_id,
|
||||
ingested.stored(),
|
||||
&content_type,
|
||||
oc_mtime,
|
||||
user.id,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to update file: {}", e)))?;
|
||||
|
||||
@@ -291,15 +311,14 @@ async fn handle_assemble(
|
||||
Some((p, n)) => (p, n),
|
||||
None => ("", dest_subpath.as_str()),
|
||||
};
|
||||
let parent_internal = format!(
|
||||
"My Folder - {}/{}",
|
||||
user.username,
|
||||
parent_sub.trim_matches('/')
|
||||
);
|
||||
let parent_internal = format!("Personal/{}", parent_sub.trim_matches('/'));
|
||||
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).await {
|
||||
let parent_folder = match folder_service
|
||||
.get_folder_by_path(parent_internal, drive_id)
|
||||
.await
|
||||
{
|
||||
Ok(folder) => folder,
|
||||
Err(e) => {
|
||||
discard_ingested(&state.core.dedup_service, &ingested).await;
|
||||
@@ -316,6 +335,7 @@ async fn handle_assemble(
|
||||
Some(parent_folder.id),
|
||||
content_type.to_string(),
|
||||
ingested.stored(),
|
||||
user.id,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to create file: {}", e)))?;
|
||||
@@ -344,9 +364,10 @@ async fn handle_assemble(
|
||||
/// DELETE — abort an upload session.
|
||||
async fn handle_abort(
|
||||
state: Arc<AppState>,
|
||||
user: &CurrentUser,
|
||||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||||
upload_id: &str,
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let user = &session.user;
|
||||
let nc = state
|
||||
.nextcloud
|
||||
.as_ref()
|
||||
|
||||
@@ -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,23 +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<String, AppError> {
|
||||
let home = format!("My Folder - {}", username);
|
||||
/// 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<String, AppError> {
|
||||
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
|
||||
@@ -113,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 "<requested-url>"`).
|
||||
/// 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<AppState>,
|
||||
req: Request<Body>,
|
||||
user: AuthUser,
|
||||
session: crate::interfaces::nextcloud::session::NcSession,
|
||||
subpath: String,
|
||||
) -> Result<Response<Body>, 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
|
||||
}
|
||||
@@ -171,9 +198,12 @@ fn handle_options() -> Result<Response<Body>, AppError> {
|
||||
async fn handle_propfind(
|
||||
state: Arc<AppState>,
|
||||
req: Request<Body>,
|
||||
user: &CurrentUser,
|
||||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||||
subpath: &str,
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let user = &session.user;
|
||||
let chroot = session.require_chroot()?;
|
||||
let url_user = &session.raw_username;
|
||||
let depth = req
|
||||
.headers()
|
||||
.get("depth")
|
||||
@@ -198,29 +228,41 @@ 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).await;
|
||||
let folder_result = folder_service
|
||||
.get_folder_by_path(&internal_path, chroot.drive_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(),
|
||||
));
|
||||
}
|
||||
|
||||
// Not a folder — try as a file.
|
||||
let file_result = file_service.get_file_by_path(&internal_path).await;
|
||||
let file_result = file_service
|
||||
.get_file_by_path(&internal_path, chroot.drive_id)
|
||||
.await;
|
||||
if let Ok(file) = file_result {
|
||||
// Batch-check favorites for this single file.
|
||||
let favorite_ids = if let Some(fav_svc) = state.favorites_service.as_ref() {
|
||||
@@ -240,6 +282,7 @@ async fn handle_propfind(
|
||||
write_nc_file_multistatus(
|
||||
&mut buf,
|
||||
&file,
|
||||
url_user,
|
||||
&user.username,
|
||||
subpath,
|
||||
file_id_svc,
|
||||
@@ -262,10 +305,11 @@ async fn handle_propfind(
|
||||
|
||||
async fn handle_get(
|
||||
state: Arc<AppState>,
|
||||
user: &CurrentUser,
|
||||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||||
subpath: &str,
|
||||
headers: &axum::http::HeaderMap,
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
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()
|
||||
@@ -275,13 +319,13 @@ 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;
|
||||
|
||||
// Check if path is a folder first (NC clients use GET as existence check)
|
||||
if folder_service
|
||||
.get_folder_by_path(&internal_path)
|
||||
.get_folder_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
@@ -293,7 +337,7 @@ async fn handle_get(
|
||||
}
|
||||
|
||||
let file = file_service
|
||||
.get_file_by_path(&internal_path)
|
||||
.get_file_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
.map_err(|_| AppError::not_found("File not found"))?;
|
||||
|
||||
@@ -340,9 +384,10 @@ async fn handle_get(
|
||||
|
||||
async fn handle_head(
|
||||
state: Arc<AppState>,
|
||||
user: &CurrentUser,
|
||||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||||
subpath: &str,
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
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()
|
||||
@@ -352,13 +397,13 @@ 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;
|
||||
|
||||
// Check if path is a folder (NC clients use HEAD as existence check)
|
||||
if folder_service
|
||||
.get_folder_by_path(&internal_path)
|
||||
.get_folder_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
@@ -370,7 +415,7 @@ async fn handle_head(
|
||||
}
|
||||
|
||||
let file = file_service
|
||||
.get_file_by_path(&internal_path)
|
||||
.get_file_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
.map_err(|_| AppError::not_found("File not found"))?;
|
||||
|
||||
@@ -404,9 +449,12 @@ async fn handle_head(
|
||||
async fn handle_proppatch(
|
||||
state: Arc<AppState>,
|
||||
req: Request<Body>,
|
||||
user: &CurrentUser,
|
||||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||||
subpath: &str,
|
||||
) -> Result<Response<Body>, 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)))?;
|
||||
@@ -428,12 +476,18 @@ 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 {
|
||||
let resource = if let Ok(file) = file_service
|
||||
.get_file_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
{
|
||||
Some((file.id, "file"))
|
||||
} else if let Ok(folder) = folder_service.get_folder_by_path(&internal_path).await {
|
||||
} else if let Ok(folder) = folder_service
|
||||
.get_folder_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
{
|
||||
Some((folder.id, "folder"))
|
||||
} else {
|
||||
None
|
||||
@@ -472,9 +526,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();
|
||||
{
|
||||
@@ -610,10 +664,11 @@ fn precondition_failed_response() -> Response<Body> {
|
||||
async fn handle_put(
|
||||
state: Arc<AppState>,
|
||||
req: Request<Body>,
|
||||
user: &CurrentUser,
|
||||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||||
subpath: &str,
|
||||
) -> Result<Response<Body>, 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;
|
||||
|
||||
@@ -635,7 +690,10 @@ async fn handle_put(
|
||||
// bandwidth or disk I/O on a body the server is going to throw away.
|
||||
// The lookup is reused for the create-vs-update distinction below,
|
||||
// so this is also free of an extra DB hit.
|
||||
let existing = file_service.get_file_by_path(&internal_path).await.ok();
|
||||
let existing = file_service
|
||||
.get_file_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
.ok();
|
||||
let current_etag = existing.as_ref().map(|f| f.etag.as_str());
|
||||
|
||||
if let Some(value) = req
|
||||
@@ -690,7 +748,14 @@ async fn handle_put(
|
||||
// Single streaming path — handles both update and create internally,
|
||||
// swapping the file row onto the already-ingested blob.
|
||||
let stored = upload_service
|
||||
.update_file_streaming(&internal_path, ingested.stored(), &content_type, oc_mtime)
|
||||
.update_file_streaming(
|
||||
&internal_path,
|
||||
chroot.drive_id,
|
||||
ingested.stored(),
|
||||
&content_type,
|
||||
oc_mtime,
|
||||
session.user.id,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to store file: {}", e)))?;
|
||||
|
||||
@@ -712,13 +777,15 @@ async fn handle_put(
|
||||
|
||||
async fn handle_mkcol(
|
||||
state: Arc<AppState>,
|
||||
user: &CurrentUser,
|
||||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||||
subpath: &str,
|
||||
) -> Result<Response<Body>, 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
|
||||
@@ -733,7 +800,7 @@ async fn handle_mkcol(
|
||||
// auto-create doesn't break real clients.
|
||||
|
||||
if folder_service
|
||||
.get_folder_by_path(&internal_path)
|
||||
.get_folder_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
@@ -751,14 +818,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).await {
|
||||
let parent_folder = match folder_service
|
||||
.get_folder_by_path(&parent_path, chroot.drive_id)
|
||||
.await
|
||||
{
|
||||
Ok(folder) => folder,
|
||||
Err(_) => {
|
||||
return Ok(Response::builder()
|
||||
@@ -787,17 +861,22 @@ async fn handle_mkcol(
|
||||
|
||||
async fn handle_delete(
|
||||
state: Arc<AppState>,
|
||||
user: &CurrentUser,
|
||||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||||
subpath: &str,
|
||||
) -> Result<Response<Body>, 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).await {
|
||||
if let Ok(folder) = folder_service
|
||||
.get_folder_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
{
|
||||
trash_svc
|
||||
.move_to_trash(&folder.id, "folder", user.id)
|
||||
.await
|
||||
@@ -807,7 +886,10 @@ async fn handle_delete(
|
||||
.body(Body::empty())
|
||||
.unwrap());
|
||||
}
|
||||
if let Ok(file) = file_service.get_file_by_path(&internal_path).await {
|
||||
if let Ok(file) = file_service
|
||||
.get_file_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
{
|
||||
trash_svc
|
||||
.move_to_trash(&file.id, "file", user.id)
|
||||
.await
|
||||
@@ -823,7 +905,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).await {
|
||||
if let Ok(folder) = folder_service
|
||||
.get_folder_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
{
|
||||
folder_service
|
||||
.delete_folder_with_perms(&folder.id, user.id)
|
||||
.await
|
||||
@@ -835,7 +920,10 @@ async fn handle_delete(
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
if let Ok(file) = file_service.get_file_by_path(&internal_path).await {
|
||||
if let Ok(file) = file_service
|
||||
.get_file_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
{
|
||||
file_mgmt
|
||||
.delete_file_with_perms(&file.id, user.id)
|
||||
.await
|
||||
@@ -855,9 +943,12 @@ async fn handle_delete(
|
||||
async fn handle_move(
|
||||
state: Arc<AppState>,
|
||||
req: Request<Body>,
|
||||
user: &CurrentUser,
|
||||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||||
subpath: &str,
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let user = &session.user;
|
||||
let chroot = session.require_chroot()?;
|
||||
let url_user = &session.raw_username;
|
||||
let destination = req
|
||||
.headers()
|
||||
.get("destination")
|
||||
@@ -879,10 +970,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;
|
||||
@@ -891,13 +986,13 @@ 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)
|
||||
.get_file_by_path(&dest_internal_precheck, chroot.drive_id)
|
||||
.await
|
||||
.ok();
|
||||
let dest_existing_folder = folder_service
|
||||
.get_folder_by_path(&dest_internal_precheck)
|
||||
.get_folder_by_path(&dest_internal_precheck, chroot.drive_id)
|
||||
.await
|
||||
.ok();
|
||||
let dest_existed_before = dest_existing_file.is_some() || dest_existing_folder.is_some();
|
||||
@@ -940,12 +1035,15 @@ async fn handle_move(
|
||||
};
|
||||
|
||||
// Try as file first.
|
||||
if let Ok(file) = file_service.get_file_by_path(&src_internal).await {
|
||||
if let Ok(file) = file_service
|
||||
.get_file_by_path(&src_internal, chroot.drive_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)?;
|
||||
|
||||
// Rename if only the name changes (same parent).
|
||||
let src_parent_sub = match subpath.rsplit_once('/') {
|
||||
@@ -962,7 +1060,7 @@ async fn handle_move(
|
||||
} else {
|
||||
// Different parent → move.
|
||||
let dest_parent = folder_service
|
||||
.get_folder_by_path(&dest_parent_internal)
|
||||
.get_folder_by_path(&dest_parent_internal, chroot.drive_id)
|
||||
.await
|
||||
.map_err(|_| AppError::not_found("Destination folder not found"))?;
|
||||
|
||||
@@ -981,9 +1079,15 @@ 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 {
|
||||
if let Ok(moved) = file_service
|
||||
.get_file_by_path(&dest_internal, chroot.drive_id)
|
||||
.await
|
||||
{
|
||||
// Route through `FileDto::etag` so the MOVE response
|
||||
// matches what a subsequent PROPFIND on the destination
|
||||
// will return — `moved.id` (UUID) would differ from the
|
||||
@@ -997,12 +1101,15 @@ async fn handle_move(
|
||||
}
|
||||
|
||||
// Try as folder.
|
||||
if let Ok(folder) = folder_service.get_folder_by_path(&src_internal).await {
|
||||
if let Ok(folder) = folder_service
|
||||
.get_folder_by_path(&src_internal, chroot.drive_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,
|
||||
@@ -1025,7 +1132,7 @@ async fn handle_move(
|
||||
} else {
|
||||
// Different parent → move.
|
||||
let dest_parent = folder_service
|
||||
.get_folder_by_path(&dest_parent_internal)
|
||||
.get_folder_by_path(&dest_parent_internal, chroot.drive_id)
|
||||
.await
|
||||
.map_err(|_| AppError::not_found("Destination parent not found"))?;
|
||||
|
||||
@@ -1112,6 +1219,7 @@ fn write_nc_multistatus_open<W: std::io::Write>(xml: &mut Writer<W>) -> Result<(
|
||||
async fn write_nc_file_multistatus<W: std::io::Write>(
|
||||
writer: W,
|
||||
file: &FileDto,
|
||||
url_user: &str,
|
||||
username: &str,
|
||||
subpath: &str,
|
||||
file_id_svc: Option<&Arc<NextcloudFileIdService>>,
|
||||
@@ -1124,7 +1232,11 @@ async fn write_nc_file_multistatus<W: std::io::Write>(
|
||||
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 `<d:href>`
|
||||
// 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(
|
||||
@@ -1530,39 +1642,83 @@ 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,
|
||||
// Test stub — path mapper doesn't read drive_id.
|
||||
drive_id: uuid::Uuid::nil(),
|
||||
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(),
|
||||
// §14 provenance not relevant to path-mapper tests.
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[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 ──
|
||||
|
||||
Reference in New Issue
Block a user