__init__.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. # This file is part of Radicale Server - Calendar 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-2018 Unrud <unrud@outlook.com>
  7. #
  8. # This library is free software: you can redistribute it and/or modify
  9. # it under the terms of the GNU General Public License as published by
  10. # the Free Software Foundation, either version 3 of the License, or
  11. # (at your option) any later version.
  12. #
  13. # This library is distributed in the hope that it will be useful,
  14. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. # GNU General Public License for more details.
  17. #
  18. # You should have received a copy of the GNU General Public License
  19. # along with Radicale. If not, see <http://www.gnu.org/licenses/>.
  20. """
  21. Module for address books and calendar entries (see ``Item``).
  22. """
  23. import math
  24. import sys
  25. from datetime import timedelta
  26. from hashlib import sha256
  27. from random import getrandbits
  28. import vobject
  29. from radicale import pathutils
  30. from radicale.item import filter as radicale_filter
  31. from radicale.log import logger
  32. def predict_tag_of_parent_collection(vobject_items):
  33. if len(vobject_items) != 1:
  34. return ""
  35. if vobject_items[0].name == "VCALENDAR":
  36. return "VCALENDAR"
  37. if vobject_items[0].name in ("VCARD", "VLIST"):
  38. return "VADDRESSBOOK"
  39. return ""
  40. def predict_tag_of_whole_collection(vobject_items, fallback_tag=None):
  41. if vobject_items and vobject_items[0].name == "VCALENDAR":
  42. return "VCALENDAR"
  43. if vobject_items and vobject_items[0].name in ("VCARD", "VLIST"):
  44. return "VADDRESSBOOK"
  45. if not fallback_tag and not vobject_items:
  46. # Maybe an empty address book
  47. return "VADDRESSBOOK"
  48. return fallback_tag
  49. def check_and_sanitize_items(vobject_items, is_collection=False, tag=None):
  50. """Check vobject items for common errors and add missing UIDs.
  51. ``is_collection`` indicates that vobject_item contains unrelated
  52. components.
  53. The ``tag`` of the collection.
  54. """
  55. if tag and tag not in ("VCALENDAR", "VADDRESSBOOK"):
  56. raise ValueError("Unsupported collection tag: %r" % tag)
  57. if not is_collection and len(vobject_items) != 1:
  58. raise ValueError("Item contains %d components" % len(vobject_items))
  59. if tag == "VCALENDAR":
  60. if len(vobject_items) > 1:
  61. raise RuntimeError("VCALENDAR collection contains %d "
  62. "components" % len(vobject_items))
  63. vobject_item = vobject_items[0]
  64. if vobject_item.name != "VCALENDAR":
  65. raise ValueError("Item type %r not supported in %r "
  66. "collection" % (vobject_item.name, tag))
  67. component_uids = set()
  68. for component in vobject_item.components():
  69. if component.name in ("VTODO", "VEVENT", "VJOURNAL"):
  70. component_uid = get_uid(component)
  71. if component_uid:
  72. component_uids.add(component_uid)
  73. component_name = None
  74. object_uid = None
  75. object_uid_set = False
  76. for component in vobject_item.components():
  77. # https://tools.ietf.org/html/rfc4791#section-4.1
  78. if component.name == "VTIMEZONE":
  79. continue
  80. if component_name is None or is_collection:
  81. component_name = component.name
  82. elif component_name != component.name:
  83. raise ValueError("Multiple component types in object: %r, %r" %
  84. (component_name, component.name))
  85. if component_name not in ("VTODO", "VEVENT", "VJOURNAL"):
  86. continue
  87. component_uid = get_uid(component)
  88. if not object_uid_set or is_collection:
  89. object_uid_set = True
  90. object_uid = component_uid
  91. if not component_uid:
  92. if not is_collection:
  93. raise ValueError("%s component without UID in object" %
  94. component_name)
  95. component_uid = find_available_uid(
  96. component_uids.__contains__)
  97. component_uids.add(component_uid)
  98. if hasattr(component, "uid"):
  99. component.uid.value = component_uid
  100. else:
  101. component.add("UID").value = component_uid
  102. elif not object_uid or not component_uid:
  103. raise ValueError("Multiple %s components without UID in "
  104. "object" % component_name)
  105. elif object_uid != component_uid:
  106. raise ValueError(
  107. "Multiple %s components with different UIDs in object: "
  108. "%r, %r" % (component_name, object_uid, component_uid))
  109. # Workaround for bug in Lightning (Thunderbird)
  110. # Rescheduling a single occurrence from a repeating event creates
  111. # an event with DTEND and DURATION:PT0S
  112. if (hasattr(component, "dtend") and
  113. hasattr(component, "duration") and
  114. component.duration.value == timedelta(0)):
  115. logger.debug("Quirks: Removing zero duration from %s in "
  116. "object %r", component_name, component_uid)
  117. del component.duration
  118. # vobject interprets recurrence rules on demand
  119. try:
  120. component.rruleset
  121. except Exception as e:
  122. raise ValueError("invalid recurrence rules in %s" %
  123. component.name) from e
  124. elif tag == "VADDRESSBOOK":
  125. # https://tools.ietf.org/html/rfc6352#section-5.1
  126. object_uids = set()
  127. for vobject_item in vobject_items:
  128. if vobject_item.name == "VCARD":
  129. object_uid = get_uid(vobject_item)
  130. if object_uid:
  131. object_uids.add(object_uid)
  132. for vobject_item in vobject_items:
  133. if vobject_item.name == "VLIST":
  134. # Custom format used by SOGo Connector to store lists of
  135. # contacts
  136. continue
  137. if vobject_item.name != "VCARD":
  138. raise ValueError("Item type %r not supported in %r "
  139. "collection" % (vobject_item.name, tag))
  140. object_uid = get_uid(vobject_item)
  141. if not object_uid:
  142. if not is_collection:
  143. raise ValueError("%s object without UID" %
  144. vobject_item.name)
  145. object_uid = find_available_uid(object_uids.__contains__)
  146. object_uids.add(object_uid)
  147. if hasattr(vobject_item, "uid"):
  148. vobject_item.uid.value = object_uid
  149. else:
  150. vobject_item.add("UID").value = object_uid
  151. else:
  152. for i in vobject_items:
  153. raise ValueError("Item type %r not supported in %s collection" %
  154. (i.name, repr(tag) if tag else "generic"))
  155. def check_and_sanitize_props(props):
  156. """Check collection properties for common errors."""
  157. tag = props.get("tag")
  158. if tag and tag not in ("VCALENDAR", "VADDRESSBOOK"):
  159. raise ValueError("Unsupported collection tag: %r" % tag)
  160. def find_available_uid(exists_fn, suffix=""):
  161. """Generate a pseudo-random UID"""
  162. # Prevent infinite loop
  163. for _ in range(1000):
  164. r = "%016x" % getrandbits(128)
  165. name = "%s-%s-%s-%s-%s%s" % (
  166. r[:8], r[8:12], r[12:16], r[16:20], r[20:], suffix)
  167. if not exists_fn(name):
  168. return name
  169. # something is wrong with the PRNG
  170. raise RuntimeError("No unique random sequence found")
  171. def get_etag(text):
  172. """Etag from collection or item.
  173. Encoded as quoted-string (see RFC 2616).
  174. """
  175. etag = sha256()
  176. etag.update(text.encode())
  177. return '"%s"' % etag.hexdigest()
  178. def get_uid(vobject_component):
  179. """UID value of an item if defined."""
  180. return (vobject_component.uid.value
  181. if hasattr(vobject_component, "uid") else None)
  182. def get_uid_from_object(vobject_item):
  183. """UID value of an calendar/addressbook object."""
  184. if vobject_item.name == "VCALENDAR":
  185. if hasattr(vobject_item, "vevent"):
  186. return get_uid(vobject_item.vevent)
  187. if hasattr(vobject_item, "vjournal"):
  188. return get_uid(vobject_item.vjournal)
  189. if hasattr(vobject_item, "vtodo"):
  190. return get_uid(vobject_item.vtodo)
  191. elif vobject_item.name == "VCARD":
  192. return get_uid(vobject_item)
  193. return None
  194. def find_tag(vobject_item):
  195. """Find component name from ``vobject_item``."""
  196. if vobject_item.name == "VCALENDAR":
  197. for component in vobject_item.components():
  198. if component.name != "VTIMEZONE":
  199. return component.name or ""
  200. return ""
  201. def find_tag_and_time_range(vobject_item):
  202. """Find component name and enclosing time range from ``vobject item``.
  203. Returns a tuple (``tag``, ``start``, ``end``) where ``tag`` is a string
  204. and ``start`` and ``end`` are POSIX timestamps (as int).
  205. This is intened to be used for matching against simplified prefilters.
  206. """
  207. tag = find_tag(vobject_item)
  208. if not tag:
  209. return (
  210. tag, radicale_filter.TIMESTAMP_MIN, radicale_filter.TIMESTAMP_MAX)
  211. start = end = None
  212. def range_fn(range_start, range_end, is_recurrence):
  213. nonlocal start, end
  214. if start is None or range_start < start:
  215. start = range_start
  216. if end is None or end < range_end:
  217. end = range_end
  218. return False
  219. def infinity_fn(range_start):
  220. nonlocal start, end
  221. if start is None or range_start < start:
  222. start = range_start
  223. end = radicale_filter.DATETIME_MAX
  224. return True
  225. radicale_filter.visit_time_ranges(vobject_item, tag, range_fn, infinity_fn)
  226. if start is None:
  227. start = radicale_filter.DATETIME_MIN
  228. if end is None:
  229. end = radicale_filter.DATETIME_MAX
  230. try:
  231. return tag, math.floor(start.timestamp()), math.ceil(end.timestamp())
  232. except ValueError as e:
  233. if str(e) == ("offset must be a timedelta representing a whole "
  234. "number of minutes") and sys.version_info < (3, 6):
  235. raise RuntimeError("Unsupported in Python < 3.6: %s" % e) from e
  236. raise
  237. class Item:
  238. """Class for address book and calendar entries."""
  239. def __init__(self, collection_path=None, collection=None,
  240. vobject_item=None, href=None, last_modified=None, text=None,
  241. etag=None, uid=None, name=None, component_name=None,
  242. time_range=None):
  243. """Initialize an item.
  244. ``collection_path`` the path of the parent collection (optional if
  245. ``collection`` is set).
  246. ``collection`` the parent collection (optional).
  247. ``href`` the href of the item.
  248. ``last_modified`` the HTTP-datetime of when the item was modified.
  249. ``text`` the text representation of the item (optional if
  250. ``vobject_item`` is set).
  251. ``vobject_item`` the vobject item (optional if ``text`` is set).
  252. ``etag`` the etag of the item (optional). See ``get_etag``.
  253. ``uid`` the UID of the object (optional). See ``get_uid_from_object``.
  254. ``name`` the name of the item (optional). See ``vobject_item.name``.
  255. ``component_name`` the name of the primary component (optional).
  256. See ``find_tag``.
  257. ``time_range`` the enclosing time range.
  258. See ``find_tag_and_time_range``.
  259. """
  260. if text is None and vobject_item is None:
  261. raise ValueError(
  262. "at least one of 'text' or 'vobject_item' must be set")
  263. if collection_path is None:
  264. if collection is None:
  265. raise ValueError("at least one of 'collection_path' or "
  266. "'collection' must be set")
  267. collection_path = collection.path
  268. assert collection_path == pathutils.strip_path(
  269. pathutils.sanitize_path(collection_path))
  270. self._collection_path = collection_path
  271. self.collection = collection
  272. self.href = href
  273. self.last_modified = last_modified
  274. self._text = text
  275. self._vobject_item = vobject_item
  276. self._etag = etag
  277. self._uid = uid
  278. self._name = name
  279. self._component_name = component_name
  280. self._time_range = time_range
  281. def serialize(self):
  282. if self._text is None:
  283. try:
  284. self._text = self.vobject_item.serialize()
  285. except Exception as e:
  286. raise RuntimeError("Failed to serialize item %r from %r: %s" %
  287. (self.href, self._collection_path,
  288. e)) from e
  289. return self._text
  290. @property
  291. def vobject_item(self):
  292. if self._vobject_item is None:
  293. try:
  294. self._vobject_item = vobject.readOne(self._text)
  295. except Exception as e:
  296. raise RuntimeError("Failed to parse item %r from %r: %s" %
  297. (self.href, self._collection_path,
  298. e)) from e
  299. return self._vobject_item
  300. @property
  301. def etag(self):
  302. """Encoded as quoted-string (see RFC 2616)."""
  303. if self._etag is None:
  304. self._etag = get_etag(self.serialize())
  305. return self._etag
  306. @property
  307. def uid(self):
  308. if self._uid is None:
  309. self._uid = get_uid_from_object(self.vobject_item)
  310. return self._uid
  311. @property
  312. def name(self):
  313. if self._name is None:
  314. self._name = self.vobject_item.name or ""
  315. return self._name
  316. @property
  317. def component_name(self):
  318. if self._component_name is not None:
  319. return self._component_name
  320. return find_tag(self.vobject_item)
  321. @property
  322. def time_range(self):
  323. if self._time_range is None:
  324. self._component_name, *self._time_range = (
  325. find_tag_and_time_range(self.vobject_item))
  326. return self._time_range
  327. def prepare(self):
  328. """Fill cache with values."""
  329. orig_vobject_item = self._vobject_item
  330. self.serialize()
  331. self.etag
  332. self.uid
  333. self.name
  334. self.time_range
  335. self.component_name
  336. self._vobject_item = orig_vobject_item