1 """WWW request handler (also used in the stand-alone server).
2 """
3 __docformat__ = 'restructuredtext'
5 import base64, binascii, cgi, codecs, httplib, 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, encode_quopri
18 from roundup.cgi import accept_language
19 from roundup import xmlrpc
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 if self.env.get('CONTENT_TYPE') == 'text/xml':
359 self.handle_xmlrpc()
360 else:
361 self.inner_main()
362 finally:
363 if hasattr(self, 'db'):
364 self.db.close()
367 def handle_xmlrpc(self):
369 # Pull the raw XML out of the form. The "value" attribute
370 # will be the raw content of the POST request.
371 assert self.form.file
372 input = self.form.value
373 # So that the rest of Roundup can query the form in the
374 # usual way, we create an empty list of fields.
375 self.form.list = []
377 # Set the charset and language, since other parts of
378 # Roundup may depend upon that.
379 self.determine_charset()
380 self.determine_language()
381 # Open the database as the correct user.
382 self.determine_user()
384 # Call the appropriate XML-RPC method.
385 handler = xmlrpc.RoundupDispatcher(self.db,
386 self.instance.actions,
387 self.translator,
388 allow_none=True)
389 output = handler.dispatch(input)
390 self.db.commit()
392 self.setHeader("Content-Type", "text/xml")
393 self.setHeader("Content-Length", str(len(output)))
394 self.write(output)
396 def inner_main(self):
397 """Process a request.
399 The most common requests are handled like so:
401 1. look for charset and language preferences, set up user locale
402 see determine_charset, determine_language
403 2. figure out who we are, defaulting to the "anonymous" user
404 see determine_user
405 3. figure out what the request is for - the context
406 see determine_context
407 4. handle any requested action (item edit, search, ...)
408 see handle_action
409 5. render a template, resulting in HTML output
411 In some situations, exceptions occur:
413 - HTTP Redirect (generally raised by an action)
414 - SendFile (generally raised by determine_context)
415 serve up a FileClass "content" property
416 - SendStaticFile (generally raised by determine_context)
417 serve up a file from the tracker "html" directory
418 - Unauthorised (generally raised by an action)
419 the action is cancelled, the request is rendered and an error
420 message is displayed indicating that permission was not
421 granted for the action to take place
422 - templating.Unauthorised (templating action not permitted)
423 raised by an attempted rendering of a template when the user
424 doesn't have permission
425 - NotFound (raised wherever it needs to be)
426 percolates up to the CGI interface that called the client
427 """
428 self.ok_message = []
429 self.error_message = []
430 try:
431 self.determine_charset()
432 self.determine_language()
434 try:
435 # make sure we're identified (even anonymously)
436 self.determine_user()
438 # figure out the context and desired content template
439 self.determine_context()
441 # possibly handle a form submit action (may change self.classname
442 # and self.template, and may also append error/ok_messages)
443 html = self.handle_action()
445 if html:
446 self.write_html(html)
447 return
449 # now render the page
450 # we don't want clients caching our dynamic pages
451 self.additional_headers['Cache-Control'] = 'no-cache'
452 # Pragma: no-cache makes Mozilla and its ilk
453 # double-load all pages!!
454 # self.additional_headers['Pragma'] = 'no-cache'
456 # pages with messages added expire right now
457 # simple views may be cached for a small amount of time
458 # TODO? make page expire time configurable
459 # <rj> always expire pages, as IE just doesn't seem to do the
460 # right thing here :(
461 date = time.time() - 1
462 #if self.error_message or self.ok_message:
463 # date = time.time() - 1
464 #else:
465 # date = time.time() + 5
466 self.additional_headers['Expires'] = rfc822.formatdate(date)
468 # render the content
469 self.write_html(self.renderContext())
470 except SendFile, designator:
471 # The call to serve_file may result in an Unauthorised
472 # exception or a NotModified exception. Those
473 # exceptions will be handled by the outermost set of
474 # exception handlers.
475 self.serve_file(designator)
476 except SendStaticFile, file:
477 self.serve_static_file(str(file))
478 except IOError:
479 # IOErrors here are due to the client disconnecting before
480 # recieving the reply.
481 pass
483 except SeriousError, message:
484 self.write_html(str(message))
485 except Redirect, url:
486 # let's redirect - if the url isn't None, then we need to do
487 # the headers, otherwise the headers have been set before the
488 # exception was raised
489 if url:
490 self.additional_headers['Location'] = str(url)
491 self.response_code = 302
492 self.write_html('Redirecting to <a href="%s">%s</a>'%(url, url))
493 except Unauthorised, message:
494 # users may always see the front page
495 self.response_code = 403
496 self.classname = self.nodeid = None
497 self.template = ''
498 self.error_message.append(message)
499 self.write_html(self.renderContext())
500 except NotModified:
501 # send the 304 response
502 self.response_code = 304
503 self.header()
504 except NotFound, e:
505 self.response_code = 404
506 self.template = '404'
507 try:
508 cl = self.db.getclass(self.classname)
509 self.write_html(self.renderContext())
510 except KeyError:
511 # we can't map the URL to a class we know about
512 # reraise the NotFound and let roundup_server
513 # handle it
514 raise NotFound, e
515 except FormError, e:
516 self.error_message.append(self._('Form Error: ') + str(e))
517 self.write_html(self.renderContext())
518 except:
519 if self.instance.config.WEB_DEBUG:
520 self.write_html(cgitb.html(i18n=self.translator))
521 else:
522 self.mailer.exception_message()
523 return self.write_html(self._(error_message))
525 def clean_sessions(self):
526 """Deprecated
527 XXX remove
528 """
529 self.clean_up()
531 def clean_up(self):
532 """Remove expired sessions and One Time Keys.
534 Do it only once an hour.
535 """
536 hour = 60*60
537 now = time.time()
539 # XXX: hack - use OTK table to store last_clean time information
540 # 'last_clean' string is used instead of otk key
541 last_clean = self.db.getOTKManager().get('last_clean', 'last_use', 0)
542 if now - last_clean < hour:
543 return
545 self.session_api.clean_up()
546 self.db.getOTKManager().clean()
547 self.db.getOTKManager().set('last_clean', last_use=now)
548 self.db.commit(fail_ok=True)
550 def determine_charset(self):
551 """Look for client charset in the form parameters or browser cookie.
553 If no charset requested by client, use storage charset (utf-8).
555 If the charset is found, and differs from the storage charset,
556 recode all form fields of type 'text/plain'
557 """
558 # look for client charset
559 charset_parameter = 0
560 if self.form.has_key('@charset'):
561 charset = self.form['@charset'].value
562 if charset.lower() == "none":
563 charset = ""
564 charset_parameter = 1
565 elif self.cookie.has_key('roundup_charset'):
566 charset = self.cookie['roundup_charset'].value
567 else:
568 charset = None
569 if charset:
570 # make sure the charset is recognized
571 try:
572 codecs.lookup(charset)
573 except LookupError:
574 self.error_message.append(self._('Unrecognized charset: %r')
575 % charset)
576 charset_parameter = 0
577 else:
578 self.charset = charset.lower()
579 # If we've got a character set in request parameters,
580 # set the browser cookie to keep the preference.
581 # This is done after codecs.lookup to make sure
582 # that we aren't keeping a wrong value.
583 if charset_parameter:
584 self.add_cookie('roundup_charset', charset)
586 # if client charset is different from the storage charset,
587 # recode form fields
588 # XXX this requires FieldStorage from Python library.
589 # mod_python FieldStorage is not supported!
590 if self.charset != self.STORAGE_CHARSET:
591 decoder = codecs.getdecoder(self.charset)
592 encoder = codecs.getencoder(self.STORAGE_CHARSET)
593 re_charref = re.compile('&#([0-9]+|x[0-9a-f]+);', re.IGNORECASE)
594 def _decode_charref(matchobj):
595 num = matchobj.group(1)
596 if num[0].lower() == 'x':
597 uc = int(num[1:], 16)
598 else:
599 uc = int(num)
600 return unichr(uc)
602 for field_name in self.form.keys():
603 field = self.form[field_name]
604 if (field.type == 'text/plain') and not field.filename:
605 try:
606 value = decoder(field.value)[0]
607 except UnicodeError:
608 continue
609 value = re_charref.sub(_decode_charref, value)
610 field.value = encoder(value)[0]
612 def determine_language(self):
613 """Determine the language"""
614 # look for language parameter
615 # then for language cookie
616 # last for the Accept-Language header
617 if self.form.has_key("@language"):
618 language = self.form["@language"].value
619 if language.lower() == "none":
620 language = ""
621 self.add_cookie("roundup_language", language)
622 elif self.cookie.has_key("roundup_language"):
623 language = self.cookie["roundup_language"].value
624 elif self.instance.config["WEB_USE_BROWSER_LANGUAGE"]:
625 hal = self.env.get('HTTP_ACCEPT_LANGUAGE')
626 language = accept_language.parse(hal)
627 else:
628 language = ""
630 self.language = language
631 if language:
632 self.setTranslator(TranslationService.get_translation(
633 language,
634 tracker_home=self.instance.config["TRACKER_HOME"]))
636 def determine_user(self):
637 """Determine who the user is"""
638 self.opendb('admin')
640 # get session data from db
641 # XXX: rename
642 self.session_api = Session(self)
644 # take the opportunity to cleanup expired sessions and otks
645 self.clean_up()
647 user = None
648 # first up, try http authorization if enabled
649 if self.instance.config['WEB_HTTP_AUTH']:
650 if self.env.has_key('REMOTE_USER'):
651 # we have external auth (e.g. by Apache)
652 user = self.env['REMOTE_USER']
653 elif self.env.get('HTTP_AUTHORIZATION', ''):
654 # try handling Basic Auth ourselves
655 auth = self.env['HTTP_AUTHORIZATION']
656 scheme, challenge = auth.split(' ', 1)
657 if scheme.lower() == 'basic':
658 try:
659 decoded = base64.decodestring(challenge)
660 except TypeError:
661 # invalid challenge
662 pass
663 username, password = decoded.split(':')
664 try:
665 login = self.get_action_class('login')(self)
666 login.verifyLogin(username, password)
667 except LoginError, err:
668 self.make_user_anonymous()
669 raise Unauthorised, err
670 user = username
672 # if user was not set by http authorization, try session lookup
673 if not user:
674 user = self.session_api.get('user')
675 if user:
676 # update session lifetime datestamp
677 self.session_api.update()
679 # if no user name set by http authorization or session lookup
680 # the user is anonymous
681 if not user:
682 user = 'anonymous'
684 # sanity check on the user still being valid,
685 # getting the userid at the same time
686 try:
687 self.userid = self.db.user.lookup(user)
688 except (KeyError, TypeError):
689 user = 'anonymous'
691 # make sure the anonymous user is valid if we're using it
692 if user == 'anonymous':
693 self.make_user_anonymous()
694 if not self.db.security.hasPermission('Web Access', self.userid):
695 raise Unauthorised, self._("Anonymous users are not "
696 "allowed to use the web interface")
697 else:
698 self.user = user
700 # reopen the database as the correct user
701 self.opendb(self.user)
703 def opendb(self, username):
704 """Open the database and set the current user.
706 Opens a database once. On subsequent calls only the user is set on
707 the database object the instance.optimize is set. If we are in
708 "Development Mode" (cf. roundup_server) then the database is always
709 re-opened.
710 """
711 # don't do anything if the db is open and the user has not changed
712 if hasattr(self, 'db') and self.db.isCurrentUser(username):
713 return
715 # open the database or only set the user
716 if not hasattr(self, 'db'):
717 self.db = self.instance.open(username)
718 else:
719 if self.instance.optimize:
720 self.db.setCurrentUser(username)
721 else:
722 self.db.close()
723 self.db = self.instance.open(username)
724 # The old session API refers to the closed database;
725 # we can no longer use it.
726 self.session_api = Session(self)
729 def determine_context(self, dre=re.compile(r'([^\d]+)0*(\d+)')):
730 """Determine the context of this page from the URL:
732 The URL path after the instance identifier is examined. The path
733 is generally only one entry long.
735 - if there is no path, then we are in the "home" context.
736 - if the path is "_file", then the additional path entry
737 specifies the filename of a static file we're to serve up
738 from the instance "html" directory. Raises a SendStaticFile
739 exception.(*)
740 - if there is something in the path (eg "issue"), it identifies
741 the tracker class we're to display.
742 - if the path is an item designator (eg "issue123"), then we're
743 to display a specific item.
744 - if the path starts with an item designator and is longer than
745 one entry, then we're assumed to be handling an item of a
746 FileClass, and the extra path information gives the filename
747 that the client is going to label the download with (ie
748 "file123/image.png" is nicer to download than "file123"). This
749 raises a SendFile exception.(*)
751 Both of the "*" types of contexts stop before we bother to
752 determine the template we're going to use. That's because they
753 don't actually use templates.
755 The template used is specified by the :template CGI variable,
756 which defaults to:
758 - only classname suplied: "index"
759 - full item designator supplied: "item"
761 We set:
763 self.classname - the class to display, can be None
765 self.template - the template to render the current context with
767 self.nodeid - the nodeid of the class we're displaying
768 """
769 # default the optional variables
770 self.classname = None
771 self.nodeid = None
773 # see if a template or messages are specified
774 template_override = ok_message = error_message = None
775 for key in self.form.keys():
776 if self.FV_TEMPLATE.match(key):
777 template_override = self.form[key].value
778 elif self.FV_OK_MESSAGE.match(key):
779 ok_message = self.form[key].value
780 ok_message = clean_message(ok_message)
781 elif self.FV_ERROR_MESSAGE.match(key):
782 error_message = self.form[key].value
783 error_message = clean_message(error_message)
785 # see if we were passed in a message
786 if ok_message:
787 self.ok_message.append(ok_message)
788 if error_message:
789 self.error_message.append(error_message)
791 # determine the classname and possibly nodeid
792 path = self.path.split('/')
793 if not path or path[0] in ('', 'home', 'index'):
794 if template_override is not None:
795 self.template = template_override
796 else:
797 self.template = ''
798 return
799 elif path[0] in ('_file', '@@file'):
800 raise SendStaticFile, os.path.join(*path[1:])
801 else:
802 self.classname = path[0]
803 if len(path) > 1:
804 # send the file identified by the designator in path[0]
805 raise SendFile, path[0]
807 # see if we got a designator
808 m = dre.match(self.classname)
809 if m:
810 self.classname = m.group(1)
811 self.nodeid = m.group(2)
812 try:
813 klass = self.db.getclass(self.classname)
814 except KeyError:
815 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
816 if not klass.hasnode(self.nodeid):
817 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
818 # with a designator, we default to item view
819 self.template = 'item'
820 else:
821 # with only a class, we default to index view
822 self.template = 'index'
824 # make sure the classname is valid
825 try:
826 self.db.getclass(self.classname)
827 except KeyError:
828 raise NotFound, self.classname
830 # see if we have a template override
831 if template_override is not None:
832 self.template = template_override
834 def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
835 """ Serve the file from the content property of the designated item.
836 """
837 m = dre.match(str(designator))
838 if not m:
839 raise NotFound, str(designator)
840 classname, nodeid = m.group(1), m.group(2)
842 klass = self.db.getclass(classname)
844 # make sure we have the appropriate properties
845 props = klass.getprops()
846 if not props.has_key('type'):
847 raise NotFound, designator
848 if not props.has_key('content'):
849 raise NotFound, designator
851 # make sure we have permission
852 if not self.db.security.hasPermission('View', self.userid,
853 classname, 'content', nodeid):
854 raise Unauthorised, self._("You are not allowed to view "
855 "this file.")
857 mime_type = klass.get(nodeid, 'type')
859 # if the mime_type is HTML-ish then make sure we're allowed to serve up
860 # HTML-ish content
861 if mime_type in ('text/html', 'text/x-html'):
862 if not self.instance.config['WEB_ALLOW_HTML_FILE']:
863 # do NOT serve the content up as HTML
864 mime_type = 'application/octet-stream'
866 # If this object is a file (i.e., an instance of FileClass),
867 # see if we can find it in the filesystem. If so, we may be
868 # able to use the more-efficient request.sendfile method of
869 # sending the file. If not, just get the "content" property
870 # in the usual way, and use that.
871 content = None
872 filename = None
873 if isinstance(klass, hyperdb.FileClass):
874 try:
875 filename = self.db.filename(classname, nodeid)
876 except AttributeError:
877 # The database doesn't store files in the filesystem
878 # and therefore doesn't provide the "filename" method.
879 pass
880 except IOError:
881 # The file does not exist.
882 pass
883 if not filename:
884 content = klass.get(nodeid, 'content')
886 lmt = klass.get(nodeid, 'activity').timestamp()
888 self._serve_file(lmt, mime_type, content, filename)
890 def serve_static_file(self, file):
891 """ Serve up the file named from the templates dir
892 """
893 # figure the filename - try STATIC_FILES, then TEMPLATES dir
894 for dir_option in ('STATIC_FILES', 'TEMPLATES'):
895 prefix = self.instance.config[dir_option]
896 if not prefix:
897 continue
898 # ensure the load doesn't try to poke outside
899 # of the static files directory
900 prefix = os.path.normpath(prefix)
901 filename = os.path.normpath(os.path.join(prefix, file))
902 if os.path.isfile(filename) and filename.startswith(prefix):
903 break
904 else:
905 raise NotFound, file
907 # last-modified time
908 lmt = os.stat(filename)[stat.ST_MTIME]
910 # detemine meta-type
911 file = str(file)
912 mime_type = mimetypes.guess_type(file)[0]
913 if not mime_type:
914 if file.endswith('.css'):
915 mime_type = 'text/css'
916 else:
917 mime_type = 'text/plain'
919 self._serve_file(lmt, mime_type, '', filename)
921 def _serve_file(self, lmt, mime_type, content=None, filename=None):
922 """ guts of serve_file() and serve_static_file()
923 """
925 # spit out headers
926 self.additional_headers['Content-Type'] = mime_type
927 self.additional_headers['Last-Modified'] = rfc822.formatdate(lmt)
929 ims = None
930 # see if there's an if-modified-since...
931 # XXX see which interfaces set this
932 #if hasattr(self.request, 'headers'):
933 #ims = self.request.headers.getheader('if-modified-since')
934 if self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
935 # cgi will put the header in the env var
936 ims = self.env['HTTP_IF_MODIFIED_SINCE']
937 if ims:
938 ims = rfc822.parsedate(ims)[:6]
939 lmtt = time.gmtime(lmt)[:6]
940 if lmtt <= ims:
941 raise NotModified
943 if filename:
944 self.write_file(filename)
945 else:
946 self.additional_headers['Content-Length'] = str(len(content))
947 self.write(content)
949 def renderContext(self):
950 """ Return a PageTemplate for the named page
951 """
952 name = self.classname
953 extension = self.template
955 # catch errors so we can handle PT rendering errors more nicely
956 args = {
957 'ok_message': self.ok_message,
958 'error_message': self.error_message
959 }
960 try:
961 pt = self.instance.templates.get(name, extension)
962 # let the template render figure stuff out
963 result = pt.render(self, None, None, **args)
964 self.additional_headers['Content-Type'] = pt.content_type
965 if self.env.get('CGI_SHOW_TIMING', ''):
966 if self.env['CGI_SHOW_TIMING'].upper() == 'COMMENT':
967 timings = {'starttag': '<!-- ', 'endtag': ' -->'}
968 else:
969 timings = {'starttag': '<p>', 'endtag': '</p>'}
970 timings['seconds'] = time.time()-self.start
971 s = self._('%(starttag)sTime elapsed: %(seconds)fs%(endtag)s\n'
972 ) % timings
973 if hasattr(self.db, 'stats'):
974 timings.update(self.db.stats)
975 s += self._("%(starttag)sCache hits: %(cache_hits)d,"
976 " misses %(cache_misses)d."
977 " Loading items: %(get_items)f secs."
978 " Filtering: %(filtering)f secs."
979 "%(endtag)s\n") % timings
980 s += '</body>'
981 result = result.replace('</body>', s)
982 return result
983 except templating.NoTemplate, message:
984 return '<strong>%s</strong>'%message
985 except templating.Unauthorised, message:
986 raise Unauthorised, str(message)
987 except:
988 # everything else
989 if self.instance.config.WEB_DEBUG:
990 return cgitb.pt_html(i18n=self.translator)
991 exc_info = sys.exc_info()
992 try:
993 # If possible, send the HTML page template traceback
994 # to the administrator.
995 to = [self.mailer.config.ADMIN_EMAIL]
996 subject = "Templating Error: %s" % exc_info[1]
997 content = cgitb.pt_html()
998 message = self.mailer.get_standard_message(to, subject)
999 # delete existing content-type headers
1000 del message['Content-type']
1001 message['Content-type'] = 'text/html; charset=utf-8'
1002 message.set_payload(content)
1003 encode_quopri(message)
1004 self.mailer.smtp_send(to, str(message))
1005 # Now report the error to the user.
1006 return self._(error_message)
1007 except:
1008 # Reraise the original exception. The user will
1009 # receive an error message, and the adminstrator will
1010 # receive a traceback, albeit with less information
1011 # than the one we tried to generate above.
1012 raise exc_info[0], exc_info[1], exc_info[2]
1014 # these are the actions that are available
1015 actions = (
1016 ('edit', EditItemAction),
1017 ('editcsv', EditCSVAction),
1018 ('new', NewItemAction),
1019 ('register', RegisterAction),
1020 ('confrego', ConfRegoAction),
1021 ('passrst', PassResetAction),
1022 ('login', LoginAction),
1023 ('logout', LogoutAction),
1024 ('search', SearchAction),
1025 ('retire', RetireAction),
1026 ('show', ShowAction),
1027 ('export_csv', ExportCSVAction),
1028 )
1029 def handle_action(self):
1030 """ Determine whether there should be an Action called.
1032 The action is defined by the form variable :action which
1033 identifies the method on this object to call. The actions
1034 are defined in the "actions" sequence on this class.
1036 Actions may return a page (by default HTML) to return to the
1037 user, bypassing the usual template rendering.
1039 We explicitly catch Reject and ValueError exceptions and
1040 present their messages to the user.
1041 """
1042 if self.form.has_key(':action'):
1043 action = self.form[':action'].value.lower()
1044 elif self.form.has_key('@action'):
1045 action = self.form['@action'].value.lower()
1046 else:
1047 return None
1049 try:
1050 action_klass = self.get_action_class(action)
1052 # call the mapped action
1053 if isinstance(action_klass, type('')):
1054 # old way of specifying actions
1055 return getattr(self, action_klass)()
1056 else:
1057 return action_klass(self).execute()
1059 except (ValueError, Reject), err:
1060 self.error_message.append(str(err))
1062 def get_action_class(self, action_name):
1063 if (hasattr(self.instance, 'cgi_actions') and
1064 self.instance.cgi_actions.has_key(action_name)):
1065 # tracker-defined action
1066 action_klass = self.instance.cgi_actions[action_name]
1067 else:
1068 # go with a default
1069 for name, action_klass in self.actions:
1070 if name == action_name:
1071 break
1072 else:
1073 raise ValueError, 'No such action "%s"'%action_name
1074 return action_klass
1076 def _socket_op(self, call, *args, **kwargs):
1077 """Execute socket-related operation, catch common network errors
1079 Parameters:
1080 call: a callable to execute
1081 args, kwargs: call arguments
1083 """
1084 try:
1085 call(*args, **kwargs)
1086 except socket.error, err:
1087 err_errno = getattr (err, 'errno', None)
1088 if err_errno is None:
1089 try:
1090 err_errno = err[0]
1091 except TypeError:
1092 pass
1093 if err_errno not in self.IGNORE_NET_ERRORS:
1094 raise
1095 except IOError:
1096 # Apache's mod_python will raise IOError -- without an
1097 # accompanying errno -- when a write to the client fails.
1098 # A common case is that the client has closed the
1099 # connection. There's no way to be certain that this is
1100 # the situation that has occurred here, but that is the
1101 # most likely case.
1102 pass
1104 def write(self, content):
1105 if not self.headers_done:
1106 self.header()
1107 if self.env['REQUEST_METHOD'] != 'HEAD':
1108 self._socket_op(self.request.wfile.write, content)
1110 def write_html(self, content):
1111 if not self.headers_done:
1112 # at this point, we are sure about Content-Type
1113 if not self.additional_headers.has_key('Content-Type'):
1114 self.additional_headers['Content-Type'] = \
1115 'text/html; charset=%s' % self.charset
1116 self.header()
1118 if self.env['REQUEST_METHOD'] == 'HEAD':
1119 # client doesn't care about content
1120 return
1122 if self.charset != self.STORAGE_CHARSET:
1123 # recode output
1124 content = content.decode(self.STORAGE_CHARSET, 'replace')
1125 content = content.encode(self.charset, 'xmlcharrefreplace')
1127 # and write
1128 self._socket_op(self.request.wfile.write, content)
1130 def http_strip(self, content):
1131 """Remove HTTP Linear White Space from 'content'.
1133 'content' -- A string.
1135 returns -- 'content', with all leading and trailing LWS
1136 removed."""
1138 # RFC 2616 2.2: Basic Rules
1139 #
1140 # LWS = [CRLF] 1*( SP | HT )
1141 return content.strip(" \r\n\t")
1143 def http_split(self, content):
1144 """Split an HTTP list.
1146 'content' -- A string, giving a list of items.
1148 returns -- A sequence of strings, containing the elements of
1149 the list."""
1151 # RFC 2616 2.1: Augmented BNF
1152 #
1153 # Grammar productions of the form "#rule" indicate a
1154 # comma-separated list of elements matching "rule". LWS
1155 # is then removed from each element, and empty elements
1156 # removed.
1158 # Split at commas.
1159 elements = content.split(",")
1160 # Remove linear whitespace at either end of the string.
1161 elements = [self.http_strip(e) for e in elements]
1162 # Remove any now-empty elements.
1163 return [e for e in elements if e]
1165 def handle_range_header(self, length, etag):
1166 """Handle the 'Range' and 'If-Range' headers.
1168 'length' -- the length of the content available for the
1169 resource.
1171 'etag' -- the entity tag for this resources.
1173 returns -- If the request headers (including 'Range' and
1174 'If-Range') indicate that only a portion of the entity should
1175 be returned, then the return value is a pair '(offfset,
1176 length)' indicating the first byte and number of bytes of the
1177 content that should be returned to the client. In addition,
1178 this method will set 'self.response_code' to indicate Partial
1179 Content. In all other cases, the return value is 'None'. If
1180 appropriate, 'self.response_code' will be
1181 set to indicate 'REQUESTED_RANGE_NOT_SATISFIABLE'. In that
1182 case, the caller should not send any data to the client."""
1184 # RFC 2616 14.35: Range
1185 #
1186 # See if the Range header is present.
1187 ranges_specifier = self.env.get("HTTP_RANGE")
1188 if ranges_specifier is None:
1189 return None
1190 # RFC 2616 14.27: If-Range
1191 #
1192 # Check to see if there is an If-Range header.
1193 # Because the specification says:
1194 #
1195 # The If-Range header ... MUST be ignored if the request
1196 # does not include a Range header, we check for If-Range
1197 # after checking for Range.
1198 if_range = self.env.get("HTTP_IF_RANGE")
1199 if if_range:
1200 # The grammar for the If-Range header is:
1201 #
1202 # If-Range = "If-Range" ":" ( entity-tag | HTTP-date )
1203 # entity-tag = [ weak ] opaque-tag
1204 # weak = "W/"
1205 # opaque-tag = quoted-string
1206 #
1207 # We only support strong entity tags.
1208 if_range = self.http_strip(if_range)
1209 if (not if_range.startswith('"')
1210 or not if_range.endswith('"')):
1211 return None
1212 # If the condition doesn't match the entity tag, then we
1213 # must send the client the entire file.
1214 if if_range != etag:
1215 return
1216 # The grammar for the Range header value is:
1217 #
1218 # ranges-specifier = byte-ranges-specifier
1219 # byte-ranges-specifier = bytes-unit "=" byte-range-set
1220 # byte-range-set = 1#( byte-range-spec | suffix-byte-range-spec )
1221 # byte-range-spec = first-byte-pos "-" [last-byte-pos]
1222 # first-byte-pos = 1*DIGIT
1223 # last-byte-pos = 1*DIGIT
1224 # suffix-byte-range-spec = "-" suffix-length
1225 # suffix-length = 1*DIGIT
1226 #
1227 # Look for the "=" separating the units from the range set.
1228 specs = ranges_specifier.split("=", 1)
1229 if len(specs) != 2:
1230 return None
1231 # Check that the bytes-unit is in fact "bytes". If it is not,
1232 # we do not know how to process this range.
1233 bytes_unit = self.http_strip(specs[0])
1234 if bytes_unit != "bytes":
1235 return None
1236 # Seperate the range-set into range-specs.
1237 byte_range_set = self.http_strip(specs[1])
1238 byte_range_specs = self.http_split(byte_range_set)
1239 # We only handle exactly one range at this time.
1240 if len(byte_range_specs) != 1:
1241 return None
1242 # Parse the spec.
1243 byte_range_spec = byte_range_specs[0]
1244 pos = byte_range_spec.split("-", 1)
1245 if len(pos) != 2:
1246 return None
1247 # Get the first and last bytes.
1248 first = self.http_strip(pos[0])
1249 last = self.http_strip(pos[1])
1250 # We do not handle suffix ranges.
1251 if not first:
1252 return None
1253 # Convert the first and last positions to integers.
1254 try:
1255 first = int(first)
1256 if last:
1257 last = int(last)
1258 else:
1259 last = length - 1
1260 except:
1261 # The positions could not be parsed as integers.
1262 return None
1263 # Check that the range makes sense.
1264 if (first < 0 or last < 0 or last < first):
1265 return None
1266 if last >= length:
1267 # RFC 2616 10.4.17: 416 Requested Range Not Satisfiable
1268 #
1269 # If there is an If-Range header, RFC 2616 says that we
1270 # should just ignore the invalid Range header.
1271 if if_range:
1272 return None
1273 # Return code 416 with a Content-Range header giving the
1274 # allowable range.
1275 self.response_code = httplib.REQUESTED_RANGE_NOT_SATISFIABLE
1276 self.setHeader("Content-Range", "bytes */%d" % length)
1277 return None
1278 # RFC 2616 10.2.7: 206 Partial Content
1279 #
1280 # Tell the client that we are honoring the Range request by
1281 # indicating that we are providing partial content.
1282 self.response_code = httplib.PARTIAL_CONTENT
1283 # RFC 2616 14.16: Content-Range
1284 #
1285 # Tell the client what data we are providing.
1286 #
1287 # content-range-spec = byte-content-range-spec
1288 # byte-content-range-spec = bytes-unit SP
1289 # byte-range-resp-spec "/"
1290 # ( instance-length | "*" )
1291 # byte-range-resp-spec = (first-byte-pos "-" last-byte-pos)
1292 # | "*"
1293 # instance-length = 1 * DIGIT
1294 self.setHeader("Content-Range",
1295 "bytes %d-%d/%d" % (first, last, length))
1296 return (first, last - first + 1)
1298 def write_file(self, filename):
1299 """Send the contents of 'filename' to the user."""
1301 # Determine the length of the file.
1302 stat_info = os.stat(filename)
1303 length = stat_info[stat.ST_SIZE]
1304 # Assume we will return the entire file.
1305 offset = 0
1306 # If the headers have not already been finalized,
1307 if not self.headers_done:
1308 # RFC 2616 14.19: ETag
1309 #
1310 # Compute the entity tag, in a format similar to that
1311 # used by Apache.
1312 etag = '"%x-%x-%x"' % (stat_info[stat.ST_INO],
1313 length,
1314 stat_info[stat.ST_MTIME])
1315 self.setHeader("ETag", etag)
1316 # RFC 2616 14.5: Accept-Ranges
1317 #
1318 # Let the client know that we will accept range requests.
1319 self.setHeader("Accept-Ranges", "bytes")
1320 # RFC 2616 14.35: Range
1321 #
1322 # If there is a Range header, we may be able to avoid
1323 # sending the entire file.
1324 content_range = self.handle_range_header(length, etag)
1325 if content_range:
1326 offset, length = content_range
1327 # RFC 2616 14.13: Content-Length
1328 #
1329 # Tell the client how much data we are providing.
1330 self.setHeader("Content-Length", str(length))
1331 # Send the HTTP header.
1332 self.header()
1333 # If the client doesn't actually want the body, or if we are
1334 # indicating an invalid range.
1335 if (self.env['REQUEST_METHOD'] == 'HEAD'
1336 or self.response_code == httplib.REQUESTED_RANGE_NOT_SATISFIABLE):
1337 return
1338 # Use the optimized "sendfile" operation, if possible.
1339 if hasattr(self.request, "sendfile"):
1340 self._socket_op(self.request.sendfile, filename, offset, length)
1341 return
1342 # Fallback to the "write" operation.
1343 f = open(filename, 'rb')
1344 try:
1345 if offset:
1346 f.seek(offset)
1347 content = f.read(length)
1348 finally:
1349 f.close()
1350 self.write(content)
1352 def setHeader(self, header, value):
1353 """Override a header to be returned to the user's browser.
1354 """
1355 self.additional_headers[header] = value
1357 def header(self, headers=None, response=None):
1358 """Put up the appropriate header.
1359 """
1360 if headers is None:
1361 headers = {'Content-Type':'text/html; charset=utf-8'}
1362 if response is None:
1363 response = self.response_code
1365 # update with additional info
1366 headers.update(self.additional_headers)
1368 if headers.get('Content-Type', 'text/html') == 'text/html':
1369 headers['Content-Type'] = 'text/html; charset=utf-8'
1371 headers = headers.items()
1373 for ((path, name), (value, expire)) in self._cookies.items():
1374 cookie = "%s=%s; Path=%s;"%(name, value, path)
1375 if expire is not None:
1376 cookie += " expires=%s;"%Cookie._getdate(expire)
1377 headers.append(('Set-Cookie', cookie))
1379 self._socket_op(self.request.start_response, headers, response)
1381 self.headers_done = 1
1382 if self.debug:
1383 self.headers_sent = headers
1385 def add_cookie(self, name, value, expire=86400*365, path=None):
1386 """Set a cookie value to be sent in HTTP headers
1388 Parameters:
1389 name:
1390 cookie name
1391 value:
1392 cookie value
1393 expire:
1394 cookie expiration time (seconds).
1395 If value is empty (meaning "delete cookie"),
1396 expiration time is forced in the past
1397 and this argument is ignored.
1398 If None, the cookie will expire at end-of-session.
1399 If omitted, the cookie will be kept for a year.
1400 path:
1401 cookie path (optional)
1403 """
1404 if path is None:
1405 path = self.cookie_path
1406 if not value:
1407 expire = -1
1408 self._cookies[(path, name)] = (value, expire)
1410 def set_cookie(self, user, expire=None):
1411 """Deprecated. Use session_api calls directly
1413 XXX remove
1414 """
1416 # insert the session in the session db
1417 self.session_api.set(user=user)
1418 # refresh session cookie
1419 self.session_api.update(set_cookie=True, expire=expire)
1421 def make_user_anonymous(self):
1422 """ Make us anonymous
1424 This method used to handle non-existence of the 'anonymous'
1425 user, but that user is mandatory now.
1426 """
1427 self.userid = self.db.user.lookup('anonymous')
1428 self.user = 'anonymous'
1430 def standard_message(self, to, subject, body, author=None):
1431 """Send a standard email message from Roundup.
1433 "to" - recipients list
1434 "subject" - Subject
1435 "body" - Message
1436 "author" - (name, address) tuple or None for admin email
1438 Arguments are passed to the Mailer.standard_message code.
1439 """
1440 try:
1441 self.mailer.standard_message(to, subject, body, author)
1442 except MessageSendError, e:
1443 self.error_message.append(str(e))
1444 return 0
1445 return 1
1447 def parsePropsFromForm(self, create=0):
1448 return FormParser(self).parse(create=create)
1450 # vim: set et sts=4 sw=4 :