Code

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