feat(mounts): P3 — WebDAV/NextCloud path resolution + browse/download

Mounts are now browsable and downloadable over both WebDAV surfaces
(/webdav/ and NextCloud /remote.php/dav) and NextCloud clients. The work
funnels through the path-based service methods all three surfaces already use,
so both WebDAV handlers gain mount support with no handler changes.

- get_folder_by_path / get_file_by_path resolve a path that descends past a
  mount root to a synthetic ext: DTO (the mount root itself stays a real row)
- list_folders_paginated_with_perms / list_files_batch_with_perms branch to the
  provider, so PROPFIND Depth:1 enumerates mount directory contents in both
  WebDAV handlers
- get_file_stream / get_file_range_stream branch ext: file ids to the provider,
  so WebDAV GET streams mount content (Pin<Box<dyn Stream>> re-boxed)
- router injected into FileRetrievalService; new MountRouter::find_path delegates
  to the registry's (drive_id, mount_path) index
- WebDAV mkdir/delete/move/rename by path work via path→ext:id→the P2 service
  methods (no extra wiring)

Fixes a leading-slash normalization bug in the registry path index: materialized
folder paths arrive both as `Personal/Media` and `/Personal/Media`; keys and
lookups now normalize the leading slash (would have broken real WebDAV paths).

Known follow-up: WebDAV PUT (upload/update by path) still ingests to the CAS;
streaming a WebDAV PUT straight to the provider needs a pre-ingest branch in the
two WebDAV PUT handlers (mirrors the REST upload branch).

Integration test: get_folder_by_path/get_file_by_path resolution, PROPFIND
Depth:1 folder+file listing, and content streaming on a real mount + Postgres.
This commit is contained in:
Bradley Nelson
2026-06-25 00:51:16 -06:00
parent 8e3e31da4d
commit d8229650ef
6 changed files with 340 additions and 9 deletions
+51 -1
View File
@@ -14,7 +14,7 @@ use crate::application::dtos::display_helpers::{
};
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::FolderDto;
use crate::application::ports::external_mount_ports::MountStat;
use crate::application::ports::external_mount_ports::{MountEntry, MountStat};
use crate::application::services::mount_registry::MountConfig;
use crate::domain::services::external_mount_id::{
encode_child_id, virtual_file_etag, virtual_folder_etag,
@@ -71,6 +71,56 @@ pub fn mount_folder_dto(cfg: &MountConfig, parent_id: &str, stat: &MountStat) ->
}
}
/// Build a `FolderDto` from a directory listing entry. `parent_id` is the
/// id-string of the directory being listed.
pub fn mount_entry_folder_dto(cfg: &MountConfig, parent_id: &str, entry: &MountEntry) -> FolderDto {
FolderDto {
etag: virtual_folder_etag(entry.modified_at),
id: encode_child_id(cfg.mount_id, entry.node_id.clone()),
name: entry.name.clone(),
path: String::new(),
parent_id: Some(parent_id.to_owned()),
owner_id: Some(cfg.owner_id.to_string()),
drive_id: cfg.drive_id,
created_at: entry.created_at,
modified_at: entry.modified_at,
is_root: false,
icon_class: Arc::from("fas fa-folder"),
icon_special_class: Arc::from("folder-icon"),
category: Arc::from("Folder"),
created_by: None,
updated_by: None,
}
}
/// Build a `FileDto` from a directory listing entry (mime sniffed from name).
pub fn mount_entry_file_dto(cfg: &MountConfig, parent_id: &str, entry: &MountEntry) -> FileDto {
let name = entry.name.as_str();
let mime = mime_guess::from_path(name)
.first_or_octet_stream()
.to_string();
FileDto {
id: encode_child_id(cfg.mount_id, entry.node_id.clone()),
name: name.to_owned(),
path: String::new(),
size: entry.size,
mime_type: Arc::from(mime.as_str()),
folder_id: Some(parent_id.to_owned()),
created_at: entry.created_at,
modified_at: entry.modified_at,
icon_class: Arc::from(icon_class_for(name, &mime)),
icon_special_class: Arc::from(icon_special_class_for(name, &mime)),
category: Arc::from(category_for(name, &mime)),
size_formatted: format_file_size(entry.size),
owner_id: Some(cfg.owner_id.to_string()),
sort_date: None,
content_hash: String::new(),
etag: virtual_file_etag(entry.size, entry.modified_at),
created_by: None,
updated_by: None,
}
}
/// Build a `FileDto` for a mount file from its stat. Virtual files have no blob
/// hash (`content_hash` empty) and a size+mtime etag.
pub fn mount_file_dto(cfg: &MountConfig, parent_id: &str, stat: &MountStat) -> FileDto {