storage.py 21 KB

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