multifilesystem.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843
  1. # This file is part of Radicale Server - Calendar Server
  2. # Copyright © 2014 Jean-Marc Martins
  3. # Copyright © 2012-2017 Guillaume Ayoub
  4. # Copyright © 2017-2018 Unrud <unrud@outlook.com>
  5. #
  6. # This library is free software: you can redistribute it and/or modify
  7. # it under the terms of the GNU General Public License as published by
  8. # the Free Software Foundation, either version 3 of the License, or
  9. # (at your option) any later version.
  10. #
  11. # This library is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU General Public License
  17. # along with Radicale. If not, see <http://www.gnu.org/licenses/>.
  18. import binascii
  19. import contextlib
  20. import json
  21. import logging
  22. import os
  23. import pickle
  24. import posixpath
  25. import shlex
  26. import subprocess
  27. import time
  28. from contextlib import contextmanager
  29. from hashlib import md5
  30. from itertools import chain
  31. from tempfile import NamedTemporaryFile, TemporaryDirectory
  32. import vobject
  33. from radicale import item as radicale_item
  34. from radicale import pathutils, storage
  35. from radicale.item import filter as radicale_filter
  36. from radicale.log import logger
  37. class Collection(storage.BaseCollection):
  38. """Collection stored in several files per calendar."""
  39. @classmethod
  40. def static_init(cls):
  41. # init storage lock
  42. folder = os.path.expanduser(cls.configuration.get(
  43. "storage", "filesystem_folder"))
  44. cls._makedirs_synced(folder)
  45. lock_path = os.path.join(folder, ".Radicale.lock")
  46. cls._lock = pathutils.RwLock(lock_path)
  47. def __init__(self, path, filesystem_path=None):
  48. folder = self._get_collection_root_folder()
  49. # Path should already be sanitized
  50. self.path = pathutils.sanitize_path(path).strip("/")
  51. self._encoding = self.configuration.get("encoding", "stock")
  52. if filesystem_path is None:
  53. filesystem_path = pathutils.path_to_filesystem(folder, self.path)
  54. self._filesystem_path = filesystem_path
  55. self._props_path = os.path.join(
  56. self._filesystem_path, ".Radicale.props")
  57. self._meta_cache = None
  58. self._etag_cache = None
  59. self._item_cache_cleaned = False
  60. @classmethod
  61. def _get_collection_root_folder(cls):
  62. filesystem_folder = os.path.expanduser(
  63. cls.configuration.get("storage", "filesystem_folder"))
  64. return os.path.join(filesystem_folder, "collection-root")
  65. @contextmanager
  66. def _atomic_write(self, path, mode="w", newline=None, sync_directory=True,
  67. replace_fn=os.replace):
  68. directory = os.path.dirname(path)
  69. tmp = NamedTemporaryFile(
  70. mode=mode, dir=directory, delete=False, prefix=".Radicale.tmp-",
  71. newline=newline, encoding=None if "b" in mode else self._encoding)
  72. try:
  73. yield tmp
  74. tmp.flush()
  75. try:
  76. self._fsync(tmp.fileno())
  77. except OSError as e:
  78. raise RuntimeError("Fsync'ing file %r failed: %s" %
  79. (path, e)) from e
  80. tmp.close()
  81. replace_fn(tmp.name, path)
  82. except BaseException:
  83. tmp.close()
  84. os.remove(tmp.name)
  85. raise
  86. if sync_directory:
  87. self._sync_directory(directory)
  88. @classmethod
  89. def _fsync(cls, fd):
  90. if cls.configuration.getboolean("internal", "filesystem_fsync"):
  91. pathutils.fsync(fd)
  92. @classmethod
  93. def _sync_directory(cls, path):
  94. """Sync directory to disk.
  95. This only works on POSIX and does nothing on other systems.
  96. """
  97. if not cls.configuration.getboolean("internal", "filesystem_fsync"):
  98. return
  99. if os.name == "posix":
  100. try:
  101. fd = os.open(path, 0)
  102. try:
  103. cls._fsync(fd)
  104. finally:
  105. os.close(fd)
  106. except OSError as e:
  107. raise RuntimeError("Fsync'ing directory %r failed: %s" %
  108. (path, e)) from e
  109. @classmethod
  110. def _makedirs_synced(cls, filesystem_path):
  111. """Recursively create a directory and its parents in a sync'ed way.
  112. This method acts silently when the folder already exists.
  113. """
  114. if os.path.isdir(filesystem_path):
  115. return
  116. parent_filesystem_path = os.path.dirname(filesystem_path)
  117. # Prevent infinite loop
  118. if filesystem_path != parent_filesystem_path:
  119. # Create parent dirs recursively
  120. cls._makedirs_synced(parent_filesystem_path)
  121. # Possible race!
  122. os.makedirs(filesystem_path, exist_ok=True)
  123. cls._sync_directory(parent_filesystem_path)
  124. @classmethod
  125. def discover(cls, path, depth="0", child_context_manager=(
  126. lambda path, href=None: contextlib.ExitStack())):
  127. # Path should already be sanitized
  128. sane_path = pathutils.sanitize_path(path).strip("/")
  129. attributes = sane_path.split("/") if sane_path else []
  130. folder = cls._get_collection_root_folder()
  131. # Create the root collection
  132. cls._makedirs_synced(folder)
  133. try:
  134. filesystem_path = pathutils.path_to_filesystem(folder, sane_path)
  135. except ValueError as e:
  136. # Path is unsafe
  137. logger.debug("Unsafe path %r requested from storage: %s",
  138. sane_path, e, exc_info=True)
  139. return
  140. # Check if the path exists and if it leads to a collection or an item
  141. if not os.path.isdir(filesystem_path):
  142. if attributes and os.path.isfile(filesystem_path):
  143. href = attributes.pop()
  144. else:
  145. return
  146. else:
  147. href = None
  148. sane_path = "/".join(attributes)
  149. collection = cls(sane_path)
  150. if href:
  151. yield collection.get(href)
  152. return
  153. yield collection
  154. if depth == "0":
  155. return
  156. for href in collection.list():
  157. with child_context_manager(sane_path, href):
  158. yield collection.get(href)
  159. for entry in os.scandir(filesystem_path):
  160. if not entry.is_dir():
  161. continue
  162. href = entry.name
  163. if not pathutils.is_safe_filesystem_path_component(href):
  164. if not href.startswith(".Radicale"):
  165. logger.debug("Skipping collection %r in %r",
  166. href, sane_path)
  167. continue
  168. child_path = posixpath.join(sane_path, href)
  169. with child_context_manager(child_path):
  170. yield cls(child_path)
  171. @classmethod
  172. def verify(cls):
  173. item_errors = collection_errors = 0
  174. @contextlib.contextmanager
  175. def exception_cm(path, href=None):
  176. nonlocal item_errors, collection_errors
  177. try:
  178. yield
  179. except Exception as e:
  180. if href:
  181. item_errors += 1
  182. name = "item %r in %r" % (href, path.strip("/"))
  183. else:
  184. collection_errors += 1
  185. name = "collection %r" % path.strip("/")
  186. logger.error("Invalid %s: %s", name, e, exc_info=True)
  187. remaining_paths = [""]
  188. while remaining_paths:
  189. path = remaining_paths.pop(0)
  190. logger.debug("Verifying collection %r", path)
  191. with exception_cm(path):
  192. saved_item_errors = item_errors
  193. collection = None
  194. uids = set()
  195. has_child_collections = False
  196. for item in cls.discover(path, "1", exception_cm):
  197. if not collection:
  198. collection = item
  199. collection.get_meta()
  200. continue
  201. if isinstance(item, storage.BaseCollection):
  202. has_child_collections = True
  203. remaining_paths.append(item.path)
  204. elif item.uid in uids:
  205. cls.logger.error(
  206. "Invalid item %r in %r: UID conflict %r",
  207. item.href, path.strip("/"), item.uid)
  208. else:
  209. uids.add(item.uid)
  210. logger.debug("Verified item %r in %r", item.href, path)
  211. if item_errors == saved_item_errors:
  212. collection.sync()
  213. if has_child_collections and collection.get_meta("tag"):
  214. cls.logger.error("Invalid collection %r: %r must not have "
  215. "child collections", path.strip("/"),
  216. collection.get_meta("tag"))
  217. return item_errors == 0 and collection_errors == 0
  218. @classmethod
  219. def create_collection(cls, href, items=None, props=None):
  220. folder = cls._get_collection_root_folder()
  221. # Path should already be sanitized
  222. sane_path = pathutils.sanitize_path(href).strip("/")
  223. filesystem_path = pathutils.path_to_filesystem(folder, sane_path)
  224. if not props:
  225. cls._makedirs_synced(filesystem_path)
  226. return cls(sane_path)
  227. parent_dir = os.path.dirname(filesystem_path)
  228. cls._makedirs_synced(parent_dir)
  229. # Create a temporary directory with an unsafe name
  230. with TemporaryDirectory(
  231. prefix=".Radicale.tmp-", dir=parent_dir) as tmp_dir:
  232. # The temporary directory itself can't be renamed
  233. tmp_filesystem_path = os.path.join(tmp_dir, "collection")
  234. os.makedirs(tmp_filesystem_path)
  235. self = cls(sane_path, filesystem_path=tmp_filesystem_path)
  236. self.set_meta(props)
  237. if items is not None:
  238. if props.get("tag") == "VCALENDAR":
  239. self._upload_all_nonatomic(items, suffix=".ics")
  240. elif props.get("tag") == "VADDRESSBOOK":
  241. self._upload_all_nonatomic(items, suffix=".vcf")
  242. # This operation is not atomic on the filesystem level but it's
  243. # very unlikely that one rename operations succeeds while the
  244. # other fails or that only one gets written to disk.
  245. if os.path.exists(filesystem_path):
  246. os.rename(filesystem_path, os.path.join(tmp_dir, "delete"))
  247. os.rename(tmp_filesystem_path, filesystem_path)
  248. cls._sync_directory(parent_dir)
  249. return cls(sane_path)
  250. def _upload_all_nonatomic(self, items, suffix=""):
  251. """Upload a new set of items.
  252. This takes a list of vobject items and
  253. uploads them nonatomic and without existence checks.
  254. """
  255. cache_folder = os.path.join(self._filesystem_path,
  256. ".Radicale.cache", "item")
  257. self._makedirs_synced(cache_folder)
  258. hrefs = set()
  259. for item in items:
  260. uid = item.uid
  261. try:
  262. cache_content = self._item_cache_content(item)
  263. except Exception as e:
  264. raise ValueError(
  265. "Failed to store item %r in temporary collection %r: %s" %
  266. (uid, self.path, e)) from e
  267. href_candidates = []
  268. if os.name in ("nt", "posix"):
  269. href_candidates.append(
  270. lambda: uid if uid.lower().endswith(suffix.lower())
  271. else uid + suffix)
  272. href_candidates.extend((
  273. lambda: radicale_item.get_etag(uid).strip('"') + suffix,
  274. lambda: radicale_item.find_available_uid(hrefs.__contains__,
  275. suffix)))
  276. href = None
  277. def replace_fn(source, target):
  278. nonlocal href
  279. while href_candidates:
  280. href = href_candidates.pop(0)()
  281. if href in hrefs:
  282. continue
  283. if not pathutils.is_safe_filesystem_path_component(href):
  284. if not href_candidates:
  285. raise pathutils.UnsafePathError(href)
  286. continue
  287. try:
  288. return os.replace(source, pathutils.path_to_filesystem(
  289. self._filesystem_path, href))
  290. except OSError as e:
  291. if href_candidates and (
  292. os.name == "posix" and e.errno == 22 or
  293. os.name == "nt" and e.errno == 123):
  294. continue
  295. raise
  296. with self._atomic_write(os.path.join(self._filesystem_path, "ign"),
  297. newline="", sync_directory=False,
  298. replace_fn=replace_fn) as f:
  299. f.write(item.serialize())
  300. hrefs.add(href)
  301. with self._atomic_write(os.path.join(cache_folder, href), "wb",
  302. sync_directory=False) as f:
  303. pickle.dump(cache_content, f)
  304. self._sync_directory(cache_folder)
  305. self._sync_directory(self._filesystem_path)
  306. @classmethod
  307. def move(cls, item, to_collection, to_href):
  308. if not pathutils.is_safe_filesystem_path_component(to_href):
  309. raise pathutils.UnsafePathError(to_href)
  310. os.replace(
  311. pathutils.path_to_filesystem(
  312. item.collection._filesystem_path, item.href),
  313. pathutils.path_to_filesystem(
  314. to_collection._filesystem_path, to_href))
  315. cls._sync_directory(to_collection._filesystem_path)
  316. if item.collection._filesystem_path != to_collection._filesystem_path:
  317. cls._sync_directory(item.collection._filesystem_path)
  318. # Move the item cache entry
  319. cache_folder = os.path.join(item.collection._filesystem_path,
  320. ".Radicale.cache", "item")
  321. to_cache_folder = os.path.join(to_collection._filesystem_path,
  322. ".Radicale.cache", "item")
  323. cls._makedirs_synced(to_cache_folder)
  324. try:
  325. os.replace(os.path.join(cache_folder, item.href),
  326. os.path.join(to_cache_folder, to_href))
  327. except FileNotFoundError:
  328. pass
  329. else:
  330. cls._makedirs_synced(to_cache_folder)
  331. if cache_folder != to_cache_folder:
  332. cls._makedirs_synced(cache_folder)
  333. # Track the change
  334. to_collection._update_history_etag(to_href, item)
  335. item.collection._update_history_etag(item.href, None)
  336. to_collection._clean_history_cache()
  337. if item.collection._filesystem_path != to_collection._filesystem_path:
  338. item.collection._clean_history_cache()
  339. @classmethod
  340. def _clean_cache(cls, folder, names, max_age=None):
  341. """Delete all ``names`` in ``folder`` that are older than ``max_age``.
  342. """
  343. age_limit = time.time() - max_age if max_age is not None else None
  344. modified = False
  345. for name in names:
  346. if not pathutils.is_safe_filesystem_path_component(name):
  347. continue
  348. if age_limit is not None:
  349. try:
  350. # Race: Another process might have deleted the file.
  351. mtime = os.path.getmtime(os.path.join(folder, name))
  352. except FileNotFoundError:
  353. continue
  354. if mtime > age_limit:
  355. continue
  356. logger.debug("Found expired item in cache: %r", name)
  357. # Race: Another process might have deleted or locked the
  358. # file.
  359. try:
  360. os.remove(os.path.join(folder, name))
  361. except (FileNotFoundError, PermissionError):
  362. continue
  363. modified = True
  364. if modified:
  365. cls._sync_directory(folder)
  366. def _update_history_etag(self, href, item):
  367. """Updates and retrieves the history etag from the history cache.
  368. The history cache contains a file for each current and deleted item
  369. of the collection. These files contain the etag of the item (empty
  370. string for deleted items) and a history etag, which is a hash over
  371. the previous history etag and the etag separated by "/".
  372. """
  373. history_folder = os.path.join(self._filesystem_path,
  374. ".Radicale.cache", "history")
  375. try:
  376. with open(os.path.join(history_folder, href), "rb") as f:
  377. cache_etag, history_etag = pickle.load(f)
  378. except (FileNotFoundError, pickle.UnpicklingError, ValueError) as e:
  379. if isinstance(e, (pickle.UnpicklingError, ValueError)):
  380. logger.warning(
  381. "Failed to load history cache entry %r in %r: %s",
  382. href, self.path, e, exc_info=True)
  383. cache_etag = ""
  384. # Initialize with random data to prevent collisions with cleaned
  385. # expired items.
  386. history_etag = binascii.hexlify(os.urandom(16)).decode("ascii")
  387. etag = item.etag if item else ""
  388. if etag != cache_etag:
  389. self._makedirs_synced(history_folder)
  390. history_etag = radicale_item.get_etag(
  391. history_etag + "/" + etag).strip("\"")
  392. try:
  393. # Race: Other processes might have created and locked the file.
  394. with self._atomic_write(os.path.join(history_folder, href),
  395. "wb") as f:
  396. pickle.dump([etag, history_etag], f)
  397. except PermissionError:
  398. pass
  399. return history_etag
  400. def _get_deleted_history_hrefs(self):
  401. """Returns the hrefs of all deleted items that are still in the
  402. history cache."""
  403. history_folder = os.path.join(self._filesystem_path,
  404. ".Radicale.cache", "history")
  405. try:
  406. for entry in os.scandir(history_folder):
  407. href = entry.name
  408. if not pathutils.is_safe_filesystem_path_component(href):
  409. continue
  410. if os.path.isfile(os.path.join(self._filesystem_path, href)):
  411. continue
  412. yield href
  413. except FileNotFoundError:
  414. pass
  415. def _clean_history_cache(self):
  416. # Delete all expired cache entries of deleted items.
  417. history_folder = os.path.join(self._filesystem_path,
  418. ".Radicale.cache", "history")
  419. self._clean_cache(history_folder, self._get_deleted_history_hrefs(),
  420. max_age=self.configuration.getint(
  421. "storage", "max_sync_token_age"))
  422. def sync(self, old_token=None):
  423. # The sync token has the form http://radicale.org/ns/sync/TOKEN_NAME
  424. # where TOKEN_NAME is the md5 hash of all history etags of present and
  425. # past items of the collection.
  426. def check_token_name(token_name):
  427. if len(token_name) != 32:
  428. return False
  429. for c in token_name:
  430. if c not in "0123456789abcdef":
  431. return False
  432. return True
  433. old_token_name = None
  434. if old_token:
  435. # Extract the token name from the sync token
  436. if not old_token.startswith("http://radicale.org/ns/sync/"):
  437. raise ValueError("Malformed token: %r" % old_token)
  438. old_token_name = old_token[len("http://radicale.org/ns/sync/"):]
  439. if not check_token_name(old_token_name):
  440. raise ValueError("Malformed token: %r" % old_token)
  441. # Get the current state and sync-token of the collection.
  442. state = {}
  443. token_name_hash = md5()
  444. # Find the history of all existing and deleted items
  445. for href, item in chain(
  446. ((item.href, item) for item in self.get_all()),
  447. ((href, None) for href in self._get_deleted_history_hrefs())):
  448. history_etag = self._update_history_etag(href, item)
  449. state[href] = history_etag
  450. token_name_hash.update((href + "/" + history_etag).encode("utf-8"))
  451. token_name = token_name_hash.hexdigest()
  452. token = "http://radicale.org/ns/sync/%s" % token_name
  453. if token_name == old_token_name:
  454. # Nothing changed
  455. return token, ()
  456. token_folder = os.path.join(self._filesystem_path,
  457. ".Radicale.cache", "sync-token")
  458. token_path = os.path.join(token_folder, token_name)
  459. old_state = {}
  460. if old_token_name:
  461. # load the old token state
  462. old_token_path = os.path.join(token_folder, old_token_name)
  463. try:
  464. # Race: Another process might have deleted the file.
  465. with open(old_token_path, "rb") as f:
  466. old_state = pickle.load(f)
  467. except (FileNotFoundError, pickle.UnpicklingError,
  468. ValueError) as e:
  469. if isinstance(e, (pickle.UnpicklingError, ValueError)):
  470. logger.warning(
  471. "Failed to load stored sync token %r in %r: %s",
  472. old_token_name, self.path, e, exc_info=True)
  473. # Delete the damaged file
  474. try:
  475. os.remove(old_token_path)
  476. except (FileNotFoundError, PermissionError):
  477. pass
  478. raise ValueError("Token not found: %r" % old_token)
  479. # write the new token state or update the modification time of
  480. # existing token state
  481. if not os.path.exists(token_path):
  482. self._makedirs_synced(token_folder)
  483. try:
  484. # Race: Other processes might have created and locked the file.
  485. with self._atomic_write(token_path, "wb") as f:
  486. pickle.dump(state, f)
  487. except PermissionError:
  488. pass
  489. else:
  490. # clean up old sync tokens and item cache
  491. self._clean_cache(token_folder, os.listdir(token_folder),
  492. max_age=self.configuration.getint(
  493. "storage", "max_sync_token_age"))
  494. self._clean_history_cache()
  495. else:
  496. # Try to update the modification time
  497. try:
  498. # Race: Another process might have deleted the file.
  499. os.utime(token_path)
  500. except FileNotFoundError:
  501. pass
  502. changes = []
  503. # Find all new, changed and deleted (that are still in the item cache)
  504. # items
  505. for href, history_etag in state.items():
  506. if history_etag != old_state.get(href):
  507. changes.append(href)
  508. # Find all deleted items that are no longer in the item cache
  509. for href, history_etag in old_state.items():
  510. if href not in state:
  511. changes.append(href)
  512. return token, changes
  513. def list(self):
  514. for entry in os.scandir(self._filesystem_path):
  515. if not entry.is_file():
  516. continue
  517. href = entry.name
  518. if not pathutils.is_safe_filesystem_path_component(href):
  519. if not href.startswith(".Radicale"):
  520. logger.debug("Skipping item %r in %r", href, self.path)
  521. continue
  522. yield href
  523. def _item_cache_hash(self, raw_text):
  524. _hash = md5()
  525. _hash.update(storage.CACHE_VERSION)
  526. _hash.update(raw_text)
  527. return _hash.hexdigest()
  528. def _item_cache_content(self, item, cache_hash=None):
  529. text = item.serialize()
  530. if cache_hash is None:
  531. cache_hash = self._item_cache_hash(text.encode(self._encoding))
  532. return (cache_hash, item.uid, item.etag, text, item.name,
  533. item.component_name, *item.time_range)
  534. def _store_item_cache(self, href, item, cache_hash=None):
  535. cache_folder = os.path.join(self._filesystem_path, ".Radicale.cache",
  536. "item")
  537. content = self._item_cache_content(item, cache_hash)
  538. self._makedirs_synced(cache_folder)
  539. try:
  540. # Race: Other processes might have created and locked the
  541. # file.
  542. with self._atomic_write(os.path.join(cache_folder, href),
  543. "wb") as f:
  544. pickle.dump(content, f)
  545. except PermissionError:
  546. pass
  547. return content
  548. def _acquire_cache_lock(self, ns=""):
  549. if self._lock.locked == "w":
  550. return contextlib.ExitStack()
  551. cache_folder = os.path.join(self._filesystem_path, ".Radicale.cache")
  552. self._makedirs_synced(cache_folder)
  553. lock_path = os.path.join(cache_folder,
  554. ".Radicale.lock" + (".%s" % ns if ns else ""))
  555. lock = pathutils.RwLock(lock_path)
  556. return lock.acquire("w")
  557. def _load_item_cache(self, href, input_hash):
  558. cache_folder = os.path.join(self._filesystem_path, ".Radicale.cache",
  559. "item")
  560. cache_hash = uid = etag = text = name = tag = start = end = None
  561. try:
  562. with open(os.path.join(cache_folder, href), "rb") as f:
  563. cache_hash, *content = pickle.load(f)
  564. if cache_hash == input_hash:
  565. uid, etag, text, name, tag, start, end = content
  566. except FileNotFoundError as e:
  567. pass
  568. except (pickle.UnpicklingError, ValueError) as e:
  569. logger.warning("Failed to load item cache entry %r in %r: %s",
  570. href, self.path, e, exc_info=True)
  571. return cache_hash, uid, etag, text, name, tag, start, end
  572. def _clean_item_cache(self):
  573. cache_folder = os.path.join(self._filesystem_path, ".Radicale.cache",
  574. "item")
  575. self._clean_cache(cache_folder, (
  576. e.name for e in os.scandir(cache_folder) if not
  577. os.path.isfile(os.path.join(self._filesystem_path, e.name))))
  578. def get(self, href, verify_href=True):
  579. if verify_href:
  580. try:
  581. if not pathutils.is_safe_filesystem_path_component(href):
  582. raise pathutils.UnsafePathError(href)
  583. path = pathutils.path_to_filesystem(
  584. self._filesystem_path, href)
  585. except ValueError as e:
  586. logger.debug(
  587. "Can't translate name %r safely to filesystem in %r: %s",
  588. href, self.path, e, exc_info=True)
  589. return None
  590. else:
  591. path = os.path.join(self._filesystem_path, href)
  592. try:
  593. with open(path, "rb") as f:
  594. raw_text = f.read()
  595. except (FileNotFoundError, IsADirectoryError):
  596. return None
  597. except PermissionError:
  598. # Windows raises ``PermissionError`` when ``path`` is a directory
  599. if (os.name == "nt" and
  600. os.path.isdir(path) and os.access(path, os.R_OK)):
  601. return None
  602. raise
  603. # The hash of the component in the file system. This is used to check,
  604. # if the entry in the cache is still valid.
  605. input_hash = self._item_cache_hash(raw_text)
  606. cache_hash, uid, etag, text, name, tag, start, end = \
  607. self._load_item_cache(href, input_hash)
  608. if input_hash != cache_hash:
  609. with self._acquire_cache_lock("item"):
  610. # Lock the item cache to prevent multpile processes from
  611. # generating the same data in parallel.
  612. # This improves the performance for multiple requests.
  613. if self._lock.locked == "r":
  614. # Check if another process created the file in the meantime
  615. cache_hash, uid, etag, text, name, tag, start, end = \
  616. self._load_item_cache(href, input_hash)
  617. if input_hash != cache_hash:
  618. try:
  619. vobject_items = tuple(vobject.readComponents(
  620. raw_text.decode(self._encoding)))
  621. radicale_item.check_and_sanitize_items(
  622. vobject_items, tag=self.get_meta("tag"))
  623. vobject_item, = vobject_items
  624. temp_item = radicale_item.Item(
  625. collection=self, vobject_item=vobject_item)
  626. cache_hash, uid, etag, text, name, tag, start, end = \
  627. self._store_item_cache(
  628. href, temp_item, input_hash)
  629. except Exception as e:
  630. raise RuntimeError("Failed to load item %r in %r: %s" %
  631. (href, self.path, e)) from e
  632. # Clean cache entries once after the data in the file
  633. # system was edited externally.
  634. if not self._item_cache_cleaned:
  635. self._item_cache_cleaned = True
  636. self._clean_item_cache()
  637. last_modified = time.strftime(
  638. "%a, %d %b %Y %H:%M:%S GMT",
  639. time.gmtime(os.path.getmtime(path)))
  640. # Don't keep reference to ``vobject_item``, because it requires a lot
  641. # of memory.
  642. return radicale_item.Item(
  643. collection=self, href=href, last_modified=last_modified, etag=etag,
  644. text=text, uid=uid, name=name, component_name=tag,
  645. time_range=(start, end))
  646. def get_multi(self, hrefs):
  647. # It's faster to check for file name collissions here, because
  648. # we only need to call os.listdir once.
  649. files = None
  650. for href in hrefs:
  651. if files is None:
  652. # List dir after hrefs returned one item, the iterator may be
  653. # empty and the for-loop is never executed.
  654. files = os.listdir(self._filesystem_path)
  655. path = os.path.join(self._filesystem_path, href)
  656. if (not pathutils.is_safe_filesystem_path_component(href) or
  657. href not in files and os.path.lexists(path)):
  658. logger.debug(
  659. "Can't translate name safely to filesystem: %r", href)
  660. yield (href, None)
  661. else:
  662. yield (href, self.get(href, verify_href=False))
  663. def get_all(self):
  664. # We don't need to check for collissions, because the the file names
  665. # are from os.listdir.
  666. return (self.get(href, verify_href=False) for href in self.list())
  667. def get_all_filtered(self, filters):
  668. tag, start, end, simple = radicale_filter.simplify_prefilters(
  669. filters, collection_tag=self.get_meta("tag"))
  670. if not tag:
  671. # no filter
  672. yield from ((item, simple) for item in self.get_all())
  673. return
  674. for item in (self.get(h, verify_href=False) for h in self.list()):
  675. istart, iend = item.time_range
  676. if tag == item.component_name and istart < end and iend > start:
  677. yield item, simple and (start <= istart or iend <= end)
  678. def upload(self, href, item):
  679. if not pathutils.is_safe_filesystem_path_component(href):
  680. raise pathutils.UnsafePathError(href)
  681. try:
  682. self._store_item_cache(href, item)
  683. except Exception as e:
  684. raise ValueError("Failed to store item %r in collection %r: %s" %
  685. (href, self.path, e)) from e
  686. path = pathutils.path_to_filesystem(self._filesystem_path, href)
  687. with self._atomic_write(path, newline="") as fd:
  688. fd.write(item.serialize())
  689. # Clean the cache after the actual item is stored, or the cache entry
  690. # will be removed again.
  691. self._clean_item_cache()
  692. # Track the change
  693. self._update_history_etag(href, item)
  694. self._clean_history_cache()
  695. return self.get(href, verify_href=False)
  696. def delete(self, href=None):
  697. if href is None:
  698. # Delete the collection
  699. parent_dir = os.path.dirname(self._filesystem_path)
  700. try:
  701. os.rmdir(self._filesystem_path)
  702. except OSError:
  703. with TemporaryDirectory(
  704. prefix=".Radicale.tmp-", dir=parent_dir) as tmp:
  705. os.rename(self._filesystem_path, os.path.join(
  706. tmp, os.path.basename(self._filesystem_path)))
  707. self._sync_directory(parent_dir)
  708. else:
  709. self._sync_directory(parent_dir)
  710. else:
  711. # Delete an item
  712. if not pathutils.is_safe_filesystem_path_component(href):
  713. raise pathutils.UnsafePathError(href)
  714. path = pathutils.path_to_filesystem(self._filesystem_path, href)
  715. if not os.path.isfile(path):
  716. raise storage.ComponentNotFoundError(href)
  717. os.remove(path)
  718. self._sync_directory(os.path.dirname(path))
  719. # Track the change
  720. self._update_history_etag(href, None)
  721. self._clean_history_cache()
  722. def get_meta(self, key=None):
  723. # reuse cached value if the storage is read-only
  724. if self._lock.locked == "w" or self._meta_cache is None:
  725. try:
  726. try:
  727. with open(self._props_path, encoding=self._encoding) as f:
  728. self._meta_cache = json.load(f)
  729. except FileNotFoundError:
  730. self._meta_cache = {}
  731. radicale_item.check_and_sanitize_props(self._meta_cache)
  732. except ValueError as e:
  733. raise RuntimeError("Failed to load properties of collection "
  734. "%r: %s" % (self.path, e)) from e
  735. return self._meta_cache.get(key) if key else self._meta_cache
  736. def set_meta(self, props):
  737. with self._atomic_write(self._props_path, "w") as f:
  738. json.dump(props, f, sort_keys=True)
  739. @property
  740. def last_modified(self):
  741. relevant_files = chain(
  742. (self._filesystem_path,),
  743. (self._props_path,) if os.path.exists(self._props_path) else (),
  744. (os.path.join(self._filesystem_path, h) for h in self.list()))
  745. last = max(map(os.path.getmtime, relevant_files))
  746. return time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(last))
  747. @property
  748. def etag(self):
  749. # reuse cached value if the storage is read-only
  750. if self._lock.locked == "w" or self._etag_cache is None:
  751. self._etag_cache = super().etag
  752. return self._etag_cache
  753. @classmethod
  754. @contextmanager
  755. def acquire_lock(cls, mode, user=None):
  756. with cls._lock.acquire(mode):
  757. yield
  758. # execute hook
  759. hook = cls.configuration.get("storage", "hook")
  760. if mode == "w" and hook:
  761. folder = os.path.expanduser(cls.configuration.get(
  762. "storage", "filesystem_folder"))
  763. logger.debug("Running hook")
  764. debug = logger.isEnabledFor(logging.DEBUG)
  765. p = subprocess.Popen(
  766. hook % {"user": shlex.quote(user or "Anonymous")},
  767. stdin=subprocess.DEVNULL,
  768. stdout=subprocess.PIPE if debug else subprocess.DEVNULL,
  769. stderr=subprocess.PIPE if debug else subprocess.DEVNULL,
  770. shell=True, universal_newlines=True, cwd=folder)
  771. stdout_data, stderr_data = p.communicate()
  772. if stdout_data:
  773. logger.debug("Captured stdout hook:\n%s", stdout_data)
  774. if stderr_data:
  775. logger.debug("Captured stderr hook:\n%s", stderr_data)
  776. if p.returncode != 0:
  777. raise subprocess.CalledProcessError(p.returncode, p.args)