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
+85
View File
@@ -0,0 +1,85 @@
//! DTOs for App Password (application-specific passwords for DAV clients).
use serde::{Deserialize, Serialize};
// ============================================================================
// Request DTOs
// ============================================================================
/// POST /api/auth/app-passwords — create a new app password
#[derive(Debug, Deserialize)]
pub struct CreateAppPasswordRequestDto {
/// Human-readable label (e.g. "DAVx5 on Pixel 8")
pub label: String,
/// Comma-separated scopes (defaults to all DAV protocols)
#[serde(default = "default_scopes")]
pub scopes: String,
/// Optional expiration in days (None = never expires)
pub expires_in_days: Option<u32>,
}
fn default_scopes() -> String {
"webdav,caldav,carddav".to_string()
}
// ============================================================================
// Response DTOs
// ============================================================================
/// Response when an app password is created — includes the plain-text password
/// that is shown ONCE to the user.
#[derive(Debug, Serialize)]
pub struct AppPasswordCreatedResponseDto {
/// Unique identifier for this app password.
pub id: String,
/// The label chosen by the user.
pub label: String,
/// The plain-text app password — shown only ONCE.
/// Format: `oxicloud-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX`
pub password: String,
/// The username to use with HTTP Basic Auth.
pub username: String,
/// Active scopes.
pub scopes: String,
/// Expiration date or null for never.
pub expires_at: Option<String>,
/// Usage instructions for common clients.
pub instructions: AppPasswordInstructions,
}
/// Usage instructions included in the creation response.
#[derive(Debug, Serialize)]
pub struct AppPasswordInstructions {
pub davx5: String,
pub thunderbird: String,
pub rclone: String,
pub curl_example: String,
}
/// Summary of an app password (list view — never includes the plain-text password).
#[derive(Debug, Serialize)]
pub struct AppPasswordSummaryDto {
pub id: String,
pub label: String,
/// First 8 chars of the token for identification.
pub prefix: String,
pub scopes: String,
pub created_at: String,
pub last_used_at: Option<String>,
pub expires_at: Option<String>,
pub active: bool,
}
/// Response for list endpoint.
#[derive(Debug, Serialize)]
pub struct AppPasswordListResponseDto {
pub app_passwords: Vec<AppPasswordSummaryDto>,
pub total: usize,
}
/// Response for revoke endpoint.
#[derive(Debug, Serialize)]
pub struct AppPasswordRevokeResponseDto {
pub status: String,
pub id: String,
}
+1
View File
@@ -1,4 +1,5 @@
pub mod address_book_dto;
pub mod app_password_dto;
pub mod calendar_dto;
pub mod contact_dto;
pub mod device_auth_dto;