storage.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854
  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 contextlib
  24. import datetime
  25. import errno
  26. import json
  27. import os
  28. import posixpath
  29. import shlex
  30. import stat
  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 groupby
  38. from random import getrandbits
  39. from tempfile import NamedTemporaryFile, TemporaryDirectory
  40. import vobject
  41. if os.name == "nt":
  42. import ctypes
  43. import ctypes.wintypes
  44. import msvcrt
  45. LOCKFILE_EXCLUSIVE_LOCK = 2
  46. if ctypes.sizeof(ctypes.c_void_p) == 4:
  47. ULONG_PTR = ctypes.c_uint32
  48. else:
  49. ULONG_PTR = ctypes.c_uint64
  50. class Overlapped(ctypes.Structure):
  51. _fields_ = [
  52. ("internal", ULONG_PTR),
  53. ("internal_high", ULONG_PTR),
  54. ("offset", ctypes.wintypes.DWORD),
  55. ("offset_high", ctypes.wintypes.DWORD),
  56. ("h_event", ctypes.wintypes.HANDLE)]
  57. lock_file_ex = ctypes.windll.kernel32.LockFileEx
  58. lock_file_ex.argtypes = [
  59. ctypes.wintypes.HANDLE,
  60. ctypes.wintypes.DWORD,
  61. ctypes.wintypes.DWORD,
  62. ctypes.wintypes.DWORD,
  63. ctypes.wintypes.DWORD,
  64. ctypes.POINTER(Overlapped)]
  65. lock_file_ex.restype = ctypes.wintypes.BOOL
  66. unlock_file_ex = ctypes.windll.kernel32.UnlockFileEx
  67. unlock_file_ex.argtypes = [
  68. ctypes.wintypes.HANDLE,
  69. ctypes.wintypes.DWORD,
  70. ctypes.wintypes.DWORD,
  71. ctypes.wintypes.DWORD,
  72. ctypes.POINTER(Overlapped)]
  73. unlock_file_ex.restype = ctypes.wintypes.BOOL
  74. elif os.name == "posix":
  75. import fcntl
  76. def load(configuration, logger):
  77. """Load the storage manager chosen in configuration."""
  78. storage_type = configuration.get("storage", "type")
  79. if storage_type == "multifilesystem":
  80. collection_class = Collection
  81. else:
  82. try:
  83. collection_class = import_module(storage_type).Collection
  84. except ImportError as e:
  85. raise RuntimeError("Storage module %r not found" %
  86. storage_type) from e
  87. logger.info("Storage type is %r", storage_type)
  88. class CollectionCopy(collection_class):
  89. """Collection copy, avoids overriding the original class attributes."""
  90. CollectionCopy.configuration = configuration
  91. CollectionCopy.logger = logger
  92. return CollectionCopy
  93. def get_etag(text):
  94. """Etag from collection or item.
  95. Encoded as quoted-string (see RFC 2616).
  96. """
  97. etag = md5()
  98. etag.update(text.encode("utf-8"))
  99. return '"%s"' % etag.hexdigest()
  100. def get_uid(item):
  101. """UID value of an item if defined."""
  102. return hasattr(item, "uid") and item.uid.value
  103. def sanitize_path(path):
  104. """Make path absolute with leading slash to prevent access to other data.
  105. Preserve a potential trailing slash.
  106. """
  107. trailing_slash = "/" if path.endswith("/") else ""
  108. path = posixpath.normpath(path)
  109. new_path = "/"
  110. for part in path.split("/"):
  111. if not is_safe_path_component(part):
  112. continue
  113. new_path = posixpath.join(new_path, part)
  114. trailing_slash = "" if new_path.endswith("/") else trailing_slash
  115. return new_path + trailing_slash
  116. def is_safe_path_component(path):
  117. """Check if path is a single component of a path.
  118. Check that the path is safe to join too.
  119. """
  120. return path and "/" not in path and path not in (".", "..")
  121. def is_safe_filesystem_path_component(path):
  122. """Check if path is a single component of a local and posix filesystem
  123. path.
  124. Check that the path is safe to join too.
  125. """
  126. return (
  127. path and not os.path.splitdrive(path)[0] and
  128. not os.path.split(path)[0] and path not in (os.curdir, os.pardir) and
  129. not path.startswith(".") and not path.endswith("~") and
  130. is_safe_path_component(path))
  131. def path_to_filesystem(root, *paths):
  132. """Convert path to a local filesystem path relative to base_folder.
  133. `root` must be a secure filesystem path, it will be prepend to the path.
  134. Conversion of `paths` is done in a secure manner, or raises ``ValueError``.
  135. """
  136. paths = [sanitize_path(path).strip("/") for path in paths]
  137. safe_path = root
  138. for path in paths:
  139. if not path:
  140. continue
  141. for part in path.split("/"):
  142. if not is_safe_filesystem_path_component(part):
  143. raise UnsafePathError(part)
  144. safe_path_parent = safe_path
  145. safe_path = os.path.join(safe_path, part)
  146. # Check for conflicting files (e.g. case-insensitive file systems
  147. # or short names on Windows file systems)
  148. if os.path.lexists(safe_path):
  149. if part not in os.listdir(safe_path_parent):
  150. raise CollidingPathError(part)
  151. return safe_path
  152. class UnsafePathError(ValueError):
  153. def __init__(self, path):
  154. message = "Can't translate name safely to filesystem: %r" % path
  155. super().__init__(message)
  156. class CollidingPathError(ValueError):
  157. def __init__(self, path):
  158. message = "File name collision: %r" % path
  159. super().__init__(message)
  160. class ComponentExistsError(ValueError):
  161. def __init__(self, path):
  162. message = "Component already exists: %r" % path
  163. super().__init__(message)
  164. class ComponentNotFoundError(ValueError):
  165. def __init__(self, path):
  166. message = "Component doesn't exist: %r" % path
  167. super().__init__(message)
  168. class Item:
  169. def __init__(self, collection, item, href, last_modified=None):
  170. self.collection = collection
  171. self.item = item
  172. self.href = href
  173. self.last_modified = last_modified
  174. def __getattr__(self, attr):
  175. return getattr(self.item, attr)
  176. @property
  177. def etag(self):
  178. """Encoded as quoted-string (see RFC 2616)."""
  179. return get_etag(self.serialize())
  180. class BaseCollection:
  181. # Overriden on copy by the "load" function
  182. configuration = None
  183. logger = None
  184. def __init__(self, path, principal=False):
  185. """Initialize the collection.
  186. ``path`` must be the normalized relative path of the collection, using
  187. the slash as the folder delimiter, with no leading nor trailing slash.
  188. """
  189. raise NotImplementedError
  190. @classmethod
  191. def discover(cls, path, depth="0"):
  192. """Discover a list of collections under the given ``path``.
  193. If ``depth`` is "0", only the actual object under ``path`` is
  194. returned.
  195. If ``depth`` is anything but "0", it is considered as "1" and direct
  196. children are included in the result.
  197. The ``path`` is relative.
  198. The root collection "/" must always exist.
  199. """
  200. raise NotImplementedError
  201. @classmethod
  202. def move(cls, item, to_collection, to_href):
  203. """Move an object.
  204. ``item`` is the item to move.
  205. ``to_collection`` is the target collection.
  206. ``to_href`` is the target name in ``to_collection``. An item with the
  207. same name might already exist.
  208. """
  209. if item.collection.path == to_collection.path and item.href == to_href:
  210. return
  211. to_collection.upload(to_href, item.item)
  212. item.collection.delete(item.href)
  213. @property
  214. def etag(self):
  215. """Encoded as quoted-string (see RFC 2616)."""
  216. return get_etag(self.serialize())
  217. @classmethod
  218. def create_collection(cls, href, collection=None, props=None):
  219. """Create a collection.
  220. If the collection already exists and neither ``collection`` nor
  221. ``props`` are set, this method shouldn't do anything. Otherwise the
  222. existing collection must be replaced.
  223. ``collection`` is a list of vobject components.
  224. ``props`` are metadata values for the collection.
  225. ``props["tag"]`` is the type of collection (VCALENDAR or
  226. VADDRESSBOOK). If the key ``tag`` is missing, it is guessed from the
  227. collection.
  228. """
  229. raise NotImplementedError
  230. def list(self):
  231. """List collection items."""
  232. raise NotImplementedError
  233. def get(self, href):
  234. """Fetch a single item."""
  235. raise NotImplementedError
  236. def get_multi(self, hrefs):
  237. """Fetch multiple items. Duplicate hrefs must be ignored.
  238. Functionally similar to ``get``, but might bring performance benefits
  239. on some storages when used cleverly.
  240. """
  241. for href in set(hrefs):
  242. yield self.get(href)
  243. def pre_filtered_list(self, filters):
  244. """List collection items with optional pre filtering.
  245. This could largely improve performance of reports depending on
  246. the filters and this implementation.
  247. This returns all event by default
  248. """
  249. return [self.get(href) for href in self.list()]
  250. def has(self, href):
  251. """Check if an item exists by its href.
  252. Functionally similar to ``get``, but might bring performance benefits
  253. on some storages when used cleverly.
  254. """
  255. return self.get(href) is not None
  256. def upload(self, href, vobject_item):
  257. """Upload a new or replace an existing item."""
  258. raise NotImplementedError
  259. def delete(self, href=None):
  260. """Delete an item.
  261. When ``href`` is ``None``, delete the collection.
  262. """
  263. raise NotImplementedError
  264. def get_meta(self, key):
  265. """Get metadata value for collection."""
  266. raise NotImplementedError
  267. def set_meta(self, props):
  268. """Set metadata values for collection."""
  269. raise NotImplementedError
  270. @property
  271. def last_modified(self):
  272. """Get the HTTP-datetime of when the collection was modified."""
  273. raise NotImplementedError
  274. def serialize(self):
  275. """Get the unicode string representing the whole collection."""
  276. raise NotImplementedError
  277. @classmethod
  278. @contextmanager
  279. def acquire_lock(cls, mode, user=None):
  280. """Set a context manager to lock the whole storage.
  281. ``mode`` must either be "r" for shared access or "w" for exclusive
  282. access.
  283. ``user`` is the name of the logged in user or empty.
  284. """
  285. raise NotImplementedError
  286. class Collection(BaseCollection):
  287. """Collection stored in several files per calendar."""
  288. def __init__(self, path, principal=False, folder=None):
  289. if not folder:
  290. folder = self._get_collection_root_folder()
  291. # Path should already be sanitized
  292. self.path = sanitize_path(path).strip("/")
  293. self.encoding = self.configuration.get("encoding", "stock")
  294. self._filesystem_path = path_to_filesystem(folder, self.path)
  295. self._props_path = os.path.join(
  296. self._filesystem_path, ".Radicale.props")
  297. split_path = self.path.split("/")
  298. self.owner = split_path[0] if len(split_path) > 1 else None
  299. self.is_principal = principal
  300. @classmethod
  301. def _get_collection_root_folder(cls):
  302. filesystem_folder = os.path.expanduser(
  303. cls.configuration.get("storage", "filesystem_folder"))
  304. return os.path.join(filesystem_folder, "collection-root")
  305. @contextmanager
  306. def _atomic_write(self, path, mode="w", newline=None):
  307. directory = os.path.dirname(path)
  308. tmp = NamedTemporaryFile(
  309. mode=mode, dir=directory, encoding=self.encoding,
  310. delete=False, prefix=".Radicale.tmp-", newline=newline)
  311. try:
  312. yield tmp
  313. self._fsync(tmp.fileno())
  314. tmp.close()
  315. os.replace(tmp.name, path)
  316. except:
  317. tmp.close()
  318. os.remove(tmp.name)
  319. raise
  320. self._sync_directory(directory)
  321. @staticmethod
  322. def _find_available_file_name(exists_fn):
  323. # Prevent infinite loop
  324. for _ in range(10000):
  325. file_name = hex(getrandbits(32))[2:]
  326. if not exists_fn(file_name):
  327. return file_name
  328. raise FileExistsError(errno.EEXIST, "No usable file name found")
  329. @classmethod
  330. def _fsync(cls, fd):
  331. if cls.configuration.getboolean("storage", "filesystem_fsync"):
  332. if os.name == "posix" and hasattr(fcntl, "F_FULLFSYNC"):
  333. fcntl.fcntl(fd, fcntl.F_FULLFSYNC)
  334. else:
  335. os.fsync(fd)
  336. @classmethod
  337. def _sync_directory(cls, path):
  338. """Sync directory to disk.
  339. This only works on POSIX and does nothing on other systems.
  340. """
  341. if not cls.configuration.getboolean("storage", "filesystem_fsync"):
  342. return
  343. if os.name == "posix":
  344. fd = os.open(path, 0)
  345. try:
  346. cls._fsync(fd)
  347. finally:
  348. os.close(fd)
  349. @classmethod
  350. def _makedirs_synced(cls, filesystem_path):
  351. """Recursively create a directory and its parents in a sync'ed way.
  352. This method acts silently when the folder already exists.
  353. """
  354. if os.path.isdir(filesystem_path):
  355. return
  356. parent_filesystem_path = os.path.dirname(filesystem_path)
  357. # Prevent infinite loop
  358. if filesystem_path != parent_filesystem_path:
  359. # Create parent dirs recursively
  360. cls._makedirs_synced(parent_filesystem_path)
  361. # Possible race!
  362. os.makedirs(filesystem_path, exist_ok=True)
  363. cls._sync_directory(parent_filesystem_path)
  364. @classmethod
  365. def discover(cls, path, depth="0"):
  366. # Path should already be sanitized
  367. sane_path = sanitize_path(path).strip("/")
  368. attributes = sane_path.split("/")
  369. if not attributes[0]:
  370. attributes.pop()
  371. folder = cls._get_collection_root_folder()
  372. # Create the root collection
  373. cls._makedirs_synced(folder)
  374. try:
  375. filesystem_path = path_to_filesystem(folder, sane_path)
  376. except ValueError as e:
  377. # Path is unsafe
  378. cls.logger.debug("Collection with unsafe path %r requested: %s",
  379. sane_path, e, exc_info=True)
  380. return
  381. # Check if the path exists and if it leads to a collection or an item
  382. if not os.path.isdir(filesystem_path):
  383. if attributes and os.path.isfile(filesystem_path):
  384. href = attributes.pop()
  385. else:
  386. return
  387. else:
  388. href = None
  389. path = "/".join(attributes)
  390. principal = len(attributes) == 1
  391. collection = cls(path, principal)
  392. if href:
  393. yield collection.get(href)
  394. return
  395. yield collection
  396. if depth == "0":
  397. return
  398. for item in collection.list():
  399. yield collection.get(item)
  400. for href in os.listdir(filesystem_path):
  401. if not is_safe_filesystem_path_component(href):
  402. if not href.startswith(".Radicale"):
  403. cls.logger.debug("Skipping collection %r in %r", href,
  404. path)
  405. continue
  406. child_filesystem_path = path_to_filesystem(filesystem_path, href)
  407. if os.path.isdir(child_filesystem_path):
  408. child_path = posixpath.join(path, href)
  409. child_principal = len(attributes) == 0
  410. yield cls(child_path, child_principal)
  411. @classmethod
  412. def create_collection(cls, href, collection=None, props=None):
  413. folder = cls._get_collection_root_folder()
  414. # Path should already be sanitized
  415. sane_path = sanitize_path(href).strip("/")
  416. attributes = sane_path.split("/")
  417. if not attributes[0]:
  418. attributes.pop()
  419. principal = len(attributes) == 1
  420. filesystem_path = path_to_filesystem(folder, sane_path)
  421. if not props:
  422. props = {}
  423. if not props.get("tag") and collection:
  424. props["tag"] = collection[0].name
  425. if not props:
  426. cls._makedirs_synced(filesystem_path)
  427. return cls(sane_path, principal=principal)
  428. parent_dir = os.path.dirname(filesystem_path)
  429. cls._makedirs_synced(parent_dir)
  430. # Create a temporary directory with an unsafe name
  431. with TemporaryDirectory(
  432. prefix=".Radicale.tmp-", dir=parent_dir) as tmp_dir:
  433. # The temporary directory itself can't be renamed
  434. tmp_filesystem_path = os.path.join(tmp_dir, "collection")
  435. os.makedirs(tmp_filesystem_path)
  436. self = cls("/", principal=principal, folder=tmp_filesystem_path)
  437. self.set_meta(props)
  438. if collection:
  439. if props.get("tag") == "VCALENDAR":
  440. collection, = collection
  441. items = []
  442. for content in ("vevent", "vtodo", "vjournal"):
  443. items.extend(
  444. getattr(collection, "%s_list" % content, []))
  445. items_by_uid = groupby(sorted(items, key=get_uid), get_uid)
  446. vobject_items = {}
  447. for uid, items in items_by_uid:
  448. new_collection = vobject.iCalendar()
  449. for item in items:
  450. new_collection.add(item)
  451. href = self._find_available_file_name(
  452. vobject_items.get)
  453. vobject_items[href] = new_collection
  454. self.upload_all_nonatomic(vobject_items)
  455. elif props.get("tag") == "VCARD":
  456. vobject_items = {}
  457. for card in collection:
  458. href = self._find_available_file_name(
  459. vobject_items.get)
  460. vobject_items[href] = card
  461. self.upload_all_nonatomic(vobject_items)
  462. # This operation is not atomic on the filesystem level but it's
  463. # very unlikely that one rename operations succeeds while the
  464. # other fails or that only one gets written to disk.
  465. if os.path.exists(filesystem_path):
  466. os.rename(filesystem_path, os.path.join(tmp_dir, "delete"))
  467. os.rename(tmp_filesystem_path, filesystem_path)
  468. cls._sync_directory(parent_dir)
  469. return cls(sane_path, principal=principal)
  470. def upload_all_nonatomic(self, vobject_items):
  471. """Upload a new set of items.
  472. This takes a mapping of href and vobject items and
  473. uploads them nonatomic and without existence checks.
  474. """
  475. with contextlib.ExitStack() as stack:
  476. fs = []
  477. for href, item in vobject_items.items():
  478. if not is_safe_filesystem_path_component(href):
  479. raise UnsafePathError(href)
  480. path = path_to_filesystem(self._filesystem_path, href)
  481. fs.append(stack.enter_context(
  482. open(path, "w", encoding=self.encoding, newline="")))
  483. fs[-1].write(item.serialize())
  484. # sync everything at once because it's slightly faster.
  485. for f in fs:
  486. self._fsync(f.fileno())
  487. self._sync_directory(self._filesystem_path)
  488. @classmethod
  489. def move(cls, item, to_collection, to_href):
  490. if not is_safe_filesystem_path_component(to_href):
  491. raise UnsafePathError(to_href)
  492. os.replace(
  493. path_to_filesystem(item.collection._filesystem_path, item.href),
  494. path_to_filesystem(to_collection._filesystem_path, to_href))
  495. cls._sync_directory(to_collection._filesystem_path)
  496. if item.collection._filesystem_path != to_collection._filesystem_path:
  497. cls._sync_directory(item.collection._filesystem_path)
  498. def list(self):
  499. for href in os.listdir(self._filesystem_path):
  500. if not is_safe_filesystem_path_component(href):
  501. if not href.startswith(".Radicale"):
  502. self.logger.debug(
  503. "Skipping item %r in %r", href, self.path)
  504. continue
  505. path = os.path.join(self._filesystem_path, href)
  506. if os.path.isfile(path):
  507. yield href
  508. def get(self, href):
  509. if not href:
  510. return None
  511. if not is_safe_filesystem_path_component(href):
  512. self.logger.debug("Can't translate name %r safely to filesystem "
  513. "in %r", href, self.path)
  514. return None
  515. path = path_to_filesystem(self._filesystem_path, href)
  516. if not os.path.isfile(path):
  517. return None
  518. with open(path, encoding=self.encoding, newline="") as f:
  519. text = f.read()
  520. last_modified = time.strftime(
  521. "%a, %d %b %Y %H:%M:%S GMT",
  522. time.gmtime(os.path.getmtime(path)))
  523. try:
  524. item = vobject.readOne(text)
  525. except Exception as e:
  526. raise RuntimeError("Failed to parse item %r in %r" %
  527. (href, self.path)) from e
  528. return Item(self, item, href, last_modified)
  529. def upload(self, href, vobject_item):
  530. if not is_safe_filesystem_path_component(href):
  531. raise UnsafePathError(href)
  532. path = path_to_filesystem(self._filesystem_path, href)
  533. item = Item(self, vobject_item, href)
  534. with self._atomic_write(path, newline="") as fd:
  535. fd.write(item.serialize())
  536. return item
  537. def delete(self, href=None):
  538. if href is None:
  539. # Delete the collection
  540. parent_dir = os.path.dirname(self._filesystem_path)
  541. try:
  542. os.rmdir(self._filesystem_path)
  543. except OSError:
  544. with TemporaryDirectory(
  545. prefix=".Radicale.tmp-", dir=parent_dir) as tmp:
  546. os.rename(self._filesystem_path, os.path.join(
  547. tmp, os.path.basename(self._filesystem_path)))
  548. self._sync_directory(parent_dir)
  549. else:
  550. self._sync_directory(parent_dir)
  551. else:
  552. # Delete an item
  553. if not is_safe_filesystem_path_component(href):
  554. raise UnsafePathError(href)
  555. path = path_to_filesystem(self._filesystem_path, href)
  556. if not os.path.isfile(path):
  557. raise ComponentNotFoundError(href)
  558. os.remove(path)
  559. self._sync_directory(os.path.dirname(path))
  560. def get_meta(self, key=None):
  561. if os.path.exists(self._props_path):
  562. with open(self._props_path, encoding=self.encoding) as f:
  563. try:
  564. meta = json.load(f)
  565. except ValueError as e:
  566. raise RuntimeError("Failed to load properties of collect"
  567. "ion %r: %s" % (self.path, e)) from e
  568. return meta.get(key) if key else meta
  569. def set_meta(self, props):
  570. if os.path.exists(self._props_path):
  571. with open(self._props_path, encoding=self.encoding) as f:
  572. old_props = json.load(f)
  573. old_props.update(props)
  574. props = old_props
  575. props = {key: value for key, value in props.items() if value}
  576. with self._atomic_write(self._props_path, "w+") as f:
  577. json.dump(props, f)
  578. @property
  579. def last_modified(self):
  580. relevant_files = [self._filesystem_path] + [
  581. path_to_filesystem(self._filesystem_path, href)
  582. for href in self.list()]
  583. if os.path.exists(self._props_path):
  584. relevant_files.append(self._props_path)
  585. last = max(map(os.path.getmtime, relevant_files))
  586. return time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(last))
  587. def serialize(self):
  588. items = []
  589. time_begin = datetime.datetime.now()
  590. for href in self.list():
  591. items.append(self.get(href).item)
  592. time_end = datetime.datetime.now()
  593. self.logger.info(
  594. "Read %d items in %.3f seconds from %r", len(items),
  595. (time_end - time_begin).total_seconds(), self.path)
  596. if self.get_meta("tag") == "VCALENDAR":
  597. collection = vobject.iCalendar()
  598. for item in items:
  599. for content in ("vevent", "vtodo", "vjournal"):
  600. if content in item.contents:
  601. for item_part in getattr(item, "%s_list" % content):
  602. collection.add(item_part)
  603. break
  604. return collection.serialize()
  605. elif self.get_meta("tag") == "VADDRESSBOOK":
  606. return "".join([item.serialize() for item in items])
  607. return ""
  608. _lock = threading.Lock()
  609. _waiters = []
  610. _lock_file = None
  611. _lock_file_locked = False
  612. _readers = 0
  613. _writer = False
  614. @classmethod
  615. @contextmanager
  616. def acquire_lock(cls, mode, user=None):
  617. if not cls.configuration.getboolean("storage", "filesystem_locking"):
  618. yield
  619. return
  620. def condition():
  621. if mode == "r":
  622. return not cls._writer
  623. else:
  624. return not cls._writer and cls._readers == 0
  625. folder = os.path.expanduser(cls.configuration.get(
  626. "storage", "filesystem_folder"))
  627. # Use a primitive lock which only works within one process as a
  628. # precondition for inter-process file-based locking
  629. with cls._lock:
  630. if cls._waiters or not condition():
  631. # Use FIFO for access requests
  632. waiter = threading.Condition(lock=cls._lock)
  633. cls._waiters.append(waiter)
  634. while True:
  635. waiter.wait()
  636. if condition():
  637. break
  638. cls._waiters.pop(0)
  639. if mode == "r":
  640. cls._readers += 1
  641. # Notify additional potential readers
  642. if cls._waiters:
  643. cls._waiters[0].notify()
  644. else:
  645. cls._writer = True
  646. if not cls._lock_file:
  647. cls._makedirs_synced(folder)
  648. lock_path = os.path.join(folder, ".Radicale.lock")
  649. cls._lock_file = open(lock_path, "w+")
  650. # Set access rights to a necessary minimum to prevent locking
  651. # by arbitrary users
  652. try:
  653. os.chmod(lock_path, stat.S_IWUSR | stat.S_IRUSR)
  654. except OSError as e:
  655. cls.logger.info("Failed to set permissions on lock file:"
  656. " %s", e, exc_info=True)
  657. if not cls._lock_file_locked:
  658. if os.name == "nt":
  659. handle = msvcrt.get_osfhandle(cls._lock_file.fileno())
  660. flags = LOCKFILE_EXCLUSIVE_LOCK if mode == "w" else 0
  661. overlapped = Overlapped()
  662. if not lock_file_ex(handle, flags, 0, 1, 0, overlapped):
  663. raise RuntimeError("Locking the storage failed: %s" %
  664. ctypes.FormatError())
  665. elif os.name == "posix":
  666. _cmd = fcntl.LOCK_EX if mode == "w" else fcntl.LOCK_SH
  667. try:
  668. fcntl.flock(cls._lock_file.fileno(), _cmd)
  669. except OSError as e:
  670. raise RuntimeError("Locking the storage failed: %s" %
  671. e) from e
  672. else:
  673. raise RuntimeError("Locking the storage failed: "
  674. "Unsupported operating system")
  675. cls._lock_file_locked = True
  676. try:
  677. yield
  678. # execute hook
  679. hook = cls.configuration.get("storage", "hook")
  680. if mode == "w" and hook:
  681. cls.logger.debug("Running hook")
  682. subprocess.check_call(
  683. hook % {"user": shlex.quote(user or "Anonymous")},
  684. shell=True, cwd=folder)
  685. finally:
  686. with cls._lock:
  687. if mode == "r":
  688. cls._readers -= 1
  689. else:
  690. cls._writer = False
  691. if cls._readers == 0:
  692. if os.name == "nt":
  693. handle = msvcrt.get_osfhandle(cls._lock_file.fileno())
  694. overlapped = Overlapped()
  695. if not unlock_file_ex(handle, 0, 1, 0, overlapped):
  696. raise RuntimeError("Unlocking the storage failed: "
  697. "%s" % ctypes.FormatError())
  698. elif os.name == "posix":
  699. try:
  700. fcntl.flock(cls._lock_file.fileno(), fcntl.LOCK_UN)
  701. except OSError as e:
  702. raise RuntimeError("Unlocking the storage failed: "
  703. "%s" % e) from e
  704. else:
  705. raise RuntimeError("Unlocking the storage failed: "
  706. "Unsupported operating system")
  707. cls._lock_file_locked = False
  708. if cls._waiters:
  709. cls._waiters[0].notify()
  710. if (cls.configuration.getboolean(
  711. "storage", "filesystem_close_lock_file") and
  712. cls._readers == 0 and not cls._waiters):
  713. cls._lock_file.close()
  714. cls._lock_file = None