style: cargo fmt --all
This commit is contained in:
+140
-148
@@ -1,148 +1,140 @@
|
||||
//! HttpOnly cookie helpers for secure token transport.
|
||||
//!
|
||||
//! Tokens are set as `HttpOnly; SameSite=Lax` cookies so that
|
||||
//! browser-based JavaScript cannot read them (mitigates XSS token theft).
|
||||
//! The `Secure` flag is controlled by the `OXICLOUD_COOKIE_SECURE` env var
|
||||
//! (default: auto-detect from `OXICLOUD_BASE_URL`).
|
||||
//!
|
||||
//! A companion **non-HttpOnly** CSRF cookie (`oxicloud_csrf`) is set
|
||||
//! alongside the auth cookies. The frontend must read it and echo its
|
||||
//! value back as `X-CSRF-Token` on every state-changing request.
|
||||
//! A middleware (`csrf_middleware`) validates the match.
|
||||
//!
|
||||
//! DAV clients continue to use `Authorization: Basic` with app passwords
|
||||
//! and are completely unaffected by this mechanism.
|
||||
|
||||
use axum::http::header::SET_COOKIE;
|
||||
use axum::http::{HeaderMap, HeaderValue};
|
||||
|
||||
/// Cookie name for the JWT access token.
|
||||
pub const ACCESS_COOKIE: &str = "oxicloud_access";
|
||||
/// Cookie name for the opaque refresh token.
|
||||
pub const REFRESH_COOKIE: &str = "oxicloud_refresh";
|
||||
/// Cookie name for the CSRF double-submit token (readable by JS).
|
||||
pub const CSRF_COOKIE: &str = "oxicloud_csrf";
|
||||
/// Header the frontend must send with the CSRF token value.
|
||||
pub const CSRF_HEADER: &str = "x-csrf-token";
|
||||
|
||||
/// Whether the `Secure` flag should be set on cookies.
|
||||
/// Auto-detected from `OXICLOUD_BASE_URL` (if it starts with `https`)
|
||||
/// or overridden with `OXICLOUD_COOKIE_SECURE=true|false`.
|
||||
fn cookie_secure() -> bool {
|
||||
if let Ok(v) = std::env::var("OXICLOUD_COOKIE_SECURE") {
|
||||
return v == "true" || v == "1";
|
||||
}
|
||||
// Auto-detect from base URL
|
||||
std::env::var("OXICLOUD_BASE_URL")
|
||||
.map(|u| u.starts_with("https"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Build a `Set-Cookie` header value.
|
||||
fn build_cookie(name: &str, value: &str, path: &str, max_age_secs: i64) -> String {
|
||||
let secure = if cookie_secure() { "; Secure" } else { "" };
|
||||
format!(
|
||||
"{name}={value}; HttpOnly; SameSite=Lax; Path={path}; Max-Age={max_age_secs}{secure}",
|
||||
)
|
||||
}
|
||||
|
||||
/// Append `Set-Cookie` headers for both access and refresh tokens.
|
||||
///
|
||||
/// The access cookie covers all paths (`/`) because the API lives under
|
||||
/// `/api`, CalDAV under `/caldav`, WebDAV under `/webdav`, etc.
|
||||
///
|
||||
/// The refresh cookie is restricted to `/api/auth` so it is only sent
|
||||
/// when the client explicitly calls the refresh or logout endpoints.
|
||||
pub fn append_auth_cookies(
|
||||
headers: &mut HeaderMap,
|
||||
access_token: &str,
|
||||
refresh_token: &str,
|
||||
access_expiry_secs: i64,
|
||||
refresh_expiry_secs: i64,
|
||||
) {
|
||||
if let Ok(val) = HeaderValue::from_str(&build_cookie(
|
||||
ACCESS_COOKIE,
|
||||
access_token,
|
||||
"/",
|
||||
access_expiry_secs,
|
||||
)) {
|
||||
headers.append(SET_COOKIE, val);
|
||||
}
|
||||
if let Ok(val) = HeaderValue::from_str(&build_cookie(
|
||||
REFRESH_COOKIE,
|
||||
refresh_token,
|
||||
"/api/auth",
|
||||
refresh_expiry_secs,
|
||||
)) {
|
||||
headers.append(SET_COOKIE, val);
|
||||
}
|
||||
}
|
||||
|
||||
/// Append `Set-Cookie` headers that immediately expire both auth cookies,
|
||||
/// effectively logging the user out on the browser side.
|
||||
pub fn append_clear_cookies(headers: &mut HeaderMap) {
|
||||
for (name, path) in [(ACCESS_COOKIE, "/"), (REFRESH_COOKIE, "/api/auth")] {
|
||||
let secure = if cookie_secure() { "; Secure" } else { "" };
|
||||
let val = format!(
|
||||
"{name}=; HttpOnly; SameSite=Lax; Path={path}; Max-Age=0{secure}",
|
||||
);
|
||||
if let Ok(hv) = HeaderValue::from_str(&val) {
|
||||
headers.append(SET_COOKIE, hv);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a named cookie value from the `Cookie` request header.
|
||||
pub fn extract_cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
|
||||
let cookie_header = headers.get(axum::http::header::COOKIE)?;
|
||||
let cookie_str = cookie_header.to_str().ok()?;
|
||||
|
||||
for pair in cookie_str.split(';') {
|
||||
let pair = pair.trim();
|
||||
if let Some(val) = pair.strip_prefix(name) {
|
||||
let val = val.strip_prefix('=')?;
|
||||
if !val.is_empty() {
|
||||
return Some(val.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// CSRF double-submit cookie helpers
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Generate a cryptographically random CSRF token (128-bit UUIDv4, hex-like).
|
||||
pub fn generate_csrf_token() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
/// Build a **non-HttpOnly** CSRF cookie so that frontend JS can read it
|
||||
/// via `document.cookie` and echo it back in the `X-CSRF-Token` header.
|
||||
fn build_csrf_cookie(value: &str, max_age_secs: i64) -> String {
|
||||
let secure = if cookie_secure() { "; Secure" } else { "" };
|
||||
format!(
|
||||
"{CSRF_COOKIE}={value}; SameSite=Lax; Path=/; Max-Age={max_age_secs}{secure}",
|
||||
)
|
||||
}
|
||||
|
||||
/// Append a CSRF double-submit cookie alongside the auth cookies.
|
||||
/// Should be called in every endpoint that also sets auth cookies.
|
||||
pub fn append_csrf_cookie(headers: &mut HeaderMap, access_expiry_secs: i64) {
|
||||
let token = generate_csrf_token();
|
||||
if let Ok(val) = HeaderValue::from_str(&build_csrf_cookie(&token, access_expiry_secs)) {
|
||||
headers.append(SET_COOKIE, val);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the CSRF cookie (on logout).
|
||||
pub fn append_clear_csrf_cookie(headers: &mut HeaderMap) {
|
||||
let secure = if cookie_secure() { "; Secure" } else { "" };
|
||||
let val = format!(
|
||||
"{CSRF_COOKIE}=; SameSite=Lax; Path=/; Max-Age=0{secure}",
|
||||
);
|
||||
if let Ok(hv) = HeaderValue::from_str(&val) {
|
||||
headers.append(SET_COOKIE, hv);
|
||||
}
|
||||
}
|
||||
//! HttpOnly cookie helpers for secure token transport.
|
||||
//!
|
||||
//! Tokens are set as `HttpOnly; SameSite=Lax` cookies so that
|
||||
//! browser-based JavaScript cannot read them (mitigates XSS token theft).
|
||||
//! The `Secure` flag is controlled by the `OXICLOUD_COOKIE_SECURE` env var
|
||||
//! (default: auto-detect from `OXICLOUD_BASE_URL`).
|
||||
//!
|
||||
//! A companion **non-HttpOnly** CSRF cookie (`oxicloud_csrf`) is set
|
||||
//! alongside the auth cookies. The frontend must read it and echo its
|
||||
//! value back as `X-CSRF-Token` on every state-changing request.
|
||||
//! A middleware (`csrf_middleware`) validates the match.
|
||||
//!
|
||||
//! DAV clients continue to use `Authorization: Basic` with app passwords
|
||||
//! and are completely unaffected by this mechanism.
|
||||
|
||||
use axum::http::header::SET_COOKIE;
|
||||
use axum::http::{HeaderMap, HeaderValue};
|
||||
|
||||
/// Cookie name for the JWT access token.
|
||||
pub const ACCESS_COOKIE: &str = "oxicloud_access";
|
||||
/// Cookie name for the opaque refresh token.
|
||||
pub const REFRESH_COOKIE: &str = "oxicloud_refresh";
|
||||
/// Cookie name for the CSRF double-submit token (readable by JS).
|
||||
pub const CSRF_COOKIE: &str = "oxicloud_csrf";
|
||||
/// Header the frontend must send with the CSRF token value.
|
||||
pub const CSRF_HEADER: &str = "x-csrf-token";
|
||||
|
||||
/// Whether the `Secure` flag should be set on cookies.
|
||||
/// Auto-detected from `OXICLOUD_BASE_URL` (if it starts with `https`)
|
||||
/// or overridden with `OXICLOUD_COOKIE_SECURE=true|false`.
|
||||
fn cookie_secure() -> bool {
|
||||
if let Ok(v) = std::env::var("OXICLOUD_COOKIE_SECURE") {
|
||||
return v == "true" || v == "1";
|
||||
}
|
||||
// Auto-detect from base URL
|
||||
std::env::var("OXICLOUD_BASE_URL")
|
||||
.map(|u| u.starts_with("https"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Build a `Set-Cookie` header value.
|
||||
fn build_cookie(name: &str, value: &str, path: &str, max_age_secs: i64) -> String {
|
||||
let secure = if cookie_secure() { "; Secure" } else { "" };
|
||||
format!("{name}={value}; HttpOnly; SameSite=Lax; Path={path}; Max-Age={max_age_secs}{secure}",)
|
||||
}
|
||||
|
||||
/// Append `Set-Cookie` headers for both access and refresh tokens.
|
||||
///
|
||||
/// The access cookie covers all paths (`/`) because the API lives under
|
||||
/// `/api`, CalDAV under `/caldav`, WebDAV under `/webdav`, etc.
|
||||
///
|
||||
/// The refresh cookie is restricted to `/api/auth` so it is only sent
|
||||
/// when the client explicitly calls the refresh or logout endpoints.
|
||||
pub fn append_auth_cookies(
|
||||
headers: &mut HeaderMap,
|
||||
access_token: &str,
|
||||
refresh_token: &str,
|
||||
access_expiry_secs: i64,
|
||||
refresh_expiry_secs: i64,
|
||||
) {
|
||||
if let Ok(val) = HeaderValue::from_str(&build_cookie(
|
||||
ACCESS_COOKIE,
|
||||
access_token,
|
||||
"/",
|
||||
access_expiry_secs,
|
||||
)) {
|
||||
headers.append(SET_COOKIE, val);
|
||||
}
|
||||
if let Ok(val) = HeaderValue::from_str(&build_cookie(
|
||||
REFRESH_COOKIE,
|
||||
refresh_token,
|
||||
"/api/auth",
|
||||
refresh_expiry_secs,
|
||||
)) {
|
||||
headers.append(SET_COOKIE, val);
|
||||
}
|
||||
}
|
||||
|
||||
/// Append `Set-Cookie` headers that immediately expire both auth cookies,
|
||||
/// effectively logging the user out on the browser side.
|
||||
pub fn append_clear_cookies(headers: &mut HeaderMap) {
|
||||
for (name, path) in [(ACCESS_COOKIE, "/"), (REFRESH_COOKIE, "/api/auth")] {
|
||||
let secure = if cookie_secure() { "; Secure" } else { "" };
|
||||
let val = format!("{name}=; HttpOnly; SameSite=Lax; Path={path}; Max-Age=0{secure}",);
|
||||
if let Ok(hv) = HeaderValue::from_str(&val) {
|
||||
headers.append(SET_COOKIE, hv);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a named cookie value from the `Cookie` request header.
|
||||
pub fn extract_cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
|
||||
let cookie_header = headers.get(axum::http::header::COOKIE)?;
|
||||
let cookie_str = cookie_header.to_str().ok()?;
|
||||
|
||||
for pair in cookie_str.split(';') {
|
||||
let pair = pair.trim();
|
||||
if let Some(val) = pair.strip_prefix(name) {
|
||||
let val = val.strip_prefix('=')?;
|
||||
if !val.is_empty() {
|
||||
return Some(val.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// CSRF double-submit cookie helpers
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Generate a cryptographically random CSRF token (128-bit UUIDv4, hex-like).
|
||||
pub fn generate_csrf_token() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
/// Build a **non-HttpOnly** CSRF cookie so that frontend JS can read it
|
||||
/// via `document.cookie` and echo it back in the `X-CSRF-Token` header.
|
||||
fn build_csrf_cookie(value: &str, max_age_secs: i64) -> String {
|
||||
let secure = if cookie_secure() { "; Secure" } else { "" };
|
||||
format!("{CSRF_COOKIE}={value}; SameSite=Lax; Path=/; Max-Age={max_age_secs}{secure}",)
|
||||
}
|
||||
|
||||
/// Append a CSRF double-submit cookie alongside the auth cookies.
|
||||
/// Should be called in every endpoint that also sets auth cookies.
|
||||
pub fn append_csrf_cookie(headers: &mut HeaderMap, access_expiry_secs: i64) {
|
||||
let token = generate_csrf_token();
|
||||
if let Ok(val) = HeaderValue::from_str(&build_csrf_cookie(&token, access_expiry_secs)) {
|
||||
headers.append(SET_COOKIE, val);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the CSRF cookie (on logout).
|
||||
pub fn append_clear_csrf_cookie(headers: &mut HeaderMap) {
|
||||
let secure = if cookie_secure() { "; Secure" } else { "" };
|
||||
let val = format!("{CSRF_COOKIE}=; SameSite=Lax; Path=/; Max-Age=0{secure}",);
|
||||
if let Ok(hv) = HeaderValue::from_str(&val) {
|
||||
headers.append(SET_COOKIE, hv);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,10 +197,7 @@ async fn login(
|
||||
auth_response.expires_in,
|
||||
state.core.config.auth.refresh_token_expiry_secs,
|
||||
);
|
||||
cookie_auth::append_csrf_cookie(
|
||||
response.headers_mut(),
|
||||
auth_response.expires_in,
|
||||
);
|
||||
cookie_auth::append_csrf_cookie(response.headers_mut(), auth_response.expires_in);
|
||||
Ok(response)
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -253,10 +250,7 @@ async fn refresh_token(
|
||||
auth_response.expires_in,
|
||||
state.core.config.auth.refresh_token_expiry_secs,
|
||||
);
|
||||
cookie_auth::append_csrf_cookie(
|
||||
response.headers_mut(),
|
||||
auth_response.expires_in,
|
||||
);
|
||||
cookie_auth::append_csrf_cookie(response.headers_mut(), auth_response.expires_in);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
@@ -522,9 +516,6 @@ async fn oidc_exchange(
|
||||
auth_response.expires_in,
|
||||
state.core.config.auth.refresh_token_expiry_secs,
|
||||
);
|
||||
cookie_auth::append_csrf_cookie(
|
||||
response.headers_mut(),
|
||||
auth_response.expires_in,
|
||||
);
|
||||
cookie_auth::append_csrf_cookie(response.headers_mut(), auth_response.expires_in);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
@@ -52,11 +52,10 @@ pub fn caldav_routes() -> Router<Arc<AppState>> {
|
||||
/// Creates RFC 6764 well-known discovery routes.
|
||||
/// These are public (no auth) and simply redirect to the CalDAV root.
|
||||
pub fn well_known_routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/.well-known/caldav",
|
||||
axum::routing::any(handle_well_known_caldav),
|
||||
)
|
||||
Router::new().route(
|
||||
"/.well-known/caldav",
|
||||
axum::routing::any(handle_well_known_caldav),
|
||||
)
|
||||
}
|
||||
|
||||
async fn handle_well_known_caldav() -> Response<Body> {
|
||||
@@ -114,13 +113,9 @@ fn extract_caldav_path(uri_path: &str) -> String {
|
||||
} else if uri_path.ends_with("/caldav") {
|
||||
""
|
||||
} else {
|
||||
uri_path
|
||||
.trim_start_matches('/')
|
||||
.trim_end_matches('/')
|
||||
uri_path.trim_start_matches('/').trim_end_matches('/')
|
||||
};
|
||||
percent_decode_str(encoded)
|
||||
.decode_utf8_lossy()
|
||||
.into_owned()
|
||||
percent_decode_str(encoded).decode_utf8_lossy().into_owned()
|
||||
}
|
||||
|
||||
// ─── Helper: extract user from request ───────────────────────────────
|
||||
@@ -198,9 +193,7 @@ async fn handle_propfind(
|
||||
calendar_service
|
||||
.list_my_calendars(&user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to list calendars: {}", e))
|
||||
})?
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to list calendars: {}", e)))?
|
||||
};
|
||||
|
||||
let base_href = "/caldav/";
|
||||
@@ -221,9 +214,7 @@ async fn handle_propfind(
|
||||
.unwrap())
|
||||
} else if path.starts_with("principals/") || path == "principals" {
|
||||
// Principal resource — return user principal properties
|
||||
let username = path
|
||||
.strip_prefix("principals/")
|
||||
.unwrap_or(&user.username);
|
||||
let username = path.strip_prefix("principals/").unwrap_or(&user.username);
|
||||
let username = if username.is_empty() {
|
||||
&user.username
|
||||
} else {
|
||||
@@ -255,9 +246,7 @@ async fn handle_propfind(
|
||||
|
||||
if parts.len() == 1 {
|
||||
// Single path segment: try as calendar ID first, fall back to user home
|
||||
let calendar_result = calendar_service
|
||||
.get_calendar(first_segment, &user.id)
|
||||
.await;
|
||||
let calendar_result = calendar_service.get_calendar(first_segment, &user.id).await;
|
||||
|
||||
if let Ok(calendar) = calendar_result {
|
||||
// Valid calendar ID — return calendar collection
|
||||
@@ -281,9 +270,7 @@ async fn handle_propfind(
|
||||
base_href,
|
||||
&depth,
|
||||
)
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to generate XML: {}", e))
|
||||
})?;
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::MULTI_STATUS)
|
||||
@@ -293,12 +280,13 @@ async fn handle_propfind(
|
||||
} else {
|
||||
// Not a calendar ID — treat as user calendar home (e.g. /caldav/{username}/)
|
||||
// List all calendars for this user
|
||||
let calendars = calendar_service
|
||||
.list_my_calendars(&user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to list calendars: {}", e))
|
||||
})?;
|
||||
let calendars =
|
||||
calendar_service
|
||||
.list_my_calendars(&user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to list calendars: {}", e))
|
||||
})?;
|
||||
|
||||
let base_href = &format!("/caldav/{}/", first_segment);
|
||||
let mut response_body = Vec::new();
|
||||
@@ -309,9 +297,7 @@ async fn handle_propfind(
|
||||
&propfind_request,
|
||||
base_href,
|
||||
)
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to generate XML: {}", e))
|
||||
})?;
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::MULTI_STATUS)
|
||||
@@ -324,9 +310,7 @@ async fn handle_propfind(
|
||||
let rest = parts[1];
|
||||
|
||||
// Check if first_segment is a valid calendar ID
|
||||
let calendar_result = calendar_service
|
||||
.get_calendar(first_segment, &user.id)
|
||||
.await;
|
||||
let calendar_result = calendar_service.get_calendar(first_segment, &user.id).await;
|
||||
|
||||
let (calendar_id, event_path) = if calendar_result.is_ok() {
|
||||
// first_segment is a calendar ID, rest is event path
|
||||
@@ -341,9 +325,7 @@ async fn handle_propfind(
|
||||
let cal = calendar_service
|
||||
.get_calendar(sub_parts[0], &user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::not_found(format!("Calendar not found: {}", e))
|
||||
})?;
|
||||
.map_err(|e| AppError::not_found(format!("Calendar not found: {}", e)))?;
|
||||
|
||||
let events = if depth != "0" {
|
||||
calendar_service
|
||||
@@ -354,8 +336,7 @@ async fn handle_propfind(
|
||||
vec![]
|
||||
};
|
||||
|
||||
let base_href =
|
||||
&format!("/caldav/{}/{}/", first_segment, sub_parts[0]);
|
||||
let base_href = &format!("/caldav/{}/{}/", first_segment, sub_parts[0]);
|
||||
let mut response_body = Vec::new();
|
||||
|
||||
CalDavAdapter::generate_calendar_collection_propfind(
|
||||
@@ -387,16 +368,12 @@ async fn handle_propfind(
|
||||
let events = calendar_service
|
||||
.list_events(calendar_id, None, None, &user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to list events: {}", e))
|
||||
})?;
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?;
|
||||
|
||||
let event = events
|
||||
.iter()
|
||||
.find(|e| e.ical_uid == ical_uid)
|
||||
.ok_or_else(|| {
|
||||
AppError::not_found(format!("Event not found: {}", ical_uid))
|
||||
})?;
|
||||
.ok_or_else(|| AppError::not_found(format!("Event not found: {}", ical_uid)))?;
|
||||
|
||||
let base_href = &format!("/caldav/{}/", calendar_id);
|
||||
let report_type = CalDavReportType::CalendarMultiget {
|
||||
@@ -411,9 +388,7 @@ async fn handle_propfind(
|
||||
&report_type,
|
||||
base_href,
|
||||
)
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to generate XML: {}", e))
|
||||
})?;
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::MULTI_STATUS)
|
||||
@@ -718,7 +693,10 @@ fn generate_event_ical(event: &crate::application::dtos::calendar_dto::CalendarE
|
||||
}
|
||||
|
||||
/// Writes a VEVENT block directly into `buf` — zero intermediate allocations.
|
||||
fn write_vevent(buf: &mut String, event: &crate::application::dtos::calendar_dto::CalendarEventDto) {
|
||||
fn write_vevent(
|
||||
buf: &mut String,
|
||||
event: &crate::application::dtos::calendar_dto::CalendarEventDto,
|
||||
) {
|
||||
let _ = write!(
|
||||
buf,
|
||||
"BEGIN:VEVENT\r\nUID:{}\r\nSUMMARY:{}\r\nDTSTART:{}\r\nDTEND:{}\r\n",
|
||||
|
||||
@@ -99,9 +99,7 @@ fn extract_carddav_path(uri_path: &str) -> String {
|
||||
} else if uri_path.ends_with("/carddav") {
|
||||
""
|
||||
} else {
|
||||
uri_path
|
||||
.trim_start_matches('/')
|
||||
.trim_end_matches('/')
|
||||
uri_path.trim_start_matches('/').trim_end_matches('/')
|
||||
};
|
||||
percent_encoding::percent_decode_str(encoded)
|
||||
.decode_utf8_lossy()
|
||||
|
||||
@@ -1,227 +1,225 @@
|
||||
//! HTTP handlers for OAuth 2.0 Device Authorization Grant (RFC 8628).
|
||||
//!
|
||||
//! Endpoints:
|
||||
//! POST /api/auth/device/authorize — Client starts the device flow (public)
|
||||
//! GET /api/auth/device/verify — Check user_code validity (authenticated)
|
||||
//! POST /api/auth/device/verify — User approves/denies (authenticated)
|
||||
//! POST /api/auth/device/token — Client polls for tokens (public)
|
||||
//! GET /api/auth/device/devices — List user's authorized devices (authenticated)
|
||||
//! DELETE /api/auth/device/devices/{id} — Revoke a device (authenticated)
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{Json, Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{delete, get, post},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::dtos::device_auth_dto::*;
|
||||
use crate::application::services::device_auth_service::DeviceAuthService;
|
||||
use crate::common::di::AppState;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::AuthUser;
|
||||
|
||||
/// Create the device auth router.
|
||||
///
|
||||
/// Public endpoints (no auth middleware): authorize, token
|
||||
/// Protected endpoints (behind auth middleware): verify (GET+POST), devices
|
||||
pub fn device_auth_public_routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
// Client-facing endpoints (no auth needed — the client doesn't have tokens yet)
|
||||
.route("/authorize", post(device_authorize))
|
||||
.route("/token", post(device_token))
|
||||
}
|
||||
|
||||
pub fn device_auth_protected_routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
// User-facing endpoints (require valid session)
|
||||
.route("/verify", get(device_verify_info))
|
||||
.route("/verify", post(device_verify_action))
|
||||
.route("/devices", get(list_devices))
|
||||
.route("/devices/{id}", delete(revoke_device))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// POST /api/auth/device/authorize — Client initiates the device flow
|
||||
// ============================================================================
|
||||
|
||||
/// Client sends: `{ "client_name": "rclone", "scope": "webdav" }`
|
||||
/// Server returns: device_code, user_code, verification_uri, etc.
|
||||
async fn device_authorize(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<DeviceAuthorizeRequestDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let device_service = get_device_service(&state)?;
|
||||
|
||||
let response = device_service.initiate(body).await.map_err(|e| {
|
||||
tracing::error!("Device authorize failed: {}", e);
|
||||
AppError::from(e)
|
||||
})?;
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// POST /api/auth/device/token — Client polls for tokens
|
||||
// ============================================================================
|
||||
|
||||
/// Client sends: `{ "device_code": "...", "grant_type": "urn:ietf:params:oauth:grant-type:device_code" }`
|
||||
/// Returns tokens on success, or RFC 8628 error codes while pending.
|
||||
async fn device_token(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<DeviceTokenRequestDto>,
|
||||
) -> Result<impl IntoResponse, impl IntoResponse> {
|
||||
let device_service = match get_device_service(&state) {
|
||||
Ok(svc) => svc,
|
||||
Err(e) => return Err(e.into_response()),
|
||||
};
|
||||
|
||||
// Validate grant_type if provided (RFC compliance)
|
||||
if !body.grant_type.is_empty()
|
||||
&& body.grant_type != "urn:ietf:params:oauth:grant-type:device_code"
|
||||
{
|
||||
let error_body = serde_json::json!({
|
||||
"error": "unsupported_grant_type",
|
||||
"error_description": "grant_type must be urn:ietf:params:oauth:grant-type:device_code"
|
||||
});
|
||||
return Err((StatusCode::BAD_REQUEST, Json(error_body)).into_response());
|
||||
}
|
||||
|
||||
match device_service.poll(&body.device_code).await {
|
||||
Ok(tokens) => Ok((StatusCode::OK, Json(tokens)).into_response()),
|
||||
Err(poll_err) => {
|
||||
let status = StatusCode::from_u16(poll_err.http_status())
|
||||
.unwrap_or(StatusCode::BAD_REQUEST);
|
||||
let error_body = serde_json::json!({
|
||||
"error": poll_err.error_code(),
|
||||
"error_description": poll_err.description()
|
||||
});
|
||||
Err((status, Json(error_body)).into_response())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GET /api/auth/device/verify?code=ABCD-1234 — Check if user_code is valid
|
||||
// ============================================================================
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct VerifyQuery {
|
||||
#[serde(default)]
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
async fn device_verify_info(
|
||||
State(state): State<Arc<AppState>>,
|
||||
_auth_user: AuthUser,
|
||||
Query(query): Query<VerifyQuery>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let device_service = get_device_service(&state)?;
|
||||
|
||||
let info = device_service
|
||||
.verify_user_code(&query.code)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!("Device verify lookup failed: {}", e);
|
||||
AppError::from(e)
|
||||
})?;
|
||||
|
||||
Ok((StatusCode::OK, Json(info)))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// POST /api/auth/device/verify — User approves or denies
|
||||
// ============================================================================
|
||||
|
||||
async fn device_verify_action(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Json(body): Json<DeviceVerifyRequestDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let device_service = get_device_service(&state)?;
|
||||
|
||||
match body.action.to_lowercase().as_str() {
|
||||
"approve" | "allow" | "accept" => {
|
||||
device_service
|
||||
.approve(&body.user_code, &auth_user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Device approve failed: {}", e);
|
||||
AppError::from(e)
|
||||
})?;
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({ "status": "approved" })),
|
||||
))
|
||||
}
|
||||
"deny" | "reject" | "cancel" => {
|
||||
device_service.deny(&body.user_code).await.map_err(|e| {
|
||||
tracing::error!("Device deny failed: {}", e);
|
||||
AppError::from(e)
|
||||
})?;
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({ "status": "denied" })),
|
||||
))
|
||||
}
|
||||
_ => Err(AppError::bad_request(
|
||||
"action must be 'approve' or 'deny'",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GET /api/auth/device/devices — List user's authorized devices
|
||||
// ============================================================================
|
||||
|
||||
async fn list_devices(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let device_service = get_device_service(&state)?;
|
||||
|
||||
let devices = device_service
|
||||
.list_user_devices(&auth_user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("List devices failed: {}", e);
|
||||
AppError::from(e)
|
||||
})?;
|
||||
|
||||
Ok((StatusCode::OK, Json(devices)))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DELETE /api/auth/device/devices/{id} — Revoke a device authorization
|
||||
// ============================================================================
|
||||
|
||||
async fn revoke_device(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Path(device_id): Path<String>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let device_service = get_device_service(&state)?;
|
||||
|
||||
device_service
|
||||
.revoke_device(&device_id, &auth_user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Revoke device failed: {}", e);
|
||||
AppError::from(e)
|
||||
})?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper
|
||||
// ============================================================================
|
||||
|
||||
fn get_device_service(state: &AppState) -> Result<&Arc<DeviceAuthService>, AppError> {
|
||||
state
|
||||
.device_auth_service
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Device authorization service not configured"))
|
||||
}
|
||||
//! HTTP handlers for OAuth 2.0 Device Authorization Grant (RFC 8628).
|
||||
//!
|
||||
//! Endpoints:
|
||||
//! POST /api/auth/device/authorize — Client starts the device flow (public)
|
||||
//! GET /api/auth/device/verify — Check user_code validity (authenticated)
|
||||
//! POST /api/auth/device/verify — User approves/denies (authenticated)
|
||||
//! POST /api/auth/device/token — Client polls for tokens (public)
|
||||
//! GET /api/auth/device/devices — List user's authorized devices (authenticated)
|
||||
//! DELETE /api/auth/device/devices/{id} — Revoke a device (authenticated)
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{Json, Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{delete, get, post},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::dtos::device_auth_dto::*;
|
||||
use crate::application::services::device_auth_service::DeviceAuthService;
|
||||
use crate::common::di::AppState;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::AuthUser;
|
||||
|
||||
/// Create the device auth router.
|
||||
///
|
||||
/// Public endpoints (no auth middleware): authorize, token
|
||||
/// Protected endpoints (behind auth middleware): verify (GET+POST), devices
|
||||
pub fn device_auth_public_routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
// Client-facing endpoints (no auth needed — the client doesn't have tokens yet)
|
||||
.route("/authorize", post(device_authorize))
|
||||
.route("/token", post(device_token))
|
||||
}
|
||||
|
||||
pub fn device_auth_protected_routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
// User-facing endpoints (require valid session)
|
||||
.route("/verify", get(device_verify_info))
|
||||
.route("/verify", post(device_verify_action))
|
||||
.route("/devices", get(list_devices))
|
||||
.route("/devices/{id}", delete(revoke_device))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// POST /api/auth/device/authorize — Client initiates the device flow
|
||||
// ============================================================================
|
||||
|
||||
/// Client sends: `{ "client_name": "rclone", "scope": "webdav" }`
|
||||
/// Server returns: device_code, user_code, verification_uri, etc.
|
||||
async fn device_authorize(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<DeviceAuthorizeRequestDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let device_service = get_device_service(&state)?;
|
||||
|
||||
let response = device_service.initiate(body).await.map_err(|e| {
|
||||
tracing::error!("Device authorize failed: {}", e);
|
||||
AppError::from(e)
|
||||
})?;
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// POST /api/auth/device/token — Client polls for tokens
|
||||
// ============================================================================
|
||||
|
||||
/// Client sends: `{ "device_code": "...", "grant_type": "urn:ietf:params:oauth:grant-type:device_code" }`
|
||||
/// Returns tokens on success, or RFC 8628 error codes while pending.
|
||||
async fn device_token(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<DeviceTokenRequestDto>,
|
||||
) -> Result<impl IntoResponse, impl IntoResponse> {
|
||||
let device_service = match get_device_service(&state) {
|
||||
Ok(svc) => svc,
|
||||
Err(e) => return Err(e.into_response()),
|
||||
};
|
||||
|
||||
// Validate grant_type if provided (RFC compliance)
|
||||
if !body.grant_type.is_empty()
|
||||
&& body.grant_type != "urn:ietf:params:oauth:grant-type:device_code"
|
||||
{
|
||||
let error_body = serde_json::json!({
|
||||
"error": "unsupported_grant_type",
|
||||
"error_description": "grant_type must be urn:ietf:params:oauth:grant-type:device_code"
|
||||
});
|
||||
return Err((StatusCode::BAD_REQUEST, Json(error_body)).into_response());
|
||||
}
|
||||
|
||||
match device_service.poll(&body.device_code).await {
|
||||
Ok(tokens) => Ok((StatusCode::OK, Json(tokens)).into_response()),
|
||||
Err(poll_err) => {
|
||||
let status =
|
||||
StatusCode::from_u16(poll_err.http_status()).unwrap_or(StatusCode::BAD_REQUEST);
|
||||
let error_body = serde_json::json!({
|
||||
"error": poll_err.error_code(),
|
||||
"error_description": poll_err.description()
|
||||
});
|
||||
Err((status, Json(error_body)).into_response())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GET /api/auth/device/verify?code=ABCD-1234 — Check if user_code is valid
|
||||
// ============================================================================
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct VerifyQuery {
|
||||
#[serde(default)]
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
async fn device_verify_info(
|
||||
State(state): State<Arc<AppState>>,
|
||||
_auth_user: AuthUser,
|
||||
Query(query): Query<VerifyQuery>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let device_service = get_device_service(&state)?;
|
||||
|
||||
let info = device_service
|
||||
.verify_user_code(&query.code)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!("Device verify lookup failed: {}", e);
|
||||
AppError::from(e)
|
||||
})?;
|
||||
|
||||
Ok((StatusCode::OK, Json(info)))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// POST /api/auth/device/verify — User approves or denies
|
||||
// ============================================================================
|
||||
|
||||
async fn device_verify_action(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Json(body): Json<DeviceVerifyRequestDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let device_service = get_device_service(&state)?;
|
||||
|
||||
match body.action.to_lowercase().as_str() {
|
||||
"approve" | "allow" | "accept" => {
|
||||
device_service
|
||||
.approve(&body.user_code, &auth_user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Device approve failed: {}", e);
|
||||
AppError::from(e)
|
||||
})?;
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({ "status": "approved" })),
|
||||
))
|
||||
}
|
||||
"deny" | "reject" | "cancel" => {
|
||||
device_service.deny(&body.user_code).await.map_err(|e| {
|
||||
tracing::error!("Device deny failed: {}", e);
|
||||
AppError::from(e)
|
||||
})?;
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({ "status": "denied" })),
|
||||
))
|
||||
}
|
||||
_ => Err(AppError::bad_request("action must be 'approve' or 'deny'")),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GET /api/auth/device/devices — List user's authorized devices
|
||||
// ============================================================================
|
||||
|
||||
async fn list_devices(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let device_service = get_device_service(&state)?;
|
||||
|
||||
let devices = device_service
|
||||
.list_user_devices(&auth_user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("List devices failed: {}", e);
|
||||
AppError::from(e)
|
||||
})?;
|
||||
|
||||
Ok((StatusCode::OK, Json(devices)))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DELETE /api/auth/device/devices/{id} — Revoke a device authorization
|
||||
// ============================================================================
|
||||
|
||||
async fn revoke_device(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Path(device_id): Path<String>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let device_service = get_device_service(&state)?;
|
||||
|
||||
device_service
|
||||
.revoke_device(&device_id, &auth_user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Revoke device failed: {}", e);
|
||||
AppError::from(e)
|
||||
})?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper
|
||||
// ============================================================================
|
||||
|
||||
fn get_device_service(state: &AppState) -> Result<&Arc<DeviceAuthService>, AppError> {
|
||||
state
|
||||
.device_auth_service
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Device authorization service not configured"))
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@ pub mod admin_handler;
|
||||
pub mod app_password_handler;
|
||||
pub mod auth_handler;
|
||||
pub mod batch_handler;
|
||||
pub mod device_auth_handler;
|
||||
pub mod caldav_handler;
|
||||
pub mod carddav_handler;
|
||||
pub mod chunked_upload_handler;
|
||||
pub mod dedup_handler;
|
||||
pub mod device_auth_handler;
|
||||
pub mod favorites_handler;
|
||||
pub mod file_handler;
|
||||
pub mod folder_handler;
|
||||
|
||||
@@ -28,7 +28,7 @@ use crate::common::di::AppState;
|
||||
use crate::infrastructure::services::path_resolver_service::ResolvedResource;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::CurrentUser;
|
||||
use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC, AsciiSet};
|
||||
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Characters that MUST NOT be percent-encoded inside a URI path segment.
|
||||
@@ -115,9 +115,7 @@ fn extract_webdav_path(uri: &axum::http::Uri) -> String {
|
||||
trimmed.trim_end_matches('/')
|
||||
};
|
||||
// Decode percent-encoded characters (e.g. %20 → space)
|
||||
percent_decode_str(encoded)
|
||||
.decode_utf8_lossy()
|
||||
.into_owned()
|
||||
percent_decode_str(encoded).decode_utf8_lossy().into_owned()
|
||||
}
|
||||
|
||||
async fn handle_webdav_methods_root(
|
||||
@@ -324,8 +322,13 @@ async fn handle_propfind(
|
||||
let mut xml_writer = Writer::new(&mut buf);
|
||||
WebDavAdapter::write_multistatus_start(&mut xml_writer)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
WebDavAdapter::write_file_entry(&mut xml_writer, &file, &propfind_request, &base_href)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
WebDavAdapter::write_file_entry(
|
||||
&mut xml_writer,
|
||||
&file,
|
||||
&propfind_request,
|
||||
&base_href,
|
||||
)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
WebDavAdapter::write_multistatus_end(&mut xml_writer)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
}
|
||||
@@ -358,8 +361,13 @@ async fn handle_propfind(
|
||||
let mut xml_writer = Writer::new(&mut buf);
|
||||
WebDavAdapter::write_multistatus_start(&mut xml_writer)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
WebDavAdapter::write_file_entry(&mut xml_writer, &file, &propfind_request, &base_href)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
WebDavAdapter::write_file_entry(
|
||||
&mut xml_writer,
|
||||
&file,
|
||||
&propfind_request,
|
||||
&base_href,
|
||||
)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
WebDavAdapter::write_multistatus_end(&mut xml_writer)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
}
|
||||
@@ -910,13 +918,17 @@ async fn handle_delete(
|
||||
folder_service
|
||||
.delete_folder(&folder.id, caller_id)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to delete folder: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to delete folder: {}", e))
|
||||
})?;
|
||||
}
|
||||
Ok(ResolvedResource::File(file)) => {
|
||||
file_management_service
|
||||
.delete_file(&file.id)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to delete file: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to delete file: {}", e))
|
||||
})?;
|
||||
}
|
||||
Err(_) => return Err(AppError::not_found(format!("Resource not found: {}", path))),
|
||||
}
|
||||
@@ -1002,8 +1014,14 @@ async fn handle_move(
|
||||
let dest_exists = if let Some(resolver) = &state.path_resolver {
|
||||
resolver.exists(&destination_path).await.unwrap_or(false)
|
||||
} else {
|
||||
folder_service.get_folder_by_path(&destination_path).await.is_ok()
|
||||
|| file_retrieval_service.get_file_by_path(&destination_path).await.is_ok()
|
||||
folder_service
|
||||
.get_folder_by_path(&destination_path)
|
||||
.await
|
||||
.is_ok()
|
||||
|| file_retrieval_service
|
||||
.get_file_by_path(&destination_path)
|
||||
.await
|
||||
.is_ok()
|
||||
};
|
||||
if dest_exists {
|
||||
return Err(AppError::precondition_failed(
|
||||
@@ -1044,7 +1062,9 @@ async fn handle_move(
|
||||
folder.owner_id.as_deref().unwrap_or("webdav"),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to move folder: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to move folder: {}", e))
|
||||
})?;
|
||||
|
||||
if folder.name != dest_folder_name {
|
||||
let rename_dto = crate::application::dtos::folder_dto::RenameFolderDto {
|
||||
@@ -1057,7 +1077,9 @@ async fn handle_move(
|
||||
folder.owner_id.as_deref().unwrap_or("webdav"),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to rename folder: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to rename folder: {}", e))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(ResolvedResource::File(file)) => {
|
||||
@@ -1080,16 +1102,25 @@ async fn handle_move(
|
||||
file_management_service
|
||||
.move_file(&file.id, Some(dest_parent_path.to_string()))
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to move file: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to move file: {}", e))
|
||||
})?;
|
||||
}
|
||||
if file.name != dest_filename {
|
||||
file_management_service
|
||||
.rename_file(&file.id, dest_filename)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to rename file: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to rename file: {}", e))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Err(_) => return Err(AppError::not_found(format!("Resource not found: {}", source_path))),
|
||||
Err(_) => {
|
||||
return Err(AppError::not_found(format!(
|
||||
"Resource not found: {}",
|
||||
source_path
|
||||
)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: legacy double-query path
|
||||
@@ -1137,13 +1168,17 @@ async fn handle_move(
|
||||
folder.owner_id.as_deref().unwrap_or("webdav"),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to rename folder: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to rename folder: {}", e))
|
||||
})?;
|
||||
}
|
||||
} else {
|
||||
let file = file_retrieval_service
|
||||
.get_file_by_path(&source_path)
|
||||
.await
|
||||
.map_err(|_e| AppError::not_found(format!("Resource not found: {}", source_path)))?;
|
||||
.map_err(|_e| {
|
||||
AppError::not_found(format!("Resource not found: {}", source_path))
|
||||
})?;
|
||||
|
||||
let dest_filename = destination_path
|
||||
.split('/')
|
||||
@@ -1170,7 +1205,9 @@ async fn handle_move(
|
||||
file_management_service
|
||||
.rename_file(&file.id, dest_filename)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to rename file: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to rename file: {}", e))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1240,8 +1277,14 @@ async fn handle_copy(
|
||||
let dest_exists = if let Some(resolver) = &state.path_resolver {
|
||||
resolver.exists(&destination_path).await.unwrap_or(false)
|
||||
} else {
|
||||
folder_service.get_folder_by_path(&destination_path).await.is_ok()
|
||||
|| file_retrieval_service.get_file_by_path(&destination_path).await.is_ok()
|
||||
folder_service
|
||||
.get_folder_by_path(&destination_path)
|
||||
.await
|
||||
.is_ok()
|
||||
|| file_retrieval_service
|
||||
.get_file_by_path(&destination_path)
|
||||
.await
|
||||
.is_ok()
|
||||
};
|
||||
if dest_exists {
|
||||
return Err(AppError::precondition_failed(
|
||||
@@ -1296,7 +1339,10 @@ async fn handle_copy(
|
||||
.create_folder(create_dto)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to create destination folder: {}", e))
|
||||
AppError::internal_error(format!(
|
||||
"Failed to create destination folder: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
@@ -1322,7 +1368,12 @@ async fn handle_copy(
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to copy file: {}", e)))?;
|
||||
}
|
||||
Err(_) => return Err(AppError::not_found(format!("Resource not found: {}", source_path))),
|
||||
Err(_) => {
|
||||
return Err(AppError::not_found(format!(
|
||||
"Resource not found: {}",
|
||||
source_path
|
||||
)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: legacy double-query path
|
||||
@@ -1371,14 +1422,19 @@ async fn handle_copy(
|
||||
.create_folder(create_dto)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to create destination folder: {}", e))
|
||||
AppError::internal_error(format!(
|
||||
"Failed to create destination folder: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
}
|
||||
} else {
|
||||
let file = file_retrieval_service
|
||||
.get_file_by_path(&source_path)
|
||||
.await
|
||||
.map_err(|_e| AppError::not_found(format!("Resource not found: {}", source_path)))?;
|
||||
.map_err(|_e| {
|
||||
AppError::not_found(format!("Resource not found: {}", source_path))
|
||||
})?;
|
||||
|
||||
let dest_parent_path = if let Some(idx) = destination_path.rfind('/') {
|
||||
&destination_path[..idx]
|
||||
|
||||
Reference in New Issue
Block a user