fix(528): pass3: PUT with RECURRENCE-ID

This commit is contained in:
Edouard Vanbelle
2026-07-14 17:46:24 +02:00
parent 02f67f7a5c
commit 7966c7178a
11 changed files with 805 additions and 79 deletions
+39 -75
View File
@@ -627,87 +627,51 @@ async fn handle_put(
let ical_data = String::from_utf8(body_bytes.to_vec())
.map_err(|e| AppError::bad_request(format!("Invalid UTF-8 in iCalendar data: {}", e)))?;
let ical_uid = extract_uid_from_ical(&ical_data);
// Indexed single-row lookup — listing the whole calendar (every row
// with its ical_data) to find one UID made imports O(N²).
let existing = if let Some(ref uid) = ical_uid {
calendar_service
.get_event_by_ical_uid(calendar_id, uid, user.id)
.await
.unwrap_or_default()
} else {
None
// 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,
};
if let Some(existing_event) = existing {
// Update existing event — re-create from iCal for full fidelity.
// Both calls use `AppError::from` — the delete propagates
// NotFound/AccessDenied as 404/403, and the recreate propagates
// InvalidInput on malformed iCalendar as 400 (see comment on
// create_event_from_ical below).
calendar_service
.delete_event(&existing_event.id, user.id)
.await
.map_err(AppError::from)?;
let result = calendar_service
.upsert_ical_events(create_dto, user.id)
.await
.map_err(AppError::from)?;
let create_dto = CreateEventICalDto {
calendar_id: calendar_id.to_string(),
ical_data,
};
let event = calendar_service
.create_event_from_ical(create_dto, user.id)
.await
.map_err(AppError::from)?;
// 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();
Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.header(header::ETAG, format!("\"{}\"", event.id))
.body(Body::empty())
.unwrap())
let status = if result.any_inserted {
StatusCode::CREATED
} else {
let create_dto = CreateEventICalDto {
calendar_id: calendar_id.to_string(),
ical_data,
};
StatusCode::NO_CONTENT
};
// `AppError::from(DomainError)` (via the `From` impl in
// `interfaces/errors.rs`) maps the ErrorKind onto the correct
// HTTP status:
// * `InvalidInput` → 400 (e.g. "Missing DTSTART in iCalendar
// data" from `CalendarEvent::from_ical`) — this is the fix
// for AtalayaLabs/OxiCloud#545 comment from `funboytwo`.
// * `NotFound` → 404 (parent calendar doesn't exist)
// * `AccessDenied` → 403 (caller lacks Write on the calendar)
// * `DatabaseError`/`InternalError` → 500 (genuine server bug)
//
// The old `map_err(|e| AppError::internal_error(...))` was
// blanket-wrapping every case as 500, hiding client-input bugs
// as opaque server errors. Downstream monitoring (500 rate,
// pager alerts) took the false hit; users saw an unhelpful
// "Internal Server Error" for their own bad iCalendar.
let event = calendar_service
.create_event_from_ical(create_dto, user.id)
.await
.map_err(AppError::from)?;
Ok(Response::builder()
.status(StatusCode::CREATED)
.header(header::ETAG, format!("\"{}\"", event.id))
.body(Body::empty())
.unwrap())
}
}
/// Extract UID from iCalendar data
fn extract_uid_from_ical(ical_data: &str) -> Option<String> {
for line in ical_data.lines() {
let trimmed = line.trim();
if let Some(stripped) = trimmed.strip_prefix("UID:") {
return Some(stripped.trim().to_string());
}
}
None
Ok(Response::builder()
.status(status)
.header(header::ETAG, format!("\"{}\"", etag_source))
.body(Body::empty())
.unwrap())
}
// ─── GET (.ics) ──────────────────────────────────────────────────────