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