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
@@ -3,6 +3,7 @@ use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use rand_core::RngCore;
use uuid::Uuid;
/// Maximum number of concurrent pending login flows to prevent memory exhaustion.
const MAX_PENDING_FLOWS: usize = 1000;
@@ -30,6 +31,13 @@ pub struct LoginResult {
struct PendingFlow {
created_at: Instant,
poll_token: String,
/// Set after the user authenticates on the login page **and** has more
/// than one root drive — the flow is paused until the user picks a
/// drive on the picker page. Consumed by `take_pending_user` when the
/// picker submission arrives, so the second step is single-use even
/// if the flow token leaks. `None` for single-drive accounts (legacy
/// path goes straight to `completed`).
pending_user_id: Option<Uuid>,
completed: Option<LoginResult>,
}
@@ -80,6 +88,7 @@ impl NextcloudLoginFlowService {
PendingFlow {
created_at: Instant::now(),
poll_token: poll_token.clone(),
pending_user_id: None,
completed: None,
},
);
@@ -101,6 +110,39 @@ impl NextcloudLoginFlowService {
state.flows.contains_key(flow_token)
}
/// Stash a verified user_id on the flow so a follow-up drive-pick
/// request can prove "this browser just authenticated" without
/// asking for the password again. Returns `false` if the flow
/// token is unknown or expired.
///
/// Only used on multi-drive accounts — single-drive logins go
/// straight to [`complete`](Self::complete).
pub fn mark_awaiting_drive(&self, flow_token: &str, user_id: Uuid) -> bool {
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
prune_expired(&mut state, self.ttl);
match state.flows.get_mut(flow_token) {
Some(pending) => {
pending.pending_user_id = Some(user_id);
true
}
None => false,
}
}
/// Consume the stashed user_id (single-use). Returns the user_id
/// when the flow is in "awaiting drive choice" state, or `None`
/// when the flow is unknown, expired, or was never marked. Single-
/// use semantics make this safe even if the flow token leaks: the
/// second drive-pick attempt finds nothing to consume.
pub fn take_pending_user(&self, flow_token: &str) -> Option<Uuid> {
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
prune_expired(&mut state, self.ttl);
state
.flows
.get_mut(flow_token)
.and_then(|pending| pending.pending_user_id.take())
}
pub fn complete(
&self,
flow_token: &str,
@@ -257,6 +299,35 @@ mod tests {
assert!(svc.poll(&info.poll_token).is_none());
}
#[test]
fn test_mark_awaiting_drive_then_take_pending_user() {
let svc = service();
let info = svc.initiate("https://cloud.example.com").unwrap();
let flow_token = info.login_url.rsplit('/').next().unwrap();
let uid = Uuid::new_v4();
assert!(svc.mark_awaiting_drive(flow_token, uid));
// First take consumes the slot.
assert_eq!(svc.take_pending_user(flow_token), Some(uid));
// Second take must return None (single-use).
assert_eq!(svc.take_pending_user(flow_token), None);
}
#[test]
fn test_mark_awaiting_drive_unknown_flow_returns_false() {
let svc = service();
assert!(!svc.mark_awaiting_drive("nonexistent", Uuid::new_v4()));
}
#[test]
fn test_take_pending_user_without_mark_returns_none() {
let svc = service();
let info = svc.initiate("https://cloud.example.com").unwrap();
let flow_token = info.login_url.rsplit('/').next().unwrap();
// Flow exists but mark_awaiting_drive was never called.
assert_eq!(svc.take_pending_user(flow_token), None);
}
#[test]
fn test_max_pending_flows_cap() {
let svc = NextcloudLoginFlowService::new(Duration::from_secs(600));
+2 -1
View File
@@ -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,68 @@ 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 → user's home folder. 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.
use crate::application::ports::folder_ports::FolderUseCase;
let chroot = match drive_marker.as_deref() {
None => {
let expected = format!("My Folder - {}", current_user.username);
match state
.applications
.folder_service
.list_folders_with_perms(None, current_user.id)
.await
{
Ok(folders) => folders.into_iter().find(|f| f.name == expected),
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(_) => {
+288 -15
View File
@@ -1,3 +1,4 @@
use askama::Template;
use axum::{
extract::{Path, Query, State},
http::{HeaderMap, StatusCode, header},
@@ -7,8 +8,47 @@ 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>,
}
/// Find the index of the user's home folder inside `drives`.
///
/// Convention: home is the root folder named `"My Folder - {username}"`,
/// set by `FolderService::ensure_home_folder` at registration. Extra
/// root folders (POC drive seeding via direct SQL insert) don't follow
/// this name, so the pattern disambiguates home from sibling drives.
/// Returns `None` if no folder matches — caller decides whether that
/// is fatal or just "treat everything as a non-home drive".
///
/// Mirrors the same lookup performed in `routes.rs::
/// verify_url_user_and_resolve_chroot` (legacy no-`~` path); both
/// sites must agree on which row is home or the URL and the auth
/// marker will diverge.
fn find_home_index(
drives: &[crate::application::dtos::folder_dto::FolderDto],
username: &str,
) -> Option<usize> {
let expected = format!("My Folder - {}", username);
drives.iter().position(|f| f.name == expected)
}
/// Serve an HTML page with a Content-Security-Policy header as defense-in-depth.
fn html_with_csp(html: &'static str) -> Response {
@@ -175,47 +215,280 @@ 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) = find_home_index(&drives, &current_user.username)
&& 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, &current_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, &current_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 = find_home_index(&drives, &user.username).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
+1
View File
@@ -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;
+31 -10
View File
@@ -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,
@@ -404,10 +428,7 @@ pub async fn handle_search(
// 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("Personal/")
.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();
+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 {
+28 -35
View File
@@ -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())
}
+134
View File
@@ -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)
}
}
+19 -11
View File
@@ -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,7 +134,7 @@ 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()
@@ -184,8 +190,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 +213,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
+20 -13
View File
@@ -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()
@@ -296,7 +299,10 @@ async fn handle_assemble(
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, user.id).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;
@@ -341,9 +347,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()
+188 -69
View File
@@ -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,30 +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> {
// 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();
/// 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
@@ -120,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
}
@@ -178,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")
@@ -205,23 +228,33 @@ 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, user.id).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
// 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(),
));
}
@@ -247,6 +280,7 @@ async fn handle_propfind(
write_nc_file_multistatus(
&mut buf,
&file,
url_user,
&user.username,
subpath,
file_id_svc,
@@ -269,10 +303,12 @@ 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 user = &session.user;
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()
@@ -282,7 +318,7 @@ 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;
@@ -347,9 +383,11 @@ 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 user = &session.user;
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()
@@ -359,7 +397,7 @@ 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;
@@ -411,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)))?;
@@ -435,12 +476,15 @@ 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 {
Some((file.id, "file"))
} else if let Ok(folder) = folder_service.get_folder_by_path(&internal_path, user.id).await {
} else if let Ok(folder) = folder_service
.get_folder_by_path(&internal_path, user.id)
.await
{
Some((folder.id, "folder"))
} else {
None
@@ -479,9 +523,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();
{
@@ -617,10 +661,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;
@@ -719,13 +764,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
@@ -758,14 +805,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, user.id).await {
let parent_folder = match folder_service
.get_folder_by_path(&parent_path, user.id)
.await
{
Ok(folder) => folder,
Err(_) => {
return Ok(Response::builder()
@@ -794,17 +848,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, user.id).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
@@ -830,7 +889,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, user.id).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
@@ -862,9 +924,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")
@@ -886,10 +951,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;
@@ -898,7 +967,7 @@ 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)
.await
@@ -952,7 +1021,7 @@ async fn handle_move(
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('/') {
@@ -988,7 +1057,10 @@ 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 {
// Route through `FileDto::etag` so the MOVE response
@@ -1004,12 +1076,15 @@ async fn handle_move(
}
// Try as folder.
if let Ok(folder) = folder_service.get_folder_by_path(&src_internal, user.id).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()),
};
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,
@@ -1119,6 +1194,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>>,
@@ -1131,7 +1207,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(
@@ -1537,39 +1617,78 @@ 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,
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(),
}
}
#[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 ──
+47
View File
@@ -803,3 +803,50 @@
font-weight: var(--weight-medium);
color: var(--color-warning-orange-text);
}
/* Nextcloud drive picker — radio list of drives the authenticated user
can select for this app-password binding. */
.auth-drive-option {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
margin-bottom: 8px;
border: 1px solid var(--color-border);
border-radius: 12px;
cursor: pointer;
transition:
background 0.2s ease,
border-color 0.2s ease;
}
.auth-drive-option:hover {
background: var(--color-bg-hover);
border-color: var(--color-accent);
}
.auth-drive-option input[type="radio"] {
margin: 0;
accent-color: var(--color-accent);
}
.auth-drive-option input[type="radio"]:checked ~ .auth-drive-name {
font-weight: 600;
}
.auth-drive-name {
flex: 1;
color: var(--color-text);
font-size: 15px;
}
.auth-drive-badge {
padding: 2px 10px;
border-radius: 999px;
background: var(--color-accent-gradient);
color: var(--color-danger-text);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
}
+51
View File
@@ -0,0 +1,51 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<title>Choose a drive - OxiCloud</title>
<link rel="icon" type="image/svg+xml" href="/logo/logo-plain.svg">
<script src="/js/core/theme-init.js"></script>
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/views/auth.css">
</head>
<body>
<div class="auth-container">
<div class="auth-panel">
<div class="auth-logo">
<div class="auth-logo-icon">
<svg viewBox="0 0 500 500">
<path d="M345 310c32 0 58-26 58-58s-26-58-58-58c-6.2 0-12 0.9-17.5 2.7C318 166 289 143 255 143c-34.3 0-63.1 22.6-73 53.7C176.9 195.7 171 195 165 195c-32 0-58 26-58 58s26 58 58 58h180z" fill="#fff"/>
</svg>
</div>
<div class="auth-logo-text">OxiCloud</div>
</div>
<h2 class="auth-title">Choose a drive</h2>
<p class="auth-subtitle">
Your account has access to several drives. Pick the one this Nextcloud client should sync.
</p>
<form class="auth-form" method="POST" action="{{ form_action }}">
{%- for drive in drives %}
<label class="auth-drive-option">
<input
type="radio"
name="drive"
value="{{ drive.id }}"
{%- if loop.first %} checked{% endif %}
required>
<span class="auth-drive-name">{{ drive.name }}</span>
{%- if loop.first %}
<span class="auth-drive-badge">Home</span>
{%- endif %}
</label>
{%- endfor %}
<button type="submit" class="auth-button">Continue</button>
</form>
</div>
</div>
</body>
</html>