storage.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666
  1. # This file is part of Radicale Server - Calendar Server
  2. # Copyright © 2014 Jean-Marc Martins
  3. # Copyright © 2012-2016 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 json
  24. import os
  25. import posixpath
  26. import shutil
  27. import stat
  28. import threading
  29. import time
  30. from contextlib import contextmanager
  31. from hashlib import md5
  32. from importlib import import_module
  33. from itertools import groupby
  34. from random import getrandbits
  35. from atomicwrites import AtomicWriter
  36. import vobject
  37. if os.name == "nt":
  38. import ctypes
  39. import ctypes.wintypes
  40. import msvcrt
  41. LOCKFILE_EXCLUSIVE_LOCK = 2
  42. if ctypes.sizeof(ctypes.c_void_p) == 4:
  43. ULONG_PTR = ctypes.c_uint32
  44. else:
  45. ULONG_PTR = ctypes.c_uint64
  46. class Overlapped(ctypes.Structure):
  47. _fields_ = [("internal", ULONG_PTR),
  48. ("internal_high", ULONG_PTR),
  49. ("offset", ctypes.wintypes.DWORD),
  50. ("offset_high", ctypes.wintypes.DWORD),
  51. ("h_event", ctypes.wintypes.HANDLE)]
  52. lock_file_ex = ctypes.windll.kernel32.LockFileEx
  53. lock_file_ex.argtypes = [ctypes.wintypes.HANDLE,
  54. ctypes.wintypes.DWORD,
  55. ctypes.wintypes.DWORD,
  56. ctypes.wintypes.DWORD,
  57. ctypes.wintypes.DWORD,
  58. ctypes.POINTER(Overlapped)]
  59. lock_file_ex.restype = ctypes.wintypes.BOOL
  60. unlock_file_ex = ctypes.windll.kernel32.UnlockFileEx
  61. unlock_file_ex.argtypes = [ctypes.wintypes.HANDLE,
  62. ctypes.wintypes.DWORD,
  63. ctypes.wintypes.DWORD,
  64. ctypes.wintypes.DWORD,
  65. ctypes.POINTER(Overlapped)]
  66. unlock_file_ex.restype = ctypes.wintypes.BOOL
  67. elif os.name == "posix":
  68. import fcntl
  69. def load(configuration, logger):
  70. """Load the storage manager chosen in configuration."""
  71. storage_type = configuration.get("storage", "type")
  72. if storage_type == "multifilesystem":
  73. collection_class = Collection
  74. else:
  75. collection_class = import_module(storage_type).Collection
  76. class CollectionCopy(collection_class):
  77. """Collection copy, avoids overriding the original class attributes."""
  78. CollectionCopy.configuration = configuration
  79. CollectionCopy.logger = logger
  80. return CollectionCopy
  81. MIMETYPES = {"VADDRESSBOOK": "text/vcard", "VCALENDAR": "text/calendar"}
  82. def get_etag(text):
  83. """Etag from collection or item."""
  84. etag = md5()
  85. etag.update(text.encode("utf-8"))
  86. return '"%s"' % etag.hexdigest()
  87. def is_safe_path_component(path):
  88. """Check if path is a single component of a path.
  89. Check that the path is safe to join too.
  90. """
  91. return path and "/" not in path and path not in (".", "..")
  92. def sanitize_path(path):
  93. """Make path absolute with leading slash to prevent access to other data.
  94. Preserve a potential trailing slash.
  95. """
  96. trailing_slash = "/" if path.endswith("/") else ""
  97. path = posixpath.normpath(path)
  98. new_path = "/"
  99. for part in path.split("/"):
  100. if not part or part in (".", ".."):
  101. continue
  102. new_path = posixpath.join(new_path, part)
  103. trailing_slash = "" if new_path.endswith("/") else trailing_slash
  104. return new_path + trailing_slash
  105. def is_safe_filesystem_path_component(path):
  106. """Check if path is a single component of a filesystem path.
  107. Check that the path is safe to join too.
  108. """
  109. return (
  110. path and not os.path.splitdrive(path)[0] and
  111. not os.path.split(path)[0] and path not in (os.curdir, os.pardir) and
  112. not path.startswith(".") and not path.endswith("~"))
  113. def path_to_filesystem(root, *paths):
  114. """Convert path to a local filesystem path relative to base_folder.
  115. `root` must be a secure filesystem path, it will be prepend to the path.
  116. Conversion of `paths` is done in a secure manner, or raises ``ValueError``.
  117. """
  118. paths = [sanitize_path(path).strip("/") for path in paths]
  119. safe_path = root
  120. for path in paths:
  121. if not path:
  122. continue
  123. for part in path.split("/"):
  124. if not is_safe_filesystem_path_component(part):
  125. raise ValueError(
  126. "Can't tranlate name safely to filesystem: %s" % part)
  127. safe_path = os.path.join(safe_path, part)
  128. return safe_path
  129. class _EncodedAtomicWriter(AtomicWriter):
  130. def __init__(self, path, encoding, mode="w", overwrite=True):
  131. self._encoding = encoding
  132. return super().__init__(path, mode, overwrite=True)
  133. def get_fileobject(self, **kwargs):
  134. return super().get_fileobject(encoding=self._encoding,
  135. prefix=".Radicale.tmp-", **kwargs)
  136. class Item:
  137. def __init__(self, collection, item, href, last_modified=None):
  138. self.collection = collection
  139. self.item = item
  140. self.href = href
  141. self.last_modified = last_modified
  142. def __getattr__(self, attr):
  143. return getattr(self.item, attr)
  144. @property
  145. def etag(self):
  146. return get_etag(self.serialize())
  147. class BaseCollection:
  148. # Overriden on copy by the "load" function
  149. configuration = None
  150. logger = None
  151. def __init__(self, path, principal=False):
  152. """Initialize the collection.
  153. ``path`` must be the normalized relative path of the collection, using
  154. the slash as the folder delimiter, with no leading nor trailing slash.
  155. """
  156. raise NotImplementedError
  157. @classmethod
  158. def discover(cls, path, depth="1"):
  159. """Discover a list of collections under the given ``path``.
  160. If ``depth`` is "0", only the actual object under ``path`` is
  161. returned.
  162. If ``depth`` is anything but "0", it is considered as "1" and direct
  163. children are included in the result. If ``include_container`` is
  164. ``True`` (the default), the containing object is included in the
  165. result.
  166. The ``path`` is relative.
  167. """
  168. raise NotImplementedError
  169. @property
  170. def etag(self):
  171. return get_etag(self.serialize())
  172. @classmethod
  173. def create_collection(cls, href, collection=None, tag=None):
  174. """Create a collection.
  175. ``collection`` is a list of vobject components.
  176. ``tag`` is the type of collection (VCALENDAR or VADDRESSBOOK). If
  177. ``tag`` is not given, it is guessed from the collection.
  178. """
  179. raise NotImplementedError
  180. def list(self):
  181. """List collection items."""
  182. raise NotImplementedError
  183. def get(self, href):
  184. """Fetch a single item."""
  185. raise NotImplementedError
  186. def get_multi(self, hrefs):
  187. """Fetch multiple items. Duplicate hrefs must be ignored.
  188. Functionally similar to ``get``, but might bring performance benefits
  189. on some storages when used cleverly.
  190. """
  191. for href in set(hrefs):
  192. yield self.get(href)
  193. def pre_filtered_list(self, filters):
  194. """List collection items with optional pre filtering.
  195. This could largely improve performance of reports depending on
  196. the filters and this implementation.
  197. This returns all event by default
  198. """
  199. return [self.get(href) for href, _ in self.list()]
  200. def has(self, href):
  201. """Check if an item exists by its href.
  202. Functionally similar to ``get``, but might bring performance benefits
  203. on some storages when used cleverly.
  204. """
  205. return self.get(href) is not None
  206. def upload(self, href, vobject_item):
  207. """Upload a new item."""
  208. raise NotImplementedError
  209. def update(self, href, vobject_item, etag=None):
  210. """Update an item.
  211. Functionally similar to ``delete`` plus ``upload``, but might bring
  212. performance benefits on some storages when used cleverly.
  213. """
  214. self.delete(href, etag)
  215. self.upload(href, vobject_item)
  216. def delete(self, href=None, etag=None):
  217. """Delete an item.
  218. When ``href`` is ``None``, delete the collection.
  219. """
  220. raise NotImplementedError
  221. def get_meta(self, key):
  222. """Get metadata value for collection."""
  223. raise NotImplementedError
  224. def set_meta(self, key, value):
  225. """Set metadata value for collection."""
  226. raise NotImplementedError
  227. @property
  228. def last_modified(self):
  229. """Get the HTTP-datetime of when the collection was modified."""
  230. raise NotImplementedError
  231. def serialize(self):
  232. """Get the unicode string representing the whole collection."""
  233. raise NotImplementedError
  234. @classmethod
  235. @contextmanager
  236. def acquire_lock(cls, mode):
  237. """Set a context manager to lock the whole storage.
  238. ``mode`` must either be "r" for shared access or "w" for exclusive
  239. access.
  240. """
  241. raise NotImplementedError
  242. class Collection(BaseCollection):
  243. """Collection stored in several files per calendar."""
  244. def __init__(self, path, principal=False):
  245. folder = os.path.expanduser(
  246. self.configuration.get("storage", "filesystem_folder"))
  247. # path should already be sanitized
  248. self.path = sanitize_path(path).strip("/")
  249. self.storage_encoding = self.configuration.get("encoding", "stock")
  250. self._filesystem_path = path_to_filesystem(folder, self.path)
  251. split_path = self.path.split("/")
  252. if len(split_path) > 1:
  253. # URL with at least one folder
  254. self.owner = split_path[0]
  255. else:
  256. self.owner = None
  257. self.is_principal = principal
  258. @contextmanager
  259. def _atomic_write(self, path, mode="w"):
  260. with _EncodedAtomicWriter(
  261. path, self.storage_encoding, mode).open() as fd:
  262. yield fd
  263. @classmethod
  264. def discover(cls, path, depth="1"):
  265. # path == None means wrong URL
  266. if path is None:
  267. return
  268. # path should already be sanitized
  269. sane_path = sanitize_path(path).strip("/")
  270. attributes = sane_path.split("/")
  271. if not attributes:
  272. return
  273. # Try to guess if the path leads to a collection or an item
  274. folder = os.path.expanduser(
  275. cls.configuration.get("storage", "filesystem_folder"))
  276. if not os.path.isdir(path_to_filesystem(folder, sane_path)):
  277. # path is not a collection
  278. if os.path.isfile(path_to_filesystem(folder, sane_path)):
  279. # path is an item
  280. attributes.pop()
  281. elif os.path.isdir(path_to_filesystem(folder, *attributes[:-1])):
  282. # path parent is a collection
  283. attributes.pop()
  284. # TODO: else: return?
  285. path = "/".join(attributes)
  286. principal = len(attributes) <= 1
  287. collection = cls(path, principal)
  288. yield collection
  289. if depth != "0":
  290. # TODO: fix this
  291. items = list(collection.list())
  292. if items:
  293. for item in items:
  294. yield collection.get(item[0])
  295. _, directories, _ = next(os.walk(collection._filesystem_path))
  296. for sub_path in directories:
  297. if not is_safe_filesystem_path_component(sub_path):
  298. cls.logger.debug("Skipping collection: %s", sub_path)
  299. continue
  300. full_path = os.path.join(collection._filesystem_path, sub_path)
  301. if os.path.exists(full_path):
  302. yield cls(posixpath.join(path, sub_path))
  303. @classmethod
  304. def create_collection(cls, href, collection=None, tag=None):
  305. folder = os.path.expanduser(
  306. cls.configuration.get("storage", "filesystem_folder"))
  307. path = path_to_filesystem(folder, href)
  308. if not os.path.exists(path):
  309. os.makedirs(path)
  310. if not tag and collection:
  311. tag = collection[0].name
  312. self = cls(href)
  313. if tag == "VCALENDAR":
  314. self.set_meta("tag", "VCALENDAR")
  315. if collection:
  316. collection, = collection
  317. items = []
  318. for content in ("vevent", "vtodo", "vjournal"):
  319. items.extend(getattr(collection, "%s_list" % content, []))
  320. def get_uid(item):
  321. return hasattr(item, "uid") and item.uid.value
  322. items_by_uid = groupby(
  323. sorted(items, key=get_uid), get_uid)
  324. for uid, items in items_by_uid:
  325. new_collection = vobject.iCalendar()
  326. for item in items:
  327. new_collection.add(item)
  328. file_name = hex(getrandbits(32))[2:]
  329. self.upload(file_name, new_collection)
  330. elif tag == "VCARD":
  331. self.set_meta("tag", "VADDRESSBOOK")
  332. if collection:
  333. for card in collection:
  334. file_name = hex(getrandbits(32))[2:]
  335. self.upload(file_name, card)
  336. return self
  337. def list(self):
  338. try:
  339. hrefs = os.listdir(self._filesystem_path)
  340. except IOError:
  341. return
  342. for href in hrefs:
  343. if not is_safe_filesystem_path_component(href):
  344. self.logger.debug("Skipping component: %s", href)
  345. continue
  346. path = os.path.join(self._filesystem_path, href)
  347. if not href.endswith(".props") and os.path.isfile(path):
  348. with open(path, encoding=self.storage_encoding) as fd:
  349. yield href, get_etag(fd.read())
  350. def get(self, href):
  351. if not href:
  352. return None
  353. href = href.strip("{}").replace("/", "_")
  354. if not is_safe_filesystem_path_component(href):
  355. self.logger.debug(
  356. "Can't tranlate name safely to filesystem: %s", href)
  357. return None
  358. path = path_to_filesystem(self._filesystem_path, href)
  359. if not os.path.isfile(path):
  360. return None
  361. with open(path, encoding=self.storage_encoding) as fd:
  362. text = fd.read()
  363. last_modified = time.strftime(
  364. "%a, %d %b %Y %H:%M:%S GMT",
  365. time.gmtime(os.path.getmtime(path)))
  366. return Item(self, vobject.readOne(text), href, last_modified)
  367. def has(self, href):
  368. return self.get(href) is not None
  369. def upload(self, href, vobject_item):
  370. # TODO: use returned object in code
  371. if not is_safe_filesystem_path_component(href):
  372. raise ValueError(
  373. "Can't tranlate name safely to filesystem: %s" % href)
  374. path = path_to_filesystem(self._filesystem_path, href)
  375. if os.path.exists(path):
  376. raise ValueError("Component already exists: %s" % href)
  377. item = Item(self, vobject_item, href)
  378. with self._atomic_write(path) as fd:
  379. fd.write(item.serialize())
  380. return item
  381. def update(self, href, vobject_item, etag=None):
  382. # TODO: use etag in code and test it here
  383. # TODO: use returned object in code
  384. if not is_safe_filesystem_path_component(href):
  385. raise ValueError(
  386. "Can't tranlate name safely to filesystem: %s" % href)
  387. path = path_to_filesystem(self._filesystem_path, href)
  388. if not os.path.isfile(path):
  389. raise ValueError("Component doesn't exist: %s" % href)
  390. with open(path, encoding=self.storage_encoding) as fd:
  391. text = fd.read()
  392. if etag and etag != get_etag(text):
  393. raise ValueError(
  394. "ETag doesn't match: %s != %s" % (etag, get_etag(text)))
  395. item = Item(self, vobject_item, href)
  396. with self._atomic_write(path) as fd:
  397. fd.write(item.serialize())
  398. return item
  399. def delete(self, href=None, etag=None):
  400. # TODO: use etag in code and test it here
  401. # TODO: use returned object in code
  402. if href is None:
  403. # Delete the collection
  404. if os.path.isdir(self._filesystem_path):
  405. shutil.rmtree(self._filesystem_path)
  406. props_path = self._filesystem_path + ".props"
  407. if os.path.isfile(props_path):
  408. os.remove(props_path)
  409. else:
  410. # Delete an item
  411. if not is_safe_filesystem_path_component(href):
  412. raise ValueError(
  413. "Can't tranlate name safely to filesystem: %s" % href)
  414. path = path_to_filesystem(self._filesystem_path, href)
  415. if not os.path.isfile(path):
  416. raise ValueError("Component doesn't exist: %s" % href)
  417. with open(path, encoding=self.storage_encoding) as fd:
  418. text = fd.read()
  419. if etag and etag != get_etag(text):
  420. raise ValueError(
  421. "ETag doesn't match: %s != %s" % (etag, get_etag(text)))
  422. os.remove(path)
  423. def get_meta(self, key):
  424. props_path = self._filesystem_path + ".props"
  425. if os.path.exists(props_path):
  426. with open(props_path, encoding=self.storage_encoding) as prop:
  427. return json.load(prop).get(key)
  428. def set_meta(self, key, value):
  429. props_path = self._filesystem_path + ".props"
  430. properties = {}
  431. if os.path.exists(props_path):
  432. with open(props_path, encoding=self.storage_encoding) as prop:
  433. properties.update(json.load(prop))
  434. if value:
  435. properties[key] = value
  436. else:
  437. properties.pop(key, None)
  438. with self._atomic_write(props_path, "w+") as prop:
  439. json.dump(properties, prop)
  440. @property
  441. def last_modified(self):
  442. last = max([os.path.getmtime(self._filesystem_path)] + [
  443. os.path.getmtime(os.path.join(self._filesystem_path, filename))
  444. for filename in os.listdir(self._filesystem_path)] or [0])
  445. return time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(last))
  446. def serialize(self):
  447. if not os.path.exists(self._filesystem_path):
  448. return None
  449. items = []
  450. for href in os.listdir(self._filesystem_path):
  451. if not is_safe_filesystem_path_component(href):
  452. self.logger.debug("Skipping component: %s", href)
  453. continue
  454. path = os.path.join(self._filesystem_path, href)
  455. if os.path.isfile(path) and not path.endswith(".props"):
  456. with open(path, encoding=self.storage_encoding) as fd:
  457. items.append(vobject.readOne(fd.read()))
  458. if self.get_meta("tag") == "VCALENDAR":
  459. collection = vobject.iCalendar()
  460. for item in items:
  461. for content in ("vevent", "vtodo", "vjournal"):
  462. if content in item.contents:
  463. for item_part in getattr(item, "%s_list" % content):
  464. collection.add(item_part)
  465. break
  466. return collection.serialize()
  467. elif self.get_meta("tag") == "VADDRESSBOOK":
  468. return "".join([item.serialize() for item in items])
  469. return ""
  470. _lock = threading.Lock()
  471. _waiters = []
  472. _lock_file = None
  473. _lock_file_locked = False
  474. _readers = 0
  475. _writer = False
  476. @classmethod
  477. @contextmanager
  478. def acquire_lock(cls, mode):
  479. def condition():
  480. if mode == "r":
  481. return not cls._writer
  482. else:
  483. return not cls._writer and cls._readers == 0
  484. if mode not in ("r", "w"):
  485. raise ValueError("Invalid lock mode: %s" % mode)
  486. # Use a primitive lock which only works within one process as a
  487. # precondition for inter-process file-based locking
  488. with cls._lock:
  489. if cls._waiters or not condition():
  490. # use FIFO for access requests
  491. waiter = threading.Condition(lock=cls._lock)
  492. cls._waiters.append(waiter)
  493. while True:
  494. waiter.wait()
  495. if condition():
  496. break
  497. cls._waiters.pop(0)
  498. if mode == "r":
  499. cls._readers += 1
  500. # notify additional potential readers
  501. if cls._waiters:
  502. cls._waiters[0].notify()
  503. else:
  504. cls._writer = True
  505. if not cls._lock_file:
  506. folder = os.path.expanduser(
  507. cls.configuration.get("storage", "filesystem_folder"))
  508. if not os.path.exists(folder):
  509. os.makedirs(folder, exist_ok=True)
  510. lock_path = os.path.join(folder, ".Radicale.lock")
  511. cls._lock_file = open(lock_path, "w+")
  512. # set access rights to a necessary minimum to prevent locking
  513. # by arbitrary users
  514. try:
  515. os.chmod(lock_path, stat.S_IWUSR | stat.S_IRUSR)
  516. except OSError:
  517. cls.logger.debug("Failed to set permissions on lock file")
  518. if not cls._lock_file_locked:
  519. if os.name == "nt":
  520. handle = msvcrt.get_osfhandle(cls._lock_file.fileno())
  521. flags = LOCKFILE_EXCLUSIVE_LOCK if mode == "w" else 0
  522. overlapped = Overlapped()
  523. if not lock_file_ex(handle, flags, 0, 1, 0, overlapped):
  524. cls.logger.debug("Locking not supported")
  525. elif os.name == "posix":
  526. _cmd = fcntl.LOCK_EX if mode == "w" else fcntl.LOCK_SH
  527. try:
  528. fcntl.lockf(cls._lock_file.fileno(), _cmd)
  529. except OSError:
  530. cls.logger.debug("Locking not supported")
  531. cls._lock_file_locked = True
  532. try:
  533. yield
  534. finally:
  535. with cls._lock:
  536. if mode == "r":
  537. cls._readers -= 1
  538. else:
  539. cls._writer = False
  540. if cls._readers == 0:
  541. if os.name == "nt":
  542. handle = msvcrt.get_osfhandle(cls._lock_file.fileno())
  543. overlapped = Overlapped()
  544. if not unlock_file_ex(handle, 0, 1, 0, overlapped):
  545. cls.logger.debug("Unlocking not supported")
  546. elif os.name == "posix":
  547. try:
  548. fcntl.lockf(cls._lock_file.fileno(), fcntl.LOCK_UN)
  549. except OSError:
  550. cls.logger.debug("Unlocking not supported")
  551. cls._lock_file_locked = False
  552. if cls._waiters:
  553. cls._waiters[0].notify()