render.rs
| 1 | //! The `calendar-data` and `address-data` a REPORT returns: the whole |
| 2 | //! object, a part of it, or its recurrence set expanded (RFC 4791, 9.6; |
| 3 | //! RFC 6352, 10.4). |
| 4 | |
| 5 | use std::collections::{HashMap, HashSet}; |
| 6 | |
| 7 | use calcard::common::{Data, PartialDateTime}; |
| 8 | use calcard::icalendar::{ |
| 9 | ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarParameter, |
| 10 | ICalendarParameterName, ICalendarParameterValue, ICalendarProperty, ICalendarValue, |
| 11 | ICalendarValueType, |
| 12 | }; |
| 13 | use calcard::vcard::{VCard, VCardValue, VCardVersion}; |
| 14 | use chrono::{DateTime, Utc}; |
| 15 | use xmltree::Element; |
| 16 | |
| 17 | use crate::expand::{expand, original, stamp}; |
| 18 | use crate::filter::{TimeRange, time_range}; |
| 19 | use crate::freebusy::period; |
| 20 | use crate::report::Refused; |
| 21 | use crate::xml::{CALDAV, CARDDAV, Name, elements}; |
| 22 | use crate::zone::{Zone, Zones}; |
| 23 | |
| 24 | /// Instances one object may expand into in one response. |
| 25 | const MAX_EXPANDED: usize = 10_000; |
| 26 | |
| 27 | /// Bytes one object may expand into in one response. |
| 28 | const MAX_EXPANDED_BYTES: usize = 16 * 1024 * 1024; |
| 29 | |
| 30 | #[derive(Debug, Clone, Default, PartialEq)] |
| 31 | pub struct CalendarData { |
| 32 | /// `None` returns every component and property. |
| 33 | pub comp: Option<CompSelect>, |
| 34 | pub expand: Option<TimeRange>, |
| 35 | pub limit_recurrence: Option<TimeRange>, |
| 36 | pub limit_freebusy: Option<TimeRange>, |
| 37 | } |
| 38 | |
| 39 | #[derive(Debug, Clone, PartialEq)] |
| 40 | pub struct CompSelect { |
| 41 | /// Upper case. |
| 42 | pub name: String, |
| 43 | /// `None` for `allprop`. |
| 44 | pub props: Option<Vec<PropSelect>>, |
| 45 | /// `None` for `allcomp`. |
| 46 | pub comps: Option<Vec<CompSelect>>, |
| 47 | } |
| 48 | |
| 49 | #[derive(Debug, Clone, PartialEq)] |
| 50 | pub struct PropSelect { |
| 51 | pub name: String, |
| 52 | pub novalue: bool, |
| 53 | } |
| 54 | |
| 55 | #[derive(Debug, Clone, Default, PartialEq)] |
| 56 | pub struct AddressData { |
| 57 | /// `None` for `allprop`. |
| 58 | pub props: Option<Vec<PropSelect>>, |
| 59 | pub version: Option<VCardVersion>, |
| 60 | } |
| 61 | |
| 62 | /// The expansion would exceed [`MAX_EXPANDED`] instances or |
| 63 | /// [`MAX_EXPANDED_BYTES`]. |
| 64 | #[derive(Debug, PartialEq, Eq)] |
| 65 | pub struct TooManyInstances; |
| 66 | |
| 67 | pub fn calendar_request(e: &Element) -> Result<CalendarData, Refused> { |
| 68 | let content_type = e.attributes.get("content-type").map(String::as_str); |
| 69 | let version = e.attributes.get("version").map(String::as_str); |
| 70 | if content_type.is_some_and(|t| t != "text/calendar") || version.is_some_and(|v| v != "2.0") { |
| 71 | return Err(Refused::Condition(Name::new( |
| 72 | CALDAV, |
| 73 | "supported-calendar-data", |
| 74 | ))); |
| 75 | } |
| 76 | let mut d = CalendarData::default(); |
| 77 | for c in elements(e).filter(|c| Name::of(c).ns == CALDAV) { |
| 78 | match c.name.as_str() { |
| 79 | "comp" => d.comp = Some(comp_select(c)?), |
| 80 | "expand" => d.expand = Some(time_range(c)?), |
| 81 | "limit-recurrence-set" => d.limit_recurrence = Some(time_range(c)?), |
| 82 | "limit-freebusy-set" => d.limit_freebusy = Some(time_range(c)?), |
| 83 | _ => {} |
| 84 | } |
| 85 | } |
| 86 | Ok(d) |
| 87 | } |
| 88 | |
| 89 | fn comp_select(e: &Element) -> Result<CompSelect, Refused> { |
| 90 | let name = e.attributes.get("name").ok_or(Refused::Invalid)?; |
| 91 | let has = |local: &str| elements(e).any(|c| Name::of(c).is(CALDAV, local)); |
| 92 | // Clients send an empty `<comp name="VTIMEZONE"/>` for the whole zone. |
| 93 | let all = !["allprop", "prop", "allcomp", "comp"].into_iter().any(has); |
| 94 | let props = (!all && !has("allprop")).then(|| prop_selects(e, CALDAV)); |
| 95 | let comps = match all || has("allcomp") { |
| 96 | true => None, |
| 97 | false => Some( |
| 98 | elements(e) |
| 99 | .filter(|c| Name::of(c).is(CALDAV, "comp")) |
| 100 | .map(comp_select) |
| 101 | .collect::<Result<_, _>>()?, |
| 102 | ), |
| 103 | }; |
| 104 | Ok(CompSelect { |
| 105 | name: name.to_ascii_uppercase(), |
| 106 | props, |
| 107 | comps, |
| 108 | }) |
| 109 | } |
| 110 | |
| 111 | fn prop_selects(e: &Element, ns: &str) -> Vec<PropSelect> { |
| 112 | elements(e) |
| 113 | .filter(|c| Name::of(c).is(ns, "prop")) |
| 114 | .filter_map(|c| { |
| 115 | Some(PropSelect { |
| 116 | name: c.attributes.get("name")?.clone(), |
| 117 | novalue: c.attributes.get("novalue").map(String::as_str) == Some("yes"), |
| 118 | }) |
| 119 | }) |
| 120 | .collect() |
| 121 | } |
| 122 | |
| 123 | pub fn address_request(e: &Element) -> Result<AddressData, Refused> { |
| 124 | let unsupported = || Refused::Condition(Name::new(CARDDAV, "supported-address-data")); |
| 125 | if e.attributes |
| 126 | .get("content-type") |
| 127 | .is_some_and(|t| t != "text/vcard") |
| 128 | { |
| 129 | return Err(unsupported()); |
| 130 | } |
| 131 | // RFC 6352, 10.4: without a version, 3.0. |
| 132 | let version = match e.attributes.get("version").map(String::as_str) { |
| 133 | None | Some("3.0") => Some(VCardVersion::V3_0), |
| 134 | Some("4.0") => Some(VCardVersion::V4_0), |
| 135 | Some(_) => return Err(unsupported()), |
| 136 | }; |
| 137 | let all = elements(e).any(|c| Name::of(c).is(CARDDAV, "allprop")); |
| 138 | let props = prop_selects(e, CARDDAV); |
| 139 | Ok(AddressData { |
| 140 | props: (!all && !props.is_empty()).then_some(props), |
| 141 | version, |
| 142 | }) |
| 143 | } |
| 144 | |
| 145 | /// The calendar-data of one object, and how many instances an `expand` |
| 146 | /// produced. The stored text is returned unchanged unless the request |
| 147 | /// narrows or expands it. |
| 148 | pub fn calendar_data( |
| 149 | raw: &str, |
| 150 | req: &CalendarData, |
| 151 | floating: &Zone, |
| 152 | ) -> Result<(String, usize), TooManyInstances> { |
| 153 | if *req == CalendarData::default() { |
| 154 | return Ok((raw.to_string(), 0)); |
| 155 | } |
| 156 | let Some(mut cal) = parse(raw) else { |
| 157 | return Ok((raw.to_string(), 0)); |
| 158 | }; |
| 159 | let mut instances = 0; |
| 160 | if let Some(r) = &req.expand { |
| 161 | (cal, instances) = expanded(&cal, r, floating, req.comp.as_ref())?; |
| 162 | } |
| 163 | if let Some(r) = &req.limit_recurrence { |
| 164 | cal = limit_recurrence(&cal, r, floating); |
| 165 | } |
| 166 | if let Some(r) = &req.limit_freebusy { |
| 167 | limit_freebusy(&mut cal, r, floating); |
| 168 | } |
| 169 | if let Some(sel) = &req.comp { |
| 170 | let mut out = Vec::new(); |
| 171 | copy(&cal, 0, Some(sel), &mut out); |
| 172 | cal = ICalendar { components: out }; |
| 173 | } |
| 174 | Ok((write(&cal), instances)) |
| 175 | } |
| 176 | |
| 177 | /// The text of `cal`. Use this instead of `to_string`, which breaks URIs. |
| 178 | pub fn write(cal: &ICalendar) -> String { |
| 179 | raw_uris(&cal.to_string()) |
| 180 | } |
| 181 | |
| 182 | /// One component per instance in `range`, in UTC, with a RECURRENCE-ID and |
| 183 | /// without recurrence rules (RFC 4791, 9.6.5). Dates stay dates. |
| 184 | fn expanded( |
| 185 | cal: &ICalendar, |
| 186 | range: &TimeRange, |
| 187 | floating: &Zone, |
| 188 | sel: Option<&CompSelect>, |
| 189 | ) -> Result<(ICalendar, usize), TooManyInstances> { |
| 190 | let exp = expand(cal, range.clone(), floating.clone()); |
| 191 | let count = exp.instances.len(); |
| 192 | if exp.truncated || count > MAX_EXPANDED { |
| 193 | return Err(TooManyInstances); |
| 194 | } |
| 195 | // Each instance copies its component, so many attendees times many |
| 196 | // instances can be huge before anything is written. |
| 197 | let mut sizes = HashMap::new(); |
| 198 | let mut bytes = 0; |
| 199 | for x in &exp.instances { |
| 200 | // Measured as written: with the requested selection. |
| 201 | bytes += *sizes.entry(x.component).or_insert_with(|| { |
| 202 | let mut one = vec![root(cal)]; |
| 203 | let at = copy(cal, x.component, None, &mut one); |
| 204 | one[0].component_ids.push(at); |
| 205 | let mut one = ICalendar { components: one }; |
| 206 | if let Some(sel) = sel { |
| 207 | let mut picked = Vec::new(); |
| 208 | copy(&one, 0, Some(sel), &mut picked); |
| 209 | one = ICalendar { components: picked }; |
| 210 | } |
| 211 | one.to_string().len() |
| 212 | }); |
| 213 | if bytes > MAX_EXPANDED_BYTES { |
| 214 | return Err(TooManyInstances); |
| 215 | } |
| 216 | } |
| 217 | let zones = Zones::new(cal, floating.clone()); |
| 218 | let mut out = vec![root(cal)]; |
| 219 | for x in exp.instances { |
| 220 | let src = &cal.components[x.component]; |
| 221 | let date = src |
| 222 | .property(&ICalendarProperty::Dtstart) |
| 223 | .and_then(|e| e.values.first()?.as_partial_date_time()) |
| 224 | .is_some_and(|p| p.hour.is_none()); |
| 225 | let at = copy(cal, x.component, None, &mut out); |
| 226 | let c = &mut out[at as usize]; |
| 227 | c.entries.retain(|e| { |
| 228 | !matches!( |
| 229 | e.name, |
| 230 | ICalendarProperty::Rrule |
| 231 | | ICalendarProperty::Rdate |
| 232 | | ICalendarProperty::Exdate |
| 233 | | ICalendarProperty::Exrule |
| 234 | | ICalendarProperty::Dtstart |
| 235 | | ICalendarProperty::Dtend |
| 236 | | ICalendarProperty::Due |
| 237 | | ICalendarProperty::RecurrenceId |
| 238 | ) |
| 239 | }); |
| 240 | in_utc(&mut c.entries, &zones); |
| 241 | let when = |name: ICalendarProperty, t: DateTime<Utc>| { |
| 242 | let (value, params) = if date { |
| 243 | let day = floating.to_local(t).and_utc().timestamp(); |
| 244 | let param = ICalendarParameter::new( |
| 245 | ICalendarParameterName::Value, |
| 246 | ICalendarParameterValue::Value(ICalendarValueType::Date), |
| 247 | ); |
| 248 | (PartialDateTime::from_date_timestamp(day), vec![param]) |
| 249 | } else { |
| 250 | (PartialDateTime::from_utc_timestamp(t.timestamp()), vec![]) |
| 251 | }; |
| 252 | ICalendarEntry { |
| 253 | name, |
| 254 | params, |
| 255 | values: vec![ICalendarValue::PartialDateTime(Box::new(value))], |
| 256 | } |
| 257 | }; |
| 258 | c.entries.push(when(ICalendarProperty::Dtstart, x.start)); |
| 259 | if src.has_property(&ICalendarProperty::Dtend) { |
| 260 | c.entries.push(when(ICalendarProperty::Dtend, x.end)); |
| 261 | } |
| 262 | if src.has_property(&ICalendarProperty::Due) { |
| 263 | c.entries.push(when(ICalendarProperty::Due, x.end)); |
| 264 | } |
| 265 | if let Some(rid) = x.recurrence_id { |
| 266 | c.entries.push(when(ICalendarProperty::RecurrenceId, rid)); |
| 267 | } |
| 268 | out[0].component_ids.push(at); |
| 269 | } |
| 270 | // A task or journal without DTSTART has no instances to expand. |
| 271 | for &i in cal.components.first().map_or(&[][..], |r| &r.component_ids) { |
| 272 | let c = &cal.components[i as usize]; |
| 273 | if c.component_type != ICalendarComponentType::VTimezone |
| 274 | && !c.has_property(&ICalendarProperty::Dtstart) |
| 275 | { |
| 276 | let at = copy(cal, i as usize, None, &mut out); |
| 277 | in_utc(&mut out[at as usize].entries, &zones); |
| 278 | out[0].component_ids.push(at); |
| 279 | } |
| 280 | } |
| 281 | Ok((ICalendar { components: out }, count)) |
| 282 | } |
| 283 | |
| 284 | /// Date-times in UTC: an expanded answer holds no VTIMEZONE. |
| 285 | fn in_utc(entries: &mut [ICalendarEntry], zones: &Zones) { |
| 286 | for e in entries { |
| 287 | let tzid = e.tz_id().map(str::to_string); |
| 288 | for v in &mut e.values { |
| 289 | if let ICalendarValue::PartialDateTime(p) = v |
| 290 | && p.hour.is_some() |
| 291 | && let Some(s) = stamp(zones, p, tzid.as_deref()) |
| 292 | { |
| 293 | **p = PartialDateTime::from_utc_timestamp(s.utc().timestamp()); |
| 294 | } |
| 295 | } |
| 296 | e.params.retain(|p| p.name != ICalendarParameterName::Tzid); |
| 297 | } |
| 298 | } |
| 299 | |
| 300 | /// The masters, and only the overrides that affect `range` (RFC 4791, |
| 301 | /// 9.6.6). |
| 302 | fn limit_recurrence(cal: &ICalendar, range: &TimeRange, floating: &Zone) -> ICalendar { |
| 303 | if !cal |
| 304 | .components |
| 305 | .iter() |
| 306 | .any(|c| c.has_property(&ICalendarProperty::RecurrenceId)) |
| 307 | { |
| 308 | return cal.clone(); |
| 309 | } |
| 310 | let exp = expand(cal, range.clone(), floating.clone()); |
| 311 | let used: HashSet<usize> = exp.instances.iter().map(|x| x.component).collect(); |
| 312 | let zones = Zones::new(cal, floating.clone()); |
| 313 | // An override moved out of the range still removes an instance in it. |
| 314 | let master = cal.components.iter().position(|c| { |
| 315 | matches!( |
| 316 | c.component_type, |
| 317 | ICalendarComponentType::VEvent |
| 318 | | ICalendarComponentType::VTodo |
| 319 | | ICalendarComponentType::VJournal |
| 320 | ) && !c.has_property(&ICalendarProperty::RecurrenceId) |
| 321 | }); |
| 322 | let replaces_in_range = |rid: &ICalendarEntry| { |
| 323 | let Some(s) = rid |
| 324 | .values |
| 325 | .first() |
| 326 | .and_then(|v| v.as_partial_date_time()) |
| 327 | .and_then(|p| stamp(&zones, p, rid.tz_id())) |
| 328 | else { |
| 329 | return false; |
| 330 | }; |
| 331 | let (start, end) = master |
| 332 | .and_then(|m| original(cal, &zones, m, &s)) |
| 333 | .unwrap_or((s.utc(), s.utc())); |
| 334 | match start == end { |
| 335 | true => range.contains(&start), |
| 336 | false => start < range.end && end > range.start, |
| 337 | } |
| 338 | }; |
| 339 | let mut out = vec![root(cal)]; |
| 340 | for &i in cal.components.first().map_or(&[][..], |r| &r.component_ids) { |
| 341 | let c = &cal.components[i as usize]; |
| 342 | let keep = match c.property(&ICalendarProperty::RecurrenceId) { |
| 343 | None => true, |
| 344 | Some(rid) => { |
| 345 | exp.truncated |
| 346 | || used.contains(&(i as usize)) |
| 347 | || replaces_in_range(rid) |
| 348 | || rid.parameter(&ICalendarParameterName::Range).is_some() |
| 349 | } |
| 350 | }; |
| 351 | if keep { |
| 352 | let at = copy(cal, i as usize, None, &mut out); |
| 353 | out[0].component_ids.push(at); |
| 354 | } |
| 355 | } |
| 356 | ICalendar { components: out } |
| 357 | } |
| 358 | |
| 359 | /// Drops the FREEBUSY periods outside `range` (RFC 4791, 9.6.7). |
| 360 | fn limit_freebusy(cal: &mut ICalendar, range: &TimeRange, floating: &Zone) { |
| 361 | let zones = Zones::new(cal, floating.clone()); |
| 362 | for c in cal |
| 363 | .components |
| 364 | .iter_mut() |
| 365 | .filter(|c| c.component_type == ICalendarComponentType::VFreebusy) |
| 366 | { |
| 367 | for e in c |
| 368 | .entries |
| 369 | .iter_mut() |
| 370 | .filter(|e| e.name == ICalendarProperty::Freebusy) |
| 371 | { |
| 372 | let tzid = e.tz_id().map(str::to_string); |
| 373 | e.values.retain(|v| { |
| 374 | period(&zones, tzid.as_deref(), v) |
| 375 | .is_some_and(|(s, e)| s < range.end && e > range.start) |
| 376 | }); |
| 377 | } |
| 378 | c.entries |
| 379 | .retain(|e| e.name != ICalendarProperty::Freebusy || !e.values.is_empty()); |
| 380 | } |
| 381 | } |
| 382 | |
| 383 | /// The VCALENDAR with its properties and no components. |
| 384 | fn root(cal: &ICalendar) -> ICalendarComponent { |
| 385 | ICalendarComponent { |
| 386 | component_type: ICalendarComponentType::VCalendar, |
| 387 | entries: cal |
| 388 | .components |
| 389 | .first() |
| 390 | .map(|c| c.entries.clone()) |
| 391 | .unwrap_or_default(), |
| 392 | component_ids: Vec::new(), |
| 393 | } |
| 394 | } |
| 395 | |
| 396 | /// Appends component `i` of `src` and its sub-components, narrowed by `sel`, |
| 397 | /// to `out`. Returns the new index. |
| 398 | fn copy( |
| 399 | src: &ICalendar, |
| 400 | i: usize, |
| 401 | sel: Option<&CompSelect>, |
| 402 | out: &mut Vec<ICalendarComponent>, |
| 403 | ) -> u32 { |
| 404 | let c = &src.components[i]; |
| 405 | let entries = match sel.and_then(|s| s.props.as_ref()) { |
| 406 | None => c.entries.clone(), |
| 407 | Some(props) => c |
| 408 | .entries |
| 409 | .iter() |
| 410 | .filter_map(|e| { |
| 411 | let p = props |
| 412 | .iter() |
| 413 | .find(|p| p.name.eq_ignore_ascii_case(e.name.as_str()))?; |
| 414 | let mut e = e.clone(); |
| 415 | if p.novalue { |
| 416 | e.values.clear(); |
| 417 | } |
| 418 | Some(e) |
| 419 | }) |
| 420 | .collect(), |
| 421 | }; |
| 422 | let at = out.len(); |
| 423 | out.push(ICalendarComponent { |
| 424 | component_type: c.component_type.clone(), |
| 425 | entries, |
| 426 | component_ids: Vec::new(), |
| 427 | }); |
| 428 | for &child in &c.component_ids { |
| 429 | let child = child as usize; |
| 430 | let child_sel = match sel.map(|s| &s.comps) { |
| 431 | None | Some(None) => None, |
| 432 | Some(Some(list)) => { |
| 433 | let name = src.components[child].component_type.as_str(); |
| 434 | match list.iter().find(|s| s.name.eq_ignore_ascii_case(name)) { |
| 435 | Some(s) => Some(s), |
| 436 | None => continue, |
| 437 | } |
| 438 | } |
| 439 | }; |
| 440 | let id = copy(src, child, child_sel, out); |
| 441 | out[at].component_ids.push(id); |
| 442 | } |
| 443 | at as u32 |
| 444 | } |
| 445 | |
| 446 | /// The address-data of one card. The stored text is returned unchanged |
| 447 | /// unless the request narrows it or asks for another version. |
| 448 | pub fn address_data(raw: &str, req: &AddressData) -> String { |
| 449 | if *req == AddressData::default() { |
| 450 | return raw.to_string(); |
| 451 | } |
| 452 | let Some(card) = parse_card(raw) else { |
| 453 | return raw.to_string(); |
| 454 | }; |
| 455 | let version = req.version.or(card.version()).unwrap_or_default(); |
| 456 | if req.props.is_none() && Some(version) == card.version() { |
| 457 | return raw.to_string(); |
| 458 | } |
| 459 | // calcard's 4.0 writer drops a `pref` type, so map before parsing. |
| 460 | let card = match version { |
| 461 | VCardVersion::V4_0 => parse_card(&apple_forms(raw, version)).unwrap_or(card), |
| 462 | _ => card, |
| 463 | }; |
| 464 | let entries = match &req.props { |
| 465 | None => card.entries, |
| 466 | Some(props) => card |
| 467 | .entries |
| 468 | .into_iter() |
| 469 | .filter_map(|mut e| { |
| 470 | let p = props |
| 471 | .iter() |
| 472 | .find(|p| p.name.eq_ignore_ascii_case(e.name.as_str()))?; |
| 473 | if p.novalue { |
| 474 | e.values.clear(); |
| 475 | } |
| 476 | Some(e) |
| 477 | }) |
| 478 | .collect(), |
| 479 | }; |
| 480 | let mut out = String::new(); |
| 481 | let _ = VCard { entries }.write_to(&mut out, version); |
| 482 | raw_uris(&match version { |
| 483 | VCardVersion::V4_0 => out, |
| 484 | _ => apple_forms(&out, version), |
| 485 | }) |
| 486 | } |
| 487 | |
| 488 | /// Properties whose values are URIs without a `VALUE=URI` parameter. |
| 489 | const URI_PROPS: [&str; 10] = [ |
| 490 | "URL", |
| 491 | "GEO", |
| 492 | "ATTACH", |
| 493 | "CONFERENCE", |
| 494 | "TZURL", |
| 495 | "SOURCE", |
| 496 | "IMAGE", |
| 497 | "X-GOOGLE-CONFERENCE", |
| 498 | "X-MICROSOFT-SKYPETEAMSMEETINGURL", |
| 499 | "X-SOCIALPROFILE", |
| 500 | ]; |
| 501 | |
| 502 | /// calcard's writers escape `,` and `;` in URI values as TEXT, which breaks |
| 503 | /// the URI. A URI has no backslash to keep. A vCard PHOTO or KEY without a |
| 504 | /// media type becomes `data:;base64,`, so the type comes from the first |
| 505 | /// bytes or from `TYPE=PGP`. |
| 506 | fn raw_uris(text: &str) -> String { |
| 507 | let eol = if text.contains("\r\n") { "\r\n" } else { "\n" }; |
| 508 | let mut out = String::with_capacity(text.len()); |
| 509 | for raw in crate::text::logical_lines(text) { |
| 510 | let orig = crate::text::unfold(raw); |
| 511 | let name = crate::text::prop(&orig); |
| 512 | let value = crate::text::value(&orig); |
| 513 | let uri = URI_PROPS.iter().any(|p| p.eq_ignore_ascii_case(&name)) |
| 514 | || crate::text::param(&orig, "VALUE").is_some_and(|v| v.eq_ignore_ascii_case("uri")) |
| 515 | || ["PHOTO", "LOGO", "SOUND", "KEY"].contains(&name.as_str()) |
| 516 | && value |
| 517 | .get(..5) |
| 518 | .is_some_and(|v| v.eq_ignore_ascii_case("data:")); |
| 519 | if !uri { |
| 520 | out.push_str(raw); |
| 521 | continue; |
| 522 | } |
| 523 | let head = crate::text::head(&orig); |
| 524 | let value = value.replace("\\,", ",").replace("\\;", ";"); |
| 525 | const BARE: &str = "data:;base64,"; |
| 526 | let data = value |
| 527 | .get(..BARE.len()) |
| 528 | .filter(|p| p.eq_ignore_ascii_case(BARE)) |
| 529 | .map(|_| &value[BARE.len()..]); |
| 530 | let pgp = name == "KEY" |
| 531 | && crate::text::param(&orig, "TYPE").is_some_and(|t| t.eq_ignore_ascii_case("PGP")); |
| 532 | let sniffed = data.and_then(|d| match d { |
| 533 | d if d.starts_with("iVBORw0KGgo") => Some("image/png"), |
| 534 | d if d.starts_with("/9j/") => Some("image/jpeg"), |
| 535 | d if d.starts_with("R0lGOD") => Some("image/gif"), |
| 536 | d if d.starts_with("UklG") && d.get(12..16) == Some("RUJQ") => Some("image/webp"), |
| 537 | _ if pgp => Some("application/pgp-keys"), |
| 538 | _ => None, |
| 539 | }); |
| 540 | let new = match (sniffed, data) { |
| 541 | (Some(t), Some(d)) => format!("{head}:data:{t};base64,{d}"), |
| 542 | _ => format!("{head}:{value}"), |
| 543 | }; |
| 544 | if new == orig { |
| 545 | out.push_str(raw); |
| 546 | } else { |
| 547 | out.push_str(&crate::text::fold(&new, eol)); |
| 548 | } |
| 549 | } |
| 550 | out |
| 551 | } |
| 552 | |
| 553 | /// A calendar object parsed so that writing it keeps its binary values. |
| 554 | /// calcard reads a base64 value whose bytes are UTF-8 as TEXT and drops |
| 555 | /// its ENCODING. |
| 556 | pub fn parse(text: &str) -> Option<ICalendar> { |
| 557 | let mut cal = ICalendar::parse(text).ok()?; |
| 558 | let encoded = encoded(text); |
| 559 | if encoded.is_empty() { |
| 560 | return Some(cal); |
| 561 | } |
| 562 | for (i, c) in cal.components.iter_mut().enumerate() { |
| 563 | for e in &mut c.entries { |
| 564 | if let [ICalendarValue::Text(s)] = e.values.as_slice() |
| 565 | && encoded.contains(&(i, e.name.as_str().to_ascii_uppercase(), base64(s))) |
| 566 | { |
| 567 | e.values = vec![ICalendarValue::Binary(s.clone().into_bytes())]; |
| 568 | } |
| 569 | } |
| 570 | } |
| 571 | Some(cal) |
| 572 | } |
| 573 | |
| 574 | /// [`parse`] for a vCard. Only media and keys are binary; a base64 NOTE |
| 575 | /// stays text. |
| 576 | fn parse_card(text: &str) -> Option<VCard> { |
| 577 | let mut card = VCard::parse(text).ok()?; |
| 578 | let encoded = encoded(text); |
| 579 | if encoded.is_empty() { |
| 580 | return Some(card); |
| 581 | } |
| 582 | for e in &mut card.entries { |
| 583 | let name = e.name.as_str().to_ascii_uppercase(); |
| 584 | if ["PHOTO", "LOGO", "SOUND", "KEY"].contains(&name.as_str()) |
| 585 | && let [VCardValue::Text(s)] = e.values.as_slice() |
| 586 | && encoded.contains(&(0, name, base64(s))) |
| 587 | { |
| 588 | e.values = vec![VCardValue::Binary(Data { |
| 589 | content_type: None, |
| 590 | data: s.clone().into_bytes(), |
| 591 | })]; |
| 592 | } |
| 593 | } |
| 594 | Some(card) |
| 595 | } |
| 596 | |
| 597 | /// The lines in base64, the encoding calcard drops, by the position of |
| 598 | /// their component in BEGIN order, name and value. With a CHARSET the |
| 599 | /// value is text. |
| 600 | fn encoded(text: &str) -> HashSet<(usize, String, String)> { |
| 601 | let mut out = HashSet::new(); |
| 602 | if !text |
| 603 | .as_bytes() |
| 604 | .windows(9) |
| 605 | .any(|w| w.eq_ignore_ascii_case(b"ENCODING=")) |
| 606 | { |
| 607 | return out; |
| 608 | } |
| 609 | let (mut open, mut next) = (Vec::new(), 0); |
| 610 | for l in crate::text::logical_lines(text) |
| 611 | .into_iter() |
| 612 | .map(crate::text::unfold) |
| 613 | { |
| 614 | match crate::text::prop(&l).as_str() { |
| 615 | "BEGIN" => { |
| 616 | open.push(next); |
| 617 | next += 1; |
| 618 | } |
| 619 | "END" => { |
| 620 | open.pop(); |
| 621 | } |
| 622 | name if crate::text::param(&l, "ENCODING").is_some_and(|e| { |
| 623 | e.eq_ignore_ascii_case("BASE64") || e.eq_ignore_ascii_case("B") |
| 624 | }) && crate::text::param(&l, "CHARSET").is_none() => |
| 625 | { |
| 626 | let value = crate::text::value(&l).split_whitespace().collect(); |
| 627 | out.insert((open.last().copied().unwrap_or(0), name.to_string(), value)); |
| 628 | } |
| 629 | _ => {} |
| 630 | } |
| 631 | } |
| 632 | out |
| 633 | } |
| 634 | |
| 635 | fn base64(s: &str) -> String { |
| 636 | let data = Data { |
| 637 | content_type: None, |
| 638 | data: s.as_bytes().to_vec(), |
| 639 | }; |
| 640 | data.to_unwrapped_string()["data:;base64,".len()..].to_string() |
| 641 | } |
| 642 | |
| 643 | /// The version a GET asks for in its `Accept` header: 4.0 when named, |
| 644 | /// else 3.0, which RFC 6352 makes the default and Apple Contacts needs. |
| 645 | pub fn accepted_version(accept: Option<&str>) -> VCardVersion { |
| 646 | match accept.is_some_and(|a| a.contains("version=4.0")) { |
| 647 | true => VCardVersion::V4_0, |
| 648 | false => VCardVersion::V3_0, |
| 649 | } |
| 650 | } |
| 651 | |
| 652 | /// vCard 4.0 groups and companies in the vCard 3.0 extensions Apple |
| 653 | /// Contacts reads, and back: `KIND:group` and `MEMBER` as |
| 654 | /// `X-ADDRESSBOOKSERVER-KIND` and `X-ADDRESSBOOKSERVER-MEMBER`, `KIND:org` |
| 655 | /// as `X-ABSHOWAS:COMPANY`, and `PREF=1` as `TYPE=pref`. sabre/vobject |
| 656 | /// converts the same way. |
| 657 | fn apple_forms(text: &str, version: VCardVersion) -> String { |
| 658 | let v3 = version == VCardVersion::V3_0; |
| 659 | let eol = if text.contains("\r\n") { "\r\n" } else { "\n" }; |
| 660 | let mut out = String::with_capacity(text.len()); |
| 661 | // vCard 3.0 has no ranks: the best ranked line of a name becomes the pref. |
| 662 | let mut best: HashMap<String, u32> = HashMap::new(); |
| 663 | if v3 { |
| 664 | for raw in crate::text::logical_lines(text) { |
| 665 | let line = crate::text::unfold(raw); |
| 666 | let name = crate::text::name(&line); |
| 667 | let name = name.rsplit('.').next().unwrap_or_default().to_string(); |
| 668 | for p in crate::text::param_parts(&line) { |
| 669 | if let Some(n) = p |
| 670 | .split_once('=') |
| 671 | .filter(|(k, _)| k.eq_ignore_ascii_case("PREF")) |
| 672 | .and_then(|(_, v)| v.trim().parse::<u32>().ok()) |
| 673 | { |
| 674 | let b = best.entry(name.clone()).or_insert(n); |
| 675 | *b = (*b).min(n); |
| 676 | } |
| 677 | } |
| 678 | } |
| 679 | } |
| 680 | for raw in crate::text::logical_lines(text) { |
| 681 | let line = crate::text::unfold(raw); |
| 682 | let start = crate::text::value_start(&line); |
| 683 | let head = crate::text::head(&line); |
| 684 | let head_name = head.split(';').next().unwrap_or_default(); |
| 685 | let (group, name) = match head_name.rsplit_once('.') { |
| 686 | Some((g, n)) => (&head_name[..=g.len()], n.to_ascii_uppercase()), |
| 687 | None => ("", head_name.to_ascii_uppercase()), |
| 688 | }; |
| 689 | let value = &line[start..]; |
| 690 | let lower = value.trim().to_ascii_lowercase(); |
| 691 | let renamed = match (v3, name.as_str(), lower.as_str()) { |
| 692 | (true, "KIND", "group") => Some(("X-ADDRESSBOOKSERVER-KIND", "group")), |
| 693 | (true, "KIND", "org") => Some(("X-ABSHOWAS", "COMPANY")), |
| 694 | (true, "KIND", "individual") => continue, |
| 695 | (true, "MEMBER", _) => Some(("X-ADDRESSBOOKSERVER-MEMBER", value)), |
| 696 | (false, "X-ADDRESSBOOKSERVER-KIND", "group") => Some(("KIND", "group")), |
| 697 | (false, "X-ABSHOWAS", "company") => Some(("KIND", "org")), |
| 698 | (false, "X-ADDRESSBOOKSERVER-MEMBER", _) => Some(("MEMBER", value)), |
| 699 | _ => None, |
| 700 | }; |
| 701 | let params = pref_params( |
| 702 | crate::text::param_parts(&line), |
| 703 | v3, |
| 704 | best.get(&name).copied().unwrap_or(1), |
| 705 | ); |
| 706 | let (name, value) = renamed.unwrap_or((&name, value)); |
| 707 | let (params, value) = plain_forms(v3, &line, name, params, value); |
| 708 | let mut new = format!("{group}{name}"); |
| 709 | for p in ¶ms { |
| 710 | new.push(';'); |
| 711 | new.push_str(p); |
| 712 | } |
| 713 | new.push(':'); |
| 714 | new.push_str(&value); |
| 715 | if new == line { |
| 716 | out.push_str(raw); |
| 717 | } else { |
| 718 | out.push_str(&crate::text::fold(&new, eol)); |
| 719 | } |
| 720 | } |
| 721 | out |
| 722 | } |
| 723 | |
| 724 | /// vCard 4.0 values vCard 3.0 readers reject, and back: a `tel:` URI as the |
| 725 | /// bare number, a `geo:` URI as `lat;lon`, and a birthday without year as |
| 726 | /// Apple writes it, with `X-APPLE-OMIT-YEAR`. |
| 727 | fn plain_forms( |
| 728 | v3: bool, |
| 729 | line: &str, |
| 730 | name: &str, |
| 731 | params: Vec<String>, |
| 732 | value: &str, |
| 733 | ) -> (Vec<String>, String) { |
| 734 | let key = |p: &str, k: &str| { |
| 735 | p.split_once('=') |
| 736 | .is_some_and(|(pk, _)| pk.trim().eq_ignore_ascii_case(k)) |
| 737 | }; |
| 738 | let param = |k: &str| crate::text::param(line, k); |
| 739 | let digits = |s: &str| s.bytes().all(|b| b.is_ascii_digit()); |
| 740 | match (v3, name) { |
| 741 | (true, "TEL") => { |
| 742 | let uri = param("VALUE").is_some_and(|v| v.eq_ignore_ascii_case("uri")); |
| 743 | match value.get(..4).filter(|s| s.eq_ignore_ascii_case("tel:")) { |
| 744 | Some(_) if uri => ( |
| 745 | params.into_iter().filter(|p| !key(p, "VALUE")).collect(), |
| 746 | value[4..].to_string(), |
| 747 | ), |
| 748 | _ => (params, value.to_string()), |
| 749 | } |
| 750 | } |
| 751 | (true, "BDAY") => { |
| 752 | let md = value.trim().strip_prefix("--").map(|v| v.replace('-', "")); |
| 753 | match md { |
| 754 | Some(md) if md.len() == 4 && digits(&md) => { |
| 755 | let mut params = params; |
| 756 | params.push("X-APPLE-OMIT-YEAR=1604".to_string()); |
| 757 | (params, format!("1604-{}-{}", &md[..2], &md[2..])) |
| 758 | } |
| 759 | _ => (params, value.to_string()), |
| 760 | } |
| 761 | } |
| 762 | (true, "GEO") => match value.trim().get(..4) { |
| 763 | Some(g) if g.eq_ignore_ascii_case("geo:") => { |
| 764 | let point = value.trim()[4..].split(';').next().unwrap_or_default(); |
| 765 | let point = point.replace("\\,", ","); |
| 766 | let mut coords = point.split(','); |
| 767 | let (lat, lon) = ( |
| 768 | coords.next().unwrap_or_default(), |
| 769 | coords.next().unwrap_or_default(), |
| 770 | ); |
| 771 | (params, format!("{lat};{lon}")) |
| 772 | } |
| 773 | _ => (params, value.to_string()), |
| 774 | }, |
| 775 | (false, "GEO") if value.contains(';') && !value.contains(':') => ( |
| 776 | params, |
| 777 | format!("geo:{}", value.trim().replacen(';', ",", 1)), |
| 778 | ), |
| 779 | (false, "BDAY") => { |
| 780 | let year = param("X-APPLE-OMIT-YEAR"); |
| 781 | let date = value |
| 782 | .trim() |
| 783 | .split(['T', 't']) |
| 784 | .next() |
| 785 | .unwrap_or_default() |
| 786 | .replace('-', ""); |
| 787 | match year { |
| 788 | Some(y) if date.len() == 8 && digits(&date) && date[..4] == y => ( |
| 789 | params |
| 790 | .into_iter() |
| 791 | .filter(|p| !key(p, "X-APPLE-OMIT-YEAR")) |
| 792 | .collect(), |
| 793 | format!("--{}", &date[4..]), |
| 794 | ), |
| 795 | _ => (params, value.to_string()), |
| 796 | } |
| 797 | } |
| 798 | _ => (params, value.to_string()), |
| 799 | } |
| 800 | } |
| 801 | |
| 802 | /// The lowest `PREF` as a `pref` type for vCard 3.0, and back for 4.0. |
| 803 | fn pref_params(params: Vec<&str>, v3: bool, lowest: u32) -> Vec<String> { |
| 804 | let mut out = Vec::with_capacity(params.len()); |
| 805 | let mut pref = false; |
| 806 | for p in params { |
| 807 | let (key, value) = p.split_once('=').unwrap_or((p, "")); |
| 808 | match (v3, key.to_ascii_uppercase().as_str()) { |
| 809 | (true, "PREF") => pref |= value.trim().parse() == Ok(lowest), |
| 810 | (false, "TYPE") => { |
| 811 | let (prefs, rest): (Vec<&str>, Vec<&str>) = value |
| 812 | .trim_matches('"') |
| 813 | .split(',') |
| 814 | .partition(|t| t.eq_ignore_ascii_case("pref")); |
| 815 | pref |= !prefs.is_empty(); |
| 816 | if !rest.is_empty() { |
| 817 | out.push(format!("{key}={}", rest.join(","))); |
| 818 | } |
| 819 | } |
| 820 | _ => out.push(p.to_string()), |
| 821 | } |
| 822 | } |
| 823 | if pref { |
| 824 | out.push(if v3 { "TYPE=pref" } else { "PREF=1" }.to_string()); |
| 825 | } |
| 826 | out |
| 827 | } |
| 828 |