storage.py 63 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639
  1. # This file is part of Radicale Server - Calendar Server
  2. # Copyright © 2014 Jean-Marc Martins
  3. # Copyright © 2012-2017 Guillaume Ayoub
  4. #
  5. # This library is free software: you can redistribute it and/or modify
  6. # it under the terms of the GNU General Public License as published by
  7. # the Free Software Foundation, either version 3 of the License, or
  8. # (at your option) any later version.
  9. #
  10. # This library is distributed in the hope that it will be useful,
  11. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. # GNU General Public License for more details.
  14. #
  15. # You should have received a copy of the GNU General Public License
  16. # along with Radicale. If not, see <http://www.gnu.org/licenses/>.
  17. """
  18. Storage backends.
  19. This module loads the storage backend, according to the storage configuration.
  20. Default storage uses one folder per collection and one file per collection
  21. entry.
  22. """
  23. import binascii
  24. import contextlib
  25. import json
  26. import logging
  27. import os
  28. import pickle
  29. import posixpath
  30. import shlex
  31. import subprocess
  32. import threading
  33. import time
  34. from contextlib import contextmanager
  35. from hashlib import md5
  36. from importlib import import_module
  37. from itertools import chain
  38. from random import getrandbits
  39. from tempfile import NamedTemporaryFile, TemporaryDirectory
  40. import pkg_resources
  41. import vobject
  42. from radicale import xmlutils
  43. from radicale.log import logger
  44. if os.name == "nt":
  45. import ctypes
  46. import ctypes.wintypes
  47. import msvcrt
  48. LOCKFILE_EXCLUSIVE_LOCK = 2
  49. if ctypes.sizeof(ctypes.c_void_p) == 4:
  50. ULONG_PTR = ctypes.c_uint32
  51. else:
  52. ULONG_PTR = ctypes.c_uint64
  53. class Overlapped(ctypes.Structure):
  54. _fields_ = [
  55. ("internal", ULONG_PTR),
  56. ("internal_high", ULONG_PTR),
  57. ("offset", ctypes.wintypes.DWORD),
  58. ("offset_high", ctypes.wintypes.DWORD),
  59. ("h_event", ctypes.wintypes.HANDLE)]
  60. lock_file_ex = ctypes.windll.kernel32.LockFileEx
  61. lock_file_ex.argtypes = [
  62. ctypes.wintypes.HANDLE,
  63. ctypes.wintypes.DWORD,
  64. ctypes.wintypes.DWORD,
  65. ctypes.wintypes.DWORD,
  66. ctypes.wintypes.DWORD,
  67. ctypes.POINTER(Overlapped)]
  68. lock_file_ex.restype = ctypes.wintypes.BOOL
  69. unlock_file_ex = ctypes.windll.kernel32.UnlockFileEx
  70. unlock_file_ex.argtypes = [
  71. ctypes.wintypes.HANDLE,
  72. ctypes.wintypes.DWORD,
  73. ctypes.wintypes.DWORD,
  74. ctypes.wintypes.DWORD,
  75. ctypes.POINTER(Overlapped)]
  76. unlock_file_ex.restype = ctypes.wintypes.BOOL
  77. elif os.name == "posix":
  78. import fcntl
  79. INTERNAL_TYPES = ("multifilesystem",)
  80. DEPS = ("radicale", "vobject", "python-dateutil",)
  81. ITEM_CACHE_TAG = (";".join(pkg_resources.get_distribution(pkg).version
  82. for pkg in DEPS) + ";").encode()
  83. def load(configuration):
  84. """Load the storage manager chosen in configuration."""
  85. storage_type = configuration.get("storage", "type")
  86. if storage_type == "multifilesystem":
  87. collection_class = Collection
  88. else:
  89. try:
  90. collection_class = import_module(storage_type).Collection
  91. except Exception as e:
  92. raise RuntimeError("Failed to load storage module %r: %s" %
  93. (storage_type, e)) from e
  94. logger.info("Storage type is %r", storage_type)
  95. class CollectionCopy(collection_class):
  96. """Collection copy, avoids overriding the original class attributes."""
  97. CollectionCopy.configuration = configuration
  98. CollectionCopy.static_init()
  99. return CollectionCopy
  100. def predict_tag_of_parent_collection(vobject_items):
  101. if len(vobject_items) != 1:
  102. return ""
  103. if vobject_items[0].name == "VCALENDAR":
  104. return "VCALENDAR"
  105. if vobject_items[0].name in ("VCARD", "VLIST"):
  106. return "VADDRESSBOOK"
  107. return ""
  108. def predict_tag_of_whole_collection(vobject_items, fallback_tag=None):
  109. if vobject_items and vobject_items[0].name == "VCALENDAR":
  110. return "VCALENDAR"
  111. if vobject_items and vobject_items[0].name in ("VCARD", "VLIST"):
  112. return "VADDRESSBOOK"
  113. if not fallback_tag and not vobject_items:
  114. # Maybe an empty address book
  115. return "VADDRESSBOOK"
  116. return fallback_tag
  117. def check_and_sanitize_items(vobject_items, is_collection=False, tag=None):
  118. """Check vobject items for common errors and add missing UIDs.
  119. ``is_collection`` indicates that vobject_item contains unrelated
  120. components.
  121. The ``tag`` of the collection.
  122. """
  123. if tag and tag not in ("VCALENDAR", "VADDRESSBOOK"):
  124. raise ValueError("Unsupported collection tag: %r" % tag)
  125. if not is_collection and len(vobject_items) != 1:
  126. raise ValueError("Item contains %d components" % len(vobject_items))
  127. if tag == "VCALENDAR":
  128. if len(vobject_items) > 1:
  129. raise RuntimeError("VCALENDAR collection contains %d "
  130. "components" % len(vobject_items))
  131. vobject_item = vobject_items[0]
  132. if vobject_item.name != "VCALENDAR":
  133. raise ValueError("Item type %r not supported in %r "
  134. "collection" % (vobject_item.name, tag))
  135. component_uids = set()
  136. for component in vobject_item.components():
  137. if component.name in ("VTODO", "VEVENT", "VJOURNAL"):
  138. component_uid = get_uid(component)
  139. if component_uid:
  140. component_uids.add(component_uid)
  141. component_name = None
  142. object_uid = None
  143. object_uid_set = False
  144. for component in vobject_item.components():
  145. # https://tools.ietf.org/html/rfc4791#section-4.1
  146. if component.name == "VTIMEZONE":
  147. continue
  148. if component_name is None or is_collection:
  149. component_name = component.name
  150. elif component_name != component.name:
  151. raise ValueError("Multiple component types in object: %r, %r" %
  152. (component_name, component.name))
  153. if component_name not in ("VTODO", "VEVENT", "VJOURNAL"):
  154. continue
  155. component_uid = get_uid(component)
  156. if not object_uid_set or is_collection:
  157. object_uid_set = True
  158. object_uid = component_uid
  159. if not component_uid:
  160. if not is_collection:
  161. raise ValueError("%s component without UID in object" %
  162. component_name)
  163. component_uid = find_available_name(
  164. component_uids.__contains__)
  165. component_uids.add(component_uid)
  166. if hasattr(component, "uid"):
  167. component.uid.value = component_uid
  168. else:
  169. component.add("UID").value = component_uid
  170. elif not object_uid or not component_uid:
  171. raise ValueError("Multiple %s components without UID in "
  172. "object" % component_name)
  173. elif object_uid != component_uid:
  174. raise ValueError(
  175. "Multiple %s components with different UIDs in object: "
  176. "%r, %r" % (component_name, object_uid, component_uid))
  177. # vobject interprets recurrence rules on demand
  178. try:
  179. component.rruleset
  180. except Exception as e:
  181. raise ValueError("invalid recurrence rules in %s" %
  182. component.name) from e
  183. elif tag == "VADDRESSBOOK":
  184. # https://tools.ietf.org/html/rfc6352#section-5.1
  185. object_uids = set()
  186. for vobject_item in vobject_items:
  187. if vobject_item.name == "VCARD":
  188. object_uid = get_uid(vobject_item)
  189. if object_uid:
  190. object_uids.add(object_uid)
  191. for vobject_item in vobject_items:
  192. if vobject_item.name == "VLIST":
  193. # Custom format used by SOGo Connector to store lists of
  194. # contacts
  195. continue
  196. if vobject_item.name != "VCARD":
  197. raise ValueError("Item type %r not supported in %r "
  198. "collection" % (vobject_item.name, tag))
  199. object_uid = get_uid(vobject_item)
  200. if not object_uid:
  201. if not is_collection:
  202. raise ValueError("%s object without UID" %
  203. vobject_item.name)
  204. object_uid = find_available_name(object_uids.__contains__)
  205. object_uids.add(object_uid)
  206. if hasattr(vobject_item, "uid"):
  207. vobject_item.uid.value = object_uid
  208. else:
  209. vobject_item.add("UID").value = object_uid
  210. else:
  211. for i in vobject_items:
  212. raise ValueError("Item type %r not supported in %s collection" %
  213. (i.name, repr(tag) if tag else "generic"))
  214. def check_and_sanitize_props(props):
  215. """Check collection properties for common errors."""
  216. tag = props.get("tag")
  217. if tag and tag not in ("VCALENDAR", "VADDRESSBOOK"):
  218. raise ValueError("Unsupported collection tag: %r" % tag)
  219. def find_available_name(exists_fn, suffix=""):
  220. """Generate a pseudo-random UID"""
  221. # Prevent infinite loop
  222. for _ in range(1000):
  223. r = "%016x" % getrandbits(128)
  224. name = "%s-%s-%s-%s-%s%s" % (
  225. r[:8], r[8:12], r[12:16], r[16:20], r[20:], suffix)
  226. if not exists_fn(name):
  227. return name
  228. # something is wrong with the PRNG
  229. raise RuntimeError("No unique random sequence found")
  230. def get_etag(text):
  231. """Etag from collection or item.
  232. Encoded as quoted-string (see RFC 2616).
  233. """
  234. etag = md5()
  235. etag.update(text.encode("utf-8"))
  236. return '"%s"' % etag.hexdigest()
  237. def get_uid(vobject_component):
  238. """UID value of an item if defined."""
  239. return (vobject_component.uid.value
  240. if hasattr(vobject_component, "uid") else None)
  241. def get_uid_from_object(vobject_item):
  242. """UID value of an calendar/addressbook object."""
  243. if vobject_item.name == "VCALENDAR":
  244. if hasattr(vobject_item, "vevent"):
  245. return get_uid(vobject_item.vevent)
  246. if hasattr(vobject_item, "vjournal"):
  247. return get_uid(vobject_item.vjournal)
  248. if hasattr(vobject_item, "vtodo"):
  249. return get_uid(vobject_item.vtodo)
  250. elif vobject_item.name == "VCARD":
  251. return get_uid(vobject_item)
  252. return None
  253. def sanitize_path(path):
  254. """Make path absolute with leading slash to prevent access to other data.
  255. Preserve a potential trailing slash.
  256. """
  257. trailing_slash = "/" if path.endswith("/") else ""
  258. path = posixpath.normpath(path)
  259. new_path = "/"
  260. for part in path.split("/"):
  261. if not is_safe_path_component(part):
  262. continue
  263. new_path = posixpath.join(new_path, part)
  264. trailing_slash = "" if new_path.endswith("/") else trailing_slash
  265. return new_path + trailing_slash
  266. def is_safe_path_component(path):
  267. """Check if path is a single component of a path.
  268. Check that the path is safe to join too.
  269. """
  270. return path and "/" not in path and path not in (".", "..")
  271. def is_safe_filesystem_path_component(path):
  272. """Check if path is a single component of a local and posix filesystem
  273. path.
  274. Check that the path is safe to join too.
  275. """
  276. return (
  277. path and not os.path.splitdrive(path)[0] and
  278. not os.path.split(path)[0] and path not in (os.curdir, os.pardir) and
  279. not path.startswith(".") and not path.endswith("~") and
  280. is_safe_path_component(path))
  281. def path_to_filesystem(root, *paths):
  282. """Convert path to a local filesystem path relative to base_folder.
  283. `root` must be a secure filesystem path, it will be prepend to the path.
  284. Conversion of `paths` is done in a secure manner, or raises ``ValueError``.
  285. """
  286. paths = [sanitize_path(path).strip("/") for path in paths]
  287. safe_path = root
  288. for path in paths:
  289. if not path:
  290. continue
  291. for part in path.split("/"):
  292. if not is_safe_filesystem_path_component(part):
  293. raise UnsafePathError(part)
  294. safe_path_parent = safe_path
  295. safe_path = os.path.join(safe_path, part)
  296. # Check for conflicting files (e.g. case-insensitive file systems
  297. # or short names on Windows file systems)
  298. if (os.path.lexists(safe_path) and
  299. part not in (e.name for e in
  300. os.scandir(safe_path_parent))):
  301. raise CollidingPathError(part)
  302. return safe_path
  303. class UnsafePathError(ValueError):
  304. def __init__(self, path):
  305. message = "Can't translate name safely to filesystem: %r" % path
  306. super().__init__(message)
  307. class CollidingPathError(ValueError):
  308. def __init__(self, path):
  309. message = "File name collision: %r" % path
  310. super().__init__(message)
  311. class ComponentExistsError(ValueError):
  312. def __init__(self, path):
  313. message = "Component already exists: %r" % path
  314. super().__init__(message)
  315. class ComponentNotFoundError(ValueError):
  316. def __init__(self, path):
  317. message = "Component doesn't exist: %r" % path
  318. super().__init__(message)
  319. class Item:
  320. def __init__(self, collection_path=None, collection=None,
  321. vobject_item=None, href=None, last_modified=None, text=None,
  322. etag=None, uid=None, name=None, component_name=None,
  323. time_range=None):
  324. """Initialize an item.
  325. ``collection_path`` the path of the parent collection (optional if
  326. ``collection`` is set).
  327. ``collection`` the parent collection (optional).
  328. ``href`` the href of the item.
  329. ``last_modified`` the HTTP-datetime of when the item was modified.
  330. ``text`` the text representation of the item (optional if
  331. ``vobject_item`` is set).
  332. ``vobject_item`` the vobject item (optional if ``text`` is set).
  333. ``etag`` the etag of the item (optional). See ``get_etag``.
  334. ``uid`` the UID of the object (optional). See ``get_uid_from_object``.
  335. ``name`` the name of the item (optional). See ``vobject_item.name``.
  336. ``component_name`` the name of the primary component (optional).
  337. See ``find_tag``.
  338. ``time_range`` the enclosing time range.
  339. See ``find_tag_and_time_range``.
  340. """
  341. if text is None and vobject_item is None:
  342. raise ValueError(
  343. "at least one of 'text' or 'vobject_item' must be set")
  344. if collection_path is None:
  345. if collection is None:
  346. raise ValueError("at least one of 'collection_path' or "
  347. "'collection' must be set")
  348. collection_path = collection.path
  349. self._collection_path = collection_path
  350. self.collection = collection
  351. self.href = href
  352. self.last_modified = last_modified
  353. self._text = text
  354. self._vobject_item = vobject_item
  355. self._etag = etag
  356. self._uid = uid
  357. self._name = name
  358. self._component_name = component_name
  359. self._time_range = time_range
  360. def serialize(self):
  361. if self._text is None:
  362. try:
  363. self._text = self.vobject_item.serialize()
  364. except Exception as e:
  365. raise RuntimeError("Failed to serialize item %r from %r: %s" %
  366. (self.href, self._collection_path,
  367. e)) from e
  368. return self._text
  369. @property
  370. def vobject_item(self):
  371. if self._vobject_item is None:
  372. try:
  373. self._vobject_item = vobject.readOne(self._text)
  374. except Exception as e:
  375. raise RuntimeError("Failed to parse item %r from %r: %s" %
  376. (self.href, self._collection_path,
  377. e)) from e
  378. return self._vobject_item
  379. @property
  380. def etag(self):
  381. """Encoded as quoted-string (see RFC 2616)."""
  382. if self._etag is None:
  383. self._etag = get_etag(self.serialize())
  384. return self._etag
  385. @property
  386. def uid(self):
  387. if self._uid is None:
  388. self._uid = get_uid_from_object(self.vobject_item)
  389. return self._uid
  390. @property
  391. def name(self):
  392. if self._name is None:
  393. self._name = self.vobject_item.name or ""
  394. return self._name
  395. @property
  396. def component_name(self):
  397. if self._component_name is not None:
  398. return self._component_name
  399. return xmlutils.find_tag(self.vobject_item)
  400. @property
  401. def time_range(self):
  402. if self._time_range is None:
  403. self._component_name, *self._time_range = (
  404. xmlutils.find_tag_and_time_range(self.vobject_item))
  405. return self._time_range
  406. def prepare(self):
  407. """Fill cache with values."""
  408. orig_vobject_item = self._vobject_item
  409. self.serialize()
  410. self.etag
  411. self.uid
  412. self.name
  413. self.time_range
  414. self.component_name
  415. self._vobject_item = orig_vobject_item
  416. class BaseCollection:
  417. # Overriden on copy by the "load" function
  418. configuration = None
  419. # Properties of instance
  420. """The sanitized path of the collection without leading or trailing ``/``.
  421. """
  422. path = ""
  423. @classmethod
  424. def static_init():
  425. """init collection copy"""
  426. pass
  427. @property
  428. def owner(self):
  429. """The owner of the collection."""
  430. return self.path.split("/", maxsplit=1)[0]
  431. @property
  432. def is_principal(self):
  433. """Collection is a principal."""
  434. return bool(self.path) and "/" not in self.path
  435. @classmethod
  436. def discover(cls, path, depth="0"):
  437. """Discover a list of collections under the given ``path``.
  438. ``path`` is sanitized.
  439. If ``depth`` is "0", only the actual object under ``path`` is
  440. returned.
  441. If ``depth`` is anything but "0", it is considered as "1" and direct
  442. children are included in the result.
  443. The root collection "/" must always exist.
  444. """
  445. raise NotImplementedError
  446. @classmethod
  447. def move(cls, item, to_collection, to_href):
  448. """Move an object.
  449. ``item`` is the item to move.
  450. ``to_collection`` is the target collection.
  451. ``to_href`` is the target name in ``to_collection``. An item with the
  452. same name might already exist.
  453. """
  454. if item.collection.path == to_collection.path and item.href == to_href:
  455. return
  456. to_collection.upload(to_href, item)
  457. item.collection.delete(item.href)
  458. @property
  459. def etag(self):
  460. """Encoded as quoted-string (see RFC 2616)."""
  461. etag = md5()
  462. for item in self.get_all():
  463. etag.update((item.href + "/" + item.etag).encode("utf-8"))
  464. etag.update(json.dumps(self.get_meta(), sort_keys=True).encode())
  465. return '"%s"' % etag.hexdigest()
  466. @classmethod
  467. def create_collection(cls, href, items=None, props=None):
  468. """Create a collection.
  469. ``href`` is the sanitized path.
  470. If the collection already exists and neither ``collection`` nor
  471. ``props`` are set, this method shouldn't do anything. Otherwise the
  472. existing collection must be replaced.
  473. ``collection`` is a list of vobject components.
  474. ``props`` are metadata values for the collection.
  475. ``props["tag"]`` is the type of collection (VCALENDAR or
  476. VADDRESSBOOK). If the key ``tag`` is missing, it is guessed from the
  477. collection.
  478. """
  479. raise NotImplementedError
  480. def sync(self, old_token=None):
  481. """Get the current sync token and changed items for synchronization.
  482. ``old_token`` an old sync token which is used as the base of the
  483. delta update. If sync token is missing, all items are returned.
  484. ValueError is raised for invalid or old tokens.
  485. WARNING: This simple default implementation treats all sync-token as
  486. invalid. It adheres to the specification but some clients
  487. (e.g. InfCloud) don't like it. Subclasses should provide a
  488. more sophisticated implementation.
  489. """
  490. token = "http://radicale.org/ns/sync/%s" % self.etag.strip("\"")
  491. if old_token:
  492. raise ValueError("Sync token are not supported")
  493. return token, self.list()
  494. def list(self):
  495. """List collection items."""
  496. raise NotImplementedError
  497. def get(self, href):
  498. """Fetch a single item."""
  499. raise NotImplementedError
  500. def get_multi(self, hrefs):
  501. """Fetch multiple items.
  502. Functionally similar to ``get``, but might bring performance benefits
  503. on some storages when used cleverly. It's not required to return the
  504. requested items in the correct order. Duplicated hrefs can be ignored.
  505. Returns tuples with the href and the item or None if the item doesn't
  506. exist.
  507. """
  508. return ((href, self.get(href)) for href in hrefs)
  509. def get_all(self):
  510. """Fetch all items.
  511. Functionally similar to ``get``, but might bring performance benefits
  512. on some storages when used cleverly.
  513. """
  514. return map(self.get, self.list())
  515. def get_all_filtered(self, filters):
  516. """Fetch all items with optional filtering.
  517. This can largely improve performance of reports depending on
  518. the filters and this implementation.
  519. Returns tuples in the form ``(item, filters_matched)``.
  520. ``filters_matched`` is a bool that indicates if ``filters`` are fully
  521. matched.
  522. This returns all events by default
  523. """
  524. return ((item, False) for item in self.get_all())
  525. def has(self, href):
  526. """Check if an item exists by its href.
  527. Functionally similar to ``get``, but might bring performance benefits
  528. on some storages when used cleverly.
  529. """
  530. return self.get(href) is not None
  531. def has_uid(self, uid):
  532. """Check if a UID exists in the collection."""
  533. for item in self.get_all():
  534. if item.uid == uid:
  535. return True
  536. return False
  537. def upload(self, href, item):
  538. """Upload a new or replace an existing item."""
  539. raise NotImplementedError
  540. def delete(self, href=None):
  541. """Delete an item.
  542. When ``href`` is ``None``, delete the collection.
  543. """
  544. raise NotImplementedError
  545. def get_meta(self, key=None):
  546. """Get metadata value for collection.
  547. Return the value of the property ``key``. If ``key`` is ``None`` return
  548. a dict with all properties
  549. """
  550. raise NotImplementedError
  551. def set_meta(self, props):
  552. """Set metadata values for collection.
  553. ``props`` a dict with values for properties.
  554. """
  555. raise NotImplementedError
  556. @property
  557. def last_modified(self):
  558. """Get the HTTP-datetime of when the collection was modified."""
  559. raise NotImplementedError
  560. def serialize(self):
  561. """Get the unicode string representing the whole collection."""
  562. if self.get_meta("tag") == "VCALENDAR":
  563. in_vcalendar = False
  564. vtimezones = ""
  565. included_tzids = set()
  566. vtimezone = []
  567. tzid = None
  568. components = ""
  569. # Concatenate all child elements of VCALENDAR from all items
  570. # together, while preventing duplicated VTIMEZONE entries.
  571. # VTIMEZONEs are only distinguished by their TZID, if different
  572. # timezones share the same TZID this produces errornous ouput.
  573. # VObject fails at this too.
  574. for item in self.get_all():
  575. depth = 0
  576. for line in item.serialize().split("\r\n"):
  577. if line.startswith("BEGIN:"):
  578. depth += 1
  579. if depth == 1 and line == "BEGIN:VCALENDAR":
  580. in_vcalendar = True
  581. elif in_vcalendar:
  582. if depth == 1 and line.startswith("END:"):
  583. in_vcalendar = False
  584. if depth == 2 and line == "BEGIN:VTIMEZONE":
  585. vtimezone.append(line + "\r\n")
  586. elif vtimezone:
  587. vtimezone.append(line + "\r\n")
  588. if depth == 2 and line.startswith("TZID:"):
  589. tzid = line[len("TZID:"):]
  590. elif depth == 2 and line.startswith("END:"):
  591. if tzid is None or tzid not in included_tzids:
  592. vtimezones += "".join(vtimezone)
  593. included_tzids.add(tzid)
  594. vtimezone.clear()
  595. tzid = None
  596. elif depth >= 2:
  597. components += line + "\r\n"
  598. if line.startswith("END:"):
  599. depth -= 1
  600. template = vobject.iCalendar()
  601. displayname = self.get_meta("D:displayname")
  602. if displayname:
  603. template.add("X-WR-CALNAME")
  604. template.x_wr_calname.value_param = "TEXT"
  605. template.x_wr_calname.value = displayname
  606. description = self.get_meta("C:calendar-description")
  607. if description:
  608. template.add("X-WR-CALDESC")
  609. template.x_wr_caldesc.value_param = "TEXT"
  610. template.x_wr_caldesc.value = description
  611. template = template.serialize()
  612. template_insert_pos = template.find("\r\nEND:VCALENDAR\r\n") + 2
  613. assert template_insert_pos != -1
  614. return (template[:template_insert_pos] +
  615. vtimezones + components +
  616. template[template_insert_pos:])
  617. elif self.get_meta("tag") == "VADDRESSBOOK":
  618. return "".join((item.serialize() for item in self.get_all()))
  619. return ""
  620. @classmethod
  621. @contextmanager
  622. def acquire_lock(cls, mode, user=None):
  623. """Set a context manager to lock the whole storage.
  624. ``mode`` must either be "r" for shared access or "w" for exclusive
  625. access.
  626. ``user`` is the name of the logged in user or empty.
  627. """
  628. raise NotImplementedError
  629. @classmethod
  630. def verify(cls):
  631. """Check the storage for errors."""
  632. return True
  633. class Collection(BaseCollection):
  634. """Collection stored in several files per calendar."""
  635. @classmethod
  636. def static_init(cls):
  637. # init storage lock
  638. folder = os.path.expanduser(cls.configuration.get(
  639. "storage", "filesystem_folder"))
  640. cls._makedirs_synced(folder)
  641. lock_path = os.path.join(folder, ".Radicale.lock")
  642. cls._lock = FileBackedRwLock(lock_path)
  643. def __init__(self, path, filesystem_path=None):
  644. folder = self._get_collection_root_folder()
  645. # Path should already be sanitized
  646. self.path = sanitize_path(path).strip("/")
  647. self._encoding = self.configuration.get("encoding", "stock")
  648. if filesystem_path is None:
  649. filesystem_path = path_to_filesystem(folder, self.path)
  650. self._filesystem_path = filesystem_path
  651. self._props_path = os.path.join(
  652. self._filesystem_path, ".Radicale.props")
  653. self._meta_cache = None
  654. self._etag_cache = None
  655. self._item_cache_cleaned = False
  656. @classmethod
  657. def _get_collection_root_folder(cls):
  658. filesystem_folder = os.path.expanduser(
  659. cls.configuration.get("storage", "filesystem_folder"))
  660. return os.path.join(filesystem_folder, "collection-root")
  661. @contextmanager
  662. def _atomic_write(self, path, mode="w", newline=None, sync_directory=True,
  663. replace_fn=os.replace):
  664. directory = os.path.dirname(path)
  665. tmp = NamedTemporaryFile(
  666. mode=mode, dir=directory, delete=False, prefix=".Radicale.tmp-",
  667. newline=newline, encoding=None if "b" in mode else self._encoding)
  668. try:
  669. yield tmp
  670. tmp.flush()
  671. try:
  672. self._fsync(tmp.fileno())
  673. except OSError as e:
  674. raise RuntimeError("Fsync'ing file %r failed: %s" %
  675. (path, e)) from e
  676. tmp.close()
  677. replace_fn(tmp.name, path)
  678. except BaseException:
  679. tmp.close()
  680. os.remove(tmp.name)
  681. raise
  682. if sync_directory:
  683. self._sync_directory(directory)
  684. @classmethod
  685. def _fsync(cls, fd):
  686. if cls.configuration.getboolean("internal", "filesystem_fsync"):
  687. if os.name == "posix" and hasattr(fcntl, "F_FULLFSYNC"):
  688. fcntl.fcntl(fd, fcntl.F_FULLFSYNC)
  689. else:
  690. os.fsync(fd)
  691. @classmethod
  692. def _sync_directory(cls, path):
  693. """Sync directory to disk.
  694. This only works on POSIX and does nothing on other systems.
  695. """
  696. if not cls.configuration.getboolean("internal", "filesystem_fsync"):
  697. return
  698. if os.name == "posix":
  699. try:
  700. fd = os.open(path, 0)
  701. try:
  702. cls._fsync(fd)
  703. finally:
  704. os.close(fd)
  705. except OSError as e:
  706. raise RuntimeError("Fsync'ing directory %r failed: %s" %
  707. (path, e)) from e
  708. @classmethod
  709. def _makedirs_synced(cls, filesystem_path):
  710. """Recursively create a directory and its parents in a sync'ed way.
  711. This method acts silently when the folder already exists.
  712. """
  713. if os.path.isdir(filesystem_path):
  714. return
  715. parent_filesystem_path = os.path.dirname(filesystem_path)
  716. # Prevent infinite loop
  717. if filesystem_path != parent_filesystem_path:
  718. # Create parent dirs recursively
  719. cls._makedirs_synced(parent_filesystem_path)
  720. # Possible race!
  721. os.makedirs(filesystem_path, exist_ok=True)
  722. cls._sync_directory(parent_filesystem_path)
  723. @classmethod
  724. def discover(cls, path, depth="0", child_context_manager=(
  725. lambda path, href=None: contextlib.ExitStack())):
  726. # Path should already be sanitized
  727. sane_path = sanitize_path(path).strip("/")
  728. attributes = sane_path.split("/") if sane_path else []
  729. folder = cls._get_collection_root_folder()
  730. # Create the root collection
  731. cls._makedirs_synced(folder)
  732. try:
  733. filesystem_path = path_to_filesystem(folder, sane_path)
  734. except ValueError as e:
  735. # Path is unsafe
  736. logger.debug("Unsafe path %r requested from storage: %s",
  737. sane_path, e, exc_info=True)
  738. return
  739. # Check if the path exists and if it leads to a collection or an item
  740. if not os.path.isdir(filesystem_path):
  741. if attributes and os.path.isfile(filesystem_path):
  742. href = attributes.pop()
  743. else:
  744. return
  745. else:
  746. href = None
  747. sane_path = "/".join(attributes)
  748. collection = cls(sane_path)
  749. if href:
  750. yield collection.get(href)
  751. return
  752. yield collection
  753. if depth == "0":
  754. return
  755. for href in collection.list():
  756. with child_context_manager(sane_path, href):
  757. yield collection.get(href)
  758. for entry in os.scandir(filesystem_path):
  759. if not entry.is_dir():
  760. continue
  761. href = entry.name
  762. if not is_safe_filesystem_path_component(href):
  763. if not href.startswith(".Radicale"):
  764. logger.debug("Skipping collection %r in %r",
  765. href, sane_path)
  766. continue
  767. child_path = posixpath.join(sane_path, href)
  768. with child_context_manager(child_path):
  769. yield cls(child_path)
  770. @classmethod
  771. def verify(cls):
  772. item_errors = collection_errors = 0
  773. @contextlib.contextmanager
  774. def exception_cm(path, href=None):
  775. nonlocal item_errors, collection_errors
  776. try:
  777. yield
  778. except Exception as e:
  779. if href:
  780. item_errors += 1
  781. name = "item %r in %r" % (href, path.strip("/"))
  782. else:
  783. collection_errors += 1
  784. name = "collection %r" % path.strip("/")
  785. logger.error("Invalid %s: %s", name, e, exc_info=True)
  786. remaining_paths = [""]
  787. while remaining_paths:
  788. path = remaining_paths.pop(0)
  789. logger.debug("Verifying collection %r", path)
  790. with exception_cm(path):
  791. saved_item_errors = item_errors
  792. collection = None
  793. uids = set()
  794. has_child_collections = False
  795. for item in cls.discover(path, "1", exception_cm):
  796. if not collection:
  797. collection = item
  798. collection.get_meta()
  799. continue
  800. if isinstance(item, BaseCollection):
  801. has_child_collections = True
  802. remaining_paths.append(item.path)
  803. elif item.uid in uids:
  804. cls.logger.error(
  805. "Invalid item %r in %r: UID conflict %r",
  806. item.href, path.strip("/"), item.uid)
  807. else:
  808. uids.add(item.uid)
  809. logger.debug("Verified item %r in %r", item.href, path)
  810. if item_errors == saved_item_errors:
  811. collection.sync()
  812. if has_child_collections and collection.get_meta("tag"):
  813. cls.logger.error("Invalid collection %r: %r must not have "
  814. "child collections", path.strip("/"),
  815. collection.get_meta("tag"))
  816. return item_errors == 0 and collection_errors == 0
  817. @classmethod
  818. def create_collection(cls, href, items=None, props=None):
  819. folder = cls._get_collection_root_folder()
  820. # Path should already be sanitized
  821. sane_path = sanitize_path(href).strip("/")
  822. filesystem_path = path_to_filesystem(folder, sane_path)
  823. if not props:
  824. cls._makedirs_synced(filesystem_path)
  825. return cls(sane_path)
  826. parent_dir = os.path.dirname(filesystem_path)
  827. cls._makedirs_synced(parent_dir)
  828. # Create a temporary directory with an unsafe name
  829. with TemporaryDirectory(
  830. prefix=".Radicale.tmp-", dir=parent_dir) as tmp_dir:
  831. # The temporary directory itself can't be renamed
  832. tmp_filesystem_path = os.path.join(tmp_dir, "collection")
  833. os.makedirs(tmp_filesystem_path)
  834. self = cls(sane_path, filesystem_path=tmp_filesystem_path)
  835. self.set_meta(props)
  836. if items is not None:
  837. if props.get("tag") == "VCALENDAR":
  838. self._upload_all_nonatomic(items, suffix=".ics")
  839. elif props.get("tag") == "VADDRESSBOOK":
  840. self._upload_all_nonatomic(items, suffix=".vcf")
  841. # This operation is not atomic on the filesystem level but it's
  842. # very unlikely that one rename operations succeeds while the
  843. # other fails or that only one gets written to disk.
  844. if os.path.exists(filesystem_path):
  845. os.rename(filesystem_path, os.path.join(tmp_dir, "delete"))
  846. os.rename(tmp_filesystem_path, filesystem_path)
  847. cls._sync_directory(parent_dir)
  848. return cls(sane_path)
  849. def _upload_all_nonatomic(self, items, suffix=""):
  850. """Upload a new set of items.
  851. This takes a list of vobject items and
  852. uploads them nonatomic and without existence checks.
  853. """
  854. cache_folder = os.path.join(self._filesystem_path,
  855. ".Radicale.cache", "item")
  856. self._makedirs_synced(cache_folder)
  857. hrefs = set()
  858. for item in items:
  859. uid = item.uid
  860. try:
  861. cache_content = self._item_cache_content(item)
  862. except Exception as e:
  863. raise ValueError(
  864. "Failed to store item %r in temporary collection %r: %s" %
  865. (uid, self.path, e)) from e
  866. href_candidates = []
  867. if os.name in ("nt", "posix"):
  868. href_candidates.append(
  869. lambda: uid if uid.lower().endswith(suffix.lower())
  870. else uid + suffix)
  871. href_candidates.extend((
  872. lambda: get_etag(uid).strip('"') + suffix,
  873. lambda: find_available_name(hrefs.__contains__, suffix)))
  874. href = None
  875. def replace_fn(source, target):
  876. nonlocal href
  877. while href_candidates:
  878. href = href_candidates.pop(0)()
  879. if href in hrefs:
  880. continue
  881. if not is_safe_filesystem_path_component(href):
  882. if not href_candidates:
  883. raise UnsafePathError(href)
  884. continue
  885. try:
  886. return os.replace(source, path_to_filesystem(
  887. self._filesystem_path, href))
  888. except OSError as e:
  889. if href_candidates and (
  890. os.name == "posix" and e.errno == 22 or
  891. os.name == "nt" and e.errno == 123):
  892. continue
  893. raise
  894. with self._atomic_write(os.path.join(self._filesystem_path, "ign"),
  895. newline="", sync_directory=False,
  896. replace_fn=replace_fn) as f:
  897. f.write(item.serialize())
  898. hrefs.add(href)
  899. with self._atomic_write(os.path.join(cache_folder, href), "wb",
  900. sync_directory=False) as f:
  901. pickle.dump(cache_content, f)
  902. self._sync_directory(cache_folder)
  903. self._sync_directory(self._filesystem_path)
  904. @classmethod
  905. def move(cls, item, to_collection, to_href):
  906. if not is_safe_filesystem_path_component(to_href):
  907. raise UnsafePathError(to_href)
  908. os.replace(
  909. path_to_filesystem(item.collection._filesystem_path, item.href),
  910. path_to_filesystem(to_collection._filesystem_path, to_href))
  911. cls._sync_directory(to_collection._filesystem_path)
  912. if item.collection._filesystem_path != to_collection._filesystem_path:
  913. cls._sync_directory(item.collection._filesystem_path)
  914. # Move the item cache entry
  915. cache_folder = os.path.join(item.collection._filesystem_path,
  916. ".Radicale.cache", "item")
  917. to_cache_folder = os.path.join(to_collection._filesystem_path,
  918. ".Radicale.cache", "item")
  919. cls._makedirs_synced(to_cache_folder)
  920. try:
  921. os.replace(os.path.join(cache_folder, item.href),
  922. os.path.join(to_cache_folder, to_href))
  923. except FileNotFoundError:
  924. pass
  925. else:
  926. cls._makedirs_synced(to_cache_folder)
  927. if cache_folder != to_cache_folder:
  928. cls._makedirs_synced(cache_folder)
  929. # Track the change
  930. to_collection._update_history_etag(to_href, item)
  931. item.collection._update_history_etag(item.href, None)
  932. to_collection._clean_history_cache()
  933. if item.collection._filesystem_path != to_collection._filesystem_path:
  934. item.collection._clean_history_cache()
  935. @classmethod
  936. def _clean_cache(cls, folder, names, max_age=None):
  937. """Delete all ``names`` in ``folder`` that are older than ``max_age``.
  938. """
  939. age_limit = time.time() - max_age if max_age is not None else None
  940. modified = False
  941. for name in names:
  942. if not is_safe_filesystem_path_component(name):
  943. continue
  944. if age_limit is not None:
  945. try:
  946. # Race: Another process might have deleted the file.
  947. mtime = os.path.getmtime(os.path.join(folder, name))
  948. except FileNotFoundError:
  949. continue
  950. if mtime > age_limit:
  951. continue
  952. logger.debug("Found expired item in cache: %r", name)
  953. # Race: Another process might have deleted or locked the
  954. # file.
  955. try:
  956. os.remove(os.path.join(folder, name))
  957. except (FileNotFoundError, PermissionError):
  958. continue
  959. modified = True
  960. if modified:
  961. cls._sync_directory(folder)
  962. def _update_history_etag(self, href, item):
  963. """Updates and retrieves the history etag from the history cache.
  964. The history cache contains a file for each current and deleted item
  965. of the collection. These files contain the etag of the item (empty
  966. string for deleted items) and a history etag, which is a hash over
  967. the previous history etag and the etag separated by "/".
  968. """
  969. history_folder = os.path.join(self._filesystem_path,
  970. ".Radicale.cache", "history")
  971. try:
  972. with open(os.path.join(history_folder, href), "rb") as f:
  973. cache_etag, history_etag = pickle.load(f)
  974. except (FileNotFoundError, pickle.UnpicklingError, ValueError) as e:
  975. if isinstance(e, (pickle.UnpicklingError, ValueError)):
  976. logger.warning(
  977. "Failed to load history cache entry %r in %r: %s",
  978. href, self.path, e, exc_info=True)
  979. cache_etag = ""
  980. # Initialize with random data to prevent collisions with cleaned
  981. # expired items.
  982. history_etag = binascii.hexlify(os.urandom(16)).decode("ascii")
  983. etag = item.etag if item else ""
  984. if etag != cache_etag:
  985. self._makedirs_synced(history_folder)
  986. history_etag = get_etag(history_etag + "/" + etag).strip("\"")
  987. try:
  988. # Race: Other processes might have created and locked the file.
  989. with self._atomic_write(os.path.join(history_folder, href),
  990. "wb") as f:
  991. pickle.dump([etag, history_etag], f)
  992. except PermissionError:
  993. pass
  994. return history_etag
  995. def _get_deleted_history_hrefs(self):
  996. """Returns the hrefs of all deleted items that are still in the
  997. history cache."""
  998. history_folder = os.path.join(self._filesystem_path,
  999. ".Radicale.cache", "history")
  1000. try:
  1001. for entry in os.scandir(history_folder):
  1002. href = entry.name
  1003. if not is_safe_filesystem_path_component(href):
  1004. continue
  1005. if os.path.isfile(os.path.join(self._filesystem_path, href)):
  1006. continue
  1007. yield href
  1008. except FileNotFoundError:
  1009. pass
  1010. def _clean_history_cache(self):
  1011. # Delete all expired cache entries of deleted items.
  1012. history_folder = os.path.join(self._filesystem_path,
  1013. ".Radicale.cache", "history")
  1014. self._clean_cache(history_folder, self._get_deleted_history_hrefs(),
  1015. max_age=self.configuration.getint(
  1016. "storage", "max_sync_token_age"))
  1017. def sync(self, old_token=None):
  1018. # The sync token has the form http://radicale.org/ns/sync/TOKEN_NAME
  1019. # where TOKEN_NAME is the md5 hash of all history etags of present and
  1020. # past items of the collection.
  1021. def check_token_name(token_name):
  1022. if len(token_name) != 32:
  1023. return False
  1024. for c in token_name:
  1025. if c not in "0123456789abcdef":
  1026. return False
  1027. return True
  1028. old_token_name = None
  1029. if old_token:
  1030. # Extract the token name from the sync token
  1031. if not old_token.startswith("http://radicale.org/ns/sync/"):
  1032. raise ValueError("Malformed token: %r" % old_token)
  1033. old_token_name = old_token[len("http://radicale.org/ns/sync/"):]
  1034. if not check_token_name(old_token_name):
  1035. raise ValueError("Malformed token: %r" % old_token)
  1036. # Get the current state and sync-token of the collection.
  1037. state = {}
  1038. token_name_hash = md5()
  1039. # Find the history of all existing and deleted items
  1040. for href, item in chain(
  1041. ((item.href, item) for item in self.get_all()),
  1042. ((href, None) for href in self._get_deleted_history_hrefs())):
  1043. history_etag = self._update_history_etag(href, item)
  1044. state[href] = history_etag
  1045. token_name_hash.update((href + "/" + history_etag).encode("utf-8"))
  1046. token_name = token_name_hash.hexdigest()
  1047. token = "http://radicale.org/ns/sync/%s" % token_name
  1048. if token_name == old_token_name:
  1049. # Nothing changed
  1050. return token, ()
  1051. token_folder = os.path.join(self._filesystem_path,
  1052. ".Radicale.cache", "sync-token")
  1053. token_path = os.path.join(token_folder, token_name)
  1054. old_state = {}
  1055. if old_token_name:
  1056. # load the old token state
  1057. old_token_path = os.path.join(token_folder, old_token_name)
  1058. try:
  1059. # Race: Another process might have deleted the file.
  1060. with open(old_token_path, "rb") as f:
  1061. old_state = pickle.load(f)
  1062. except (FileNotFoundError, pickle.UnpicklingError,
  1063. ValueError) as e:
  1064. if isinstance(e, (pickle.UnpicklingError, ValueError)):
  1065. logger.warning(
  1066. "Failed to load stored sync token %r in %r: %s",
  1067. old_token_name, self.path, e, exc_info=True)
  1068. # Delete the damaged file
  1069. try:
  1070. os.remove(old_token_path)
  1071. except (FileNotFoundError, PermissionError):
  1072. pass
  1073. raise ValueError("Token not found: %r" % old_token)
  1074. # write the new token state or update the modification time of
  1075. # existing token state
  1076. if not os.path.exists(token_path):
  1077. self._makedirs_synced(token_folder)
  1078. try:
  1079. # Race: Other processes might have created and locked the file.
  1080. with self._atomic_write(token_path, "wb") as f:
  1081. pickle.dump(state, f)
  1082. except PermissionError:
  1083. pass
  1084. else:
  1085. # clean up old sync tokens and item cache
  1086. self._clean_cache(token_folder, os.listdir(token_folder),
  1087. max_age=self.configuration.getint(
  1088. "storage", "max_sync_token_age"))
  1089. self._clean_history_cache()
  1090. else:
  1091. # Try to update the modification time
  1092. try:
  1093. # Race: Another process might have deleted the file.
  1094. os.utime(token_path)
  1095. except FileNotFoundError:
  1096. pass
  1097. changes = []
  1098. # Find all new, changed and deleted (that are still in the item cache)
  1099. # items
  1100. for href, history_etag in state.items():
  1101. if history_etag != old_state.get(href):
  1102. changes.append(href)
  1103. # Find all deleted items that are no longer in the item cache
  1104. for href, history_etag in old_state.items():
  1105. if href not in state:
  1106. changes.append(href)
  1107. return token, changes
  1108. def list(self):
  1109. for entry in os.scandir(self._filesystem_path):
  1110. if not entry.is_file():
  1111. continue
  1112. href = entry.name
  1113. if not is_safe_filesystem_path_component(href):
  1114. if not href.startswith(".Radicale"):
  1115. logger.debug("Skipping item %r in %r", href, self.path)
  1116. continue
  1117. yield href
  1118. def _item_cache_hash(self, raw_text):
  1119. _hash = md5()
  1120. _hash.update(ITEM_CACHE_TAG)
  1121. _hash.update(raw_text)
  1122. return _hash.hexdigest()
  1123. def _item_cache_content(self, item, cache_hash=None):
  1124. text = item.serialize()
  1125. if cache_hash is None:
  1126. cache_hash = self._item_cache_hash(text.encode(self._encoding))
  1127. return (cache_hash, item.uid, item.etag, text, item.name,
  1128. item.component_name, *item.time_range)
  1129. def _store_item_cache(self, href, item, cache_hash=None):
  1130. cache_folder = os.path.join(self._filesystem_path, ".Radicale.cache",
  1131. "item")
  1132. content = self._item_cache_content(item, cache_hash)
  1133. self._makedirs_synced(cache_folder)
  1134. try:
  1135. # Race: Other processes might have created and locked the
  1136. # file.
  1137. with self._atomic_write(os.path.join(cache_folder, href),
  1138. "wb") as f:
  1139. pickle.dump(content, f)
  1140. except PermissionError:
  1141. pass
  1142. return content
  1143. def _acquire_cache_lock(self, ns=""):
  1144. if self._lock.locked == "w":
  1145. return contextlib.ExitStack()
  1146. cache_folder = os.path.join(self._filesystem_path, ".Radicale.cache")
  1147. self._makedirs_synced(cache_folder)
  1148. lock_path = os.path.join(cache_folder,
  1149. ".Radicale.lock" + (".%s" % ns if ns else ""))
  1150. lock = FileBackedRwLock(lock_path)
  1151. return lock.acquire("w")
  1152. def _load_item_cache(self, href, input_hash):
  1153. cache_folder = os.path.join(self._filesystem_path, ".Radicale.cache",
  1154. "item")
  1155. cache_hash = uid = etag = text = name = tag = start = end = None
  1156. try:
  1157. with open(os.path.join(cache_folder, href), "rb") as f:
  1158. cache_hash, *content = pickle.load(f)
  1159. if cache_hash == input_hash:
  1160. uid, etag, text, name, tag, start, end = content
  1161. except FileNotFoundError as e:
  1162. pass
  1163. except (pickle.UnpicklingError, ValueError) as e:
  1164. logger.warning("Failed to load item cache entry %r in %r: %s",
  1165. href, self.path, e, exc_info=True)
  1166. return cache_hash, uid, etag, text, name, tag, start, end
  1167. def _clean_item_cache(self):
  1168. cache_folder = os.path.join(self._filesystem_path, ".Radicale.cache",
  1169. "item")
  1170. self._clean_cache(cache_folder, (
  1171. e.name for e in os.scandir(cache_folder) if not
  1172. os.path.isfile(os.path.join(self._filesystem_path, e.name))))
  1173. def get(self, href, verify_href=True):
  1174. if verify_href:
  1175. try:
  1176. if not is_safe_filesystem_path_component(href):
  1177. raise UnsafePathError(href)
  1178. path = path_to_filesystem(self._filesystem_path, href)
  1179. except ValueError as e:
  1180. logger.debug(
  1181. "Can't translate name %r safely to filesystem in %r: %s",
  1182. href, self.path, e, exc_info=True)
  1183. return None
  1184. else:
  1185. path = os.path.join(self._filesystem_path, href)
  1186. try:
  1187. with open(path, "rb") as f:
  1188. raw_text = f.read()
  1189. except (FileNotFoundError, IsADirectoryError):
  1190. return None
  1191. except PermissionError:
  1192. # Windows raises ``PermissionError`` when ``path`` is a directory
  1193. if (os.name == "nt" and
  1194. os.path.isdir(path) and os.access(path, os.R_OK)):
  1195. return None
  1196. raise
  1197. # The hash of the component in the file system. This is used to check,
  1198. # if the entry in the cache is still valid.
  1199. input_hash = self._item_cache_hash(raw_text)
  1200. cache_hash, uid, etag, text, name, tag, start, end = \
  1201. self._load_item_cache(href, input_hash)
  1202. if input_hash != cache_hash:
  1203. with self._acquire_cache_lock("item"):
  1204. # Lock the item cache to prevent multpile processes from
  1205. # generating the same data in parallel.
  1206. # This improves the performance for multiple requests.
  1207. if self._lock.locked == "r":
  1208. # Check if another process created the file in the meantime
  1209. cache_hash, uid, etag, text, name, tag, start, end = \
  1210. self._load_item_cache(href, input_hash)
  1211. if input_hash != cache_hash:
  1212. try:
  1213. vobject_items = tuple(vobject.readComponents(
  1214. raw_text.decode(self._encoding)))
  1215. check_and_sanitize_items(vobject_items,
  1216. tag=self.get_meta("tag"))
  1217. vobject_item, = vobject_items
  1218. temp_item = Item(collection=self,
  1219. vobject_item=vobject_item)
  1220. cache_hash, uid, etag, text, name, tag, start, end = \
  1221. self._store_item_cache(
  1222. href, temp_item, input_hash)
  1223. except Exception as e:
  1224. raise RuntimeError("Failed to load item %r in %r: %s" %
  1225. (href, self.path, e)) from e
  1226. # Clean cache entries once after the data in the file
  1227. # system was edited externally.
  1228. if not self._item_cache_cleaned:
  1229. self._item_cache_cleaned = True
  1230. self._clean_item_cache()
  1231. last_modified = time.strftime(
  1232. "%a, %d %b %Y %H:%M:%S GMT",
  1233. time.gmtime(os.path.getmtime(path)))
  1234. # Don't keep reference to ``vobject_item``, because it requires a lot
  1235. # of memory.
  1236. return Item(
  1237. collection=self, href=href, last_modified=last_modified, etag=etag,
  1238. text=text, uid=uid, name=name, component_name=tag,
  1239. time_range=(start, end))
  1240. def get_multi(self, hrefs):
  1241. # It's faster to check for file name collissions here, because
  1242. # we only need to call os.listdir once.
  1243. files = None
  1244. for href in hrefs:
  1245. if files is None:
  1246. # List dir after hrefs returned one item, the iterator may be
  1247. # empty and the for-loop is never executed.
  1248. files = os.listdir(self._filesystem_path)
  1249. path = os.path.join(self._filesystem_path, href)
  1250. if (not is_safe_filesystem_path_component(href) or
  1251. href not in files and os.path.lexists(path)):
  1252. logger.debug(
  1253. "Can't translate name safely to filesystem: %r", href)
  1254. yield (href, None)
  1255. else:
  1256. yield (href, self.get(href, verify_href=False))
  1257. def get_all(self):
  1258. # We don't need to check for collissions, because the the file names
  1259. # are from os.listdir.
  1260. return (self.get(href, verify_href=False) for href in self.list())
  1261. def get_all_filtered(self, filters):
  1262. tag, start, end, simple = xmlutils.simplify_prefilters(
  1263. filters, collection_tag=self.get_meta("tag"))
  1264. if not tag:
  1265. # no filter
  1266. yield from ((item, simple) for item in self.get_all())
  1267. return
  1268. for item in (self.get(h, verify_href=False) for h in self.list()):
  1269. istart, iend = item.time_range
  1270. if tag == item.component_name and istart < end and iend > start:
  1271. yield item, simple and (start <= istart or iend <= end)
  1272. def upload(self, href, item):
  1273. if not is_safe_filesystem_path_component(href):
  1274. raise UnsafePathError(href)
  1275. try:
  1276. self._store_item_cache(href, item)
  1277. except Exception as e:
  1278. raise ValueError("Failed to store item %r in collection %r: %s" %
  1279. (href, self.path, e)) from e
  1280. path = path_to_filesystem(self._filesystem_path, href)
  1281. with self._atomic_write(path, newline="") as fd:
  1282. fd.write(item.serialize())
  1283. # Clean the cache after the actual item is stored, or the cache entry
  1284. # will be removed again.
  1285. self._clean_item_cache()
  1286. # Track the change
  1287. self._update_history_etag(href, item)
  1288. self._clean_history_cache()
  1289. return self.get(href, verify_href=False)
  1290. def delete(self, href=None):
  1291. if href is None:
  1292. # Delete the collection
  1293. parent_dir = os.path.dirname(self._filesystem_path)
  1294. try:
  1295. os.rmdir(self._filesystem_path)
  1296. except OSError:
  1297. with TemporaryDirectory(
  1298. prefix=".Radicale.tmp-", dir=parent_dir) as tmp:
  1299. os.rename(self._filesystem_path, os.path.join(
  1300. tmp, os.path.basename(self._filesystem_path)))
  1301. self._sync_directory(parent_dir)
  1302. else:
  1303. self._sync_directory(parent_dir)
  1304. else:
  1305. # Delete an item
  1306. if not is_safe_filesystem_path_component(href):
  1307. raise UnsafePathError(href)
  1308. path = path_to_filesystem(self._filesystem_path, href)
  1309. if not os.path.isfile(path):
  1310. raise ComponentNotFoundError(href)
  1311. os.remove(path)
  1312. self._sync_directory(os.path.dirname(path))
  1313. # Track the change
  1314. self._update_history_etag(href, None)
  1315. self._clean_history_cache()
  1316. def get_meta(self, key=None):
  1317. # reuse cached value if the storage is read-only
  1318. if self._lock.locked == "w" or self._meta_cache is None:
  1319. try:
  1320. try:
  1321. with open(self._props_path, encoding=self._encoding) as f:
  1322. self._meta_cache = json.load(f)
  1323. except FileNotFoundError:
  1324. self._meta_cache = {}
  1325. check_and_sanitize_props(self._meta_cache)
  1326. except ValueError as e:
  1327. raise RuntimeError("Failed to load properties of collection "
  1328. "%r: %s" % (self.path, e)) from e
  1329. return self._meta_cache.get(key) if key else self._meta_cache
  1330. def set_meta(self, props):
  1331. with self._atomic_write(self._props_path, "w") as f:
  1332. json.dump(props, f, sort_keys=True)
  1333. @property
  1334. def last_modified(self):
  1335. relevant_files = chain(
  1336. (self._filesystem_path,),
  1337. (self._props_path,) if os.path.exists(self._props_path) else (),
  1338. (os.path.join(self._filesystem_path, h) for h in self.list()))
  1339. last = max(map(os.path.getmtime, relevant_files))
  1340. return time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(last))
  1341. @property
  1342. def etag(self):
  1343. # reuse cached value if the storage is read-only
  1344. if self._lock.locked == "w" or self._etag_cache is None:
  1345. self._etag_cache = super().etag
  1346. return self._etag_cache
  1347. @classmethod
  1348. @contextmanager
  1349. def acquire_lock(cls, mode, user=None):
  1350. with cls._lock.acquire(mode):
  1351. yield
  1352. # execute hook
  1353. hook = cls.configuration.get("storage", "hook")
  1354. if mode == "w" and hook:
  1355. folder = os.path.expanduser(cls.configuration.get(
  1356. "storage", "filesystem_folder"))
  1357. logger.debug("Running hook")
  1358. debug = logger.isEnabledFor(logging.DEBUG)
  1359. p = subprocess.Popen(
  1360. hook % {"user": shlex.quote(user or "Anonymous")},
  1361. stdin=subprocess.DEVNULL,
  1362. stdout=subprocess.PIPE if debug else subprocess.DEVNULL,
  1363. stderr=subprocess.PIPE if debug else subprocess.DEVNULL,
  1364. shell=True, universal_newlines=True, cwd=folder)
  1365. stdout_data, stderr_data = p.communicate()
  1366. if stdout_data:
  1367. logger.debug("Captured stdout hook:\n%s", stdout_data)
  1368. if stderr_data:
  1369. logger.debug("Captured stderr hook:\n%s", stderr_data)
  1370. if p.returncode != 0:
  1371. raise subprocess.CalledProcessError(p.returncode, p.args)
  1372. class FileBackedRwLock:
  1373. """A readers-Writer lock that locks a file."""
  1374. def __init__(self, path):
  1375. self._path = path
  1376. self._readers = 0
  1377. self._writer = False
  1378. self._lock = threading.Lock()
  1379. @property
  1380. def locked(self):
  1381. with self._lock:
  1382. if self._readers > 0:
  1383. return "r"
  1384. if self._writer:
  1385. return "w"
  1386. return ""
  1387. @contextmanager
  1388. def acquire(self, mode):
  1389. if mode not in "rw":
  1390. raise ValueError("Invalid mode: %r" % mode)
  1391. with open(self._path, "w+") as lock_file:
  1392. if os.name == "nt":
  1393. handle = msvcrt.get_osfhandle(lock_file.fileno())
  1394. flags = LOCKFILE_EXCLUSIVE_LOCK if mode == "w" else 0
  1395. overlapped = Overlapped()
  1396. if not lock_file_ex(handle, flags, 0, 1, 0, overlapped):
  1397. raise RuntimeError("Locking the storage failed: %s" %
  1398. ctypes.FormatError())
  1399. elif os.name == "posix":
  1400. _cmd = fcntl.LOCK_EX if mode == "w" else fcntl.LOCK_SH
  1401. try:
  1402. fcntl.flock(lock_file.fileno(), _cmd)
  1403. except OSError as e:
  1404. raise RuntimeError("Locking the storage failed: %s" %
  1405. e) from e
  1406. else:
  1407. raise RuntimeError("Locking the storage failed: "
  1408. "Unsupported operating system")
  1409. with self._lock:
  1410. if self._writer or mode == "w" and self._readers != 0:
  1411. raise RuntimeError("Locking the storage failed: "
  1412. "Guarantees failed")
  1413. if mode == "r":
  1414. self._readers += 1
  1415. else:
  1416. self._writer = True
  1417. try:
  1418. yield
  1419. finally:
  1420. with self._lock:
  1421. if mode == "r":
  1422. self._readers -= 1
  1423. self._writer = False