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')
822 content = klass.get(nodeid, 'content')
823 lmt = klass.get(nodeid, 'activity').timestamp()
825 self._serve_file(lmt, mime_type, content)
827 def serve_static_file(self, file):
828 ''' Serve up the file named from the templates dir
829 '''
830 # figure the filename - try STATIC_FILES, then TEMPLATES dir
831 for dir_option in ('STATIC_FILES', 'TEMPLATES'):
832 prefix = self.instance.config[dir_option]
833 if not prefix:
834 continue
835 # ensure the load doesn't try to poke outside
836 # of the static files directory
837 prefix = os.path.normpath(prefix)
838 filename = os.path.normpath(os.path.join(prefix, file))
839 if os.path.isfile(filename) and filename.startswith(prefix):
840 break
841 else:
842 raise NotFound, file
844 # last-modified time
845 lmt = os.stat(filename)[stat.ST_MTIME]
847 # detemine meta-type
848 file = str(file)
849 mime_type = mimetypes.guess_type(file)[0]
850 if not mime_type:
851 if file.endswith('.css'):
852 mime_type = 'text/css'
853 else:
854 mime_type = 'text/plain'
856 # snarf the content
857 f = open(filename, 'rb')
858 try:
859 content = f.read()
860 finally:
861 f.close()
863 self._serve_file(lmt, mime_type, content)
865 def _serve_file(self, lmt, mime_type, content):
866 ''' guts of serve_file() and serve_static_file()
867 '''
868 # spit out headers
869 self.additional_headers['Content-Type'] = mime_type
870 self.additional_headers['Content-Length'] = str(len(content))
871 self.additional_headers['Last-Modified'] = rfc822.formatdate(lmt)
873 ims = None
874 # see if there's an if-modified-since...
875 # XXX see which interfaces set this
876 #if hasattr(self.request, 'headers'):
877 #ims = self.request.headers.getheader('if-modified-since')
878 if self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
879 # cgi will put the header in the env var
880 ims = self.env['HTTP_IF_MODIFIED_SINCE']
881 if ims:
882 ims = rfc822.parsedate(ims)[:6]
883 lmtt = time.gmtime(lmt)[:6]
884 if lmtt <= ims:
885 raise NotModified
887 self.write(content)
889 def renderContext(self):
890 ''' Return a PageTemplate for the named page
891 '''
892 name = self.classname
893 extension = self.template
895 # catch errors so we can handle PT rendering errors more nicely
896 args = {
897 'ok_message': self.ok_message,
898 'error_message': self.error_message
899 }
900 try:
901 pt = self.instance.templates.get(name, extension)
902 # let the template render figure stuff out
903 result = pt.render(self, None, None, **args)
904 self.additional_headers['Content-Type'] = pt.content_type
905 if self.env.get('CGI_SHOW_TIMING', ''):
906 if self.env['CGI_SHOW_TIMING'].upper() == 'COMMENT':
907 timings = {'starttag': '<!-- ', 'endtag': ' -->'}
908 else:
909 timings = {'starttag': '<p>', 'endtag': '</p>'}
910 timings['seconds'] = time.time()-self.start
911 s = self._('%(starttag)sTime elapsed: %(seconds)fs%(endtag)s\n'
912 ) % timings
913 if hasattr(self.db, 'stats'):
914 timings.update(self.db.stats)
915 s += self._("%(starttag)sCache hits: %(cache_hits)d,"
916 " misses %(cache_misses)d."
917 " Loading items: %(get_items)f secs."
918 " Filtering: %(filtering)f secs."
919 "%(endtag)s\n") % timings
920 s += '</body>'
921 result = result.replace('</body>', s)
922 return result
923 except templating.NoTemplate, message:
924 return '<strong>%s</strong>'%message
925 except templating.Unauthorised, message:
926 raise Unauthorised, str(message)
927 except:
928 # everything else
929 if self.instance.config.WEB_DEBUG:
930 return cgitb.pt_html(i18n=self.translator)
931 exc_info = sys.exc_info()
932 try:
933 # If possible, send the HTML page template traceback
934 # to the administrator.
935 to = [self.mailer.config.ADMIN_EMAIL]
936 subject = "Templating Error: %s" % exc_info[1]
937 content = cgitb.pt_html()
938 message, writer = self.mailer.get_standard_message(
939 to, subject)
940 writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
941 body = writer.startbody('text/html; charset=utf-8')
942 content = StringIO(content)
943 quopri.encode(content, body, 0)
944 self.mailer.smtp_send(to, message)
945 # Now report the error to the user.
946 return self._(error_message)
947 except:
948 # Reraise the original exception. The user will
949 # receive an error message, and the adminstrator will
950 # receive a traceback, albeit with less information
951 # than the one we tried to generate above.
952 raise exc_info[0], exc_info[1], exc_info[2]
954 # these are the actions that are available
955 actions = (
956 ('edit', EditItemAction),
957 ('editcsv', EditCSVAction),
958 ('new', NewItemAction),
959 ('register', RegisterAction),
960 ('confrego', ConfRegoAction),
961 ('passrst', PassResetAction),
962 ('login', LoginAction),
963 ('logout', LogoutAction),
964 ('search', SearchAction),
965 ('retire', RetireAction),
966 ('show', ShowAction),
967 ('export_csv', ExportCSVAction),
968 )
969 def handle_action(self):
970 ''' Determine whether there should be an Action called.
972 The action is defined by the form variable :action which
973 identifies the method on this object to call. The actions
974 are defined in the "actions" sequence on this class.
976 Actions may return a page (by default HTML) to return to the
977 user, bypassing the usual template rendering.
979 We explicitly catch Reject and ValueError exceptions and
980 present their messages to the user.
981 '''
982 if self.form.has_key(':action'):
983 action = self.form[':action'].value.lower()
984 elif self.form.has_key('@action'):
985 action = self.form['@action'].value.lower()
986 else:
987 return None
989 try:
990 action_klass = self.get_action_class(action)
992 # call the mapped action
993 if isinstance(action_klass, type('')):
994 # old way of specifying actions
995 return getattr(self, action_klass)()
996 else:
997 return action_klass(self).execute()
999 except (ValueError, Reject), err:
1000 self.error_message.append(str(err))
1002 def get_action_class(self, action_name):
1003 if (hasattr(self.instance, 'cgi_actions') and
1004 self.instance.cgi_actions.has_key(action_name)):
1005 # tracker-defined action
1006 action_klass = self.instance.cgi_actions[action_name]
1007 else:
1008 # go with a default
1009 for name, action_klass in self.actions:
1010 if name == action_name:
1011 break
1012 else:
1013 raise ValueError, 'No such action "%s"'%action_name
1014 return action_klass
1016 def _socket_op(self, call, *args, **kwargs):
1017 """Execute socket-related operation, catch common network errors
1019 Parameters:
1020 call: a callable to execute
1021 args, kwargs: call arguments
1023 """
1024 try:
1025 call(*args, **kwargs)
1026 except socket.error, err:
1027 err_errno = getattr (err, 'errno', None)
1028 if err_errno is None:
1029 try:
1030 err_errno = err[0]
1031 except TypeError:
1032 pass
1033 if err_errno not in self.IGNORE_NET_ERRORS:
1034 raise
1036 def write(self, content):
1037 if not self.headers_done:
1038 self.header()
1039 if self.env['REQUEST_METHOD'] != 'HEAD':
1040 self._socket_op(self.request.wfile.write, content)
1042 def write_html(self, content):
1043 if not self.headers_done:
1044 # at this point, we are sure about Content-Type
1045 if not self.additional_headers.has_key('Content-Type'):
1046 self.additional_headers['Content-Type'] = \
1047 'text/html; charset=%s' % self.charset
1048 self.header()
1050 if self.env['REQUEST_METHOD'] == 'HEAD':
1051 # client doesn't care about content
1052 return
1054 if self.charset != self.STORAGE_CHARSET:
1055 # recode output
1056 content = content.decode(self.STORAGE_CHARSET, 'replace')
1057 content = content.encode(self.charset, 'xmlcharrefreplace')
1059 # and write
1060 self._socket_op(self.request.wfile.write, content)
1062 def setHeader(self, header, value):
1063 '''Override a header to be returned to the user's browser.
1064 '''
1065 self.additional_headers[header] = value
1067 def header(self, headers=None, response=None):
1068 '''Put up the appropriate header.
1069 '''
1070 if headers is None:
1071 headers = {'Content-Type':'text/html; charset=utf-8'}
1072 if response is None:
1073 response = self.response_code
1075 # update with additional info
1076 headers.update(self.additional_headers)
1078 if headers.get('Content-Type', 'text/html') == 'text/html':
1079 headers['Content-Type'] = 'text/html; charset=utf-8'
1081 headers = headers.items()
1083 for ((path, name), (value, expire)) in self._cookies.items():
1084 cookie = "%s=%s; Path=%s;"%(name, value, path)
1085 if expire is not None:
1086 cookie += " expires=%s;"%Cookie._getdate(expire)
1087 headers.append(('Set-Cookie', cookie))
1089 self._socket_op(self.request.start_response, headers, response)
1091 self.headers_done = 1
1092 if self.debug:
1093 self.headers_sent = headers
1095 def add_cookie(self, name, value, expire=86400*365, path=None):
1096 """Set a cookie value to be sent in HTTP headers
1098 Parameters:
1099 name:
1100 cookie name
1101 value:
1102 cookie value
1103 expire:
1104 cookie expiration time (seconds).
1105 If value is empty (meaning "delete cookie"),
1106 expiration time is forced in the past
1107 and this argument is ignored.
1108 If None, the cookie will expire at end-of-session.
1109 If omitted, the cookie will be kept for a year.
1110 path:
1111 cookie path (optional)
1113 """
1114 if path is None:
1115 path = self.cookie_path
1116 if not value:
1117 expire = -1
1118 self._cookies[(path, name)] = (value, expire)
1120 def set_cookie(self, user, expire=None):
1121 """Deprecated. Use session_api calls directly
1123 XXX remove
1124 """
1126 # insert the session in the session db
1127 self.session_api.set(user=user)
1128 # refresh session cookie
1129 self.session_api.update(set_cookie=True, expire=expire)
1131 def make_user_anonymous(self):
1132 ''' Make us anonymous
1134 This method used to handle non-existence of the 'anonymous'
1135 user, but that user is mandatory now.
1136 '''
1137 self.userid = self.db.user.lookup('anonymous')
1138 self.user = 'anonymous'
1140 def standard_message(self, to, subject, body, author=None):
1141 '''Send a standard email message from Roundup.
1143 "to" - recipients list
1144 "subject" - Subject
1145 "body" - Message
1146 "author" - (name, address) tuple or None for admin email
1148 Arguments are passed to the Mailer.standard_message code.
1149 '''
1150 try:
1151 self.mailer.standard_message(to, subject, body, author)
1152 except MessageSendError, e:
1153 self.error_message.append(str(e))
1154 return 0
1155 return 1
1157 def parsePropsFromForm(self, create=0):
1158 return FormParser(self).parse(create=create)
1160 # vim: set et sts=4 sw=4 :