pim_views.rs
| 1 | //! What the web UI shows of calendars and address books (session-authenticated): |
| 2 | //! - `GET {PIM_INSTANCES}` — occurrences in a range |
| 3 | //! - `GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}` — one event or contact |
| 4 | //! - `GET {PIM_CONTACTS}` — contacts, searched |
| 5 | //! - `GET {PIM_INVITATIONS}`, `POST` a reply — unanswered invitations |
| 6 | //! |
| 7 | //! The UI never parses iCalendar or vCard: these endpoints do. |
| 8 | |
| 9 | use std::collections::{HashMap, HashSet}; |
| 10 | use std::sync::Arc; |
| 11 | |
| 12 | use api_types::{ |
| 13 | OBJECTS_SUFFIX, OkResp, PHOTO_SUFFIX, PIM_COLLECTIONS, PimAttendee, PimContact, |
| 14 | PimContactDetail, PimEventDetail, PimInstance, PimInstances, PimInvitation, PimLabeled, |
| 15 | PimObjectDetail, PimPerson, PimReply, PimShareMode, |
| 16 | }; |
| 17 | use axum::Json; |
| 18 | use axum::extract::{Path as AxumPath, Query, State}; |
| 19 | use axum::http::StatusCode; |
| 20 | use chrono::{DateTime, SecondsFormat, TimeDelta, Utc}; |
| 21 | use pimdav::calcard::icalendar::{ |
| 22 | ICalendar, ICalendarComponentType, ICalendarParticipationStatus, ICalendarProperty, |
| 23 | }; |
| 24 | use pimdav::expand::expand; |
| 25 | use pimdav::itip::{self, Role}; |
| 26 | use pimdav::render; |
| 27 | use pimdav::view::{self, Card, EventInfo, Person}; |
| 28 | use pimdav::zone::{self, Zone}; |
| 29 | use serde::Deserialize; |
| 30 | |
| 31 | use crate::api::common::SessionUser; |
| 32 | use crate::api::common::blocking; |
| 33 | use crate::api::pim::{BIRTHDAYS, DIRECTORY, INBOX, generated, member_of, members_of, seg}; |
| 34 | use crate::api::pim_api::reachable; |
| 35 | use crate::api::pim_schedule::{self, Directory, Writer}; |
| 36 | use crate::db::{PimKind, PimObject}; |
| 37 | use crate::error::{ApiError, AppState}; |
| 38 | |
| 39 | /// The widest range `GET {PIM_INSTANCES}` expands. |
| 40 | const MAX_RANGE_DAYS: i64 = 400; |
| 41 | /// The most instances one answer holds. |
| 42 | const MAX_INSTANCES: usize = 5000; |
| 43 | /// How far ahead an invitation's next instance is looked for. |
| 44 | const INVITATION_HORIZON_DAYS: i64 = 3653; |
| 45 | |
| 46 | fn rfc3339(t: DateTime<Utc>) -> String { |
| 47 | t.to_rfc3339_opts(SecondsFormat::Secs, true) |
| 48 | } |
| 49 | |
| 50 | fn parse_time(s: &str) -> Result<DateTime<Utc>, ApiError> { |
| 51 | DateTime::parse_from_rfc3339(s) |
| 52 | .map(|t| t.with_timezone(&Utc)) |
| 53 | .map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "times must be RFC 3339")) |
| 54 | } |
| 55 | |
| 56 | /// The zone all-day and floating times are read in: the viewer's. |
| 57 | fn floating(tz: Option<&str>) -> Zone { |
| 58 | tz.and_then(zone::by_name).unwrap_or(Zone::Utc) |
| 59 | } |
| 60 | |
| 61 | fn wanted(ids: Option<&str>) -> Option<HashSet<i64>> { |
| 62 | ids.map(|s| s.split(',').filter_map(|i| i.trim().parse().ok()).collect()) |
| 63 | } |
| 64 | |
| 65 | /// `(collection id, owner principal)` of the calendars or address books the |
| 66 | /// signed-in user reads: own ones, the generated one and lent ones. Not the |
| 67 | /// scheduling inbox. |
| 68 | async fn readable( |
| 69 | state: &AppState, |
| 70 | auth: &SessionUser, |
| 71 | kind: PimKind, |
| 72 | ) -> Result<Vec<(i64, i64)>, ApiError> { |
| 73 | let db = &state.db; |
| 74 | let pid = db.principal_of(auth.user.id).await?; |
| 75 | db.pim_ensure_defaults(pid).await?; |
| 76 | let mut out: Vec<(i64, i64)> = db |
| 77 | .pim_collections(pid, kind) |
| 78 | .await? |
| 79 | .into_iter() |
| 80 | .filter(|c| c.slug != INBOX) |
| 81 | .map(|c| (c.id, pid)) |
| 82 | .collect(); |
| 83 | out.push(match kind { |
| 84 | PimKind::Calendar => (BIRTHDAYS, pid), |
| 85 | PimKind::Addressbook => (DIRECTORY, pid), |
| 86 | }); |
| 87 | for (col, _, _) in db.pim_shared_collections(auth.user.id, kind).await? { |
| 88 | if let Some((owner, _, _)) = db.pim_collection_by_id(col.id).await? { |
| 89 | out.push((col.id, owner)); |
| 90 | } |
| 91 | } |
| 92 | Ok(out) |
| 93 | } |
| 94 | |
| 95 | fn parse(data: &[u8]) -> Option<ICalendar> { |
| 96 | render::parse(&String::from_utf8_lossy(data)) |
| 97 | } |
| 98 | |
| 99 | fn display(p: &Person) -> String { |
| 100 | p.name.clone().unwrap_or_else(|| { |
| 101 | p.address |
| 102 | .strip_prefix("mailto:") |
| 103 | .unwrap_or(&p.address) |
| 104 | .to_string() |
| 105 | }) |
| 106 | } |
| 107 | |
| 108 | fn wire_person(p: &Person) -> PimPerson { |
| 109 | PimPerson { |
| 110 | name: p.name.clone(), |
| 111 | address: p.address.clone(), |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | #[derive(Deserialize)] |
| 116 | pub struct InstancesQuery { |
| 117 | from: String, |
| 118 | to: String, |
| 119 | tz: Option<String>, |
| 120 | collections: Option<String>, |
| 121 | } |
| 122 | |
| 123 | /// GET {PIM_INSTANCES} |
| 124 | // ponytail: parses and expands every object of every calendar on each call. |
| 125 | // Index each object's first and last instance if large calendars get slow. |
| 126 | pub async fn instances( |
| 127 | State(state): State<Arc<AppState>>, |
| 128 | auth: SessionUser, |
| 129 | Query(q): Query<InstancesQuery>, |
| 130 | ) -> Result<Json<PimInstances>, ApiError> { |
| 131 | let (from, to) = (parse_time(&q.from)?, parse_time(&q.to)?); |
| 132 | if to <= from || to - from > TimeDelta::days(MAX_RANGE_DAYS) { |
| 133 | return Err(ApiError::new( |
| 134 | StatusCode::BAD_REQUEST, |
| 135 | "the range must be positive and at most 400 days", |
| 136 | )); |
| 137 | } |
| 138 | let zone = floating(q.tz.as_deref()); |
| 139 | let wanted = wanted(q.collections.as_deref()); |
| 140 | let dir = Directory::load(&state).await?; |
| 141 | let mut sources = Vec::new(); |
| 142 | for (id, owner) in readable(&state, &auth, PimKind::Calendar).await? { |
| 143 | if wanted.as_ref().is_some_and(|w| !w.contains(&id)) { |
| 144 | continue; |
| 145 | } |
| 146 | sources.push((id, owner, members_of(&state, owner, id).await?)); |
| 147 | } |
| 148 | let (mut out, truncated) = blocking(move || -> Result<_, ApiError> { |
| 149 | let mut out = Vec::new(); |
| 150 | let mut truncated = false; |
| 151 | 'all: for (id, owner, members) in sources { |
| 152 | let owns = dir.is(owner); |
| 153 | for (obj, data) in members { |
| 154 | let Some(cal) = parse(&data) else { |
| 155 | continue; |
| 156 | }; |
| 157 | let exp = expand(&cal, from..to, zone.clone()); |
| 158 | truncated |= exp.truncated; |
| 159 | let mut infos: HashMap<usize, EventInfo> = HashMap::new(); |
| 160 | for i in exp.instances { |
| 161 | if cal.components[i.component].component_type != ICalendarComponentType::VEvent |
| 162 | { |
| 163 | continue; |
| 164 | } |
| 165 | if out.len() == MAX_INSTANCES { |
| 166 | truncated = true; |
| 167 | break 'all; |
| 168 | } |
| 169 | let info = infos |
| 170 | .entry(i.component) |
| 171 | .or_insert_with(|| view::event_info(&cal, i.component, &owns)); |
| 172 | out.push(PimInstance { |
| 173 | collection_id: id, |
| 174 | name: obj.name.clone(), |
| 175 | uid: obj.uid.clone(), |
| 176 | recurrence_id: i.recurrence_id.map(rfc3339), |
| 177 | start: rfc3339(i.start), |
| 178 | end: rfc3339(i.end), |
| 179 | all_day: info.all_day, |
| 180 | component: info.component.clone(), |
| 181 | summary: info.summary.clone(), |
| 182 | location: info.location.clone(), |
| 183 | status: info.status.clone(), |
| 184 | transparent: info.transparent, |
| 185 | has_attendees: !info.attendees.is_empty(), |
| 186 | partstat: info.partstat().map(str::to_string), |
| 187 | organizer: info.organizer.as_ref().map(display), |
| 188 | }); |
| 189 | } |
| 190 | } |
| 191 | } |
| 192 | Ok((out, truncated)) |
| 193 | }) |
| 194 | .await?; |
| 195 | out.sort_by(|a, b| (&a.start, &a.end).cmp(&(&b.start, &b.end))); |
| 196 | Ok(Json(PimInstances { |
| 197 | instances: out, |
| 198 | truncated, |
| 199 | })) |
| 200 | } |
| 201 | |
| 202 | #[derive(Deserialize)] |
| 203 | pub struct DetailQuery { |
| 204 | recurrence_id: Option<String>, |
| 205 | tz: Option<String>, |
| 206 | } |
| 207 | |
| 208 | /// GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name} |
| 209 | pub async fn object( |
| 210 | State(state): State<Arc<AppState>>, |
| 211 | auth: SessionUser, |
| 212 | AxumPath((id, name)): AxumPath<(i64, String)>, |
| 213 | Query(q): Query<DetailQuery>, |
| 214 | ) -> Result<Json<PimObjectDetail>, ApiError> { |
| 215 | let not_found = || ApiError::new(StatusCode::NOT_FOUND, "object not found"); |
| 216 | let (owner, kind, col, _) = reachable(&state, &auth, id).await?; |
| 217 | let found = member_of(&state, owner, col.id, &name).await?; |
| 218 | let (obj, data) = &found.ok_or_else(not_found)?; |
| 219 | match kind { |
| 220 | PimKind::Calendar => { |
| 221 | let cal = parse(data).ok_or_else(not_found)?; |
| 222 | let zone = floating(q.tz.as_deref()); |
| 223 | let rid = q.recurrence_id.as_deref().map(parse_time).transpose()?; |
| 224 | let index = view::component_for(&cal, rid, &zone).ok_or_else(not_found)?; |
| 225 | let dir = Directory::load(&state).await?; |
| 226 | let owns = dir.is(owner); |
| 227 | let instance = view::instance_for(&cal, index, rid, &zone); |
| 228 | // An instance a THISANDFUTURE override moved takes its text too. |
| 229 | let info = view::event_info( |
| 230 | &cal, |
| 231 | instance.as_ref().map_or(index, |i| i.component), |
| 232 | &owns, |
| 233 | ); |
| 234 | let answers = may_answer(&state, &auth, owner, col.id).await?; |
| 235 | let attendee = matches!(itip::role(&cal, &owns), Ok(Role::Attendee)); |
| 236 | Ok(Json(PimObjectDetail::Event(PimEventDetail { |
| 237 | collection_id: id, |
| 238 | name: obj.name.clone(), |
| 239 | uid: obj.uid.clone(), |
| 240 | component: info.component, |
| 241 | summary: info.summary, |
| 242 | description: info.description, |
| 243 | location: info.location, |
| 244 | url: info.url, |
| 245 | status: info.status, |
| 246 | transparent: info.transparent, |
| 247 | all_day: info.all_day, |
| 248 | start: instance.as_ref().map(|i| rfc3339(i.start)), |
| 249 | end: instance.as_ref().map(|i| rfc3339(i.end)), |
| 250 | categories: info.categories, |
| 251 | rrule: info.rrule, |
| 252 | organizer: info.organizer.as_ref().map(wire_person), |
| 253 | attendees: info |
| 254 | .attendees |
| 255 | .iter() |
| 256 | .map(|a| PimAttendee { |
| 257 | person: wire_person(&a.person), |
| 258 | partstat: a.partstat.clone(), |
| 259 | role: a.role.clone(), |
| 260 | is_owner: a.is_owner, |
| 261 | }) |
| 262 | .collect(), |
| 263 | is_override: cal.components[index].has_property(&ICalendarProperty::RecurrenceId), |
| 264 | series_partstat: view::component_for(&cal, None, &zone) |
| 265 | .filter(|&m| !cal.components[m].has_property(&ICalendarProperty::RecurrenceId)) |
| 266 | .and_then(|m| { |
| 267 | view::event_info(&cal, m, &owns) |
| 268 | .partstat() |
| 269 | .map(str::to_string) |
| 270 | }), |
| 271 | can_reply: attendee && answers, |
| 272 | }))) |
| 273 | } |
| 274 | PimKind::Addressbook => { |
| 275 | let card = view::card(&String::from_utf8_lossy(data)); |
| 276 | let members = match card.is_group { |
| 277 | true => { |
| 278 | let by_uid: HashMap<String, String> = members_of(&state, owner, col.id) |
| 279 | .await? |
| 280 | .iter() |
| 281 | .map(|(_, d)| view::card(&String::from_utf8_lossy(d))) |
| 282 | .filter_map(|c| Some((c.uid?, c.full_name))) |
| 283 | .collect(); |
| 284 | card.members |
| 285 | .iter() |
| 286 | .map(|m| by_uid.get(m).cloned().unwrap_or_else(|| m.clone())) |
| 287 | .collect() |
| 288 | } |
| 289 | false => Vec::new(), |
| 290 | }; |
| 291 | // photo() reads stored objects only. |
| 292 | let photo_url = (card.has_photo && !generated(col.id)).then(|| { |
| 293 | format!( |
| 294 | "{PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{}{PHOTO_SUFFIX}", |
| 295 | seg(&obj.name) |
| 296 | ) |
| 297 | }); |
| 298 | Ok(Json(PimObjectDetail::Contact(contact_detail( |
| 299 | id, obj, card, members, photo_url, |
| 300 | )))) |
| 301 | } |
| 302 | } |
| 303 | } |
| 304 | |
| 305 | fn labeled(v: Vec<view::Labeled>) -> Vec<PimLabeled> { |
| 306 | v.into_iter() |
| 307 | .map(|l| PimLabeled { |
| 308 | label: l.label, |
| 309 | value: l.value, |
| 310 | }) |
| 311 | .collect() |
| 312 | } |
| 313 | |
| 314 | fn contact_detail( |
| 315 | id: i64, |
| 316 | obj: &PimObject, |
| 317 | card: Card, |
| 318 | members: Vec<String>, |
| 319 | photo_url: Option<String>, |
| 320 | ) -> PimContactDetail { |
| 321 | PimContactDetail { |
| 322 | collection_id: id, |
| 323 | name: obj.name.clone(), |
| 324 | uid: card.uid, |
| 325 | full_name: card.full_name, |
| 326 | org: card.org, |
| 327 | title: card.title, |
| 328 | emails: labeled(card.emails), |
| 329 | phones: labeled(card.phones), |
| 330 | addresses: labeled(card.addresses), |
| 331 | urls: labeled(card.urls), |
| 332 | birthday: card.birthday, |
| 333 | anniversary: card.anniversary, |
| 334 | note: card.note, |
| 335 | is_group: card.is_group, |
| 336 | members, |
| 337 | photo_url, |
| 338 | } |
| 339 | } |
| 340 | |
| 341 | /// Whether the signed-in user may answer invitations in a calendar of |
| 342 | /// `owner`: their own, or one lent with `rw+schedule`. |
| 343 | pub(super) async fn may_answer( |
| 344 | state: &AppState, |
| 345 | auth: &SessionUser, |
| 346 | owner: i64, |
| 347 | collection_id: i64, |
| 348 | ) -> Result<bool, ApiError> { |
| 349 | if collection_id <= DIRECTORY { |
| 350 | return Ok(false); |
| 351 | } |
| 352 | if owner == state.db.principal_of(auth.user.id).await? { |
| 353 | return Ok(true); |
| 354 | } |
| 355 | Ok(state |
| 356 | .db |
| 357 | .pim_shared_collections(auth.user.id, PimKind::Calendar) |
| 358 | .await? |
| 359 | .into_iter() |
| 360 | .any(|(c, _, mode)| c.id == collection_id && mode == PimShareMode::RwSchedule)) |
| 361 | } |
| 362 | |
| 363 | #[derive(Deserialize)] |
| 364 | pub struct ContactsQuery { |
| 365 | q: Option<String>, |
| 366 | collections: Option<String>, |
| 367 | } |
| 368 | |
| 369 | /// GET {PIM_CONTACTS} |
| 370 | pub async fn contacts( |
| 371 | State(state): State<Arc<AppState>>, |
| 372 | auth: SessionUser, |
| 373 | Query(q): Query<ContactsQuery>, |
| 374 | ) -> Result<Json<Vec<PimContact>>, ApiError> { |
| 375 | let needle = q.q.as_deref().map(str::trim).unwrap_or("").to_lowercase(); |
| 376 | let wanted = wanted(q.collections.as_deref()); |
| 377 | let mut sources = Vec::new(); |
| 378 | for (id, owner) in readable(&state, &auth, PimKind::Addressbook).await? { |
| 379 | if wanted.as_ref().is_some_and(|w| !w.contains(&id)) { |
| 380 | continue; |
| 381 | } |
| 382 | sources.push((id, members_of(&state, owner, id).await?)); |
| 383 | } |
| 384 | let mut out = blocking(move || -> Result<_, ApiError> { |
| 385 | let mut out = Vec::new(); |
| 386 | for (id, members) in sources { |
| 387 | for (obj, data) in members { |
| 388 | let c = view::card(&String::from_utf8_lossy(&data)); |
| 389 | let hit = needle.is_empty() |
| 390 | || [Some(&c.full_name), c.org.as_ref()] |
| 391 | .into_iter() |
| 392 | .flatten() |
| 393 | .chain(c.emails.iter().map(|e| &e.value)) |
| 394 | .chain(c.phones.iter().map(|p| &p.value)) |
| 395 | .any(|v| v.to_lowercase().contains(&needle)); |
| 396 | if !hit { |
| 397 | continue; |
| 398 | } |
| 399 | out.push(PimContact { |
| 400 | collection_id: id, |
| 401 | name: obj.name, |
| 402 | full_name: c.full_name, |
| 403 | org: c.org, |
| 404 | email: c.emails.into_iter().next().map(|e| e.value), |
| 405 | phone: c.phones.into_iter().next().map(|p| p.value), |
| 406 | has_photo: c.has_photo && !generated(id), |
| 407 | is_group: c.is_group, |
| 408 | }); |
| 409 | } |
| 410 | } |
| 411 | Ok(out) |
| 412 | }) |
| 413 | .await?; |
| 414 | out.sort_by_cached_key(|c| (c.full_name.to_lowercase(), c.collection_id)); |
| 415 | Ok(Json(out)) |
| 416 | } |
| 417 | |
| 418 | #[derive(Deserialize)] |
| 419 | pub struct TzQuery { |
| 420 | tz: Option<String>, |
| 421 | } |
| 422 | |
| 423 | /// GET {PIM_INVITATIONS}: in the signed-in user's own calendars, the series |
| 424 | /// and instances they are invited to and have not answered, and whose next |
| 425 | /// instance is still ahead. |
| 426 | pub async fn invitations( |
| 427 | State(state): State<Arc<AppState>>, |
| 428 | auth: SessionUser, |
| 429 | Query(q): Query<TzQuery>, |
| 430 | ) -> Result<Json<Vec<PimInvitation>>, ApiError> { |
| 431 | let db = &state.db; |
| 432 | let pid = db.principal_of(auth.user.id).await?; |
| 433 | let dir = Directory::load(&state).await?; |
| 434 | let owns = dir.is(pid); |
| 435 | let zone = floating(q.tz.as_deref()); |
| 436 | let now = Utc::now(); |
| 437 | let window = now..now + TimeDelta::days(INVITATION_HORIZON_DAYS); |
| 438 | let mut out = Vec::new(); |
| 439 | for col in db.pim_collections(pid, PimKind::Calendar).await? { |
| 440 | if col.slug == INBOX { |
| 441 | continue; |
| 442 | } |
| 443 | for (obj, data) in db.pim_objects_with_data(col.id).await? { |
| 444 | // Most objects invite no one: skip their parse. |
| 445 | if !data.windows(8).any(|w| w.eq_ignore_ascii_case(b"ATTENDEE")) { |
| 446 | continue; |
| 447 | } |
| 448 | let Some(cal) = parse(&data) else { |
| 449 | continue; |
| 450 | }; |
| 451 | if !matches!(itip::role(&cal, &owns), Ok(Role::Attendee)) { |
| 452 | continue; |
| 453 | } |
| 454 | let instances = expand(&cal, window.clone(), zone.clone()).instances; |
| 455 | for (index, c) in cal.components.iter().enumerate() { |
| 456 | if c.component_type != ICalendarComponentType::VEvent { |
| 457 | continue; |
| 458 | } |
| 459 | let info = view::event_info(&cal, index, &owns); |
| 460 | if info.partstat() != Some("NEEDS-ACTION") |
| 461 | || info.status.as_deref() == Some("CANCELLED") |
| 462 | { |
| 463 | continue; |
| 464 | } |
| 465 | let Some(next) = instances.iter().find(|i| i.component == index) else { |
| 466 | continue; |
| 467 | }; |
| 468 | let is_override = c.has_property(&ICalendarProperty::RecurrenceId); |
| 469 | out.push(PimInvitation { |
| 470 | collection_id: col.id, |
| 471 | name: obj.name.clone(), |
| 472 | uid: obj.uid.clone(), |
| 473 | recurrence_id: is_override |
| 474 | .then_some(next.recurrence_id) |
| 475 | .flatten() |
| 476 | .map(rfc3339), |
| 477 | summary: info.summary.clone(), |
| 478 | location: info.location.clone(), |
| 479 | organizer: info.organizer.as_ref().map(wire_person), |
| 480 | start: rfc3339(next.start), |
| 481 | end: rfc3339(next.end), |
| 482 | all_day: info.all_day, |
| 483 | recurring: info.rrule.is_some() && !is_override, |
| 484 | rrule: info.rrule.clone().filter(|_| !is_override), |
| 485 | }); |
| 486 | } |
| 487 | } |
| 488 | } |
| 489 | out.sort_by(|a, b| a.start.cmp(&b.start)); |
| 490 | Ok(Json(out)) |
| 491 | } |
| 492 | |
| 493 | /// POST {PIM_INVITATIONS}: writes the answer into the calendar owner's copy |
| 494 | /// through the same path as a client's PUT, so the organizer gets the REPLY. |
| 495 | pub async fn reply( |
| 496 | State(state): State<Arc<AppState>>, |
| 497 | auth: SessionUser, |
| 498 | Json(body): Json<PimReply>, |
| 499 | ) -> Result<Json<OkResp>, ApiError> { |
| 500 | let answer = match body.partstat.to_ascii_uppercase().as_str() { |
| 501 | "ACCEPTED" => ICalendarParticipationStatus::Accepted, |
| 502 | "TENTATIVE" => ICalendarParticipationStatus::Tentative, |
| 503 | "DECLINED" => ICalendarParticipationStatus::Declined, |
| 504 | _ => { |
| 505 | return Err(ApiError::new( |
| 506 | StatusCode::BAD_REQUEST, |
| 507 | "partstat must be ACCEPTED, TENTATIVE or DECLINED", |
| 508 | )); |
| 509 | } |
| 510 | }; |
| 511 | let rid = body.recurrence_id.as_deref().map(parse_time).transpose()?; |
| 512 | let _lock = pim_schedule::LOCK.lock().await; |
| 513 | let (owner, kind, col, _) = reachable(&state, &auth, body.collection_id).await?; |
| 514 | if kind != PimKind::Calendar || !may_answer(&state, &auth, owner, col.id).await? { |
| 515 | return Err(ApiError::new(StatusCode::FORBIDDEN, "cannot answer here")); |
| 516 | } |
| 517 | let not_found = || ApiError::new(StatusCode::NOT_FOUND, "object not found"); |
| 518 | let (obj, old) = state |
| 519 | .db |
| 520 | .pim_object(col.id, &body.name) |
| 521 | .await? |
| 522 | .ok_or_else(not_found)?; |
| 523 | let cal = parse(&old).ok_or_else(not_found)?; |
| 524 | let dir = Directory::load(&state).await?; |
| 525 | let principal = dir.get(owner).cloned().ok_or_else(not_found)?; |
| 526 | let owns = dir.is(owner); |
| 527 | if !matches!(itip::role(&cal, &owns), Ok(Role::Attendee)) { |
| 528 | return Err(ApiError::new( |
| 529 | StatusCode::BAD_REQUEST, |
| 530 | "the calendar owner is not an attendee", |
| 531 | )); |
| 532 | } |
| 533 | let zone = floating(body.tz.as_deref()); |
| 534 | let new = itip::respond(&cal, &owns, answer, rid, &zone) |
| 535 | .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "instance not found"))?; |
| 536 | let new = render::write(&new); |
| 537 | let me = state.db.principal_of(auth.user.id).await?; |
| 538 | let w = Writer::new(&principal, me, &auth.user.name, true); |
| 539 | let stored = match pim_schedule::put( |
| 540 | &state, |
| 541 | &dir, |
| 542 | &w, |
| 543 | (col.id, &obj.name), |
| 544 | Some(&old), |
| 545 | new.as_bytes(), |
| 546 | ) |
| 547 | .await? |
| 548 | { |
| 549 | Ok(s) => s, |
| 550 | Err(condition) => return Err(ApiError::new(StatusCode::FORBIDDEN, &condition.name)), |
| 551 | }; |
| 552 | state.db.pim_apply(&stored.into_ops(col.id, obj)).await?; |
| 553 | Ok(Json(OkResp {})) |
| 554 | } |
| 555 |