1 """WWW request handler (also used in the stand-alone server).
2 """
3 __docformat__ = 'restructuredtext'
5 import base64, binascii, cgi, codecs, mimetypes, os
6 import quopri, random, re, rfc822, stat, sys, time, urllib, urlparse
7 import Cookie, socket, errno
8 from Cookie import CookieError, BaseCookie, SimpleCookie
9 from cStringIO import StringIO
11 from roundup import roundupdb, date, hyperdb, password
12 from roundup.cgi import templating, cgitb, TranslationService
13 from roundup.cgi.actions import *
14 from roundup.exceptions import *
15 from roundup.cgi.exceptions import *
16 from roundup.cgi.form_parser import FormParser
17 from roundup.mailer import Mailer, MessageSendError
18 from roundup.cgi import accept_language
20 def initialiseSecurity(security):
21 '''Create some Permissions and Roles on the security object
23 This function is directly invoked by security.Security.__init__()
24 as a part of the Security object instantiation.
25 '''
26 p = security.addPermission(name="Web Access",
27 description="User may access the web interface")
28 security.addPermissionToRole('Admin', p)
30 # doing Role stuff through the web - make sure Admin can
31 # TODO: deprecate this and use a property-based control
32 p = security.addPermission(name="Web Roles",
33 description="User may manipulate user Roles through the web")
34 security.addPermissionToRole('Admin', p)
36 # used to clean messages passed through CGI variables - HTML-escape any tag
37 # that isn't <a href="">, <i>, <b> and <br> (including XHTML variants) so
38 # that people can't pass through nasties like <script>, <iframe>, ...
39 CLEAN_MESSAGE_RE = r'(<(/?(.*?)(\s*href="[^"]")?\s*/?)>)'
40 def clean_message(message, mc=re.compile(CLEAN_MESSAGE_RE, re.I)):
41 return mc.sub(clean_message_callback, message)
42 def clean_message_callback(match, ok={'a':1,'i':1,'b':1,'br':1}):
43 ''' Strip all non <a>,<i>,<b> and <br> tags from a string
44 '''
45 if ok.has_key(match.group(3).lower()):
46 return match.group(1)
47 return '<%s>'%match.group(2)
50 error_message = ""'''<html><head><title>An error has occurred</title></head>
51 <body><h1>An error has occurred</h1>
52 <p>A problem was encountered processing your request.
53 The tracker maintainers have been notified of the problem.</p>
54 </body></html>'''
57 class LiberalCookie(SimpleCookie):
58 ''' Python's SimpleCookie throws an exception if the cookie uses invalid
59 syntax. Other applications on the same server may have done precisely
60 this, preventing roundup from working through no fault of roundup.
61 Numerous other python apps have run into the same problem:
63 trac: http://trac.edgewall.org/ticket/2256
64 mailman: http://bugs.python.org/issue472646
66 This particular implementation comes from trac's solution to the
67 problem. Unfortunately it requires some hackery in SimpleCookie's
68 internals to provide a more liberal __set method.
69 '''
70 def load(self, rawdata, ignore_parse_errors=True):
71 if ignore_parse_errors:
72 self.bad_cookies = []
73 self._BaseCookie__set = self._loose_set
74 SimpleCookie.load(self, rawdata)
75 if ignore_parse_errors:
76 self._BaseCookie__set = self._strict_set
77 for key in self.bad_cookies:
78 del self[key]
80 _strict_set = BaseCookie._BaseCookie__set
82 def _loose_set(self, key, real_value, coded_value):
83 try:
84 self._strict_set(key, real_value, coded_value)
85 except CookieError:
86 self.bad_cookies.append(key)
87 dict.__setitem__(self, key, None)
90 class Session:
91 '''
92 Needs DB to be already opened by client
94 Session attributes at instantiation:
96 - "client" - reference to client for add_cookie function
97 - "session_db" - session DB manager
98 - "cookie_name" - name of the cookie with session id
99 - "_sid" - session id for current user
100 - "_data" - session data cache
102 session = Session(client)
103 session.set(name=value)
104 value = session.get(name)
106 session.destroy() # delete current session
107 session.clean_up() # clean up session table
109 session.update(set_cookie=True, expire=3600*24*365)
110 # refresh session expiration time, setting persistent
111 # cookie if needed to last for 'expire' seconds
113 '''
115 def __init__(self, client):
116 self._data = {}
117 self._sid = None
119 self.client = client
120 self.session_db = client.db.getSessionManager()
122 # parse cookies for session id
123 self.cookie_name = 'roundup_session_%s' % \
124 re.sub('[^a-zA-Z]', '', client.instance.config.TRACKER_NAME)
125 cookies = LiberalCookie(client.env.get('HTTP_COOKIE', ''))
126 if self.cookie_name in cookies:
127 if not self.session_db.exists(cookies[self.cookie_name].value):
128 self._sid = None
129 # remove old cookie
130 self.client.add_cookie(self.cookie_name, None)
131 else:
132 self._sid = cookies[self.cookie_name].value
133 self._data = self.session_db.getall(self._sid)
135 def _gen_sid(self):
136 ''' generate a unique session key '''
137 while 1:
138 s = '%s%s'%(time.time(), random.random())
139 s = binascii.b2a_base64(s).strip()
140 if not self.session_db.exists(s):
141 break
143 # clean up the base64
144 if s[-1] == '=':
145 if s[-2] == '=':
146 s = s[:-2]
147 else:
148 s = s[:-1]
149 return s
151 def clean_up(self):
152 '''Remove expired sessions'''
153 self.session_db.clean()
155 def destroy(self):
156 self.client.add_cookie(self.cookie_name, None)
157 self._data = {}
158 self.session_db.destroy(self._sid)
159 self.client.db.commit()
161 def get(self, name, default=None):
162 return self._data.get(name, default)
164 def set(self, **kwargs):
165 self._data.update(kwargs)
166 if not self._sid:
167 self._sid = self._gen_sid()
168 self.session_db.set(self._sid, **self._data)
169 # add session cookie
170 self.update(set_cookie=True)
172 # XXX added when patching 1.4.4 for backward compatibility
173 # XXX remove
174 self.client.session = self._sid
175 else:
176 self.session_db.set(self._sid, **self._data)
177 self.client.db.commit()
179 def update(self, set_cookie=False, expire=None):
180 ''' update timestamp in db to avoid expiration
182 if 'set_cookie' is True, set cookie with 'expire' seconds lifetime
183 if 'expire' is None - session will be closed with the browser
185 XXX the session can be purged within a week even if a cookie
186 lifetime is longer
187 '''
188 self.session_db.updateTimestamp(self._sid)
189 self.client.db.commit()
191 if set_cookie:
192 self.client.add_cookie(self.cookie_name, self._sid, expire=expire)
196 class Client:
197 '''Instantiate to handle one CGI request.
199 See inner_main for request processing.
201 Client attributes at instantiation:
203 - "path" is the PATH_INFO inside the instance (with no leading '/')
204 - "base" is the base URL for the instance
205 - "form" is the cgi form, an instance of FieldStorage from the standard
206 cgi module
207 - "additional_headers" is a dictionary of additional HTTP headers that
208 should be sent to the client
209 - "response_code" is the HTTP response code to send to the client
210 - "translator" is TranslationService instance
212 During the processing of a request, the following attributes are used:
214 - "db"
215 - "error_message" holds a list of error messages
216 - "ok_message" holds a list of OK messages
217 - "session" is deprecated in favor of session_api (XXX remove)
218 - "session_api" is the interface to store data in session
219 - "user" is the current user's name
220 - "userid" is the current user's id
221 - "template" is the current :template context
222 - "classname" is the current class context name
223 - "nodeid" is the current context item id
225 User Identification:
226 Users that are absent in session data are anonymous and are logged
227 in as that user. This typically gives them all Permissions assigned to the
228 Anonymous Role.
230 Every user is assigned a session. "session_api" is the interface to work
231 with session data.
233 Special form variables:
234 Note that in various places throughout this code, special form
235 variables of the form :<name> are used. The colon (":") part may
236 actually be one of either ":" or "@".
237 '''
239 # charset used for data storage and form templates
240 # Note: must be in lower case for comparisons!
241 # XXX take this from instance.config?
242 STORAGE_CHARSET = 'utf-8'
244 #
245 # special form variables
246 #
247 FV_TEMPLATE = re.compile(r'[@:]template')
248 FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
249 FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
251 # Note: index page stuff doesn't appear here:
252 # columns, sort, sortdir, filter, group, groupdir, search_text,
253 # pagesize, startwith
255 # list of network error codes that shouldn't be reported to tracker admin
256 # (error descriptions from FreeBSD intro(2))
257 IGNORE_NET_ERRORS = (
258 # A write on a pipe, socket or FIFO for which there is
259 # no process to read the data.
260 errno.EPIPE,
261 # A connection was forcibly closed by a peer.
262 # This normally results from a loss of the connection
263 # on the remote socket due to a timeout or a reboot.
264 errno.ECONNRESET,
265 # Software caused connection abort. A connection abort
266 # was caused internal to your host machine.
267 errno.ECONNABORTED,
268 # A connect or send request failed because the connected party
269 # did not properly respond after a period of time.
270 errno.ETIMEDOUT,
271 )
273 def __init__(self, instance, request, env, form=None, translator=None):
274 # re-seed the random number generator
275 random.seed()
276 self.start = time.time()
277 self.instance = instance
278 self.request = request
279 self.env = env
280 self.setTranslator(translator)
281 self.mailer = Mailer(instance.config)
283 # save off the path
284 self.path = env['PATH_INFO']
286 # this is the base URL for this tracker
287 self.base = self.instance.config.TRACKER_WEB
289 # check the tracker_we setting
290 if not self.base.endswith('/'):
291 self.base = self.base + '/'
293 # this is the "cookie path" for this tracker (ie. the path part of
294 # the "base" url)
295 self.cookie_path = urlparse.urlparse(self.base)[2]
296 # cookies to set in http responce
297 # {(path, name): (value, expire)}
298 self._cookies = {}
300 # see if we need to re-parse the environment for the form (eg Zope)
301 if form is None:
302 self.form = cgi.FieldStorage(environ=env)
303 else:
304 self.form = form
306 # turn debugging on/off
307 try:
308 self.debug = int(env.get("ROUNDUP_DEBUG", 0))
309 except ValueError:
310 # someone gave us a non-int debug level, turn it off
311 self.debug = 0
313 # flag to indicate that the HTTP headers have been sent
314 self.headers_done = 0
316 # additional headers to send with the request - must be registered
317 # before the first write
318 self.additional_headers = {}
319 self.response_code = 200
321 # default character set
322 self.charset = self.STORAGE_CHARSET
324 # parse cookies (used for charset lookups)
325 # use our own LiberalCookie to handle bad apps on the same
326 # server that have set cookies that are out of spec
327 self.cookie = LiberalCookie(self.env.get('HTTP_COOKIE', ''))
329 self.user = None
330 self.userid = None
331 self.nodeid = None
332 self.classname = None
333 self.template = None
335 def setTranslator(self, translator=None):
336 """Replace the translation engine
338 'translator'
339 is TranslationService instance.
340 It must define methods 'translate' (TAL-compatible i18n),
341 'gettext' and 'ngettext' (gettext-compatible i18n).
343 If omitted, create default TranslationService.
344 """
345 if translator is None:
346 translator = TranslationService.get_translation(
347 language=self.instance.config["TRACKER_LANGUAGE"],
348 tracker_home=self.instance.config["TRACKER_HOME"])
349 self.translator = translator
350 self._ = self.gettext = translator.gettext
351 self.ngettext = translator.ngettext
353 def main(self):
354 ''' Wrap the real main in a try/finally so we always close off the db.
355 '''
356 try:
357 self.inner_main()
358 finally:
359 if hasattr(self, 'db'):
360 self.db.close()
362 def inner_main(self):
363 '''Process a request.
365 The most common requests are handled like so:
367 1. look for charset and language preferences, set up user locale
368 see determine_charset, determine_language
369 2. figure out who we are, defaulting to the "anonymous" user
370 see determine_user
371 3. figure out what the request is for - the context
372 see determine_context
373 4. handle any requested action (item edit, search, ...)
374 see handle_action
375 5. render a template, resulting in HTML output
377 In some situations, exceptions occur:
379 - HTTP Redirect (generally raised by an action)
380 - SendFile (generally raised by determine_context)
381 serve up a FileClass "content" property
382 - SendStaticFile (generally raised by determine_context)
383 serve up a file from the tracker "html" directory
384 - Unauthorised (generally raised by an action)
385 the action is cancelled, the request is rendered and an error
386 message is displayed indicating that permission was not
387 granted for the action to take place
388 - templating.Unauthorised (templating action not permitted)
389 raised by an attempted rendering of a template when the user
390 doesn't have permission
391 - NotFound (raised wherever it needs to be)
392 percolates up to the CGI interface that called the client
393 '''
394 self.ok_message = []
395 self.error_message = []
396 try:
397 self.determine_charset()
398 self.determine_language()
400 # make sure we're identified (even anonymously)
401 self.determine_user()
403 # figure out the context and desired content template
404 self.determine_context()
406 # possibly handle a form submit action (may change self.classname
407 # and self.template, and may also append error/ok_messages)
408 html = self.handle_action()
410 if html:
411 self.write_html(html)
412 return
414 # now render the page
415 # we don't want clients caching our dynamic pages
416 self.additional_headers['Cache-Control'] = 'no-cache'
417 # Pragma: no-cache makes Mozilla and its ilk double-load all pages!!
418 # self.additional_headers['Pragma'] = 'no-cache'
420 # pages with messages added expire right now
421 # simple views may be cached for a small amount of time
422 # TODO? make page expire time configurable
423 # <rj> always expire pages, as IE just doesn't seem to do the
424 # right thing here :(
425 date = time.time() - 1
426 #if self.error_message or self.ok_message:
427 # date = time.time() - 1
428 #else:
429 # date = time.time() + 5
430 self.additional_headers['Expires'] = rfc822.formatdate(date)
432 # render the content
433 try:
434 self.write_html(self.renderContext())
435 except IOError:
436 # IOErrors here are due to the client disconnecting before
437 # recieving the reply.
438 pass
440 except SeriousError, message:
441 self.write_html(str(message))
442 except Redirect, url:
443 # let's redirect - if the url isn't None, then we need to do
444 # the headers, otherwise the headers have been set before the
445 # exception was raised
446 if url:
447 self.additional_headers['Location'] = str(url)
448 self.response_code = 302
449 self.write_html('Redirecting to <a href="%s">%s</a>'%(url, url))
450 except SendFile, designator:
451 try:
452 self.serve_file(designator)
453 except NotModified:
454 # send the 304 response
455 self.response_code = 304
456 self.header()
457 except SendStaticFile, file:
458 try:
459 self.serve_static_file(str(file))
460 except NotModified:
461 # send the 304 response
462 self.response_code = 304
463 self.header()
464 except Unauthorised, message:
465 # users may always see the front page
466 self.classname = self.nodeid = None
467 self.template = ''
468 self.error_message.append(message)
469 self.write_html(self.renderContext())
470 except NotFound, e:
471 self.response_code = 404
472 self.template = '404'
473 try:
474 cl = self.db.getclass(self.classname)
475 self.write_html(self.renderContext())
476 except KeyError:
477 # we can't map the URL to a class we know about
478 # reraise the NotFound and let roundup_server
479 # handle it
480 raise NotFound, e
481 except FormError, e:
482 self.error_message.append(self._('Form Error: ') + str(e))
483 self.write_html(self.renderContext())
484 except:
485 if self.instance.config.WEB_DEBUG:
486 self.write_html(cgitb.html(i18n=self.translator))
487 else:
488 self.mailer.exception_message()
489 return self.write_html(self._(error_message))
491 def clean_sessions(self):
492 """Deprecated
493 XXX remove
494 """
495 self.clean_up()
497 def clean_up(self):
498 """Remove expired sessions and One Time Keys.
500 Do it only once an hour.
501 """
502 hour = 60*60
503 now = time.time()
505 # XXX: hack - use OTK table to store last_clean time information
506 # 'last_clean' string is used instead of otk key
507 last_clean = self.db.getOTKManager().get('last_clean', 'last_use', 0)
508 if now - last_clean < hour:
509 return
511 self.session_api.clean_up()
512 self.db.getOTKManager().clean()
513 self.db.getOTKManager().set('last_clean', last_use=now)
514 self.db.commit(fail_ok=True)
516 def determine_charset(self):
517 """Look for client charset in the form parameters or browser cookie.
519 If no charset requested by client, use storage charset (utf-8).
521 If the charset is found, and differs from the storage charset,
522 recode all form fields of type 'text/plain'
523 """
524 # look for client charset
525 charset_parameter = 0
526 if self.form.has_key('@charset'):
527 charset = self.form['@charset'].value
528 if charset.lower() == "none":
529 charset = ""
530 charset_parameter = 1
531 elif self.cookie.has_key('roundup_charset'):
532 charset = self.cookie['roundup_charset'].value
533 else:
534 charset = None
535 if charset:
536 # make sure the charset is recognized
537 try:
538 codecs.lookup(charset)
539 except LookupError:
540 self.error_message.append(self._('Unrecognized charset: %r')
541 % charset)
542 charset_parameter = 0
543 else:
544 self.charset = charset.lower()
545 # If we've got a character set in request parameters,
546 # set the browser cookie to keep the preference.
547 # This is done after codecs.lookup to make sure
548 # that we aren't keeping a wrong value.
549 if charset_parameter:
550 self.add_cookie('roundup_charset', charset)
552 # if client charset is different from the storage charset,
553 # recode form fields
554 # XXX this requires FieldStorage from Python library.
555 # mod_python FieldStorage is not supported!
556 if self.charset != self.STORAGE_CHARSET:
557 decoder = codecs.getdecoder(self.charset)
558 encoder = codecs.getencoder(self.STORAGE_CHARSET)
559 re_charref = re.compile('&#([0-9]+|x[0-9a-f]+);', re.IGNORECASE)
560 def _decode_charref(matchobj):
561 num = matchobj.group(1)
562 if num[0].lower() == 'x':
563 uc = int(num[1:], 16)
564 else:
565 uc = int(num)
566 return unichr(uc)
568 for field_name in self.form.keys():
569 field = self.form[field_name]
570 if (field.type == 'text/plain') and not field.filename:
571 try:
572 value = decoder(field.value)[0]
573 except UnicodeError:
574 continue
575 value = re_charref.sub(_decode_charref, value)
576 field.value = encoder(value)[0]
578 def determine_language(self):
579 """Determine the language"""
580 # look for language parameter
581 # then for language cookie
582 # last for the Accept-Language header
583 if self.form.has_key("@language"):
584 language = self.form["@language"].value
585 if language.lower() == "none":
586 language = ""
587 self.add_cookie("roundup_language", language)
588 elif self.cookie.has_key("roundup_language"):
589 language = self.cookie["roundup_language"].value
590 elif self.instance.config["WEB_USE_BROWSER_LANGUAGE"]:
591 hal = self.env.get('HTTP_ACCEPT_LANGUAGE')
592 language = accept_language.parse(hal)
593 else:
594 language = ""
596 self.language = language
597 if language:
598 self.setTranslator(TranslationService.get_translation(
599 language,
600 tracker_home=self.instance.config["TRACKER_HOME"]))
602 def determine_user(self):
603 """Determine who the user is"""
604 self.opendb('admin')
606 # get session data from db
607 # XXX: rename
608 self.session_api = Session(self)
610 # take the opportunity to cleanup expired sessions and otks
611 self.clean_up()
613 user = None
614 # first up, try http authorization if enabled
615 if self.instance.config['WEB_HTTP_AUTH']:
616 if self.env.has_key('REMOTE_USER'):
617 # we have external auth (e.g. by Apache)
618 user = self.env['REMOTE_USER']
619 elif self.env.get('HTTP_AUTHORIZATION', ''):
620 # try handling Basic Auth ourselves
621 auth = self.env['HTTP_AUTHORIZATION']
622 scheme, challenge = auth.split(' ', 1)
623 if scheme.lower() == 'basic':
624 try:
625 decoded = base64.decodestring(challenge)
626 except TypeError:
627 # invalid challenge
628 pass
629 username, password = decoded.split(':')
630 try:
631 login = self.get_action_class('login')(self)
632 login.verifyLogin(username, password)
633 except LoginError, err:
634 self.make_user_anonymous()
635 self.response_code = 403
636 raise Unauthorised, err
638 user = username
640 # if user was not set by http authorization, try session lookup
641 if not user:
642 user = self.session_api.get('user')
643 if user:
644 # update session lifetime datestamp
645 self.session_api.update()
647 # if no user name set by http authorization or session lookup
648 # the user is anonymous
649 if not user:
650 user = 'anonymous'
652 # sanity check on the user still being valid,
653 # getting the userid at the same time
654 try:
655 self.userid = self.db.user.lookup(user)
656 except (KeyError, TypeError):
657 user = 'anonymous'
659 # make sure the anonymous user is valid if we're using it
660 if user == 'anonymous':
661 self.make_user_anonymous()
662 if not self.db.security.hasPermission('Web Access', self.userid):
663 raise Unauthorised, self._("Anonymous users are not "
664 "allowed to use the web interface")
665 else:
666 self.user = user
668 # reopen the database as the correct user
669 self.opendb(self.user)
671 def opendb(self, username):
672 """Open the database and set the current user.
674 Opens a database once. On subsequent calls only the user is set on
675 the database object the instance.optimize is set. If we are in
676 "Development Mode" (cf. roundup_server) then the database is always
677 re-opened.
678 """
679 # don't do anything if the db is open and the user has not changed
680 if hasattr(self, 'db') and self.db.isCurrentUser(username):
681 return
683 # open the database or only set the user
684 if not hasattr(self, 'db'):
685 self.db = self.instance.open(username)
686 else:
687 if self.instance.optimize:
688 self.db.setCurrentUser(username)
689 else:
690 self.db.close()
691 self.db = self.instance.open(username)
693 def determine_context(self, dre=re.compile(r'([^\d]+)0*(\d+)')):
694 """Determine the context of this page from the URL:
696 The URL path after the instance identifier is examined. The path
697 is generally only one entry long.
699 - if there is no path, then we are in the "home" context.
700 - if the path is "_file", then the additional path entry
701 specifies the filename of a static file we're to serve up
702 from the instance "html" directory. Raises a SendStaticFile
703 exception.(*)
704 - if there is something in the path (eg "issue"), it identifies
705 the tracker class we're to display.
706 - if the path is an item designator (eg "issue123"), then we're
707 to display a specific item.
708 - if the path starts with an item designator and is longer than
709 one entry, then we're assumed to be handling an item of a
710 FileClass, and the extra path information gives the filename
711 that the client is going to label the download with (ie
712 "file123/image.png" is nicer to download than "file123"). This
713 raises a SendFile exception.(*)
715 Both of the "*" types of contexts stop before we bother to
716 determine the template we're going to use. That's because they
717 don't actually use templates.
719 The template used is specified by the :template CGI variable,
720 which defaults to:
722 - only classname suplied: "index"
723 - full item designator supplied: "item"
725 We set:
727 self.classname - the class to display, can be None
729 self.template - the template to render the current context with
731 self.nodeid - the nodeid of the class we're displaying
732 """
733 # default the optional variables
734 self.classname = None
735 self.nodeid = None
737 # see if a template or messages are specified
738 template_override = ok_message = error_message = None
739 for key in self.form.keys():
740 if self.FV_TEMPLATE.match(key):
741 template_override = self.form[key].value
742 elif self.FV_OK_MESSAGE.match(key):
743 ok_message = self.form[key].value
744 ok_message = clean_message(ok_message)
745 elif self.FV_ERROR_MESSAGE.match(key):
746 error_message = self.form[key].value
747 error_message = clean_message(error_message)
749 # see if we were passed in a message
750 if ok_message:
751 self.ok_message.append(ok_message)
752 if error_message:
753 self.error_message.append(error_message)
755 # determine the classname and possibly nodeid
756 path = self.path.split('/')
757 if not path or path[0] in ('', 'home', 'index'):
758 if template_override is not None:
759 self.template = template_override
760 else:
761 self.template = ''
762 return
763 elif path[0] in ('_file', '@@file'):
764 raise SendStaticFile, os.path.join(*path[1:])
765 else:
766 self.classname = path[0]
767 if len(path) > 1:
768 # send the file identified by the designator in path[0]
769 raise SendFile, path[0]
771 # see if we got a designator
772 m = dre.match(self.classname)
773 if m:
774 self.classname = m.group(1)
775 self.nodeid = m.group(2)
776 try:
777 klass = self.db.getclass(self.classname)
778 except KeyError:
779 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
780 if not klass.hasnode(self.nodeid):
781 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
782 # with a designator, we default to item view
783 self.template = 'item'
784 else:
785 # with only a class, we default to index view
786 self.template = 'index'
788 # make sure the classname is valid
789 try:
790 self.db.getclass(self.classname)
791 except KeyError:
792 raise NotFound, self.classname
794 # see if we have a template override
795 if template_override is not None:
796 self.template = template_override
798 def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
799 ''' Serve the file from the content property of the designated item.
800 '''
801 m = dre.match(str(designator))
802 if not m:
803 raise NotFound, str(designator)
804 classname, nodeid = m.group(1), m.group(2)
806 klass = self.db.getclass(classname)
808 # make sure we have the appropriate properties
809 props = klass.getprops()
810 if not props.has_key('type'):
811 raise NotFound, designator
812 if not props.has_key('content'):
813 raise NotFound, designator
815 # make sure we have permission
816 if not self.db.security.hasPermission('View', self.userid,
817 classname, 'content', nodeid):
818 raise Unauthorised, self._("You are not allowed to view "
819 "this file.")
821 mime_type = klass.get(nodeid, 'type')
823 # If this object is a file (i.e., an instance of FileClass),
824 # see if we can find it in the filesystem. If so, we may be
825 # able to use the more-efficient request.sendfile method of
826 # sending the file. If not, just get the "content" property
827 # in the usual way, and use that.
828 content = None
829 filename = None
830 if isinstance(klass, hyperdb.FileClass):
831 try:
832 filename = self.db.filename(classname, nodeid)
833 except AttributeError:
834 # The database doesn't store files in the filesystem
835 # and therefore doesn't provide the "filename" method.
836 pass
837 except IOError:
838 # The file does not exist.
839 pass
840 if not filename:
841 content = klass.get(nodeid, 'content')
843 lmt = klass.get(nodeid, 'activity').timestamp()
845 self._serve_file(lmt, mime_type, content, filename)
847 def serve_static_file(self, file):
848 ''' Serve up the file named from the templates dir
849 '''
850 # figure the filename - try STATIC_FILES, then TEMPLATES dir
851 for dir_option in ('STATIC_FILES', 'TEMPLATES'):
852 prefix = self.instance.config[dir_option]
853 if not prefix:
854 continue
855 # ensure the load doesn't try to poke outside
856 # of the static files directory
857 prefix = os.path.normpath(prefix)
858 filename = os.path.normpath(os.path.join(prefix, file))
859 if os.path.isfile(filename) and filename.startswith(prefix):
860 break
861 else:
862 raise NotFound, file
864 # last-modified time
865 lmt = os.stat(filename)[stat.ST_MTIME]
867 # detemine meta-type
868 file = str(file)
869 mime_type = mimetypes.guess_type(file)[0]
870 if not mime_type:
871 if file.endswith('.css'):
872 mime_type = 'text/css'
873 else:
874 mime_type = 'text/plain'
876 self._serve_file(lmt, mime_type, '', filename)
878 def _serve_file(self, lmt, mime_type, content=None, filename=None):
879 ''' guts of serve_file() and serve_static_file()
880 '''
882 if not content:
883 length = os.stat(filename)[stat.ST_SIZE]
884 else:
885 length = len(content)
887 # spit out headers
888 self.additional_headers['Content-Type'] = mime_type
889 self.additional_headers['Content-Length'] = str(length)
890 self.additional_headers['Last-Modified'] = rfc822.formatdate(lmt)
892 ims = None
893 # see if there's an if-modified-since...
894 # XXX see which interfaces set this
895 #if hasattr(self.request, 'headers'):
896 #ims = self.request.headers.getheader('if-modified-since')
897 if self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
898 # cgi will put the header in the env var
899 ims = self.env['HTTP_IF_MODIFIED_SINCE']
900 if ims:
901 ims = rfc822.parsedate(ims)[:6]
902 lmtt = time.gmtime(lmt)[:6]
903 if lmtt <= ims:
904 raise NotModified
906 if not self.headers_done:
907 self.header()
909 if self.env['REQUEST_METHOD'] == 'HEAD':
910 return
912 # If we have a file, and the 'sendfile' method is available,
913 # we can bypass reading and writing the content into application
914 # memory entirely.
915 if filename:
916 if hasattr(self.request, 'sendfile'):
917 self._socket_op(self.request.sendfile, filename)
918 return
919 f = open(filename, 'rb')
920 try:
921 content = f.read()
922 finally:
923 f.close()
925 self._socket_op(self.request.wfile.write, content)
928 def renderContext(self):
929 ''' Return a PageTemplate for the named page
930 '''
931 name = self.classname
932 extension = self.template
934 # catch errors so we can handle PT rendering errors more nicely
935 args = {
936 'ok_message': self.ok_message,
937 'error_message': self.error_message
938 }
939 try:
940 pt = self.instance.templates.get(name, extension)
941 # let the template render figure stuff out
942 result = pt.render(self, None, None, **args)
943 self.additional_headers['Content-Type'] = pt.content_type
944 if self.env.get('CGI_SHOW_TIMING', ''):
945 if self.env['CGI_SHOW_TIMING'].upper() == 'COMMENT':
946 timings = {'starttag': '<!-- ', 'endtag': ' -->'}
947 else:
948 timings = {'starttag': '<p>', 'endtag': '</p>'}
949 timings['seconds'] = time.time()-self.start
950 s = self._('%(starttag)sTime elapsed: %(seconds)fs%(endtag)s\n'
951 ) % timings
952 if hasattr(self.db, 'stats'):
953 timings.update(self.db.stats)
954 s += self._("%(starttag)sCache hits: %(cache_hits)d,"
955 " misses %(cache_misses)d."
956 " Loading items: %(get_items)f secs."
957 " Filtering: %(filtering)f secs."
958 "%(endtag)s\n") % timings
959 s += '</body>'
960 result = result.replace('</body>', s)
961 return result
962 except templating.NoTemplate, message:
963 return '<strong>%s</strong>'%message
964 except templating.Unauthorised, message:
965 raise Unauthorised, str(message)
966 except:
967 # everything else
968 if self.instance.config.WEB_DEBUG:
969 return cgitb.pt_html(i18n=self.translator)
970 exc_info = sys.exc_info()
971 try:
972 # If possible, send the HTML page template traceback
973 # to the administrator.
974 to = [self.mailer.config.ADMIN_EMAIL]
975 subject = "Templating Error: %s" % exc_info[1]
976 content = cgitb.pt_html()
977 message, writer = self.mailer.get_standard_message(
978 to, subject)
979 writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
980 body = writer.startbody('text/html; charset=utf-8')
981 content = StringIO(content)
982 quopri.encode(content, body, 0)
983 self.mailer.smtp_send(to, message)
984 # Now report the error to the user.
985 return self._(error_message)
986 except:
987 # Reraise the original exception. The user will
988 # receive an error message, and the adminstrator will
989 # receive a traceback, albeit with less information
990 # than the one we tried to generate above.
991 raise exc_info[0], exc_info[1], exc_info[2]
993 # these are the actions that are available
994 actions = (
995 ('edit', EditItemAction),
996 ('editcsv', EditCSVAction),
997 ('new', NewItemAction),
998 ('register', RegisterAction),
999 ('confrego', ConfRegoAction),
1000 ('passrst', PassResetAction),
1001 ('login', LoginAction),
1002 ('logout', LogoutAction),
1003 ('search', SearchAction),
1004 ('retire', RetireAction),
1005 ('show', ShowAction),
1006 ('export_csv', ExportCSVAction),
1007 )
1008 def handle_action(self):
1009 ''' Determine whether there should be an Action called.
1011 The action is defined by the form variable :action which
1012 identifies the method on this object to call. The actions
1013 are defined in the "actions" sequence on this class.
1015 Actions may return a page (by default HTML) to return to the
1016 user, bypassing the usual template rendering.
1018 We explicitly catch Reject and ValueError exceptions and
1019 present their messages to the user.
1020 '''
1021 if self.form.has_key(':action'):
1022 action = self.form[':action'].value.lower()
1023 elif self.form.has_key('@action'):
1024 action = self.form['@action'].value.lower()
1025 else:
1026 return None
1028 try:
1029 action_klass = self.get_action_class(action)
1031 # call the mapped action
1032 if isinstance(action_klass, type('')):
1033 # old way of specifying actions
1034 return getattr(self, action_klass)()
1035 else:
1036 return action_klass(self).execute()
1038 except (ValueError, Reject), err:
1039 self.error_message.append(str(err))
1041 def get_action_class(self, action_name):
1042 if (hasattr(self.instance, 'cgi_actions') and
1043 self.instance.cgi_actions.has_key(action_name)):
1044 # tracker-defined action
1045 action_klass = self.instance.cgi_actions[action_name]
1046 else:
1047 # go with a default
1048 for name, action_klass in self.actions:
1049 if name == action_name:
1050 break
1051 else:
1052 raise ValueError, 'No such action "%s"'%action_name
1053 return action_klass
1055 def _socket_op(self, call, *args, **kwargs):
1056 """Execute socket-related operation, catch common network errors
1058 Parameters:
1059 call: a callable to execute
1060 args, kwargs: call arguments
1062 """
1063 try:
1064 call(*args, **kwargs)
1065 except socket.error, err:
1066 err_errno = getattr (err, 'errno', None)
1067 if err_errno is None:
1068 try:
1069 err_errno = err[0]
1070 except TypeError:
1071 pass
1072 if err_errno not in self.IGNORE_NET_ERRORS:
1073 raise
1075 def write(self, content):
1076 if not self.headers_done:
1077 self.header()
1078 if self.env['REQUEST_METHOD'] != 'HEAD':
1079 self._socket_op(self.request.wfile.write, content)
1081 def write_html(self, content):
1082 if not self.headers_done:
1083 # at this point, we are sure about Content-Type
1084 if not self.additional_headers.has_key('Content-Type'):
1085 self.additional_headers['Content-Type'] = \
1086 'text/html; charset=%s' % self.charset
1087 self.header()
1089 if self.env['REQUEST_METHOD'] == 'HEAD':
1090 # client doesn't care about content
1091 return
1093 if self.charset != self.STORAGE_CHARSET:
1094 # recode output
1095 content = content.decode(self.STORAGE_CHARSET, 'replace')
1096 content = content.encode(self.charset, 'xmlcharrefreplace')
1098 # and write
1099 self._socket_op(self.request.wfile.write, content)
1102 def setHeader(self, header, value):
1103 '''Override a header to be returned to the user's browser.
1104 '''
1105 self.additional_headers[header] = value
1107 def header(self, headers=None, response=None):
1108 '''Put up the appropriate header.
1109 '''
1110 if headers is None:
1111 headers = {'Content-Type':'text/html; charset=utf-8'}
1112 if response is None:
1113 response = self.response_code
1115 # update with additional info
1116 headers.update(self.additional_headers)
1118 if headers.get('Content-Type', 'text/html') == 'text/html':
1119 headers['Content-Type'] = 'text/html; charset=utf-8'
1121 headers = headers.items()
1123 for ((path, name), (value, expire)) in self._cookies.items():
1124 cookie = "%s=%s; Path=%s;"%(name, value, path)
1125 if expire is not None:
1126 cookie += " expires=%s;"%Cookie._getdate(expire)
1127 headers.append(('Set-Cookie', cookie))
1129 self._socket_op(self.request.start_response, headers, response)
1131 self.headers_done = 1
1132 if self.debug:
1133 self.headers_sent = headers
1135 def add_cookie(self, name, value, expire=86400*365, path=None):
1136 """Set a cookie value to be sent in HTTP headers
1138 Parameters:
1139 name:
1140 cookie name
1141 value:
1142 cookie value
1143 expire:
1144 cookie expiration time (seconds).
1145 If value is empty (meaning "delete cookie"),
1146 expiration time is forced in the past
1147 and this argument is ignored.
1148 If None, the cookie will expire at end-of-session.
1149 If omitted, the cookie will be kept for a year.
1150 path:
1151 cookie path (optional)
1153 """
1154 if path is None:
1155 path = self.cookie_path
1156 if not value:
1157 expire = -1
1158 self._cookies[(path, name)] = (value, expire)
1160 def set_cookie(self, user, expire=None):
1161 """Deprecated. Use session_api calls directly
1163 XXX remove
1164 """
1166 # insert the session in the session db
1167 self.session_api.set(user=user)
1168 # refresh session cookie
1169 self.session_api.update(set_cookie=True, expire=expire)
1171 def make_user_anonymous(self):
1172 ''' Make us anonymous
1174 This method used to handle non-existence of the 'anonymous'
1175 user, but that user is mandatory now.
1176 '''
1177 self.userid = self.db.user.lookup('anonymous')
1178 self.user = 'anonymous'
1180 def standard_message(self, to, subject, body, author=None):
1181 '''Send a standard email message from Roundup.
1183 "to" - recipients list
1184 "subject" - Subject
1185 "body" - Message
1186 "author" - (name, address) tuple or None for admin email
1188 Arguments are passed to the Mailer.standard_message code.
1189 '''
1190 try:
1191 self.mailer.standard_message(to, subject, body, author)
1192 except MessageSendError, e:
1193 self.error_message.append(str(e))
1194 return 0
1195 return 1
1197 def parsePropsFromForm(self, create=0):
1198 return FormParser(self).parse(create=create)
1200 # vim: set et sts=4 sw=4 :