Merge pull request #479 from EdouardVanbelle/feat/drive-impl

feat/drive impl
This commit is contained in:
Dionisio Pozo
2026-06-19 18:48:32 +02:00
committed by GitHub
102 changed files with 6362 additions and 1078 deletions
@@ -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,
}
}
+1
View File
@@ -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,
+107 -22
View 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()
};
+29 -1
View File
@@ -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 {
+6
View File
@@ -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,
+13
View File
@@ -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;