storage.py 20 KB

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