cubicweb/web/views/staticcontrollers.py
changeset 11057 0b59724cb3f2
parent 10852 e35d23686d1f
child 11870 3a84a79c4ed5
equal deleted inserted replaced
11052:058bb3dc685f 11057:0b59724cb3f2
       
     1 # copyright 2003-2013 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
       
     2 # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
       
     3 #
       
     4 # This file is part of CubicWeb.
       
     5 #
       
     6 # CubicWeb is free software: you can redistribute it and/or modify it under the
       
     7 # terms of the GNU Lesser General Public License as published by the Free
       
     8 # Software Foundation, either version 2.1 of the License, or (at your option)
       
     9 # any later version.
       
    10 #
       
    11 # CubicWeb is distributed in the hope that it will be useful, but WITHOUT
       
    12 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
       
    13 # FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
       
    14 # details.
       
    15 #
       
    16 # You should have received a copy of the GNU Lesser General Public License along
       
    17 # with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
       
    18 """Set of static resources controllers for :
       
    19 
       
    20 - /data/...
       
    21 - /static/...
       
    22 - /fckeditor/...
       
    23 """
       
    24 
       
    25 import os
       
    26 import os.path as osp
       
    27 import hashlib
       
    28 import mimetypes
       
    29 import threading
       
    30 import tempfile
       
    31 from time import mktime
       
    32 from datetime import datetime, timedelta
       
    33 from logging import getLogger
       
    34 
       
    35 from cubicweb import Forbidden
       
    36 from cubicweb.web import NotFound, Redirect
       
    37 from cubicweb.web.http_headers import generateDateTime
       
    38 from cubicweb.web.controller import Controller
       
    39 from cubicweb.web.views.urlrewrite import URLRewriter
       
    40 
       
    41 
       
    42 
       
    43 class StaticFileController(Controller):
       
    44     """an abtract class to serve static file
       
    45 
       
    46     Make sure to add your subclass to the STATIC_CONTROLLERS list"""
       
    47     __abstract__ = True
       
    48     directory_listing_allowed = False
       
    49 
       
    50     def max_age(self, path):
       
    51         """max cache TTL"""
       
    52         return 60*60*24*7
       
    53 
       
    54     def static_file(self, path):
       
    55         """Return full content of a static file.
       
    56 
       
    57         XXX iterable content would be better
       
    58         """
       
    59         debugmode = self._cw.vreg.config.debugmode
       
    60         if osp.isdir(path):
       
    61             if self.directory_listing_allowed:
       
    62                 return u''
       
    63             raise Forbidden(path)
       
    64         if not osp.isfile(path):
       
    65             raise NotFound()
       
    66         if not debugmode:
       
    67             # XXX: Don't provide additional resource information to error responses
       
    68             #
       
    69             # the HTTP RFC recommends not going further than 1 year ahead
       
    70             expires = datetime.now() + timedelta(seconds=self.max_age(path))
       
    71             self._cw.set_header('Expires', generateDateTime(mktime(expires.timetuple())))
       
    72             self._cw.set_header('Cache-Control', 'max-age=%s' % self.max_age(path))
       
    73 
       
    74         # XXX system call to os.stats could be cached once and for all in
       
    75         # production mode (where static files are not expected to change)
       
    76         #
       
    77         # Note that: we do a osp.isdir + osp.isfile before and a potential
       
    78         # os.read after. Improving this specific call will not help
       
    79         #
       
    80         # Real production environment should use dedicated static file serving.
       
    81         self._cw.set_header('last-modified', generateDateTime(os.stat(path).st_mtime))
       
    82         if self._cw.is_client_cache_valid():
       
    83             return ''
       
    84         # XXX elif uri.startswith('/https/'): uri = uri[6:]
       
    85         mimetype, encoding = mimetypes.guess_type(path)
       
    86         if mimetype is None:
       
    87             mimetype = 'application/octet-stream'
       
    88         self._cw.set_content_type(mimetype, osp.basename(path), encoding)
       
    89         with open(path, 'rb') as resource:
       
    90             return resource.read()
       
    91 
       
    92     @property
       
    93     def relpath(self):
       
    94         """path of a requested file relative to the controller"""
       
    95         path = self._cw.form.get('static_relative_path')
       
    96         if path is None:
       
    97             path = self._cw.relative_path(includeparams=True)
       
    98         return path
       
    99 
       
   100 
       
   101 class ConcatFilesHandler(object):
       
   102     """Emulating the behavior of modconcat
       
   103 
       
   104     this serve multiple file as a single one.
       
   105     """
       
   106 
       
   107     def __init__(self, config):
       
   108         self._resources = {}
       
   109         self.config = config
       
   110         self.logger = getLogger('cubicweb.web')
       
   111         self.lock = threading.Lock()
       
   112 
       
   113     def _resource(self, path):
       
   114         """get the resouce"""
       
   115         try:
       
   116             return self._resources[path]
       
   117         except KeyError:
       
   118             self._resources[path] = self.config.locate_resource(path)
       
   119             return self._resources[path]
       
   120 
       
   121     def _up_to_date(self, filepath, paths):
       
   122         """
       
   123         The concat-file is considered up-to-date if it exists.
       
   124         In debug mode, an additional check is performed to make sure that
       
   125         concat-file is more recent than all concatenated files
       
   126         """
       
   127         if not osp.isfile(filepath):
       
   128             return False
       
   129         if self.config.debugmode:
       
   130             concat_lastmod = os.stat(filepath).st_mtime
       
   131             for path in paths:
       
   132                 dirpath, rid = self._resource(path)
       
   133                 if rid is None:
       
   134                     raise NotFound(path)
       
   135                 path = osp.join(dirpath, rid)
       
   136                 if os.stat(path).st_mtime > concat_lastmod:
       
   137                     return False
       
   138         return True
       
   139 
       
   140     def build_filepath(self, paths):
       
   141         """return the filepath that will be used to cache concatenation of `paths`
       
   142         """
       
   143         _, ext = osp.splitext(paths[0])
       
   144         fname = 'cache_concat_' + hashlib.md5((';'.join(paths)).encode('ascii')).hexdigest() + ext
       
   145         return osp.join(self.config.appdatahome, 'uicache', fname)
       
   146 
       
   147     def concat_cached_filepath(self, paths):
       
   148         filepath = self.build_filepath(paths)
       
   149         if not self._up_to_date(filepath, paths):
       
   150             with self.lock:
       
   151                 if self._up_to_date(filepath, paths):
       
   152                     # first check could have raced with some other thread
       
   153                     # updating the file
       
   154                     return filepath
       
   155                 fd, tmpfile = tempfile.mkstemp(dir=os.path.dirname(filepath))
       
   156                 try:
       
   157                     f = os.fdopen(fd, 'wb')
       
   158                     for path in paths:
       
   159                         dirpath, rid = self._resource(path)
       
   160                         if rid is None:
       
   161                             # In production mode log an error, do not return a 404
       
   162                             # XXX the erroneous content is cached anyway
       
   163                             self.logger.error('concatenated data url error: %r file '
       
   164                                               'does not exist', path)
       
   165                             if self.config.debugmode:
       
   166                                 raise NotFound(path)
       
   167                         else:
       
   168                             with open(osp.join(dirpath, rid), 'rb') as source:
       
   169                                 for line in source:
       
   170                                     f.write(line)
       
   171                             f.write(b'\n')
       
   172                     f.close()
       
   173                 except:
       
   174                     os.remove(tmpfile)
       
   175                     raise
       
   176                 else:
       
   177                     os.rename(tmpfile, filepath)
       
   178         return filepath
       
   179 
       
   180 
       
   181 class DataController(StaticFileController):
       
   182     """Controller in charge of serving static files in /data/
       
   183 
       
   184     Handles mod_concat-like URLs.
       
   185     """
       
   186 
       
   187     __regid__ = 'data'
       
   188 
       
   189     def __init__(self, *args, **kwargs):
       
   190         super(DataController, self).__init__(*args, **kwargs)
       
   191         config = self._cw.vreg.config
       
   192         self.base_datapath = config.data_relpath()
       
   193         self.data_modconcat_basepath = '%s??' % self.base_datapath
       
   194         self.concat_files_registry = ConcatFilesHandler(config)
       
   195 
       
   196     def publish(self, rset=None):
       
   197         config = self._cw.vreg.config
       
   198         # includeparams=True for modconcat-like urls
       
   199         relpath = self.relpath
       
   200         if relpath.startswith(self.data_modconcat_basepath):
       
   201             paths = relpath[len(self.data_modconcat_basepath):].split(',')
       
   202             filepath = self.concat_files_registry.concat_cached_filepath(paths)
       
   203         else:
       
   204             if not relpath.startswith(self.base_datapath):
       
   205                 # /data/foo, redirect to /data/{hash}/foo
       
   206                 prefix = 'data/'
       
   207                 relpath = relpath[len(prefix):]
       
   208                 raise Redirect(self._cw.data_url(relpath), 302)
       
   209             # skip leading '/data/{hash}/' and url params
       
   210             prefix = self.base_datapath
       
   211             relpath = relpath[len(prefix):]
       
   212             relpath = relpath.split('?', 1)[0]
       
   213             dirpath, rid = config.locate_resource(relpath)
       
   214             if dirpath is None:
       
   215                 raise NotFound()
       
   216             filepath = osp.join(dirpath, rid)
       
   217         return self.static_file(filepath)
       
   218 
       
   219 
       
   220 class FCKEditorController(StaticFileController):
       
   221     """Controller in charge of serving FCKEditor related file
       
   222 
       
   223     The motivational for a dedicated controller have been lost.
       
   224     """
       
   225 
       
   226     __regid__ = 'fckeditor'
       
   227 
       
   228     def publish(self, rset=None):
       
   229         config = self._cw.vreg.config
       
   230         if self._cw.https:
       
   231             uiprops = config.https_uiprops
       
   232         else:
       
   233             uiprops = config.uiprops
       
   234         relpath = self.relpath
       
   235         if relpath.startswith('fckeditor/'):
       
   236             relpath = relpath[len('fckeditor/'):]
       
   237         relpath = relpath.split('?', 1)[0]
       
   238         return self.static_file(osp.join(uiprops['FCKEDITOR_PATH'], relpath))
       
   239 
       
   240 
       
   241 class StaticDirectoryController(StaticFileController):
       
   242     """Controller in charge of serving static file in /static/
       
   243     """
       
   244     __regid__ = 'static'
       
   245 
       
   246     def publish(self, rset=None):
       
   247         staticdir = self._cw.vreg.config.static_directory
       
   248         relpath = self.relpath[len(self.__regid__) + 1:]
       
   249         return self.static_file(osp.join(staticdir, relpath))
       
   250 
       
   251 STATIC_CONTROLLERS = [DataController, FCKEditorController,
       
   252                       StaticDirectoryController]
       
   253 
       
   254 class StaticControlerRewriter(URLRewriter):
       
   255     """a quick and dirty rewritter in charge of server static file.
       
   256 
       
   257     This is a work around the flatness of url handling in cubicweb."""
       
   258 
       
   259     __regid__ = 'static'
       
   260 
       
   261     priority = 10
       
   262 
       
   263     def rewrite(self, req, uri):
       
   264         for ctrl in STATIC_CONTROLLERS:
       
   265             if uri.startswith('/%s/' % ctrl.__regid__):
       
   266                 break
       
   267         else:
       
   268             self.debug("not a static file uri: %s", uri)
       
   269             raise KeyError(uri)
       
   270         relpath = self._cw.relative_path(includeparams=False)
       
   271         self._cw.form['static_relative_path'] = self._cw.relative_path(includeparams=True)
       
   272         return ctrl.__regid__, None