storage.py 13 KB

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