"""abstract class for http request:organization: Logilab:copyright: 2001-2010 LOGILAB S.A. (Paris, FRANCE), license is LGPL v2.:contact: http://www.logilab.fr/ -- mailto:contact@logilab.fr:license: GNU Lesser General Public License, v2.1 - http://www.gnu.org/licenses"""__docformat__="restructuredtext en"importCookieimportshaimporttimeimportrandomimportbase64fromdatetimeimportdatefromurlparseimporturlsplitfromitertoolsimportcountfromsimplejsonimportdumpsfromrql.utilsimportrqlvar_makerfromlogilab.common.decoratorsimportcachedfromlogilab.common.deprecationimportdeprecatedfromlogilab.mtconverterimportxml_escapefromcubicweb.dbapiimportDBAPIRequestfromcubicweb.mailimportheaderfromcubicweb.uilibimportremove_html_tagsfromcubicweb.utilsimportSizeConstrainedList,HTMLHead,make_uidfromcubicweb.viewimportSTRICT_DOCTYPE,TRANSITIONAL_DOCTYPE_NOEXTfromcubicweb.webimport(INTERNAL_FIELD_VALUE,LOGGER,NothingToEdit,RequestError,StatusResponse)_MARKER=object()deflist_form_param(form,param,pop=False):"""get param from form parameters and return its value as a list, skipping internal markers if any * if the parameter isn't defined, return an empty list * if the parameter is a single (unicode) value, return a list containing that value * if the parameter is already a list or tuple, just skip internal markers if pop is True, the parameter is removed from the form dictionnary """ifpop:try:value=form.pop(param)exceptKeyError:return[]else:value=form.get(param,())ifvalueisNone:value=()elifnotisinstance(value,(list,tuple)):value=[value]return[vforvinvalueifv!=INTERNAL_FIELD_VALUE]classCubicWebRequestBase(DBAPIRequest):"""abstract HTTP request, should be extended according to the HTTP backend"""json_request=False# to be set to True by json controllersdef__init__(self,vreg,https,form=None):super(CubicWebRequestBase,self).__init__(vreg)self.message=Noneself.authmode=vreg.config['auth-mode']self.https=https# raw html headers that can be added from any viewself.html_headers=HTMLHead()# form parametersself.setup_params(form)# dictionnary that may be used to store request data that has to be# shared among various components used to publish the request (views,# controller, application...)self.data={}# search state: 'normal' or 'linksearch' (eg searching for an object# to create a relation with another)self.search_state=('normal',)# tabindex generatorself.tabindexgen=count(1)self.next_tabindex=self.tabindexgen.next# page id, set by htmlheader templateself.pageid=Noneself.datadir_url=self._datadir_url()self._set_pageid()def_set_pageid(self):"""initialize self.pageid if req.form provides a specific pageid, use it, otherwise build a new one. """pid=self.form.get('pageid')ifpidisNone:pid=make_uid(id(self))self.pageid=pidself.html_headers.define_var('pageid',pid,override=False)@propertydefvarmaker(self):"""the rql varmaker is exposed both as a property and as the set_varmaker function since we've two use cases: * accessing the req.varmaker property to get a new variable name * calling req.set_varmaker() to ensure a varmaker is set for later ajax calls sharing our .pageid """returnself.set_varmaker()defset_varmaker(self):varmaker=self.get_page_data('rql_varmaker')ifvarmakerisNone:varmaker=rqlvar_maker()self.set_page_data('rql_varmaker',varmaker)returnvarmakerdefset_connection(self,cnx,user=None):"""method called by the session handler when the user is authenticated or an anonymous connection is open """super(CubicWebRequestBase,self).set_connection(cnx,user)# set request languagetry:vreg=self.vregifself.user:try:# 1. user specified languagelang=vreg.typed_value('ui.language',self.user.properties['ui.language'])self.set_language(lang)returnexceptKeyError:passifvreg.config['language-negociation']:# 2. http negociated languageforlanginself.header_accept_language():iflanginself.translations:self.set_language(lang)return# 3. default languageself.set_default_language(vreg)finally:# XXX code smell# have to be done here because language is not yet set in setup_params## special key for created entity, added in controller's reset method# if no message set, we don't want this neitherif'__createdpath'inself.formandself.message:self.message+=' (<a href="%s">%s</a>)'%(self.build_url(self.form.pop('__createdpath')),self._('click here to see created entity'))defset_language(self,lang):gettext,self.pgettext=self.translations[lang]self._=self.__=gettextself.lang=langself.cnx.set_session_props(lang=lang)self.debug('request language: %s',lang)# input form parameters management ######################################### common form parameters which should be protected against html values# XXX can't add 'eid' for instance since it may be multivalued# dont put rql as well, if query contains < and > it will be corrupted!no_script_form_params=set(('vid','etype','vtitle','title','__message','__redirectvid','__redirectrql'))defsetup_params(self,params):"""WARNING: we're intentionaly leaving INTERNAL_FIELD_VALUE here subclasses should overrides to """ifparamsisNone:params={}self.form=paramsencoding=self.encodingfork,vinparams.items():ifisinstance(v,(tuple,list)):v=[unicode(x,encoding)forxinv]iflen(v)==1:v=v[0]ifkinself.no_script_form_params:v=self.no_script_form_param(k,value=v)ifisinstance(v,str):v=unicode(v,encoding)ifk=='__message':self.set_message(v)delself.form[k]else:self.form[k]=vdefno_script_form_param(self,param,default=None,value=None):"""ensure there is no script in a user form param by default return a cleaned string instead of raising a security exception this method should be called on every user input (form at least) fields that are at some point inserted in a generated html page to protect against script kiddies """ifvalueisNone:value=self.form.get(param,default)ifnotvalueisdefaultandvalue:# safety belt for strange urls like http://...?vtitle=yo&vtitle=yoifisinstance(value,(list,tuple)):self.error('no_script_form_param got a list (%s). Who generated the URL ?',repr(value))value=value[0]returnremove_html_tags(value)returnvaluedeflist_form_param(self,param,form=None,pop=False):"""get param from form parameters and return its value as a list, skipping internal markers if any * if the parameter isn't defined, return an empty list * if the parameter is a single (unicode) value, return a list containing that value * if the parameter is already a list or tuple, just skip internal markers if pop is True, the parameter is removed from the form dictionnary """ifformisNone:form=self.formreturnlist_form_param(form,param,pop)defreset_headers(self):"""used by AutomaticWebTest to clear html headers between tests on the same resultset """self.html_headers=HTMLHead()returnself# web state helpers #######################################################defset_message(self,msg):assertisinstance(msg,unicode)self.message=msgdefupdate_search_state(self):"""update the current search state"""searchstate=self.form.get('__mode')ifnotsearchstateandself.cnxisnotNone:searchstate=self.get_session_data('search_state','normal')self.set_search_state(searchstate)defset_search_state(self,searchstate):"""set a new search state"""ifsearchstateisNoneorsearchstate=='normal':self.search_state=(searchstateor'normal',)else:self.search_state=('linksearch',searchstate.split(':'))assertlen(self.search_state[-1])==4ifself.cnxisnotNone:self.set_session_data('search_state',searchstate)defmatch_search_state(self,rset):"""when searching an entity to create a relation, return True if entities in the given rset may be used as relation end """try:searchedtype=self.search_state[1][-1]exceptIndexError:returnFalse# no searching for associationforetypeinrset.column_types(0):ifetype!=searchedtype:returnFalsereturnTruedefupdate_breadcrumbs(self):"""stores the last visisted page in session data"""searchstate=self.get_session_data('search_state')ifsearchstate=='normal':breadcrumbs=self.get_session_data('breadcrumbs',None)ifbreadcrumbsisNone:breadcrumbs=SizeConstrainedList(10)self.set_session_data('breadcrumbs',breadcrumbs)breadcrumbs.append(self.url())else:url=self.url()ifbreadcrumbs[-1]!=url:breadcrumbs.append(url)deflast_visited_page(self):breadcrumbs=self.get_session_data('breadcrumbs',None)ifbreadcrumbs:returnbreadcrumbs.pop()returnself.base_url()defuser_rql_callback(self,args,msg=None):"""register a user callback to execute some rql query and return an url to call it ready to be inserted in html """defrqlexec(req,rql,args=None,key=None):req.execute(rql,args,key)returnself.user_callback(rqlexec,args,msg)defuser_callback(self,cb,args,msg=None,nonify=False):"""register the given user callback and return an url to call it ready to be inserted in html """self.add_js('cubicweb.ajax.js')cbname=self.register_onetime_callback(cb,*args)msg=dumps(msgor'')return"javascript:userCallbackThenReloadPage('%s', %s)"%(cbname,msg)defregister_onetime_callback(self,func,*args):cbname='cb_%s'%(sha.sha('%s%s%s%s'%(time.time(),func.__name__,random.random(),self.user.login)).hexdigest())def_cb(req):try:ret=func(req,*args)exceptTypeError:fromwarningsimportwarnwarn('user callback should now take request as argument')ret=func(*args)self.unregister_callback(self.pageid,cbname)returnretself.set_page_data(cbname,_cb)returncbnamedefunregister_callback(self,pageid,cbname):assertpageidisnotNoneassertcbname.startswith('cb_')self.info('unregistering callback %s for pageid %s',cbname,pageid)self.del_page_data(cbname)defclear_user_callbacks(self):ifself.cnxisnotNone:sessdata=self.session_data()callbacks=[keyforkeyinsessdataifkey.startswith('cb_')]forcallbackincallbacks:self.del_session_data(callback)# web edition helpers #####################################################@cached# so it's writed only oncedeffckeditor_config(self):self.add_js('fckeditor/fckeditor.js')self.html_headers.define_var('fcklang',self.lang)self.html_headers.define_var('fckconfigpath',self.build_url('data/cubicweb.fckcwconfig.js'))defuse_fckeditor(self):returnself.vreg.config.fckeditor_installed()andself.property_value('ui.fckeditor')defedited_eids(self,withtype=False):"""return a list of edited eids"""yielded=False# warning: use .keys since the caller may change `form`form=self.formtry:eids=form['eid']exceptKeyError:raiseNothingToEdit(self._('no selected entities'))ifisinstance(eids,basestring):eids=(eids,)forpeidineids:ifwithtype:typekey='__type:%s'%peidasserttypekeyinform,'no entity type specified'yieldpeid,form[typekey]else:yieldpeidyielded=Trueifnotyielded:raiseNothingToEdit(self._('no selected entities'))# minparams=3 by default: at least eid, __type, and some params to changedefextract_entity_params(self,eid,minparams=3):"""extract form parameters relative to the given eid"""params={}eid=str(eid)form=self.formforparaminform:try:name,peid=param.split(':',1)exceptValueError:ifnotparam.startswith('__')andparam!="eid":self.warning('param %s mis-formatted',param)continueifpeid==eid:value=form[param]ifvalue==INTERNAL_FIELD_VALUE:value=Noneparams[name]=valueparams['eid']=eidiflen(params)<minparams:raiseRequestError(self._('missing parameters for entity %s')%eid)returnparams# XXX this should go to the GenericRelationsField. missing edition cancel protocol.defremove_pending_operations(self):"""shortcut to clear req's pending_{delete,insert} entries This is needed when the edition is completed (whether it's validated or cancelled) """self.del_session_data('pending_insert')self.del_session_data('pending_delete')defcancel_edition(self,errorurl):"""remove pending operations and `errorurl`'s specific stored data """self.del_session_data(errorurl)self.remove_pending_operations()# high level methods for HTTP headers management ########################### must be cached since login/password are popped from the form dictionary# and this method may be called multiple times during authentication@cacheddefget_authorization(self):"""Parse and return the Authorization header"""ifself.authmode=="cookie":try:user=self.form.pop("__login")passwd=self.form.pop("__password",'')returnuser,passwd.encode('UTF8')exceptKeyError:self.debug('no login/password in form params')returnNone,Noneelse:returnself.header_authorization()defget_cookie(self):"""retrieve request cookies, returns an empty cookie if not found"""try:returnCookie.SimpleCookie(self.get_header('Cookie'))exceptKeyError:returnCookie.SimpleCookie()defset_cookie(self,cookie,key,maxage=300,expires=None):"""set / update a cookie key by default, cookie will be available for the next 5 minutes. Give maxage = None to have a "session" cookie expiring when the client close its browser """morsel=cookie[key]ifmaxageisnotNone:morsel['Max-Age']=maxageifexpires:morsel['expires']=expires.strftime('%a, %d %b %Y %H:%M:%S %z')# make sure cookie is set on the correct pathmorsel['path']=self.base_url_path()self.add_header('Set-Cookie',morsel.OutputString())defremove_cookie(self,cookie,key):"""remove a cookie by expiring it"""self.set_cookie(cookie,key,maxage=0,expires=date(1970,1,1))defset_content_type(self,content_type,filename=None,encoding=None):"""set output content type for this request. An optional filename may be given """ifcontent_type.startswith('text/'):content_type+=';charset='+(encodingorself.encoding)self.set_header('content-type',content_type)iffilename:ifisinstance(filename,unicode):filename=header(filename).encode()self.set_header('content-disposition','inline; filename=%s'%filename)# high level methods for HTML headers management ##########################defadd_onload(self,jscode):self.html_headers.add_onload(jscode,self.json_request)defadd_js(self,jsfiles,localfile=True):"""specify a list of JS files to include in the HTML headers :param jsfiles: a JS filename or a list of JS filenames :param localfile: if True, the default data dir prefix is added to the JS filename """ifisinstance(jsfiles,basestring):jsfiles=(jsfiles,)forjsfileinjsfiles:iflocalfile:jsfile=self.datadir_url+jsfileself.html_headers.add_js(jsfile)defadd_css(self,cssfiles,media=u'all',localfile=True,ieonly=False,iespec=u'[if lt IE 8]'):"""specify a CSS file to include in the HTML headers :param cssfiles: a CSS filename or a list of CSS filenames :param media: the CSS's media if necessary :param localfile: if True, the default data dir prefix is added to the CSS filename :param ieonly: True if this css is specific to IE :param iespec: conditional expression that will be used around the css inclusion. cf: http://msdn.microsoft.com/en-us/library/ms537512(VS.85).aspx """ifisinstance(cssfiles,basestring):cssfiles=(cssfiles,)ifieonly:ifself.ie_browser():extraargs=[iespec]add_css=self.html_headers.add_ie_csselse:return# no need to do anything on non IE browserselse:extraargs=[]add_css=self.html_headers.add_cssforcssfileincssfiles:iflocalfile:cssfile=self.datadir_url+cssfileadd_css(cssfile,media,*extraargs)defbuild_ajax_replace_url(self,nodeid,rql,vid,replacemode='replace',**extraparams):"""builds an ajax url that will replace `nodeid`s content :param nodeid: the dom id of the node to replace :param rql: rql to execute :param vid: the view to apply on the resultset :param replacemode: defines how the replacement should be done. Possible values are : - 'replace' to replace the node's content with the generated HTML - 'swap' to replace the node itself with the generated HTML - 'append' to append the generated HTML to the node's content """url=self.build_url('view',rql=rql,vid=vid,__notemplate=1,**extraparams)return"javascript: loadxhtml('%s', '%s', '%s')"%(nodeid,xml_escape(url),replacemode)# urls/path management ####################################################defurl(self,includeparams=True):"""return currently accessed url"""returnself.base_url()+self.relative_path(includeparams)def_datadir_url(self):"""return url of the instance's data directory"""returnself.base_url()+'data%s/'%self.vreg.config.instance_md5_version()defselected(self,url):"""return True if the url is equivalent to currently accessed url"""reqpath=self.relative_path().lower()baselen=len(self.base_url())return(reqpath==url[baselen:].lower())defbase_url_prepend_host(self,hostname):protocol,roothost=urlsplit(self.base_url())[:2]ifroothost.startswith('www.'):roothost=roothost[4:]return'%s://%s.%s'%(protocol,hostname,roothost)defbase_url_path(self):"""returns the absolute path of the base url"""returnurlsplit(self.base_url())[2]@cacheddeffrom_controller(self):"""return the id (string) of the controller issuing the request"""controller=self.relative_path(False).split('/',1)[0]registered_controllers=self.vreg['controllers'].keys()ifcontrollerinregistered_controllers:returncontrollerreturn'view'defexternal_resource(self,rid,default=_MARKER):"""return a path to an external resource, using its identifier raise KeyError if the resource is not defined """try:value=self.vreg.config.ext_resources[rid]exceptKeyError:ifdefaultis_MARKER:raisereturndefaultifvalueisNone:returnNonebaseurl=self.datadir_url[:-1]# remove trailing /ifisinstance(value,list):return[v.replace('DATADIR',baseurl)forvinvalue]returnvalue.replace('DATADIR',baseurl)external_resource=cached(external_resource,keyarg=1)defvalidate_cache(self):"""raise a `DirectResponse` exception if a cached page along the way exists and is still usable. calls the client-dependant implementation of `_validate_cache` """self._validate_cache()ifself.http_method()=='HEAD':raiseStatusResponse(200,'')# abstract methods to override according to the web front-end #############defhttp_method(self):"""returns 'POST', 'GET', 'HEAD', etc."""raiseNotImplementedError()def_validate_cache(self):"""raise a `DirectResponse` exception if a cached page along the way exists and is still usable """raiseNotImplementedError()defrelative_path(self,includeparams=True):"""return the normalized path of the request (ie at least relative to the instance's root, but some other normalization may be needed so that the returned path may be used to compare to generated urls :param includeparams: boolean indicating if GET form parameters should be kept in the path """raiseNotImplementedError()defget_header(self,header,default=None):"""return the value associated with the given input HTTP header, raise KeyError if the header is not set """raiseNotImplementedError()defset_header(self,header,value):"""set an output HTTP header"""raiseNotImplementedError()defadd_header(self,header,value):"""add an output HTTP header"""raiseNotImplementedError()defremove_header(self,header):"""remove an output HTTP header"""raiseNotImplementedError()defheader_authorization(self):"""returns a couple (auth-type, auth-value)"""auth=self.get_header("Authorization",None)ifauth:scheme,rest=auth.split(' ',1)scheme=scheme.lower()try:assertscheme=="basic"user,passwd=base64.decodestring(rest).split(":",1)# XXX HTTP header encoding: use email.Header?returnuser.decode('UTF8'),passwdexceptException,ex:self.debug('bad authorization %s (%s: %s)',auth,ex.__class__.__name__,ex)returnNone,None@deprecated("[3.4] use parse_accept_header('Accept-Language')")defheader_accept_language(self):"""returns an ordered list of preferred languages"""return[value.split('-')[0]forvalueinself.parse_accept_header('Accept-Language')]defparse_accept_header(self,header):"""returns an ordered list of preferred languages"""accepteds=self.get_header(header,'')values=[]forinfoinaccepteds.split(','):try:value,scores=info.split(';',1)exceptValueError:value=infoscore=1.0else:forscoreinscores.split(';'):try:scorekey,scoreval=score.split('=')ifscorekey=='q':# XXX 'level'score=float(scoreval)exceptValueError:continuevalues.append((score,value))values.sort(reverse=True)return(valuefor(score,value)invalues)defheader_if_modified_since(self):"""If the HTTP header If-modified-since is set, return the equivalent mx date time value (GMT), else return None """raiseNotImplementedError()defdemote_to_html(self):"""helper method to dynamically set request content type to text/html The global doctype and xmldec must also be changed otherwise the browser will display '<[' at the beginning of the page """self.set_content_type('text/html')self.main_stream.doctype=TRANSITIONAL_DOCTYPE_NOEXTself.main_stream.xmldecl=u''# page data management ####################################################defget_page_data(self,key,default=None):"""return value associated to `key` in curernt page data"""page_data=self.cnx.get_session_data(self.pageid,{})returnpage_data.get(key,default)defset_page_data(self,key,value):"""set value associated to `key` in current page data"""self.html_headers.add_unload_pagedata()page_data=self.cnx.get_session_data(self.pageid,{})page_data[key]=valuereturnself.cnx.set_session_data(self.pageid,page_data)defdel_page_data(self,key=None):"""remove value associated to `key` in current page data if `key` is None, all page data will be cleared """ifkeyisNone:self.cnx.del_session_data(self.pageid)else:page_data=self.cnx.get_session_data(self.pageid,{})page_data.pop(key,None)self.cnx.set_session_data(self.pageid,page_data)# user-agent detection ####################################################@cacheddefuseragent(self):returnself.get_header('User-Agent',None)defie_browser(self):useragent=self.useragent()returnuseragentand'MSIE'inuseragentdefxhtml_browser(self):"""return True if the browser is considered as xhtml compatible. If the instance is configured to always return text/html and not application/xhtml+xml, this method will always return False, even though this is semantically different """ifself.vreg.config['force-html-content-type']:returnFalseuseragent=self.useragent()# * MSIE/Konqueror does not support xml content-type# * Opera supports xhtml and handles namespaces properly but it breaks# jQuery.attr()ifuseragentand('MSIE'inuseragentor'KHTML'inuseragentor'Opera'inuseragent):returnFalsereturnTruedefhtml_content_type(self):ifself.xhtml_browser():return'application/xhtml+xml'return'text/html'defdocument_surrounding_div(self):ifself.xhtml_browser():return(u'<?xml version="1.0"?>\n'+STRICT_DOCTYPE+# XXX encoding ?u'<div xmlns="http://www.w3.org/1999/xhtml" xmlns:cubicweb="http://www.logilab.org/2008/cubicweb">')returnu'<div>'fromcubicwebimportset_log_methodsset_log_methods(CubicWebRequestBase,LOGGER)