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:
@@ -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,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;
|
||||
|
||||
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user