feat(drive): improve Drive model

now Drive is purely a metadata
    each drive has always a root folder
    this model minimize Oxicloud changes, and simplify
    the Drive name is simply the folder's root's name
    note: owner of Drive has more permission that an owner of the root folder
This commit is contained in:
Edouard Vanbelle
2026-06-18 23:02:17 +02:00
parent eab7a609b9
commit 16ea08b093
26 changed files with 1067 additions and 460 deletions
+7 -3
View File
@@ -399,10 +399,14 @@ 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))
.strip_prefix("Personal/")
.unwrap_or(&file.path);
let display_path = format!("/{}", display_path);
@@ -427,11 +431,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);
+21 -9
View File
@@ -83,7 +83,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
@@ -146,7 +150,7 @@ async fn handle_filter_files(
write_multistatus_start(&mut xml)?;
for file in &files {
let subpath = strip_home_prefix(&file.path, &home_prefix);
let subpath = strip_home_prefix(&file.path, home_prefix);
let href = nc_href(&user.username, subpath);
let fid = file_id_map.get(&file.id).copied();
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
@@ -163,7 +167,7 @@ async fn handle_filter_files(
}
for folder in &folders {
let subpath = strip_home_prefix(&folder.path, &home_prefix);
let subpath = strip_home_prefix(&folder.path, home_prefix);
let href = format!("{}/", nc_href(&user.username, subpath));
let fid = folder_id_map.get(&folder.id).copied();
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
@@ -210,7 +214,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, &user.username, user.id).await;
let criteria = SearchCriteriaDto {
name_contains: Some(term),
@@ -227,7 +231,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,7 +256,7 @@ async fn handle_search(
// Files.
for file in &files {
let subpath = strip_home_prefix(&file.path, &home_prefix);
let subpath = strip_home_prefix(&file.path, home_prefix);
let href = nc_href(&user.username, subpath);
let fid = file_id_map.get(&file.id).copied();
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
@@ -267,7 +274,7 @@ async fn handle_search(
// Folders.
for folder in &folders {
let subpath = strip_home_prefix(&folder.path, &home_prefix);
let subpath = strip_home_prefix(&folder.path, home_prefix);
let href = format!("{}/", nc_href(&user.username, subpath));
let fid = folder_id_map.get(&folder.id).copied();
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
@@ -461,7 +468,12 @@ 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> {
async fn resolve_scope_folder(
state: &AppState,
body: &str,
username: &str,
user_id: uuid::Uuid,
) -> Option<String> {
let href = parse_scope_href(body)?;
// The href is typically `/files/{user}/subpath` or `/remote.php/dav/files/{user}/subpath`.
@@ -477,7 +489,7 @@ async fn resolve_scope_folder(state: &AppState, body: &str, username: &str) -> O
let folder_service = &state.applications.folder_service;
folder_service
.get_folder_by_path(&internal_path)
.get_folder_by_path(&internal_path, user_id)
.await
.ok()
.map(|f| f.id)
+13 -6
View File
@@ -133,7 +133,7 @@ async fn handle_restore(
let file_service = &state.applications.file_retrieval_service;
let dest_taken = file_service.get_file_by_path(&dest_internal).await.is_ok()
|| folder_service
.get_folder_by_path(&dest_internal)
.get_folder_by_path(&dest_internal, user.id)
.await
.is_ok();
if dest_taken {
@@ -248,11 +248,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 ──────────────
+8 -11
View File
@@ -256,11 +256,12 @@ 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('/')
);
// 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(
@@ -291,15 +292,11 @@ 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, user.id).await {
Ok(folder) => folder,
Err(e) => {
discard_ingested(&state.core.dedup_service, &ingested).await;
+21 -14
View File
@@ -53,8 +53,15 @@ const HEADER_DAV: HeaderName = HeaderName::from_static("dav");
/// Internal: My Folder - {username}/{subpath}
///
/// 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);
pub fn nc_to_internal_path(_username: &str, subpath: &str) -> Result<String, AppError> {
// D0: every default personal drive's root folder is named "Personal"
// (docs/plan/drive.md §3 — the canonical post-D0 default). The NC
// dispatcher chroots into the caller's default drive, so the leading
// segment of the internal path is always the drive's root folder
// name. Hardcoded for now; a follow-up will read it from
// `drives.root_folder_id`'s name to support secondary drives with
// custom root-folder names.
let home = "Personal".to_string();
let subpath = subpath.trim_matches('/');
if subpath.is_empty() {
return Ok(home);
@@ -203,7 +210,7 @@ async fn handle_propfind(
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, user.id).await;
if let Ok(folder) = folder_result {
// It's a folder — stream the multistatus: children are fetched in
@@ -281,7 +288,7 @@ async fn handle_get(
// 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, user.id)
.await
.is_ok()
{
@@ -358,7 +365,7 @@ async fn handle_head(
// 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, user.id)
.await
.is_ok()
{
@@ -433,7 +440,7 @@ async fn handle_proppatch(
let folder_service = &state.applications.folder_service;
let resource = if let Ok(file) = file_service.get_file_by_path(&internal_path).await {
Some((file.id, "file"))
} else if let Ok(folder) = folder_service.get_folder_by_path(&internal_path).await {
} else if let Ok(folder) = folder_service.get_folder_by_path(&internal_path, user.id).await {
Some((folder.id, "folder"))
} else {
None
@@ -733,7 +740,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, user.id)
.await
.is_ok()
{
@@ -758,7 +765,7 @@ async fn handle_mkcol(
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, user.id).await {
Ok(folder) => folder,
Err(_) => {
return Ok(Response::builder()
@@ -797,7 +804,7 @@ async fn handle_delete(
// 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, user.id).await {
trash_svc
.move_to_trash(&folder.id, "folder", user.id)
.await
@@ -823,7 +830,7 @@ 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, user.id).await {
folder_service
.delete_folder_with_perms(&folder.id, user.id)
.await
@@ -897,7 +904,7 @@ async fn handle_move(
.await
.ok();
let dest_existing_folder = folder_service
.get_folder_by_path(&dest_internal_precheck)
.get_folder_by_path(&dest_internal_precheck, user.id)
.await
.ok();
let dest_existed_before = dest_existing_file.is_some() || dest_existing_folder.is_some();
@@ -962,7 +969,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, user.id)
.await
.map_err(|_| AppError::not_found("Destination folder not found"))?;
@@ -997,7 +1004,7 @@ 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, user.id).await {
let (dest_parent_sub, dest_name) = match dest_subpath.rsplit_once('/') {
Some((parent, name)) => (parent, name),
None => ("", dest_subpath.as_str()),
@@ -1025,7 +1032,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, user.id)
.await
.map_err(|_| AppError::not_found("Destination parent not found"))?;