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