storage.py 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068
  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 datetime
  26. import errno
  27. import json
  28. import os
  29. import pickle
  30. import posixpath
  31. import shlex
  32. import stat
  33. import subprocess
  34. import threading
  35. import time
  36. from contextlib import contextmanager
  37. from hashlib import md5
  38. from importlib import import_module
  39. from itertools import chain, groupby
  40. from random import getrandbits
  41. from tempfile import NamedTemporaryFile, TemporaryDirectory
  42. import vobject
  43. if os.name == "nt":
  44. import ctypes
  45. import ctypes.wintypes
  46. import msvcrt
  47. LOCKFILE_EXCLUSIVE_LOCK = 2
  48. if ctypes.sizeof(ctypes.c_void_p) == 4:
  49. ULONG_PTR = ctypes.c_uint32
  50. else:
  51. ULONG_PTR = ctypes.c_uint64
  52. class Overlapped(ctypes.Structure):
  53. _fields_ = [
  54. ("internal", ULONG_PTR),
  55. ("internal_high", ULONG_PTR),
  56. ("offset", ctypes.wintypes.DWORD),
  57. ("offset_high", ctypes.wintypes.DWORD),
  58. ("h_event", ctypes.wintypes.HANDLE)]
  59. lock_file_ex = ctypes.windll.kernel32.LockFileEx
  60. lock_file_ex.argtypes = [
  61. ctypes.wintypes.HANDLE,
  62. ctypes.wintypes.DWORD,
  63. ctypes.wintypes.DWORD,
  64. ctypes.wintypes.DWORD,
  65. ctypes.wintypes.DWORD,
  66. ctypes.POINTER(Overlapped)]
  67. lock_file_ex.restype = ctypes.wintypes.BOOL
  68. unlock_file_ex = ctypes.windll.kernel32.UnlockFileEx
  69. unlock_file_ex.argtypes = [
  70. ctypes.wintypes.HANDLE,
  71. ctypes.wintypes.DWORD,
  72. ctypes.wintypes.DWORD,
  73. ctypes.wintypes.DWORD,
  74. ctypes.POINTER(Overlapped)]
  75. unlock_file_ex.restype = ctypes.wintypes.BOOL
  76. elif os.name == "posix":
  77. import fcntl
  78. def load(configuration, logger):
  79. """Load the storage manager chosen in configuration."""
  80. storage_type = configuration.get("storage", "type")
  81. if storage_type == "multifilesystem":
  82. collection_class = Collection
  83. else:
  84. try:
  85. collection_class = import_module(storage_type).Collection
  86. except ImportError as e:
  87. raise RuntimeError("Storage module %r not found" %
  88. storage_type) from e
  89. logger.info("Storage type is %r", storage_type)
  90. class CollectionCopy(collection_class):
  91. """Collection copy, avoids overriding the original class attributes."""
  92. CollectionCopy.configuration = configuration
  93. CollectionCopy.logger = logger
  94. return CollectionCopy
  95. def get_etag(text):
  96. """Etag from collection or item.
  97. Encoded as quoted-string (see RFC 2616).
  98. """
  99. etag = md5()
  100. etag.update(text.encode("utf-8"))
  101. return '"%s"' % etag.hexdigest()
  102. def get_uid(item):
  103. """UID value of an item if defined."""
  104. return hasattr(item, "uid") and item.uid.value
  105. def sanitize_path(path):
  106. """Make path absolute with leading slash to prevent access to other data.
  107. Preserve a potential trailing slash.
  108. """
  109. trailing_slash = "/" if path.endswith("/") else ""
  110. path = posixpath.normpath(path)
  111. new_path = "/"
  112. for part in path.split("/"):
  113. if not is_safe_path_component(part):
  114. continue
  115. new_path = posixpath.join(new_path, part)
  116. trailing_slash = "" if new_path.endswith("/") else trailing_slash
  117. return new_path + trailing_slash
  118. def is_safe_path_component(path):
  119. """Check if path is a single component of a path.
  120. Check that the path is safe to join too.
  121. """
  122. return path and "/" not in path and path not in (".", "..")
  123. def is_safe_filesystem_path_component(path):
  124. """Check if path is a single component of a local and posix filesystem
  125. path.
  126. Check that the path is safe to join too.
  127. """
  128. return (
  129. path and not os.path.splitdrive(path)[0] and
  130. not os.path.split(path)[0] and path not in (os.curdir, os.pardir) and
  131. not path.startswith(".") and not path.endswith("~") and
  132. is_safe_path_component(path))
  133. def path_to_filesystem(root, *paths):
  134. """Convert path to a local filesystem path relative to base_folder.
  135. `root` must be a secure filesystem path, it will be prepend to the path.
  136. Conversion of `paths` is done in a secure manner, or raises ``ValueError``.
  137. """
  138. paths = [sanitize_path(path).strip("/") for path in paths]
  139. safe_path = root
  140. for path in paths:
  141. if not path:
  142. continue
  143. for part in path.split("/"):
  144. if not is_safe_filesystem_path_component(part):
  145. raise UnsafePathError(part)
  146. safe_path_parent = safe_path
  147. safe_path = os.path.join(safe_path, part)
  148. # Check for conflicting files (e.g. case-insensitive file systems
  149. # or short names on Windows file systems)
  150. if os.path.lexists(safe_path):
  151. if part not in os.listdir(safe_path_parent):
  152. raise CollidingPathError(part)
  153. return safe_path
  154. class UnsafePathError(ValueError):
  155. def __init__(self, path):
  156. message = "Can't translate name safely to filesystem: %r" % path
  157. super().__init__(message)
  158. class CollidingPathError(ValueError):
  159. def __init__(self, path):
  160. message = "File name collision: %r" % path
  161. super().__init__(message)
  162. class ComponentExistsError(ValueError):
  163. def __init__(self, path):
  164. message = "Component already exists: %r" % path
  165. super().__init__(message)
  166. class ComponentNotFoundError(ValueError):
  167. def __init__(self, path):
  168. message = "Component doesn't exist: %r" % path
  169. super().__init__(message)
  170. class Item:
  171. def __init__(self, collection, item, href, last_modified=None):
  172. self.collection = collection
  173. self.item = item
  174. self.href = href
  175. self.last_modified = last_modified
  176. def __getattr__(self, attr):
  177. return getattr(self.item, attr)
  178. @property
  179. def etag(self):
  180. """Encoded as quoted-string (see RFC 2616)."""
  181. return get_etag(self.serialize())
  182. class BaseCollection:
  183. # Overriden on copy by the "load" function
  184. configuration = None
  185. logger = None
  186. def __init__(self, path, principal=False):
  187. """Initialize the collection.
  188. ``path`` must be the normalized relative path of the collection, using
  189. the slash as the folder delimiter, with no leading nor trailing slash.
  190. """
  191. raise NotImplementedError
  192. @classmethod
  193. def discover(cls, path, depth="0"):
  194. """Discover a list of collections under the given ``path``.
  195. If ``depth`` is "0", only the actual object under ``path`` is
  196. returned.
  197. If ``depth`` is anything but "0", it is considered as "1" and direct
  198. children are included in the result.
  199. The ``path`` is relative.
  200. The root collection "/" must always exist.
  201. """
  202. raise NotImplementedError
  203. @classmethod
  204. def move(cls, item, to_collection, to_href):
  205. """Move an object.
  206. ``item`` is the item to move.
  207. ``to_collection`` is the target collection.
  208. ``to_href`` is the target name in ``to_collection``. An item with the
  209. same name might already exist.
  210. """
  211. if item.collection.path == to_collection.path and item.href == to_href:
  212. return
  213. to_collection.upload(to_href, item.item)
  214. item.collection.delete(item.href)
  215. @property
  216. def etag(self):
  217. """Encoded as quoted-string (see RFC 2616)."""
  218. return get_etag(self.serialize())
  219. @classmethod
  220. def create_collection(cls, href, collection=None, props=None):
  221. """Create a collection.
  222. If the collection already exists and neither ``collection`` nor
  223. ``props`` are set, this method shouldn't do anything. Otherwise the
  224. existing collection must be replaced.
  225. ``collection`` is a list of vobject components.
  226. ``props`` are metadata values for the collection.
  227. ``props["tag"]`` is the type of collection (VCALENDAR or
  228. VADDRESSBOOK). If the key ``tag`` is missing, it is guessed from the
  229. collection.
  230. """
  231. raise NotImplementedError
  232. def sync(self, old_token=None):
  233. """Get the current sync token and changed items for synchronization.
  234. ``old_token`` an old sync token which is used as the base of the
  235. delta update. If sync token is missing, all items are returned.
  236. ValueError is raised for invalid or old tokens.
  237. WARNING: This simple default implementation treats all sync-token as
  238. invalid. It adheres to the specification but some clients
  239. (e.g. InfCloud) don't like it. Subclasses should provide a
  240. more sophisticated implementation.
  241. """
  242. token = "http://radicale.org/ns/sync/%s" % self.etag.strip("\"")
  243. if old_token:
  244. raise ValueError("Sync token are not supported")
  245. return token, self.list()
  246. def list(self):
  247. """List collection items."""
  248. raise NotImplementedError
  249. def get(self, href):
  250. """Fetch a single item."""
  251. raise NotImplementedError
  252. def get_multi(self, hrefs):
  253. """Fetch multiple items. Duplicate hrefs must be ignored.
  254. Functionally similar to ``get``, but might bring performance benefits
  255. on some storages when used cleverly.
  256. """
  257. for href in set(hrefs):
  258. yield self.get(href)
  259. def pre_filtered_list(self, filters):
  260. """List collection items with optional pre filtering.
  261. This could largely improve performance of reports depending on
  262. the filters and this implementation.
  263. This returns all event by default
  264. """
  265. return [self.get(href) for href in self.list()]
  266. def has(self, href):
  267. """Check if an item exists by its href.
  268. Functionally similar to ``get``, but might bring performance benefits
  269. on some storages when used cleverly.
  270. """
  271. return self.get(href) is not None
  272. def upload(self, href, vobject_item):
  273. """Upload a new or replace an existing item."""
  274. raise NotImplementedError
  275. def delete(self, href=None):
  276. """Delete an item.
  277. When ``href`` is ``None``, delete the collection.
  278. """
  279. raise NotImplementedError
  280. def get_meta(self, key):
  281. """Get metadata value for collection."""
  282. raise NotImplementedError
  283. def set_meta(self, props):
  284. """Set metadata values for collection."""
  285. raise NotImplementedError
  286. @property
  287. def last_modified(self):
  288. """Get the HTTP-datetime of when the collection was modified."""
  289. raise NotImplementedError
  290. def serialize(self):
  291. """Get the unicode string representing the whole collection."""
  292. raise NotImplementedError
  293. @classmethod
  294. @contextmanager
  295. def acquire_lock(cls, mode, user=None):
  296. """Set a context manager to lock the whole storage.
  297. ``mode`` must either be "r" for shared access or "w" for exclusive
  298. access.
  299. ``user`` is the name of the logged in user or empty.
  300. """
  301. raise NotImplementedError
  302. class Collection(BaseCollection):
  303. """Collection stored in several files per calendar."""
  304. def __init__(self, path, principal=False, folder=None):
  305. if not folder:
  306. folder = self._get_collection_root_folder()
  307. # Path should already be sanitized
  308. self.path = sanitize_path(path).strip("/")
  309. self.encoding = self.configuration.get("encoding", "stock")
  310. self._filesystem_path = path_to_filesystem(folder, self.path)
  311. self._props_path = os.path.join(
  312. self._filesystem_path, ".Radicale.props")
  313. split_path = self.path.split("/")
  314. self.owner = split_path[0] if len(split_path) > 1 else None
  315. self.is_principal = principal
  316. @classmethod
  317. def _get_collection_root_folder(cls):
  318. filesystem_folder = os.path.expanduser(
  319. cls.configuration.get("storage", "filesystem_folder"))
  320. return os.path.join(filesystem_folder, "collection-root")
  321. @contextmanager
  322. def _atomic_write(self, path, mode="w", newline=None):
  323. directory = os.path.dirname(path)
  324. tmp = NamedTemporaryFile(
  325. mode=mode, dir=directory, delete=False, prefix=".Radicale.tmp-",
  326. newline=newline, encoding=None if "b" in mode else self.encoding)
  327. try:
  328. yield tmp
  329. self._fsync(tmp.fileno())
  330. tmp.close()
  331. os.replace(tmp.name, path)
  332. except:
  333. tmp.close()
  334. os.remove(tmp.name)
  335. raise
  336. self._sync_directory(directory)
  337. @staticmethod
  338. def _find_available_file_name(exists_fn):
  339. # Prevent infinite loop
  340. for _ in range(10000):
  341. file_name = hex(getrandbits(32))[2:]
  342. if not exists_fn(file_name):
  343. return file_name
  344. raise FileExistsError(errno.EEXIST, "No usable file name found")
  345. @classmethod
  346. def _fsync(cls, fd):
  347. if cls.configuration.getboolean("storage", "filesystem_fsync"):
  348. if os.name == "posix" and hasattr(fcntl, "F_FULLFSYNC"):
  349. fcntl.fcntl(fd, fcntl.F_FULLFSYNC)
  350. else:
  351. os.fsync(fd)
  352. @classmethod
  353. def _sync_directory(cls, path):
  354. """Sync directory to disk.
  355. This only works on POSIX and does nothing on other systems.
  356. """
  357. if not cls.configuration.getboolean("storage", "filesystem_fsync"):
  358. return
  359. if os.name == "posix":
  360. fd = os.open(path, 0)
  361. try:
  362. cls._fsync(fd)
  363. finally:
  364. os.close(fd)
  365. @classmethod
  366. def _makedirs_synced(cls, filesystem_path):
  367. """Recursively create a directory and its parents in a sync'ed way.
  368. This method acts silently when the folder already exists.
  369. """
  370. if os.path.isdir(filesystem_path):
  371. return
  372. parent_filesystem_path = os.path.dirname(filesystem_path)
  373. # Prevent infinite loop
  374. if filesystem_path != parent_filesystem_path:
  375. # Create parent dirs recursively
  376. cls._makedirs_synced(parent_filesystem_path)
  377. # Possible race!
  378. os.makedirs(filesystem_path, exist_ok=True)
  379. cls._sync_directory(parent_filesystem_path)
  380. @classmethod
  381. def discover(cls, path, depth="0"):
  382. # Path should already be sanitized
  383. sane_path = sanitize_path(path).strip("/")
  384. attributes = sane_path.split("/")
  385. if not attributes[0]:
  386. attributes.pop()
  387. folder = cls._get_collection_root_folder()
  388. # Create the root collection
  389. cls._makedirs_synced(folder)
  390. try:
  391. filesystem_path = path_to_filesystem(folder, sane_path)
  392. except ValueError as e:
  393. # Path is unsafe
  394. cls.logger.debug("Collection with unsafe path %r requested: %s",
  395. sane_path, e, exc_info=True)
  396. return
  397. # Check if the path exists and if it leads to a collection or an item
  398. if not os.path.isdir(filesystem_path):
  399. if attributes and os.path.isfile(filesystem_path):
  400. href = attributes.pop()
  401. else:
  402. return
  403. else:
  404. href = None
  405. path = "/".join(attributes)
  406. principal = len(attributes) == 1
  407. collection = cls(path, principal)
  408. if href:
  409. yield collection.get(href)
  410. return
  411. yield collection
  412. if depth == "0":
  413. return
  414. for item in collection.list():
  415. yield collection.get(item)
  416. for href in os.listdir(filesystem_path):
  417. if not is_safe_filesystem_path_component(href):
  418. if not href.startswith(".Radicale"):
  419. cls.logger.debug("Skipping collection %r in %r", href,
  420. path)
  421. continue
  422. child_filesystem_path = path_to_filesystem(filesystem_path, href)
  423. if os.path.isdir(child_filesystem_path):
  424. child_path = posixpath.join(path, href)
  425. child_principal = len(attributes) == 0
  426. yield cls(child_path, child_principal)
  427. @classmethod
  428. def create_collection(cls, href, collection=None, props=None):
  429. folder = cls._get_collection_root_folder()
  430. # Path should already be sanitized
  431. sane_path = sanitize_path(href).strip("/")
  432. attributes = sane_path.split("/")
  433. if not attributes[0]:
  434. attributes.pop()
  435. principal = len(attributes) == 1
  436. filesystem_path = path_to_filesystem(folder, sane_path)
  437. if not props:
  438. props = {}
  439. if not props.get("tag") and collection:
  440. props["tag"] = collection[0].name
  441. if not props:
  442. cls._makedirs_synced(filesystem_path)
  443. return cls(sane_path, principal=principal)
  444. parent_dir = os.path.dirname(filesystem_path)
  445. cls._makedirs_synced(parent_dir)
  446. # Create a temporary directory with an unsafe name
  447. with TemporaryDirectory(
  448. prefix=".Radicale.tmp-", dir=parent_dir) as tmp_dir:
  449. # The temporary directory itself can't be renamed
  450. tmp_filesystem_path = os.path.join(tmp_dir, "collection")
  451. os.makedirs(tmp_filesystem_path)
  452. self = cls("/", principal=principal, folder=tmp_filesystem_path)
  453. self.set_meta(props)
  454. if collection:
  455. if props.get("tag") == "VCALENDAR":
  456. collection, = collection
  457. items = []
  458. for content in ("vevent", "vtodo", "vjournal"):
  459. items.extend(
  460. getattr(collection, "%s_list" % content, []))
  461. items_by_uid = groupby(sorted(items, key=get_uid), get_uid)
  462. vobject_items = {}
  463. for uid, items in items_by_uid:
  464. new_collection = vobject.iCalendar()
  465. for item in items:
  466. new_collection.add(item)
  467. href = self._find_available_file_name(
  468. vobject_items.get)
  469. vobject_items[href] = new_collection
  470. self.upload_all_nonatomic(vobject_items)
  471. elif props.get("tag") == "VCARD":
  472. vobject_items = {}
  473. for card in collection:
  474. href = self._find_available_file_name(
  475. vobject_items.get)
  476. vobject_items[href] = card
  477. self.upload_all_nonatomic(vobject_items)
  478. # This operation is not atomic on the filesystem level but it's
  479. # very unlikely that one rename operations succeeds while the
  480. # other fails or that only one gets written to disk.
  481. if os.path.exists(filesystem_path):
  482. os.rename(filesystem_path, os.path.join(tmp_dir, "delete"))
  483. os.rename(tmp_filesystem_path, filesystem_path)
  484. cls._sync_directory(parent_dir)
  485. return cls(sane_path, principal=principal)
  486. def upload_all_nonatomic(self, vobject_items):
  487. """Upload a new set of items.
  488. This takes a mapping of href and vobject items and
  489. uploads them nonatomic and without existence checks.
  490. """
  491. with contextlib.ExitStack() as stack:
  492. fs = []
  493. for href, item in vobject_items.items():
  494. if not is_safe_filesystem_path_component(href):
  495. raise UnsafePathError(href)
  496. path = path_to_filesystem(self._filesystem_path, href)
  497. fs.append(stack.enter_context(
  498. open(path, "w", encoding=self.encoding, newline="")))
  499. fs[-1].write(item.serialize())
  500. # sync everything at once because it's slightly faster.
  501. for f in fs:
  502. self._fsync(f.fileno())
  503. self._sync_directory(self._filesystem_path)
  504. @classmethod
  505. def move(cls, item, to_collection, to_href):
  506. if not is_safe_filesystem_path_component(to_href):
  507. raise UnsafePathError(to_href)
  508. os.replace(
  509. path_to_filesystem(item.collection._filesystem_path, item.href),
  510. path_to_filesystem(to_collection._filesystem_path, to_href))
  511. cls._sync_directory(to_collection._filesystem_path)
  512. if item.collection._filesystem_path != to_collection._filesystem_path:
  513. cls._sync_directory(item.collection._filesystem_path)
  514. # Track the change
  515. to_collection._update_history_etag(to_href, item)
  516. item.collection._update_history_etag(item.href, None)
  517. to_collection._clean_history_cache()
  518. if item.collection._filesystem_path != to_collection._filesystem_path:
  519. item.collection._clean_history_cache()
  520. @classmethod
  521. def _clean_cache(cls, folder, names, max_age=None):
  522. """Delete all ``names`` in ``folder`` that are older than ``max_age``.
  523. """
  524. age_limit = time.time() - max_age if max_age is not None else None
  525. modified = False
  526. for name in names:
  527. if not is_safe_filesystem_path_component(name):
  528. continue
  529. if age_limit is not None:
  530. try:
  531. # Race: Another process might have deleted the file.
  532. mtime = os.path.getmtime(os.path.join(folder, name))
  533. except FileNotFoundError:
  534. continue
  535. if mtime > age_limit:
  536. continue
  537. cls.logger.debug("Found expired item in cache: %r", name)
  538. # Race: Another process might have deleted or locked the
  539. # file.
  540. try:
  541. os.remove(os.path.join(folder, name))
  542. except (FileNotFoundError, PermissionError):
  543. continue
  544. modified = True
  545. if modified:
  546. cls._sync_directory(folder)
  547. def _update_history_etag(self, href, item):
  548. """Updates and retrieves the history etag from the history cache.
  549. The history cache contains a file for each current and deleted item
  550. of the collection. These files contain the etag of the item (empty
  551. string for deleted items) and a history etag, which is a hash over
  552. the previous history etag and the etag separated by "/".
  553. """
  554. history_folder = os.path.join(self._filesystem_path,
  555. ".Radicale.cache", "history")
  556. try:
  557. with open(os.path.join(history_folder, href), "rb") as f:
  558. cache_etag, history_etag = pickle.load(f)
  559. except (FileNotFoundError, pickle.UnpicklingError, ValueError) as e:
  560. if isinstance(e, (pickle.UnpicklingError, ValueError)):
  561. self.logger.warning(
  562. "Failed to load history cache entry %r in %r: %s",
  563. href, self.path, e, exc_info=True)
  564. # Delete the damaged file
  565. try:
  566. os.remove(os.path.join(history_folder, href))
  567. except (FileNotFoundError, PermissionError):
  568. pass
  569. cache_etag = ""
  570. # Initialize with random data to prevent collisions with cleaned
  571. # expired items.
  572. history_etag = binascii.hexlify(os.urandom(16)).decode("ascii")
  573. etag = item.etag if item else ""
  574. if etag != cache_etag:
  575. self._makedirs_synced(history_folder)
  576. history_etag = get_etag(history_etag + "/" + etag).strip("\"")
  577. try:
  578. # Race: Other processes might have created and locked the file.
  579. with self._atomic_write(os.path.join(history_folder, href),
  580. "wb") as f:
  581. pickle.dump([etag, history_etag], f)
  582. except PermissionError:
  583. pass
  584. return history_etag
  585. def _get_deleted_history_hrefs(self):
  586. """Returns the hrefs of all deleted items that are still in the
  587. history cache."""
  588. history_folder = os.path.join(self._filesystem_path,
  589. ".Radicale.cache", "history")
  590. try:
  591. for href in os.listdir(history_folder):
  592. if not is_safe_filesystem_path_component(href):
  593. continue
  594. if os.path.isfile(os.path.join(self._filesystem_path, href)):
  595. continue
  596. yield href
  597. except FileNotFoundError:
  598. pass
  599. def _clean_history_cache(self):
  600. # Delete all expired cache entries of deleted items.
  601. history_folder = os.path.join(self._filesystem_path,
  602. ".Radicale.cache", "history")
  603. self._clean_cache(history_folder, self._get_deleted_history_hrefs(),
  604. max_age=self.configuration.getint(
  605. "storage", "max_sync_token_age"))
  606. def sync(self, old_token=None):
  607. # The sync token has the form http://radicale.org/ns/sync/TOKEN_NAME
  608. # where TOKEN_NAME is the md5 hash of all history etags of present and
  609. # past items of the collection.
  610. def check_token_name(token_name):
  611. if len(token_name) != 32:
  612. return False
  613. for c in token_name:
  614. if c not in "0123456789abcdef":
  615. return False
  616. return True
  617. old_token_name = None
  618. if old_token:
  619. # Extract the token name from the sync token
  620. if not old_token.startswith("http://radicale.org/ns/sync/"):
  621. raise ValueError("Malformed token: %s" % old_token)
  622. old_token_name = old_token[len("http://radicale.org/ns/sync/"):]
  623. if not check_token_name(old_token_name):
  624. raise ValueError("Malformed token: %s" % old_token)
  625. # Get the current state and sync-token of the collection.
  626. state = {}
  627. token_name_hash = md5()
  628. # Find the history of all existing and deleted items
  629. for href, item in chain(
  630. ((item.href, item) for item in self.pre_filtered_list(())),
  631. ((href, None) for href in self._get_deleted_history_hrefs())):
  632. history_etag = self._update_history_etag(href, item)
  633. state[href] = history_etag
  634. token_name_hash.update((href + "/" + history_etag).encode("utf-8"))
  635. token_name = token_name_hash.hexdigest()
  636. token = "http://radicale.org/ns/sync/%s" % token_name
  637. if token_name == old_token_name:
  638. # Nothing changed
  639. return token, ()
  640. token_folder = os.path.join(self._filesystem_path,
  641. ".Radicale.cache", "sync-token")
  642. token_path = os.path.join(token_folder, token_name)
  643. old_state = {}
  644. if old_token_name:
  645. # load the old token state
  646. old_token_path = os.path.join(token_folder, old_token_name)
  647. try:
  648. # Race: Another process might have deleted the file.
  649. with open(old_token_path, "rb") as f:
  650. old_state = pickle.load(f)
  651. except (FileNotFoundError, pickle.UnpicklingError,
  652. ValueError) as e:
  653. if isinstance(e, (pickle.UnpicklingError, ValueError)):
  654. self.logger.warning(
  655. "Failed to load stored sync token %r in %r: %s",
  656. old_token_name, self.path, e, exc_info=True)
  657. # Delete the damaged file
  658. try:
  659. os.remove(old_token_path)
  660. except (FileNotFoundError, PermissionError):
  661. pass
  662. raise ValueError("Token not found: %s" % old_token)
  663. # write the new token state or update the modification time of
  664. # existing token state
  665. if not os.path.exists(token_path):
  666. self._makedirs_synced(token_folder)
  667. try:
  668. # Race: Other processes might have created and locked the file.
  669. with self._atomic_write(token_path, "wb") as f:
  670. pickle.dump(state, f)
  671. except PermissionError:
  672. pass
  673. else:
  674. # clean up old sync tokens and item cache
  675. self._clean_cache(token_folder, os.listdir(token_folder),
  676. max_age=self.configuration.getint(
  677. "storage", "max_sync_token_age"))
  678. self._clean_history_cache()
  679. else:
  680. # Try to update the modification time
  681. try:
  682. # Race: Another process might have deleted the file.
  683. os.utime(token_path)
  684. except FileNotFoundError:
  685. pass
  686. changes = []
  687. # Find all new, changed and deleted (that are still in the item cache)
  688. # items
  689. for href, history_etag in state.items():
  690. if history_etag != old_state.get(href):
  691. changes.append(href)
  692. # Find all deleted items that are no longer in the item cache
  693. for href, history_etag in old_state.items():
  694. if href not in state:
  695. changes.append(href)
  696. return token, changes
  697. def list(self):
  698. for href in os.listdir(self._filesystem_path):
  699. if not is_safe_filesystem_path_component(href):
  700. if not href.startswith(".Radicale"):
  701. self.logger.debug(
  702. "Skipping item %r in %r", href, self.path)
  703. continue
  704. path = os.path.join(self._filesystem_path, href)
  705. if os.path.isfile(path):
  706. yield href
  707. def get(self, href):
  708. if not href:
  709. return None
  710. if not is_safe_filesystem_path_component(href):
  711. self.logger.debug("Can't translate name %r safely to filesystem "
  712. "in %r", href, self.path)
  713. return None
  714. path = path_to_filesystem(self._filesystem_path, href)
  715. if not os.path.isfile(path):
  716. return None
  717. with open(path, encoding=self.encoding, newline="") as f:
  718. text = f.read()
  719. last_modified = time.strftime(
  720. "%a, %d %b %Y %H:%M:%S GMT",
  721. time.gmtime(os.path.getmtime(path)))
  722. try:
  723. item = vobject.readOne(text)
  724. except Exception as e:
  725. raise RuntimeError("Failed to parse item %r in %r" %
  726. (href, self.path)) from e
  727. return Item(self, item, href, last_modified)
  728. def upload(self, href, vobject_item):
  729. if not is_safe_filesystem_path_component(href):
  730. raise UnsafePathError(href)
  731. path = path_to_filesystem(self._filesystem_path, href)
  732. item = Item(self, vobject_item, href)
  733. with self._atomic_write(path, newline="") as fd:
  734. fd.write(item.serialize())
  735. # Track the change
  736. self._update_history_etag(href, item)
  737. self._clean_history_cache()
  738. return item
  739. def delete(self, href=None):
  740. if href is None:
  741. # Delete the collection
  742. parent_dir = os.path.dirname(self._filesystem_path)
  743. try:
  744. os.rmdir(self._filesystem_path)
  745. except OSError:
  746. with TemporaryDirectory(
  747. prefix=".Radicale.tmp-", dir=parent_dir) as tmp:
  748. os.rename(self._filesystem_path, os.path.join(
  749. tmp, os.path.basename(self._filesystem_path)))
  750. self._sync_directory(parent_dir)
  751. else:
  752. self._sync_directory(parent_dir)
  753. else:
  754. # Delete an item
  755. if not is_safe_filesystem_path_component(href):
  756. raise UnsafePathError(href)
  757. path = path_to_filesystem(self._filesystem_path, href)
  758. if not os.path.isfile(path):
  759. raise ComponentNotFoundError(href)
  760. os.remove(path)
  761. self._sync_directory(os.path.dirname(path))
  762. # Track the change
  763. self._update_history_etag(href, None)
  764. self._clean_history_cache()
  765. def get_meta(self, key=None):
  766. if os.path.exists(self._props_path):
  767. with open(self._props_path, encoding=self.encoding) as f:
  768. try:
  769. meta = json.load(f)
  770. except ValueError as e:
  771. raise RuntimeError("Failed to load properties of collect"
  772. "ion %r: %s" % (self.path, e)) from e
  773. return meta.get(key) if key else meta
  774. def set_meta(self, props):
  775. if os.path.exists(self._props_path):
  776. with open(self._props_path, encoding=self.encoding) as f:
  777. old_props = json.load(f)
  778. old_props.update(props)
  779. props = old_props
  780. props = {key: value for key, value in props.items() if value}
  781. with self._atomic_write(self._props_path, "w+") as f:
  782. json.dump(props, f)
  783. @property
  784. def last_modified(self):
  785. relevant_files = [self._filesystem_path] + [
  786. path_to_filesystem(self._filesystem_path, href)
  787. for href in self.list()]
  788. if os.path.exists(self._props_path):
  789. relevant_files.append(self._props_path)
  790. last = max(map(os.path.getmtime, relevant_files))
  791. return time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(last))
  792. def serialize(self):
  793. items = []
  794. time_begin = datetime.datetime.now()
  795. for href in self.list():
  796. items.append(self.get(href).item)
  797. time_end = datetime.datetime.now()
  798. self.logger.info(
  799. "Read %d items in %.3f seconds from %r", len(items),
  800. (time_end - time_begin).total_seconds(), self.path)
  801. if self.get_meta("tag") == "VCALENDAR":
  802. collection = vobject.iCalendar()
  803. for item in items:
  804. for content in ("vevent", "vtodo", "vjournal"):
  805. if content in item.contents:
  806. for item_part in getattr(item, "%s_list" % content):
  807. collection.add(item_part)
  808. break
  809. return collection.serialize()
  810. elif self.get_meta("tag") == "VADDRESSBOOK":
  811. return "".join([item.serialize() for item in items])
  812. return ""
  813. _lock = threading.Lock()
  814. _waiters = []
  815. _lock_file = None
  816. _lock_file_locked = False
  817. _readers = 0
  818. _writer = False
  819. @classmethod
  820. @contextmanager
  821. def acquire_lock(cls, mode, user=None):
  822. def condition():
  823. if mode == "r":
  824. return not cls._writer
  825. else:
  826. return not cls._writer and cls._readers == 0
  827. file_locking = cls.configuration.getboolean("storage",
  828. "filesystem_locking")
  829. folder = os.path.expanduser(cls.configuration.get(
  830. "storage", "filesystem_folder"))
  831. # Use a primitive lock which only works within one process as a
  832. # precondition for inter-process file-based locking
  833. with cls._lock:
  834. if cls._waiters or not condition():
  835. # Use FIFO for access requests
  836. waiter = threading.Condition(lock=cls._lock)
  837. cls._waiters.append(waiter)
  838. while True:
  839. waiter.wait()
  840. if condition():
  841. break
  842. cls._waiters.pop(0)
  843. if mode == "r":
  844. cls._readers += 1
  845. # Notify additional potential readers
  846. if cls._waiters:
  847. cls._waiters[0].notify()
  848. else:
  849. cls._writer = True
  850. if not cls._lock_file:
  851. cls._makedirs_synced(folder)
  852. lock_path = os.path.join(folder, ".Radicale.lock")
  853. cls._lock_file = open(lock_path, "w+")
  854. # Set access rights to a necessary minimum to prevent locking
  855. # by arbitrary users
  856. try:
  857. os.chmod(lock_path, stat.S_IWUSR | stat.S_IRUSR)
  858. except OSError as e:
  859. cls.logger.info("Failed to set permissions on lock file:"
  860. " %s", e, exc_info=True)
  861. if file_locking and not cls._lock_file_locked:
  862. if os.name == "nt":
  863. handle = msvcrt.get_osfhandle(cls._lock_file.fileno())
  864. flags = LOCKFILE_EXCLUSIVE_LOCK if mode == "w" else 0
  865. overlapped = Overlapped()
  866. if not lock_file_ex(handle, flags, 0, 1, 0, overlapped):
  867. raise RuntimeError("Locking the storage failed: %s" %
  868. ctypes.FormatError())
  869. elif os.name == "posix":
  870. _cmd = fcntl.LOCK_EX if mode == "w" else fcntl.LOCK_SH
  871. try:
  872. fcntl.flock(cls._lock_file.fileno(), _cmd)
  873. except OSError as e:
  874. raise RuntimeError("Locking the storage failed: %s" %
  875. e) from e
  876. else:
  877. raise RuntimeError("Locking the storage failed: "
  878. "Unsupported operating system")
  879. cls._lock_file_locked = True
  880. try:
  881. yield
  882. # execute hook
  883. hook = cls.configuration.get("storage", "hook")
  884. if mode == "w" and hook:
  885. cls.logger.debug("Running hook")
  886. subprocess.check_call(
  887. hook % {"user": shlex.quote(user or "Anonymous")},
  888. shell=True, cwd=folder)
  889. finally:
  890. with cls._lock:
  891. if mode == "r":
  892. cls._readers -= 1
  893. else:
  894. cls._writer = False
  895. if file_locking and cls._readers == 0:
  896. if os.name == "nt":
  897. handle = msvcrt.get_osfhandle(cls._lock_file.fileno())
  898. overlapped = Overlapped()
  899. if not unlock_file_ex(handle, 0, 1, 0, overlapped):
  900. raise RuntimeError("Unlocking the storage failed: "
  901. "%s" % ctypes.FormatError())
  902. elif os.name == "posix":
  903. try:
  904. fcntl.flock(cls._lock_file.fileno(), fcntl.LOCK_UN)
  905. except OSError as e:
  906. raise RuntimeError("Unlocking the storage failed: "
  907. "%s" % e) from e
  908. else:
  909. raise RuntimeError("Unlocking the storage failed: "
  910. "Unsupported operating system")
  911. cls._lock_file_locked = False
  912. if cls._waiters:
  913. cls._waiters[0].notify()
  914. if (cls.configuration.getboolean(
  915. "storage", "filesystem_close_lock_file") and
  916. cls._readers == 0 and not cls._waiters):
  917. cls._lock_file.close()
  918. cls._lock_file = None