2026-02-10 18:46:59 +01:00
|
|
|
/**
|
|
|
|
|
* CalDAV Handler Module
|
2026-02-14 01:29:34 +01:00
|
|
|
*
|
2026-02-10 18:46:59 +01:00
|
|
|
* This module implements the CalDAV protocol (RFC 4791) endpoints for OxiCloud.
|
|
|
|
|
* It provides calendar access and management through standard CalDAV methods,
|
|
|
|
|
* allowing clients like Thunderbird, Apple Calendar, and GNOME Calendar to sync.
|
2026-02-14 01:29:34 +01:00
|
|
|
*
|
2026-02-10 18:46:59 +01:00
|
|
|
* Supported methods:
|
|
|
|
|
* - OPTIONS: Advertise CalDAV capabilities
|
|
|
|
|
* - PROPFIND: List calendars and their properties
|
|
|
|
|
* - REPORT: Query events (calendar-query, calendar-multiget)
|
|
|
|
|
* - MKCALENDAR: Create a new calendar
|
|
|
|
|
* - PUT: Create/update calendar events (.ics)
|
|
|
|
|
* - GET: Retrieve calendar event data
|
|
|
|
|
* - DELETE: Remove calendars or events
|
|
|
|
|
* - PROPPATCH: Modify calendar properties
|
|
|
|
|
*/
|
2025-04-13 01:04:04 +02:00
|
|
|
use axum::{
|
|
|
|
|
Router,
|
2026-02-14 01:29:34 +01:00
|
|
|
body::{self, Body},
|
|
|
|
|
http::{HeaderName, Request, StatusCode, header},
|
2026-02-10 18:46:59 +01:00
|
|
|
response::Response,
|
2025-04-13 01:04:04 +02:00
|
|
|
};
|
2026-02-10 18:46:59 +01:00
|
|
|
use bytes::Buf;
|
2026-03-01 20:34:12 +01:00
|
|
|
use percent_encoding::percent_decode_str;
|
2026-03-02 23:58:15 +01:00
|
|
|
use std::fmt::Write;
|
2026-02-14 01:29:34 +01:00
|
|
|
use std::sync::Arc;
|
2025-04-13 01:04:04 +02:00
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
use crate::application::adapters::caldav_adapter::{CalDavAdapter, CalDavReportType};
|
2026-06-11 09:21:35 +00:00
|
|
|
use crate::application::adapters::uid_from_multiget_href;
|
2026-02-10 18:46:59 +01:00
|
|
|
use crate::application::adapters::webdav_adapter::{PropFindRequest, PropFindType};
|
|
|
|
|
use crate::application::dtos::calendar_dto::{
|
2026-02-14 01:29:34 +01:00
|
|
|
CreateCalendarDto, CreateEventICalDto, UpdateCalendarDto,
|
2026-02-10 18:46:59 +01:00
|
|
|
};
|
2026-02-14 01:29:34 +01:00
|
|
|
use crate::application::ports::calendar_ports::CalendarUseCase;
|
2026-03-04 23:55:08 +01:00
|
|
|
use crate::application::services::calendar_service::CalendarService;
|
2026-02-14 01:29:34 +01:00
|
|
|
use crate::common::di::AppState;
|
2026-02-10 18:46:59 +01:00
|
|
|
use crate::interfaces::errors::AppError;
|
2026-03-09 00:08:34 +01:00
|
|
|
use crate::interfaces::middleware::auth::{AuthUser, CurrentUser};
|
2026-02-10 18:46:59 +01:00
|
|
|
|
|
|
|
|
const HEADER_DAV: HeaderName = HeaderName::from_static("dav");
|
2025-04-13 01:04:04 +02:00
|
|
|
|
2026-03-05 16:57:44 +01:00
|
|
|
/// Maximum allowed request body size for CalDAV XML/iCal endpoints (1 MB).
|
|
|
|
|
/// Prevents OOM/DoS via unbounded body buffering.
|
|
|
|
|
const MAX_CALDAV_BODY: usize = 1_048_576;
|
|
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
/// Creates CalDAV routes with full path prefixes.
|
2026-02-14 01:29:34 +01:00
|
|
|
///
|
2026-02-10 18:46:59 +01:00
|
|
|
/// Uses `merge()` instead of `nest()` to avoid Axum's trailing-slash routing gap.
|
|
|
|
|
/// Registers `/caldav`, `/caldav/`, and `/caldav/{*path}` explicitly.
|
2026-02-24 15:11:56 +01:00
|
|
|
pub fn caldav_routes() -> Router<Arc<AppState>> {
|
2025-04-13 01:04:04 +02:00
|
|
|
Router::new()
|
2026-02-10 18:46:59 +01:00
|
|
|
.route("/caldav/{*path}", axum::routing::any(handle_caldav_methods))
|
|
|
|
|
.route("/caldav/", axum::routing::any(handle_caldav_methods_root))
|
|
|
|
|
.route("/caldav", axum::routing::any(handle_caldav_methods_root))
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 20:40:41 +00:00
|
|
|
/// 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>> {
|
2026-03-03 01:49:18 +01:00
|
|
|
Router::new().route(
|
|
|
|
|
"/.well-known/caldav",
|
|
|
|
|
axum::routing::any(handle_well_known_caldav),
|
|
|
|
|
)
|
2026-03-02 20:40:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn handle_well_known_caldav() -> Response<Body> {
|
|
|
|
|
Response::builder()
|
|
|
|
|
.status(StatusCode::MOVED_PERMANENTLY)
|
|
|
|
|
.header(header::LOCATION, "/caldav/")
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.unwrap()
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
async fn handle_caldav_methods_root(
|
2026-02-24 15:11:56 +01:00
|
|
|
axum::extract::State(state): axum::extract::State<Arc<AppState>>,
|
2026-02-10 18:46:59 +01:00
|
|
|
req: Request<Body>,
|
|
|
|
|
) -> Result<Response<Body>, AppError> {
|
|
|
|
|
handle_caldav_methods_inner(state, req, String::new()).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn handle_caldav_methods(
|
2026-02-24 15:11:56 +01:00
|
|
|
axum::extract::State(state): axum::extract::State<Arc<AppState>>,
|
2026-02-10 18:46:59 +01:00
|
|
|
req: Request<Body>,
|
|
|
|
|
) -> Result<Response<Body>, AppError> {
|
|
|
|
|
let uri = req.uri().clone();
|
|
|
|
|
let path = extract_caldav_path(uri.path());
|
2026-03-05 16:09:37 +01:00
|
|
|
reject_path_traversal(&path)?;
|
2026-02-10 18:46:59 +01:00
|
|
|
handle_caldav_methods_inner(state, req, path).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn handle_caldav_methods_inner(
|
2026-02-24 15:11:56 +01:00
|
|
|
state: Arc<AppState>,
|
2026-02-10 18:46:59 +01:00
|
|
|
req: Request<Body>,
|
|
|
|
|
path: String,
|
|
|
|
|
) -> Result<Response<Body>, AppError> {
|
|
|
|
|
let method = req.method().clone();
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
match method.as_str() {
|
|
|
|
|
"OPTIONS" => handle_options().await,
|
|
|
|
|
"PROPFIND" => handle_propfind(state, req, &path).await,
|
|
|
|
|
"REPORT" => handle_report(state, req, &path).await,
|
|
|
|
|
"MKCALENDAR" => handle_mkcalendar(state, req, &path).await,
|
|
|
|
|
"PUT" => handle_put(state, req, &path).await,
|
|
|
|
|
"GET" => handle_get(state, req, &path).await,
|
|
|
|
|
"DELETE" => handle_delete(state, req, &path).await,
|
|
|
|
|
"PROPPATCH" => handle_proppatch(state, req, &path).await,
|
2026-02-14 01:29:34 +01:00
|
|
|
_ => Err(AppError::method_not_allowed(format!(
|
|
|
|
|
"Method not allowed: {}",
|
|
|
|
|
method
|
|
|
|
|
))),
|
2026-02-10 18:46:59 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-01 20:34:12 +01:00
|
|
|
/// Extract the CalDAV path from the full URI path, percent-decoding the result.
|
2026-02-10 18:46:59 +01:00
|
|
|
fn extract_caldav_path(uri_path: &str) -> String {
|
2026-03-01 20:34:12 +01:00
|
|
|
let encoded = if let Some(pos) = uri_path.find("/caldav/") {
|
2026-02-10 18:46:59 +01:00
|
|
|
let after = &uri_path[pos + 8..];
|
2026-03-01 20:34:12 +01:00
|
|
|
after.trim_end_matches('/')
|
2026-02-10 18:46:59 +01:00
|
|
|
} else if uri_path.ends_with("/caldav") {
|
2026-03-01 20:34:12 +01:00
|
|
|
""
|
2026-02-10 18:46:59 +01:00
|
|
|
} else {
|
2026-03-03 01:49:18 +01:00
|
|
|
uri_path.trim_start_matches('/').trim_end_matches('/')
|
2026-03-01 20:34:12 +01:00
|
|
|
};
|
2026-03-03 01:49:18 +01:00
|
|
|
percent_decode_str(encoded).decode_utf8_lossy().into_owned()
|
2026-02-10 18:46:59 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-05 16:09:37 +01:00
|
|
|
/// Reject paths that contain path-traversal segments (`.` or `..`).
|
|
|
|
|
fn reject_path_traversal(path: &str) -> Result<(), AppError> {
|
|
|
|
|
for segment in path.split('/') {
|
|
|
|
|
if segment == ".." || segment == "." {
|
|
|
|
|
return Err(AppError::bad_request(
|
|
|
|
|
"Path must not contain '.' or '..' segments",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-30 09:52:48 +02:00
|
|
|
// ─── Helper: strip optional username prefix from CalDAV path ─────────
|
|
|
|
|
//
|
|
|
|
|
// The `calendar-home-set` discovery property returns `/caldav/{username}/`,
|
|
|
|
|
// so standard clients (DAVx5, Apple Calendar, Thunderbird) will prefix all
|
|
|
|
|
// subsequent requests with the username segment. The handlers below expect
|
|
|
|
|
// paths of the form `{calendar_id}` or `{calendar_id}/{event}.ics`, so we
|
|
|
|
|
// need to detect and strip the leading username when present.
|
|
|
|
|
//
|
|
|
|
|
// Heuristic: if the first path segment is a valid UUID it is already a
|
|
|
|
|
// calendar ID; otherwise treat it as a username and skip it.
|
|
|
|
|
|
|
|
|
|
fn strip_username_prefix(path: &str) -> &str {
|
|
|
|
|
if let Some(pos) = path.find('/') {
|
|
|
|
|
let first = &path[..pos];
|
|
|
|
|
if uuid::Uuid::parse_str(first).is_ok() {
|
|
|
|
|
// First segment is a UUID → no username prefix
|
|
|
|
|
path
|
|
|
|
|
} else {
|
|
|
|
|
// First segment is not a UUID → treat as username, return the rest
|
|
|
|
|
&path[pos + 1..]
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Single segment (no slash)
|
|
|
|
|
if uuid::Uuid::parse_str(path).is_ok() {
|
|
|
|
|
path
|
|
|
|
|
} else {
|
|
|
|
|
// Single non-UUID segment (bare username) → nothing useful after it
|
|
|
|
|
""
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
// ─── Helper: extract user from request ───────────────────────────────
|
|
|
|
|
|
2026-03-09 00:08:34 +01:00
|
|
|
fn extract_user(req: &Request<Body>) -> Result<AuthUser, AppError> {
|
2026-02-10 18:46:59 +01:00
|
|
|
req.extensions()
|
2026-03-07 11:23:56 +01:00
|
|
|
.get::<Arc<CurrentUser>>()
|
2026-03-09 00:08:34 +01:00
|
|
|
.cloned()
|
|
|
|
|
.map(AuthUser)
|
2026-02-10 18:46:59 +01:00
|
|
|
.ok_or_else(|| AppError::unauthorized("Authentication required"))
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 15:36:42 +00:00
|
|
|
fn get_calendar_service(state: &AppState) -> Result<&Arc<CalendarService>, AppError> {
|
2026-02-10 18:46:59 +01:00
|
|
|
state.calendar_use_case.as_ref().ok_or_else(|| {
|
|
|
|
|
AppError::new(
|
|
|
|
|
StatusCode::NOT_IMPLEMENTED,
|
|
|
|
|
"CalDAV service is not configured",
|
|
|
|
|
"NotImplemented",
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── OPTIONS ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async fn handle_options() -> Result<Response<Body>, AppError> {
|
|
|
|
|
Ok(Response::builder()
|
|
|
|
|
.status(StatusCode::OK)
|
|
|
|
|
.header(HEADER_DAV, "1, 2, calendar-access")
|
2026-02-14 01:29:34 +01:00
|
|
|
.header(
|
|
|
|
|
header::ALLOW,
|
|
|
|
|
"OPTIONS, GET, PUT, DELETE, PROPFIND, PROPPATCH, REPORT, MKCALENDAR",
|
|
|
|
|
)
|
2026-02-10 18:46:59 +01:00
|
|
|
.body(Body::empty())
|
|
|
|
|
.unwrap())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── PROPFIND ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async fn handle_propfind(
|
|
|
|
|
state: Arc<AppState>,
|
|
|
|
|
req: Request<Body>,
|
|
|
|
|
path: &str,
|
|
|
|
|
) -> Result<Response<Body>, AppError> {
|
2026-02-14 01:29:34 +01:00
|
|
|
let depth = req
|
|
|
|
|
.headers()
|
2026-02-10 18:46:59 +01:00
|
|
|
.get("Depth")
|
|
|
|
|
.and_then(|v| v.to_str().ok())
|
|
|
|
|
.unwrap_or("1")
|
|
|
|
|
.to_string();
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
let user = extract_user(&req)?;
|
2026-06-19 08:38:27 +00:00
|
|
|
// Caller UUID (string form) — gates the `<D:write/>` privilege on calendars
|
|
|
|
|
// the caller owns, so clients mount their own calendars read-write.
|
|
|
|
|
let caller_id = user.id.to_string();
|
2026-02-10 18:46:59 +01:00
|
|
|
let calendar_service = get_calendar_service(&state)?;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-03-05 16:57:44 +01:00
|
|
|
let body_bytes = body::to_bytes(req.into_body(), MAX_CALDAV_BODY)
|
2026-02-10 18:46:59 +01:00
|
|
|
.await
|
|
|
|
|
.map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
// Parse PROPFIND request
|
|
|
|
|
let propfind_request = if body_bytes.is_empty() {
|
2026-02-14 01:29:34 +01:00
|
|
|
PropFindRequest {
|
|
|
|
|
prop_find_type: PropFindType::AllProp,
|
|
|
|
|
}
|
2026-02-10 18:46:59 +01:00
|
|
|
} else {
|
2026-02-14 01:29:34 +01:00
|
|
|
crate::application::adapters::webdav_adapter::WebDavAdapter::parse_propfind(
|
|
|
|
|
body_bytes.reader(),
|
|
|
|
|
)
|
|
|
|
|
.map_err(|e| AppError::bad_request(format!("Failed to parse PROPFIND: {}", e)))?
|
2026-02-10 18:46:59 +01:00
|
|
|
};
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
if path.is_empty() {
|
2026-03-02 20:40:41 +00:00
|
|
|
// Root CalDAV path — return discovery properties + list user's calendars
|
|
|
|
|
// At depth 0, only return root entry; at depth 1+, also include calendars
|
|
|
|
|
let calendars = if depth == "0" {
|
|
|
|
|
vec![]
|
|
|
|
|
} else {
|
|
|
|
|
calendar_service
|
2026-03-07 14:59:32 +01:00
|
|
|
.list_my_calendars(user.id)
|
2026-03-02 20:40:41 +00:00
|
|
|
.await
|
2026-07-14 22:27:30 +02:00
|
|
|
.map_err(AppError::from)?
|
2026-03-02 20:40:41 +00:00
|
|
|
};
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
let base_href = "/caldav/";
|
|
|
|
|
let mut response_body = Vec::new();
|
2026-03-02 20:40:41 +00:00
|
|
|
CalDavAdapter::generate_root_propfind_response(
|
2026-02-10 18:46:59 +01:00
|
|
|
&mut response_body,
|
|
|
|
|
&calendars,
|
|
|
|
|
&propfind_request,
|
|
|
|
|
base_href,
|
2026-03-02 20:40:41 +00:00
|
|
|
&user.username,
|
2026-06-19 08:38:27 +00:00
|
|
|
&caller_id,
|
2026-03-02 20:40:41 +00:00
|
|
|
)
|
|
|
|
|
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
|
|
|
|
|
|
|
|
|
|
Ok(Response::builder()
|
|
|
|
|
.status(StatusCode::MULTI_STATUS)
|
|
|
|
|
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
|
|
|
|
.body(Body::from(response_body))
|
|
|
|
|
.unwrap())
|
|
|
|
|
} else if path.starts_with("principals/") || path == "principals" {
|
|
|
|
|
// Principal resource — return user principal properties
|
2026-03-03 01:49:18 +01:00
|
|
|
let username = path.strip_prefix("principals/").unwrap_or(&user.username);
|
2026-03-02 20:40:41 +00:00
|
|
|
let username = if username.is_empty() {
|
|
|
|
|
&user.username
|
|
|
|
|
} else {
|
|
|
|
|
username
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let mut response_body = Vec::new();
|
|
|
|
|
CalDavAdapter::generate_principal_propfind_response(
|
|
|
|
|
&mut response_body,
|
|
|
|
|
&propfind_request,
|
|
|
|
|
username,
|
2026-02-14 01:29:34 +01:00
|
|
|
)
|
|
|
|
|
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
|
|
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
Ok(Response::builder()
|
|
|
|
|
.status(StatusCode::MULTI_STATUS)
|
|
|
|
|
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
|
|
|
|
.body(Body::from(response_body))
|
|
|
|
|
.unwrap())
|
|
|
|
|
} else {
|
2026-03-02 20:40:41 +00:00
|
|
|
// Path could be:
|
|
|
|
|
// {username} — user calendar home (from calendar-home-set)
|
|
|
|
|
// {calendar_id} — calendar collection
|
|
|
|
|
// {calendar_id}/{event_uid}.ics — individual event
|
|
|
|
|
// {username}/{calendar_id} — calendar under user home
|
|
|
|
|
// {username}/{calendar_id}/{uid}.ics — event under user home
|
2026-04-30 09:52:48 +02:00
|
|
|
//
|
|
|
|
|
// Use strip_username_prefix heuristic: if first segment is a UUID
|
|
|
|
|
// it's a calendar ID, otherwise it's a username prefix.
|
2026-02-10 18:46:59 +01:00
|
|
|
let parts: Vec<&str> = path.splitn(2, '/').collect();
|
2026-03-02 20:40:41 +00:00
|
|
|
let first_segment = parts[0];
|
2026-04-30 09:52:48 +02:00
|
|
|
let first_is_uuid = uuid::Uuid::parse_str(first_segment).is_ok();
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
if parts.len() == 1 {
|
2026-04-30 09:52:48 +02:00
|
|
|
// Single path segment: UUID means calendar ID, otherwise user home
|
|
|
|
|
let calendar_result = if first_is_uuid {
|
|
|
|
|
calendar_service.get_calendar(first_segment, user.id).await
|
|
|
|
|
} else {
|
|
|
|
|
Err(crate::domain::errors::DomainError::new(
|
|
|
|
|
crate::domain::errors::ErrorKind::NotFound,
|
|
|
|
|
"Calendar",
|
|
|
|
|
"Not a UUID",
|
|
|
|
|
))
|
|
|
|
|
};
|
2026-03-02 20:40:41 +00:00
|
|
|
|
|
|
|
|
if let Ok(calendar) = calendar_result {
|
|
|
|
|
// Valid calendar ID — return calendar collection
|
|
|
|
|
let events = if depth != "0" {
|
|
|
|
|
calendar_service
|
2026-03-07 14:59:32 +01:00
|
|
|
.list_events(first_segment, None, None, user.id)
|
2026-03-02 20:40:41 +00:00
|
|
|
.await
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
} else {
|
|
|
|
|
vec![]
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let base_href = &format!("/caldav/{}/", first_segment);
|
|
|
|
|
let mut response_body = Vec::new();
|
|
|
|
|
|
|
|
|
|
CalDavAdapter::generate_calendar_collection_propfind(
|
|
|
|
|
&mut response_body,
|
|
|
|
|
&calendar,
|
|
|
|
|
&events,
|
|
|
|
|
&propfind_request,
|
|
|
|
|
base_href,
|
|
|
|
|
&depth,
|
2026-06-19 08:38:27 +00:00
|
|
|
&caller_id,
|
2026-03-02 20:40:41 +00:00
|
|
|
)
|
2026-03-03 01:49:18 +01:00
|
|
|
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
|
2026-03-02 20:40:41 +00:00
|
|
|
|
|
|
|
|
Ok(Response::builder()
|
|
|
|
|
.status(StatusCode::MULTI_STATUS)
|
|
|
|
|
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
|
|
|
|
.body(Body::from(response_body))
|
|
|
|
|
.unwrap())
|
2026-07-06 08:28:00 +02:00
|
|
|
} else if first_is_uuid {
|
|
|
|
|
// Path segment IS a UUID but the calendar isn't
|
|
|
|
|
// accessible to the caller — could be another
|
|
|
|
|
// owner's calendar or genuinely missing. Return
|
|
|
|
|
// 404 (anti-enum, matches every other OxiCloud
|
|
|
|
|
// surface post-D7). The pre-Round-3 fall-through
|
|
|
|
|
// silently listed the caller's OWN calendars,
|
|
|
|
|
// which was misleading (the URL claimed one calendar,
|
|
|
|
|
// response returned unrelated ones) and violated
|
|
|
|
|
// the anti-enumeration contract audited in
|
|
|
|
|
// `docs/plan/authz_audit/caldav_carddav_wopi.md`.
|
|
|
|
|
Err(AppError::not_found("Calendar not found"))
|
2026-02-10 18:46:59 +01:00
|
|
|
} else {
|
2026-03-02 20:40:41 +00:00
|
|
|
// Not a calendar ID — treat as user calendar home (e.g. /caldav/{username}/)
|
|
|
|
|
// List all calendars for this user
|
2026-03-09 14:34:07 +01:00
|
|
|
let calendars = calendar_service
|
|
|
|
|
.list_my_calendars(user.id)
|
|
|
|
|
.await
|
2026-07-14 22:27:30 +02:00
|
|
|
.map_err(AppError::from)?;
|
2026-03-02 20:40:41 +00:00
|
|
|
|
|
|
|
|
let base_href = &format!("/caldav/{}/", first_segment);
|
|
|
|
|
let mut response_body = Vec::new();
|
|
|
|
|
|
|
|
|
|
CalDavAdapter::generate_calendars_propfind_response(
|
|
|
|
|
&mut response_body,
|
|
|
|
|
&calendars,
|
|
|
|
|
&propfind_request,
|
|
|
|
|
base_href,
|
2026-06-19 08:38:27 +00:00
|
|
|
&caller_id,
|
2026-03-02 20:40:41 +00:00
|
|
|
)
|
2026-03-03 01:49:18 +01:00
|
|
|
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
|
2026-03-02 20:40:41 +00:00
|
|
|
|
|
|
|
|
Ok(Response::builder()
|
|
|
|
|
.status(StatusCode::MULTI_STATUS)
|
|
|
|
|
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
|
|
|
|
.body(Body::from(response_body))
|
|
|
|
|
.unwrap())
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Multi-segment path: {something}/{rest}
|
|
|
|
|
let rest = parts[1];
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-04-30 09:52:48 +02:00
|
|
|
// Use UUID heuristic: if first segment is a UUID it's a calendar ID
|
|
|
|
|
let (calendar_id, event_path) = if first_is_uuid {
|
2026-03-02 20:40:41 +00:00
|
|
|
// first_segment is a calendar ID, rest is event path
|
|
|
|
|
(first_segment, rest)
|
|
|
|
|
} else {
|
|
|
|
|
// first_segment may be a username, rest could be {calendar_id} or
|
|
|
|
|
// {calendar_id}/{event}.ics
|
|
|
|
|
let sub_parts: Vec<&str> = rest.splitn(2, '/').collect();
|
|
|
|
|
if sub_parts.len() == 1 {
|
|
|
|
|
// /caldav/{username}/{calendar_id}
|
|
|
|
|
// Try to get this as a calendar collection
|
|
|
|
|
let cal = calendar_service
|
2026-03-07 14:59:32 +01:00
|
|
|
.get_calendar(sub_parts[0], user.id)
|
2026-03-02 20:40:41 +00:00
|
|
|
.await
|
2026-03-03 01:49:18 +01:00
|
|
|
.map_err(|e| AppError::not_found(format!("Calendar not found: {}", e)))?;
|
2026-03-02 20:40:41 +00:00
|
|
|
|
|
|
|
|
let events = if depth != "0" {
|
|
|
|
|
calendar_service
|
2026-03-07 14:59:32 +01:00
|
|
|
.list_events(sub_parts[0], None, None, user.id)
|
2026-03-02 20:40:41 +00:00
|
|
|
.await
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
} else {
|
|
|
|
|
vec![]
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-03 01:49:18 +01:00
|
|
|
let base_href = &format!("/caldav/{}/{}/", first_segment, sub_parts[0]);
|
2026-03-02 20:40:41 +00:00
|
|
|
let mut response_body = Vec::new();
|
|
|
|
|
|
|
|
|
|
CalDavAdapter::generate_calendar_collection_propfind(
|
|
|
|
|
&mut response_body,
|
|
|
|
|
&cal,
|
|
|
|
|
&events,
|
|
|
|
|
&propfind_request,
|
|
|
|
|
base_href,
|
|
|
|
|
&depth,
|
2026-06-19 08:38:27 +00:00
|
|
|
&caller_id,
|
2026-03-02 20:40:41 +00:00
|
|
|
)
|
|
|
|
|
.map_err(|e| {
|
|
|
|
|
AppError::internal_error(format!("Failed to generate XML: {}", e))
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
return Ok(Response::builder()
|
|
|
|
|
.status(StatusCode::MULTI_STATUS)
|
|
|
|
|
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
|
|
|
|
.body(Body::from(response_body))
|
|
|
|
|
.unwrap());
|
|
|
|
|
} else {
|
|
|
|
|
// /caldav/{username}/{calendar_id}/{event}.ics
|
|
|
|
|
(sub_parts[0], sub_parts[1])
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-06-11 09:21:35 +00:00
|
|
|
// Individual event .ics — indexed lookup by iCalendar UID.
|
2026-03-02 20:40:41 +00:00
|
|
|
let ical_uid = event_path.trim_end_matches(".ics");
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-06-11 09:21:35 +00:00
|
|
|
let event = calendar_service
|
|
|
|
|
.get_event_by_ical_uid(calendar_id, ical_uid, user.id)
|
2026-02-14 01:29:34 +01:00
|
|
|
.await
|
2026-07-14 22:27:30 +02:00
|
|
|
.map_err(AppError::from)?
|
2026-03-03 01:49:18 +01:00
|
|
|
.ok_or_else(|| AppError::not_found(format!("Event not found: {}", ical_uid)))?;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
let base_href = &format!("/caldav/{}/", calendar_id);
|
|
|
|
|
let report_type = CalDavReportType::CalendarMultiget {
|
|
|
|
|
hrefs: vec![format!("{}{}.ics", base_href, ical_uid)],
|
|
|
|
|
props: vec![],
|
|
|
|
|
};
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
let mut response_body = Vec::new();
|
|
|
|
|
CalDavAdapter::generate_calendar_events_response(
|
|
|
|
|
&mut response_body,
|
2026-06-11 09:21:35 +00:00
|
|
|
std::slice::from_ref(&event),
|
2026-02-10 18:46:59 +01:00
|
|
|
&report_type,
|
|
|
|
|
base_href,
|
2026-02-14 01:29:34 +01:00
|
|
|
)
|
2026-03-03 01:49:18 +01:00
|
|
|
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
Ok(Response::builder()
|
|
|
|
|
.status(StatusCode::MULTI_STATUS)
|
|
|
|
|
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
|
|
|
|
.body(Body::from(response_body))
|
|
|
|
|
.unwrap())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── REPORT ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async fn handle_report(
|
|
|
|
|
state: Arc<AppState>,
|
|
|
|
|
req: Request<Body>,
|
|
|
|
|
path: &str,
|
|
|
|
|
) -> Result<Response<Body>, AppError> {
|
|
|
|
|
let user = extract_user(&req)?;
|
|
|
|
|
let calendar_service = get_calendar_service(&state)?;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-03-05 16:57:44 +01:00
|
|
|
let body_bytes = body::to_bytes(req.into_body(), MAX_CALDAV_BODY)
|
2026-02-10 18:46:59 +01:00
|
|
|
.await
|
|
|
|
|
.map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
let report = CalDavAdapter::parse_report(body_bytes.reader())
|
|
|
|
|
.map_err(|e| AppError::bad_request(format!("Failed to parse REPORT: {}", e)))?;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-04-30 09:52:48 +02:00
|
|
|
let effective_path = strip_username_prefix(path);
|
|
|
|
|
let calendar_id = effective_path.split('/').next().unwrap_or(effective_path);
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
if calendar_id.is_empty() {
|
|
|
|
|
return Err(AppError::bad_request("Calendar ID required in path"));
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
let events = match &report {
|
|
|
|
|
CalDavReportType::CalendarQuery { time_range, .. } => {
|
|
|
|
|
if let Some((start, end)) = time_range {
|
2026-02-14 01:29:34 +01:00
|
|
|
calendar_service
|
2026-03-07 14:59:32 +01:00
|
|
|
.get_events_in_range(calendar_id, *start, *end, user.id)
|
2026-02-14 01:29:34 +01:00
|
|
|
.await
|
2026-07-14 22:27:30 +02:00
|
|
|
.map_err(AppError::from)?
|
2026-02-10 18:46:59 +01:00
|
|
|
} else {
|
2026-02-14 01:29:34 +01:00
|
|
|
calendar_service
|
2026-03-07 14:59:32 +01:00
|
|
|
.list_events(calendar_id, None, None, user.id)
|
2026-02-14 01:29:34 +01:00
|
|
|
.await
|
2026-07-14 22:27:30 +02:00
|
|
|
.map_err(AppError::from)?
|
2026-02-10 18:46:59 +01:00
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
}
|
2026-02-10 18:46:59 +01:00
|
|
|
CalDavReportType::CalendarMultiget { hrefs, .. } => {
|
2026-06-11 09:21:35 +00:00
|
|
|
// Indexed batch lookup (`ical_uid = ANY(...)`) — a multiget for
|
|
|
|
|
// a handful of events must not pay for listing the whole
|
|
|
|
|
// calendar and filtering client-side.
|
|
|
|
|
let uids: Vec<String> = hrefs
|
|
|
|
|
.iter()
|
|
|
|
|
.filter_map(|href| uid_from_multiget_href(href, ".ics"))
|
|
|
|
|
.collect();
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-06-11 09:21:35 +00:00
|
|
|
calendar_service
|
|
|
|
|
.get_events_by_ical_uids(calendar_id, &uids, user.id)
|
|
|
|
|
.await
|
2026-07-14 22:27:30 +02:00
|
|
|
.map_err(AppError::from)?
|
2026-02-14 01:29:34 +01:00
|
|
|
}
|
|
|
|
|
CalDavReportType::SyncCollection { .. } => calendar_service
|
2026-03-07 14:59:32 +01:00
|
|
|
.list_events(calendar_id, None, None, user.id)
|
2026-02-14 01:29:34 +01:00
|
|
|
.await
|
2026-07-14 22:27:30 +02:00
|
|
|
.map_err(AppError::from)?,
|
2026-02-10 18:46:59 +01:00
|
|
|
};
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
let base_href = &format!("/caldav/{}/", calendar_id);
|
|
|
|
|
let mut response_body = Vec::new();
|
|
|
|
|
CalDavAdapter::generate_calendar_events_response(
|
|
|
|
|
&mut response_body,
|
|
|
|
|
&events,
|
|
|
|
|
&report,
|
|
|
|
|
base_href,
|
2026-02-14 01:29:34 +01:00
|
|
|
)
|
|
|
|
|
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
|
|
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
Ok(Response::builder()
|
|
|
|
|
.status(StatusCode::MULTI_STATUS)
|
|
|
|
|
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
|
|
|
|
.body(Body::from(response_body))
|
|
|
|
|
.unwrap())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── MKCALENDAR ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async fn handle_mkcalendar(
|
|
|
|
|
state: Arc<AppState>,
|
|
|
|
|
req: Request<Body>,
|
|
|
|
|
path: &str,
|
|
|
|
|
) -> Result<Response<Body>, AppError> {
|
|
|
|
|
let user = extract_user(&req)?;
|
|
|
|
|
let calendar_service = get_calendar_service(&state)?;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-03-05 16:57:44 +01:00
|
|
|
let body_bytes = body::to_bytes(req.into_body(), MAX_CALDAV_BODY)
|
2026-02-10 18:46:59 +01:00
|
|
|
.await
|
|
|
|
|
.map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
let (name, description, color) = if body_bytes.is_empty() {
|
2026-02-14 01:29:34 +01:00
|
|
|
let name = path
|
|
|
|
|
.split('/')
|
|
|
|
|
.next_back()
|
|
|
|
|
.unwrap_or("New Calendar")
|
|
|
|
|
.to_string();
|
2026-02-10 18:46:59 +01:00
|
|
|
(name, None, None)
|
|
|
|
|
} else {
|
|
|
|
|
CalDavAdapter::parse_mkcalendar(body_bytes.reader())
|
|
|
|
|
.map_err(|e| AppError::bad_request(format!("Failed to parse MKCALENDAR: {}", e)))?
|
|
|
|
|
};
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
let create_dto = CreateCalendarDto {
|
|
|
|
|
name,
|
|
|
|
|
description,
|
|
|
|
|
color,
|
|
|
|
|
is_public: Some(false),
|
|
|
|
|
};
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-07-14 14:45:04 +02:00
|
|
|
// See the comment above create_event_from_ical for why this uses
|
|
|
|
|
// `AppError::from` (kind-aware mapping) instead of `internal_error`.
|
2026-02-14 01:29:34 +01:00
|
|
|
calendar_service
|
2026-03-07 14:59:32 +01:00
|
|
|
.create_calendar(create_dto, user.id)
|
2026-02-14 01:29:34 +01:00
|
|
|
.await
|
2026-07-14 14:45:04 +02:00
|
|
|
.map_err(AppError::from)?;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
Ok(Response::builder()
|
|
|
|
|
.status(StatusCode::CREATED)
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.unwrap())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── PUT (.ics) ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async fn handle_put(
|
|
|
|
|
state: Arc<AppState>,
|
|
|
|
|
req: Request<Body>,
|
|
|
|
|
path: &str,
|
|
|
|
|
) -> Result<Response<Body>, AppError> {
|
|
|
|
|
let user = extract_user(&req)?;
|
|
|
|
|
let calendar_service = get_calendar_service(&state)?;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-04-30 09:52:48 +02:00
|
|
|
let effective_path = strip_username_prefix(path);
|
|
|
|
|
let parts: Vec<&str> = effective_path.splitn(2, '/').collect();
|
2026-02-10 18:46:59 +01:00
|
|
|
if parts.len() < 2 {
|
2026-02-14 01:29:34 +01:00
|
|
|
return Err(AppError::bad_request(
|
|
|
|
|
"Path must be {calendar_id}/{uid}.ics",
|
|
|
|
|
));
|
2026-02-10 18:46:59 +01:00
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
let calendar_id = parts[0];
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-03-05 16:57:44 +01:00
|
|
|
let body_bytes = body::to_bytes(req.into_body(), MAX_CALDAV_BODY)
|
2026-02-10 18:46:59 +01:00
|
|
|
.await
|
|
|
|
|
.map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
let ical_data = String::from_utf8(body_bytes.to_vec())
|
|
|
|
|
.map_err(|e| AppError::bad_request(format!("Invalid UTF-8 in iCalendar data: {}", e)))?;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-07-14 17:46:24 +02:00
|
|
|
// Route the PUT through `upsert_ical_events` so a body carrying a
|
|
|
|
|
// master + N per-instance overrides (RFC 5545 §3.8.4.4 — the
|
|
|
|
|
// Thunderbird / Apple Calendar / DAVx⁵ "modify one occurrence"
|
|
|
|
|
// shape) persists each VEVENT to its own row instead of the last
|
|
|
|
|
// one clobbering the master. See AtalayaLabs/OxiCloud#528.
|
|
|
|
|
//
|
|
|
|
|
// Kind-aware error mapping (`AppError::from(DomainError)`):
|
|
|
|
|
// * `InvalidInput` → 400 (malformed iCal / missing DTSTART)
|
|
|
|
|
// * `NotFound` → 404 (calendar doesn't exist / no perm)
|
|
|
|
|
// * `AccessDenied` → 403 (caller lacks Write on the calendar)
|
|
|
|
|
// * anything else → 500 (genuine server bug)
|
|
|
|
|
let create_dto = CreateEventICalDto {
|
|
|
|
|
calendar_id: calendar_id.to_string(),
|
|
|
|
|
ical_data,
|
2026-02-10 18:46:59 +01:00
|
|
|
};
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-07-14 17:46:24 +02:00
|
|
|
let result = calendar_service
|
|
|
|
|
.upsert_ical_events(create_dto, user.id)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(AppError::from)?;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-07-14 17:46:24 +02:00
|
|
|
// The event surface still exposes a single object resource per
|
|
|
|
|
// UID, so we return an ETag anchored on the master row when
|
|
|
|
|
// present, otherwise the first exception's id. This matches the
|
|
|
|
|
// pre-#528 header contract for clients that only understand a
|
|
|
|
|
// single ETag per PUT.
|
|
|
|
|
let etag_source = result
|
|
|
|
|
.events
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|e| e.recurrence_id.is_none())
|
|
|
|
|
.or_else(|| result.events.first())
|
|
|
|
|
.map(|e| e.id.to_string())
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
|
|
|
|
let status = if result.any_inserted {
|
|
|
|
|
StatusCode::CREATED
|
2026-02-10 18:46:59 +01:00
|
|
|
} else {
|
2026-07-14 17:46:24 +02:00
|
|
|
StatusCode::NO_CONTENT
|
|
|
|
|
};
|
2026-02-10 18:46:59 +01:00
|
|
|
|
2026-07-14 17:46:24 +02:00
|
|
|
Ok(Response::builder()
|
|
|
|
|
.status(status)
|
|
|
|
|
.header(header::ETAG, format!("\"{}\"", etag_source))
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.unwrap())
|
2025-04-13 01:04:04 +02:00
|
|
|
}
|
|
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
// ─── GET (.ics) ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async fn handle_get(
|
|
|
|
|
state: Arc<AppState>,
|
|
|
|
|
req: Request<Body>,
|
|
|
|
|
path: &str,
|
|
|
|
|
) -> Result<Response<Body>, AppError> {
|
|
|
|
|
let user = extract_user(&req)?;
|
|
|
|
|
let calendar_service = get_calendar_service(&state)?;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-04-30 09:52:48 +02:00
|
|
|
let effective_path = strip_username_prefix(path);
|
|
|
|
|
let parts: Vec<&str> = effective_path.splitn(2, '/').collect();
|
2026-02-10 18:46:59 +01:00
|
|
|
let calendar_id = parts[0];
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
if parts.len() < 2 {
|
|
|
|
|
// GET on calendar collection
|
2026-02-14 01:29:34 +01:00
|
|
|
let events = calendar_service
|
2026-03-07 14:59:32 +01:00
|
|
|
.list_events(calendar_id, None, None, user.id)
|
2026-02-14 01:29:34 +01:00
|
|
|
.await
|
2026-07-14 22:27:30 +02:00
|
|
|
.map_err(AppError::from)?;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
|
|
|
let calendar = calendar_service
|
2026-03-07 14:59:32 +01:00
|
|
|
.get_calendar(calendar_id, user.id)
|
2026-02-14 01:29:34 +01:00
|
|
|
.await
|
2026-07-14 22:27:30 +02:00
|
|
|
.map_err(AppError::from)?;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
let ical = generate_full_calendar_ical(&calendar.name, &events);
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
Ok(Response::builder()
|
|
|
|
|
.status(StatusCode::OK)
|
|
|
|
|
.header(header::CONTENT_TYPE, "text/calendar; charset=utf-8")
|
|
|
|
|
.header(header::ETAG, format!("\"{}\"", calendar.id))
|
|
|
|
|
.body(Body::from(ical))
|
|
|
|
|
.unwrap())
|
|
|
|
|
} else {
|
2026-06-10 09:52:28 +00:00
|
|
|
// GET on individual event — indexed lookup by iCalendar UID.
|
2026-02-10 18:46:59 +01:00
|
|
|
let event_file = parts[1];
|
|
|
|
|
let ical_uid = event_file.trim_end_matches(".ics");
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-06-10 09:52:28 +00:00
|
|
|
let event = calendar_service
|
|
|
|
|
.get_event_by_ical_uid(calendar_id, ical_uid, user.id)
|
2026-02-14 01:29:34 +01:00
|
|
|
.await
|
2026-07-14 22:27:30 +02:00
|
|
|
.map_err(AppError::from)?
|
2026-02-10 18:46:59 +01:00
|
|
|
.ok_or_else(|| AppError::not_found(format!("Event not found: {}", ical_uid)))?;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-06-10 09:52:28 +00:00
|
|
|
let ical = generate_event_ical(&event);
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
Ok(Response::builder()
|
|
|
|
|
.status(StatusCode::OK)
|
|
|
|
|
.header(header::CONTENT_TYPE, "text/calendar; charset=utf-8")
|
|
|
|
|
.header(header::ETAG, format!("\"{}\"", event.id))
|
|
|
|
|
.body(Body::from(ical))
|
|
|
|
|
.unwrap())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn generate_full_calendar_ical(
|
|
|
|
|
calendar_name: &str,
|
|
|
|
|
events: &[crate::application::dtos::calendar_dto::CalendarEventDto],
|
|
|
|
|
) -> String {
|
2026-03-02 23:58:15 +01:00
|
|
|
// Pre-estimate: ~200 bytes header + ~320 bytes per event
|
|
|
|
|
let mut buf = String::with_capacity(256 + events.len() * 320);
|
|
|
|
|
let _ = write!(
|
|
|
|
|
buf,
|
2026-02-10 18:46:59 +01:00
|
|
|
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//OxiCloud//NONSGML Calendar//EN\r\nX-WR-CALNAME:{}\r\n",
|
|
|
|
|
calendar_name
|
|
|
|
|
);
|
|
|
|
|
for event in events {
|
2026-03-02 23:58:15 +01:00
|
|
|
write_vevent(&mut buf, event);
|
2026-02-10 18:46:59 +01:00
|
|
|
}
|
2026-03-02 23:58:15 +01:00
|
|
|
buf.push_str("END:VCALENDAR\r\n");
|
|
|
|
|
buf
|
2026-02-10 18:46:59 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn generate_event_ical(event: &crate::application::dtos::calendar_dto::CalendarEventDto) -> String {
|
2026-03-02 23:58:15 +01:00
|
|
|
let mut buf = String::with_capacity(512);
|
|
|
|
|
buf.push_str("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//OxiCloud//NONSGML Calendar//EN\r\n");
|
|
|
|
|
write_vevent(&mut buf, event);
|
|
|
|
|
buf.push_str("END:VCALENDAR\r\n");
|
|
|
|
|
buf
|
2026-02-10 18:46:59 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-02 23:58:15 +01:00
|
|
|
/// Writes a VEVENT block directly into `buf` — zero intermediate allocations.
|
2026-03-03 01:49:18 +01:00
|
|
|
fn write_vevent(
|
|
|
|
|
buf: &mut String,
|
|
|
|
|
event: &crate::application::dtos::calendar_dto::CalendarEventDto,
|
|
|
|
|
) {
|
2026-03-02 23:58:15 +01:00
|
|
|
let _ = write!(
|
|
|
|
|
buf,
|
2026-02-10 18:46:59 +01:00
|
|
|
"BEGIN:VEVENT\r\nUID:{}\r\nSUMMARY:{}\r\nDTSTART:{}\r\nDTEND:{}\r\n",
|
|
|
|
|
event.ical_uid,
|
|
|
|
|
event.summary.replace('\n', "\\n"),
|
|
|
|
|
event.start_time.format("%Y%m%dT%H%M%SZ"),
|
|
|
|
|
event.end_time.format("%Y%m%dT%H%M%SZ"),
|
|
|
|
|
);
|
|
|
|
|
if let Some(ref desc) = event.description {
|
2026-03-02 23:58:15 +01:00
|
|
|
let _ = write!(buf, "DESCRIPTION:{}\r\n", desc.replace('\n', "\\n"));
|
2026-02-10 18:46:59 +01:00
|
|
|
}
|
|
|
|
|
if let Some(ref loc) = event.location {
|
2026-03-02 23:58:15 +01:00
|
|
|
let _ = write!(buf, "LOCATION:{}\r\n", loc);
|
2026-02-10 18:46:59 +01:00
|
|
|
}
|
|
|
|
|
if let Some(ref rrule) = event.rrule {
|
2026-03-02 23:58:15 +01:00
|
|
|
let _ = write!(buf, "RRULE:{}\r\n", rrule);
|
2026-02-10 18:46:59 +01:00
|
|
|
}
|
2026-03-02 23:58:15 +01:00
|
|
|
let _ = write!(
|
|
|
|
|
buf,
|
2026-02-10 18:46:59 +01:00
|
|
|
"DTSTAMP:{}\r\nCREATED:{}\r\nLAST-MODIFIED:{}\r\nEND:VEVENT\r\n",
|
|
|
|
|
event.updated_at.format("%Y%m%dT%H%M%SZ"),
|
|
|
|
|
event.created_at.format("%Y%m%dT%H%M%SZ"),
|
|
|
|
|
event.updated_at.format("%Y%m%dT%H%M%SZ"),
|
2026-03-02 23:58:15 +01:00
|
|
|
);
|
2026-02-10 18:46:59 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── DELETE ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async fn handle_delete(
|
|
|
|
|
state: Arc<AppState>,
|
|
|
|
|
req: Request<Body>,
|
|
|
|
|
path: &str,
|
|
|
|
|
) -> Result<Response<Body>, AppError> {
|
|
|
|
|
let user = extract_user(&req)?;
|
|
|
|
|
let calendar_service = get_calendar_service(&state)?;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-04-30 09:52:48 +02:00
|
|
|
let effective_path = strip_username_prefix(path);
|
|
|
|
|
let parts: Vec<&str> = effective_path.splitn(2, '/').collect();
|
2026-02-10 18:46:59 +01:00
|
|
|
let calendar_id = parts[0];
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
if calendar_id.is_empty() {
|
|
|
|
|
return Err(AppError::bad_request("Calendar ID required"));
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
if parts.len() < 2 {
|
2026-02-14 01:29:34 +01:00
|
|
|
calendar_service
|
2026-03-07 14:59:32 +01:00
|
|
|
.delete_calendar(calendar_id, user.id)
|
2026-02-14 01:29:34 +01:00
|
|
|
.await
|
2026-07-14 22:27:30 +02:00
|
|
|
.map_err(AppError::from)?;
|
2026-02-10 18:46:59 +01:00
|
|
|
} else {
|
|
|
|
|
let event_file = parts[1];
|
|
|
|
|
let ical_uid = event_file.trim_end_matches(".ics");
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-06-10 09:52:28 +00:00
|
|
|
// Indexed lookup by iCalendar UID instead of listing the calendar.
|
|
|
|
|
let event = calendar_service
|
|
|
|
|
.get_event_by_ical_uid(calendar_id, ical_uid, user.id)
|
2026-02-14 01:29:34 +01:00
|
|
|
.await
|
2026-07-14 22:27:30 +02:00
|
|
|
.map_err(AppError::from)?
|
2026-02-10 18:46:59 +01:00
|
|
|
.ok_or_else(|| AppError::not_found(format!("Event not found: {}", ical_uid)))?;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
|
|
|
calendar_service
|
2026-03-07 14:59:32 +01:00
|
|
|
.delete_event(&event.id, user.id)
|
2026-02-14 01:29:34 +01:00
|
|
|
.await
|
2026-07-14 22:27:30 +02:00
|
|
|
.map_err(AppError::from)?;
|
2026-02-10 18:46:59 +01:00
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
Ok(Response::builder()
|
|
|
|
|
.status(StatusCode::NO_CONTENT)
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.unwrap())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── PROPPATCH ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async fn handle_proppatch(
|
|
|
|
|
state: Arc<AppState>,
|
|
|
|
|
req: Request<Body>,
|
|
|
|
|
path: &str,
|
|
|
|
|
) -> Result<Response<Body>, AppError> {
|
|
|
|
|
let user = extract_user(&req)?;
|
|
|
|
|
let calendar_service = get_calendar_service(&state)?;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-03-05 16:57:44 +01:00
|
|
|
let body_bytes = body::to_bytes(req.into_body(), MAX_CALDAV_BODY)
|
2026-02-10 18:46:59 +01:00
|
|
|
.await
|
|
|
|
|
.map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-06-30 08:58:13 +02:00
|
|
|
let ops = crate::application::adapters::webdav_adapter::WebDavAdapter::parse_proppatch(
|
|
|
|
|
body_bytes.reader(),
|
|
|
|
|
)
|
|
|
|
|
.map_err(|e| AppError::bad_request(format!("Failed to parse PROPPATCH: {}", e)))?;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-04-30 09:52:48 +02:00
|
|
|
let effective_path = strip_username_prefix(path);
|
|
|
|
|
let calendar_id = effective_path.split('/').next().unwrap_or(effective_path);
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
if calendar_id.is_empty() {
|
|
|
|
|
return Err(AppError::bad_request("Calendar ID required"));
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
let mut update = UpdateCalendarDto {
|
|
|
|
|
name: None,
|
|
|
|
|
description: None,
|
|
|
|
|
color: None,
|
|
|
|
|
is_public: None,
|
|
|
|
|
};
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-06-30 08:58:13 +02:00
|
|
|
for op in &ops {
|
|
|
|
|
if let crate::application::adapters::webdav_adapter::PropPatchOp::Set(prop) = op {
|
|
|
|
|
match prop.name.name.as_str() {
|
|
|
|
|
"displayname" => update.name = Some(prop.value.clone().unwrap_or_default()),
|
|
|
|
|
"calendar-description" => update.description = prop.value.clone(),
|
|
|
|
|
"calendar-color" => update.color = prop.value.clone(),
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
2026-02-10 18:46:59 +01:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
if update.name.is_some() || update.description.is_some() || update.color.is_some() {
|
2026-02-14 01:29:34 +01:00
|
|
|
calendar_service
|
2026-03-07 14:59:32 +01:00
|
|
|
.update_calendar(calendar_id, update, user.id)
|
2026-02-14 01:29:34 +01:00
|
|
|
.await
|
2026-07-14 22:27:30 +02:00
|
|
|
.map_err(AppError::from)?;
|
2026-02-10 18:46:59 +01:00
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
let mut results = Vec::new();
|
2026-06-30 08:58:13 +02:00
|
|
|
for op in &ops {
|
|
|
|
|
match op {
|
|
|
|
|
crate::application::adapters::webdav_adapter::PropPatchOp::Set(prop) => {
|
|
|
|
|
results.push((&prop.name, true));
|
|
|
|
|
}
|
|
|
|
|
crate::application::adapters::webdav_adapter::PropPatchOp::Remove(name) => {
|
|
|
|
|
results.push((name, true));
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-10 18:46:59 +01:00
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
let href = format!("/caldav/{}", path);
|
|
|
|
|
let mut response_body = Vec::new();
|
|
|
|
|
crate::application::adapters::webdav_adapter::WebDavAdapter::generate_proppatch_response(
|
|
|
|
|
&mut response_body,
|
|
|
|
|
&href,
|
|
|
|
|
&results,
|
2026-02-14 01:29:34 +01:00
|
|
|
)
|
|
|
|
|
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
|
|
|
|
|
|
2026-02-10 18:46:59 +01:00
|
|
|
Ok(Response::builder()
|
|
|
|
|
.status(StatusCode::MULTI_STATUS)
|
|
|
|
|
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
|
|
|
|
.body(Body::from(response_body))
|
|
|
|
|
.unwrap())
|
2026-02-14 01:29:34 +01:00
|
|
|
}
|
2026-04-30 09:52:48 +02:00
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::strip_username_prefix;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_strip_username_prefix_uuid_only() {
|
|
|
|
|
let uuid = "ae8ae236-709f-4939-b766-37ad589ac7f2";
|
|
|
|
|
assert_eq!(strip_username_prefix(uuid), uuid);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_strip_username_prefix_uuid_with_event() {
|
|
|
|
|
let path = "ae8ae236-709f-4939-b766-37ad589ac7f2/event.ics";
|
|
|
|
|
assert_eq!(strip_username_prefix(path), path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_strip_username_prefix_username_and_uuid() {
|
|
|
|
|
let path = "timm/ae8ae236-709f-4939-b766-37ad589ac7f2";
|
|
|
|
|
assert_eq!(
|
|
|
|
|
strip_username_prefix(path),
|
|
|
|
|
"ae8ae236-709f-4939-b766-37ad589ac7f2"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_strip_username_prefix_username_uuid_and_event() {
|
|
|
|
|
let path = "timm/ae8ae236-709f-4939-b766-37ad589ac7f2/event.ics";
|
|
|
|
|
assert_eq!(
|
|
|
|
|
strip_username_prefix(path),
|
|
|
|
|
"ae8ae236-709f-4939-b766-37ad589ac7f2/event.ics"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_strip_username_prefix_bare_username() {
|
|
|
|
|
assert_eq!(strip_username_prefix("timm"), "");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_strip_username_prefix_empty() {
|
|
|
|
|
assert_eq!(strip_username_prefix(""), "");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_strip_username_prefix_email_style_username() {
|
|
|
|
|
let path = "user@example.com/ae8ae236-709f-4939-b766-37ad589ac7f2/event.ics";
|
|
|
|
|
assert_eq!(
|
|
|
|
|
strip_username_prefix(path),
|
|
|
|
|
"ae8ae236-709f-4939-b766-37ad589ac7f2/event.ics"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|