style: cargo fmt --all
This commit is contained in:
@@ -351,27 +351,23 @@ impl CalDavAdapter {
|
|||||||
username
|
username
|
||||||
))))?;
|
))))?;
|
||||||
xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?;
|
xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?;
|
||||||
xml_writer
|
xml_writer.write_event(Event::End(BytesEnd::new("D:current-user-principal")))?;
|
||||||
.write_event(Event::End(BytesEnd::new("D:current-user-principal")))?;
|
|
||||||
|
|
||||||
// calendar-home-set
|
// calendar-home-set
|
||||||
xml_writer
|
xml_writer.write_event(Event::Start(BytesStart::new("C:calendar-home-set")))?;
|
||||||
.write_event(Event::Start(BytesStart::new("C:calendar-home-set")))?;
|
|
||||||
xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?;
|
xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?;
|
||||||
xml_writer.write_event(Event::Text(BytesText::new(&format!(
|
xml_writer.write_event(Event::Text(BytesText::new(&format!(
|
||||||
"/caldav/{}/",
|
"/caldav/{}/",
|
||||||
username
|
username
|
||||||
))))?;
|
))))?;
|
||||||
xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?;
|
xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?;
|
||||||
xml_writer
|
xml_writer.write_event(Event::End(BytesEnd::new("C:calendar-home-set")))?;
|
||||||
.write_event(Event::End(BytesEnd::new("C:calendar-home-set")))?;
|
|
||||||
}
|
}
|
||||||
PropFindType::PropName => {
|
PropFindType::PropName => {
|
||||||
xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?;
|
xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?;
|
||||||
xml_writer
|
xml_writer
|
||||||
.write_event(Event::Empty(BytesStart::new("D:current-user-principal")))?;
|
.write_event(Event::Empty(BytesStart::new("D:current-user-principal")))?;
|
||||||
xml_writer
|
xml_writer.write_event(Event::Empty(BytesStart::new("C:calendar-home-set")))?;
|
||||||
.write_event(Event::Empty(BytesStart::new("C:calendar-home-set")))?;
|
|
||||||
}
|
}
|
||||||
PropFindType::Prop(props) => {
|
PropFindType::Prop(props) => {
|
||||||
Self::write_root_requested_props(xml_writer, username, props)?;
|
Self::write_root_requested_props(xml_writer, username, props)?;
|
||||||
@@ -404,9 +400,8 @@ impl CalDavAdapter {
|
|||||||
xml_writer.write_event(Event::End(BytesEnd::new("D:resourcetype")))?;
|
xml_writer.write_event(Event::End(BytesEnd::new("D:resourcetype")))?;
|
||||||
}
|
}
|
||||||
("DAV:", "current-user-principal") => {
|
("DAV:", "current-user-principal") => {
|
||||||
xml_writer.write_event(Event::Start(BytesStart::new(
|
xml_writer
|
||||||
"D:current-user-principal",
|
.write_event(Event::Start(BytesStart::new("D:current-user-principal")))?;
|
||||||
)))?;
|
|
||||||
xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?;
|
xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?;
|
||||||
xml_writer.write_event(Event::Text(BytesText::new(&format!(
|
xml_writer.write_event(Event::Text(BytesText::new(&format!(
|
||||||
"/caldav/principals/{}/",
|
"/caldav/principals/{}/",
|
||||||
@@ -417,16 +412,14 @@ impl CalDavAdapter {
|
|||||||
.write_event(Event::End(BytesEnd::new("D:current-user-principal")))?;
|
.write_event(Event::End(BytesEnd::new("D:current-user-principal")))?;
|
||||||
}
|
}
|
||||||
("urn:ietf:params:xml:ns:caldav", "calendar-home-set") => {
|
("urn:ietf:params:xml:ns:caldav", "calendar-home-set") => {
|
||||||
xml_writer
|
xml_writer.write_event(Event::Start(BytesStart::new("C:calendar-home-set")))?;
|
||||||
.write_event(Event::Start(BytesStart::new("C:calendar-home-set")))?;
|
|
||||||
xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?;
|
xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?;
|
||||||
xml_writer.write_event(Event::Text(BytesText::new(&format!(
|
xml_writer.write_event(Event::Text(BytesText::new(&format!(
|
||||||
"/caldav/{}/",
|
"/caldav/{}/",
|
||||||
username
|
username
|
||||||
))))?;
|
))))?;
|
||||||
xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?;
|
xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?;
|
||||||
xml_writer
|
xml_writer.write_event(Event::End(BytesEnd::new("C:calendar-home-set")))?;
|
||||||
.write_event(Event::End(BytesEnd::new("C:calendar-home-set")))?;
|
|
||||||
}
|
}
|
||||||
("DAV:", "displayname") => {
|
("DAV:", "displayname") => {
|
||||||
xml_writer.write_event(Event::Start(BytesStart::new("D:displayname")))?;
|
xml_writer.write_event(Event::Start(BytesStart::new("D:displayname")))?;
|
||||||
@@ -452,10 +445,7 @@ impl CalDavAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Write standard properties for a principal resource.
|
/// Write standard properties for a principal resource.
|
||||||
fn write_principal_props<W: Write>(
|
fn write_principal_props<W: Write>(xml_writer: &mut Writer<W>, username: &str) -> Result<()> {
|
||||||
xml_writer: &mut Writer<W>,
|
|
||||||
username: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
// resourcetype — principal
|
// resourcetype — principal
|
||||||
xml_writer.write_event(Event::Start(BytesStart::new("D:resourcetype")))?;
|
xml_writer.write_event(Event::Start(BytesStart::new("D:resourcetype")))?;
|
||||||
xml_writer.write_event(Event::Empty(BytesStart::new("D:collection")))?;
|
xml_writer.write_event(Event::Empty(BytesStart::new("D:collection")))?;
|
||||||
@@ -510,9 +500,8 @@ impl CalDavAdapter {
|
|||||||
xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?;
|
xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?;
|
||||||
}
|
}
|
||||||
("DAV:", "current-user-principal") => {
|
("DAV:", "current-user-principal") => {
|
||||||
xml_writer.write_event(Event::Start(BytesStart::new(
|
xml_writer
|
||||||
"D:current-user-principal",
|
.write_event(Event::Start(BytesStart::new("D:current-user-principal")))?;
|
||||||
)))?;
|
|
||||||
xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?;
|
xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?;
|
||||||
xml_writer.write_event(Event::Text(BytesText::new(&format!(
|
xml_writer.write_event(Event::Text(BytesText::new(&format!(
|
||||||
"/caldav/principals/{}/",
|
"/caldav/principals/{}/",
|
||||||
@@ -523,16 +512,14 @@ impl CalDavAdapter {
|
|||||||
.write_event(Event::End(BytesEnd::new("D:current-user-principal")))?;
|
.write_event(Event::End(BytesEnd::new("D:current-user-principal")))?;
|
||||||
}
|
}
|
||||||
("urn:ietf:params:xml:ns:caldav", "calendar-home-set") => {
|
("urn:ietf:params:xml:ns:caldav", "calendar-home-set") => {
|
||||||
xml_writer
|
xml_writer.write_event(Event::Start(BytesStart::new("C:calendar-home-set")))?;
|
||||||
.write_event(Event::Start(BytesStart::new("C:calendar-home-set")))?;
|
|
||||||
xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?;
|
xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?;
|
||||||
xml_writer.write_event(Event::Text(BytesText::new(&format!(
|
xml_writer.write_event(Event::Text(BytesText::new(&format!(
|
||||||
"/caldav/{}/",
|
"/caldav/{}/",
|
||||||
username
|
username
|
||||||
))))?;
|
))))?;
|
||||||
xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?;
|
xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?;
|
||||||
xml_writer
|
xml_writer.write_event(Event::End(BytesEnd::new("C:calendar-home-set")))?;
|
||||||
.write_event(Event::End(BytesEnd::new("C:calendar-home-set")))?;
|
|
||||||
}
|
}
|
||||||
("urn:ietf:params:xml:ns:caldav", "calendar-user-address-set") => {
|
("urn:ietf:params:xml:ns:caldav", "calendar-user-address-set") => {
|
||||||
xml_writer.write_event(Event::Start(BytesStart::new(
|
xml_writer.write_event(Event::Start(BytesStart::new(
|
||||||
@@ -544,9 +531,8 @@ impl CalDavAdapter {
|
|||||||
username
|
username
|
||||||
))))?;
|
))))?;
|
||||||
xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?;
|
xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?;
|
||||||
xml_writer.write_event(Event::End(BytesEnd::new(
|
xml_writer
|
||||||
"C:calendar-user-address-set",
|
.write_event(Event::End(BytesEnd::new("C:calendar-user-address-set")))?;
|
||||||
)))?;
|
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
let prop_name = if prop.namespace == "http://calendarserver.org/ns/" {
|
let prop_name = if prop.namespace == "http://calendarserver.org/ns/" {
|
||||||
|
|||||||
@@ -356,7 +356,11 @@ mod tests {
|
|||||||
</D:propfind>"#;
|
</D:propfind>"#;
|
||||||
|
|
||||||
let result = WebDavAdapter::parse_propfind(Cursor::new(xml));
|
let result = WebDavAdapter::parse_propfind(Cursor::new(xml));
|
||||||
assert!(result.is_ok(), "Failed to parse PROPFIND: {:?}", result.err());
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"Failed to parse PROPFIND: {:?}",
|
||||||
|
result.err()
|
||||||
|
);
|
||||||
|
|
||||||
let request = result.unwrap();
|
let request = result.unwrap();
|
||||||
match request.prop_find_type {
|
match request.prop_find_type {
|
||||||
@@ -431,7 +435,11 @@ mod tests {
|
|||||||
"/caldav/",
|
"/caldav/",
|
||||||
"testuser",
|
"testuser",
|
||||||
);
|
);
|
||||||
assert!(result.is_ok(), "Failed to generate root propfind: {:?}", result.err());
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"Failed to generate root propfind: {:?}",
|
||||||
|
result.err()
|
||||||
|
);
|
||||||
|
|
||||||
let xml_str = String::from_utf8(output).expect("Invalid UTF-8");
|
let xml_str = String::from_utf8(output).expect("Invalid UTF-8");
|
||||||
|
|
||||||
@@ -511,11 +519,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut output = Vec::new();
|
let mut output = Vec::new();
|
||||||
let result = CalDavAdapter::generate_principal_propfind_response(
|
let result =
|
||||||
&mut output,
|
CalDavAdapter::generate_principal_propfind_response(&mut output, &request, "testuser");
|
||||||
&request,
|
|
||||||
"testuser",
|
|
||||||
);
|
|
||||||
assert!(result.is_ok(), "Failed: {:?}", result.err());
|
assert!(result.is_ok(), "Failed: {:?}", result.err());
|
||||||
|
|
||||||
let xml_str = String::from_utf8(output).expect("Invalid UTF-8");
|
let xml_str = String::from_utf8(output).expect("Invalid UTF-8");
|
||||||
@@ -576,10 +581,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// displayname should be populated
|
// displayname should be populated
|
||||||
assert!(
|
assert!(xml_str.contains("Personal"), "Should contain calendar name");
|
||||||
xml_str.contains("Personal"),
|
|
||||||
"Should contain calendar name"
|
|
||||||
);
|
|
||||||
|
|
||||||
// supported-calendar-component-set should have VEVENT
|
// supported-calendar-component-set should have VEVENT
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -132,9 +132,9 @@ impl AppPasswordService {
|
|||||||
let password_hash = self.hasher.hash_password(&plain_token).await?;
|
let password_hash = self.hasher.hash_password(&plain_token).await?;
|
||||||
|
|
||||||
// Calculate expiration
|
// Calculate expiration
|
||||||
let expires_at = request.expires_in_days.map(|days| {
|
let expires_at = request
|
||||||
Utc::now() + Duration::days(days as i64)
|
.expires_in_days
|
||||||
});
|
.map(|days| Utc::now() + Duration::days(days as i64));
|
||||||
|
|
||||||
// Create entity
|
// Create entity
|
||||||
let app_password = AppPassword::new(
|
let app_password = AppPassword::new(
|
||||||
@@ -148,9 +148,7 @@ impl AppPasswordService {
|
|||||||
|
|
||||||
let saved = self.repo.create(app_password).await?;
|
let saved = self.repo.create(app_password).await?;
|
||||||
|
|
||||||
let expires_str = saved
|
let expires_str = saved.expires_at.map(|dt| dt.to_rfc3339());
|
||||||
.expires_at
|
|
||||||
.map(|dt| dt.to_rfc3339());
|
|
||||||
|
|
||||||
let curl_example = format!(
|
let curl_example = format!(
|
||||||
"curl -u '{}:{}' -X PROPFIND {}/webdav/",
|
"curl -u '{}:{}' -X PROPFIND {}/webdav/",
|
||||||
@@ -225,7 +223,11 @@ impl AppPasswordService {
|
|||||||
/// Also invalidates **all** cached Basic Auth entries for the owning user
|
/// Also invalidates **all** cached Basic Auth entries for the owning user
|
||||||
/// so that the revocation takes effect immediately (instead of waiting
|
/// so that the revocation takes effect immediately (instead of waiting
|
||||||
/// up to `BASIC_AUTH_CACHE_TTL_SECS`).
|
/// up to `BASIC_AUTH_CACHE_TTL_SECS`).
|
||||||
pub async fn revoke(&self, user_id: &str, id: &str) -> Result<AppPasswordRevokeResponseDto, DomainError> {
|
pub async fn revoke(
|
||||||
|
&self,
|
||||||
|
user_id: &str,
|
||||||
|
id: &str,
|
||||||
|
) -> Result<AppPasswordRevokeResponseDto, DomainError> {
|
||||||
let ap = self.repo.get_by_id(id).await?;
|
let ap = self.repo.get_by_id(id).await?;
|
||||||
if ap.user_id != user_id {
|
if ap.user_id != user_id {
|
||||||
return Err(DomainError::unauthorized(
|
return Err(DomainError::unauthorized(
|
||||||
@@ -241,7 +243,11 @@ impl AppPasswordService {
|
|||||||
.invalidate_entries_if(move |_key, val| val.user_id == uid)
|
.invalidate_entries_if(move |_key, val| val.user_id == uid)
|
||||||
.ok();
|
.ok();
|
||||||
|
|
||||||
tracing::debug!("Revoked app password {} — auth cache entries for user {} invalidated", id, user_id);
|
tracing::debug!(
|
||||||
|
"Revoked app password {} — auth cache entries for user {} invalidated",
|
||||||
|
id,
|
||||||
|
user_id
|
||||||
|
);
|
||||||
|
|
||||||
Ok(AppPasswordRevokeResponseDto {
|
Ok(AppPasswordRevokeResponseDto {
|
||||||
status: "revoked".to_string(),
|
status: "revoked".to_string(),
|
||||||
@@ -270,19 +276,12 @@ impl AppPasswordService {
|
|||||||
// ── 1. Compute cache key = blake3("username:password") ────────
|
// ── 1. Compute cache key = blake3("username:password") ────────
|
||||||
// The plain-text password is never stored; only the 32-byte
|
// The plain-text password is never stored; only the 32-byte
|
||||||
// cryptographic digest is used as lookup key.
|
// cryptographic digest is used as lookup key.
|
||||||
let cache_key: [u8; 32] = blake3::hash(
|
let cache_key: [u8; 32] =
|
||||||
format!("{}:{}", username, password).as_bytes(),
|
blake3::hash(format!("{}:{}", username, password).as_bytes()).into();
|
||||||
)
|
|
||||||
.into();
|
|
||||||
|
|
||||||
// ── 2. Cache hit → return immediately ────────────────────────
|
// ── 2. Cache hit → return immediately ────────────────────────
|
||||||
if let Some(cached) = self.auth_cache.get(&cache_key).await {
|
if let Some(cached) = self.auth_cache.get(&cache_key).await {
|
||||||
return Ok((
|
return Ok((cached.user_id, cached.username, cached.email, cached.role));
|
||||||
cached.user_id,
|
|
||||||
cached.username,
|
|
||||||
cached.email,
|
|
||||||
cached.role,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 3. Cache miss → full verification ────────────────────────
|
// ── 3. Cache miss → full verification ────────────────────────
|
||||||
@@ -294,10 +293,7 @@ impl AppPasswordService {
|
|||||||
.map_err(|_| DomainError::unauthorized("Invalid username or app password"))?;
|
.map_err(|_| DomainError::unauthorized("Invalid username or app password"))?;
|
||||||
|
|
||||||
// Get all active app passwords for this user
|
// Get all active app passwords for this user
|
||||||
let app_passwords = self
|
let app_passwords = self.repo.get_active_by_user_id(user.id()).await?;
|
||||||
.repo
|
|
||||||
.get_active_by_user_id(user.id())
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if app_passwords.is_empty() {
|
if app_passwords.is_empty() {
|
||||||
return Err(DomainError::unauthorized(
|
return Err(DomainError::unauthorized(
|
||||||
@@ -307,7 +303,11 @@ impl AppPasswordService {
|
|||||||
|
|
||||||
// Try each app password hash (Argon2id — CPU-intensive)
|
// Try each app password hash (Argon2id — CPU-intensive)
|
||||||
for ap in &app_passwords {
|
for ap in &app_passwords {
|
||||||
if let Ok(true) = self.hasher.verify_password(password, &ap.password_hash).await {
|
if let Ok(true) = self
|
||||||
|
.hasher
|
||||||
|
.verify_password(password, &ap.password_hash)
|
||||||
|
.await
|
||||||
|
{
|
||||||
// Update last_used_at (fire-and-forget; don't fail auth on touch error)
|
// Update last_used_at (fire-and-forget; don't fail auth on touch error)
|
||||||
let _ = self.repo.touch_last_used(&ap.id).await;
|
let _ = self.repo.touch_last_used(&ap.id).await;
|
||||||
|
|
||||||
@@ -321,12 +321,7 @@ impl AppPasswordService {
|
|||||||
// ── 4. Cache the successful result ────────────────────
|
// ── 4. Cache the successful result ────────────────────
|
||||||
self.auth_cache.insert(cache_key, result.clone()).await;
|
self.auth_cache.insert(cache_key, result.clone()).await;
|
||||||
|
|
||||||
return Ok((
|
return Ok((result.user_id, result.username, result.email, result.role));
|
||||||
result.user_id,
|
|
||||||
result.username,
|
|
||||||
result.email,
|
|
||||||
result.role,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,7 +138,9 @@ impl BatchOperationService {
|
|||||||
let target_folder = target_folder.clone();
|
let target_folder = target_folder.clone();
|
||||||
|
|
||||||
async move {
|
async move {
|
||||||
let copy_result = mgmt.copy_file(&file_id, target_folder.map(|s| s.to_string())).await;
|
let copy_result = mgmt
|
||||||
|
.copy_file(&file_id, target_folder.map(|s| s.to_string()))
|
||||||
|
.await;
|
||||||
(file_id, copy_result)
|
(file_id, copy_result)
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
@@ -201,7 +203,9 @@ impl BatchOperationService {
|
|||||||
let target_folder = target_folder.clone();
|
let target_folder = target_folder.clone();
|
||||||
|
|
||||||
async move {
|
async move {
|
||||||
let move_result = mgmt.move_file(&file_id, target_folder.map(|s| s.to_string())).await;
|
let move_result = mgmt
|
||||||
|
.move_file(&file_id, target_folder.map(|s| s.to_string()))
|
||||||
|
.await;
|
||||||
(file_id, move_result)
|
(file_id, move_result)
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
@@ -578,7 +582,9 @@ impl BatchOperationService {
|
|||||||
let caller = caller.clone();
|
let caller = caller.clone();
|
||||||
|
|
||||||
async move {
|
async move {
|
||||||
let dto = MoveFolderDto { parent_id: target.map(|s| s.to_string()) };
|
let dto = MoveFolderDto {
|
||||||
|
parent_id: target.map(|s| s.to_string()),
|
||||||
|
};
|
||||||
let move_result = folder_service.move_folder(&folder_id, dto, &caller).await;
|
let move_result = folder_service.move_folder(&folder_id, dto, &caller).await;
|
||||||
(folder_id, move_result)
|
(folder_id, move_result)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,13 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::application::dtos::device_auth_dto::*;
|
use crate::application::dtos::device_auth_dto::*;
|
||||||
use crate::application::ports::auth_ports::{DeviceCodeStoragePort, TokenServicePort, UserStoragePort};
|
use crate::application::ports::auth_ports::SessionStoragePort;
|
||||||
|
use crate::application::ports::auth_ports::{
|
||||||
|
DeviceCodeStoragePort, TokenServicePort, UserStoragePort,
|
||||||
|
};
|
||||||
use crate::common::errors::{DomainError, ErrorKind};
|
use crate::common::errors::{DomainError, ErrorKind};
|
||||||
use crate::domain::entities::device_code::{DeviceCode, DeviceCodeStatus};
|
use crate::domain::entities::device_code::{DeviceCode, DeviceCodeStatus};
|
||||||
use crate::domain::entities::session::Session;
|
use crate::domain::entities::session::Session;
|
||||||
use crate::application::ports::auth_ports::SessionStoragePort;
|
|
||||||
|
|
||||||
/// Default device code lifetime: 15 minutes (RFC 8628 recommends 5-30 min).
|
/// Default device code lifetime: 15 minutes (RFC 8628 recommends 5-30 min).
|
||||||
const DEVICE_CODE_LIFETIME_SECS: i64 = 900;
|
const DEVICE_CODE_LIFETIME_SECS: i64 = 900;
|
||||||
@@ -149,11 +151,7 @@ impl DeviceAuthService {
|
|||||||
///
|
///
|
||||||
/// * `user_code` — the code from the verification page
|
/// * `user_code` — the code from the verification page
|
||||||
/// * `user_id` — the authenticated user's ID (from session/JWT)
|
/// * `user_id` — the authenticated user's ID (from session/JWT)
|
||||||
pub async fn approve(
|
pub async fn approve(&self, user_code: &str, user_id: &str) -> Result<(), DomainError> {
|
||||||
&self,
|
|
||||||
user_code: &str,
|
|
||||||
user_id: &str,
|
|
||||||
) -> Result<(), DomainError> {
|
|
||||||
let normalized = user_code.trim().to_uppercase().replace(' ', "");
|
let normalized = user_code.trim().to_uppercase().replace(' ', "");
|
||||||
|
|
||||||
let mut dc = self
|
let mut dc = self
|
||||||
@@ -226,10 +224,7 @@ impl DeviceAuthService {
|
|||||||
/// Client polls for tokens. Returns:
|
/// Client polls for tokens. Returns:
|
||||||
/// - `Ok(DeviceTokenSuccessDto)` if authorized
|
/// - `Ok(DeviceTokenSuccessDto)` if authorized
|
||||||
/// - `Err` with specific RFC 8628 error codes for pending/slow_down/expired/denied
|
/// - `Err` with specific RFC 8628 error codes for pending/slow_down/expired/denied
|
||||||
pub async fn poll(
|
pub async fn poll(&self, device_code: &str) -> Result<DeviceTokenSuccessDto, DevicePollError> {
|
||||||
&self,
|
|
||||||
device_code: &str,
|
|
||||||
) -> Result<DeviceTokenSuccessDto, DevicePollError> {
|
|
||||||
let mut dc = self
|
let mut dc = self
|
||||||
.device_code_storage
|
.device_code_storage
|
||||||
.get_by_device_code(device_code)
|
.get_by_device_code(device_code)
|
||||||
@@ -240,7 +235,10 @@ impl DeviceAuthService {
|
|||||||
if dc.is_expired() && dc.status() == DeviceCodeStatus::Pending {
|
if dc.is_expired() && dc.status() == DeviceCodeStatus::Pending {
|
||||||
let mut expired_dc = dc.clone();
|
let mut expired_dc = dc.clone();
|
||||||
expired_dc.mark_expired();
|
expired_dc.mark_expired();
|
||||||
let _ = self.device_code_storage.update_device_code(expired_dc).await;
|
let _ = self
|
||||||
|
.device_code_storage
|
||||||
|
.update_device_code(expired_dc)
|
||||||
|
.await;
|
||||||
return Err(DevicePollError::ExpiredToken);
|
return Err(DevicePollError::ExpiredToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -398,7 +398,11 @@ impl FolderUseCase for FolderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Rename folder — UPDATE RETURNING gives us the updated row directly
|
// Rename folder — UPDATE RETURNING gives us the updated row directly
|
||||||
let folder = self.folder_storage.rename_folder(id, dto.name).await.map_err(|e| {
|
let folder = self
|
||||||
|
.folder_storage
|
||||||
|
.rename_folder(id, dto.name)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
DomainError::internal_error(
|
DomainError::internal_error(
|
||||||
"FolderStorage",
|
"FolderStorage",
|
||||||
format!("Failed to rename folder with ID: {}: {}", id, e),
|
format!("Failed to rename folder with ID: {}: {}", id, e),
|
||||||
@@ -455,7 +459,11 @@ impl FolderUseCase for FolderService {
|
|||||||
|
|
||||||
// Move folder — UPDATE RETURNING gives us the updated row directly
|
// Move folder — UPDATE RETURNING gives us the updated row directly
|
||||||
let parent_ref = dto.parent_id.as_deref();
|
let parent_ref = dto.parent_id.as_deref();
|
||||||
let folder = self.folder_storage.move_folder(id, parent_ref).await.map_err(|e| {
|
let folder = self
|
||||||
|
.folder_storage
|
||||||
|
.move_folder(id, parent_ref)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
DomainError::internal_error(
|
DomainError::internal_error(
|
||||||
"FolderStorage",
|
"FolderStorage",
|
||||||
format!("Failed to move folder with ID: {}: {}", id, e),
|
format!("Failed to move folder with ID: {}: {}", id, e),
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ pub mod app_password_service;
|
|||||||
pub mod auth_application_service;
|
pub mod auth_application_service;
|
||||||
pub mod batch_operations;
|
pub mod batch_operations;
|
||||||
pub mod calendar_service;
|
pub mod calendar_service;
|
||||||
pub mod device_auth_service;
|
|
||||||
pub mod contact_service;
|
pub mod contact_service;
|
||||||
|
pub mod device_auth_service;
|
||||||
pub mod favorites_service;
|
pub mod favorites_service;
|
||||||
pub mod file_management_service;
|
pub mod file_management_service;
|
||||||
pub mod file_retrieval_service;
|
pub mod file_retrieval_service;
|
||||||
|
|||||||
@@ -636,7 +636,8 @@ impl AppConfig {
|
|||||||
{
|
{
|
||||||
config.auth.rate_limit.register_max_requests = val;
|
config.auth.rate_limit.register_max_requests = val;
|
||||||
}
|
}
|
||||||
if let Ok(v) = env::var("OXICLOUD_RATE_LIMIT_REGISTER_WINDOW_SECS").map(|v| v.parse::<u64>())
|
if let Ok(v) =
|
||||||
|
env::var("OXICLOUD_RATE_LIMIT_REGISTER_WINDOW_SECS").map(|v| v.parse::<u64>())
|
||||||
&& let Ok(val) = v
|
&& let Ok(val) = v
|
||||||
{
|
{
|
||||||
config.auth.rate_limit.register_window_secs = val;
|
config.auth.rate_limit.register_window_secs = val;
|
||||||
|
|||||||
+10
-7
@@ -603,10 +603,11 @@ impl AppServiceFactory {
|
|||||||
Arc::new(crate::infrastructure::repositories::UserPgRepository::new(
|
Arc::new(crate::infrastructure::repositories::UserPgRepository::new(
|
||||||
pool.clone(),
|
pool.clone(),
|
||||||
));
|
));
|
||||||
let session_repo: Arc<dyn crate::application::ports::auth_ports::SessionStoragePort> =
|
let session_repo: Arc<
|
||||||
Arc::new(crate::infrastructure::repositories::SessionPgRepository::new(
|
dyn crate::application::ports::auth_ports::SessionStoragePort,
|
||||||
pool.clone(),
|
> = Arc::new(
|
||||||
));
|
crate::infrastructure::repositories::SessionPgRepository::new(pool.clone()),
|
||||||
|
);
|
||||||
let base_url = self.config.base_url();
|
let base_url = self.config.base_url();
|
||||||
|
|
||||||
let device_auth_svc = Arc::new(DeviceAuthService::new(
|
let device_auth_svc = Arc::new(DeviceAuthService::new(
|
||||||
@@ -625,8 +626,9 @@ impl AppServiceFactory {
|
|||||||
use crate::application::services::app_password_service::AppPasswordService;
|
use crate::application::services::app_password_service::AppPasswordService;
|
||||||
use crate::infrastructure::repositories::AppPasswordPgRepository;
|
use crate::infrastructure::repositories::AppPasswordPgRepository;
|
||||||
|
|
||||||
let app_pw_repo: Arc<dyn crate::application::ports::auth_ports::AppPasswordStoragePort> =
|
let app_pw_repo: Arc<
|
||||||
Arc::new(AppPasswordPgRepository::new(pool.clone()));
|
dyn crate::application::ports::auth_ports::AppPasswordStoragePort,
|
||||||
|
> = Arc::new(AppPasswordPgRepository::new(pool.clone()));
|
||||||
let hasher: Arc<dyn crate::application::ports::auth_ports::PasswordHasherPort> =
|
let hasher: Arc<dyn crate::application::ports::auth_ports::PasswordHasherPort> =
|
||||||
Arc::new(
|
Arc::new(
|
||||||
crate::infrastructure::services::password_hasher::Argon2PasswordHasher::new(
|
crate::infrastructure::services::password_hasher::Argon2PasswordHasher::new(
|
||||||
@@ -818,7 +820,8 @@ pub struct ApplicationServices {
|
|||||||
pub struct AuthServices {
|
pub struct AuthServices {
|
||||||
pub token_service: Arc<dyn crate::application::ports::auth_ports::TokenServicePort>,
|
pub token_service: Arc<dyn crate::application::ports::auth_ports::TokenServicePort>,
|
||||||
pub auth_application_service: Arc<AuthApplicationService>,
|
pub auth_application_service: Arc<AuthApplicationService>,
|
||||||
pub login_lockout: Arc<crate::infrastructure::services::login_lockout_service::LoginLockoutService>,
|
pub login_lockout:
|
||||||
|
Arc<crate::infrastructure::services::login_lockout_service::LoginLockoutService>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Global application state for dependency injection
|
/// Global application state for dependency injection
|
||||||
|
|||||||
@@ -242,12 +242,7 @@ impl DeviceCode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Authorize this device code for a specific user, storing the tokens.
|
/// Authorize this device code for a specific user, storing the tokens.
|
||||||
pub fn authorize(
|
pub fn authorize(&mut self, user_id: String, access_token: String, refresh_token: String) {
|
||||||
&mut self,
|
|
||||||
user_id: String,
|
|
||||||
access_token: String,
|
|
||||||
refresh_token: String,
|
|
||||||
) {
|
|
||||||
self.status = DeviceCodeStatus::Authorized;
|
self.status = DeviceCodeStatus::Authorized;
|
||||||
self.user_id = Some(user_id);
|
self.user_id = Some(user_id);
|
||||||
self.access_token = Some(access_token);
|
self.access_token = Some(access_token);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ pub mod pg;
|
|||||||
|
|
||||||
// Re-exportar para facilitar acceso
|
// Re-exportar para facilitar acceso
|
||||||
pub use pg::{
|
pub use pg::{
|
||||||
AppPasswordPgRepository, DeviceCodePgRepository, FileBlobReadRepository, FileBlobWriteRepository,
|
AppPasswordPgRepository, DeviceCodePgRepository, FileBlobReadRepository,
|
||||||
FolderDbRepository, SessionPgRepository, TrashDbRepository, UserPgRepository,
|
FileBlobWriteRepository, FolderDbRepository, SessionPgRepository, TrashDbRepository,
|
||||||
|
UserPgRepository,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -86,10 +86,7 @@ impl AppPasswordStoragePort for AppPasswordPgRepository {
|
|||||||
Ok(row.into())
|
Ok(row.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_active_by_user_id(
|
async fn get_active_by_user_id(&self, user_id: &str) -> Result<Vec<AppPassword>, DomainError> {
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
) -> Result<Vec<AppPassword>, DomainError> {
|
|
||||||
let rows = sqlx::query_as::<_, AppPasswordRow>(
|
let rows = sqlx::query_as::<_, AppPasswordRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, user_id, label, password_hash, prefix, scopes,
|
SELECT id, user_id, label, password_hash, prefix, scopes,
|
||||||
@@ -103,9 +100,7 @@ impl AppPasswordStoragePort for AppPasswordPgRepository {
|
|||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_all(self.pool())
|
.fetch_all(self.pool())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| DomainError::internal_error("AppPasswordPg", format!("get_active: {e}")))?;
|
||||||
DomainError::internal_error("AppPasswordPg", format!("get_active: {e}"))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(rows.into_iter().map(|r| r.into()).collect())
|
Ok(rows.into_iter().map(|r| r.into()).collect())
|
||||||
}
|
}
|
||||||
@@ -115,21 +110,16 @@ impl AppPasswordStoragePort for AppPasswordPgRepository {
|
|||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(self.pool())
|
.execute(self.pool())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| DomainError::internal_error("AppPasswordPg", format!("touch: {e}")))?;
|
||||||
DomainError::internal_error("AppPasswordPg", format!("touch: {e}"))
|
|
||||||
})?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn revoke(&self, id: &str) -> Result<(), DomainError> {
|
async fn revoke(&self, id: &str) -> Result<(), DomainError> {
|
||||||
let result =
|
let result = sqlx::query("UPDATE auth.app_passwords SET active = FALSE WHERE id = $1")
|
||||||
sqlx::query("UPDATE auth.app_passwords SET active = FALSE WHERE id = $1")
|
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(self.pool())
|
.execute(self.pool())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| DomainError::internal_error("AppPasswordPg", format!("revoke: {e}")))?;
|
||||||
DomainError::internal_error("AppPasswordPg", format!("revoke: {e}"))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if result.rows_affected() == 0 {
|
if result.rows_affected() == 0 {
|
||||||
return Err(DomainError::not_found("AppPassword", id));
|
return Err(DomainError::not_found("AppPassword", id));
|
||||||
|
|||||||
@@ -114,11 +114,9 @@ impl DeviceCodeStoragePort for DeviceCodePgRepository {
|
|||||||
.fetch_one(self.pool.as_ref())
|
.fetch_one(self.pool.as_ref())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| match e {
|
.map_err(|e| match e {
|
||||||
sqlx::Error::RowNotFound => DomainError::new(
|
sqlx::Error::RowNotFound => {
|
||||||
ErrorKind::NotFound,
|
DomainError::new(ErrorKind::NotFound, "DeviceCode", "Device code not found")
|
||||||
"DeviceCode",
|
}
|
||||||
"Device code not found",
|
|
||||||
),
|
|
||||||
_ => DomainError::new(
|
_ => DomainError::new(
|
||||||
ErrorKind::DatabaseError,
|
ErrorKind::DatabaseError,
|
||||||
"DeviceCode",
|
"DeviceCode",
|
||||||
|
|||||||
@@ -268,8 +268,16 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
limit: usize,
|
limit: usize,
|
||||||
include_total: bool,
|
include_total: bool,
|
||||||
) -> Result<(Vec<Folder>, Option<usize>), DomainError> {
|
) -> Result<(Vec<Folder>, Option<usize>), DomainError> {
|
||||||
let rows: Vec<(String, String, String, Option<String>, String, i64, i64, i64)> =
|
let rows: Vec<(
|
||||||
if let Some(pid) = parent_id {
|
String,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
Option<String>,
|
||||||
|
String,
|
||||||
|
i64,
|
||||||
|
i64,
|
||||||
|
i64,
|
||||||
|
)> = if let Some(pid) = parent_id {
|
||||||
sqlx::query_as(
|
sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT id::text, name, path, parent_id::text, user_id,
|
SELECT id::text, name, path, parent_id::text, user_id,
|
||||||
@@ -333,8 +341,16 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
limit: usize,
|
limit: usize,
|
||||||
include_total: bool,
|
include_total: bool,
|
||||||
) -> Result<(Vec<Folder>, Option<usize>), DomainError> {
|
) -> Result<(Vec<Folder>, Option<usize>), DomainError> {
|
||||||
let rows: Vec<(String, String, String, Option<String>, String, i64, i64, i64)> =
|
let rows: Vec<(
|
||||||
if let Some(pid) = parent_id {
|
String,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
Option<String>,
|
||||||
|
String,
|
||||||
|
i64,
|
||||||
|
i64,
|
||||||
|
i64,
|
||||||
|
)> = if let Some(pid) = parent_id {
|
||||||
sqlx::query_as(
|
sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT id::text, name, path, parent_id::text, user_id,
|
SELECT id::text, name, path, parent_id::text, user_id,
|
||||||
@@ -372,9 +388,7 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
.fetch_all(self.pool())
|
.fetch_all(self.pool())
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
.map_err(|e| {
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("paginate_by_owner: {e}")))?;
|
||||||
DomainError::internal_error("FolderDb", format!("paginate_by_owner: {e}"))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let total = if include_total {
|
let total = if include_total {
|
||||||
Some(rows.first().map_or(0, |r| r.7) as usize)
|
Some(rows.first().map_or(0, |r| r.7) as usize)
|
||||||
@@ -867,10 +881,7 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
user_id: &str,
|
user_id: &str,
|
||||||
) -> Result<Vec<Folder>, DomainError> {
|
) -> Result<Vec<Folder>, DomainError> {
|
||||||
let (where_extra, name_pattern) = match name_contains {
|
let (where_extra, name_pattern) = match name_contains {
|
||||||
Some(name) if name.len() >= 3 => (
|
Some(name) if name.len() >= 3 => (" AND fo.name ILIKE $3", Some(format!("%{}%", name))),
|
||||||
" AND fo.name ILIKE $3",
|
|
||||||
Some(format!("%{}%", name)),
|
|
||||||
),
|
|
||||||
_ => ("", None),
|
_ => ("", None),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -335,7 +335,9 @@ mod tests {
|
|||||||
let claims1 = service.validate_token(&token).expect("Should validate");
|
let claims1 = service.validate_token(&token).expect("Should validate");
|
||||||
|
|
||||||
// Second call: cache hit — skips HMAC, returns cloned claims
|
// Second call: cache hit — skips HMAC, returns cloned claims
|
||||||
let claims2 = service.validate_token(&token).expect("Should validate from cache");
|
let claims2 = service
|
||||||
|
.validate_token(&token)
|
||||||
|
.expect("Should validate from cache");
|
||||||
|
|
||||||
assert_eq!(claims1.sub, claims2.sub);
|
assert_eq!(claims1.sub, claims2.sub);
|
||||||
assert_eq!(claims1.username, claims2.username);
|
assert_eq!(claims1.username, claims2.username);
|
||||||
|
|||||||
@@ -73,12 +73,9 @@ impl LoginLockoutService {
|
|||||||
/// Record a failed login attempt. Returns the new failure count.
|
/// Record a failed login attempt. Returns the new failure count.
|
||||||
pub fn record_failure(&self, username: &str) -> u32 {
|
pub fn record_failure(&self, username: &str) -> u32 {
|
||||||
let key = username.to_lowercase();
|
let key = username.to_lowercase();
|
||||||
let new_count = self
|
let new_count = self.cache.get(&key).map(|r| r.count + 1).unwrap_or(1);
|
||||||
.cache
|
self.cache
|
||||||
.get(&key)
|
.insert(key.clone(), FailureRecord { count: new_count });
|
||||||
.map(|r| r.count + 1)
|
|
||||||
.unwrap_or(1);
|
|
||||||
self.cache.insert(key.clone(), FailureRecord { count: new_count });
|
|
||||||
|
|
||||||
if new_count >= self.max_failures {
|
if new_count >= self.max_failures {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
pub mod chunked_upload_service;
|
pub mod chunked_upload_service;
|
||||||
pub mod compression_service;
|
pub mod compression_service;
|
||||||
pub mod dedup_service;
|
pub mod dedup_service;
|
||||||
pub mod login_lockout_service;
|
|
||||||
pub mod file_content_cache;
|
pub mod file_content_cache;
|
||||||
pub mod file_system_i18n_service;
|
pub mod file_system_i18n_service;
|
||||||
pub mod image_transcode_service;
|
pub mod image_transcode_service;
|
||||||
pub mod jwt_service;
|
pub mod jwt_service;
|
||||||
|
pub mod login_lockout_service;
|
||||||
pub mod oidc_service;
|
pub mod oidc_service;
|
||||||
pub mod password_hasher;
|
pub mod password_hasher;
|
||||||
pub mod path_resolver_service;
|
pub mod path_resolver_service;
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ impl PathResolverService {
|
|||||||
// Single round-trip: folder branch ∪ file branch, LIMIT 1.
|
// Single round-trip: folder branch ∪ file branch, LIMIT 1.
|
||||||
// Column order: resource_type, id, name, path, parent_id, user_id,
|
// Column order: resource_type, id, name, path, parent_id, user_id,
|
||||||
// created_at, modified_at, size, mime_type, folder_id
|
// created_at, modified_at, size, mime_type, folder_id
|
||||||
let row = sqlx::query_as::<_, (
|
let row = sqlx::query_as::<
|
||||||
|
_,
|
||||||
|
(
|
||||||
String, // resource_type
|
String, // resource_type
|
||||||
String, // id
|
String, // id
|
||||||
String, // name
|
String, // name
|
||||||
@@ -66,7 +68,8 @@ impl PathResolverService {
|
|||||||
Option<i64>, // size (NULL for folder)
|
Option<i64>, // size (NULL for folder)
|
||||||
Option<String>, // mime_type (NULL for folder)
|
Option<String>, // mime_type (NULL for folder)
|
||||||
Option<String>, // folder_id (NULL for folder)
|
Option<String>, // folder_id (NULL for folder)
|
||||||
)>(
|
),
|
||||||
|
>(
|
||||||
r#"
|
r#"
|
||||||
SELECT resource_type, id, name, path, parent_id, user_id,
|
SELECT resource_type, id, name, path, parent_id, user_id,
|
||||||
created_at, modified_at, size, mime_type, folder_id
|
created_at, modified_at, size, mime_type, folder_id
|
||||||
@@ -122,8 +125,19 @@ impl PathResolverService {
|
|||||||
.map_err(|e| DomainError::internal_error("PathResolver", format!("resolve: {e}")))?
|
.map_err(|e| DomainError::internal_error("PathResolver", format!("resolve: {e}")))?
|
||||||
.ok_or_else(|| DomainError::not_found("Resource", path))?;
|
.ok_or_else(|| DomainError::not_found("Resource", path))?;
|
||||||
|
|
||||||
let (resource_type, id, name, res_path, parent_id, user_id,
|
let (
|
||||||
created_at, modified_at, size, mime_type, folder_id) = row;
|
resource_type,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
res_path,
|
||||||
|
parent_id,
|
||||||
|
user_id,
|
||||||
|
created_at,
|
||||||
|
modified_at,
|
||||||
|
size,
|
||||||
|
mime_type,
|
||||||
|
folder_id,
|
||||||
|
) = row;
|
||||||
|
|
||||||
match resource_type.as_str() {
|
match resource_type.as_str() {
|
||||||
"folder" => Ok(ResolvedResource::Folder(FolderDto {
|
"folder" => Ok(ResolvedResource::Folder(FolderDto {
|
||||||
|
|||||||
@@ -41,9 +41,7 @@ fn cookie_secure() -> bool {
|
|||||||
/// Build a `Set-Cookie` header value.
|
/// Build a `Set-Cookie` header value.
|
||||||
fn build_cookie(name: &str, value: &str, path: &str, max_age_secs: i64) -> String {
|
fn build_cookie(name: &str, value: &str, path: &str, max_age_secs: i64) -> String {
|
||||||
let secure = if cookie_secure() { "; Secure" } else { "" };
|
let secure = if cookie_secure() { "; Secure" } else { "" };
|
||||||
format!(
|
format!("{name}={value}; HttpOnly; SameSite=Lax; Path={path}; Max-Age={max_age_secs}{secure}",)
|
||||||
"{name}={value}; HttpOnly; SameSite=Lax; Path={path}; Max-Age={max_age_secs}{secure}",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Append `Set-Cookie` headers for both access and refresh tokens.
|
/// Append `Set-Cookie` headers for both access and refresh tokens.
|
||||||
@@ -83,9 +81,7 @@ pub fn append_auth_cookies(
|
|||||||
pub fn append_clear_cookies(headers: &mut HeaderMap) {
|
pub fn append_clear_cookies(headers: &mut HeaderMap) {
|
||||||
for (name, path) in [(ACCESS_COOKIE, "/"), (REFRESH_COOKIE, "/api/auth")] {
|
for (name, path) in [(ACCESS_COOKIE, "/"), (REFRESH_COOKIE, "/api/auth")] {
|
||||||
let secure = if cookie_secure() { "; Secure" } else { "" };
|
let secure = if cookie_secure() { "; Secure" } else { "" };
|
||||||
let val = format!(
|
let val = format!("{name}=; HttpOnly; SameSite=Lax; Path={path}; Max-Age=0{secure}",);
|
||||||
"{name}=; HttpOnly; SameSite=Lax; Path={path}; Max-Age=0{secure}",
|
|
||||||
);
|
|
||||||
if let Ok(hv) = HeaderValue::from_str(&val) {
|
if let Ok(hv) = HeaderValue::from_str(&val) {
|
||||||
headers.append(SET_COOKIE, hv);
|
headers.append(SET_COOKIE, hv);
|
||||||
}
|
}
|
||||||
@@ -122,9 +118,7 @@ pub fn generate_csrf_token() -> String {
|
|||||||
/// via `document.cookie` and echo it back in the `X-CSRF-Token` header.
|
/// via `document.cookie` and echo it back in the `X-CSRF-Token` header.
|
||||||
fn build_csrf_cookie(value: &str, max_age_secs: i64) -> String {
|
fn build_csrf_cookie(value: &str, max_age_secs: i64) -> String {
|
||||||
let secure = if cookie_secure() { "; Secure" } else { "" };
|
let secure = if cookie_secure() { "; Secure" } else { "" };
|
||||||
format!(
|
format!("{CSRF_COOKIE}={value}; SameSite=Lax; Path=/; Max-Age={max_age_secs}{secure}",)
|
||||||
"{CSRF_COOKIE}={value}; SameSite=Lax; Path=/; Max-Age={max_age_secs}{secure}",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Append a CSRF double-submit cookie alongside the auth cookies.
|
/// Append a CSRF double-submit cookie alongside the auth cookies.
|
||||||
@@ -139,9 +133,7 @@ pub fn append_csrf_cookie(headers: &mut HeaderMap, access_expiry_secs: i64) {
|
|||||||
/// Clear the CSRF cookie (on logout).
|
/// Clear the CSRF cookie (on logout).
|
||||||
pub fn append_clear_csrf_cookie(headers: &mut HeaderMap) {
|
pub fn append_clear_csrf_cookie(headers: &mut HeaderMap) {
|
||||||
let secure = if cookie_secure() { "; Secure" } else { "" };
|
let secure = if cookie_secure() { "; Secure" } else { "" };
|
||||||
let val = format!(
|
let val = format!("{CSRF_COOKIE}=; SameSite=Lax; Path=/; Max-Age=0{secure}",);
|
||||||
"{CSRF_COOKIE}=; SameSite=Lax; Path=/; Max-Age=0{secure}",
|
|
||||||
);
|
|
||||||
if let Ok(hv) = HeaderValue::from_str(&val) {
|
if let Ok(hv) = HeaderValue::from_str(&val) {
|
||||||
headers.append(SET_COOKIE, hv);
|
headers.append(SET_COOKIE, hv);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,10 +197,7 @@ async fn login(
|
|||||||
auth_response.expires_in,
|
auth_response.expires_in,
|
||||||
state.core.config.auth.refresh_token_expiry_secs,
|
state.core.config.auth.refresh_token_expiry_secs,
|
||||||
);
|
);
|
||||||
cookie_auth::append_csrf_cookie(
|
cookie_auth::append_csrf_cookie(response.headers_mut(), auth_response.expires_in);
|
||||||
response.headers_mut(),
|
|
||||||
auth_response.expires_in,
|
|
||||||
);
|
|
||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -253,10 +250,7 @@ async fn refresh_token(
|
|||||||
auth_response.expires_in,
|
auth_response.expires_in,
|
||||||
state.core.config.auth.refresh_token_expiry_secs,
|
state.core.config.auth.refresh_token_expiry_secs,
|
||||||
);
|
);
|
||||||
cookie_auth::append_csrf_cookie(
|
cookie_auth::append_csrf_cookie(response.headers_mut(), auth_response.expires_in);
|
||||||
response.headers_mut(),
|
|
||||||
auth_response.expires_in,
|
|
||||||
);
|
|
||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -522,9 +516,6 @@ async fn oidc_exchange(
|
|||||||
auth_response.expires_in,
|
auth_response.expires_in,
|
||||||
state.core.config.auth.refresh_token_expiry_secs,
|
state.core.config.auth.refresh_token_expiry_secs,
|
||||||
);
|
);
|
||||||
cookie_auth::append_csrf_cookie(
|
cookie_auth::append_csrf_cookie(response.headers_mut(), auth_response.expires_in);
|
||||||
response.headers_mut(),
|
|
||||||
auth_response.expires_in,
|
|
||||||
);
|
|
||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,8 +52,7 @@ pub fn caldav_routes() -> Router<Arc<AppState>> {
|
|||||||
/// Creates RFC 6764 well-known discovery routes.
|
/// Creates RFC 6764 well-known discovery routes.
|
||||||
/// These are public (no auth) and simply redirect to the CalDAV root.
|
/// These are public (no auth) and simply redirect to the CalDAV root.
|
||||||
pub fn well_known_routes() -> Router<Arc<AppState>> {
|
pub fn well_known_routes() -> Router<Arc<AppState>> {
|
||||||
Router::new()
|
Router::new().route(
|
||||||
.route(
|
|
||||||
"/.well-known/caldav",
|
"/.well-known/caldav",
|
||||||
axum::routing::any(handle_well_known_caldav),
|
axum::routing::any(handle_well_known_caldav),
|
||||||
)
|
)
|
||||||
@@ -114,13 +113,9 @@ fn extract_caldav_path(uri_path: &str) -> String {
|
|||||||
} else if uri_path.ends_with("/caldav") {
|
} else if uri_path.ends_with("/caldav") {
|
||||||
""
|
""
|
||||||
} else {
|
} else {
|
||||||
uri_path
|
uri_path.trim_start_matches('/').trim_end_matches('/')
|
||||||
.trim_start_matches('/')
|
|
||||||
.trim_end_matches('/')
|
|
||||||
};
|
};
|
||||||
percent_decode_str(encoded)
|
percent_decode_str(encoded).decode_utf8_lossy().into_owned()
|
||||||
.decode_utf8_lossy()
|
|
||||||
.into_owned()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helper: extract user from request ───────────────────────────────
|
// ─── Helper: extract user from request ───────────────────────────────
|
||||||
@@ -198,9 +193,7 @@ async fn handle_propfind(
|
|||||||
calendar_service
|
calendar_service
|
||||||
.list_my_calendars(&user.id)
|
.list_my_calendars(&user.id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| AppError::internal_error(format!("Failed to list calendars: {}", e)))?
|
||||||
AppError::internal_error(format!("Failed to list calendars: {}", e))
|
|
||||||
})?
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let base_href = "/caldav/";
|
let base_href = "/caldav/";
|
||||||
@@ -221,9 +214,7 @@ async fn handle_propfind(
|
|||||||
.unwrap())
|
.unwrap())
|
||||||
} else if path.starts_with("principals/") || path == "principals" {
|
} else if path.starts_with("principals/") || path == "principals" {
|
||||||
// Principal resource — return user principal properties
|
// Principal resource — return user principal properties
|
||||||
let username = path
|
let username = path.strip_prefix("principals/").unwrap_or(&user.username);
|
||||||
.strip_prefix("principals/")
|
|
||||||
.unwrap_or(&user.username);
|
|
||||||
let username = if username.is_empty() {
|
let username = if username.is_empty() {
|
||||||
&user.username
|
&user.username
|
||||||
} else {
|
} else {
|
||||||
@@ -255,9 +246,7 @@ async fn handle_propfind(
|
|||||||
|
|
||||||
if parts.len() == 1 {
|
if parts.len() == 1 {
|
||||||
// Single path segment: try as calendar ID first, fall back to user home
|
// Single path segment: try as calendar ID first, fall back to user home
|
||||||
let calendar_result = calendar_service
|
let calendar_result = calendar_service.get_calendar(first_segment, &user.id).await;
|
||||||
.get_calendar(first_segment, &user.id)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if let Ok(calendar) = calendar_result {
|
if let Ok(calendar) = calendar_result {
|
||||||
// Valid calendar ID — return calendar collection
|
// Valid calendar ID — return calendar collection
|
||||||
@@ -281,9 +270,7 @@ async fn handle_propfind(
|
|||||||
base_href,
|
base_href,
|
||||||
&depth,
|
&depth,
|
||||||
)
|
)
|
||||||
.map_err(|e| {
|
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
|
||||||
AppError::internal_error(format!("Failed to generate XML: {}", e))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(Response::builder()
|
Ok(Response::builder()
|
||||||
.status(StatusCode::MULTI_STATUS)
|
.status(StatusCode::MULTI_STATUS)
|
||||||
@@ -293,7 +280,8 @@ async fn handle_propfind(
|
|||||||
} else {
|
} else {
|
||||||
// Not a calendar ID — treat as user calendar home (e.g. /caldav/{username}/)
|
// Not a calendar ID — treat as user calendar home (e.g. /caldav/{username}/)
|
||||||
// List all calendars for this user
|
// List all calendars for this user
|
||||||
let calendars = calendar_service
|
let calendars =
|
||||||
|
calendar_service
|
||||||
.list_my_calendars(&user.id)
|
.list_my_calendars(&user.id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -309,9 +297,7 @@ async fn handle_propfind(
|
|||||||
&propfind_request,
|
&propfind_request,
|
||||||
base_href,
|
base_href,
|
||||||
)
|
)
|
||||||
.map_err(|e| {
|
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
|
||||||
AppError::internal_error(format!("Failed to generate XML: {}", e))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(Response::builder()
|
Ok(Response::builder()
|
||||||
.status(StatusCode::MULTI_STATUS)
|
.status(StatusCode::MULTI_STATUS)
|
||||||
@@ -324,9 +310,7 @@ async fn handle_propfind(
|
|||||||
let rest = parts[1];
|
let rest = parts[1];
|
||||||
|
|
||||||
// Check if first_segment is a valid calendar ID
|
// Check if first_segment is a valid calendar ID
|
||||||
let calendar_result = calendar_service
|
let calendar_result = calendar_service.get_calendar(first_segment, &user.id).await;
|
||||||
.get_calendar(first_segment, &user.id)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let (calendar_id, event_path) = if calendar_result.is_ok() {
|
let (calendar_id, event_path) = if calendar_result.is_ok() {
|
||||||
// first_segment is a calendar ID, rest is event path
|
// first_segment is a calendar ID, rest is event path
|
||||||
@@ -341,9 +325,7 @@ async fn handle_propfind(
|
|||||||
let cal = calendar_service
|
let cal = calendar_service
|
||||||
.get_calendar(sub_parts[0], &user.id)
|
.get_calendar(sub_parts[0], &user.id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| AppError::not_found(format!("Calendar not found: {}", e)))?;
|
||||||
AppError::not_found(format!("Calendar not found: {}", e))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let events = if depth != "0" {
|
let events = if depth != "0" {
|
||||||
calendar_service
|
calendar_service
|
||||||
@@ -354,8 +336,7 @@ async fn handle_propfind(
|
|||||||
vec![]
|
vec![]
|
||||||
};
|
};
|
||||||
|
|
||||||
let base_href =
|
let base_href = &format!("/caldav/{}/{}/", first_segment, sub_parts[0]);
|
||||||
&format!("/caldav/{}/{}/", first_segment, sub_parts[0]);
|
|
||||||
let mut response_body = Vec::new();
|
let mut response_body = Vec::new();
|
||||||
|
|
||||||
CalDavAdapter::generate_calendar_collection_propfind(
|
CalDavAdapter::generate_calendar_collection_propfind(
|
||||||
@@ -387,16 +368,12 @@ async fn handle_propfind(
|
|||||||
let events = calendar_service
|
let events = calendar_service
|
||||||
.list_events(calendar_id, None, None, &user.id)
|
.list_events(calendar_id, None, None, &user.id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?;
|
||||||
AppError::internal_error(format!("Failed to list events: {}", e))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let event = events
|
let event = events
|
||||||
.iter()
|
.iter()
|
||||||
.find(|e| e.ical_uid == ical_uid)
|
.find(|e| e.ical_uid == ical_uid)
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| AppError::not_found(format!("Event not found: {}", ical_uid)))?;
|
||||||
AppError::not_found(format!("Event not found: {}", ical_uid))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let base_href = &format!("/caldav/{}/", calendar_id);
|
let base_href = &format!("/caldav/{}/", calendar_id);
|
||||||
let report_type = CalDavReportType::CalendarMultiget {
|
let report_type = CalDavReportType::CalendarMultiget {
|
||||||
@@ -411,9 +388,7 @@ async fn handle_propfind(
|
|||||||
&report_type,
|
&report_type,
|
||||||
base_href,
|
base_href,
|
||||||
)
|
)
|
||||||
.map_err(|e| {
|
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
|
||||||
AppError::internal_error(format!("Failed to generate XML: {}", e))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(Response::builder()
|
Ok(Response::builder()
|
||||||
.status(StatusCode::MULTI_STATUS)
|
.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.
|
/// 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!(
|
let _ = write!(
|
||||||
buf,
|
buf,
|
||||||
"BEGIN:VEVENT\r\nUID:{}\r\nSUMMARY:{}\r\nDTSTART:{}\r\nDTEND:{}\r\n",
|
"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 if uri_path.ends_with("/carddav") {
|
||||||
""
|
""
|
||||||
} else {
|
} else {
|
||||||
uri_path
|
uri_path.trim_start_matches('/').trim_end_matches('/')
|
||||||
.trim_start_matches('/')
|
|
||||||
.trim_end_matches('/')
|
|
||||||
};
|
};
|
||||||
percent_encoding::percent_decode_str(encoded)
|
percent_encoding::percent_decode_str(encoded)
|
||||||
.decode_utf8_lossy()
|
.decode_utf8_lossy()
|
||||||
|
|||||||
@@ -92,8 +92,8 @@ async fn device_token(
|
|||||||
match device_service.poll(&body.device_code).await {
|
match device_service.poll(&body.device_code).await {
|
||||||
Ok(tokens) => Ok((StatusCode::OK, Json(tokens)).into_response()),
|
Ok(tokens) => Ok((StatusCode::OK, Json(tokens)).into_response()),
|
||||||
Err(poll_err) => {
|
Err(poll_err) => {
|
||||||
let status = StatusCode::from_u16(poll_err.http_status())
|
let status =
|
||||||
.unwrap_or(StatusCode::BAD_REQUEST);
|
StatusCode::from_u16(poll_err.http_status()).unwrap_or(StatusCode::BAD_REQUEST);
|
||||||
let error_body = serde_json::json!({
|
let error_body = serde_json::json!({
|
||||||
"error": poll_err.error_code(),
|
"error": poll_err.error_code(),
|
||||||
"error_description": poll_err.description()
|
"error_description": poll_err.description()
|
||||||
@@ -166,9 +166,7 @@ async fn device_verify_action(
|
|||||||
Json(serde_json::json!({ "status": "denied" })),
|
Json(serde_json::json!({ "status": "denied" })),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
_ => Err(AppError::bad_request(
|
_ => Err(AppError::bad_request("action must be 'approve' or 'deny'")),
|
||||||
"action must be 'approve' or 'deny'",
|
|
||||||
)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ pub mod admin_handler;
|
|||||||
pub mod app_password_handler;
|
pub mod app_password_handler;
|
||||||
pub mod auth_handler;
|
pub mod auth_handler;
|
||||||
pub mod batch_handler;
|
pub mod batch_handler;
|
||||||
pub mod device_auth_handler;
|
|
||||||
pub mod caldav_handler;
|
pub mod caldav_handler;
|
||||||
pub mod carddav_handler;
|
pub mod carddav_handler;
|
||||||
pub mod chunked_upload_handler;
|
pub mod chunked_upload_handler;
|
||||||
pub mod dedup_handler;
|
pub mod dedup_handler;
|
||||||
|
pub mod device_auth_handler;
|
||||||
pub mod favorites_handler;
|
pub mod favorites_handler;
|
||||||
pub mod file_handler;
|
pub mod file_handler;
|
||||||
pub mod folder_handler;
|
pub mod folder_handler;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ use crate::common::di::AppState;
|
|||||||
use crate::infrastructure::services::path_resolver_service::ResolvedResource;
|
use crate::infrastructure::services::path_resolver_service::ResolvedResource;
|
||||||
use crate::interfaces::errors::AppError;
|
use crate::interfaces::errors::AppError;
|
||||||
use crate::interfaces::middleware::auth::CurrentUser;
|
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;
|
use std::sync::Arc;
|
||||||
|
|
||||||
/// Characters that MUST NOT be percent-encoded inside a URI path segment.
|
/// 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('/')
|
trimmed.trim_end_matches('/')
|
||||||
};
|
};
|
||||||
// Decode percent-encoded characters (e.g. %20 → space)
|
// Decode percent-encoded characters (e.g. %20 → space)
|
||||||
percent_decode_str(encoded)
|
percent_decode_str(encoded).decode_utf8_lossy().into_owned()
|
||||||
.decode_utf8_lossy()
|
|
||||||
.into_owned()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_webdav_methods_root(
|
async fn handle_webdav_methods_root(
|
||||||
@@ -324,7 +322,12 @@ async fn handle_propfind(
|
|||||||
let mut xml_writer = Writer::new(&mut buf);
|
let mut xml_writer = Writer::new(&mut buf);
|
||||||
WebDavAdapter::write_multistatus_start(&mut xml_writer)
|
WebDavAdapter::write_multistatus_start(&mut xml_writer)
|
||||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||||
WebDavAdapter::write_file_entry(&mut xml_writer, &file, &propfind_request, &base_href)
|
WebDavAdapter::write_file_entry(
|
||||||
|
&mut xml_writer,
|
||||||
|
&file,
|
||||||
|
&propfind_request,
|
||||||
|
&base_href,
|
||||||
|
)
|
||||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||||
WebDavAdapter::write_multistatus_end(&mut xml_writer)
|
WebDavAdapter::write_multistatus_end(&mut xml_writer)
|
||||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||||
@@ -358,7 +361,12 @@ async fn handle_propfind(
|
|||||||
let mut xml_writer = Writer::new(&mut buf);
|
let mut xml_writer = Writer::new(&mut buf);
|
||||||
WebDavAdapter::write_multistatus_start(&mut xml_writer)
|
WebDavAdapter::write_multistatus_start(&mut xml_writer)
|
||||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||||
WebDavAdapter::write_file_entry(&mut xml_writer, &file, &propfind_request, &base_href)
|
WebDavAdapter::write_file_entry(
|
||||||
|
&mut xml_writer,
|
||||||
|
&file,
|
||||||
|
&propfind_request,
|
||||||
|
&base_href,
|
||||||
|
)
|
||||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||||
WebDavAdapter::write_multistatus_end(&mut xml_writer)
|
WebDavAdapter::write_multistatus_end(&mut xml_writer)
|
||||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||||
@@ -910,13 +918,17 @@ async fn handle_delete(
|
|||||||
folder_service
|
folder_service
|
||||||
.delete_folder(&folder.id, caller_id)
|
.delete_folder(&folder.id, caller_id)
|
||||||
.await
|
.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)) => {
|
Ok(ResolvedResource::File(file)) => {
|
||||||
file_management_service
|
file_management_service
|
||||||
.delete_file(&file.id)
|
.delete_file(&file.id)
|
||||||
.await
|
.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))),
|
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 {
|
let dest_exists = if let Some(resolver) = &state.path_resolver {
|
||||||
resolver.exists(&destination_path).await.unwrap_or(false)
|
resolver.exists(&destination_path).await.unwrap_or(false)
|
||||||
} else {
|
} else {
|
||||||
folder_service.get_folder_by_path(&destination_path).await.is_ok()
|
folder_service
|
||||||
|| file_retrieval_service.get_file_by_path(&destination_path).await.is_ok()
|
.get_folder_by_path(&destination_path)
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
|| file_retrieval_service
|
||||||
|
.get_file_by_path(&destination_path)
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
};
|
};
|
||||||
if dest_exists {
|
if dest_exists {
|
||||||
return Err(AppError::precondition_failed(
|
return Err(AppError::precondition_failed(
|
||||||
@@ -1044,7 +1062,9 @@ async fn handle_move(
|
|||||||
folder.owner_id.as_deref().unwrap_or("webdav"),
|
folder.owner_id.as_deref().unwrap_or("webdav"),
|
||||||
)
|
)
|
||||||
.await
|
.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 {
|
if folder.name != dest_folder_name {
|
||||||
let rename_dto = crate::application::dtos::folder_dto::RenameFolderDto {
|
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"),
|
folder.owner_id.as_deref().unwrap_or("webdav"),
|
||||||
)
|
)
|
||||||
.await
|
.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)) => {
|
Ok(ResolvedResource::File(file)) => {
|
||||||
@@ -1080,16 +1102,25 @@ async fn handle_move(
|
|||||||
file_management_service
|
file_management_service
|
||||||
.move_file(&file.id, Some(dest_parent_path.to_string()))
|
.move_file(&file.id, Some(dest_parent_path.to_string()))
|
||||||
.await
|
.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 {
|
if file.name != dest_filename {
|
||||||
file_management_service
|
file_management_service
|
||||||
.rename_file(&file.id, dest_filename)
|
.rename_file(&file.id, dest_filename)
|
||||||
.await
|
.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 {
|
} else {
|
||||||
// Fallback: legacy double-query path
|
// Fallback: legacy double-query path
|
||||||
@@ -1137,13 +1168,17 @@ async fn handle_move(
|
|||||||
folder.owner_id.as_deref().unwrap_or("webdav"),
|
folder.owner_id.as_deref().unwrap_or("webdav"),
|
||||||
)
|
)
|
||||||
.await
|
.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 {
|
} else {
|
||||||
let file = file_retrieval_service
|
let file = file_retrieval_service
|
||||||
.get_file_by_path(&source_path)
|
.get_file_by_path(&source_path)
|
||||||
.await
|
.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
|
let dest_filename = destination_path
|
||||||
.split('/')
|
.split('/')
|
||||||
@@ -1170,7 +1205,9 @@ async fn handle_move(
|
|||||||
file_management_service
|
file_management_service
|
||||||
.rename_file(&file.id, dest_filename)
|
.rename_file(&file.id, dest_filename)
|
||||||
.await
|
.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 {
|
let dest_exists = if let Some(resolver) = &state.path_resolver {
|
||||||
resolver.exists(&destination_path).await.unwrap_or(false)
|
resolver.exists(&destination_path).await.unwrap_or(false)
|
||||||
} else {
|
} else {
|
||||||
folder_service.get_folder_by_path(&destination_path).await.is_ok()
|
folder_service
|
||||||
|| file_retrieval_service.get_file_by_path(&destination_path).await.is_ok()
|
.get_folder_by_path(&destination_path)
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
|| file_retrieval_service
|
||||||
|
.get_file_by_path(&destination_path)
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
};
|
};
|
||||||
if dest_exists {
|
if dest_exists {
|
||||||
return Err(AppError::precondition_failed(
|
return Err(AppError::precondition_failed(
|
||||||
@@ -1296,7 +1339,10 @@ async fn handle_copy(
|
|||||||
.create_folder(create_dto)
|
.create_folder(create_dto)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.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
|
.await
|
||||||
.map_err(|e| AppError::internal_error(format!("Failed to copy file: {}", e)))?;
|
.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 {
|
} else {
|
||||||
// Fallback: legacy double-query path
|
// Fallback: legacy double-query path
|
||||||
@@ -1371,14 +1422,19 @@ async fn handle_copy(
|
|||||||
.create_folder(create_dto)
|
.create_folder(create_dto)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
AppError::internal_error(format!("Failed to create destination folder: {}", e))
|
AppError::internal_error(format!(
|
||||||
|
"Failed to create destination folder: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let file = file_retrieval_service
|
let file = file_retrieval_service
|
||||||
.get_file_by_path(&source_path)
|
.get_file_by_path(&source_path)
|
||||||
.await
|
.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('/') {
|
let dest_parent_path = if let Some(idx) = destination_path.rfind('/') {
|
||||||
&destination_path[..idx]
|
&destination_path[..idx]
|
||||||
|
|||||||
@@ -205,10 +205,7 @@ pub async fn auth_middleware(
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("Bearer token validation failed: {}", e);
|
tracing::warn!("Bearer token validation failed: {}", e);
|
||||||
return Err(AuthError::InvalidToken(format!(
|
return Err(AuthError::InvalidToken(format!("Invalid token: {}", e)));
|
||||||
"Invalid token: {}",
|
|
||||||
e
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -273,7 +270,9 @@ pub async fn auth_middleware(
|
|||||||
{
|
{
|
||||||
use crate::interfaces::api::cookie_auth;
|
use crate::interfaces::api::cookie_auth;
|
||||||
|
|
||||||
if let Some(token_str) = cookie_auth::extract_cookie_value(&headers, cookie_auth::ACCESS_COOKIE) {
|
if let Some(token_str) =
|
||||||
|
cookie_auth::extract_cookie_value(&headers, cookie_auth::ACCESS_COOKIE)
|
||||||
|
{
|
||||||
if !token_str.is_empty() {
|
if !token_str.is_empty() {
|
||||||
tracing::debug!("Processing cookie-based authentication");
|
tracing::debug!("Processing cookie-based authentication");
|
||||||
|
|
||||||
@@ -281,10 +280,7 @@ pub async fn auth_middleware(
|
|||||||
let token_service = &auth_service.token_service;
|
let token_service = &auth_service.token_service;
|
||||||
match token_service.validate_token(&token_str) {
|
match token_service.validate_token(&token_str) {
|
||||||
Ok(claims) => {
|
Ok(claims) => {
|
||||||
tracing::debug!(
|
tracing::debug!("Cookie token validated for user: {}", claims.username);
|
||||||
"Cookie token validated for user: {}",
|
|
||||||
claims.username
|
|
||||||
);
|
|
||||||
let current_user = CurrentUser {
|
let current_user = CurrentUser {
|
||||||
id: claims.sub,
|
id: claims.sub,
|
||||||
username: claims.username,
|
username: claims.username,
|
||||||
|
|||||||
@@ -40,10 +40,8 @@ pub async fn csrf_middleware(request: Request, next: Next) -> Result<Response, R
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract the CSRF token from the cookie.
|
// Extract the CSRF token from the cookie.
|
||||||
let cookie_token = cookie_auth::extract_cookie_value(
|
let cookie_token =
|
||||||
request.headers(),
|
cookie_auth::extract_cookie_value(request.headers(), cookie_auth::CSRF_COOKIE);
|
||||||
cookie_auth::CSRF_COOKIE,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Extract the CSRF token from the request header.
|
// Extract the CSRF token from the request header.
|
||||||
let header_token = request
|
let header_token = request
|
||||||
|
|||||||
@@ -62,12 +62,7 @@ impl RateLimiter {
|
|||||||
// moka's entry API lets us atomically read-modify-write.
|
// moka's entry API lets us atomically read-modify-write.
|
||||||
// On first access the entry is inserted with count = 1 and the TTL
|
// On first access the entry is inserted with count = 1 and the TTL
|
||||||
// starts. Subsequent accesses within the window increment the count.
|
// starts. Subsequent accesses within the window increment the count.
|
||||||
let count = self
|
let count = self.cache.entry(key).or_insert_with(|| 0).into_value() + 1;
|
||||||
.cache
|
|
||||||
.entry(key)
|
|
||||||
.or_insert_with(|| 0)
|
|
||||||
.into_value()
|
|
||||||
+ 1;
|
|
||||||
|
|
||||||
// Write back the incremented value. Because `or_insert_with` returns
|
// Write back the incremented value. Because `or_insert_with` returns
|
||||||
// the *existing* value when the key was already present, we must always
|
// the *existing* value when the key was already present, we must always
|
||||||
@@ -75,8 +70,7 @@ impl RateLimiter {
|
|||||||
// insert still governs eviction because moka uses insert-time TTL.
|
// insert still governs eviction because moka uses insert-time TTL.
|
||||||
// However, on re-insert moka resets the TTL — for rate limiting this
|
// However, on re-insert moka resets the TTL — for rate limiting this
|
||||||
// is fine because it means the window "slides" forward on activity.
|
// is fine because it means the window "slides" forward on activity.
|
||||||
self.cache
|
self.cache.insert(ip.to_string(), count);
|
||||||
.insert(ip.to_string(), count);
|
|
||||||
|
|
||||||
if count > self.max_requests {
|
if count > self.max_requests {
|
||||||
Err(())
|
Err(())
|
||||||
|
|||||||
+27
-13
@@ -170,13 +170,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if config.features.enable_auth {
|
if config.features.enable_auth {
|
||||||
use interfaces::api::handlers::auth_handler::{auth_routes, login_route, register_route, refresh_route};
|
use interfaces::api::handlers::auth_handler::{
|
||||||
use oxicloud::interfaces::api::handlers::device_auth_handler;
|
auth_routes, login_route, refresh_route, register_route,
|
||||||
|
};
|
||||||
use oxicloud::interfaces::api::handlers::app_password_handler;
|
use oxicloud::interfaces::api::handlers::app_password_handler;
|
||||||
|
use oxicloud::interfaces::api::handlers::device_auth_handler;
|
||||||
use oxicloud::interfaces::middleware::auth::auth_middleware;
|
use oxicloud::interfaces::middleware::auth::auth_middleware;
|
||||||
use oxicloud::interfaces::middleware::csrf::csrf_middleware;
|
use oxicloud::interfaces::middleware::csrf::csrf_middleware;
|
||||||
use oxicloud::interfaces::middleware::rate_limit::{
|
use oxicloud::interfaces::middleware::rate_limit::{
|
||||||
RateLimiter, rate_limit_login, rate_limit_register, rate_limit_refresh,
|
RateLimiter, rate_limit_login, rate_limit_refresh, rate_limit_register,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Rate limiters (IP-based, in-memory via moka) ────────────────
|
// ── Rate limiters (IP-based, in-memory via moka) ────────────────
|
||||||
@@ -198,28 +200,40 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
));
|
));
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Rate limiting enabled — login: {}/{} s, register: {}/{} s, refresh: {}/{} s",
|
"Rate limiting enabled — login: {}/{} s, register: {}/{} s, refresh: {}/{} s",
|
||||||
rl.login_max_requests, rl.login_window_secs,
|
rl.login_max_requests,
|
||||||
rl.register_max_requests, rl.register_window_secs,
|
rl.login_window_secs,
|
||||||
rl.refresh_max_requests, rl.refresh_window_secs,
|
rl.register_max_requests,
|
||||||
|
rl.register_window_secs,
|
||||||
|
rl.refresh_max_requests,
|
||||||
|
rl.refresh_window_secs,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Auth routes split by rate-limit policy
|
// Auth routes split by rate-limit policy
|
||||||
let auth_login = login_route()
|
let auth_login = login_route()
|
||||||
.layer(axum::middleware::from_fn_with_state(login_limiter.clone(), rate_limit_login))
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
|
login_limiter.clone(),
|
||||||
|
rate_limit_login,
|
||||||
|
))
|
||||||
.with_state(app_state.clone());
|
.with_state(app_state.clone());
|
||||||
let auth_register = register_route()
|
let auth_register = register_route()
|
||||||
.layer(axum::middleware::from_fn_with_state(register_limiter.clone(), rate_limit_register))
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
|
register_limiter.clone(),
|
||||||
|
rate_limit_register,
|
||||||
|
))
|
||||||
.with_state(app_state.clone());
|
.with_state(app_state.clone());
|
||||||
let auth_refresh = refresh_route()
|
let auth_refresh = refresh_route()
|
||||||
.layer(axum::middleware::from_fn_with_state(refresh_limiter.clone(), rate_limit_refresh))
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
|
refresh_limiter.clone(),
|
||||||
|
rate_limit_refresh,
|
||||||
|
))
|
||||||
.with_state(app_state.clone());
|
.with_state(app_state.clone());
|
||||||
// Remaining auth routes (status, OIDC, protected /me, /logout, etc.)
|
// Remaining auth routes (status, OIDC, protected /me, /logout, etc.)
|
||||||
let auth_router = auth_routes().with_state(app_state.clone());
|
let auth_router = auth_routes().with_state(app_state.clone());
|
||||||
|
|
||||||
// Device Authorization Grant (RFC 8628)
|
// Device Authorization Grant (RFC 8628)
|
||||||
// Public endpoints: /api/auth/device/authorize + /api/auth/device/token
|
// Public endpoints: /api/auth/device/authorize + /api/auth/device/token
|
||||||
let device_public = device_auth_handler::device_auth_public_routes()
|
let device_public =
|
||||||
.with_state(app_state.clone());
|
device_auth_handler::device_auth_public_routes().with_state(app_state.clone());
|
||||||
// Protected endpoints: /api/auth/device/verify, /api/auth/device/devices
|
// Protected endpoints: /api/auth/device/verify, /api/auth/device/devices
|
||||||
let device_protected = device_auth_handler::device_auth_protected_routes()
|
let device_protected = device_auth_handler::device_auth_protected_routes()
|
||||||
.layer(axum::middleware::from_fn(csrf_middleware))
|
.layer(axum::middleware::from_fn(csrf_middleware))
|
||||||
@@ -325,8 +339,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
// ── Security headers ─────────────────────────────────────────────────
|
// ── Security headers ─────────────────────────────────────────────────
|
||||||
// Applied globally so every response (API, static, DAV) carries them.
|
// Applied globally so every response (API, static, DAV) carries them.
|
||||||
use axum::http::header::HeaderName;
|
|
||||||
use axum::http::HeaderValue;
|
use axum::http::HeaderValue;
|
||||||
|
use axum::http::header::HeaderName;
|
||||||
|
|
||||||
app = app
|
app = app
|
||||||
.layer(SetResponseHeaderLayer::overriding(
|
.layer(SetResponseHeaderLayer::overriding(
|
||||||
@@ -347,7 +361,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
frame-src *; \
|
frame-src *; \
|
||||||
frame-ancestors 'none'; \
|
frame-ancestors 'none'; \
|
||||||
base-uri 'self'; \
|
base-uri 'self'; \
|
||||||
form-action 'self'"
|
form-action 'self'",
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
.layer(SetResponseHeaderLayer::overriding(
|
.layer(SetResponseHeaderLayer::overriding(
|
||||||
|
|||||||
Reference in New Issue
Block a user