web/request.py
changeset 11057 0b59724cb3f2
parent 11052 058bb3dc685f
child 11058 23eb30449fe5
equal deleted inserted replaced
11052:058bb3dc685f 11057:0b59724cb3f2
     1 # copyright 2003-2014 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 """abstract class for http request"""
       
    19 
       
    20 __docformat__ = "restructuredtext en"
       
    21 
       
    22 import time
       
    23 import random
       
    24 import base64
       
    25 from hashlib import sha1 # pylint: disable=E0611
       
    26 from calendar import timegm
       
    27 from datetime import date, datetime
       
    28 from warnings import warn
       
    29 from io import BytesIO
       
    30 
       
    31 from six import PY2, binary_type, text_type, string_types
       
    32 from six.moves import http_client
       
    33 from six.moves.urllib.parse import urlsplit, quote as urlquote
       
    34 from six.moves.http_cookies import SimpleCookie
       
    35 
       
    36 from rql.utils import rqlvar_maker
       
    37 
       
    38 from logilab.common.decorators import cached
       
    39 from logilab.common.deprecation import deprecated
       
    40 from logilab.mtconverter import xml_escape
       
    41 
       
    42 from cubicweb import AuthenticationError
       
    43 from cubicweb.req import RequestSessionBase
       
    44 from cubicweb.uilib import remove_html_tags, js
       
    45 from cubicweb.utils import HTMLHead, make_uid
       
    46 from cubicweb.view import TRANSITIONAL_DOCTYPE_NOEXT
       
    47 from cubicweb.web import (INTERNAL_FIELD_VALUE, LOGGER, NothingToEdit,
       
    48                           RequestError, StatusResponse)
       
    49 from cubicweb.web.httpcache import get_validators
       
    50 from cubicweb.web.http_headers import Headers, Cookie, parseDateTime
       
    51 
       
    52 _MARKER = object()
       
    53 
       
    54 def build_cb_uid(seed):
       
    55     sha = sha1(('%s%s%s' % (time.time(), seed, random.random())).encode('ascii'))
       
    56     return 'cb_%s' % (sha.hexdigest())
       
    57 
       
    58 
       
    59 def list_form_param(form, param, pop=False):
       
    60     """get param from form parameters and return its value as a list,
       
    61     skipping internal markers if any
       
    62 
       
    63     * if the parameter isn't defined, return an empty list
       
    64     * if the parameter is a single (unicode) value, return a list
       
    65       containing that value
       
    66     * if the parameter is already a list or tuple, just skip internal
       
    67       markers
       
    68 
       
    69     if pop is True, the parameter is removed from the form dictionary
       
    70     """
       
    71     if pop:
       
    72         try:
       
    73             value = form.pop(param)
       
    74         except KeyError:
       
    75             return []
       
    76     else:
       
    77         value = form.get(param, ())
       
    78     if value is None:
       
    79         value = ()
       
    80     elif not isinstance(value, (list, tuple)):
       
    81         value = [value]
       
    82     return [v for v in value if v != INTERNAL_FIELD_VALUE]
       
    83 
       
    84 
       
    85 class Counter(object):
       
    86     """A picklable counter object, usable for e.g. page tab index count"""
       
    87     __slots__ = ('value',)
       
    88 
       
    89     def __init__(self, initialvalue=0):
       
    90         self.value = initialvalue
       
    91 
       
    92     def __call__(self):
       
    93         value = self.value
       
    94         self.value += 1
       
    95         return value
       
    96 
       
    97     def __getstate__(self):
       
    98         return {'value': self.value}
       
    99 
       
   100     def __setstate__(self, state):
       
   101         self.value = state['value']
       
   102 
       
   103 
       
   104 class _CubicWebRequestBase(RequestSessionBase):
       
   105     """abstract HTTP request, should be extended according to the HTTP backend
       
   106     Immutable attributes that describe the received query and generic configuration
       
   107     """
       
   108     ajax_request = False # to be set to True by ajax controllers
       
   109 
       
   110     def __init__(self, vreg, https=False, form=None, headers=None):
       
   111         """
       
   112         :vreg: Vregistry,
       
   113         :https: boolean, s this a https request
       
   114         :form: Forms value
       
   115         :headers: dict, request header
       
   116         """
       
   117         super(_CubicWebRequestBase, self).__init__(vreg)
       
   118         #: (Boolean) Is this an https request.
       
   119         self.https = https
       
   120         #: User interface property (vary with https) (see :ref:`uiprops`)
       
   121         self.uiprops = None
       
   122         #: url for serving datadir (vary with https) (see :ref:`resources`)
       
   123         self.datadir_url = None
       
   124         if https and vreg.config.https_uiprops is not None:
       
   125             self.uiprops = vreg.config.https_uiprops
       
   126         else:
       
   127             self.uiprops = vreg.config.uiprops
       
   128         if https and vreg.config.https_datadir_url is not None:
       
   129             self.datadir_url = vreg.config.https_datadir_url
       
   130         else:
       
   131             self.datadir_url = vreg.config.datadir_url
       
   132         #: enable UStringIO's write tracing
       
   133         self.tracehtml = False
       
   134         if vreg.config.debugmode:
       
   135             self.tracehtml = bool(form.pop('_cwtracehtml', False))
       
   136         #: raw html headers that can be added from any view
       
   137         self.html_headers = HTMLHead(self, tracewrites=self.tracehtml)
       
   138         #: received headers
       
   139         self._headers_in = Headers()
       
   140         if headers is not None:
       
   141             for k, v in headers.items():
       
   142                 self._headers_in.addRawHeader(k, v)
       
   143         #: form parameters
       
   144         self.setup_params(form)
       
   145         #: received body
       
   146         self.content = BytesIO()
       
   147         # prepare output header
       
   148         #: Header used for the final response
       
   149         self.headers_out = Headers()
       
   150         #: HTTP status use by the final response
       
   151         self.status_out  = 200
       
   152         # set up language based on request headers or site default (we don't
       
   153         # have a user yet, and might not get one)
       
   154         self.set_user_language(None)
       
   155         #: dictionary that may be used to store request data that has to be
       
   156         #: shared among various components used to publish the request (views,
       
   157         #: controller, application...)
       
   158         self.data = {}
       
   159         self._search_state = None
       
   160         #: page id, set by htmlheader template
       
   161         self.pageid = None
       
   162         self._set_pageid()
       
   163 
       
   164     def _set_pageid(self):
       
   165         """initialize self.pageid
       
   166         if req.form provides a specific pageid, use it, otherwise build a
       
   167         new one.
       
   168         """
       
   169         pid = self.form.get('pageid')
       
   170         if pid is None:
       
   171             pid = make_uid(id(self))
       
   172             self.html_headers.define_var('pageid', pid, override=False)
       
   173         self.pageid = pid
       
   174 
       
   175     def _get_json_request(self):
       
   176         warn('[3.15] self._cw.json_request is deprecated, use self._cw.ajax_request instead',
       
   177              DeprecationWarning, stacklevel=2)
       
   178         return self.ajax_request
       
   179     def _set_json_request(self, value):
       
   180         warn('[3.15] self._cw.json_request is deprecated, use self._cw.ajax_request instead',
       
   181              DeprecationWarning, stacklevel=2)
       
   182         self.ajax_request = value
       
   183     json_request = property(_get_json_request, _set_json_request)
       
   184 
       
   185     def _base_url(self, secure=None):
       
   186         """return the root url of the instance
       
   187 
       
   188         secure = False -> base-url
       
   189         secure = None  -> https-url if req.https
       
   190         secure = True  -> https if it exist
       
   191         """
       
   192         if secure is None:
       
   193             secure = self.https
       
   194         base_url = None
       
   195         if secure:
       
   196             base_url = self.vreg.config.get('https-url')
       
   197         if base_url is None:
       
   198             base_url = super(_CubicWebRequestBase, self)._base_url()
       
   199         return base_url
       
   200 
       
   201     @property
       
   202     def authmode(self):
       
   203         """Authentification mode of the instance
       
   204         (see :ref:`WebServerConfig`)"""
       
   205         return self.vreg.config['auth-mode']
       
   206 
       
   207     # Various variable generator.
       
   208 
       
   209     @property
       
   210     def varmaker(self):
       
   211         """the rql varmaker is exposed both as a property and as the
       
   212         set_varmaker function since we've two use cases:
       
   213 
       
   214         * accessing the req.varmaker property to get a new variable name
       
   215 
       
   216         * calling req.set_varmaker() to ensure a varmaker is set for later ajax
       
   217           calls sharing our .pageid
       
   218         """
       
   219         return self.set_varmaker()
       
   220 
       
   221     def next_tabindex(self):
       
   222         nextfunc = self.get_page_data('nexttabfunc')
       
   223         if nextfunc is None:
       
   224             nextfunc = Counter(1)
       
   225             self.set_page_data('nexttabfunc', nextfunc)
       
   226         return nextfunc()
       
   227 
       
   228     def set_varmaker(self):
       
   229         varmaker = self.get_page_data('rql_varmaker')
       
   230         if varmaker is None:
       
   231             varmaker = rqlvar_maker()
       
   232             self.set_page_data('rql_varmaker', varmaker)
       
   233         return varmaker
       
   234 
       
   235     # input form parameters management ########################################
       
   236 
       
   237     # common form parameters which should be protected against html values
       
   238     # XXX can't add 'eid' for instance since it may be multivalued
       
   239     # dont put rql as well, if query contains < and > it will be corrupted!
       
   240     no_script_form_params = set(('vid',
       
   241                                  'etype',
       
   242                                  'vtitle', 'title',
       
   243                                  '__redirectvid', '__redirectrql'))
       
   244 
       
   245     def setup_params(self, params):
       
   246         """WARNING: we're intentionally leaving INTERNAL_FIELD_VALUE here
       
   247 
       
   248         subclasses should overrides to
       
   249         """
       
   250         self.form = {}
       
   251         if params is None:
       
   252             return
       
   253         encoding = self.encoding
       
   254         for param, val in params.items():
       
   255             if isinstance(val, (tuple, list)):
       
   256                 if PY2:
       
   257                     val = [unicode(x, encoding) for x in val]
       
   258                 if len(val) == 1:
       
   259                     val = val[0]
       
   260             elif PY2 and isinstance(val, str):
       
   261                 val = unicode(val, encoding)
       
   262             if param in self.no_script_form_params and val:
       
   263                 val = self.no_script_form_param(param, val)
       
   264             if param == '_cwmsgid':
       
   265                 self.set_message_id(val)
       
   266             else:
       
   267                 self.form[param] = val
       
   268 
       
   269     def no_script_form_param(self, param, value):
       
   270         """ensure there is no script in a user form param
       
   271 
       
   272         by default return a cleaned string instead of raising a security
       
   273         exception
       
   274 
       
   275         this method should be called on every user input (form at least) fields
       
   276         that are at some point inserted in a generated html page to protect
       
   277         against script kiddies
       
   278         """
       
   279         # safety belt for strange urls like http://...?vtitle=yo&vtitle=yo
       
   280         if isinstance(value, (list, tuple)):
       
   281             self.error('no_script_form_param got a list (%s). Who generated the URL ?',
       
   282                        repr(value))
       
   283             value = value[0]
       
   284         return remove_html_tags(value)
       
   285 
       
   286     def list_form_param(self, param, form=None, pop=False):
       
   287         """get param from form parameters and return its value as a list,
       
   288         skipping internal markers if any
       
   289 
       
   290         * if the parameter isn't defined, return an empty list
       
   291         * if the parameter is a single (unicode) value, return a list
       
   292           containing that value
       
   293         * if the parameter is already a list or tuple, just skip internal
       
   294           markers
       
   295 
       
   296         if pop is True, the parameter is removed from the form dictionary
       
   297         """
       
   298         if form is None:
       
   299             form = self.form
       
   300         return list_form_param(form, param, pop)
       
   301 
       
   302     def reset_headers(self):
       
   303         """used by AutomaticWebTest to clear html headers between tests on
       
   304         the same resultset
       
   305         """
       
   306         self.html_headers = HTMLHead(self)
       
   307         return self
       
   308 
       
   309     # web state helpers #######################################################
       
   310 
       
   311     @property
       
   312     def message(self):
       
   313         try:
       
   314             return self.session.data.pop(self._msgid, u'')
       
   315         except AttributeError:
       
   316             try:
       
   317                 return self._msg
       
   318             except AttributeError:
       
   319                 return None
       
   320 
       
   321     def set_message(self, msg):
       
   322         assert isinstance(msg, text_type)
       
   323         self.reset_message()
       
   324         self._msg = msg
       
   325 
       
   326     def set_message_id(self, msgid):
       
   327         self._msgid = msgid
       
   328 
       
   329     @cached
       
   330     def redirect_message_id(self):
       
   331         return make_uid()
       
   332 
       
   333     def set_redirect_message(self, msg):
       
   334         # TODO - this should probably be merged with append_to_redirect_message
       
   335         assert isinstance(msg, text_type)
       
   336         msgid = self.redirect_message_id()
       
   337         self.session.data[msgid] = msg
       
   338         return msgid
       
   339 
       
   340     def append_to_redirect_message(self, msg):
       
   341         msgid = self.redirect_message_id()
       
   342         currentmsg = self.session.data.get(msgid)
       
   343         if currentmsg is not None:
       
   344             currentmsg = u'%s %s' % (currentmsg, msg)
       
   345         else:
       
   346             currentmsg = msg
       
   347         self.session.data[msgid] = currentmsg
       
   348         return msgid
       
   349 
       
   350     def reset_message(self):
       
   351         if hasattr(self, '_msg'):
       
   352             del self._msg
       
   353         if hasattr(self, '_msgid'):
       
   354             self.session.data.pop(self._msgid, u'')
       
   355             del self._msgid
       
   356 
       
   357     def _load_search_state(self, searchstate):
       
   358         if searchstate is None or searchstate == 'normal':
       
   359             self._search_state = ('normal',)
       
   360         else:
       
   361             self._search_state = ('linksearch', searchstate.split(':'))
       
   362             assert len(self._search_state[-1]) == 4, 'invalid searchstate'
       
   363 
       
   364     @property
       
   365     def search_state(self):
       
   366         """search state: 'normal' or 'linksearch' (i.e. searching for an object
       
   367         to create a relation with another)"""
       
   368         if self._search_state is None:
       
   369             searchstate = self.session.data.get('search_state', 'normal')
       
   370             self._load_search_state(searchstate)
       
   371         return self._search_state
       
   372 
       
   373     @search_state.setter
       
   374     def search_state(self, searchstate):
       
   375         self._search_state = searchstate
       
   376 
       
   377     def update_search_state(self):
       
   378         """update the current search state if needed"""
       
   379         searchstate = self.form.get('__mode')
       
   380         if searchstate:
       
   381             self.set_search_state(searchstate)
       
   382 
       
   383     def set_search_state(self, searchstate):
       
   384         """set a new search state"""
       
   385         self.session.data['search_state'] = searchstate
       
   386         self._load_search_state(searchstate)
       
   387 
       
   388     def match_search_state(self, rset):
       
   389         """when searching an entity to create a relation, return True if entities in
       
   390         the given rset may be used as relation end
       
   391         """
       
   392         try:
       
   393             searchedtype = self.search_state[1][-1]
       
   394         except IndexError:
       
   395             return False # no searching for association
       
   396         for etype in rset.column_types(0):
       
   397             if etype != searchedtype:
       
   398                 return False
       
   399         return True
       
   400 
       
   401     # web edition helpers #####################################################
       
   402 
       
   403     @cached # so it's writed only once
       
   404     def fckeditor_config(self):
       
   405         fckeditor_url = self.build_url('fckeditor/fckeditor.js')
       
   406         self.add_js(fckeditor_url, localfile=False)
       
   407         self.html_headers.define_var('fcklang', self.lang)
       
   408         self.html_headers.define_var('fckconfigpath',
       
   409                                      self.data_url('cubicweb.fckcwconfig.js'))
       
   410     def use_fckeditor(self):
       
   411         return self.vreg.config.fckeditor_installed() and self.property_value('ui.fckeditor')
       
   412 
       
   413     def edited_eids(self, withtype=False):
       
   414         """return a list of edited eids"""
       
   415         yielded = False
       
   416         # warning: use .keys since the caller may change `form`
       
   417         form = self.form
       
   418         try:
       
   419             eids = form['eid']
       
   420         except KeyError:
       
   421             raise NothingToEdit(self._('no selected entities'))
       
   422         if isinstance(eids, string_types):
       
   423             eids = (eids,)
       
   424         for peid in eids:
       
   425             if withtype:
       
   426                 typekey = '__type:%s' % peid
       
   427                 assert typekey in form, 'no entity type specified'
       
   428                 yield peid, form[typekey]
       
   429             else:
       
   430                 yield peid
       
   431             yielded = True
       
   432         if not yielded:
       
   433             raise NothingToEdit(self._('no selected entities'))
       
   434 
       
   435     # minparams=3 by default: at least eid, __type, and some params to change
       
   436     def extract_entity_params(self, eid, minparams=3):
       
   437         """extract form parameters relative to the given eid"""
       
   438         params = {}
       
   439         eid = str(eid)
       
   440         form = self.form
       
   441         for param in form:
       
   442             try:
       
   443                 name, peid = param.split(':', 1)
       
   444             except ValueError:
       
   445                 if not param.startswith('__') and param not in ('eid', '_cw_fields'):
       
   446                     self.warning('param %s mis-formatted', param)
       
   447                 continue
       
   448             if peid == eid:
       
   449                 value = form[param]
       
   450                 if value == INTERNAL_FIELD_VALUE:
       
   451                     value = None
       
   452                 params[name] = value
       
   453         params['eid'] = eid
       
   454         if len(params) < minparams:
       
   455             raise RequestError(self._('missing parameters for entity %s') % eid)
       
   456         return params
       
   457 
       
   458     # XXX this should go to the GenericRelationsField. missing edition cancel protocol.
       
   459 
       
   460     def remove_pending_operations(self):
       
   461         """shortcut to clear req's pending_{delete,insert} entries
       
   462 
       
   463         This is needed when the edition is completed (whether it's validated
       
   464         or cancelled)
       
   465         """
       
   466         self.session.data.pop('pending_insert', None)
       
   467         self.session.data.pop('pending_delete', None)
       
   468 
       
   469     def cancel_edition(self, errorurl):
       
   470         """remove pending operations and `errorurl`'s specific stored data
       
   471         """
       
   472         self.session.data.pop(errorurl, None)
       
   473         self.remove_pending_operations()
       
   474 
       
   475     # high level methods for HTTP headers management ##########################
       
   476 
       
   477     # must be cached since login/password are popped from the form dictionary
       
   478     # and this method may be called multiple times during authentication
       
   479     @cached
       
   480     def get_authorization(self):
       
   481         """Parse and return the Authorization header"""
       
   482         if self.authmode == "cookie":
       
   483             try:
       
   484                 user = self.form.pop("__login")
       
   485                 passwd = self.form.pop("__password", '')
       
   486                 return user, passwd.encode('UTF8')
       
   487             except KeyError:
       
   488                 self.debug('no login/password in form params')
       
   489                 return None, None
       
   490         else:
       
   491             return self.header_authorization()
       
   492 
       
   493     def get_cookie(self):
       
   494         """retrieve request cookies, returns an empty cookie if not found"""
       
   495         # XXX use http_headers implementation
       
   496         try:
       
   497             return SimpleCookie(self.get_header('Cookie'))
       
   498         except KeyError:
       
   499             return SimpleCookie()
       
   500 
       
   501     def set_cookie(self, name, value, maxage=300, expires=None, secure=False, httponly=False):
       
   502         """set / update a cookie
       
   503 
       
   504         by default, cookie will be available for the next 5 minutes.
       
   505         Give maxage = None to have a "session" cookie expiring when the
       
   506         client close its browser
       
   507         """
       
   508         if isinstance(name, SimpleCookie):
       
   509             warn('[3.13] set_cookie now takes name and value as two first '
       
   510                  'argument, not anymore cookie object and name',
       
   511                  DeprecationWarning, stacklevel=2)
       
   512             secure = name[value]['secure']
       
   513             name, value = value, name[value].value
       
   514         if maxage: # don't check is None, 0 may be specified
       
   515             assert expires is None, 'both max age and expires cant be specified'
       
   516             expires = maxage + time.time()
       
   517         elif expires:
       
   518             # we don't want to handle times before the EPOCH (cause bug on
       
   519             # windows). Also use > and not >= else expires == 0 and Cookie think
       
   520             # that means no expire...
       
   521             assert expires > date(1970, 1, 1)
       
   522             expires = timegm(expires.timetuple())
       
   523         else:
       
   524             expires = None
       
   525         # make sure cookie is set on the correct path
       
   526         cookie = Cookie(str(name), str(value), self.base_url_path(),
       
   527                         expires=expires, secure=secure, httponly=httponly)
       
   528         self.headers_out.addHeader('Set-cookie', cookie)
       
   529 
       
   530     def remove_cookie(self, name, bwcompat=None):
       
   531         """remove a cookie by expiring it"""
       
   532         if bwcompat is not None:
       
   533             warn('[3.13] remove_cookie now take only a name as argument',
       
   534                  DeprecationWarning, stacklevel=2)
       
   535             name = bwcompat
       
   536         self.set_cookie(name, '', maxage=0, expires=date(2000, 1, 1))
       
   537 
       
   538     def set_content_type(self, content_type, filename=None, encoding=None,
       
   539                          disposition='inline'):
       
   540         """set output content type for this request. An optional filename
       
   541         may be given.
       
   542 
       
   543         The disposition argument may be `attachement` or `inline` as specified
       
   544         for the Content-disposition HTTP header. The disposition parameter have
       
   545         no effect if no filename are specified.
       
   546         """
       
   547         if content_type.startswith('text/') and ';charset=' not in content_type:
       
   548             content_type += ';charset=' + (encoding or self.encoding)
       
   549         self.set_header('content-type', content_type)
       
   550         if filename:
       
   551             header = [disposition]
       
   552             unicode_filename = None
       
   553             try:
       
   554                 ascii_filename = filename.encode('ascii').decode('ascii')
       
   555             except UnicodeEncodeError:
       
   556                 # fallback filename for very old browser
       
   557                 unicode_filename = filename
       
   558                 ascii_filename = filename.encode('ascii', 'ignore').decode('ascii')
       
   559             # escape " and \
       
   560             # see http://greenbytes.de/tech/tc2231/#attwithfilenameandextparamescaped
       
   561             ascii_filename = ascii_filename.replace('\x5c', r'\\').replace('"', r'\"')
       
   562             header.append('filename="%s"' % ascii_filename)
       
   563             if unicode_filename is not None:
       
   564                 # encoded filename according RFC5987
       
   565                 urlquoted_filename = urlquote(unicode_filename.encode('utf-8'), '')
       
   566                 header.append("filename*=utf-8''" + urlquoted_filename)
       
   567             self.set_header('content-disposition', ';'.join(header))
       
   568 
       
   569     # high level methods for HTML headers management ##########################
       
   570 
       
   571     def add_onload(self, jscode):
       
   572         self.html_headers.add_onload(jscode)
       
   573 
       
   574     def add_js(self, jsfiles, localfile=True):
       
   575         """specify a list of JS files to include in the HTML headers.
       
   576 
       
   577         :param jsfiles: a JS filename or a list of JS filenames
       
   578         :param localfile: if True, the default data dir prefix is added to the
       
   579                           JS filename
       
   580         """
       
   581         if isinstance(jsfiles, string_types):
       
   582             jsfiles = (jsfiles,)
       
   583         for jsfile in jsfiles:
       
   584             if localfile:
       
   585                 jsfile = self.data_url(jsfile)
       
   586             self.html_headers.add_js(jsfile)
       
   587 
       
   588     def add_css(self, cssfiles, media=u'all', localfile=True, ieonly=False,
       
   589                 iespec=u'[if lt IE 8]'):
       
   590         """specify a CSS file to include in the HTML headers
       
   591 
       
   592         :param cssfiles: a CSS filename or a list of CSS filenames.
       
   593         :param media: the CSS's media if necessary
       
   594         :param localfile: if True, the default data dir prefix is added to the
       
   595                           CSS filename
       
   596         :param ieonly: True if this css is specific to IE
       
   597         :param iespec: conditional expression that will be used around
       
   598                        the css inclusion. cf:
       
   599                        http://msdn.microsoft.com/en-us/library/ms537512(VS.85).aspx
       
   600         """
       
   601         if isinstance(cssfiles, string_types):
       
   602             cssfiles = (cssfiles,)
       
   603         if ieonly:
       
   604             if self.ie_browser():
       
   605                 extraargs = [iespec]
       
   606                 add_css = self.html_headers.add_ie_css
       
   607             else:
       
   608                 return # no need to do anything on non IE browsers
       
   609         else:
       
   610             extraargs = []
       
   611             add_css = self.html_headers.add_css
       
   612         for cssfile in cssfiles:
       
   613             if localfile:
       
   614                 cssfile = self.data_url(cssfile)
       
   615             add_css(cssfile, media, *extraargs)
       
   616 
       
   617     def ajax_replace_url(self, nodeid, replacemode='replace', **extraparams):
       
   618         """builds an ajax url that will replace nodeid's content
       
   619 
       
   620         :param nodeid: the dom id of the node to replace
       
   621         :param replacemode: defines how the replacement should be done.
       
   622 
       
   623           Possible values are :
       
   624           - 'replace' to replace the node's content with the generated HTML
       
   625           - 'swap' to replace the node itself with the generated HTML
       
   626           - 'append' to append the generated HTML to the node's content
       
   627 
       
   628         Arbitrary extra named arguments may be given, they will be included as
       
   629         parameters of the generated url.
       
   630         """
       
   631         # define a function in headers and use it in the link to avoid url
       
   632         # unescaping pb: browsers give the js expression to the interpreter
       
   633         # after having url unescaping the content. This may make appear some
       
   634         # quote or other special characters that will break the js expression.
       
   635         extraparams.setdefault('fname', 'view')
       
   636         # remove pageid from the generated URL as it's forced as a parameter
       
   637         # to the loadxhtml call below.
       
   638         extraparams.pop('pageid', None)
       
   639         url = self.build_url('ajax', **extraparams)
       
   640         cbname = build_cb_uid(url[:50])
       
   641         # think to propagate pageid. XXX see https://www.cubicweb.org/ticket/1753121
       
   642         jscode = u'function %s() { $("#%s").%s; }' % (
       
   643             cbname, nodeid, js.loadxhtml(url, {'pageid': self.pageid},
       
   644                                          'get', replacemode))
       
   645         self.html_headers.add_post_inline_script(jscode)
       
   646         return "javascript: %s()" % cbname
       
   647 
       
   648     # urls/path management ####################################################
       
   649 
       
   650     def build_url(self, *args, **kwargs):
       
   651         """return an absolute URL using params dictionary key/values as URL
       
   652         parameters. Values are automatically URL quoted, and the
       
   653         publishing method to use may be specified or will be guessed.
       
   654         """
       
   655         if '__message' in kwargs:
       
   656             msg = kwargs.pop('__message')
       
   657             kwargs['_cwmsgid'] = self.set_redirect_message(msg)
       
   658         if not args:
       
   659             method = 'view'
       
   660             if (self.from_controller() == 'view'
       
   661                 and not '_restpath' in kwargs):
       
   662                 method = self.relative_path(includeparams=False) or 'view'
       
   663             args = (method,)
       
   664         return super(_CubicWebRequestBase, self).build_url(*args, **kwargs)
       
   665 
       
   666     def url(self, includeparams=True):
       
   667         """return currently accessed url"""
       
   668         return self.base_url() + self.relative_path(includeparams)
       
   669 
       
   670     def selected(self, url):
       
   671         """return True if the url is equivalent to currently accessed url"""
       
   672         reqpath = self.relative_path().lower()
       
   673         baselen = len(self.base_url())
       
   674         return (reqpath == url[baselen:].lower())
       
   675 
       
   676     def base_url_prepend_host(self, hostname):
       
   677         protocol, roothost = urlsplit(self.base_url())[:2]
       
   678         if roothost.startswith('www.'):
       
   679             roothost = roothost[4:]
       
   680         return '%s://%s.%s' % (protocol, hostname, roothost)
       
   681 
       
   682     def base_url_path(self):
       
   683         """returns the absolute path of the base url"""
       
   684         return urlsplit(self.base_url())[2]
       
   685 
       
   686     def data_url(self, relpath):
       
   687         """returns the absolute path for a data resource"""
       
   688         return self.datadir_url + relpath
       
   689 
       
   690     @cached
       
   691     def from_controller(self):
       
   692         """return the id (string) of the controller issuing the request"""
       
   693         controller = self.relative_path(False).split('/', 1)[0]
       
   694         if controller in self.vreg['controllers']:
       
   695             return controller
       
   696         return 'view'
       
   697 
       
   698     def is_client_cache_valid(self):
       
   699         """check if a client cached page exists (as specified in request
       
   700         headers) and is still usable.
       
   701 
       
   702         Return False if the page has to be calculated, else True.
       
   703 
       
   704         Some response cache headers may be set by this method.
       
   705         """
       
   706         modified = True
       
   707         # Here, we search for any invalid 'not modified' condition
       
   708         # see http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.3
       
   709         validators = get_validators(self._headers_in)
       
   710         if validators: # if we have no
       
   711             modified = any(func(val, self.headers_out) for func, val in validators)
       
   712         # Forge expected response
       
   713         if not modified:
       
   714             # overwrite headers_out to forge a brand new not-modified response
       
   715             self.headers_out = self._forge_cached_headers()
       
   716             if self.http_method() in ('HEAD', 'GET'):
       
   717                 self.status_out = http_client.NOT_MODIFIED
       
   718             else:
       
   719                 self.status_out = http_client.PRECONDITION_FAILED
       
   720             # XXX replace by True once validate_cache bw compat method is dropped
       
   721             return self.status_out
       
   722         # XXX replace by False once validate_cache bw compat method is dropped
       
   723         return None
       
   724 
       
   725     @deprecated('[3.18] use .is_client_cache_valid() method instead')
       
   726     def validate_cache(self):
       
   727         """raise a `StatusResponse` exception if a cached page along the way
       
   728         exists and is still usable.
       
   729         """
       
   730         status_code = self.is_client_cache_valid()
       
   731         if status_code is not None:
       
   732             raise StatusResponse(status_code)
       
   733 
       
   734     # abstract methods to override according to the web front-end #############
       
   735 
       
   736     def http_method(self):
       
   737         """returns 'POST', 'GET', 'HEAD', etc."""
       
   738         raise NotImplementedError()
       
   739 
       
   740     def _forge_cached_headers(self):
       
   741         # overwrite headers_out to forge a brand new not-modified response
       
   742         headers = Headers()
       
   743         for header in (
       
   744             # Required from sec 10.3.5:
       
   745             'date', 'etag', 'content-location', 'expires',
       
   746             'cache-control', 'vary',
       
   747             # Others:
       
   748             'server', 'proxy-authenticate', 'www-authenticate', 'warning'):
       
   749             value = self._headers_in.getRawHeaders(header)
       
   750             if value is not None:
       
   751                 headers.setRawHeaders(header, value)
       
   752         return headers
       
   753 
       
   754     def relative_path(self, includeparams=True):
       
   755         """return the normalized path of the request (ie at least relative
       
   756         to the instance's root, but some other normalization may be needed
       
   757         so that the returned path may be used to compare to generated urls
       
   758 
       
   759         :param includeparams:
       
   760            boolean indicating if GET form parameters should be kept in the path
       
   761         """
       
   762         raise NotImplementedError()
       
   763 
       
   764     # http headers ############################################################
       
   765 
       
   766     ### incoming headers
       
   767 
       
   768     def get_header(self, header, default=None, raw=True):
       
   769         """return the value associated with the given input header, raise
       
   770         KeyError if the header is not set
       
   771         """
       
   772         if raw:
       
   773             return self._headers_in.getRawHeaders(header, [default])[0]
       
   774         return self._headers_in.getHeader(header, default)
       
   775 
       
   776     def header_accept_language(self):
       
   777         """returns an ordered list of preferred languages"""
       
   778         acceptedlangs = self.get_header('Accept-Language', raw=False) or {}
       
   779         for lang, _ in sorted(acceptedlangs.items(), key=lambda x: x[1],
       
   780                               reverse=True):
       
   781             lang = lang.split('-')[0]
       
   782             yield lang
       
   783 
       
   784     def header_if_modified_since(self):
       
   785         """If the HTTP header If-modified-since is set, return the equivalent
       
   786         date time value (GMT), else return None
       
   787         """
       
   788         mtime = self.get_header('If-modified-since', raw=False)
       
   789         if mtime:
       
   790             return datetime.utcfromtimestamp(mtime)
       
   791         return None
       
   792 
       
   793     ### outcoming headers
       
   794     def set_header(self, header, value, raw=True):
       
   795         """set an output HTTP header"""
       
   796         if raw:
       
   797             # adding encoded header is important, else page content
       
   798             # will be reconverted back to unicode and apart unefficiency, this
       
   799             # may cause decoding problem (e.g. when downloading a file)
       
   800             self.headers_out.setRawHeaders(header, [str(value)])
       
   801         else:
       
   802             self.headers_out.setHeader(header, value)
       
   803 
       
   804     def add_header(self, header, value):
       
   805         """add an output HTTP header"""
       
   806         # adding encoded header is important, else page content
       
   807         # will be reconverted back to unicode and apart unefficiency, this
       
   808         # may cause decoding problem (e.g. when downloading a file)
       
   809         self.headers_out.addRawHeader(header, str(value))
       
   810 
       
   811     def remove_header(self, header):
       
   812         """remove an output HTTP header"""
       
   813         self.headers_out.removeHeader(header)
       
   814 
       
   815     def header_authorization(self):
       
   816         """returns a couple (auth-type, auth-value)"""
       
   817         auth = self.get_header("Authorization", None)
       
   818         if auth:
       
   819             scheme, rest = auth.split(' ', 1)
       
   820             scheme = scheme.lower()
       
   821             try:
       
   822                 assert scheme == "basic"
       
   823                 user, passwd = base64.decodestring(rest.encode('ascii')).split(b":", 1)
       
   824                 # XXX HTTP header encoding: use email.Header?
       
   825                 return user.decode('UTF8'), passwd
       
   826             except Exception as ex:
       
   827                 self.debug('bad authorization %s (%s: %s)',
       
   828                            auth, ex.__class__.__name__, ex)
       
   829         return None, None
       
   830 
       
   831     def parse_accept_header(self, header):
       
   832         """returns an ordered list of accepted values"""
       
   833         try:
       
   834             value_parser, value_sort_key = ACCEPT_HEADER_PARSER[header.lower()]
       
   835         except KeyError:
       
   836             value_parser = value_sort_key = None
       
   837         accepteds = self.get_header(header, '')
       
   838         values = _parse_accept_header(accepteds, value_parser, value_sort_key)
       
   839         return (raw_value for (raw_value, parsed_value, score) in values)
       
   840 
       
   841     @deprecated('[3.17] demote_to_html is deprecated as we always serve html')
       
   842     def demote_to_html(self):
       
   843         """helper method to dynamically set request content type to text/html
       
   844 
       
   845         The global doctype and xmldec must also be changed otherwise the browser
       
   846         will display '<[' at the beginning of the page
       
   847         """
       
   848         pass
       
   849 
       
   850 
       
   851     # xml doctype #############################################################
       
   852 
       
   853     def set_doctype(self, doctype, reset_xmldecl=None):
       
   854         """helper method to dynamically change page doctype
       
   855 
       
   856         :param doctype: the new doctype, e.g. '<!DOCTYPE html>'
       
   857         """
       
   858         if reset_xmldecl is not None:
       
   859             warn('[3.17] reset_xmldecl is deprecated as we only serve html',
       
   860                  DeprecationWarning, stacklevel=2)
       
   861         self.main_stream.set_doctype(doctype)
       
   862 
       
   863     # page data management ####################################################
       
   864 
       
   865     def get_page_data(self, key, default=None):
       
   866         """return value associated to `key` in current page data"""
       
   867         page_data = self.session.data.get(self.pageid)
       
   868         if page_data is None:
       
   869             return default
       
   870         return page_data.get(key, default)
       
   871 
       
   872     def set_page_data(self, key, value):
       
   873         """set value associated to `key` in current page data"""
       
   874         self.html_headers.add_unload_pagedata()
       
   875         page_data = self.session.data.setdefault(self.pageid, {})
       
   876         page_data[key] = value
       
   877         self.session.data[self.pageid] = page_data
       
   878 
       
   879     def del_page_data(self, key=None):
       
   880         """remove value associated to `key` in current page data
       
   881         if `key` is None, all page data will be cleared
       
   882         """
       
   883         if key is None:
       
   884             self.session.data.pop(self.pageid, None)
       
   885         else:
       
   886             try:
       
   887                 del self.session.data[self.pageid][key]
       
   888             except KeyError:
       
   889                 pass
       
   890 
       
   891     # user-agent detection ####################################################
       
   892 
       
   893     @cached
       
   894     def useragent(self):
       
   895         return self.get_header('User-Agent', None)
       
   896 
       
   897     def ie_browser(self):
       
   898         useragent = self.useragent()
       
   899         return useragent and 'MSIE' in useragent
       
   900 
       
   901     @deprecated('[3.17] xhtml_browser is deprecated (xhtml is no longer served)')
       
   902     def xhtml_browser(self):
       
   903         """return True if the browser is considered as xhtml compatible.
       
   904 
       
   905         If the instance is configured to always return text/html and not
       
   906         application/xhtml+xml, this method will always return False, even though
       
   907         this is semantically different
       
   908         """
       
   909         return False
       
   910 
       
   911     def html_content_type(self):
       
   912         return 'text/html'
       
   913 
       
   914     def set_user_language(self, user):
       
   915         vreg = self.vreg
       
   916         if user is not None:
       
   917             try:
       
   918                 # 1. user-specified language
       
   919                 lang = vreg.typed_value('ui.language', user.properties['ui.language'])
       
   920                 self.set_language(lang)
       
   921                 return
       
   922             except KeyError:
       
   923                 pass
       
   924         if vreg.config.get('language-negociation', False):
       
   925             # 2. http accept-language
       
   926             self.headers_out.addHeader('Vary', 'Accept-Language')
       
   927             for lang in self.header_accept_language():
       
   928                 if lang in self.translations:
       
   929                     self.set_language(lang)
       
   930                     return
       
   931         # 3. site's default language
       
   932         self.set_default_language(vreg)
       
   933 
       
   934 
       
   935 def _cnx_func(name):
       
   936     def proxy(req, *args, **kwargs):
       
   937         return getattr(req.cnx, name)(*args, **kwargs)
       
   938     return proxy
       
   939 
       
   940 class _NeedAuthAccessMock(object):
       
   941 
       
   942     def __getattribute__(self, attr):
       
   943         raise AuthenticationError()
       
   944 
       
   945     def __bool__(self):
       
   946         return False
       
   947     
       
   948     __nonzero__ = __bool__
       
   949 
       
   950 class _MockAnonymousSession(object):
       
   951     sessionid = 'thisisnotarealsession'
       
   952 
       
   953     @property
       
   954     def data(self):
       
   955         return {}
       
   956 
       
   957     @property
       
   958     def anonymous_session(self):
       
   959         return True
       
   960 
       
   961 class ConnectionCubicWebRequestBase(_CubicWebRequestBase):
       
   962     cnx = None
       
   963     session = None
       
   964 
       
   965     def __init__(self, vreg, https=False, form=None, headers={}):
       
   966         """"""
       
   967         self.vreg = vreg
       
   968         try:
       
   969             # no vreg or config which doesn't handle translations
       
   970             self.translations = vreg.config.translations
       
   971         except AttributeError:
       
   972             self.translations = {}
       
   973         super(ConnectionCubicWebRequestBase, self).__init__(vreg, https=https,
       
   974                                                        form=form, headers=headers)
       
   975         self.session = _MockAnonymousSession()
       
   976         self.cnx = self.user = _NeedAuthAccessMock()
       
   977 
       
   978     @property
       
   979     def transaction_data(self):
       
   980         return self.cnx.transaction_data
       
   981 
       
   982     def set_cnx(self, cnx):
       
   983         self.cnx = cnx
       
   984         self.session = cnx.session
       
   985         self._set_user(cnx.user)
       
   986         self.set_user_language(cnx.user)
       
   987 
       
   988     def execute(self, *args, **kwargs):
       
   989         rset = self.cnx.execute(*args, **kwargs)
       
   990         rset.req = self
       
   991         return rset
       
   992 
       
   993     def set_default_language(self, vreg):
       
   994         try:
       
   995             lang = vreg.property_value('ui.language')
       
   996         except Exception: # property may not be registered
       
   997             lang = 'en'
       
   998         try:
       
   999             self.set_language(lang)
       
  1000         except KeyError:
       
  1001             # this occurs usually during test execution
       
  1002             self._ = self.__ = text_type
       
  1003             self.pgettext = lambda x, y: text_type(y)
       
  1004 
       
  1005     entity_metas = _cnx_func('entity_metas')
       
  1006     source_defs = _cnx_func('source_defs')
       
  1007     get_shared_data = _cnx_func('get_shared_data')
       
  1008     set_shared_data = _cnx_func('set_shared_data')
       
  1009     describe = _cnx_func('describe') # deprecated XXX
       
  1010 
       
  1011     # security #################################################################
       
  1012 
       
  1013     security_enabled = _cnx_func('security_enabled')
       
  1014 
       
  1015     # server-side service call #################################################
       
  1016 
       
  1017     def call_service(self, regid, **kwargs):
       
  1018         return self.cnx.call_service(regid, **kwargs)
       
  1019 
       
  1020     # entities cache management ###############################################
       
  1021 
       
  1022     def entity_cache(self, eid):
       
  1023         return self.transaction_data['req_ecache'][eid]
       
  1024 
       
  1025     def set_entity_cache(self, entity):
       
  1026         ecache = self.transaction_data.setdefault('req_ecache', {})
       
  1027         ecache.setdefault(entity.eid, entity)
       
  1028 
       
  1029     def cached_entities(self):
       
  1030         return self.transaction_data.get('req_ecache', {}).values()
       
  1031 
       
  1032     def drop_entity_cache(self, eid=None):
       
  1033         if eid is None:
       
  1034             self.transaction_data.pop('req_ecache', None)
       
  1035         else:
       
  1036             del self.transaction_data['req_ecache'][eid]
       
  1037 
       
  1038 
       
  1039 CubicWebRequestBase = ConnectionCubicWebRequestBase
       
  1040 
       
  1041 
       
  1042 ## HTTP-accept parsers / utilies ##############################################
       
  1043 def _mimetype_sort_key(accept_info):
       
  1044     """accepted mimetypes must be sorted by :
       
  1045 
       
  1046     1/ highest score first
       
  1047     2/ most specific mimetype first, e.g. :
       
  1048        - 'text/html level=1' is more specific 'text/html'
       
  1049        - 'text/html' is more specific than 'text/*'
       
  1050        - 'text/*' itself more specific than '*/*'
       
  1051 
       
  1052     """
       
  1053     raw_value, (media_type, media_subtype, media_type_params), score = accept_info
       
  1054     # FIXME: handle '+' in media_subtype ? (should xhtml+xml have a
       
  1055     # higher precedence than xml ?)
       
  1056     if media_subtype == '*':
       
  1057         score -= 0.0001
       
  1058     if media_type == '*':
       
  1059         score -= 0.0001
       
  1060     return 1./score, media_type, media_subtype, 1./(1+len(media_type_params))
       
  1061 
       
  1062 def _charset_sort_key(accept_info):
       
  1063     """accepted mimetypes must be sorted by :
       
  1064 
       
  1065     1/ highest score first
       
  1066     2/ most specific charset first, e.g. :
       
  1067        - 'utf-8' is more specific than '*'
       
  1068     """
       
  1069     raw_value, value, score = accept_info
       
  1070     if value == '*':
       
  1071         score -= 0.0001
       
  1072     return 1./score, value
       
  1073 
       
  1074 def _parse_accept_header(raw_header, value_parser=None, value_sort_key=None):
       
  1075     """returns an ordered list accepted types
       
  1076 
       
  1077     :param value_parser: a function to parse a raw accept chunk. If None
       
  1078     is provided, the function defaults to identity. If a function is provided,
       
  1079     it must accept 2 parameters ``value`` and ``other_params``. ``value`` is
       
  1080     the value found before the first ';', `other_params` is a dictionary
       
  1081     built from all other chunks after this first ';'
       
  1082 
       
  1083     :param value_sort_key: a key function to sort values found in the accept
       
  1084     header. This function will be passed a 3-tuple
       
  1085     (raw_value, parsed_value, score). If None is provided, the default
       
  1086     sort_key is 1./score
       
  1087 
       
  1088     :return: a list of 3-tuple (raw_value, parsed_value, score),
       
  1089     ordered by score. ``parsed_value`` will be the return value of
       
  1090     ``value_parser(raw_value)``
       
  1091     """
       
  1092     if value_sort_key is None:
       
  1093         value_sort_key = lambda infos: 1./infos[-1]
       
  1094     values = []
       
  1095     for info in raw_header.split(','):
       
  1096         score = 1.0
       
  1097         other_params = {}
       
  1098         try:
       
  1099             value, infodef = info.split(';', 1)
       
  1100         except ValueError:
       
  1101             value = info
       
  1102         else:
       
  1103             for info in infodef.split(';'):
       
  1104                 try:
       
  1105                     infokey, infoval = info.split('=')
       
  1106                     if infokey == 'q': # XXX 'level'
       
  1107                         score = float(infoval)
       
  1108                         continue
       
  1109                 except ValueError:
       
  1110                     continue
       
  1111                 other_params[infokey] = infoval
       
  1112         parsed_value = value_parser(value, other_params) if value_parser else value
       
  1113         values.append( (value.strip(), parsed_value, score) )
       
  1114     values.sort(key=value_sort_key)
       
  1115     return values
       
  1116 
       
  1117 
       
  1118 def _mimetype_parser(value, other_params):
       
  1119     """return a 3-tuple
       
  1120     (type, subtype, type_params) corresponding to the mimetype definition
       
  1121     e.g. : for 'text/*', `mimetypeinfo` will be ('text', '*', {}), for
       
  1122     'text/html;level=1', `mimetypeinfo` will be ('text', '*', {'level': '1'})
       
  1123     """
       
  1124     try:
       
  1125         media_type, media_subtype = value.strip().split('/', 1)
       
  1126     except ValueError: # safety belt : '/' should always be present
       
  1127         media_type = value.strip()
       
  1128         media_subtype = '*'
       
  1129     return (media_type, media_subtype, other_params)
       
  1130 
       
  1131 
       
  1132 ACCEPT_HEADER_PARSER = {
       
  1133     'accept': (_mimetype_parser, _mimetype_sort_key),
       
  1134     'accept-charset': (None, _charset_sort_key),
       
  1135     }
       
  1136 
       
  1137 from cubicweb import set_log_methods
       
  1138 set_log_methods(_CubicWebRequestBase, LOGGER)