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