fix(528): pass3: PUT with RECURRENCE-ID
This commit is contained in:
@@ -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) ──────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user