fix: URL-decode DAV paths with spaces + feat: app passwords for Basic Auth

Bug fix:
- URL-decode paths in extract_webdav_path(), extract_caldav_path(),
  extract_carddav_path() so folders with spaces (e.g. 'My Folder') no
  longer return 404 when accessed via encoded URIs (%20)
- Properly encode href values in PROPFIND/PROPPATCH/LOCK XML responses
- Decode Destination header in MOVE/COPY operations

New feature - App Passwords (API keys for DAV clients):
- POST /api/auth/app-passwords  → create (shows token once)
- GET  /api/auth/app-passwords  → list (prefix only)
- DELETE /api/auth/app-passwords/:id → revoke
- Auth middleware now accepts both Bearer JWT and Basic Auth
- Argon2 hashed, scoped (webdav/caldav/carddav), optional expiry
- Compatible with DAVx5, Thunderbird, rclone, curl

Tested: 12/12 E2E tests pass (create, list, WebDAV/CalDAV/CardDAV
Basic Auth, URL-decode with spaces, wrong password 401, revoke, post-
revoke 401).
This commit is contained in:
Dionisio
2026-03-01 20:34:12 +01:00
parent 48d853360e
commit 81987e9321
21 changed files with 963 additions and 68 deletions
@@ -0,0 +1,84 @@
//! HTTP handlers for App Password management.
//!
//! All endpoints require JWT authentication (the user must be logged in to
//! create/list/revoke their app passwords).
use crate::application::dtos::app_password_dto::CreateAppPasswordRequestDto;
use crate::common::di::AppState;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::CurrentUser;
use axum::extract::State;
use axum::routing::{delete, get, post};
use axum::{Json, Router};
use std::sync::Arc;
/// Protected routes — require JWT auth middleware.
pub fn app_password_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/app-passwords", post(create_app_password))
.route("/app-passwords", get(list_app_passwords))
.route("/app-passwords/{id}", delete(revoke_app_password))
}
/// POST /api/auth/app-passwords — Create a new app password.
///
/// Returns the plain-text password ONCE. The user must copy it immediately.
async fn create_app_password(
State(state): State<Arc<AppState>>,
axum::Extension(user): axum::Extension<CurrentUser>,
Json(request): Json<CreateAppPasswordRequestDto>,
) -> Result<Json<crate::application::dtos::app_password_dto::AppPasswordCreatedResponseDto>, AppError>
{
let service = state
.app_password_service
.as_ref()
.ok_or_else(|| AppError::internal_error("App password service not configured"))?;
let response = service
.create(&user.id, request)
.await
.map_err(|e| AppError::from(e))?;
Ok(Json(response))
}
/// GET /api/auth/app-passwords — List all app passwords for the current user.
///
/// Never returns plain-text passwords (only prefix + metadata).
async fn list_app_passwords(
State(state): State<Arc<AppState>>,
axum::Extension(user): axum::Extension<CurrentUser>,
) -> Result<Json<crate::application::dtos::app_password_dto::AppPasswordListResponseDto>, AppError>
{
let service = state
.app_password_service
.as_ref()
.ok_or_else(|| AppError::internal_error("App password service not configured"))?;
let response = service
.list(&user.id)
.await
.map_err(|e| AppError::from(e))?;
Ok(Json(response))
}
/// DELETE /api/auth/app-passwords/:id — Revoke an app password.
async fn revoke_app_password(
State(state): State<Arc<AppState>>,
axum::Extension(user): axum::Extension<CurrentUser>,
axum::extract::Path(id): axum::extract::Path<String>,
) -> Result<Json<crate::application::dtos::app_password_dto::AppPasswordRevokeResponseDto>, AppError>
{
let service = state
.app_password_service
.as_ref()
.ok_or_else(|| AppError::internal_error("App password service not configured"))?;
let response = service
.revoke(&user.id, &id)
.await
.map_err(|e| AppError::from(e))?;
Ok(Json(response))
}
@@ -22,6 +22,7 @@ use axum::{
response::Response,
};
use bytes::Buf;
use percent_encoding::percent_decode_str;
use std::sync::Arc;
use crate::application::adapters::caldav_adapter::{CalDavAdapter, CalDavReportType};
@@ -86,19 +87,21 @@ async fn handle_caldav_methods_inner(
}
}
/// Extract the CalDAV path from the full URI path.
/// Extract the CalDAV path from the full URI path, percent-decoding the result.
fn extract_caldav_path(uri_path: &str) -> String {
if let Some(pos) = uri_path.find("/caldav/") {
let encoded = if let Some(pos) = uri_path.find("/caldav/") {
let after = &uri_path[pos + 8..];
after.trim_end_matches('/').to_string()
after.trim_end_matches('/')
} else if uri_path.ends_with("/caldav") {
String::new()
""
} else {
uri_path
.trim_start_matches('/')
.trim_end_matches('/')
.to_string()
}
};
percent_decode_str(encoded)
.decode_utf8_lossy()
.into_owned()
}
// ─── Helper: extract user from request ───────────────────────────────
@@ -91,19 +91,21 @@ async fn handle_carddav_methods_inner(
}
}
/// Extract the CardDAV path from the full URI path.
/// Extract the CardDAV path from the full URI path, percent-decoding the result.
fn extract_carddav_path(uri_path: &str) -> String {
if let Some(pos) = uri_path.find("/carddav/") {
let encoded = if let Some(pos) = uri_path.find("/carddav/") {
let after = &uri_path[pos + 9..];
after.trim_end_matches('/').to_string()
after.trim_end_matches('/')
} else if uri_path.ends_with("/carddav") {
String::new()
""
} else {
uri_path
.trim_start_matches('/')
.trim_end_matches('/')
.to_string()
}
};
percent_encoding::percent_decode_str(encoded)
.decode_utf8_lossy()
.into_owned()
}
// ─── Helper: extract user from request ───────────────────────────────
+1
View File
@@ -1,4 +1,5 @@
pub mod admin_handler;
pub mod app_password_handler;
pub mod auth_handler;
pub mod batch_handler;
pub mod device_auth_handler;
+62 -21
View File
@@ -27,8 +27,45 @@ use crate::application::ports::inbound::FolderUseCase;
use crate::common::di::AppState;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::CurrentUser;
use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC, AsciiSet};
use std::sync::Arc;
/// Characters that MUST NOT be percent-encoded inside a URI path segment.
/// RFC 3986 §3.3 pchar = unreserved / pct-encoded / sub-delims / ":" / "@"
/// unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
/// sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "="
const PATH_SEGMENT_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
.remove(b'-')
.remove(b'.')
.remove(b'_')
.remove(b'~')
.remove(b'!')
.remove(b'$')
.remove(b'&')
.remove(b'\'')
.remove(b'(')
.remove(b')')
.remove(b'*')
.remove(b'+')
.remove(b',')
.remove(b';')
.remove(b'=')
.remove(b':')
.remove(b'@');
/// Percent-encode a single URI path segment (folder/file name).
fn encode_path_segment(segment: &str) -> String {
utf8_percent_encode(segment, PATH_SEGMENT_ENCODE_SET).to_string()
}
/// Percent-encode a full slash-separated path, encoding each segment individually.
pub(crate) fn encode_uri_path(path: &str) -> String {
path.split('/')
.map(|seg| encode_path_segment(seg))
.collect::<Vec<_>>()
.join("/")
}
// Create a custom DAV header since it's not in the standard headers
const HEADER_DAV: HeaderName = HeaderName::from_static("dav");
const HEADER_LOCK_TOKEN: HeaderName = HeaderName::from_static("lock-token");
@@ -62,22 +99,24 @@ pub fn webdav_routes() -> Router<Arc<AppState>> {
.route("/webdav", axum::routing::any(handle_webdav_methods_root))
}
/// Extract the resource path from the request URI, stripping the `/webdav/` prefix.
/// Extract the resource path from the request URI, stripping the `/webdav/` prefix
/// and percent-decoding the result so that folder/file names with spaces and
/// special characters match the values stored in the database.
fn extract_webdav_path(uri: &axum::http::Uri) -> String {
let raw = uri.path();
if let Some(rest) = raw.strip_prefix("/webdav/") {
rest.trim_end_matches('/').to_string()
let encoded = if let Some(rest) = raw.strip_prefix("/webdav/") {
rest.trim_end_matches('/')
} else if raw == "/webdav" {
String::new()
""
} else {
// Fallback: split-based extraction
let parts: Vec<&str> = raw.split('/').collect();
if parts.len() > 2 {
parts[2..].join("/")
} else {
String::new()
}
}
let trimmed = raw.strip_prefix('/').unwrap_or(raw);
trimmed.trim_end_matches('/')
};
// Decode percent-encoded characters (e.g. %20 → space)
percent_decode_str(encoded)
.decode_utf8_lossy()
.into_owned()
}
async fn handle_webdav_methods_root(
@@ -230,7 +269,7 @@ async fn handle_propfind(
let base_href = if path.is_empty() || path == "/" {
"/webdav/".to_string()
} else {
format!("/webdav/{}/", path)
format!("/webdav/{}/", encode_uri_path(&path))
};
// ── 5. Determine target resource ─────────────────────────────
@@ -358,7 +397,7 @@ async fn build_streaming_propfind_response(
{
let mut w = Writer::new(&mut chunk);
for subfolder in &result.items {
let href = format!("{}{}/", base_href, subfolder.name);
let href = format!("{}{}/", base_href, encode_path_segment(&subfolder.name));
WebDavAdapter::write_folder_entry(&mut w, subfolder, &propfind_request, &href)
.map_err(|e| std::io::Error::other(e.to_string()))?;
}
@@ -389,7 +428,7 @@ async fn build_streaming_propfind_response(
{
let mut w = Writer::new(&mut chunk);
for file in &batch {
let href = format!("{}{}", base_href, file.name);
let href = format!("{}{}", base_href, encode_path_segment(&file.name));
WebDavAdapter::write_file_entry(&mut w, file, &propfind_request, &href)
.map_err(|e| std::io::Error::other(e.to_string()))?;
}
@@ -472,7 +511,7 @@ async fn handle_proppatch(
}
// Generate response
let href = format!("/webdav/{}", path);
let href = format!("/webdav/{}", encode_uri_path(&path));
let mut response_body = Vec::new();
WebDavAdapter::generate_proppatch_response(&mut response_body, &href, &results).map_err(
|e| AppError::internal_error(format!("Failed to generate PROPPATCH response: {}", e)),
@@ -860,10 +899,11 @@ async fn handle_move(
.unwrap_or("T")
!= "F";
// Extract destination path from URL
// Extract destination path from URL and percent-decode it
let destination_path = if let Some(webdav_prefix) = destination.find("/webdav/") {
let after_prefix = &destination[webdav_prefix + 8..];
after_prefix.trim_end_matches('/').to_string()
let trimmed = after_prefix.trim_end_matches('/');
percent_decode_str(trimmed).decode_utf8_lossy().into_owned()
} else {
return Err(AppError::bad_request("Invalid destination URL"));
};
@@ -1021,10 +1061,11 @@ async fn handle_copy(
.unwrap_or("T")
!= "F";
// Extract destination path from URL
// Extract destination path from URL and percent-decode it
let destination_path = if let Some(webdav_prefix) = destination.find("/webdav/") {
let after_prefix = &destination[webdav_prefix + 8..];
after_prefix.trim_end_matches('/').to_string()
let trimmed = after_prefix.trim_end_matches('/');
percent_decode_str(trimmed).decode_utf8_lossy().into_owned()
} else {
return Err(AppError::bad_request("Invalid destination URL"));
};
@@ -1229,7 +1270,7 @@ async fn handle_lock(
};
// Generate response
let href = format!("/webdav/{}", path);
let href = format!("/webdav/{}", encode_uri_path(&path));
let mut response_body = Vec::new();
WebDavAdapter::generate_lock_response(&mut response_body, &lock_info, &href).map_err(
|e| AppError::internal_error(format!("Failed to generate LOCK response: {}", e)),
@@ -1258,7 +1299,7 @@ async fn handle_lock(
};
// Generate response
let href = format!("/webdav/{}", path);
let href = format!("/webdav/{}", encode_uri_path(&path));
let mut response_body = Vec::new();
WebDavAdapter::generate_lock_response(&mut response_body, &lock_info, &href).map_err(
|e| AppError::internal_error(format!("Failed to generate LOCK response: {}", e)),