__init__.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  1. # This file is part of Radicale - CalDAV and CardDAV server
  2. # Copyright © 2008 Nicolas Kandel
  3. # Copyright © 2008 Pascal Halter
  4. # Copyright © 2014 Jean-Marc Martins
  5. # Copyright © 2008-2017 Guillaume Ayoub
  6. # Copyright © 2017-2022 Unrud <unrud@outlook.com>
  7. # Copyright © 2024-2026 Peter Bieringer <pb@bieringer.de>
  8. #
  9. # This library is free software: you can redistribute it and/or modify
  10. # it under the terms of the GNU General Public License as published by
  11. # the Free Software Foundation, either version 3 of the License, or
  12. # (at your option) any later version.
  13. #
  14. # This library is distributed in the hope that it will be useful,
  15. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  16. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  17. # GNU General Public License for more details.
  18. #
  19. # You should have received a copy of the GNU General Public License
  20. # along with Radicale. If not, see <http://www.gnu.org/licenses/>.
  21. """
  22. Module for address books and calendar entries (see ``Item``).
  23. """
  24. import binascii
  25. import contextlib
  26. import math
  27. import os
  28. import re
  29. from datetime import datetime, timedelta
  30. from hashlib import sha256
  31. from itertools import chain
  32. from typing import (Any, Callable, List, MutableMapping, Optional, Sequence,
  33. Tuple)
  34. import vobject
  35. from radicale import storage # noqa:F401
  36. from radicale import pathutils, utils
  37. from radicale.item import filter as radicale_filter
  38. from radicale.log import logger
  39. def read_components(s: str) -> List[vobject.base.Component]:
  40. """Wrapper for vobject.readComponents"""
  41. # Workaround for bug in InfCloud
  42. # PHOTO is a data URI
  43. s = re.sub(r"^(PHOTO(?:;[^:\r\n]*)?;ENCODING=b(?:;[^:\r\n]*)?:)"
  44. r"data:[^;,\r\n]*;base64,", r"\1", s,
  45. flags=re.MULTILINE | re.IGNORECASE)
  46. # Workaround for bug with malformed ICS files containing control codes
  47. # Filter out all control codes except those we expect to find:
  48. # * 0x09 Horizontal Tab
  49. # * 0x0A Line Feed
  50. # * 0x0D Carriage Return
  51. s = re.sub(r'[\x00-\x08\x0B\x0C\x0E-\x1F]', '', s)
  52. # Workaround delete all empty lines to avoid vobject parsing errors
  53. s = re.sub(r'(?m)^[ \t]*\r?\n', '', s)
  54. return list(vobject.readComponents(s, allowQP=True))
  55. def predict_tag_of_parent_collection(
  56. vobject_items: Sequence[vobject.base.Component]) -> Optional[str]:
  57. """Returns the predicted tag or `None`"""
  58. if len(vobject_items) != 1:
  59. return None
  60. if vobject_items[0].name == "VCALENDAR":
  61. return "VCALENDAR"
  62. if vobject_items[0].name in ("VCARD", "VLIST"):
  63. return "VADDRESSBOOK"
  64. return None
  65. def predict_tag_of_whole_collection(
  66. vobject_items: Sequence[vobject.base.Component],
  67. fallback_tag: Optional[str] = None) -> Optional[str]:
  68. """Returns the predicted tag or `fallback_tag`"""
  69. if vobject_items and vobject_items[0].name == "VCALENDAR":
  70. return "VCALENDAR"
  71. if vobject_items and vobject_items[0].name in ("VCARD", "VLIST"):
  72. return "VADDRESSBOOK"
  73. if not fallback_tag and not vobject_items:
  74. # Maybe an empty address book
  75. return "VADDRESSBOOK"
  76. return fallback_tag
  77. def check_and_sanitize_items(
  78. vobject_items: List[vobject.base.Component],
  79. is_collection: bool = False, tag: str = "") -> None:
  80. """Check vobject items for common errors and add missing UIDs.
  81. Modifies the list `vobject_items`.
  82. ``is_collection`` indicates that vobject_item contains unrelated
  83. components.
  84. The ``tag`` of the collection.
  85. """
  86. if tag and tag not in ("VCALENDAR", "VADDRESSBOOK", "VSUBSCRIBED"):
  87. raise ValueError("Unsupported collection tag: %r" % tag)
  88. if not is_collection and len(vobject_items) != 1:
  89. raise ValueError("Item contains %d components" % len(vobject_items))
  90. if tag == "VCALENDAR":
  91. if len(vobject_items) > 1:
  92. raise RuntimeError("VCALENDAR collection contains %d "
  93. "components" % len(vobject_items))
  94. vobject_item = vobject_items[0]
  95. if vobject_item.name != "VCALENDAR":
  96. raise ValueError("Item type %r not supported in %r "
  97. "collection" % (vobject_item.name, tag))
  98. component_uids = set()
  99. for component in vobject_item.components():
  100. if component.name in ("VTODO", "VEVENT", "VJOURNAL"):
  101. component_uid = get_uid(component)
  102. if component_uid:
  103. component_uids.add(component_uid)
  104. component_name = None
  105. object_uid = None
  106. object_uid_set = False
  107. for component in vobject_item.components():
  108. # https://tools.ietf.org/html/rfc4791#section-4.1
  109. if component.name == "VTIMEZONE":
  110. continue
  111. if component_name is None or is_collection:
  112. component_name = component.name
  113. elif component_name != component.name:
  114. raise ValueError("Multiple component types in object: %r, %r" %
  115. (component_name, component.name))
  116. if component_name not in ("VTODO", "VEVENT", "VJOURNAL"):
  117. continue
  118. component_uid = get_uid(component)
  119. if not object_uid_set or is_collection:
  120. object_uid_set = True
  121. object_uid = component_uid
  122. if not component_uid:
  123. if not is_collection:
  124. raise ValueError("%s component without UID in object" %
  125. component_name)
  126. component_uid = find_available_uid(
  127. component_uids.__contains__)
  128. component_uids.add(component_uid)
  129. if hasattr(component, "uid"):
  130. component.uid.value = component_uid
  131. else:
  132. component.add("UID").value = component_uid
  133. elif not object_uid or not component_uid:
  134. raise ValueError("Multiple %s components without UID in "
  135. "object" % component_name)
  136. elif object_uid != component_uid:
  137. raise ValueError(
  138. "Multiple %s components with different UIDs in object: "
  139. "%r, %r" % (component_name, object_uid, component_uid))
  140. # Workaround for bug in Lightning (Thunderbird)
  141. # Rescheduling a single occurrence from a repeating event creates
  142. # an event with DTEND and DURATION:PT0S
  143. if (hasattr(component, "dtend") and
  144. hasattr(component, "duration") and
  145. component.duration.value == timedelta(0)):
  146. logger.debug("Quirks: Removing zero duration from %s in "
  147. "object %r", component_name, component_uid)
  148. del component.duration
  149. # Workaround for Evolution
  150. # EXDATE has value DATE even if DTSTART/DTEND is DATE-TIME.
  151. # The RFC is vaguely formulated on the issue.
  152. # To resolve the issue convert EXDATE and RDATE to
  153. # the same type as DTDSTART
  154. if hasattr(component, "dtstart"):
  155. ref_date = component.dtstart.value
  156. ref_value_param = component.dtstart.params.get("VALUE")
  157. for dates in chain(component.contents.get("exdate", []),
  158. component.contents.get("rdate", [])):
  159. if all(type(d) is type(ref_date) for d in dates.value):
  160. continue
  161. for i, date in enumerate(dates.value):
  162. dates.value[i] = ref_date.replace(
  163. date.year, date.month, date.day)
  164. with contextlib.suppress(KeyError):
  165. del dates.params["VALUE"]
  166. if ref_value_param is not None:
  167. dates.params["VALUE"] = ref_value_param
  168. # vobject interprets recurrence rules on demand
  169. try:
  170. component.rruleset
  171. except Exception as e:
  172. raise ValueError("Invalid recurrence rules in %s in object %r"
  173. % (component.name, component_uid)) from e
  174. elif tag == "VADDRESSBOOK":
  175. # https://tools.ietf.org/html/rfc6352#section-5.1
  176. object_uids = set()
  177. for vobject_item in vobject_items:
  178. if vobject_item.name == "VCARD":
  179. object_uid = get_uid(vobject_item)
  180. if object_uid:
  181. object_uids.add(object_uid)
  182. for vobject_item in vobject_items:
  183. if vobject_item.name == "VLIST":
  184. # Custom format used by SOGo Connector to store lists of
  185. # contacts
  186. continue
  187. if vobject_item.name != "VCARD":
  188. raise ValueError("Item type %r not supported in %r "
  189. "collection" % (vobject_item.name, tag))
  190. object_uid = get_uid(vobject_item)
  191. if not object_uid:
  192. if not is_collection:
  193. raise ValueError("%s object without UID" %
  194. vobject_item.name)
  195. object_uid = find_available_uid(object_uids.__contains__)
  196. object_uids.add(object_uid)
  197. if hasattr(vobject_item, "uid"):
  198. vobject_item.uid.value = object_uid
  199. else:
  200. vobject_item.add("UID").value = object_uid
  201. else:
  202. for item in vobject_items:
  203. raise ValueError("Item type %r not supported in %s collection" %
  204. (item.name, repr(tag) if tag else "generic"))
  205. def check_and_sanitize_props(props: MutableMapping[Any, Any]
  206. ) -> MutableMapping[str, str]:
  207. """Check collection properties for common errors.
  208. Modifies the dict `props`.
  209. """
  210. for k, v in list(props.items()): # Make copy to be able to delete items
  211. if not isinstance(k, str):
  212. raise ValueError("Key must be %r not %r: %r" % (
  213. str.__name__, type(k).__name__, k))
  214. if not isinstance(v, str):
  215. if v is None:
  216. del props[k]
  217. continue
  218. raise ValueError("Value of %r must be %r not %r: %r" % (
  219. k, str.__name__, type(v).__name__, v))
  220. if k == "tag":
  221. if v not in ("", "VCALENDAR", "VADDRESSBOOK", "VSUBSCRIBED"):
  222. raise ValueError("Unsupported collection tag: %r" % v)
  223. return props
  224. def find_available_uid(exists_fn: Callable[[str], bool], suffix: str = ""
  225. ) -> str:
  226. """Generate a pseudo-random UID"""
  227. # Prevent infinite loop
  228. for _ in range(1000):
  229. r = binascii.hexlify(os.urandom(16)).decode("ascii")
  230. name = "%s-%s-%s-%s-%s%s" % (
  231. r[:8], r[8:12], r[12:16], r[16:20], r[20:], suffix)
  232. if not exists_fn(name):
  233. return name
  234. # Something is wrong with the PRNG or `exists_fn`
  235. raise RuntimeError("No available random UID found")
  236. def get_etag(text: str) -> str:
  237. """Etag from collection or item.
  238. Encoded as quoted-string (see RFC 2616).
  239. """
  240. etag = sha256()
  241. etag.update(text.encode())
  242. return '"%s"' % etag.hexdigest()
  243. def get_uid(vobject_component: vobject.base.Component) -> str:
  244. """UID value of an item if defined."""
  245. return (vobject_component.uid.value or ""
  246. if hasattr(vobject_component, "uid") else "")
  247. def get_uid_from_object(vobject_item: vobject.base.Component) -> str:
  248. """UID value of an calendar/addressbook object."""
  249. if vobject_item.name == "VCALENDAR":
  250. if hasattr(vobject_item, "vevent"):
  251. return get_uid(vobject_item.vevent)
  252. if hasattr(vobject_item, "vjournal"):
  253. return get_uid(vobject_item.vjournal)
  254. if hasattr(vobject_item, "vtodo"):
  255. return get_uid(vobject_item.vtodo)
  256. elif vobject_item.name == "VCARD":
  257. return get_uid(vobject_item)
  258. return ""
  259. def find_tag(vobject_item: vobject.base.Component) -> str:
  260. """Find component name from ``vobject_item``."""
  261. if vobject_item.name == "VCALENDAR":
  262. for component in vobject_item.components():
  263. if component.name != "VTIMEZONE":
  264. return component.name or ""
  265. return ""
  266. def find_time_range(vobject_item: vobject.base.Component, tag: str
  267. ) -> Tuple[int, int]:
  268. """Find enclosing time range from ``vobject item``.
  269. ``tag`` must be set to the return value of ``find_tag``.
  270. Returns a tuple (``start``, ``end``) where ``start`` and ``end`` are
  271. POSIX timestamps.
  272. This is intended to be used for matching against simplified prefilters.
  273. """
  274. if not tag:
  275. return radicale_filter.TIMESTAMP_MIN, radicale_filter.TIMESTAMP_MAX
  276. start = end = None
  277. def range_fn(range_start: datetime, range_end: datetime,
  278. is_recurrence: bool) -> bool:
  279. nonlocal start, end
  280. if start is None or range_start < start:
  281. start = range_start
  282. if end is None or end < range_end:
  283. end = range_end
  284. return False
  285. def infinity_fn(range_start: datetime) -> bool:
  286. nonlocal start, end
  287. if start is None or range_start < start:
  288. start = range_start
  289. end = radicale_filter.DATETIME_MAX
  290. return True
  291. radicale_filter.visit_time_ranges(vobject_item, tag, range_fn, infinity_fn)
  292. if start is None:
  293. start = radicale_filter.DATETIME_MIN
  294. if end is None:
  295. end = radicale_filter.DATETIME_MAX
  296. return math.floor(start.timestamp()), math.ceil(end.timestamp())
  297. def verify(file: str, encoding: str):
  298. logger.info("Verifying item: %s", file)
  299. with open(file, "rb") as f:
  300. content_raw = f.read()
  301. content = content_raw.decode(encoding)
  302. logger.info("Verifying item: %s has sha256sum %r", file, utils.sha256_bytes(content_raw))
  303. try:
  304. vobject_items = read_components(content) # noqa: F841
  305. except Exception as e:
  306. logger.error("Verifying item: %s problem: %s", file, e)
  307. logger.warning("Item content:\n%s", utils.textwrap_str(content))
  308. logger.info("Item content (hexdump):\n%s", utils.hexdump_str(content))
  309. logger.info("Item content (hexdump/lines):\n%s", utils.hexdump_lines(content))
  310. return False
  311. else:
  312. logger.info("Verifying item: %s successful", file)
  313. return True
  314. class Item:
  315. """Class for address book and calendar entries."""
  316. collection: Optional["storage.BaseCollection"]
  317. href: Optional[str]
  318. last_modified: Optional[str]
  319. _collection_path: str
  320. _text: Optional[str]
  321. _vobject_item: Optional[vobject.base.Component]
  322. _etag: Optional[str]
  323. _uid: Optional[str]
  324. _name: Optional[str]
  325. _component_name: Optional[str]
  326. _time_range: Optional[Tuple[int, int]]
  327. def __init__(self,
  328. collection_path: Optional[str] = None,
  329. collection: Optional["storage.BaseCollection"] = None,
  330. vobject_item: Optional[vobject.base.Component] = None,
  331. href: Optional[str] = None,
  332. last_modified: Optional[str] = None,
  333. text: Optional[str] = None,
  334. etag: Optional[str] = None,
  335. uid: Optional[str] = None,
  336. name: Optional[str] = None,
  337. component_name: Optional[str] = None,
  338. time_range: Optional[Tuple[int, int]] = None):
  339. """Initialize an item.
  340. ``collection_path`` the path of the parent collection (optional if
  341. ``collection`` is set).
  342. ``collection`` the parent collection (optional).
  343. ``href`` the href of the item.
  344. ``last_modified`` the HTTP-datetime of when the item was modified.
  345. ``text`` the text representation of the item (optional if
  346. ``vobject_item`` is set).
  347. ``vobject_item`` the vobject item (optional if ``text`` is set).
  348. ``etag`` the etag of the item (optional). See ``get_etag``.
  349. ``uid`` the UID of the object (optional). See ``get_uid_from_object``.
  350. ``name`` the name of the item (optional). See ``vobject_item.name``.
  351. ``component_name`` the name of the primary component (optional).
  352. See ``find_tag``.
  353. ``time_range`` the enclosing time range. See ``find_time_range``.
  354. """
  355. if text is None and vobject_item is None:
  356. raise ValueError(
  357. "At least one of 'text' or 'vobject_item' must be set")
  358. if collection_path is None:
  359. if collection is None:
  360. raise ValueError("At least one of 'collection_path' or "
  361. "'collection' must be set")
  362. collection_path = collection.path
  363. assert collection_path == pathutils.strip_path(
  364. pathutils.sanitize_path(collection_path))
  365. self._collection_path = collection_path
  366. self.collection = collection
  367. self.href = href
  368. self.last_modified = last_modified
  369. self._text = text
  370. self._vobject_item = vobject_item
  371. self._etag = etag
  372. self._uid = uid
  373. self._name = name
  374. self._component_name = component_name
  375. self._time_range = time_range
  376. def serialize(self) -> str:
  377. if self._text is None:
  378. try:
  379. self._text = self.vobject_item.serialize()
  380. except Exception as e:
  381. raise RuntimeError("Failed to serialize item %r from %r: %s" %
  382. (self.href, self._collection_path,
  383. e)) from e
  384. return self._text
  385. @property
  386. def vobject_item(self):
  387. if self._vobject_item is None:
  388. try:
  389. self._vobject_item = vobject.readOne(self._text)
  390. except Exception as e:
  391. raise RuntimeError("Failed to parse item %r from %r: %s" %
  392. (self.href, self._collection_path,
  393. e)) from e
  394. return self._vobject_item
  395. @property
  396. def etag(self) -> str:
  397. """Encoded as quoted-string (see RFC 2616)."""
  398. if self._etag is None:
  399. self._etag = get_etag(self.serialize())
  400. return self._etag
  401. @property
  402. def uid(self) -> str:
  403. if self._uid is None:
  404. self._uid = get_uid_from_object(self.vobject_item)
  405. return self._uid
  406. @property
  407. def name(self) -> str:
  408. if self._name is None:
  409. self._name = self.vobject_item.name or ""
  410. return self._name
  411. @property
  412. def component_name(self) -> str:
  413. if self._component_name is None:
  414. self._component_name = find_tag(self.vobject_item)
  415. return self._component_name
  416. @property
  417. def time_range(self) -> Tuple[int, int]:
  418. if self._time_range is None:
  419. self._time_range = find_time_range(
  420. self.vobject_item, self.component_name)
  421. return self._time_range
  422. def prepare(self) -> None:
  423. """Fill cache with values."""
  424. orig_vobject_item = self._vobject_item
  425. self.serialize()
  426. self.etag
  427. self.uid
  428. self.name
  429. self.time_range
  430. self.component_name
  431. self._vobject_item = orig_vobject_item