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
20 def initialiseSecurity(security):
21 '''Create some Permissions and Roles on the security object
23 This function is directly invoked by security.Security.__init__()
24 as a part of the Security object instantiation.
25 '''
26 p = security.addPermission(name="Web Access",
27 description="User may access the web interface")
28 security.addPermissionToRole('Admin', p)
30 # doing Role stuff through the web - make sure Admin can
31 # TODO: deprecate this and use a property-based control
32 p = security.addPermission(name="Web Roles",
33 description="User may manipulate user Roles through the web")
34 security.addPermissionToRole('Admin', p)
36 # used to clean messages passed through CGI variables - HTML-escape any tag
37 # that isn't <a href="">, <i>, <b> and <br> (including XHTML variants) so
38 # that people can't pass through nasties like <script>, <iframe>, ...
39 CLEAN_MESSAGE_RE = r'(<(/?(.*?)(\s*href="[^"]")?\s*/?)>)'
40 def clean_message(message, mc=re.compile(CLEAN_MESSAGE_RE, re.I)):
41 return mc.sub(clean_message_callback, message)
42 def clean_message_callback(match, ok={'a':1,'i':1,'b':1,'br':1}):
43 """ Strip all non <a>,<i>,<b> and <br> tags from a string
44 """
45 if ok.has_key(match.group(3).lower()):
46 return match.group(1)
47 return '<%s>'%match.group(2)
50 error_message = """<html><head><title>An error has occurred</title></head>
51 <body><h1>An error has occurred</h1>
52 <p>A problem was encountered processing your request.
53 The tracker maintainers have been notified of the problem.</p>
54 </body></html>"""
57 class LiberalCookie(SimpleCookie):
58 """ Python's SimpleCookie throws an exception if the cookie uses invalid
59 syntax. Other applications on the same server may have done precisely
60 this, preventing roundup from working through no fault of roundup.
61 Numerous other python apps have run into the same problem:
63 trac: http://trac.edgewall.org/ticket/2256
64 mailman: http://bugs.python.org/issue472646
66 This particular implementation comes from trac's solution to the
67 problem. Unfortunately it requires some hackery in SimpleCookie's
68 internals to provide a more liberal __set method.
69 """
70 def load(self, rawdata, ignore_parse_errors=True):
71 if ignore_parse_errors:
72 self.bad_cookies = []
73 self._BaseCookie__set = self._loose_set
74 SimpleCookie.load(self, rawdata)
75 if ignore_parse_errors:
76 self._BaseCookie__set = self._strict_set
77 for key in self.bad_cookies:
78 del self[key]
80 _strict_set = BaseCookie._BaseCookie__set
82 def _loose_set(self, key, real_value, coded_value):
83 try:
84 self._strict_set(key, real_value, coded_value)
85 except CookieError:
86 self.bad_cookies.append(key)
87 dict.__setitem__(self, key, None)
90 class Session:
91 """
92 Needs DB to be already opened by client
94 Session attributes at instantiation:
96 - "client" - reference to client for add_cookie function
97 - "session_db" - session DB manager
98 - "cookie_name" - name of the cookie with session id
99 - "_sid" - session id for current user
100 - "_data" - session data cache
102 session = Session(client)
103 session.set(name=value)
104 value = session.get(name)
106 session.destroy() # delete current session
107 session.clean_up() # clean up session table
109 session.update(set_cookie=True, expire=3600*24*365)
110 # refresh session expiration time, setting persistent
111 # cookie if needed to last for 'expire' seconds
113 """
115 def __init__(self, client):
116 self._data = {}
117 self._sid = None
119 self.client = client
120 self.session_db = client.db.getSessionManager()
122 # parse cookies for session id
123 self.cookie_name = 'roundup_session_%s' % \
124 re.sub('[^a-zA-Z]', '', client.instance.config.TRACKER_NAME)
125 cookies = LiberalCookie(client.env.get('HTTP_COOKIE', ''))
126 if self.cookie_name in cookies:
127 if not self.session_db.exists(cookies[self.cookie_name].value):
128 self._sid = None
129 # remove old cookie
130 self.client.add_cookie(self.cookie_name, None)
131 else:
132 self._sid = cookies[self.cookie_name].value
133 self._data = self.session_db.getall(self._sid)
135 def _gen_sid(self):
136 """ generate a unique session key """
137 while 1:
138 s = '%s%s'%(time.time(), random.random())
139 s = binascii.b2a_base64(s).strip()
140 if not self.session_db.exists(s):
141 break
143 # clean up the base64
144 if s[-1] == '=':
145 if s[-2] == '=':
146 s = s[:-2]
147 else:
148 s = s[:-1]
149 return s
151 def clean_up(self):
152 """Remove expired sessions"""
153 self.session_db.clean()
155 def destroy(self):
156 self.client.add_cookie(self.cookie_name, None)
157 self._data = {}
158 self.session_db.destroy(self._sid)
159 self.client.db.commit()
161 def get(self, name, default=None):
162 return self._data.get(name, default)
164 def set(self, **kwargs):
165 self._data.update(kwargs)
166 if not self._sid:
167 self._sid = self._gen_sid()
168 self.session_db.set(self._sid, **self._data)
169 # add session cookie
170 self.update(set_cookie=True)
172 # XXX added when patching 1.4.4 for backward compatibility
173 # XXX remove
174 self.client.session = self._sid
175 else:
176 self.session_db.set(self._sid, **self._data)
177 self.client.db.commit()
179 def update(self, set_cookie=False, expire=None):
180 """ update timestamp in db to avoid expiration
182 if 'set_cookie' is True, set cookie with 'expire' seconds lifetime
183 if 'expire' is None - session will be closed with the browser
185 XXX the session can be purged within a week even if a cookie
186 lifetime is longer
187 """
188 self.session_db.updateTimestamp(self._sid)
189 self.client.db.commit()
191 if set_cookie:
192 self.client.add_cookie(self.cookie_name, self._sid, expire=expire)
196 class Client:
197 """Instantiate to handle one CGI request.
199 See inner_main for request processing.
201 Client attributes at instantiation:
203 - "path" is the PATH_INFO inside the instance (with no leading '/')
204 - "base" is the base URL for the instance
205 - "form" is the cgi form, an instance of FieldStorage from the standard
206 cgi module
207 - "additional_headers" is a dictionary of additional HTTP headers that
208 should be sent to the client
209 - "response_code" is the HTTP response code to send to the client
210 - "translator" is TranslationService instance
212 During the processing of a request, the following attributes are used:
214 - "db"
215 - "error_message" holds a list of error messages
216 - "ok_message" holds a list of OK messages
217 - "session" is deprecated in favor of session_api (XXX remove)
218 - "session_api" is the interface to store data in session
219 - "user" is the current user's name
220 - "userid" is the current user's id
221 - "template" is the current :template context
222 - "classname" is the current class context name
223 - "nodeid" is the current context item id
225 User Identification:
226 Users that are absent in session data are anonymous and are logged
227 in as that user. This typically gives them all Permissions assigned to the
228 Anonymous Role.
230 Every user is assigned a session. "session_api" is the interface to work
231 with session data.
233 Special form variables:
234 Note that in various places throughout this code, special form
235 variables of the form :<name> are used. The colon (":") part may
236 actually be one of either ":" or "@".
237 """
239 # charset used for data storage and form templates
240 # Note: must be in lower case for comparisons!
241 # XXX take this from instance.config?
242 STORAGE_CHARSET = 'utf-8'
244 #
245 # special form variables
246 #
247 FV_TEMPLATE = re.compile(r'[@:]template')
248 FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
249 FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
251 # Note: index page stuff doesn't appear here:
252 # columns, sort, sortdir, filter, group, groupdir, search_text,
253 # pagesize, startwith
255 # list of network error codes that shouldn't be reported to tracker admin
256 # (error descriptions from FreeBSD intro(2))
257 IGNORE_NET_ERRORS = (
258 # A write on a pipe, socket or FIFO for which there is
259 # no process to read the data.
260 errno.EPIPE,
261 # A connection was forcibly closed by a peer.
262 # This normally results from a loss of the connection
263 # on the remote socket due to a timeout or a reboot.
264 errno.ECONNRESET,
265 # Software caused connection abort. A connection abort
266 # was caused internal to your host machine.
267 errno.ECONNABORTED,
268 # A connect or send request failed because the connected party
269 # did not properly respond after a period of time.
270 errno.ETIMEDOUT,
271 )
273 def __init__(self, instance, request, env, form=None, translator=None):
274 # re-seed the random number generator
275 random.seed()
276 self.start = time.time()
277 self.instance = instance
278 self.request = request
279 self.env = env
280 self.setTranslator(translator)
281 self.mailer = Mailer(instance.config)
283 # save off the path
284 self.path = env['PATH_INFO']
286 # this is the base URL for this tracker
287 self.base = self.instance.config.TRACKER_WEB
289 # check the tracker_we setting
290 if not self.base.endswith('/'):
291 self.base = self.base + '/'
293 # this is the "cookie path" for this tracker (ie. the path part of
294 # the "base" url)
295 self.cookie_path = urlparse.urlparse(self.base)[2]
296 # cookies to set in http responce
297 # {(path, name): (value, expire)}
298 self._cookies = {}
300 # see if we need to re-parse the environment for the form (eg Zope)
301 if form is None:
302 self.form = cgi.FieldStorage(environ=env)
303 else:
304 self.form = form
306 # turn debugging on/off
307 try:
308 self.debug = int(env.get("ROUNDUP_DEBUG", 0))
309 except ValueError:
310 # someone gave us a non-int debug level, turn it off
311 self.debug = 0
313 # flag to indicate that the HTTP headers have been sent
314 self.headers_done = 0
316 # additional headers to send with the request - must be registered
317 # before the first write
318 self.additional_headers = {}
319 self.response_code = 200
321 # default character set
322 self.charset = self.STORAGE_CHARSET
324 # parse cookies (used for charset lookups)
325 # use our own LiberalCookie to handle bad apps on the same
326 # server that have set cookies that are out of spec
327 self.cookie = LiberalCookie(self.env.get('HTTP_COOKIE', ''))
329 self.user = None
330 self.userid = None
331 self.nodeid = None
332 self.classname = None
333 self.template = None
335 def setTranslator(self, translator=None):
336 """Replace the translation engine
338 'translator'
339 is TranslationService instance.
340 It must define methods 'translate' (TAL-compatible i18n),
341 'gettext' and 'ngettext' (gettext-compatible i18n).
343 If omitted, create default TranslationService.
344 """
345 if translator is None:
346 translator = TranslationService.get_translation(
347 language=self.instance.config["TRACKER_LANGUAGE"],
348 tracker_home=self.instance.config["TRACKER_HOME"])
349 self.translator = translator
350 self._ = self.gettext = translator.gettext
351 self.ngettext = translator.ngettext
353 def main(self):
354 """ Wrap the real main in a try/finally so we always close off the db.
355 """
356 try:
357 self.inner_main()
358 finally:
359 if hasattr(self, 'db'):
360 self.db.close()
362 def inner_main(self):
363 """Process a request.
365 The most common requests are handled like so:
367 1. look for charset and language preferences, set up user locale
368 see determine_charset, determine_language
369 2. figure out who we are, defaulting to the "anonymous" user
370 see determine_user
371 3. figure out what the request is for - the context
372 see determine_context
373 4. handle any requested action (item edit, search, ...)
374 see handle_action
375 5. render a template, resulting in HTML output
377 In some situations, exceptions occur:
379 - HTTP Redirect (generally raised by an action)
380 - SendFile (generally raised by determine_context)
381 serve up a FileClass "content" property
382 - SendStaticFile (generally raised by determine_context)
383 serve up a file from the tracker "html" directory
384 - Unauthorised (generally raised by an action)
385 the action is cancelled, the request is rendered and an error
386 message is displayed indicating that permission was not
387 granted for the action to take place
388 - templating.Unauthorised (templating action not permitted)
389 raised by an attempted rendering of a template when the user
390 doesn't have permission
391 - NotFound (raised wherever it needs to be)
392 percolates up to the CGI interface that called the client
393 """
394 self.ok_message = []
395 self.error_message = []
396 try:
397 self.determine_charset()
398 self.determine_language()
400 # make sure we're identified (even anonymously)
401 self.determine_user()
403 # figure out the context and desired content template
404 self.determine_context()
406 # possibly handle a form submit action (may change self.classname
407 # and self.template, and may also append error/ok_messages)
408 html = self.handle_action()
410 if html:
411 self.write_html(html)
412 return
414 # now render the page
415 # we don't want clients caching our dynamic pages
416 self.additional_headers['Cache-Control'] = 'no-cache'
417 # Pragma: no-cache makes Mozilla and its ilk double-load all pages!!
418 # self.additional_headers['Pragma'] = 'no-cache'
420 # pages with messages added expire right now
421 # simple views may be cached for a small amount of time
422 # TODO? make page expire time configurable
423 # <rj> always expire pages, as IE just doesn't seem to do the
424 # right thing here :(
425 date = time.time() - 1
426 #if self.error_message or self.ok_message:
427 # date = time.time() - 1
428 #else:
429 # date = time.time() + 5
430 self.additional_headers['Expires'] = rfc822.formatdate(date)
432 # render the content
433 try:
434 self.write_html(self.renderContext())
435 except IOError:
436 # IOErrors here are due to the client disconnecting before
437 # recieving the reply.
438 pass
440 except SeriousError, message:
441 self.write_html(str(message))
442 except Redirect, url:
443 # let's redirect - if the url isn't None, then we need to do
444 # the headers, otherwise the headers have been set before the
445 # exception was raised
446 if url:
447 self.additional_headers['Location'] = str(url)
448 self.response_code = 302
449 self.write_html('Redirecting to <a href="%s">%s</a>'%(url, url))
450 except SendFile, designator:
451 try:
452 self.serve_file(designator)
453 except NotModified:
454 # send the 304 response
455 self.response_code = 304
456 self.header()
457 except SendStaticFile, file:
458 try:
459 self.serve_static_file(str(file))
460 except NotModified:
461 # send the 304 response
462 self.response_code = 304
463 self.header()
464 except Unauthorised, message:
465 # users may always see the front page
466 self.response_code = 403
467 self.classname = self.nodeid = None
468 self.template = ''
469 self.error_message.append(message)
470 self.write_html(self.renderContext())
471 except NotFound, e:
472 self.response_code = 404
473 self.template = '404'
474 try:
475 cl = self.db.getclass(self.classname)
476 self.write_html(self.renderContext())
477 except KeyError:
478 # we can't map the URL to a class we know about
479 # reraise the NotFound and let roundup_server
480 # handle it
481 raise NotFound, e
482 except FormError, e:
483 self.error_message.append(self._('Form Error: ') + str(e))
484 self.write_html(self.renderContext())
485 except:
486 if self.instance.config.WEB_DEBUG:
487 self.write_html(cgitb.html(i18n=self.translator))
488 else:
489 self.mailer.exception_message()
490 return self.write_html(self._(error_message))
492 def clean_sessions(self):
493 """Deprecated
494 XXX remove
495 """
496 self.clean_up()
498 def clean_up(self):
499 """Remove expired sessions and One Time Keys.
501 Do it only once an hour.
502 """
503 hour = 60*60
504 now = time.time()
506 # XXX: hack - use OTK table to store last_clean time information
507 # 'last_clean' string is used instead of otk key
508 last_clean = self.db.getOTKManager().get('last_clean', 'last_use', 0)
509 if now - last_clean < hour:
510 return
512 self.session_api.clean_up()
513 self.db.getOTKManager().clean()
514 self.db.getOTKManager().set('last_clean', last_use=now)
515 self.db.commit(fail_ok=True)
517 def determine_charset(self):
518 """Look for client charset in the form parameters or browser cookie.
520 If no charset requested by client, use storage charset (utf-8).
522 If the charset is found, and differs from the storage charset,
523 recode all form fields of type 'text/plain'
524 """
525 # look for client charset
526 charset_parameter = 0
527 if self.form.has_key('@charset'):
528 charset = self.form['@charset'].value
529 if charset.lower() == "none":
530 charset = ""
531 charset_parameter = 1
532 elif self.cookie.has_key('roundup_charset'):
533 charset = self.cookie['roundup_charset'].value
534 else:
535 charset = None
536 if charset:
537 # make sure the charset is recognized
538 try:
539 codecs.lookup(charset)
540 except LookupError:
541 self.error_message.append(self._('Unrecognized charset: %r')
542 % charset)
543 charset_parameter = 0
544 else:
545 self.charset = charset.lower()
546 # If we've got a character set in request parameters,
547 # set the browser cookie to keep the preference.
548 # This is done after codecs.lookup to make sure
549 # that we aren't keeping a wrong value.
550 if charset_parameter:
551 self.add_cookie('roundup_charset', charset)
553 # if client charset is different from the storage charset,
554 # recode form fields
555 # XXX this requires FieldStorage from Python library.
556 # mod_python FieldStorage is not supported!
557 if self.charset != self.STORAGE_CHARSET:
558 decoder = codecs.getdecoder(self.charset)
559 encoder = codecs.getencoder(self.STORAGE_CHARSET)
560 re_charref = re.compile('&#([0-9]+|x[0-9a-f]+);', re.IGNORECASE)
561 def _decode_charref(matchobj):
562 num = matchobj.group(1)
563 if num[0].lower() == 'x':
564 uc = int(num[1:], 16)
565 else:
566 uc = int(num)
567 return unichr(uc)
569 for field_name in self.form.keys():
570 field = self.form[field_name]
571 if (field.type == 'text/plain') and not field.filename:
572 try:
573 value = decoder(field.value)[0]
574 except UnicodeError:
575 continue
576 value = re_charref.sub(_decode_charref, value)
577 field.value = encoder(value)[0]
579 def determine_language(self):
580 """Determine the language"""
581 # look for language parameter
582 # then for language cookie
583 # last for the Accept-Language header
584 if self.form.has_key("@language"):
585 language = self.form["@language"].value
586 if language.lower() == "none":
587 language = ""
588 self.add_cookie("roundup_language", language)
589 elif self.cookie.has_key("roundup_language"):
590 language = self.cookie["roundup_language"].value
591 elif self.instance.config["WEB_USE_BROWSER_LANGUAGE"]:
592 hal = self.env.get('HTTP_ACCEPT_LANGUAGE')
593 language = accept_language.parse(hal)
594 else:
595 language = ""
597 self.language = language
598 if language:
599 self.setTranslator(TranslationService.get_translation(
600 language,
601 tracker_home=self.instance.config["TRACKER_HOME"]))
603 def determine_user(self):
604 """Determine who the user is"""
605 self.opendb('admin')
607 # get session data from db
608 # XXX: rename
609 self.session_api = Session(self)
611 # take the opportunity to cleanup expired sessions and otks
612 self.clean_up()
614 user = None
615 # first up, try http authorization if enabled
616 if self.instance.config['WEB_HTTP_AUTH']:
617 if self.env.has_key('REMOTE_USER'):
618 # we have external auth (e.g. by Apache)
619 user = self.env['REMOTE_USER']
620 elif self.env.get('HTTP_AUTHORIZATION', ''):
621 # try handling Basic Auth ourselves
622 auth = self.env['HTTP_AUTHORIZATION']
623 scheme, challenge = auth.split(' ', 1)
624 if scheme.lower() == 'basic':
625 try:
626 decoded = base64.decodestring(challenge)
627 except TypeError:
628 # invalid challenge
629 pass
630 username, password = decoded.split(':')
631 try:
632 login = self.get_action_class('login')(self)
633 login.verifyLogin(username, password)
634 except LoginError, err:
635 self.make_user_anonymous()
636 raise Unauthorised, err
637 user = username
639 # if user was not set by http authorization, try session lookup
640 if not user:
641 user = self.session_api.get('user')
642 if user:
643 # update session lifetime datestamp
644 self.session_api.update()
646 # if no user name set by http authorization or session lookup
647 # the user is anonymous
648 if not user:
649 user = 'anonymous'
651 # sanity check on the user still being valid,
652 # getting the userid at the same time
653 try:
654 self.userid = self.db.user.lookup(user)
655 except (KeyError, TypeError):
656 user = 'anonymous'
658 # make sure the anonymous user is valid if we're using it
659 if user == 'anonymous':
660 self.make_user_anonymous()
661 if not self.db.security.hasPermission('Web Access', self.userid):
662 raise Unauthorised, self._("Anonymous users are not "
663 "allowed to use the web interface")
664 else:
665 self.user = user
667 # reopen the database as the correct user
668 self.opendb(self.user)
670 def opendb(self, username):
671 """Open the database and set the current user.
673 Opens a database once. On subsequent calls only the user is set on
674 the database object the instance.optimize is set. If we are in
675 "Development Mode" (cf. roundup_server) then the database is always
676 re-opened.
677 """
678 # don't do anything if the db is open and the user has not changed
679 if hasattr(self, 'db') and self.db.isCurrentUser(username):
680 return
682 # open the database or only set the user
683 if not hasattr(self, 'db'):
684 self.db = self.instance.open(username)
685 else:
686 if self.instance.optimize:
687 self.db.setCurrentUser(username)
688 else:
689 self.db.close()
690 self.db = self.instance.open(username)
692 def determine_context(self, dre=re.compile(r'([^\d]+)0*(\d+)')):
693 """Determine the context of this page from the URL:
695 The URL path after the instance identifier is examined. The path
696 is generally only one entry long.
698 - if there is no path, then we are in the "home" context.
699 - if the path is "_file", then the additional path entry
700 specifies the filename of a static file we're to serve up
701 from the instance "html" directory. Raises a SendStaticFile
702 exception.(*)
703 - if there is something in the path (eg "issue"), it identifies
704 the tracker class we're to display.
705 - if the path is an item designator (eg "issue123"), then we're
706 to display a specific item.
707 - if the path starts with an item designator and is longer than
708 one entry, then we're assumed to be handling an item of a
709 FileClass, and the extra path information gives the filename
710 that the client is going to label the download with (ie
711 "file123/image.png" is nicer to download than "file123"). This
712 raises a SendFile exception.(*)
714 Both of the "*" types of contexts stop before we bother to
715 determine the template we're going to use. That's because they
716 don't actually use templates.
718 The template used is specified by the :template CGI variable,
719 which defaults to:
721 - only classname suplied: "index"
722 - full item designator supplied: "item"
724 We set:
726 self.classname - the class to display, can be None
728 self.template - the template to render the current context with
730 self.nodeid - the nodeid of the class we're displaying
731 """
732 # default the optional variables
733 self.classname = None
734 self.nodeid = None
736 # see if a template or messages are specified
737 template_override = ok_message = error_message = None
738 for key in self.form.keys():
739 if self.FV_TEMPLATE.match(key):
740 template_override = self.form[key].value
741 elif self.FV_OK_MESSAGE.match(key):
742 ok_message = self.form[key].value
743 ok_message = clean_message(ok_message)
744 elif self.FV_ERROR_MESSAGE.match(key):
745 error_message = self.form[key].value
746 error_message = clean_message(error_message)
748 # see if we were passed in a message
749 if ok_message:
750 self.ok_message.append(ok_message)
751 if error_message:
752 self.error_message.append(error_message)
754 # determine the classname and possibly nodeid
755 path = self.path.split('/')
756 if not path or path[0] in ('', 'home', 'index'):
757 if template_override is not None:
758 self.template = template_override
759 else:
760 self.template = ''
761 return
762 elif path[0] in ('_file', '@@file'):
763 raise SendStaticFile, os.path.join(*path[1:])
764 else:
765 self.classname = path[0]
766 if len(path) > 1:
767 # send the file identified by the designator in path[0]
768 raise SendFile, path[0]
770 # see if we got a designator
771 m = dre.match(self.classname)
772 if m:
773 self.classname = m.group(1)
774 self.nodeid = m.group(2)
775 try:
776 klass = self.db.getclass(self.classname)
777 except KeyError:
778 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
779 if not klass.hasnode(self.nodeid):
780 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
781 # with a designator, we default to item view
782 self.template = 'item'
783 else:
784 # with only a class, we default to index view
785 self.template = 'index'
787 # make sure the classname is valid
788 try:
789 self.db.getclass(self.classname)
790 except KeyError:
791 raise NotFound, self.classname
793 # see if we have a template override
794 if template_override is not None:
795 self.template = template_override
797 def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
798 """ Serve the file from the content property of the designated item.
799 """
800 m = dre.match(str(designator))
801 if not m:
802 raise NotFound, str(designator)
803 classname, nodeid = m.group(1), m.group(2)
805 klass = self.db.getclass(classname)
807 # make sure we have the appropriate properties
808 props = klass.getprops()
809 if not props.has_key('type'):
810 raise NotFound, designator
811 if not props.has_key('content'):
812 raise NotFound, designator
814 # make sure we have permission
815 if not self.db.security.hasPermission('View', self.userid,
816 classname, 'content', nodeid):
817 raise Unauthorised, self._("You are not allowed to view "
818 "this file.")
820 mime_type = klass.get(nodeid, 'type')
822 # If this object is a file (i.e., an instance of FileClass),
823 # see if we can find it in the filesystem. If so, we may be
824 # able to use the more-efficient request.sendfile method of
825 # sending the file. If not, just get the "content" property
826 # in the usual way, and use that.
827 content = None
828 filename = None
829 if isinstance(klass, hyperdb.FileClass):
830 try:
831 filename = self.db.filename(classname, nodeid)
832 except AttributeError:
833 # The database doesn't store files in the filesystem
834 # and therefore doesn't provide the "filename" method.
835 pass
836 except IOError:
837 # The file does not exist.
838 pass
839 if not filename:
840 content = klass.get(nodeid, 'content')
842 lmt = klass.get(nodeid, 'activity').timestamp()
844 self._serve_file(lmt, mime_type, content, filename)
846 def serve_static_file(self, file):
847 """ Serve up the file named from the templates dir
848 """
849 # figure the filename - try STATIC_FILES, then TEMPLATES dir
850 for dir_option in ('STATIC_FILES', 'TEMPLATES'):
851 prefix = self.instance.config[dir_option]
852 if not prefix:
853 continue
854 # ensure the load doesn't try to poke outside
855 # of the static files directory
856 prefix = os.path.normpath(prefix)
857 filename = os.path.normpath(os.path.join(prefix, file))
858 if os.path.isfile(filename) and filename.startswith(prefix):
859 break
860 else:
861 raise NotFound, file
863 # last-modified time
864 lmt = os.stat(filename)[stat.ST_MTIME]
866 # detemine meta-type
867 file = str(file)
868 mime_type = mimetypes.guess_type(file)[0]
869 if not mime_type:
870 if file.endswith('.css'):
871 mime_type = 'text/css'
872 else:
873 mime_type = 'text/plain'
875 self._serve_file(lmt, mime_type, '', filename)
877 def _serve_file(self, lmt, mime_type, content=None, filename=None):
878 """ guts of serve_file() and serve_static_file()
879 """
881 # spit out headers
882 self.additional_headers['Content-Type'] = mime_type
883 self.additional_headers['Last-Modified'] = rfc822.formatdate(lmt)
885 ims = None
886 # see if there's an if-modified-since...
887 # XXX see which interfaces set this
888 #if hasattr(self.request, 'headers'):
889 #ims = self.request.headers.getheader('if-modified-since')
890 if self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
891 # cgi will put the header in the env var
892 ims = self.env['HTTP_IF_MODIFIED_SINCE']
893 if ims:
894 ims = rfc822.parsedate(ims)[:6]
895 lmtt = time.gmtime(lmt)[:6]
896 if lmtt <= ims:
897 raise NotModified
899 if filename:
900 self.write_file(filename)
901 else:
902 self.additional_headers['Content-Length'] = str(len(content))
903 self.write(content)
905 def renderContext(self):
906 """ Return a PageTemplate for the named page
907 """
908 name = self.classname
909 extension = self.template
911 # catch errors so we can handle PT rendering errors more nicely
912 args = {
913 'ok_message': self.ok_message,
914 'error_message': self.error_message
915 }
916 try:
917 pt = self.instance.templates.get(name, extension)
918 # let the template render figure stuff out
919 result = pt.render(self, None, None, **args)
920 self.additional_headers['Content-Type'] = pt.content_type
921 if self.env.get('CGI_SHOW_TIMING', ''):
922 if self.env['CGI_SHOW_TIMING'].upper() == 'COMMENT':
923 timings = {'starttag': '<!-- ', 'endtag': ' -->'}
924 else:
925 timings = {'starttag': '<p>', 'endtag': '</p>'}
926 timings['seconds'] = time.time()-self.start
927 s = self._('%(starttag)sTime elapsed: %(seconds)fs%(endtag)s\n'
928 ) % timings
929 if hasattr(self.db, 'stats'):
930 timings.update(self.db.stats)
931 s += self._("%(starttag)sCache hits: %(cache_hits)d,"
932 " misses %(cache_misses)d."
933 " Loading items: %(get_items)f secs."
934 " Filtering: %(filtering)f secs."
935 "%(endtag)s\n") % timings
936 s += '</body>'
937 result = result.replace('</body>', s)
938 return result
939 except templating.NoTemplate, message:
940 return '<strong>%s</strong>'%message
941 except templating.Unauthorised, message:
942 raise Unauthorised, str(message)
943 except:
944 # everything else
945 if self.instance.config.WEB_DEBUG:
946 return cgitb.pt_html(i18n=self.translator)
947 exc_info = sys.exc_info()
948 try:
949 # If possible, send the HTML page template traceback
950 # to the administrator.
951 to = [self.mailer.config.ADMIN_EMAIL]
952 subject = "Templating Error: %s" % exc_info[1]
953 content = cgitb.pt_html()
954 message, writer = self.mailer.get_standard_message(
955 to, subject)
956 writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
957 body = writer.startbody('text/html; charset=utf-8')
958 content = StringIO(content)
959 quopri.encode(content, body, 0)
960 self.mailer.smtp_send(to, message)
961 # Now report the error to the user.
962 return self._(error_message)
963 except:
964 # Reraise the original exception. The user will
965 # receive an error message, and the adminstrator will
966 # receive a traceback, albeit with less information
967 # than the one we tried to generate above.
968 raise exc_info[0], exc_info[1], exc_info[2]
970 # these are the actions that are available
971 actions = (
972 ('edit', EditItemAction),
973 ('editcsv', EditCSVAction),
974 ('new', NewItemAction),
975 ('register', RegisterAction),
976 ('confrego', ConfRegoAction),
977 ('passrst', PassResetAction),
978 ('login', LoginAction),
979 ('logout', LogoutAction),
980 ('search', SearchAction),
981 ('retire', RetireAction),
982 ('show', ShowAction),
983 ('export_csv', ExportCSVAction),
984 )
985 def handle_action(self):
986 """ Determine whether there should be an Action called.
988 The action is defined by the form variable :action which
989 identifies the method on this object to call. The actions
990 are defined in the "actions" sequence on this class.
992 Actions may return a page (by default HTML) to return to the
993 user, bypassing the usual template rendering.
995 We explicitly catch Reject and ValueError exceptions and
996 present their messages to the user.
997 """
998 if self.form.has_key(':action'):
999 action = self.form[':action'].value.lower()
1000 elif self.form.has_key('@action'):
1001 action = self.form['@action'].value.lower()
1002 else:
1003 return None
1005 try:
1006 action_klass = self.get_action_class(action)
1008 # call the mapped action
1009 if isinstance(action_klass, type('')):
1010 # old way of specifying actions
1011 return getattr(self, action_klass)()
1012 else:
1013 return action_klass(self).execute()
1015 except (ValueError, Reject), err:
1016 self.error_message.append(str(err))
1018 def get_action_class(self, action_name):
1019 if (hasattr(self.instance, 'cgi_actions') and
1020 self.instance.cgi_actions.has_key(action_name)):
1021 # tracker-defined action
1022 action_klass = self.instance.cgi_actions[action_name]
1023 else:
1024 # go with a default
1025 for name, action_klass in self.actions:
1026 if name == action_name:
1027 break
1028 else:
1029 raise ValueError, 'No such action "%s"'%action_name
1030 return action_klass
1032 def _socket_op(self, call, *args, **kwargs):
1033 """Execute socket-related operation, catch common network errors
1035 Parameters:
1036 call: a callable to execute
1037 args, kwargs: call arguments
1039 """
1040 try:
1041 call(*args, **kwargs)
1042 except socket.error, err:
1043 err_errno = getattr (err, 'errno', None)
1044 if err_errno is None:
1045 try:
1046 err_errno = err[0]
1047 except TypeError:
1048 pass
1049 if err_errno not in self.IGNORE_NET_ERRORS:
1050 raise
1051 except IOError:
1052 # Apache's mod_python will raise IOError -- without an
1053 # accompanying errno -- when a write to the client fails.
1054 # A common case is that the client has closed the
1055 # connection. There's no way to be certain that this is
1056 # the situation that has occurred here, but that is the
1057 # most likely case.
1058 pass
1060 def write(self, content):
1061 if not self.headers_done:
1062 self.header()
1063 if self.env['REQUEST_METHOD'] != 'HEAD':
1064 self._socket_op(self.request.wfile.write, content)
1066 def write_html(self, content):
1067 if not self.headers_done:
1068 # at this point, we are sure about Content-Type
1069 if not self.additional_headers.has_key('Content-Type'):
1070 self.additional_headers['Content-Type'] = \
1071 'text/html; charset=%s' % self.charset
1072 self.header()
1074 if self.env['REQUEST_METHOD'] == 'HEAD':
1075 # client doesn't care about content
1076 return
1078 if self.charset != self.STORAGE_CHARSET:
1079 # recode output
1080 content = content.decode(self.STORAGE_CHARSET, 'replace')
1081 content = content.encode(self.charset, 'xmlcharrefreplace')
1083 # and write
1084 self._socket_op(self.request.wfile.write, content)
1086 def http_strip(self, content):
1087 """Remove HTTP Linear White Space from 'content'.
1089 'content' -- A string.
1091 returns -- 'content', with all leading and trailing LWS
1092 removed."""
1094 # RFC 2616 2.2: Basic Rules
1095 #
1096 # LWS = [CRLF] 1*( SP | HT )
1097 return content.strip(" \r\n\t")
1099 def http_split(self, content):
1100 """Split an HTTP list.
1102 'content' -- A string, giving a list of items.
1104 returns -- A sequence of strings, containing the elements of
1105 the list."""
1107 # RFC 2616 2.1: Augmented BNF
1108 #
1109 # Grammar productions of the form "#rule" indicate a
1110 # comma-separated list of elements matching "rule". LWS
1111 # is then removed from each element, and empty elements
1112 # removed.
1114 # Split at commas.
1115 elements = content.split(",")
1116 # Remove linear whitespace at either end of the string.
1117 elements = [self.http_strip(e) for e in elements]
1118 # Remove any now-empty elements.
1119 return [e for e in elements if e]
1121 def handle_range_header(self, length, etag):
1122 """Handle the 'Range' and 'If-Range' headers.
1124 'length' -- the length of the content available for the
1125 resource.
1127 'etag' -- the entity tag for this resources.
1129 returns -- If the request headers (including 'Range' and
1130 'If-Range') indicate that only a portion of the entity should
1131 be returned, then the return value is a pair '(offfset,
1132 length)' indicating the first byte and number of bytes of the
1133 content that should be returned to the client. In addition,
1134 this method will set 'self.response_code' to indicate Partial
1135 Content. In all other cases, the return value is 'None'. If
1136 appropriate, 'self.response_code' will be
1137 set to indicate 'REQUESTED_RANGE_NOT_SATISFIABLE'. In that
1138 case, the caller should not send any data to the client."""
1140 # RFC 2616 14.35: Range
1141 #
1142 # See if the Range header is present.
1143 ranges_specifier = self.env.get("HTTP_RANGE")
1144 if ranges_specifier is None:
1145 return None
1146 # RFC 2616 14.27: If-Range
1147 #
1148 # Check to see if there is an If-Range header.
1149 # Because the specification says:
1150 #
1151 # The If-Range header ... MUST be ignored if the request
1152 # does not include a Range header, we check for If-Range
1153 # after checking for Range.
1154 if_range = self.env.get("HTTP_IF_RANGE")
1155 if if_range:
1156 # The grammar for the If-Range header is:
1157 #
1158 # If-Range = "If-Range" ":" ( entity-tag | HTTP-date )
1159 # entity-tag = [ weak ] opaque-tag
1160 # weak = "W/"
1161 # opaque-tag = quoted-string
1162 #
1163 # We only support strong entity tags.
1164 if_range = self.http_strip(if_range)
1165 if (not if_range.startswith('"')
1166 or not if_range.endswith('"')):
1167 return None
1168 # If the condition doesn't match the entity tag, then we
1169 # must send the client the entire file.
1170 if if_range != etag:
1171 return
1172 # The grammar for the Range header value is:
1173 #
1174 # ranges-specifier = byte-ranges-specifier
1175 # byte-ranges-specifier = bytes-unit "=" byte-range-set
1176 # byte-range-set = 1#( byte-range-spec | suffix-byte-range-spec )
1177 # byte-range-spec = first-byte-pos "-" [last-byte-pos]
1178 # first-byte-pos = 1*DIGIT
1179 # last-byte-pos = 1*DIGIT
1180 # suffix-byte-range-spec = "-" suffix-length
1181 # suffix-length = 1*DIGIT
1182 #
1183 # Look for the "=" separating the units from the range set.
1184 specs = ranges_specifier.split("=", 1)
1185 if len(specs) != 2:
1186 return None
1187 # Check that the bytes-unit is in fact "bytes". If it is not,
1188 # we do not know how to process this range.
1189 bytes_unit = self.http_strip(specs[0])
1190 if bytes_unit != "bytes":
1191 return None
1192 # Seperate the range-set into range-specs.
1193 byte_range_set = self.http_strip(specs[1])
1194 byte_range_specs = self.http_split(byte_range_set)
1195 # We only handle exactly one range at this time.
1196 if len(byte_range_specs) != 1:
1197 return None
1198 # Parse the spec.
1199 byte_range_spec = byte_range_specs[0]
1200 pos = byte_range_spec.split("-", 1)
1201 if len(pos) != 2:
1202 return None
1203 # Get the first and last bytes.
1204 first = self.http_strip(pos[0])
1205 last = self.http_strip(pos[1])
1206 # We do not handle suffix ranges.
1207 if not first:
1208 return None
1209 # Convert the first and last positions to integers.
1210 try:
1211 first = int(first)
1212 if last:
1213 last = int(last)
1214 else:
1215 last = length - 1
1216 except:
1217 # The positions could not be parsed as integers.
1218 return None
1219 # Check that the range makes sense.
1220 if (first < 0 or last < 0 or last < first):
1221 return None
1222 if last >= length:
1223 # RFC 2616 10.4.17: 416 Requested Range Not Satisfiable
1224 #
1225 # If there is an If-Range header, RFC 2616 says that we
1226 # should just ignore the invalid Range header.
1227 if if_range:
1228 return None
1229 # Return code 416 with a Content-Range header giving the
1230 # allowable range.
1231 self.response_code = httplib.REQUESTED_RANGE_NOT_SATISFIABLE
1232 self.setHeader("Content-Range", "bytes */%d" % length)
1233 return None
1234 # RFC 2616 10.2.7: 206 Partial Content
1235 #
1236 # Tell the client that we are honoring the Range request by
1237 # indicating that we are providing partial content.
1238 self.response_code = httplib.PARTIAL_CONTENT
1239 # RFC 2616 14.16: Content-Range
1240 #
1241 # Tell the client what data we are providing.
1242 #
1243 # content-range-spec = byte-content-range-spec
1244 # byte-content-range-spec = bytes-unit SP
1245 # byte-range-resp-spec "/"
1246 # ( instance-length | "*" )
1247 # byte-range-resp-spec = (first-byte-pos "-" last-byte-pos)
1248 # | "*"
1249 # instance-length = 1 * DIGIT
1250 self.setHeader("Content-Range",
1251 "bytes %d-%d/%d" % (first, last, length))
1252 return (first, last - first + 1)
1254 def write_file(self, filename):
1255 """Send the contents of 'filename' to the user."""
1257 # Determine the length of the file.
1258 stat_info = os.stat(filename)
1259 length = stat_info[stat.ST_SIZE]
1260 # Assume we will return the entire file.
1261 offset = 0
1262 # If the headers have not already been finalized,
1263 if not self.headers_done:
1264 # RFC 2616 14.19: ETag
1265 #
1266 # Compute the entity tag, in a format similar to that
1267 # used by Apache.
1268 etag = '"%x-%x-%x"' % (stat_info[stat.ST_INO],
1269 length,
1270 stat_info[stat.ST_MTIME])
1271 self.setHeader("ETag", etag)
1272 # RFC 2616 14.5: Accept-Ranges
1273 #
1274 # Let the client know that we will accept range requests.
1275 self.setHeader("Accept-Ranges", "bytes")
1276 # RFC 2616 14.35: Range
1277 #
1278 # If there is a Range header, we may be able to avoid
1279 # sending the entire file.
1280 content_range = self.handle_range_header(length, etag)
1281 if content_range:
1282 offset, length = content_range
1283 # RFC 2616 14.13: Content-Length
1284 #
1285 # Tell the client how much data we are providing.
1286 self.setHeader("Content-Length", length)
1287 # Send the HTTP header.
1288 self.header()
1289 # If the client doesn't actually want the body, or if we are
1290 # indicating an invalid range.
1291 if (self.env['REQUEST_METHOD'] == 'HEAD'
1292 or self.response_code == httplib.REQUESTED_RANGE_NOT_SATISFIABLE):
1293 return
1294 # Use the optimized "sendfile" operation, if possible.
1295 if hasattr(self.request, "sendfile"):
1296 self._socket_op(self.request.sendfile, filename, offset, length)
1297 return
1298 # Fallback to the "write" operation.
1299 f = open(filename, 'rb')
1300 try:
1301 if offset:
1302 f.seek(offset)
1303 content = f.read(length)
1304 finally:
1305 f.close()
1306 self.write(content)
1308 def setHeader(self, header, value):
1309 """Override a header to be returned to the user's browser.
1310 """
1311 self.additional_headers[header] = value
1313 def header(self, headers=None, response=None):
1314 """Put up the appropriate header.
1315 """
1316 if headers is None:
1317 headers = {'Content-Type':'text/html; charset=utf-8'}
1318 if response is None:
1319 response = self.response_code
1321 # update with additional info
1322 headers.update(self.additional_headers)
1324 if headers.get('Content-Type', 'text/html') == 'text/html':
1325 headers['Content-Type'] = 'text/html; charset=utf-8'
1327 headers = headers.items()
1329 for ((path, name), (value, expire)) in self._cookies.items():
1330 cookie = "%s=%s; Path=%s;"%(name, value, path)
1331 if expire is not None:
1332 cookie += " expires=%s;"%Cookie._getdate(expire)
1333 headers.append(('Set-Cookie', cookie))
1335 self._socket_op(self.request.start_response, headers, response)
1337 self.headers_done = 1
1338 if self.debug:
1339 self.headers_sent = headers
1341 def add_cookie(self, name, value, expire=86400*365, path=None):
1342 """Set a cookie value to be sent in HTTP headers
1344 Parameters:
1345 name:
1346 cookie name
1347 value:
1348 cookie value
1349 expire:
1350 cookie expiration time (seconds).
1351 If value is empty (meaning "delete cookie"),
1352 expiration time is forced in the past
1353 and this argument is ignored.
1354 If None, the cookie will expire at end-of-session.
1355 If omitted, the cookie will be kept for a year.
1356 path:
1357 cookie path (optional)
1359 """
1360 if path is None:
1361 path = self.cookie_path
1362 if not value:
1363 expire = -1
1364 self._cookies[(path, name)] = (value, expire)
1366 def set_cookie(self, user, expire=None):
1367 """Deprecated. Use session_api calls directly
1369 XXX remove
1370 """
1372 # insert the session in the session db
1373 self.session_api.set(user=user)
1374 # refresh session cookie
1375 self.session_api.update(set_cookie=True, expire=expire)
1377 def make_user_anonymous(self):
1378 """ Make us anonymous
1380 This method used to handle non-existence of the 'anonymous'
1381 user, but that user is mandatory now.
1382 """
1383 self.userid = self.db.user.lookup('anonymous')
1384 self.user = 'anonymous'
1386 def standard_message(self, to, subject, body, author=None):
1387 """Send a standard email message from Roundup.
1389 "to" - recipients list
1390 "subject" - Subject
1391 "body" - Message
1392 "author" - (name, address) tuple or None for admin email
1394 Arguments are passed to the Mailer.standard_message code.
1395 """
1396 try:
1397 self.mailer.standard_message(to, subject, body, author)
1398 except MessageSendError, e:
1399 self.error_message.append(str(e))
1400 return 0
1401 return 1
1403 def parsePropsFromForm(self, create=0):
1404 return FormParser(self).parse(create=create)
1406 # vim: set et sts=4 sw=4 :