storage.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476
  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 time
  28. from contextlib import contextmanager
  29. from hashlib import md5
  30. from importlib import import_module
  31. from uuid import uuid4
  32. import vobject
  33. def load(configuration, logger):
  34. """Load the storage manager chosen in configuration."""
  35. storage_type = configuration.get("storage", "type")
  36. if storage_type == "multifilesystem":
  37. collection_class = Collection
  38. else:
  39. collection_class = import_module(storage_type).Collection
  40. class CollectionCopy(collection_class):
  41. """Collection copy, avoids overriding the original class attributes."""
  42. CollectionCopy.configuration = configuration
  43. CollectionCopy.logger = logger
  44. return CollectionCopy
  45. MIMETYPES = {"VADDRESSBOOK": "text/vcard", "VCALENDAR": "text/calendar"}
  46. def get_etag(text):
  47. """Etag from collection or item."""
  48. etag = md5()
  49. etag.update(text.encode("utf-8"))
  50. return '"%s"' % etag.hexdigest()
  51. def sanitize_path(path):
  52. """Make path absolute with leading slash to prevent access to other data.
  53. Preserve a potential trailing slash.
  54. """
  55. trailing_slash = "/" if path.endswith("/") else ""
  56. path = posixpath.normpath(path)
  57. new_path = "/"
  58. for part in path.split("/"):
  59. if not part or part in (".", ".."):
  60. continue
  61. new_path = posixpath.join(new_path, part)
  62. trailing_slash = "" if new_path.endswith("/") else trailing_slash
  63. return new_path + trailing_slash
  64. def is_safe_filesystem_path_component(path):
  65. """Check if path is a single component of a filesystem path.
  66. Check that the path is safe to join too.
  67. """
  68. return (
  69. path and not os.path.splitdrive(path)[0] and
  70. not os.path.split(path)[0] and path not in (os.curdir, os.pardir))
  71. def path_to_filesystem(root, *paths):
  72. """Convert path to a local filesystem path relative to base_folder.
  73. Conversion is done in a secure manner, or raises ``ValueError``.
  74. """
  75. root = sanitize_path(root)
  76. paths = [sanitize_path(path).strip("/") for path in paths]
  77. safe_path = root
  78. for path in paths:
  79. if not path:
  80. continue
  81. for part in path.split("/"):
  82. if not is_safe_filesystem_path_component(part):
  83. raise ValueError("Unsafe path")
  84. safe_path = os.path.join(safe_path, part)
  85. return safe_path
  86. class Item:
  87. def __init__(self, collection, item, href, last_modified=None):
  88. self.collection = collection
  89. self.item = item
  90. self.href = href
  91. self.last_modified = last_modified
  92. def __getattr__(self, attr):
  93. return getattr(self.item, attr)
  94. @property
  95. def etag(self):
  96. return get_etag(self.serialize())
  97. class BaseCollection:
  98. # Overriden on copy by the "load" function
  99. configuration = None
  100. logger = None
  101. def __init__(self, path, principal=False):
  102. """Initialize the collection.
  103. ``path`` must be the normalized relative path of the collection, using
  104. the slash as the folder delimiter, with no leading nor trailing slash.
  105. """
  106. raise NotImplementedError
  107. @classmethod
  108. def discover(cls, path, depth="1"):
  109. """Discover a list of collections under the given ``path``.
  110. If ``depth`` is "0", only the actual object under ``path`` is
  111. returned.
  112. If ``depth`` is anything but "0", it is considered as "1" and direct
  113. children are included in the result. If ``include_container`` is
  114. ``True`` (the default), the containing object is included in the
  115. result.
  116. The ``path`` is relative.
  117. """
  118. raise NotImplementedError
  119. @property
  120. def etag(self):
  121. return get_etag(self.serialize())
  122. @classmethod
  123. def create_collection(cls, href, collection=None, tag=None):
  124. """Create a collection.
  125. ``collection`` is a list of vobject components.
  126. ``tag`` is the type of collection (VCALENDAR or VADDRESSBOOK). If
  127. ``tag`` is not given, it is guessed from the collection.
  128. """
  129. raise NotImplementedError
  130. def list(self):
  131. """List collection items."""
  132. raise NotImplementedError
  133. def get(self, href):
  134. """Fetch a single item."""
  135. raise NotImplementedError
  136. def get_multi(self, hrefs):
  137. """Fetch multiple items. Duplicate hrefs must be ignored.
  138. Functionally similar to ``get``, but might bring performance benefits
  139. on some storages when used cleverly.
  140. """
  141. for href in set(hrefs):
  142. yield self.get(href)
  143. def has(self, href):
  144. """Check if an item exists by its href.
  145. Functionally similar to ``get``, but might bring performance benefits
  146. on some storages when used cleverly.
  147. """
  148. return self.get(href) is not None
  149. def upload(self, href, vobject_item):
  150. """Upload a new item."""
  151. raise NotImplementedError
  152. def update(self, href, vobject_item, etag=None):
  153. """Update an item.
  154. Functionally similar to ``delete`` plus ``upload``, but might bring
  155. performance benefits on some storages when used cleverly.
  156. """
  157. self.delete(href, etag)
  158. self.upload(href, vobject_item)
  159. def delete(self, href=None, etag=None):
  160. """Delete an item.
  161. When ``href`` is ``None``, delete the collection.
  162. """
  163. raise NotImplementedError
  164. @contextmanager
  165. def at_once(self):
  166. """Set a context manager buffering the reads and writes."""
  167. # TODO: use in code
  168. yield
  169. def get_meta(self, key):
  170. """Get metadata value for collection."""
  171. raise NotImplementedError
  172. def set_meta(self, key, value):
  173. """Set metadata value for collection."""
  174. raise NotImplementedError
  175. @property
  176. def last_modified(self):
  177. """Get the HTTP-datetime of when the collection was modified."""
  178. raise NotImplementedError
  179. def serialize(self):
  180. """Get the unicode string representing the whole collection."""
  181. raise NotImplementedError
  182. class Collection(BaseCollection):
  183. """Collection stored in several files per calendar."""
  184. def __init__(self, path, principal=False):
  185. folder = os.path.expanduser(
  186. self.configuration.get("storage", "filesystem_folder"))
  187. # path should already be sanitized
  188. self.path = sanitize_path(path).strip("/")
  189. self.storage_encoding = self.configuration.get("encoding", "stock")
  190. self._filesystem_path = path_to_filesystem(folder, self.path)
  191. split_path = self.path.split("/")
  192. if len(split_path) > 1:
  193. # URL with at least one folder
  194. self.owner = split_path[0]
  195. else:
  196. self.owner = None
  197. self.is_principal = principal
  198. @classmethod
  199. def discover(cls, path, depth="1"):
  200. # path == None means wrong URL
  201. if path is None:
  202. return
  203. # path should already be sanitized
  204. sane_path = sanitize_path(path).strip("/")
  205. attributes = sane_path.split("/")
  206. if not attributes:
  207. return
  208. # Try to guess if the path leads to a collection or an item
  209. folder = os.path.expanduser(
  210. cls.configuration.get("storage", "filesystem_folder"))
  211. if not os.path.isdir(path_to_filesystem(folder, sane_path)):
  212. # path is not a collection
  213. if os.path.isfile(path_to_filesystem(folder, sane_path)):
  214. # path is an item
  215. attributes.pop()
  216. elif os.path.isdir(path_to_filesystem(folder, *attributes[:-1])):
  217. # path parent is a collection
  218. attributes.pop()
  219. # TODO: else: return?
  220. path = "/".join(attributes)
  221. principal = len(attributes) <= 1
  222. collection = cls(path, principal)
  223. yield collection
  224. if depth != "0":
  225. # TODO: fix this
  226. items = list(collection.list())
  227. if items:
  228. for item in items:
  229. yield collection.get(item[0])
  230. _, directories, _ = next(os.walk(collection._filesystem_path))
  231. for sub_path in directories:
  232. full_path = os.path.join(collection._filesystem_path, sub_path)
  233. if os.path.exists(path_to_filesystem(full_path)):
  234. yield cls(posixpath.join(path, sub_path))
  235. @classmethod
  236. def create_collection(cls, href, collection=None, tag=None):
  237. folder = os.path.expanduser(
  238. cls.configuration.get("storage", "filesystem_folder"))
  239. path = path_to_filesystem(folder, href)
  240. if not os.path.exists(path):
  241. os.makedirs(path)
  242. if not tag and collection:
  243. tag = collection[0].name
  244. self = cls(href)
  245. if tag == "VCALENDAR":
  246. self.set_meta("tag", "VCALENDAR")
  247. if collection:
  248. collection, = collection
  249. for content in ("vevent", "vtodo", "vjournal"):
  250. if content in collection.contents:
  251. for item in getattr(collection, "%s_list" % content):
  252. new_collection = vobject.iCalendar()
  253. new_collection.add(item)
  254. self.upload(uuid4().hex, new_collection)
  255. elif tag == "VCARD":
  256. self.set_meta("tag", "VADDRESSBOOK")
  257. if collection:
  258. for card in collection:
  259. self.upload(uuid4().hex, card)
  260. return self
  261. def list(self):
  262. try:
  263. hrefs = os.listdir(self._filesystem_path)
  264. except IOError:
  265. return
  266. for href in hrefs:
  267. path = os.path.join(self._filesystem_path, href)
  268. if not href.endswith(".props") and os.path.isfile(path):
  269. with open(path, encoding=self.storage_encoding) as fd:
  270. yield href, get_etag(fd.read())
  271. def get(self, href):
  272. if not href:
  273. return
  274. href = href.strip("{}").replace("/", "_")
  275. if is_safe_filesystem_path_component(href):
  276. path = os.path.join(self._filesystem_path, href)
  277. if os.path.isfile(path):
  278. with open(path, encoding=self.storage_encoding) as fd:
  279. text = fd.read()
  280. last_modified = time.strftime(
  281. "%a, %d %b %Y %H:%M:%S GMT",
  282. time.gmtime(os.path.getmtime(path)))
  283. return Item(self, vobject.readOne(text), href, last_modified)
  284. else:
  285. self.logger.debug(
  286. "Can't tranlate name safely to filesystem, "
  287. "skipping component: %s", href)
  288. def has(self, href):
  289. return self.get(href) is not None
  290. def upload(self, href, vobject_item):
  291. # TODO: use returned object in code
  292. if is_safe_filesystem_path_component(href):
  293. path = path_to_filesystem(self._filesystem_path, href)
  294. if not os.path.exists(path):
  295. item = Item(self, vobject_item, href)
  296. with open(path, "w", encoding=self.storage_encoding) as fd:
  297. fd.write(item.serialize())
  298. return item
  299. else:
  300. self.logger.debug(
  301. "Can't tranlate name safely to filesystem, "
  302. "skipping component: %s", href)
  303. def update(self, href, vobject_item, etag=None):
  304. # TODO: use etag in code and test it here
  305. # TODO: use returned object in code
  306. if is_safe_filesystem_path_component(href):
  307. path = path_to_filesystem(self._filesystem_path, href)
  308. if os.path.exists(path):
  309. with open(path, encoding=self.storage_encoding) as fd:
  310. text = fd.read()
  311. if not etag or etag == get_etag(text):
  312. item = Item(self, vobject_item, href)
  313. with open(path, "w", encoding=self.storage_encoding) as fd:
  314. fd.write(item.serialize())
  315. return item
  316. else:
  317. self.logger.debug(
  318. "Can't tranlate name safely to filesystem, "
  319. "skipping component: %s", href)
  320. def delete(self, href=None, etag=None):
  321. # TODO: use etag in code and test it here
  322. # TODO: use returned object in code
  323. if href is None:
  324. # Delete the collection
  325. if os.path.isdir(self._filesystem_path):
  326. shutil.rmtree(self._filesystem_path)
  327. props_path = self._filesystem_path + ".props"
  328. if os.path.isfile(props_path):
  329. os.remove(props_path)
  330. return
  331. elif is_safe_filesystem_path_component(href):
  332. # Delete an item
  333. path = path_to_filesystem(self._filesystem_path, href)
  334. if os.path.isfile(path):
  335. with open(path, encoding=self.storage_encoding) as fd:
  336. text = fd.read()
  337. if not etag or etag == get_etag(text):
  338. os.remove(path)
  339. return
  340. else:
  341. self.logger.debug(
  342. "Can't tranlate name safely to filesystem, "
  343. "skipping component: %s", href)
  344. @contextmanager
  345. def at_once(self):
  346. # TODO: use a file locker
  347. yield
  348. def get_meta(self, key):
  349. props_path = self._filesystem_path + ".props"
  350. if os.path.exists(props_path):
  351. with open(props_path, encoding=self.storage_encoding) as prop:
  352. return json.load(prop).get(key)
  353. def set_meta(self, key, value):
  354. props_path = self._filesystem_path + ".props"
  355. properties = {}
  356. if os.path.exists(props_path):
  357. with open(props_path, encoding=self.storage_encoding) as prop:
  358. properties.update(json.load(prop))
  359. if value:
  360. properties[key] = value
  361. else:
  362. properties.pop(key, None)
  363. with open(props_path, "w+", encoding=self.storage_encoding) as prop:
  364. json.dump(properties, prop)
  365. @property
  366. def last_modified(self):
  367. last = max([os.path.getmtime(self._filesystem_path)] + [
  368. os.path.getmtime(os.path.join(self._filesystem_path, filename))
  369. for filename in os.listdir(self._filesystem_path)] or [0])
  370. return time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(last))
  371. def serialize(self):
  372. items = []
  373. for href in os.listdir(self._filesystem_path):
  374. path = os.path.join(self._filesystem_path, href)
  375. if os.path.isfile(path) and not path.endswith(".props"):
  376. with open(path, encoding=self.storage_encoding) as fd:
  377. items.append(vobject.readOne(fd.read()))
  378. if self.get_meta("tag") == "VCALENDAR":
  379. collection = vobject.iCalendar()
  380. for item in items:
  381. for content in ("vevent", "vtodo", "vjournal"):
  382. if content in item.contents:
  383. collection.add(getattr(item, content))
  384. break
  385. return collection.serialize()
  386. elif self.get_meta("tag") == "VADDRESSBOOK":
  387. return "".join([item.serialize() for item in items])
  388. return ""