You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

397 lines
14 KiB

  1. from __future__ import unicode_literals
  2. import hashlib
  3. import json
  4. import os
  5. import posixpath
  6. import re
  7. from collections import OrderedDict
  8. from django.conf import settings
  9. from django.contrib.staticfiles.utils import check_settings, matches_patterns
  10. from django.core.cache import (
  11. InvalidCacheBackendError, cache as default_cache, caches,
  12. )
  13. from django.core.exceptions import ImproperlyConfigured
  14. from django.core.files.base import ContentFile
  15. from django.core.files.storage import FileSystemStorage, get_storage_class
  16. from django.utils.encoding import force_bytes, force_text
  17. from django.utils.functional import LazyObject
  18. from django.utils.six.moves.urllib.parse import (
  19. unquote, urldefrag, urlsplit, urlunsplit,
  20. )
  21. class StaticFilesStorage(FileSystemStorage):
  22. """
  23. Standard file system storage for static files.
  24. The defaults for ``location`` and ``base_url`` are
  25. ``STATIC_ROOT`` and ``STATIC_URL``.
  26. """
  27. def __init__(self, location=None, base_url=None, *args, **kwargs):
  28. if location is None:
  29. location = settings.STATIC_ROOT
  30. if base_url is None:
  31. base_url = settings.STATIC_URL
  32. check_settings(base_url)
  33. super(StaticFilesStorage, self).__init__(location, base_url,
  34. *args, **kwargs)
  35. # FileSystemStorage fallbacks to MEDIA_ROOT when location
  36. # is empty, so we restore the empty value.
  37. if not location:
  38. self.base_location = None
  39. self.location = None
  40. def path(self, name):
  41. if not self.location:
  42. raise ImproperlyConfigured("You're using the staticfiles app "
  43. "without having set the STATIC_ROOT "
  44. "setting to a filesystem path.")
  45. return super(StaticFilesStorage, self).path(name)
  46. class HashedFilesMixin(object):
  47. default_template = """url("%s")"""
  48. patterns = (
  49. ("*.css", (
  50. r"""(url\(['"]{0,1}\s*(.*?)["']{0,1}\))""",
  51. (r"""(@import\s*["']\s*(.*?)["'])""", """@import url("%s")"""),
  52. )),
  53. )
  54. def __init__(self, *args, **kwargs):
  55. super(HashedFilesMixin, self).__init__(*args, **kwargs)
  56. self._patterns = OrderedDict()
  57. self.hashed_files = {}
  58. for extension, patterns in self.patterns:
  59. for pattern in patterns:
  60. if isinstance(pattern, (tuple, list)):
  61. pattern, template = pattern
  62. else:
  63. template = self.default_template
  64. compiled = re.compile(pattern, re.IGNORECASE)
  65. self._patterns.setdefault(extension, []).append((compiled, template))
  66. def file_hash(self, name, content=None):
  67. """
  68. Returns a hash of the file with the given name and optional content.
  69. """
  70. if content is None:
  71. return None
  72. md5 = hashlib.md5()
  73. for chunk in content.chunks():
  74. md5.update(chunk)
  75. return md5.hexdigest()[:12]
  76. def hashed_name(self, name, content=None):
  77. parsed_name = urlsplit(unquote(name))
  78. clean_name = parsed_name.path.strip()
  79. opened = False
  80. if content is None:
  81. if not self.exists(clean_name):
  82. raise ValueError("The file '%s' could not be found with %r." %
  83. (clean_name, self))
  84. try:
  85. content = self.open(clean_name)
  86. except IOError:
  87. # Handle directory paths and fragments
  88. return name
  89. opened = True
  90. try:
  91. file_hash = self.file_hash(clean_name, content)
  92. finally:
  93. if opened:
  94. content.close()
  95. path, filename = os.path.split(clean_name)
  96. root, ext = os.path.splitext(filename)
  97. if file_hash is not None:
  98. file_hash = ".%s" % file_hash
  99. hashed_name = os.path.join(path, "%s%s%s" %
  100. (root, file_hash, ext))
  101. unparsed_name = list(parsed_name)
  102. unparsed_name[2] = hashed_name
  103. # Special casing for a @font-face hack, like url(myfont.eot?#iefix")
  104. # http://www.fontspring.com/blog/the-new-bulletproof-font-face-syntax
  105. if '?#' in name and not unparsed_name[3]:
  106. unparsed_name[2] += '?'
  107. return urlunsplit(unparsed_name)
  108. def url(self, name, force=False):
  109. """
  110. Returns the real URL in DEBUG mode.
  111. """
  112. if settings.DEBUG and not force:
  113. hashed_name, fragment = name, ''
  114. else:
  115. clean_name, fragment = urldefrag(name)
  116. if urlsplit(clean_name).path.endswith('/'): # don't hash paths
  117. hashed_name = name
  118. else:
  119. hashed_name = self.stored_name(clean_name)
  120. final_url = super(HashedFilesMixin, self).url(hashed_name)
  121. # Special casing for a @font-face hack, like url(myfont.eot?#iefix")
  122. # http://www.fontspring.com/blog/the-new-bulletproof-font-face-syntax
  123. query_fragment = '?#' in name # [sic!]
  124. if fragment or query_fragment:
  125. urlparts = list(urlsplit(final_url))
  126. if fragment and not urlparts[4]:
  127. urlparts[4] = fragment
  128. if query_fragment and not urlparts[3]:
  129. urlparts[2] += '?'
  130. final_url = urlunsplit(urlparts)
  131. return unquote(final_url)
  132. def url_converter(self, name, template=None):
  133. """
  134. Returns the custom URL converter for the given file name.
  135. """
  136. if template is None:
  137. template = self.default_template
  138. def converter(matchobj):
  139. """
  140. Converts the matched URL depending on the parent level (`..`)
  141. and returns the normalized and hashed URL using the url method
  142. of the storage.
  143. """
  144. matched, url = matchobj.groups()
  145. # Completely ignore http(s) prefixed URLs,
  146. # fragments and data-uri URLs
  147. if url.startswith(('#', 'http:', 'https:', 'data:', '//')):
  148. return matched
  149. name_parts = name.split(os.sep)
  150. # Using posix normpath here to remove duplicates
  151. url = posixpath.normpath(url)
  152. url_parts = url.split('/')
  153. parent_level, sub_level = url.count('..'), url.count('/')
  154. if url.startswith('/'):
  155. sub_level -= 1
  156. url_parts = url_parts[1:]
  157. if parent_level or not url.startswith('/'):
  158. start, end = parent_level + 1, parent_level
  159. else:
  160. if sub_level:
  161. if sub_level == 1:
  162. parent_level -= 1
  163. start, end = parent_level, 1
  164. else:
  165. start, end = 1, sub_level - 1
  166. joined_result = '/'.join(name_parts[:-start] + url_parts[end:])
  167. hashed_url = self.url(unquote(joined_result), force=True)
  168. file_name = hashed_url.split('/')[-1:]
  169. relative_url = '/'.join(url.split('/')[:-1] + file_name)
  170. # Return the hashed version to the file
  171. return template % unquote(relative_url)
  172. return converter
  173. def post_process(self, paths, dry_run=False, **options):
  174. """
  175. Post process the given OrderedDict of files (called from collectstatic).
  176. Processing is actually two separate operations:
  177. 1. renaming files to include a hash of their content for cache-busting,
  178. and copying those files to the target storage.
  179. 2. adjusting files which contain references to other files so they
  180. refer to the cache-busting filenames.
  181. If either of these are performed on a file, then that file is considered
  182. post-processed.
  183. """
  184. # don't even dare to process the files if we're in dry run mode
  185. if dry_run:
  186. return
  187. # where to store the new paths
  188. hashed_files = OrderedDict()
  189. # build a list of adjustable files
  190. matches = lambda path: matches_patterns(path, self._patterns.keys())
  191. adjustable_paths = [path for path in paths if matches(path)]
  192. # then sort the files by the directory level
  193. path_level = lambda name: len(name.split(os.sep))
  194. for name in sorted(paths.keys(), key=path_level, reverse=True):
  195. # use the original, local file, not the copied-but-unprocessed
  196. # file, which might be somewhere far away, like S3
  197. storage, path = paths[name]
  198. with storage.open(path) as original_file:
  199. # generate the hash with the original content, even for
  200. # adjustable files.
  201. hashed_name = self.hashed_name(name, original_file)
  202. # then get the original's file content..
  203. if hasattr(original_file, 'seek'):
  204. original_file.seek(0)
  205. hashed_file_exists = self.exists(hashed_name)
  206. processed = False
  207. # ..to apply each replacement pattern to the content
  208. if name in adjustable_paths:
  209. content = original_file.read().decode(settings.FILE_CHARSET)
  210. for patterns in self._patterns.values():
  211. for pattern, template in patterns:
  212. converter = self.url_converter(name, template)
  213. try:
  214. content = pattern.sub(converter, content)
  215. except ValueError as exc:
  216. yield name, None, exc
  217. if hashed_file_exists:
  218. self.delete(hashed_name)
  219. # then save the processed result
  220. content_file = ContentFile(force_bytes(content))
  221. saved_name = self._save(hashed_name, content_file)
  222. hashed_name = force_text(self.clean_name(saved_name))
  223. processed = True
  224. else:
  225. # or handle the case in which neither processing nor
  226. # a change to the original file happened
  227. if not hashed_file_exists:
  228. processed = True
  229. saved_name = self._save(hashed_name, original_file)
  230. hashed_name = force_text(self.clean_name(saved_name))
  231. # and then set the cache accordingly
  232. hashed_files[self.hash_key(name)] = hashed_name
  233. yield name, hashed_name, processed
  234. # Finally store the processed paths
  235. self.hashed_files.update(hashed_files)
  236. def clean_name(self, name):
  237. return name.replace('\\', '/')
  238. def hash_key(self, name):
  239. return name
  240. def stored_name(self, name):
  241. hash_key = self.hash_key(name)
  242. cache_name = self.hashed_files.get(hash_key)
  243. if cache_name is None:
  244. cache_name = self.clean_name(self.hashed_name(name))
  245. # store the hashed name if there was a miss, e.g.
  246. # when the files are still processed
  247. self.hashed_files[hash_key] = cache_name
  248. return cache_name
  249. class ManifestFilesMixin(HashedFilesMixin):
  250. manifest_version = '1.0' # the manifest format standard
  251. manifest_name = 'staticfiles.json'
  252. def __init__(self, *args, **kwargs):
  253. super(ManifestFilesMixin, self).__init__(*args, **kwargs)
  254. self.hashed_files = self.load_manifest()
  255. def read_manifest(self):
  256. try:
  257. with self.open(self.manifest_name) as manifest:
  258. return manifest.read().decode('utf-8')
  259. except IOError:
  260. return None
  261. def load_manifest(self):
  262. content = self.read_manifest()
  263. if content is None:
  264. return OrderedDict()
  265. try:
  266. stored = json.loads(content, object_pairs_hook=OrderedDict)
  267. except ValueError:
  268. pass
  269. else:
  270. version = stored.get('version')
  271. if version == '1.0':
  272. return stored.get('paths', OrderedDict())
  273. raise ValueError("Couldn't load manifest '%s' (version %s)" %
  274. (self.manifest_name, self.manifest_version))
  275. def post_process(self, *args, **kwargs):
  276. self.hashed_files = OrderedDict()
  277. all_post_processed = super(ManifestFilesMixin,
  278. self).post_process(*args, **kwargs)
  279. for post_processed in all_post_processed:
  280. yield post_processed
  281. self.save_manifest()
  282. def save_manifest(self):
  283. payload = {'paths': self.hashed_files, 'version': self.manifest_version}
  284. if self.exists(self.manifest_name):
  285. self.delete(self.manifest_name)
  286. contents = json.dumps(payload).encode('utf-8')
  287. self._save(self.manifest_name, ContentFile(contents))
  288. class _MappingCache(object):
  289. """
  290. A small dict-like wrapper for a given cache backend instance.
  291. """
  292. def __init__(self, cache):
  293. self.cache = cache
  294. def __setitem__(self, key, value):
  295. self.cache.set(key, value)
  296. def __getitem__(self, key):
  297. value = self.cache.get(key)
  298. if value is None:
  299. raise KeyError("Couldn't find a file name '%s'" % key)
  300. return value
  301. def clear(self):
  302. self.cache.clear()
  303. def update(self, data):
  304. self.cache.set_many(data)
  305. def get(self, key, default=None):
  306. try:
  307. return self[key]
  308. except KeyError:
  309. return default
  310. class CachedFilesMixin(HashedFilesMixin):
  311. def __init__(self, *args, **kwargs):
  312. super(CachedFilesMixin, self).__init__(*args, **kwargs)
  313. try:
  314. self.hashed_files = _MappingCache(caches['staticfiles'])
  315. except InvalidCacheBackendError:
  316. # Use the default backend
  317. self.hashed_files = _MappingCache(default_cache)
  318. def hash_key(self, name):
  319. key = hashlib.md5(force_bytes(self.clean_name(name))).hexdigest()
  320. return 'staticfiles:%s' % key
  321. class CachedStaticFilesStorage(CachedFilesMixin, StaticFilesStorage):
  322. """
  323. A static file system storage backend which also saves
  324. hashed copies of the files it saves.
  325. """
  326. pass
  327. class ManifestStaticFilesStorage(ManifestFilesMixin, StaticFilesStorage):
  328. """
  329. A static file system storage backend which also saves
  330. hashed copies of the files it saves.
  331. """
  332. pass
  333. class ConfiguredStorage(LazyObject):
  334. def _setup(self):
  335. self._wrapped = get_storage_class(settings.STATICFILES_STORAGE)()
  336. staticfiles_storage = ConfiguredStorage()