filter.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  1. # This file is part of Radicale Server - Calendar Server
  2. # Copyright © 2008 Nicolas Kandel
  3. # Copyright © 2008 Pascal Halter
  4. # Copyright © 2008-2015 Guillaume Ayoub
  5. # Copyright © 2017-2018 Unrud <unrud@outlook.com>
  6. #
  7. # This library is free software: you can redistribute it and/or modify
  8. # it under the terms of the GNU General Public License as published by
  9. # the Free Software Foundation, either version 3 of the License, or
  10. # (at your option) any later version.
  11. #
  12. # This library is distributed in the hope that it will be useful,
  13. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. # GNU General Public License for more details.
  16. #
  17. # You should have received a copy of the GNU General Public License
  18. # along with Radicale. If not, see <http://www.gnu.org/licenses/>.
  19. import math
  20. import xml.etree.ElementTree as ET
  21. from datetime import date, datetime, timedelta, timezone
  22. from itertools import chain
  23. from typing import (Callable, Iterable, Iterator, List, Optional, Sequence,
  24. Tuple)
  25. import vobject
  26. from radicale import item, xmlutils
  27. from radicale.log import logger
  28. DAY: timedelta = timedelta(days=1)
  29. SECOND: timedelta = timedelta(seconds=1)
  30. DATETIME_MIN: datetime = datetime.min.replace(tzinfo=timezone.utc)
  31. DATETIME_MAX: datetime = datetime.max.replace(tzinfo=timezone.utc)
  32. TIMESTAMP_MIN: int = math.floor(DATETIME_MIN.timestamp())
  33. TIMESTAMP_MAX: int = math.ceil(DATETIME_MAX.timestamp())
  34. def date_to_datetime(d: date) -> datetime:
  35. """Transform any date to a UTC datetime.
  36. If ``d`` is a datetime without timezone, return as UTC datetime. If ``d``
  37. is already a datetime with timezone, return as is.
  38. """
  39. if not isinstance(d, datetime):
  40. d = datetime.combine(d, datetime.min.time())
  41. if not d.tzinfo:
  42. d = d.replace(tzinfo=timezone.utc)
  43. return d
  44. def comp_match(item: "item.Item", filter_: ET.Element, level: int = 0) -> bool:
  45. """Check whether the ``item`` matches the comp ``filter_``.
  46. If ``level`` is ``0``, the filter is applied on the
  47. item's collection. Otherwise, it's applied on the item.
  48. See rfc4791-9.7.1.
  49. """
  50. # TODO: Filtering VALARM and VFREEBUSY is not implemented
  51. # HACK: the filters are tested separately against all components
  52. if level == 0:
  53. tag = item.name
  54. elif level == 1:
  55. tag = item.component_name
  56. else:
  57. logger.warning(
  58. "Filters with three levels of comp-filter are not supported")
  59. return True
  60. if not tag:
  61. return False
  62. name = filter_.get("name", "").upper()
  63. if len(filter_) == 0:
  64. # Point #1 of rfc4791-9.7.1
  65. return name == tag
  66. if len(filter_) == 1:
  67. if filter_[0].tag == xmlutils.make_clark("C:is-not-defined"):
  68. # Point #2 of rfc4791-9.7.1
  69. return name != tag
  70. if name != tag:
  71. return False
  72. if (level == 0 and name != "VCALENDAR" or
  73. level == 1 and name not in ("VTODO", "VEVENT", "VJOURNAL")):
  74. logger.warning("Filtering %s is not supported", name)
  75. return True
  76. # Point #3 and #4 of rfc4791-9.7.1
  77. components = ([item.vobject_item] if level == 0
  78. else list(getattr(item.vobject_item,
  79. "%s_list" % tag.lower())))
  80. for child in filter_:
  81. if child.tag == xmlutils.make_clark("C:prop-filter"):
  82. if not any(prop_match(comp, child, "C")
  83. for comp in components):
  84. return False
  85. elif child.tag == xmlutils.make_clark("C:time-range"):
  86. if not time_range_match(item.vobject_item, filter_[0], tag):
  87. return False
  88. elif child.tag == xmlutils.make_clark("C:comp-filter"):
  89. if not comp_match(item, child, level=level + 1):
  90. return False
  91. else:
  92. raise ValueError("Unexpected %r in comp-filter" % child.tag)
  93. return True
  94. def prop_match(vobject_item: vobject.base.Component,
  95. filter_: ET.Element, ns: str) -> bool:
  96. """Check whether the ``item`` matches the prop ``filter_``.
  97. See rfc4791-9.7.2 and rfc6352-10.5.1.
  98. """
  99. name = filter_.get("name", "").lower()
  100. if len(filter_) == 0:
  101. # Point #1 of rfc4791-9.7.2
  102. return name in vobject_item.contents
  103. if len(filter_) == 1:
  104. if filter_[0].tag == xmlutils.make_clark("%s:is-not-defined" % ns):
  105. # Point #2 of rfc4791-9.7.2
  106. return name not in vobject_item.contents
  107. if name not in vobject_item.contents:
  108. return False
  109. # Point #3 and #4 of rfc4791-9.7.2
  110. for child in filter_:
  111. if ns == "C" and child.tag == xmlutils.make_clark("C:time-range"):
  112. if not time_range_match(vobject_item, child, name):
  113. return False
  114. elif child.tag == xmlutils.make_clark("%s:text-match" % ns):
  115. if not text_match(vobject_item, child, name, ns):
  116. return False
  117. elif child.tag == xmlutils.make_clark("%s:param-filter" % ns):
  118. if not param_filter_match(vobject_item, child, name, ns):
  119. return False
  120. else:
  121. raise ValueError("Unexpected %r in prop-filter" % child.tag)
  122. return True
  123. def time_range_match(vobject_item: vobject.base.Component,
  124. filter_: ET.Element, child_name: str) -> bool:
  125. """Check whether the component/property ``child_name`` of
  126. ``vobject_item`` matches the time-range ``filter_``."""
  127. start_text = filter_.get("start")
  128. end_text = filter_.get("end")
  129. if not start_text and not end_text:
  130. return False
  131. if start_text:
  132. start = datetime.strptime(start_text, "%Y%m%dT%H%M%SZ")
  133. else:
  134. start = datetime.min
  135. if end_text:
  136. end = datetime.strptime(end_text, "%Y%m%dT%H%M%SZ")
  137. else:
  138. end = datetime.max
  139. start = start.replace(tzinfo=timezone.utc)
  140. end = end.replace(tzinfo=timezone.utc)
  141. matched = False
  142. def range_fn(range_start: datetime, range_end: datetime,
  143. is_recurrence: bool) -> bool:
  144. nonlocal matched
  145. if start < range_end and range_start < end:
  146. matched = True
  147. return True
  148. if end < range_start and not is_recurrence:
  149. return True
  150. return False
  151. def infinity_fn(start: datetime) -> bool:
  152. return False
  153. visit_time_ranges(vobject_item, child_name, range_fn, infinity_fn)
  154. return matched
  155. def visit_time_ranges(vobject_item: vobject.base.Component, child_name: str,
  156. range_fn: Callable[[datetime, datetime, bool], bool],
  157. infinity_fn: Callable[[datetime], bool]) -> None:
  158. """Visit all time ranges in the component/property ``child_name`` of
  159. `vobject_item`` with visitors ``range_fn`` and ``infinity_fn``.
  160. ``range_fn`` gets called for every time_range with ``start`` and ``end``
  161. datetimes and ``is_recurrence`` as arguments. If the function returns True,
  162. the operation is cancelled.
  163. ``infinity_fn`` gets called when an infiite recurrence rule is detected
  164. with ``start`` datetime as argument. If the function returns True, the
  165. operation is cancelled.
  166. See rfc4791-9.9.
  167. """
  168. # HACK: According to rfc5545-3.8.4.4 an recurrance that is resheduled
  169. # with Recurrence ID affects the recurrence itself and all following
  170. # recurrences too. This is not respected and client don't seem to bother
  171. # either.
  172. def getrruleset(child: vobject.base.Component, ignore: Sequence[date]
  173. ) -> Tuple[Iterable[date], bool]:
  174. if (hasattr(child, "rrule") and
  175. ";UNTIL=" not in child.rrule.value.upper() and
  176. ";COUNT=" not in child.rrule.value.upper()):
  177. for dtstart in child.getrruleset(addRDate=True):
  178. if dtstart in ignore:
  179. continue
  180. if infinity_fn(date_to_datetime(dtstart)):
  181. return (), True
  182. break
  183. return filter(lambda dtstart: dtstart not in ignore,
  184. child.getrruleset(addRDate=True)), False
  185. def get_children(components: Iterable[vobject.base.Component]) -> Iterator[
  186. Tuple[vobject.base.Component, bool, List[date]]]:
  187. main = None
  188. recurrences = []
  189. for comp in components:
  190. if hasattr(comp, "recurrence_id") and comp.recurrence_id.value:
  191. recurrences.append(comp.recurrence_id.value)
  192. if comp.rruleset:
  193. # Prevent possible infinite loop
  194. raise ValueError("Overwritten recurrence with RRULESET")
  195. yield comp, True, []
  196. else:
  197. if main is not None:
  198. raise ValueError("Multiple main components")
  199. main = comp
  200. if main is None:
  201. raise ValueError("Main component missing")
  202. yield main, False, recurrences
  203. # Comments give the lines in the tables of the specification
  204. if child_name == "VEVENT":
  205. for child, is_recurrence, recurrences in get_children(
  206. vobject_item.vevent_list):
  207. # TODO: check if there's a timezone
  208. dtstart = child.dtstart.value
  209. if child.rruleset:
  210. dtstarts, infinity = getrruleset(child, recurrences)
  211. if infinity:
  212. return
  213. else:
  214. dtstarts = (dtstart,)
  215. dtend = getattr(child, "dtend", None)
  216. if dtend is not None:
  217. dtend = dtend.value
  218. original_duration = (dtend - dtstart).total_seconds()
  219. dtend = date_to_datetime(dtend)
  220. duration = getattr(child, "duration", None)
  221. if duration is not None:
  222. original_duration = duration = duration.value
  223. for dtstart in dtstarts:
  224. dtstart_is_datetime = isinstance(dtstart, datetime)
  225. dtstart = date_to_datetime(dtstart)
  226. if dtend is not None:
  227. # Line 1
  228. dtend = dtstart + timedelta(seconds=original_duration)
  229. if range_fn(dtstart, dtend, is_recurrence):
  230. return
  231. elif duration is not None:
  232. if original_duration is None:
  233. original_duration = duration.seconds
  234. if duration.seconds > 0:
  235. # Line 2
  236. if range_fn(dtstart, dtstart + duration,
  237. is_recurrence):
  238. return
  239. else:
  240. # Line 3
  241. if range_fn(dtstart, dtstart + SECOND, is_recurrence):
  242. return
  243. elif dtstart_is_datetime:
  244. # Line 4
  245. if range_fn(dtstart, dtstart + SECOND, is_recurrence):
  246. return
  247. else:
  248. # Line 5
  249. if range_fn(dtstart, dtstart + DAY, is_recurrence):
  250. return
  251. elif child_name == "VTODO":
  252. for child, is_recurrence, recurrences in get_children(
  253. vobject_item.vtodo_list):
  254. dtstart = getattr(child, "dtstart", None)
  255. duration = getattr(child, "duration", None)
  256. due = getattr(child, "due", None)
  257. completed = getattr(child, "completed", None)
  258. created = getattr(child, "created", None)
  259. if dtstart is not None:
  260. dtstart = date_to_datetime(dtstart.value)
  261. if duration is not None:
  262. duration = duration.value
  263. if due is not None:
  264. due = date_to_datetime(due.value)
  265. if dtstart is not None:
  266. original_duration = (due - dtstart).total_seconds()
  267. if completed is not None:
  268. completed = date_to_datetime(completed.value)
  269. if created is not None:
  270. created = date_to_datetime(created.value)
  271. original_duration = (completed - created).total_seconds()
  272. elif created is not None:
  273. created = date_to_datetime(created.value)
  274. if child.rruleset:
  275. reference_dates, infinity = getrruleset(child, recurrences)
  276. if infinity:
  277. return
  278. else:
  279. if dtstart is not None:
  280. reference_dates = (dtstart,)
  281. elif due is not None:
  282. reference_dates = (due,)
  283. elif completed is not None:
  284. reference_dates = (completed,)
  285. elif created is not None:
  286. reference_dates = (created,)
  287. else:
  288. # Line 8
  289. if range_fn(DATETIME_MIN, DATETIME_MAX, is_recurrence):
  290. return
  291. reference_dates = ()
  292. for reference_date in reference_dates:
  293. reference_date = date_to_datetime(reference_date)
  294. if dtstart is not None and duration is not None:
  295. # Line 1
  296. if range_fn(reference_date,
  297. reference_date + duration + SECOND,
  298. is_recurrence):
  299. return
  300. if range_fn(reference_date + duration - SECOND,
  301. reference_date + duration + SECOND,
  302. is_recurrence):
  303. return
  304. elif dtstart is not None and due is not None:
  305. # Line 2
  306. due = reference_date + timedelta(seconds=original_duration)
  307. if (range_fn(reference_date, due, is_recurrence) or
  308. range_fn(reference_date,
  309. reference_date + SECOND, is_recurrence) or
  310. range_fn(due - SECOND, due, is_recurrence) or
  311. range_fn(due - SECOND, reference_date + SECOND,
  312. is_recurrence)):
  313. return
  314. elif dtstart is not None:
  315. if range_fn(reference_date, reference_date + SECOND,
  316. is_recurrence):
  317. return
  318. elif due is not None:
  319. # Line 4
  320. if range_fn(reference_date - SECOND, reference_date,
  321. is_recurrence):
  322. return
  323. elif completed is not None and created is not None:
  324. # Line 5
  325. completed = reference_date + timedelta(
  326. seconds=original_duration)
  327. if (range_fn(reference_date - SECOND,
  328. reference_date + SECOND,
  329. is_recurrence) or
  330. range_fn(completed - SECOND, completed + SECOND,
  331. is_recurrence) or
  332. range_fn(reference_date - SECOND,
  333. reference_date + SECOND, is_recurrence) or
  334. range_fn(completed - SECOND, completed + SECOND,
  335. is_recurrence)):
  336. return
  337. elif completed is not None:
  338. # Line 6
  339. if range_fn(reference_date - SECOND,
  340. reference_date + SECOND, is_recurrence):
  341. return
  342. elif created is not None:
  343. # Line 7
  344. if range_fn(reference_date, DATETIME_MAX, is_recurrence):
  345. return
  346. elif child_name == "VJOURNAL":
  347. for child, is_recurrence, recurrences in get_children(
  348. vobject_item.vjournal_list):
  349. dtstart = getattr(child, "dtstart", None)
  350. if dtstart is not None:
  351. dtstart = dtstart.value
  352. if child.rruleset:
  353. dtstarts, infinity = getrruleset(child, recurrences)
  354. if infinity:
  355. return
  356. else:
  357. dtstarts = (dtstart,)
  358. for dtstart in dtstarts:
  359. dtstart_is_datetime = isinstance(dtstart, datetime)
  360. dtstart = date_to_datetime(dtstart)
  361. if dtstart_is_datetime:
  362. # Line 1
  363. if range_fn(dtstart, dtstart + SECOND, is_recurrence):
  364. return
  365. else:
  366. # Line 2
  367. if range_fn(dtstart, dtstart + DAY, is_recurrence):
  368. return
  369. else:
  370. # Match a property
  371. child = getattr(vobject_item, child_name.lower())
  372. if isinstance(child, date):
  373. child_is_datetime = isinstance(child, datetime)
  374. child = date_to_datetime(child)
  375. if child_is_datetime:
  376. range_fn(child, child + SECOND, False)
  377. else:
  378. range_fn(child, child + DAY, False)
  379. def text_match(vobject_item: vobject.base.Component,
  380. filter_: ET.Element, child_name: str, ns: str,
  381. attrib_name: Optional[str] = None) -> bool:
  382. """Check whether the ``item`` matches the text-match ``filter_``.
  383. See rfc4791-9.7.5.
  384. """
  385. # TODO: collations are not supported, but the default ones needed
  386. # for DAV servers are actually pretty useless. Texts are lowered to
  387. # be case-insensitive, almost as the "i;ascii-casemap" value.
  388. text = next(filter_.itertext()).lower()
  389. match_type = "contains"
  390. if ns == "CR":
  391. match_type = filter_.get("match-type", match_type)
  392. def match(value: str) -> bool:
  393. value = value.lower()
  394. if match_type == "equals":
  395. return value == text
  396. if match_type == "contains":
  397. return text in value
  398. if match_type == "starts-with":
  399. return value.startswith(text)
  400. if match_type == "ends-with":
  401. return value.endswith(text)
  402. raise ValueError("Unexpected text-match match-type: %r" % match_type)
  403. children = getattr(vobject_item, "%s_list" % child_name, [])
  404. if attrib_name is not None:
  405. condition = any(
  406. match(attrib) for child in children
  407. for attrib in child.params.get(attrib_name, []))
  408. else:
  409. condition = any(match(child.value) for child in children)
  410. if filter_.get("negate-condition") == "yes":
  411. return not condition
  412. return condition
  413. def param_filter_match(vobject_item: vobject.base.Component,
  414. filter_: ET.Element, parent_name: str, ns: str) -> bool:
  415. """Check whether the ``item`` matches the param-filter ``filter_``.
  416. See rfc4791-9.7.3.
  417. """
  418. name = filter_.get("name", "").upper()
  419. children = getattr(vobject_item, "%s_list" % parent_name, [])
  420. condition = any(name in child.params for child in children)
  421. if len(filter_) > 0:
  422. if filter_[0].tag == xmlutils.make_clark("%s:text-match" % ns):
  423. return condition and text_match(
  424. vobject_item, filter_[0], parent_name, ns, name)
  425. if filter_[0].tag == xmlutils.make_clark("%s:is-not-defined" % ns):
  426. return not condition
  427. return condition
  428. def simplify_prefilters(filters: Iterable[ET.Element], collection_tag: str
  429. ) -> Tuple[Optional[str], int, int, bool]:
  430. """Creates a simplified condition from ``filters``.
  431. Returns a tuple (``tag``, ``start``, ``end``, ``simple``) where ``tag`` is
  432. a string or None (match all) and ``start`` and ``end`` are POSIX
  433. timestamps (as int). ``simple`` is a bool that indicates that ``filters``
  434. and the simplified condition are identical.
  435. """
  436. flat_filters = list(chain.from_iterable(filters))
  437. simple = len(flat_filters) <= 1
  438. for col_filter in flat_filters:
  439. if collection_tag != "VCALENDAR":
  440. simple = False
  441. break
  442. if (col_filter.tag != xmlutils.make_clark("C:comp-filter") or
  443. col_filter.get("name", "").upper() != "VCALENDAR"):
  444. simple = False
  445. continue
  446. simple &= len(col_filter) <= 1
  447. for comp_filter in col_filter:
  448. if comp_filter.tag != xmlutils.make_clark("C:comp-filter"):
  449. simple = False
  450. continue
  451. tag = comp_filter.get("name", "").upper()
  452. if comp_filter.find(
  453. xmlutils.make_clark("C:is-not-defined")) is not None:
  454. simple = False
  455. continue
  456. simple &= len(comp_filter) <= 1
  457. for time_filter in comp_filter:
  458. if tag not in ("VTODO", "VEVENT", "VJOURNAL"):
  459. simple = False
  460. break
  461. if time_filter.tag != xmlutils.make_clark("C:time-range"):
  462. simple = False
  463. continue
  464. start_text = time_filter.get("start")
  465. end_text = time_filter.get("end")
  466. if start_text:
  467. start = math.floor(datetime.strptime(
  468. start_text, "%Y%m%dT%H%M%SZ").replace(
  469. tzinfo=timezone.utc).timestamp())
  470. else:
  471. start = TIMESTAMP_MIN
  472. if end_text:
  473. end = math.ceil(datetime.strptime(
  474. end_text, "%Y%m%dT%H%M%SZ").replace(
  475. tzinfo=timezone.utc).timestamp())
  476. else:
  477. end = TIMESTAMP_MAX
  478. return tag, start, end, simple
  479. return tag, TIMESTAMP_MIN, TIMESTAMP_MAX, simple
  480. return None, TIMESTAMP_MIN, TIMESTAMP_MAX, simple