storage.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  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
  20. configuration.
  21. Default storage uses one folder per collection and one file per collection
  22. entry.
  23. """
  24. import json
  25. import os
  26. import posixpath
  27. import shutil
  28. import sys
  29. import time
  30. from contextlib import contextmanager
  31. from . import config, ical, log
  32. def _load():
  33. """Load the storage manager chosen in configuration."""
  34. storage_type = config.get("storage", "type")
  35. if storage_type == "multifilesystem":
  36. module = sys.modules[__name__]
  37. else:
  38. __import__(storage_type)
  39. module = sys.modules[storage_type]
  40. ical.Collection = module.Collection
  41. FOLDER = os.path.expanduser(config.get("storage", "filesystem_folder"))
  42. FILESYSTEM_ENCODING = sys.getfilesystemencoding()
  43. def is_safe_path_component(path):
  44. """Check if path is a single component of a POSIX path.
  45. Check that the path is safe to join too.
  46. """
  47. if not path:
  48. return False
  49. if posixpath.split(path)[0]:
  50. return False
  51. if path in (".", ".."):
  52. return False
  53. return True
  54. def is_safe_filesystem_path_component(path):
  55. """Check if path is a single component of a filesystem path.
  56. Check that the path is safe to join too.
  57. """
  58. if not path:
  59. return False
  60. drive, _ = os.path.splitdrive(path)
  61. if drive:
  62. return False
  63. head, _ = os.path.split(path)
  64. if head:
  65. return False
  66. if path in (os.curdir, os.pardir):
  67. return False
  68. return True
  69. def path_to_filesystem(path):
  70. """Convert path to a local filesystem path relative to base_folder.
  71. Conversion is done in a secure manner, or raises ``ValueError``.
  72. """
  73. sane_path = ical.sanitize_path(path).strip("/")
  74. safe_path = FOLDER
  75. if not sane_path:
  76. return safe_path
  77. for part in sane_path.split("/"):
  78. if not is_safe_filesystem_path_component(part):
  79. log.LOGGER.debug(
  80. "Can't translate path safely to filesystem: %s", path)
  81. raise ValueError("Unsafe path")
  82. safe_path = os.path.join(safe_path, part)
  83. return safe_path
  84. @contextmanager
  85. def _open(path, mode="r"):
  86. """Open a file at ``path`` with encoding set in the configuration."""
  87. abs_path = os.path.join(FOLDER, path.replace("/", os.sep))
  88. with open(abs_path, mode, encoding=config.get("encoding", "stock")) as fd:
  89. yield fd
  90. class Collection(ical.Collection):
  91. """Collection stored in several files per calendar."""
  92. @property
  93. def _filesystem_path(self):
  94. """Absolute path of the file at local ``path``."""
  95. return path_to_filesystem(self.path)
  96. @property
  97. def _props_path(self):
  98. """Absolute path of the file storing the collection properties."""
  99. return self._filesystem_path + ".props"
  100. def _create_dirs(self):
  101. """Create folder storing the collection if absent."""
  102. if not os.path.exists(self._filesystem_path):
  103. os.makedirs(self._filesystem_path)
  104. def set_mimetype(self, mimetype):
  105. self._create_dirs()
  106. return super().set_mimetype(mimetype)
  107. def save(self, text):
  108. self._create_dirs()
  109. item_types = (
  110. ical.Timezone, ical.Event, ical.Todo, ical.Journal, ical.Card)
  111. for name, component in self._parse(text, item_types).items():
  112. if not is_safe_filesystem_path_component(name):
  113. log.LOGGER.debug(
  114. "Can't tranlate name safely to filesystem, "
  115. "skipping component: %s", name)
  116. continue
  117. filename = os.path.join(self._filesystem_path, name)
  118. with _open(filename, "w") as fd:
  119. fd.write(component.text)
  120. @property
  121. def headers(self):
  122. return (
  123. ical.Header("PRODID:-//Radicale//NONSGML Radicale Server//EN"),
  124. ical.Header("VERSION:%s" % self.version))
  125. def delete(self):
  126. shutil.rmtree(self._filesystem_path)
  127. os.remove(self._props_path)
  128. def remove(self, name):
  129. if not is_safe_filesystem_path_component(name):
  130. log.LOGGER.debug(
  131. "Can't tranlate name safely to filesystem, "
  132. "skipping component: %s", name)
  133. return
  134. if name in self.items:
  135. del self.items[name]
  136. filesystem_path = os.path.join(self._filesystem_path, name)
  137. if os.path.exists(filesystem_path):
  138. os.remove(filesystem_path)
  139. @property
  140. def text(self):
  141. components = (
  142. ical.Timezone, ical.Event, ical.Todo, ical.Journal, ical.Card)
  143. items = {}
  144. try:
  145. filenames = os.listdir(self._filesystem_path)
  146. except (OSError, IOError) as e:
  147. log.LOGGER.info(
  148. "Error while reading collection %r: %r" % (
  149. self._filesystem_path, e))
  150. return ""
  151. for filename in filenames:
  152. path = os.path.join(self._filesystem_path, filename)
  153. try:
  154. with _open(path) as fd:
  155. items.update(self._parse(fd.read(), components))
  156. except (OSError, IOError) as e:
  157. log.LOGGER.warning(
  158. "Error while reading item %r: %r" % (path, e))
  159. return ical.serialize(
  160. self.tag, self.headers, sorted(items.values(), key=lambda x: x.name))
  161. @classmethod
  162. def children(cls, path):
  163. filesystem_path = path_to_filesystem(path)
  164. _, directories, files = next(os.walk(filesystem_path))
  165. for filename in directories + files:
  166. # make sure that the local filename can be translated
  167. # into an internal path
  168. if not is_safe_path_component(filename):
  169. log.LOGGER.debug("Skipping unsupported filename: %s", filename)
  170. continue
  171. rel_filename = posixpath.join(path, filename)
  172. if cls.is_node(rel_filename) or cls.is_leaf(rel_filename):
  173. yield cls(rel_filename)
  174. @classmethod
  175. def is_node(cls, path):
  176. filesystem_path = path_to_filesystem(path)
  177. return (
  178. os.path.isdir(filesystem_path) and
  179. not os.path.exists(filesystem_path + ".props"))
  180. @classmethod
  181. def is_leaf(cls, path):
  182. filesystem_path = path_to_filesystem(path)
  183. return (
  184. os.path.isdir(filesystem_path) and
  185. os.path.exists(filesystem_path + ".props"))
  186. @property
  187. def last_modified(self):
  188. last = max([
  189. os.path.getmtime(os.path.join(self._filesystem_path, filename))
  190. for filename in os.listdir(self._filesystem_path)] or [0])
  191. return time.strftime("%a, %d %b %Y %H:%M:%S +0000", time.gmtime(last))
  192. @property
  193. @contextmanager
  194. def props(self):
  195. # On enter
  196. properties = {}
  197. if os.path.exists(self._props_path):
  198. with open(self._props_path) as prop_file:
  199. properties.update(json.load(prop_file))
  200. old_properties = properties.copy()
  201. yield properties
  202. # On exit
  203. if old_properties != properties:
  204. with open(self._props_path, "w") as prop_file:
  205. json.dump(properties, prop_file)