Code

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