diff -r 058bb3dc685f -r 0b59724cb3f2 cubicweb/web/views/staticcontrollers.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cubicweb/web/views/staticcontrollers.py Sat Jan 16 13:48:51 2016 +0100 @@ -0,0 +1,272 @@ +# copyright 2003-2013 LOGILAB S.A. (Paris, FRANCE), all rights reserved. +# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr +# +# This file is part of CubicWeb. +# +# CubicWeb is free software: you can redistribute it and/or modify it under the +# terms of the GNU Lesser General Public License as published by the Free +# Software Foundation, either version 2.1 of the License, or (at your option) +# any later version. +# +# CubicWeb is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more +# details. +# +# You should have received a copy of the GNU Lesser General Public License along +# with CubicWeb. If not, see . +"""Set of static resources controllers for : + +- /data/... +- /static/... +- /fckeditor/... +""" + +import os +import os.path as osp +import hashlib +import mimetypes +import threading +import tempfile +from time import mktime +from datetime import datetime, timedelta +from logging import getLogger + +from cubicweb import Forbidden +from cubicweb.web import NotFound, Redirect +from cubicweb.web.http_headers import generateDateTime +from cubicweb.web.controller import Controller +from cubicweb.web.views.urlrewrite import URLRewriter + + + +class StaticFileController(Controller): + """an abtract class to serve static file + + Make sure to add your subclass to the STATIC_CONTROLLERS list""" + __abstract__ = True + directory_listing_allowed = False + + def max_age(self, path): + """max cache TTL""" + return 60*60*24*7 + + def static_file(self, path): + """Return full content of a static file. + + XXX iterable content would be better + """ + debugmode = self._cw.vreg.config.debugmode + if osp.isdir(path): + if self.directory_listing_allowed: + return u'' + raise Forbidden(path) + if not osp.isfile(path): + raise NotFound() + if not debugmode: + # XXX: Don't provide additional resource information to error responses + # + # the HTTP RFC recommends not going further than 1 year ahead + expires = datetime.now() + timedelta(seconds=self.max_age(path)) + self._cw.set_header('Expires', generateDateTime(mktime(expires.timetuple()))) + self._cw.set_header('Cache-Control', 'max-age=%s' % self.max_age(path)) + + # XXX system call to os.stats could be cached once and for all in + # production mode (where static files are not expected to change) + # + # Note that: we do a osp.isdir + osp.isfile before and a potential + # os.read after. Improving this specific call will not help + # + # Real production environment should use dedicated static file serving. + self._cw.set_header('last-modified', generateDateTime(os.stat(path).st_mtime)) + if self._cw.is_client_cache_valid(): + return '' + # XXX elif uri.startswith('/https/'): uri = uri[6:] + mimetype, encoding = mimetypes.guess_type(path) + if mimetype is None: + mimetype = 'application/octet-stream' + self._cw.set_content_type(mimetype, osp.basename(path), encoding) + with open(path, 'rb') as resource: + return resource.read() + + @property + def relpath(self): + """path of a requested file relative to the controller""" + path = self._cw.form.get('static_relative_path') + if path is None: + path = self._cw.relative_path(includeparams=True) + return path + + +class ConcatFilesHandler(object): + """Emulating the behavior of modconcat + + this serve multiple file as a single one. + """ + + def __init__(self, config): + self._resources = {} + self.config = config + self.logger = getLogger('cubicweb.web') + self.lock = threading.Lock() + + def _resource(self, path): + """get the resouce""" + try: + return self._resources[path] + except KeyError: + self._resources[path] = self.config.locate_resource(path) + return self._resources[path] + + def _up_to_date(self, filepath, paths): + """ + The concat-file is considered up-to-date if it exists. + In debug mode, an additional check is performed to make sure that + concat-file is more recent than all concatenated files + """ + if not osp.isfile(filepath): + return False + if self.config.debugmode: + concat_lastmod = os.stat(filepath).st_mtime + for path in paths: + dirpath, rid = self._resource(path) + if rid is None: + raise NotFound(path) + path = osp.join(dirpath, rid) + if os.stat(path).st_mtime > concat_lastmod: + return False + return True + + def build_filepath(self, paths): + """return the filepath that will be used to cache concatenation of `paths` + """ + _, ext = osp.splitext(paths[0]) + fname = 'cache_concat_' + hashlib.md5((';'.join(paths)).encode('ascii')).hexdigest() + ext + return osp.join(self.config.appdatahome, 'uicache', fname) + + def concat_cached_filepath(self, paths): + filepath = self.build_filepath(paths) + if not self._up_to_date(filepath, paths): + with self.lock: + if self._up_to_date(filepath, paths): + # first check could have raced with some other thread + # updating the file + return filepath + fd, tmpfile = tempfile.mkstemp(dir=os.path.dirname(filepath)) + try: + f = os.fdopen(fd, 'wb') + for path in paths: + dirpath, rid = self._resource(path) + if rid is None: + # In production mode log an error, do not return a 404 + # XXX the erroneous content is cached anyway + self.logger.error('concatenated data url error: %r file ' + 'does not exist', path) + if self.config.debugmode: + raise NotFound(path) + else: + with open(osp.join(dirpath, rid), 'rb') as source: + for line in source: + f.write(line) + f.write(b'\n') + f.close() + except: + os.remove(tmpfile) + raise + else: + os.rename(tmpfile, filepath) + return filepath + + +class DataController(StaticFileController): + """Controller in charge of serving static files in /data/ + + Handles mod_concat-like URLs. + """ + + __regid__ = 'data' + + def __init__(self, *args, **kwargs): + super(DataController, self).__init__(*args, **kwargs) + config = self._cw.vreg.config + self.base_datapath = config.data_relpath() + self.data_modconcat_basepath = '%s??' % self.base_datapath + self.concat_files_registry = ConcatFilesHandler(config) + + def publish(self, rset=None): + config = self._cw.vreg.config + # includeparams=True for modconcat-like urls + relpath = self.relpath + if relpath.startswith(self.data_modconcat_basepath): + paths = relpath[len(self.data_modconcat_basepath):].split(',') + filepath = self.concat_files_registry.concat_cached_filepath(paths) + else: + if not relpath.startswith(self.base_datapath): + # /data/foo, redirect to /data/{hash}/foo + prefix = 'data/' + relpath = relpath[len(prefix):] + raise Redirect(self._cw.data_url(relpath), 302) + # skip leading '/data/{hash}/' and url params + prefix = self.base_datapath + relpath = relpath[len(prefix):] + relpath = relpath.split('?', 1)[0] + dirpath, rid = config.locate_resource(relpath) + if dirpath is None: + raise NotFound() + filepath = osp.join(dirpath, rid) + return self.static_file(filepath) + + +class FCKEditorController(StaticFileController): + """Controller in charge of serving FCKEditor related file + + The motivational for a dedicated controller have been lost. + """ + + __regid__ = 'fckeditor' + + def publish(self, rset=None): + config = self._cw.vreg.config + if self._cw.https: + uiprops = config.https_uiprops + else: + uiprops = config.uiprops + relpath = self.relpath + if relpath.startswith('fckeditor/'): + relpath = relpath[len('fckeditor/'):] + relpath = relpath.split('?', 1)[0] + return self.static_file(osp.join(uiprops['FCKEDITOR_PATH'], relpath)) + + +class StaticDirectoryController(StaticFileController): + """Controller in charge of serving static file in /static/ + """ + __regid__ = 'static' + + def publish(self, rset=None): + staticdir = self._cw.vreg.config.static_directory + relpath = self.relpath[len(self.__regid__) + 1:] + return self.static_file(osp.join(staticdir, relpath)) + +STATIC_CONTROLLERS = [DataController, FCKEditorController, + StaticDirectoryController] + +class StaticControlerRewriter(URLRewriter): + """a quick and dirty rewritter in charge of server static file. + + This is a work around the flatness of url handling in cubicweb.""" + + __regid__ = 'static' + + priority = 10 + + def rewrite(self, req, uri): + for ctrl in STATIC_CONTROLLERS: + if uri.startswith('/%s/' % ctrl.__regid__): + break + else: + self.debug("not a static file uri: %s", uri) + raise KeyError(uri) + relpath = self._cw.relative_path(includeparams=False) + self._cw.form['static_relative_path'] = self._cw.relative_path(includeparams=True) + return ctrl.__regid__, None