filter.py 23 KB

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