POC(nextcloud): add chroot on nextcloud

Bring chroot to nextcloud capability: login on Nextcloud via username="{user}~{folder-uuid}
    Doing a such login will chroot the folder folder-uuid

    If user has several folder as root (parent=None), the login flow will request which
    folder user want to chroot
This commit is contained in:
Edouard Vanbelle
2026-06-15 22:59:34 +02:00
parent 16ea08b093
commit 42510d94f3
15 changed files with 1027 additions and 181 deletions
+49 -24
View File
@@ -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()),
@@ -149,9 +150,13 @@ async fn handle_filter_files(
write_multistatus_start(&mut xml)?;
// Keep main's batched-resolution structure (one batch query
// per type, not 2N round-trips). Hrefs use `url_user` so the
// multi-drive `~{drive}` form is echoed back to the client;
// owner-id stays canonical via `&user.username`.
for file in &files {
let subpath = strip_home_prefix(&file.path, home_prefix);
let href = nc_href(&user.username, subpath);
let href = nc_href(url_user, subpath);
let fid = file_id_map.get(&file.id).copied();
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
write_file_response(
@@ -168,7 +173,7 @@ async fn handle_filter_files(
for folder in &folders {
let subpath = strip_home_prefix(&folder.path, home_prefix);
let href = format!("{}/", nc_href(&user.username, subpath));
let href = format!("{}/", nc_href(url_user, subpath));
let fid = folder_id_map.get(&folder.id).copied();
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
write_folder_response(
@@ -199,8 +204,13 @@ async fn handle_filter_files(
async fn handle_search(
state: Arc<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()),
@@ -214,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, user.id).await;
let folder_id = resolve_scope_folder(&state, body, session).await;
let criteria = SearchCriteriaDto {
name_contains: Some(term),
@@ -257,7 +267,7 @@ async fn handle_search(
// Files.
for file in &files {
let subpath = strip_home_prefix(&file.path, home_prefix);
let href = nc_href(&user.username, subpath);
let href = nc_href(url_user, subpath);
let fid = file_id_map.get(&file.id).copied();
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
write_file_response(
@@ -275,7 +285,7 @@ async fn handle_search(
// Folders.
for folder in &folders {
let subpath = strip_home_prefix(&folder.path, home_prefix);
let href = format!("{}/", nc_href(&user.username, subpath));
let href = format!("{}/", nc_href(url_user, subpath));
let fid = folder_id_map.get(&folder.id).copied();
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
write_folder_response(
@@ -468,28 +478,40 @@ fn xml_extract_text(body: &str, local_name: &[u8]) -> Option<String> {
}
/// Resolve a scope href (e.g. `/files/username/Documents`) to a folder ID.
///
/// Pulls everything it needs from the `NcSession`: the caller's id (so
/// `get_folder_by_path` can be user-scoped — post-D0 paths like
/// `Personal/Docs` are not globally unique), the chroot (provides the
/// path prefix that `nc_to_internal_path` prepends), and the raw wire
/// `{user}` segment (bare or `admin~{uuid}`) so we strip the prefix the
/// NC client actually sent.
async fn resolve_scope_folder(
state: &AppState,
body: &str,
username: &str,
user_id: uuid::Uuid,
session: &crate::interfaces::nextcloud::session::NcSession,
) -> Option<String> {
let user = &session.user;
let chroot = session.require_chroot().ok()?;
let url_user = &session.raw_username;
let href = parse_scope_href(body)?;
// The href is typically `/files/{user}/subpath` or `/remote.php/dav/files/{user}/subpath`.
let subpath = extract_subpath_from_scope(&href, username)?;
// The href is typically `/files/{url_user}/subpath` or
// `/remote.php/dav/files/{url_user}/subpath`. On a multi-drive
// session the `{url_user}` segment carries the `~{uuid}` marker,
// so we strip with the composite to find the real subpath. Using
// `user.username` here would fail to match for non-home drives.
let subpath = extract_subpath_from_scope(&href, url_user)?;
if subpath.is_empty() {
// Root scope -- no folder_id filter needed.
return None;
}
let internal_path =
crate::interfaces::nextcloud::webdav_handler::nc_to_internal_path(username, &subpath)
.ok()?;
crate::interfaces::nextcloud::webdav_handler::nc_to_internal_path(chroot, &subpath).ok()?;
let folder_service = &state.applications.folder_service;
folder_service
.get_folder_by_path(&internal_path, user_id)
.get_folder_by_path(&internal_path, user.id)
.await
.ok()
.map(|f| f.id)
@@ -498,13 +520,16 @@ async fn resolve_scope_folder(
/// Extract the subpath portion from a scope href.
///
/// Handles both short form `/files/{user}/sub` and full
/// `/remote.php/dav/files/{user}/sub`.
fn extract_subpath_from_scope(href: &str, username: &str) -> Option<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 {