Code

- fix handling of traceback mails to the roundup admin
[roundup.git] / roundup / cgi / client.py
1 """WWW request handler (also used in the stand-alone server).
2 """
3 __docformat__ = 'restructuredtext'
5 import base64, binascii, cgi, codecs, mimetypes, os
6 import quopri, random, re, rfc822, stat, sys, time
7 import socket, errno
8 from traceback import format_exc
10 from roundup import roundupdb, date, hyperdb, password
11 from roundup.cgi import templating, cgitb, TranslationService
12 from roundup.cgi.actions import *
13 from roundup.exceptions import *
14 from roundup.cgi.exceptions import *
15 from roundup.cgi.form_parser import FormParser
16 from roundup.mailer import Mailer, MessageSendError, encode_quopri
17 from roundup.cgi import accept_language
18 from roundup import xmlrpc
20 from roundup.anypy.cookie_ import CookieError, BaseCookie, SimpleCookie, \
21     get_cookie_date
22 from roundup.anypy.io_ import StringIO
23 from roundup.anypy import http_
24 from roundup.anypy import urllib_
26 from email.MIMEBase import MIMEBase
27 from email.MIMEText import MIMEText
28 from email.MIMEMultipart import MIMEMultipart
30 def initialiseSecurity(security):
31     '''Create some Permissions and Roles on the security object
33     This function is directly invoked by security.Security.__init__()
34     as a part of the Security object instantiation.
35     '''
36     p = security.addPermission(name="Web Access",
37         description="User may access the web interface")
38     security.addPermissionToRole('Admin', p)
40     # doing Role stuff through the web - make sure Admin can
41     # TODO: deprecate this and use a property-based control
42     p = security.addPermission(name="Web Roles",
43         description="User may manipulate user Roles through the web")
44     security.addPermissionToRole('Admin', p)
46 # used to clean messages passed through CGI variables - HTML-escape any tag
47 # that isn't <a href="">, <i>, <b> and <br> (including XHTML variants) so
48 # that people can't pass through nasties like <script>, <iframe>, ...
49 CLEAN_MESSAGE_RE = r'(<(/?(.*?)(\s*href="[^"]")?\s*/?)>)'
50 def clean_message(message, mc=re.compile(CLEAN_MESSAGE_RE, re.I)):
51     return mc.sub(clean_message_callback, message)
52 def clean_message_callback(match, ok={'a':1,'i':1,'b':1,'br':1}):
53     """ Strip all non <a>,<i>,<b> and <br> tags from a string
54     """
55     if match.group(3).lower() in ok:
56         return match.group(1)
57     return '&lt;%s&gt;'%match.group(2)
60 error_message = ''"""<html><head><title>An error has occurred</title></head>
61 <body><h1>An error has occurred</h1>
62 <p>A problem was encountered processing your request.
63 The tracker maintainers have been notified of the problem.</p>
64 </body></html>"""
67 class LiberalCookie(SimpleCookie):
68     """ Python's SimpleCookie throws an exception if the cookie uses invalid
69         syntax.  Other applications on the same server may have done precisely
70         this, preventing roundup from working through no fault of roundup.
71         Numerous other python apps have run into the same problem:
73         trac: http://trac.edgewall.org/ticket/2256
74         mailman: http://bugs.python.org/issue472646
76         This particular implementation comes from trac's solution to the
77         problem. Unfortunately it requires some hackery in SimpleCookie's
78         internals to provide a more liberal __set method.
79     """
80     def load(self, rawdata, ignore_parse_errors=True):
81         if ignore_parse_errors:
82             self.bad_cookies = []
83             self._BaseCookie__set = self._loose_set
84         SimpleCookie.load(self, rawdata)
85         if ignore_parse_errors:
86             self._BaseCookie__set = self._strict_set
87             for key in self.bad_cookies:
88                 del self[key]
90     _strict_set = BaseCookie._BaseCookie__set
92     def _loose_set(self, key, real_value, coded_value):
93         try:
94             self._strict_set(key, real_value, coded_value)
95         except CookieError:
96             self.bad_cookies.append(key)
97             dict.__setitem__(self, key, None)
100 class Session:
101     """
102     Needs DB to be already opened by client
104     Session attributes at instantiation:
106     - "client" - reference to client for add_cookie function
107     - "session_db" - session DB manager
108     - "cookie_name" - name of the cookie with session id
109     - "_sid" - session id for current user
110     - "_data" - session data cache
112     session = Session(client)
113     session.set(name=value)
114     value = session.get(name)
116     session.destroy()  # delete current session
117     session.clean_up() # clean up session table
119     session.update(set_cookie=True, expire=3600*24*365)
120                        # refresh session expiration time, setting persistent
121                        # cookie if needed to last for 'expire' seconds
123     """
125     def __init__(self, client):
126         self._data = {}
127         self._sid  = None
129         self.client = client
130         self.session_db = client.db.getSessionManager()
132         # parse cookies for session id
133         self.cookie_name = 'roundup_session_%s' % \
134             re.sub('[^a-zA-Z]', '', client.instance.config.TRACKER_NAME)
135         cookies = LiberalCookie(client.env.get('HTTP_COOKIE', ''))
136         if self.cookie_name in cookies:
137             if not self.session_db.exists(cookies[self.cookie_name].value):
138                 self._sid = None
139                 # remove old cookie
140                 self.client.add_cookie(self.cookie_name, None)
141             else:
142                 self._sid = cookies[self.cookie_name].value
143                 self._data = self.session_db.getall(self._sid)
145     def _gen_sid(self):
146         """ generate a unique session key """
147         while 1:
148             s = '%s%s'%(time.time(), random.random())
149             s = binascii.b2a_base64(s).strip()
150             if not self.session_db.exists(s):
151                 break
153         # clean up the base64
154         if s[-1] == '=':
155             if s[-2] == '=':
156                 s = s[:-2]
157             else:
158                 s = s[:-1]
159         return s
161     def clean_up(self):
162         """Remove expired sessions"""
163         self.session_db.clean()
165     def destroy(self):
166         self.client.add_cookie(self.cookie_name, None)
167         self._data = {}
168         self.session_db.destroy(self._sid)
169         self.client.db.commit()
171     def get(self, name, default=None):
172         return self._data.get(name, default)
174     def set(self, **kwargs):
175         self._data.update(kwargs)
176         if not self._sid:
177             self._sid = self._gen_sid()
178             self.session_db.set(self._sid, **self._data)
179             # add session cookie
180             self.update(set_cookie=True)
182             # XXX added when patching 1.4.4 for backward compatibility
183             # XXX remove
184             self.client.session = self._sid
185         else:
186             self.session_db.set(self._sid, **self._data)
187             self.client.db.commit()
189     def update(self, set_cookie=False, expire=None):
190         """ update timestamp in db to avoid expiration
192             if 'set_cookie' is True, set cookie with 'expire' seconds lifetime
193             if 'expire' is None - session will be closed with the browser
194              
195             XXX the session can be purged within a week even if a cookie
196                 lifetime is longer
197         """
198         self.session_db.updateTimestamp(self._sid)
199         self.client.db.commit()
201         if set_cookie:
202             self.client.add_cookie(self.cookie_name, self._sid, expire=expire)
206 class Client:
207     """Instantiate to handle one CGI request.
209     See inner_main for request processing.
211     Client attributes at instantiation:
213     - "path" is the PATH_INFO inside the instance (with no leading '/')
214     - "base" is the base URL for the instance
215     - "form" is the cgi form, an instance of FieldStorage from the standard
216       cgi module
217     - "additional_headers" is a dictionary of additional HTTP headers that
218       should be sent to the client
219     - "response_code" is the HTTP response code to send to the client
220     - "translator" is TranslationService instance
222     During the processing of a request, the following attributes are used:
224     - "db" 
225     - "error_message" holds a list of error messages
226     - "ok_message" holds a list of OK messages
227     - "session" is deprecated in favor of session_api (XXX remove)
228     - "session_api" is the interface to store data in session
229     - "user" is the current user's name
230     - "userid" is the current user's id
231     - "template" is the current :template context
232     - "classname" is the current class context name
233     - "nodeid" is the current context item id
235     User Identification:
236      Users that are absent in session data are anonymous and are logged
237      in as that user. This typically gives them all Permissions assigned to the
238      Anonymous Role.
240      Every user is assigned a session. "session_api" is the interface to work
241      with session data.
243     Special form variables:
244      Note that in various places throughout this code, special form
245      variables of the form :<name> are used. The colon (":") part may
246      actually be one of either ":" or "@".
247     """
249     # charset used for data storage and form templates
250     # Note: must be in lower case for comparisons!
251     # XXX take this from instance.config?
252     STORAGE_CHARSET = 'utf-8'
254     #
255     # special form variables
256     #
257     FV_TEMPLATE = re.compile(r'[@:]template')
258     FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
259     FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
261     # Note: index page stuff doesn't appear here:
262     # columns, sort, sortdir, filter, group, groupdir, search_text,
263     # pagesize, startwith
265     # list of network error codes that shouldn't be reported to tracker admin
266     # (error descriptions from FreeBSD intro(2))
267     IGNORE_NET_ERRORS = (
268         # A write on a pipe, socket or FIFO for which there is
269         # no process to read the data.
270         errno.EPIPE,
271         # A connection was forcibly closed by a peer.
272         # This normally results from a loss of the connection
273         # on the remote socket due to a timeout or a reboot.
274         errno.ECONNRESET,
275         # Software caused connection abort.  A connection abort
276         # was caused internal to your host machine.
277         errno.ECONNABORTED,
278         # A connect or send request failed because the connected party
279         # did not properly respond after a period of time.
280         errno.ETIMEDOUT,
281     )
283     def __init__(self, instance, request, env, form=None, translator=None):
284         # re-seed the random number generator
285         random.seed()
286         self.start = time.time()
287         self.instance = instance
288         self.request = request
289         self.env = env
290         self.setTranslator(translator)
291         self.mailer = Mailer(instance.config)
293         # save off the path
294         self.path = env['PATH_INFO']
296         # this is the base URL for this tracker
297         self.base = self.instance.config.TRACKER_WEB
299         # check the tracker_we setting
300         if not self.base.endswith('/'):
301             self.base = self.base + '/'
303         # this is the "cookie path" for this tracker (ie. the path part of
304         # the "base" url)
305         self.cookie_path = urllib_.urlparse(self.base)[2]
306         # cookies to set in http responce
307         # {(path, name): (value, expire)}
308         self._cookies = {}
310         # see if we need to re-parse the environment for the form (eg Zope)
311         if form is None:
312             self.form = cgi.FieldStorage(fp=request.rfile, environ=env)
313         else:
314             self.form = form
316         # turn debugging on/off
317         try:
318             self.debug = int(env.get("ROUNDUP_DEBUG", 0))
319         except ValueError:
320             # someone gave us a non-int debug level, turn it off
321             self.debug = 0
323         # flag to indicate that the HTTP headers have been sent
324         self.headers_done = 0
326         # additional headers to send with the request - must be registered
327         # before the first write
328         self.additional_headers = {}
329         self.response_code = 200
331         # default character set
332         self.charset = self.STORAGE_CHARSET
334         # parse cookies (used for charset lookups)
335         # use our own LiberalCookie to handle bad apps on the same
336         # server that have set cookies that are out of spec
337         self.cookie = LiberalCookie(self.env.get('HTTP_COOKIE', ''))
339         self.user = None
340         self.userid = None
341         self.nodeid = None
342         self.classname = None
343         self.template = None
345     def setTranslator(self, translator=None):
346         """Replace the translation engine
348         'translator'
349            is TranslationService instance.
350            It must define methods 'translate' (TAL-compatible i18n),
351            'gettext' and 'ngettext' (gettext-compatible i18n).
353            If omitted, create default TranslationService.
354         """
355         if translator is None:
356             translator = TranslationService.get_translation(
357                 language=self.instance.config["TRACKER_LANGUAGE"],
358                 tracker_home=self.instance.config["TRACKER_HOME"])
359         self.translator = translator
360         self._ = self.gettext = translator.gettext
361         self.ngettext = translator.ngettext
363     def main(self):
364         """ Wrap the real main in a try/finally so we always close off the db.
365         """
366         try:
367             if self.env.get('CONTENT_TYPE') == 'text/xml':
368                 self.handle_xmlrpc()
369             else:
370                 self.inner_main()
371         finally:
372             if hasattr(self, 'db'):
373                 self.db.close()
376     def handle_xmlrpc(self):
378         # Pull the raw XML out of the form.  The "value" attribute
379         # will be the raw content of the POST request.
380         assert self.form.file
381         input = self.form.value
382         # So that the rest of Roundup can query the form in the
383         # usual way, we create an empty list of fields.
384         self.form.list = []
386         # Set the charset and language, since other parts of
387         # Roundup may depend upon that.
388         self.determine_charset()
389         self.determine_language()
390         # Open the database as the correct user.
391         self.determine_user()
392         self.check_anonymous_access()
394         # Call the appropriate XML-RPC method.
395         handler = xmlrpc.RoundupDispatcher(self.db,
396                                            self.instance.actions,
397                                            self.translator,
398                                            allow_none=True)
399         output = handler.dispatch(input)
401         self.setHeader("Content-Type", "text/xml")
402         self.setHeader("Content-Length", str(len(output)))
403         self.write(output)
404         
405     def inner_main(self):
406         """Process a request.
408         The most common requests are handled like so:
410         1. look for charset and language preferences, set up user locale
411            see determine_charset, determine_language
412         2. figure out who we are, defaulting to the "anonymous" user
413            see determine_user
414         3. figure out what the request is for - the context
415            see determine_context
416         4. handle any requested action (item edit, search, ...)
417            see handle_action
418         5. render a template, resulting in HTML output
420         In some situations, exceptions occur:
422         - HTTP Redirect  (generally raised by an action)
423         - SendFile       (generally raised by determine_context)
424           serve up a FileClass "content" property
425         - SendStaticFile (generally raised by determine_context)
426           serve up a file from the tracker "html" directory
427         - Unauthorised   (generally raised by an action)
428           the action is cancelled, the request is rendered and an error
429           message is displayed indicating that permission was not
430           granted for the action to take place
431         - templating.Unauthorised   (templating action not permitted)
432           raised by an attempted rendering of a template when the user
433           doesn't have permission
434         - NotFound       (raised wherever it needs to be)
435           percolates up to the CGI interface that called the client
436         """
437         self.ok_message = []
438         self.error_message = []
439         try:
440             self.determine_charset()
441             self.determine_language()
443             try:
444                 # make sure we're identified (even anonymously)
445                 self.determine_user()
447                 # figure out the context and desired content template
448                 self.determine_context()
450                 # if we've made it this far the context is to a bit of
451                 # Roundup's real web interface (not a file being served up)
452                 # so do the Anonymous Web Acess check now
453                 self.check_anonymous_access()
455                 # possibly handle a form submit action (may change self.classname
456                 # and self.template, and may also append error/ok_messages)
457                 html = self.handle_action()
459                 if html:
460                     self.write_html(html)
461                     return
463                 # now render the page
464                 # we don't want clients caching our dynamic pages
465                 self.additional_headers['Cache-Control'] = 'no-cache'
466                 # Pragma: no-cache makes Mozilla and its ilk
467                 # double-load all pages!!
468                 #            self.additional_headers['Pragma'] = 'no-cache'
470                 # pages with messages added expire right now
471                 # simple views may be cached for a small amount of time
472                 # TODO? make page expire time configurable
473                 # <rj> always expire pages, as IE just doesn't seem to do the
474                 # right thing here :(
475                 date = time.time() - 1
476                 #if self.error_message or self.ok_message:
477                 #    date = time.time() - 1
478                 #else:
479                 #    date = time.time() + 5
480                 self.additional_headers['Expires'] = rfc822.formatdate(date)
482                 # render the content
483                 self.write_html(self.renderContext())
484             except SendFile, designator:
485                 # The call to serve_file may result in an Unauthorised
486                 # exception or a NotModified exception.  Those
487                 # exceptions will be handled by the outermost set of
488                 # exception handlers.
489                 self.serve_file(designator)
490             except SendStaticFile, file:
491                 self.serve_static_file(str(file))
492             except IOError:
493                 # IOErrors here are due to the client disconnecting before
494                 # recieving the reply.
495                 pass
497         except SeriousError, message:
498             self.write_html(str(message))
499         except Redirect, url:
500             # let's redirect - if the url isn't None, then we need to do
501             # the headers, otherwise the headers have been set before the
502             # exception was raised
503             if url:
504                 self.additional_headers['Location'] = str(url)
505                 self.response_code = 302
506             self.write_html('Redirecting to <a href="%s">%s</a>'%(url, url))
507         except LoginError, message:
508             # The user tried to log in, but did not provide a valid
509             # username and password.  If we support HTTP
510             # authorization, send back a response that will cause the
511             # browser to prompt the user again.
512             if self.instance.config.WEB_HTTP_AUTH:
513                 self.response_code = http_.client.UNAUTHORIZED
514                 realm = self.instance.config.TRACKER_NAME
515                 self.setHeader("WWW-Authenticate",
516                                "Basic realm=\"%s\"" % realm)
517             else:
518                 self.response_code = http_.client.FORBIDDEN
519             self.renderFrontPage(message)
520         except Unauthorised, message:
521             # users may always see the front page
522             self.response_code = 403
523             self.renderFrontPage(message)
524         except NotModified:
525             # send the 304 response
526             self.response_code = 304
527             self.header()
528         except NotFound, e:
529             self.response_code = 404
530             self.template = '404'
531             try:
532                 cl = self.db.getclass(self.classname)
533                 self.write_html(self.renderContext())
534             except KeyError:
535                 # we can't map the URL to a class we know about
536                 # reraise the NotFound and let roundup_server
537                 # handle it
538                 raise NotFound(e)
539         except FormError, e:
540             self.error_message.append(self._('Form Error: ') + str(e))
541             self.write_html(self.renderContext())
542         except:
543             # Something has gone badly wrong.  Therefore, we should
544             # make sure that the response code indicates failure.
545             if self.response_code == http_.client.OK:
546                 self.response_code = http_.client.INTERNAL_SERVER_ERROR
547             # Help the administrator work out what went wrong.
548             html = ("<h1>Traceback</h1>"
549                     + cgitb.html(i18n=self.translator)
550                     + ("<h1>Environment Variables</h1><table>%s</table>"
551                        % cgitb.niceDict("", self.env)))
552             if not self.instance.config.WEB_DEBUG:
553                 exc_info = sys.exc_info()
554                 subject = "Error: %s" % exc_info[1]
555                 self.send_error_to_admin(subject, html, format_exc())
556                 self.write_html(self._(error_message))
557             else:
558                 self.write_html(html)
560     def clean_sessions(self):
561         """Deprecated
562            XXX remove
563         """
564         self.clean_up()
566     def clean_up(self):
567         """Remove expired sessions and One Time Keys.
569            Do it only once an hour.
570         """
571         hour = 60*60
572         now = time.time()
574         # XXX: hack - use OTK table to store last_clean time information
575         #      'last_clean' string is used instead of otk key
576         last_clean = self.db.getOTKManager().get('last_clean', 'last_use', 0)
577         if now - last_clean < hour:
578             return
580         self.session_api.clean_up()
581         self.db.getOTKManager().clean()
582         self.db.getOTKManager().set('last_clean', last_use=now)
583         self.db.commit(fail_ok=True)
585     def determine_charset(self):
586         """Look for client charset in the form parameters or browser cookie.
588         If no charset requested by client, use storage charset (utf-8).
590         If the charset is found, and differs from the storage charset,
591         recode all form fields of type 'text/plain'
592         """
593         # look for client charset
594         charset_parameter = 0
595         if '@charset' in self.form:
596             charset = self.form['@charset'].value
597             if charset.lower() == "none":
598                 charset = ""
599             charset_parameter = 1
600         elif 'roundup_charset' in self.cookie:
601             charset = self.cookie['roundup_charset'].value
602         else:
603             charset = None
604         if charset:
605             # make sure the charset is recognized
606             try:
607                 codecs.lookup(charset)
608             except LookupError:
609                 self.error_message.append(self._('Unrecognized charset: %r')
610                     % charset)
611                 charset_parameter = 0
612             else:
613                 self.charset = charset.lower()
614         # If we've got a character set in request parameters,
615         # set the browser cookie to keep the preference.
616         # This is done after codecs.lookup to make sure
617         # that we aren't keeping a wrong value.
618         if charset_parameter:
619             self.add_cookie('roundup_charset', charset)
621         # if client charset is different from the storage charset,
622         # recode form fields
623         # XXX this requires FieldStorage from Python library.
624         #   mod_python FieldStorage is not supported!
625         if self.charset != self.STORAGE_CHARSET:
626             decoder = codecs.getdecoder(self.charset)
627             encoder = codecs.getencoder(self.STORAGE_CHARSET)
628             re_charref = re.compile('&#([0-9]+|x[0-9a-f]+);', re.IGNORECASE)
629             def _decode_charref(matchobj):
630                 num = matchobj.group(1)
631                 if num[0].lower() == 'x':
632                     uc = int(num[1:], 16)
633                 else:
634                     uc = int(num)
635                 return unichr(uc)
637             for field_name in self.form:
638                 field = self.form[field_name]
639                 if (field.type == 'text/plain') and not field.filename:
640                     try:
641                         value = decoder(field.value)[0]
642                     except UnicodeError:
643                         continue
644                     value = re_charref.sub(_decode_charref, value)
645                     field.value = encoder(value)[0]
647     def determine_language(self):
648         """Determine the language"""
649         # look for language parameter
650         # then for language cookie
651         # last for the Accept-Language header
652         if "@language" in self.form:
653             language = self.form["@language"].value
654             if language.lower() == "none":
655                 language = ""
656             self.add_cookie("roundup_language", language)
657         elif "roundup_language" in self.cookie:
658             language = self.cookie["roundup_language"].value
659         elif self.instance.config["WEB_USE_BROWSER_LANGUAGE"]:
660             hal = self.env.get('HTTP_ACCEPT_LANGUAGE')
661             language = accept_language.parse(hal)
662         else:
663             language = ""
665         self.language = language
666         if language:
667             self.setTranslator(TranslationService.get_translation(
668                     language,
669                     tracker_home=self.instance.config["TRACKER_HOME"]))
671     def determine_user(self):
672         """Determine who the user is"""
673         self.opendb('admin')
675         # get session data from db
676         # XXX: rename
677         self.session_api = Session(self)
679         # take the opportunity to cleanup expired sessions and otks
680         self.clean_up()
682         user = None
683         # first up, try http authorization if enabled
684         if self.instance.config['WEB_HTTP_AUTH']:
685             if 'REMOTE_USER' in self.env:
686                 # we have external auth (e.g. by Apache)
687                 user = self.env['REMOTE_USER']
688             elif self.env.get('HTTP_AUTHORIZATION', ''):
689                 # try handling Basic Auth ourselves
690                 auth = self.env['HTTP_AUTHORIZATION']
691                 scheme, challenge = auth.split(' ', 1)
692                 if scheme.lower() == 'basic':
693                     try:
694                         decoded = base64.decodestring(challenge)
695                     except TypeError:
696                         # invalid challenge
697                         pass
698                     username, password = decoded.split(':')
699                     try:
700                         login = self.get_action_class('login')(self)
701                         login.verifyLogin(username, password)
702                     except LoginError, err:
703                         self.make_user_anonymous()
704                         raise
705                     user = username
707         # if user was not set by http authorization, try session lookup
708         if not user:
709             user = self.session_api.get('user')
710             if user:
711                 # update session lifetime datestamp
712                 self.session_api.update()
714         # if no user name set by http authorization or session lookup
715         # the user is anonymous
716         if not user:
717             user = 'anonymous'
719         # sanity check on the user still being valid,
720         # getting the userid at the same time
721         try:
722             self.userid = self.db.user.lookup(user)
723         except (KeyError, TypeError):
724             user = 'anonymous'
726         # make sure the anonymous user is valid if we're using it
727         if user == 'anonymous':
728             self.make_user_anonymous()
729         else:
730             self.user = user
732         # reopen the database as the correct user
733         self.opendb(self.user)
735     def check_anonymous_access(self):
736         """Check that the Anonymous user is actually allowed to use the web
737         interface and short-circuit all further processing if they're not.
738         """
739         # allow Anonymous to use the "login" and "register" actions (noting
740         # that "register" has its own "Register" permission check)
742         if ':action' in self.form:
743             action = self.form[':action']
744         elif '@action' in self.form:
745             action = self.form['@action']
746         else:
747             action = ''
748         if isinstance(action, list):
749             raise SeriousError('broken form: multiple @action values submitted')
750         elif action != '':
751             action = action.value.lower()
752         if action in ('login', 'register'):
753             return
755         # allow Anonymous to view the "user" "register" template if they're
756         # allowed to register
757         if (self.db.security.hasPermission('Register', self.userid, 'user')
758                 and self.classname == 'user' and self.template == 'register'):
759             return
761         # otherwise for everything else
762         if self.user == 'anonymous':
763             if not self.db.security.hasPermission('Web Access', self.userid):
764                 raise Unauthorised(self._("Anonymous users are not "
765                     "allowed to use the web interface"))
767     def opendb(self, username):
768         """Open the database and set the current user.
770         Opens a database once. On subsequent calls only the user is set on
771         the database object the instance.optimize is set. If we are in
772         "Development Mode" (cf. roundup_server) then the database is always
773         re-opened.
774         """
775         # don't do anything if the db is open and the user has not changed
776         if hasattr(self, 'db') and self.db.isCurrentUser(username):
777             return
779         # open the database or only set the user
780         if not hasattr(self, 'db'):
781             self.db = self.instance.open(username)
782         else:
783             if self.instance.optimize:
784                 self.db.setCurrentUser(username)
785             else:
786                 self.db.close()
787                 self.db = self.instance.open(username)
788                 # The old session API refers to the closed database;
789                 # we can no longer use it.
790                 self.session_api = Session(self)
791  
793     def determine_context(self, dre=re.compile(r'([^\d]+)0*(\d+)')):
794         """Determine the context of this page from the URL:
796         The URL path after the instance identifier is examined. The path
797         is generally only one entry long.
799         - if there is no path, then we are in the "home" context.
800         - if the path is "_file", then the additional path entry
801           specifies the filename of a static file we're to serve up
802           from the instance "html" directory. Raises a SendStaticFile
803           exception.(*)
804         - if there is something in the path (eg "issue"), it identifies
805           the tracker class we're to display.
806         - if the path is an item designator (eg "issue123"), then we're
807           to display a specific item.
808         - if the path starts with an item designator and is longer than
809           one entry, then we're assumed to be handling an item of a
810           FileClass, and the extra path information gives the filename
811           that the client is going to label the download with (ie
812           "file123/image.png" is nicer to download than "file123"). This
813           raises a SendFile exception.(*)
815         Both of the "*" types of contexts stop before we bother to
816         determine the template we're going to use. That's because they
817         don't actually use templates.
819         The template used is specified by the :template CGI variable,
820         which defaults to:
822         - only classname suplied:          "index"
823         - full item designator supplied:   "item"
825         We set:
827              self.classname  - the class to display, can be None
829              self.template   - the template to render the current context with
831              self.nodeid     - the nodeid of the class we're displaying
832         """
833         # default the optional variables
834         self.classname = None
835         self.nodeid = None
837         # see if a template or messages are specified
838         template_override = ok_message = error_message = None
839         for key in self.form:
840             if self.FV_TEMPLATE.match(key):
841                 template_override = self.form[key].value
842             elif self.FV_OK_MESSAGE.match(key):
843                 ok_message = self.form[key].value
844                 ok_message = clean_message(ok_message)
845             elif self.FV_ERROR_MESSAGE.match(key):
846                 error_message = self.form[key].value
847                 error_message = clean_message(error_message)
849         # see if we were passed in a message
850         if ok_message:
851             self.ok_message.append(ok_message)
852         if error_message:
853             self.error_message.append(error_message)
855         # determine the classname and possibly nodeid
856         path = self.path.split('/')
857         if not path or path[0] in ('', 'home', 'index'):
858             if template_override is not None:
859                 self.template = template_override
860             else:
861                 self.template = ''
862             return
863         elif path[0] in ('_file', '@@file'):
864             raise SendStaticFile(os.path.join(*path[1:]))
865         else:
866             self.classname = path[0]
867             if len(path) > 1:
868                 # send the file identified by the designator in path[0]
869                 raise SendFile(path[0])
871         # see if we got a designator
872         m = dre.match(self.classname)
873         if m:
874             self.classname = m.group(1)
875             self.nodeid = m.group(2)
876             try:
877                 klass = self.db.getclass(self.classname)
878             except KeyError:
879                 raise NotFound('%s/%s'%(self.classname, self.nodeid))
880             if not klass.hasnode(self.nodeid):
881                 raise NotFound('%s/%s'%(self.classname, self.nodeid))
882             # with a designator, we default to item view
883             self.template = 'item'
884         else:
885             # with only a class, we default to index view
886             self.template = 'index'
888         # make sure the classname is valid
889         try:
890             self.db.getclass(self.classname)
891         except KeyError:
892             raise NotFound(self.classname)
894         # see if we have a template override
895         if template_override is not None:
896             self.template = template_override
898     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
899         """ Serve the file from the content property of the designated item.
900         """
901         m = dre.match(str(designator))
902         if not m:
903             raise NotFound(str(designator))
904         classname, nodeid = m.group(1), m.group(2)
906         try:
907             klass = self.db.getclass(classname)
908         except KeyError:
909             # The classname was not valid.
910             raise NotFound(str(designator))
911             
912         # perform the Anonymous user access check
913         self.check_anonymous_access()
915         # make sure we have the appropriate properties
916         props = klass.getprops()
917         if 'type' not in props:
918             raise NotFound(designator)
919         if 'content' not in props:
920             raise NotFound(designator)
922         # make sure we have permission
923         if not self.db.security.hasPermission('View', self.userid,
924                 classname, 'content', nodeid):
925             raise Unauthorised(self._("You are not allowed to view "
926                 "this file."))
928         try:
929             mime_type = klass.get(nodeid, 'type')
930         except IndexError, e:
931             raise NotFound(e)
932         # Can happen for msg class:
933         if not mime_type:
934             mime_type = 'text/plain'
936         # if the mime_type is HTML-ish then make sure we're allowed to serve up
937         # HTML-ish content
938         if mime_type in ('text/html', 'text/x-html'):
939             if not self.instance.config['WEB_ALLOW_HTML_FILE']:
940                 # do NOT serve the content up as HTML
941                 mime_type = 'application/octet-stream'
943         # If this object is a file (i.e., an instance of FileClass),
944         # see if we can find it in the filesystem.  If so, we may be
945         # able to use the more-efficient request.sendfile method of
946         # sending the file.  If not, just get the "content" property
947         # in the usual way, and use that.
948         content = None
949         filename = None
950         if isinstance(klass, hyperdb.FileClass):
951             try:
952                 filename = self.db.filename(classname, nodeid)
953             except AttributeError:
954                 # The database doesn't store files in the filesystem
955                 # and therefore doesn't provide the "filename" method.
956                 pass
957             except IOError:
958                 # The file does not exist.
959                 pass
960         if not filename:
961             content = klass.get(nodeid, 'content')
962         
963         lmt = klass.get(nodeid, 'activity').timestamp()
965         self._serve_file(lmt, mime_type, content, filename)
967     def serve_static_file(self, file):
968         """ Serve up the file named from the templates dir
969         """
970         # figure the filename - try STATIC_FILES, then TEMPLATES dir
971         for dir_option in ('STATIC_FILES', 'TEMPLATES'):
972             prefix = self.instance.config[dir_option]
973             if not prefix:
974                 continue
975             # ensure the load doesn't try to poke outside
976             # of the static files directory
977             prefix = os.path.normpath(prefix)
978             filename = os.path.normpath(os.path.join(prefix, file))
979             if os.path.isfile(filename) and filename.startswith(prefix):
980                 break
981         else:
982             raise NotFound(file)
984         # last-modified time
985         lmt = os.stat(filename)[stat.ST_MTIME]
987         # detemine meta-type
988         file = str(file)
989         mime_type = mimetypes.guess_type(file)[0]
990         if not mime_type:
991             if file.endswith('.css'):
992                 mime_type = 'text/css'
993             else:
994                 mime_type = 'text/plain'
996         self._serve_file(lmt, mime_type, '', filename)
998     def _serve_file(self, lmt, mime_type, content=None, filename=None):
999         """ guts of serve_file() and serve_static_file()
1000         """
1002         # spit out headers
1003         self.additional_headers['Content-Type'] = mime_type
1004         self.additional_headers['Last-Modified'] = rfc822.formatdate(lmt)
1006         ims = None
1007         # see if there's an if-modified-since...
1008         # XXX see which interfaces set this
1009         #if hasattr(self.request, 'headers'):
1010             #ims = self.request.headers.getheader('if-modified-since')
1011         if 'HTTP_IF_MODIFIED_SINCE' in self.env:
1012             # cgi will put the header in the env var
1013             ims = self.env['HTTP_IF_MODIFIED_SINCE']
1014         if ims:
1015             ims = rfc822.parsedate(ims)[:6]
1016             lmtt = time.gmtime(lmt)[:6]
1017             if lmtt <= ims:
1018                 raise NotModified
1020         if filename:
1021             self.write_file(filename)
1022         else:
1023             self.additional_headers['Content-Length'] = str(len(content))
1024             self.write(content)
1026     def send_error_to_admin(self, subject, html, txt):
1027         """Send traceback information to admin via email.
1028            We send both, the formatted html (with more information) and
1029            the text version of the traceback. We use
1030            multipart/alternative so the receiver can chose which version
1031            to display.
1032         """
1033         to = [self.mailer.config.ADMIN_EMAIL]
1034         message = MIMEMultipart('alternative')
1035         self.mailer.set_message_attributes(message, to, subject)
1036         part = MIMEBase('text', 'html')
1037         part.set_charset('utf-8')
1038         part.set_payload(html)
1039         encode_quopri(part)
1040         message.attach(part)
1041         part = MIMEText(txt)
1042         message.attach(part)
1043         self.mailer.smtp_send(to, message.as_string())
1044     
1045     def renderFrontPage(self, message):
1046         """Return the front page of the tracker."""
1047     
1048         self.classname = self.nodeid = None
1049         self.template = ''
1050         self.error_message.append(message)
1051         self.write_html(self.renderContext())
1053     def renderContext(self):
1054         """ Return a PageTemplate for the named page
1055         """
1056         name = self.classname
1057         extension = self.template
1059         # catch errors so we can handle PT rendering errors more nicely
1060         args = {
1061             'ok_message': self.ok_message,
1062             'error_message': self.error_message
1063         }
1064         try:
1065             pt = self.instance.templates.get(name, extension)
1066             # let the template render figure stuff out
1067             result = pt.render(self, None, None, **args)
1068             self.additional_headers['Content-Type'] = pt.content_type
1069             if self.env.get('CGI_SHOW_TIMING', ''):
1070                 if self.env['CGI_SHOW_TIMING'].upper() == 'COMMENT':
1071                     timings = {'starttag': '<!-- ', 'endtag': ' -->'}
1072                 else:
1073                     timings = {'starttag': '<p>', 'endtag': '</p>'}
1074                 timings['seconds'] = time.time()-self.start
1075                 s = self._('%(starttag)sTime elapsed: %(seconds)fs%(endtag)s\n'
1076                     ) % timings
1077                 if hasattr(self.db, 'stats'):
1078                     timings.update(self.db.stats)
1079                     s += self._("%(starttag)sCache hits: %(cache_hits)d,"
1080                         " misses %(cache_misses)d."
1081                         " Loading items: %(get_items)f secs."
1082                         " Filtering: %(filtering)f secs."
1083                         "%(endtag)s\n") % timings
1084                 s += '</body>'
1085                 result = result.replace('</body>', s)
1086             return result
1087         except templating.NoTemplate, message:
1088             return '<strong>%s</strong>'%cgi.escape(str(message))
1089         except templating.Unauthorised, message:
1090             raise Unauthorised(cgi.escape(str(message)))
1091         except:
1092             # everything else
1093             if self.instance.config.WEB_DEBUG:
1094                 return cgitb.pt_html(i18n=self.translator)
1095             exc_info = sys.exc_info()
1096             try:
1097                 # If possible, send the HTML page template traceback
1098                 # to the administrator.
1099                 subject = "Templating Error: %s" % exc_info[1]
1100                 self.send_error_to_admin(subject, cgitb.pt_html(), format_exc())
1101                 # Now report the error to the user.
1102                 return self._(error_message)
1103             except:
1104                 # Reraise the original exception.  The user will
1105                 # receive an error message, and the adminstrator will
1106                 # receive a traceback, albeit with less information
1107                 # than the one we tried to generate above.
1108                 raise exc_info[0](exc_info[1]).with_traceback(exc_info[2])
1110     # these are the actions that are available
1111     actions = (
1112         ('edit',        EditItemAction),
1113         ('editcsv',     EditCSVAction),
1114         ('new',         NewItemAction),
1115         ('register',    RegisterAction),
1116         ('confrego',    ConfRegoAction),
1117         ('passrst',     PassResetAction),
1118         ('login',       LoginAction),
1119         ('logout',      LogoutAction),
1120         ('search',      SearchAction),
1121         ('retire',      RetireAction),
1122         ('show',        ShowAction),
1123         ('export_csv',  ExportCSVAction),
1124     )
1125     def handle_action(self):
1126         """ Determine whether there should be an Action called.
1128             The action is defined by the form variable :action which
1129             identifies the method on this object to call. The actions
1130             are defined in the "actions" sequence on this class.
1132             Actions may return a page (by default HTML) to return to the
1133             user, bypassing the usual template rendering.
1135             We explicitly catch Reject and ValueError exceptions and
1136             present their messages to the user.
1137         """
1138         if ':action' in self.form:
1139             action = self.form[':action']
1140         elif '@action' in self.form:
1141             action = self.form['@action']
1142         else:
1143             return None
1145         if isinstance(action, list):
1146             raise SeriousError('broken form: multiple @action values submitted')
1147         else:
1148             action = action.value.lower()
1150         try:
1151             action_klass = self.get_action_class(action)
1153             # call the mapped action
1154             if isinstance(action_klass, type('')):
1155                 # old way of specifying actions
1156                 return getattr(self, action_klass)()
1157             else:
1158                 return action_klass(self).execute()
1160         except (ValueError, Reject), err:
1161             self.error_message.append(str(err))
1163     def get_action_class(self, action_name):
1164         if (hasattr(self.instance, 'cgi_actions') and
1165                 action_name in self.instance.cgi_actions):
1166             # tracker-defined action
1167             action_klass = self.instance.cgi_actions[action_name]
1168         else:
1169             # go with a default
1170             for name, action_klass in self.actions:
1171                 if name == action_name:
1172                     break
1173             else:
1174                 raise ValueError('No such action "%s"'%action_name)
1175         return action_klass
1177     def _socket_op(self, call, *args, **kwargs):
1178         """Execute socket-related operation, catch common network errors
1180         Parameters:
1181             call: a callable to execute
1182             args, kwargs: call arguments
1184         """
1185         try:
1186             call(*args, **kwargs)
1187         except socket.error, err:
1188             err_errno = getattr (err, 'errno', None)
1189             if err_errno is None:
1190                 try:
1191                     err_errno = err[0]
1192                 except TypeError:
1193                     pass
1194             if err_errno not in self.IGNORE_NET_ERRORS:
1195                 raise
1196         except IOError:
1197             # Apache's mod_python will raise IOError -- without an
1198             # accompanying errno -- when a write to the client fails.
1199             # A common case is that the client has closed the
1200             # connection.  There's no way to be certain that this is
1201             # the situation that has occurred here, but that is the
1202             # most likely case.
1203             pass
1205     def write(self, content):
1206         if not self.headers_done:
1207             self.header()
1208         if self.env['REQUEST_METHOD'] != 'HEAD':
1209             self._socket_op(self.request.wfile.write, content)
1211     def write_html(self, content):
1212         if not self.headers_done:
1213             # at this point, we are sure about Content-Type
1214             if 'Content-Type' not in self.additional_headers:
1215                 self.additional_headers['Content-Type'] = \
1216                     'text/html; charset=%s' % self.charset
1217             self.header()
1219         if self.env['REQUEST_METHOD'] == 'HEAD':
1220             # client doesn't care about content
1221             return
1223         if self.charset != self.STORAGE_CHARSET:
1224             # recode output
1225             content = content.decode(self.STORAGE_CHARSET, 'replace')
1226             content = content.encode(self.charset, 'xmlcharrefreplace')
1228         # and write
1229         self._socket_op(self.request.wfile.write, content)
1231     def http_strip(self, content):
1232         """Remove HTTP Linear White Space from 'content'.
1234         'content' -- A string.
1236         returns -- 'content', with all leading and trailing LWS
1237         removed."""
1239         # RFC 2616 2.2: Basic Rules
1240         #
1241         # LWS = [CRLF] 1*( SP | HT )
1242         return content.strip(" \r\n\t")
1244     def http_split(self, content):
1245         """Split an HTTP list.
1247         'content' -- A string, giving a list of items.
1249         returns -- A sequence of strings, containing the elements of
1250         the list."""
1252         # RFC 2616 2.1: Augmented BNF
1253         #
1254         # Grammar productions of the form "#rule" indicate a
1255         # comma-separated list of elements matching "rule".  LWS
1256         # is then removed from each element, and empty elements
1257         # removed.
1259         # Split at commas.
1260         elements = content.split(",")
1261         # Remove linear whitespace at either end of the string.
1262         elements = [self.http_strip(e) for e in elements]
1263         # Remove any now-empty elements.
1264         return [e for e in elements if e]
1265         
1266     def handle_range_header(self, length, etag):
1267         """Handle the 'Range' and 'If-Range' headers.
1269         'length' -- the length of the content available for the
1270         resource.
1272         'etag' -- the entity tag for this resources.
1274         returns -- If the request headers (including 'Range' and
1275         'If-Range') indicate that only a portion of the entity should
1276         be returned, then the return value is a pair '(offfset,
1277         length)' indicating the first byte and number of bytes of the
1278         content that should be returned to the client.  In addition,
1279         this method will set 'self.response_code' to indicate Partial
1280         Content.  In all other cases, the return value is 'None'.  If
1281         appropriate, 'self.response_code' will be
1282         set to indicate 'REQUESTED_RANGE_NOT_SATISFIABLE'.  In that
1283         case, the caller should not send any data to the client."""
1285         # RFC 2616 14.35: Range
1286         #
1287         # See if the Range header is present.
1288         ranges_specifier = self.env.get("HTTP_RANGE")
1289         if ranges_specifier is None:
1290             return None
1291         # RFC 2616 14.27: If-Range
1292         #
1293         # Check to see if there is an If-Range header.
1294         # Because the specification says:
1295         #
1296         #  The If-Range header ... MUST be ignored if the request
1297         #  does not include a Range header, we check for If-Range
1298         #  after checking for Range.
1299         if_range = self.env.get("HTTP_IF_RANGE")
1300         if if_range:
1301             # The grammar for the If-Range header is:
1302             # 
1303             #   If-Range = "If-Range" ":" ( entity-tag | HTTP-date )
1304             #   entity-tag = [ weak ] opaque-tag
1305             #   weak = "W/"
1306             #   opaque-tag = quoted-string
1307             #
1308             # We only support strong entity tags.
1309             if_range = self.http_strip(if_range)
1310             if (not if_range.startswith('"')
1311                 or not if_range.endswith('"')):
1312                 return None
1313             # If the condition doesn't match the entity tag, then we
1314             # must send the client the entire file.
1315             if if_range != etag:
1316                 return
1317         # The grammar for the Range header value is:
1318         #
1319         #   ranges-specifier = byte-ranges-specifier
1320         #   byte-ranges-specifier = bytes-unit "=" byte-range-set
1321         #   byte-range-set = 1#( byte-range-spec | suffix-byte-range-spec )
1322         #   byte-range-spec = first-byte-pos "-" [last-byte-pos]
1323         #   first-byte-pos = 1*DIGIT
1324         #   last-byte-pos = 1*DIGIT
1325         #   suffix-byte-range-spec = "-" suffix-length
1326         #   suffix-length = 1*DIGIT
1327         #
1328         # Look for the "=" separating the units from the range set.
1329         specs = ranges_specifier.split("=", 1)
1330         if len(specs) != 2:
1331             return None
1332         # Check that the bytes-unit is in fact "bytes".  If it is not,
1333         # we do not know how to process this range.
1334         bytes_unit = self.http_strip(specs[0])
1335         if bytes_unit != "bytes":
1336             return None
1337         # Seperate the range-set into range-specs.
1338         byte_range_set = self.http_strip(specs[1])
1339         byte_range_specs = self.http_split(byte_range_set)
1340         # We only handle exactly one range at this time.
1341         if len(byte_range_specs) != 1:
1342             return None
1343         # Parse the spec.
1344         byte_range_spec = byte_range_specs[0]
1345         pos = byte_range_spec.split("-", 1)
1346         if len(pos) != 2:
1347             return None
1348         # Get the first and last bytes.
1349         first = self.http_strip(pos[0])
1350         last = self.http_strip(pos[1])
1351         # We do not handle suffix ranges.
1352         if not first:
1353             return None
1354        # Convert the first and last positions to integers.
1355         try:
1356             first = int(first)
1357             if last:
1358                 last = int(last)
1359             else:
1360                 last = length - 1
1361         except:
1362             # The positions could not be parsed as integers.
1363             return None
1364         # Check that the range makes sense.
1365         if (first < 0 or last < 0 or last < first):
1366             return None
1367         if last >= length:
1368             # RFC 2616 10.4.17: 416 Requested Range Not Satisfiable
1369             #
1370             # If there is an If-Range header, RFC 2616 says that we
1371             # should just ignore the invalid Range header.
1372             if if_range:
1373                 return None
1374             # Return code 416 with a Content-Range header giving the
1375             # allowable range.
1376             self.response_code = http_.client.REQUESTED_RANGE_NOT_SATISFIABLE
1377             self.setHeader("Content-Range", "bytes */%d" % length)
1378             return None
1379         # RFC 2616 10.2.7: 206 Partial Content
1380         #
1381         # Tell the client that we are honoring the Range request by
1382         # indicating that we are providing partial content.
1383         self.response_code = http_.client.PARTIAL_CONTENT
1384         # RFC 2616 14.16: Content-Range
1385         #
1386         # Tell the client what data we are providing.
1387         #
1388         #   content-range-spec = byte-content-range-spec
1389         #   byte-content-range-spec = bytes-unit SP
1390         #                             byte-range-resp-spec "/"
1391         #                             ( instance-length | "*" )
1392         #   byte-range-resp-spec = (first-byte-pos "-" last-byte-pos)
1393         #                          | "*"
1394         #   instance-length      = 1 * DIGIT
1395         self.setHeader("Content-Range",
1396                        "bytes %d-%d/%d" % (first, last, length))
1397         return (first, last - first + 1)
1399     def write_file(self, filename):
1400         """Send the contents of 'filename' to the user."""
1402         # Determine the length of the file.
1403         stat_info = os.stat(filename)
1404         length = stat_info[stat.ST_SIZE]
1405         # Assume we will return the entire file.
1406         offset = 0
1407         # If the headers have not already been finalized, 
1408         if not self.headers_done:
1409             # RFC 2616 14.19: ETag
1410             #
1411             # Compute the entity tag, in a format similar to that
1412             # used by Apache.
1413             etag = '"%x-%x-%x"' % (stat_info[stat.ST_INO],
1414                                    length,
1415                                    stat_info[stat.ST_MTIME])
1416             self.setHeader("ETag", etag)
1417             # RFC 2616 14.5: Accept-Ranges
1418             #
1419             # Let the client know that we will accept range requests.
1420             self.setHeader("Accept-Ranges", "bytes")
1421             # RFC 2616 14.35: Range
1422             #
1423             # If there is a Range header, we may be able to avoid
1424             # sending the entire file.
1425             content_range = self.handle_range_header(length, etag)
1426             if content_range:
1427                 offset, length = content_range
1428             # RFC 2616 14.13: Content-Length
1429             #
1430             # Tell the client how much data we are providing.
1431             self.setHeader("Content-Length", str(length))
1432             # Send the HTTP header.
1433             self.header()
1434         # If the client doesn't actually want the body, or if we are
1435         # indicating an invalid range.
1436         if (self.env['REQUEST_METHOD'] == 'HEAD'
1437             or self.response_code == http_.client.REQUESTED_RANGE_NOT_SATISFIABLE):
1438             return
1439         # Use the optimized "sendfile" operation, if possible.
1440         if hasattr(self.request, "sendfile"):
1441             self._socket_op(self.request.sendfile, filename, offset, length)
1442             return
1443         # Fallback to the "write" operation.
1444         f = open(filename, 'rb')
1445         try:
1446             if offset:
1447                 f.seek(offset)
1448             content = f.read(length)
1449         finally:
1450             f.close()
1451         self.write(content)
1453     def setHeader(self, header, value):
1454         """Override a header to be returned to the user's browser.
1455         """
1456         self.additional_headers[header] = value
1458     def header(self, headers=None, response=None):
1459         """Put up the appropriate header.
1460         """
1461         if headers is None:
1462             headers = {'Content-Type':'text/html; charset=utf-8'}
1463         if response is None:
1464             response = self.response_code
1466         # update with additional info
1467         headers.update(self.additional_headers)
1469         if headers.get('Content-Type', 'text/html') == 'text/html':
1470             headers['Content-Type'] = 'text/html; charset=utf-8'
1472         headers = list(headers.items())
1474         for ((path, name), (value, expire)) in self._cookies.iteritems():
1475             cookie = "%s=%s; Path=%s;"%(name, value, path)
1476             if expire is not None:
1477                 cookie += " expires=%s;"%get_cookie_date(expire)
1478             headers.append(('Set-Cookie', cookie))
1480         self._socket_op(self.request.start_response, headers, response)
1482         self.headers_done = 1
1483         if self.debug:
1484             self.headers_sent = headers
1486     def add_cookie(self, name, value, expire=86400*365, path=None):
1487         """Set a cookie value to be sent in HTTP headers
1489         Parameters:
1490             name:
1491                 cookie name
1492             value:
1493                 cookie value
1494             expire:
1495                 cookie expiration time (seconds).
1496                 If value is empty (meaning "delete cookie"),
1497                 expiration time is forced in the past
1498                 and this argument is ignored.
1499                 If None, the cookie will expire at end-of-session.
1500                 If omitted, the cookie will be kept for a year.
1501             path:
1502                 cookie path (optional)
1504         """
1505         if path is None:
1506             path = self.cookie_path
1507         if not value:
1508             expire = -1
1509         self._cookies[(path, name)] = (value, expire)
1511     def set_cookie(self, user, expire=None):
1512         """Deprecated. Use session_api calls directly
1514         XXX remove
1515         """
1517         # insert the session in the session db
1518         self.session_api.set(user=user)
1519         # refresh session cookie
1520         self.session_api.update(set_cookie=True, expire=expire)
1522     def make_user_anonymous(self):
1523         """ Make us anonymous
1525             This method used to handle non-existence of the 'anonymous'
1526             user, but that user is mandatory now.
1527         """
1528         self.userid = self.db.user.lookup('anonymous')
1529         self.user = 'anonymous'
1531     def standard_message(self, to, subject, body, author=None):
1532         """Send a standard email message from Roundup.
1534         "to"      - recipients list
1535         "subject" - Subject
1536         "body"    - Message
1537         "author"  - (name, address) tuple or None for admin email
1539         Arguments are passed to the Mailer.standard_message code.
1540         """
1541         try:
1542             self.mailer.standard_message(to, subject, body, author)
1543         except MessageSendError, e:
1544             self.error_message.append(str(e))
1545             return 0
1546         return 1
1548     def parsePropsFromForm(self, create=0):
1549         return FormParser(self).parse(create=create)
1551 # vim: set et sts=4 sw=4 :