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 '<%s>'%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
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)
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)
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))
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 mime_type = klass.get(nodeid, 'type')
924 # Can happen for msg class:
925 if not mime_type:
926 mime_type = 'text/plain'
928 # if the mime_type is HTML-ish then make sure we're allowed to serve up
929 # HTML-ish content
930 if mime_type in ('text/html', 'text/x-html'):
931 if not self.instance.config['WEB_ALLOW_HTML_FILE']:
932 # do NOT serve the content up as HTML
933 mime_type = 'application/octet-stream'
935 # If this object is a file (i.e., an instance of FileClass),
936 # see if we can find it in the filesystem. If so, we may be
937 # able to use the more-efficient request.sendfile method of
938 # sending the file. If not, just get the "content" property
939 # in the usual way, and use that.
940 content = None
941 filename = None
942 if isinstance(klass, hyperdb.FileClass):
943 try:
944 filename = self.db.filename(classname, nodeid)
945 except AttributeError:
946 # The database doesn't store files in the filesystem
947 # and therefore doesn't provide the "filename" method.
948 pass
949 except IOError:
950 # The file does not exist.
951 pass
952 if not filename:
953 content = klass.get(nodeid, 'content')
955 lmt = klass.get(nodeid, 'activity').timestamp()
957 self._serve_file(lmt, mime_type, content, filename)
959 def serve_static_file(self, file):
960 """ Serve up the file named from the templates dir
961 """
962 # figure the filename - try STATIC_FILES, then TEMPLATES dir
963 for dir_option in ('STATIC_FILES', 'TEMPLATES'):
964 prefix = self.instance.config[dir_option]
965 if not prefix:
966 continue
967 # ensure the load doesn't try to poke outside
968 # of the static files directory
969 prefix = os.path.normpath(prefix)
970 filename = os.path.normpath(os.path.join(prefix, file))
971 if os.path.isfile(filename) and filename.startswith(prefix):
972 break
973 else:
974 raise NotFound(file)
976 # last-modified time
977 lmt = os.stat(filename)[stat.ST_MTIME]
979 # detemine meta-type
980 file = str(file)
981 mime_type = mimetypes.guess_type(file)[0]
982 if not mime_type:
983 if file.endswith('.css'):
984 mime_type = 'text/css'
985 else:
986 mime_type = 'text/plain'
988 self._serve_file(lmt, mime_type, '', filename)
990 def _serve_file(self, lmt, mime_type, content=None, filename=None):
991 """ guts of serve_file() and serve_static_file()
992 """
994 # spit out headers
995 self.additional_headers['Content-Type'] = mime_type
996 self.additional_headers['Last-Modified'] = rfc822.formatdate(lmt)
998 ims = None
999 # see if there's an if-modified-since...
1000 # XXX see which interfaces set this
1001 #if hasattr(self.request, 'headers'):
1002 #ims = self.request.headers.getheader('if-modified-since')
1003 if 'HTTP_IF_MODIFIED_SINCE' in self.env:
1004 # cgi will put the header in the env var
1005 ims = self.env['HTTP_IF_MODIFIED_SINCE']
1006 if ims:
1007 ims = rfc822.parsedate(ims)[:6]
1008 lmtt = time.gmtime(lmt)[:6]
1009 if lmtt <= ims:
1010 raise NotModified
1012 if filename:
1013 self.write_file(filename)
1014 else:
1015 self.additional_headers['Content-Length'] = str(len(content))
1016 self.write(content)
1018 def send_html_to_admin(self, subject, content):
1020 to = [self.mailer.config.ADMIN_EMAIL]
1021 message = self.mailer.get_standard_message(to, subject)
1022 # delete existing content-type headers
1023 del message['Content-type']
1024 message['Content-type'] = 'text/html; charset=utf-8'
1025 message.set_payload(content)
1026 encode_quopri(message)
1027 self.mailer.smtp_send(to, str(message))
1029 def renderFrontPage(self, message):
1030 """Return the front page of the tracker."""
1032 self.classname = self.nodeid = None
1033 self.template = ''
1034 self.error_message.append(message)
1035 self.write_html(self.renderContext())
1037 def renderContext(self):
1038 """ Return a PageTemplate for the named page
1039 """
1040 name = self.classname
1041 extension = self.template
1043 # catch errors so we can handle PT rendering errors more nicely
1044 args = {
1045 'ok_message': self.ok_message,
1046 'error_message': self.error_message
1047 }
1048 try:
1049 pt = self.instance.templates.get(name, extension)
1050 # let the template render figure stuff out
1051 result = pt.render(self, None, None, **args)
1052 self.additional_headers['Content-Type'] = pt.content_type
1053 if self.env.get('CGI_SHOW_TIMING', ''):
1054 if self.env['CGI_SHOW_TIMING'].upper() == 'COMMENT':
1055 timings = {'starttag': '<!-- ', 'endtag': ' -->'}
1056 else:
1057 timings = {'starttag': '<p>', 'endtag': '</p>'}
1058 timings['seconds'] = time.time()-self.start
1059 s = self._('%(starttag)sTime elapsed: %(seconds)fs%(endtag)s\n'
1060 ) % timings
1061 if hasattr(self.db, 'stats'):
1062 timings.update(self.db.stats)
1063 s += self._("%(starttag)sCache hits: %(cache_hits)d,"
1064 " misses %(cache_misses)d."
1065 " Loading items: %(get_items)f secs."
1066 " Filtering: %(filtering)f secs."
1067 "%(endtag)s\n") % timings
1068 s += '</body>'
1069 result = result.replace('</body>', s)
1070 return result
1071 except templating.NoTemplate, message:
1072 return '<strong>%s</strong>'%message
1073 except templating.Unauthorised, message:
1074 raise Unauthorised(str(message))
1075 except:
1076 # everything else
1077 if self.instance.config.WEB_DEBUG:
1078 return cgitb.pt_html(i18n=self.translator)
1079 exc_info = sys.exc_info()
1080 try:
1081 # If possible, send the HTML page template traceback
1082 # to the administrator.
1083 subject = "Templating Error: %s" % exc_info[1]
1084 self.send_html_to_admin(subject, cgitb.pt_html())
1085 # Now report the error to the user.
1086 return self._(error_message)
1087 except:
1088 # Reraise the original exception. The user will
1089 # receive an error message, and the adminstrator will
1090 # receive a traceback, albeit with less information
1091 # than the one we tried to generate above.
1092 raise exc_info[0](exc_info[1]).with_traceback(exc_info[2])
1094 # these are the actions that are available
1095 actions = (
1096 ('edit', EditItemAction),
1097 ('editcsv', EditCSVAction),
1098 ('new', NewItemAction),
1099 ('register', RegisterAction),
1100 ('confrego', ConfRegoAction),
1101 ('passrst', PassResetAction),
1102 ('login', LoginAction),
1103 ('logout', LogoutAction),
1104 ('search', SearchAction),
1105 ('retire', RetireAction),
1106 ('show', ShowAction),
1107 ('export_csv', ExportCSVAction),
1108 )
1109 def handle_action(self):
1110 """ Determine whether there should be an Action called.
1112 The action is defined by the form variable :action which
1113 identifies the method on this object to call. The actions
1114 are defined in the "actions" sequence on this class.
1116 Actions may return a page (by default HTML) to return to the
1117 user, bypassing the usual template rendering.
1119 We explicitly catch Reject and ValueError exceptions and
1120 present their messages to the user.
1121 """
1122 if ':action' in self.form:
1123 action = self.form[':action']
1124 elif '@action' in self.form:
1125 action = self.form['@action']
1126 else:
1127 return None
1129 if isinstance(action, list):
1130 raise SeriousError('broken form: multiple @action values submitted')
1131 else:
1132 action = action.value.lower()
1134 try:
1135 action_klass = self.get_action_class(action)
1137 # call the mapped action
1138 if isinstance(action_klass, type('')):
1139 # old way of specifying actions
1140 return getattr(self, action_klass)()
1141 else:
1142 return action_klass(self).execute()
1144 except (ValueError, Reject), err:
1145 self.error_message.append(str(err))
1147 def get_action_class(self, action_name):
1148 if (hasattr(self.instance, 'cgi_actions') and
1149 action_name in self.instance.cgi_actions):
1150 # tracker-defined action
1151 action_klass = self.instance.cgi_actions[action_name]
1152 else:
1153 # go with a default
1154 for name, action_klass in self.actions:
1155 if name == action_name:
1156 break
1157 else:
1158 raise ValueError('No such action "%s"'%action_name)
1159 return action_klass
1161 def _socket_op(self, call, *args, **kwargs):
1162 """Execute socket-related operation, catch common network errors
1164 Parameters:
1165 call: a callable to execute
1166 args, kwargs: call arguments
1168 """
1169 try:
1170 call(*args, **kwargs)
1171 except socket.error, err:
1172 err_errno = getattr (err, 'errno', None)
1173 if err_errno is None:
1174 try:
1175 err_errno = err[0]
1176 except TypeError:
1177 pass
1178 if err_errno not in self.IGNORE_NET_ERRORS:
1179 raise
1180 except IOError:
1181 # Apache's mod_python will raise IOError -- without an
1182 # accompanying errno -- when a write to the client fails.
1183 # A common case is that the client has closed the
1184 # connection. There's no way to be certain that this is
1185 # the situation that has occurred here, but that is the
1186 # most likely case.
1187 pass
1189 def write(self, content):
1190 if not self.headers_done:
1191 self.header()
1192 if self.env['REQUEST_METHOD'] != 'HEAD':
1193 self._socket_op(self.request.wfile.write, content)
1195 def write_html(self, content):
1196 if not self.headers_done:
1197 # at this point, we are sure about Content-Type
1198 if 'Content-Type' not in self.additional_headers:
1199 self.additional_headers['Content-Type'] = \
1200 'text/html; charset=%s' % self.charset
1201 self.header()
1203 if self.env['REQUEST_METHOD'] == 'HEAD':
1204 # client doesn't care about content
1205 return
1207 if self.charset != self.STORAGE_CHARSET:
1208 # recode output
1209 content = content.decode(self.STORAGE_CHARSET, 'replace')
1210 content = content.encode(self.charset, 'xmlcharrefreplace')
1212 # and write
1213 self._socket_op(self.request.wfile.write, content)
1215 def http_strip(self, content):
1216 """Remove HTTP Linear White Space from 'content'.
1218 'content' -- A string.
1220 returns -- 'content', with all leading and trailing LWS
1221 removed."""
1223 # RFC 2616 2.2: Basic Rules
1224 #
1225 # LWS = [CRLF] 1*( SP | HT )
1226 return content.strip(" \r\n\t")
1228 def http_split(self, content):
1229 """Split an HTTP list.
1231 'content' -- A string, giving a list of items.
1233 returns -- A sequence of strings, containing the elements of
1234 the list."""
1236 # RFC 2616 2.1: Augmented BNF
1237 #
1238 # Grammar productions of the form "#rule" indicate a
1239 # comma-separated list of elements matching "rule". LWS
1240 # is then removed from each element, and empty elements
1241 # removed.
1243 # Split at commas.
1244 elements = content.split(",")
1245 # Remove linear whitespace at either end of the string.
1246 elements = [self.http_strip(e) for e in elements]
1247 # Remove any now-empty elements.
1248 return [e for e in elements if e]
1250 def handle_range_header(self, length, etag):
1251 """Handle the 'Range' and 'If-Range' headers.
1253 'length' -- the length of the content available for the
1254 resource.
1256 'etag' -- the entity tag for this resources.
1258 returns -- If the request headers (including 'Range' and
1259 'If-Range') indicate that only a portion of the entity should
1260 be returned, then the return value is a pair '(offfset,
1261 length)' indicating the first byte and number of bytes of the
1262 content that should be returned to the client. In addition,
1263 this method will set 'self.response_code' to indicate Partial
1264 Content. In all other cases, the return value is 'None'. If
1265 appropriate, 'self.response_code' will be
1266 set to indicate 'REQUESTED_RANGE_NOT_SATISFIABLE'. In that
1267 case, the caller should not send any data to the client."""
1269 # RFC 2616 14.35: Range
1270 #
1271 # See if the Range header is present.
1272 ranges_specifier = self.env.get("HTTP_RANGE")
1273 if ranges_specifier is None:
1274 return None
1275 # RFC 2616 14.27: If-Range
1276 #
1277 # Check to see if there is an If-Range header.
1278 # Because the specification says:
1279 #
1280 # The If-Range header ... MUST be ignored if the request
1281 # does not include a Range header, we check for If-Range
1282 # after checking for Range.
1283 if_range = self.env.get("HTTP_IF_RANGE")
1284 if if_range:
1285 # The grammar for the If-Range header is:
1286 #
1287 # If-Range = "If-Range" ":" ( entity-tag | HTTP-date )
1288 # entity-tag = [ weak ] opaque-tag
1289 # weak = "W/"
1290 # opaque-tag = quoted-string
1291 #
1292 # We only support strong entity tags.
1293 if_range = self.http_strip(if_range)
1294 if (not if_range.startswith('"')
1295 or not if_range.endswith('"')):
1296 return None
1297 # If the condition doesn't match the entity tag, then we
1298 # must send the client the entire file.
1299 if if_range != etag:
1300 return
1301 # The grammar for the Range header value is:
1302 #
1303 # ranges-specifier = byte-ranges-specifier
1304 # byte-ranges-specifier = bytes-unit "=" byte-range-set
1305 # byte-range-set = 1#( byte-range-spec | suffix-byte-range-spec )
1306 # byte-range-spec = first-byte-pos "-" [last-byte-pos]
1307 # first-byte-pos = 1*DIGIT
1308 # last-byte-pos = 1*DIGIT
1309 # suffix-byte-range-spec = "-" suffix-length
1310 # suffix-length = 1*DIGIT
1311 #
1312 # Look for the "=" separating the units from the range set.
1313 specs = ranges_specifier.split("=", 1)
1314 if len(specs) != 2:
1315 return None
1316 # Check that the bytes-unit is in fact "bytes". If it is not,
1317 # we do not know how to process this range.
1318 bytes_unit = self.http_strip(specs[0])
1319 if bytes_unit != "bytes":
1320 return None
1321 # Seperate the range-set into range-specs.
1322 byte_range_set = self.http_strip(specs[1])
1323 byte_range_specs = self.http_split(byte_range_set)
1324 # We only handle exactly one range at this time.
1325 if len(byte_range_specs) != 1:
1326 return None
1327 # Parse the spec.
1328 byte_range_spec = byte_range_specs[0]
1329 pos = byte_range_spec.split("-", 1)
1330 if len(pos) != 2:
1331 return None
1332 # Get the first and last bytes.
1333 first = self.http_strip(pos[0])
1334 last = self.http_strip(pos[1])
1335 # We do not handle suffix ranges.
1336 if not first:
1337 return None
1338 # Convert the first and last positions to integers.
1339 try:
1340 first = int(first)
1341 if last:
1342 last = int(last)
1343 else:
1344 last = length - 1
1345 except:
1346 # The positions could not be parsed as integers.
1347 return None
1348 # Check that the range makes sense.
1349 if (first < 0 or last < 0 or last < first):
1350 return None
1351 if last >= length:
1352 # RFC 2616 10.4.17: 416 Requested Range Not Satisfiable
1353 #
1354 # If there is an If-Range header, RFC 2616 says that we
1355 # should just ignore the invalid Range header.
1356 if if_range:
1357 return None
1358 # Return code 416 with a Content-Range header giving the
1359 # allowable range.
1360 self.response_code = http_.client.REQUESTED_RANGE_NOT_SATISFIABLE
1361 self.setHeader("Content-Range", "bytes */%d" % length)
1362 return None
1363 # RFC 2616 10.2.7: 206 Partial Content
1364 #
1365 # Tell the client that we are honoring the Range request by
1366 # indicating that we are providing partial content.
1367 self.response_code = http_.client.PARTIAL_CONTENT
1368 # RFC 2616 14.16: Content-Range
1369 #
1370 # Tell the client what data we are providing.
1371 #
1372 # content-range-spec = byte-content-range-spec
1373 # byte-content-range-spec = bytes-unit SP
1374 # byte-range-resp-spec "/"
1375 # ( instance-length | "*" )
1376 # byte-range-resp-spec = (first-byte-pos "-" last-byte-pos)
1377 # | "*"
1378 # instance-length = 1 * DIGIT
1379 self.setHeader("Content-Range",
1380 "bytes %d-%d/%d" % (first, last, length))
1381 return (first, last - first + 1)
1383 def write_file(self, filename):
1384 """Send the contents of 'filename' to the user."""
1386 # Determine the length of the file.
1387 stat_info = os.stat(filename)
1388 length = stat_info[stat.ST_SIZE]
1389 # Assume we will return the entire file.
1390 offset = 0
1391 # If the headers have not already been finalized,
1392 if not self.headers_done:
1393 # RFC 2616 14.19: ETag
1394 #
1395 # Compute the entity tag, in a format similar to that
1396 # used by Apache.
1397 etag = '"%x-%x-%x"' % (stat_info[stat.ST_INO],
1398 length,
1399 stat_info[stat.ST_MTIME])
1400 self.setHeader("ETag", etag)
1401 # RFC 2616 14.5: Accept-Ranges
1402 #
1403 # Let the client know that we will accept range requests.
1404 self.setHeader("Accept-Ranges", "bytes")
1405 # RFC 2616 14.35: Range
1406 #
1407 # If there is a Range header, we may be able to avoid
1408 # sending the entire file.
1409 content_range = self.handle_range_header(length, etag)
1410 if content_range:
1411 offset, length = content_range
1412 # RFC 2616 14.13: Content-Length
1413 #
1414 # Tell the client how much data we are providing.
1415 self.setHeader("Content-Length", str(length))
1416 # Send the HTTP header.
1417 self.header()
1418 # If the client doesn't actually want the body, or if we are
1419 # indicating an invalid range.
1420 if (self.env['REQUEST_METHOD'] == 'HEAD'
1421 or self.response_code == http_.client.REQUESTED_RANGE_NOT_SATISFIABLE):
1422 return
1423 # Use the optimized "sendfile" operation, if possible.
1424 if hasattr(self.request, "sendfile"):
1425 self._socket_op(self.request.sendfile, filename, offset, length)
1426 return
1427 # Fallback to the "write" operation.
1428 f = open(filename, 'rb')
1429 try:
1430 if offset:
1431 f.seek(offset)
1432 content = f.read(length)
1433 finally:
1434 f.close()
1435 self.write(content)
1437 def setHeader(self, header, value):
1438 """Override a header to be returned to the user's browser.
1439 """
1440 self.additional_headers[header] = value
1442 def header(self, headers=None, response=None):
1443 """Put up the appropriate header.
1444 """
1445 if headers is None:
1446 headers = {'Content-Type':'text/html; charset=utf-8'}
1447 if response is None:
1448 response = self.response_code
1450 # update with additional info
1451 headers.update(self.additional_headers)
1453 if headers.get('Content-Type', 'text/html') == 'text/html':
1454 headers['Content-Type'] = 'text/html; charset=utf-8'
1456 headers = list(headers.items())
1458 for ((path, name), (value, expire)) in self._cookies.iteritems():
1459 cookie = "%s=%s; Path=%s;"%(name, value, path)
1460 if expire is not None:
1461 cookie += " expires=%s;"%get_cookie_date(expire)
1462 headers.append(('Set-Cookie', cookie))
1464 self._socket_op(self.request.start_response, headers, response)
1466 self.headers_done = 1
1467 if self.debug:
1468 self.headers_sent = headers
1470 def add_cookie(self, name, value, expire=86400*365, path=None):
1471 """Set a cookie value to be sent in HTTP headers
1473 Parameters:
1474 name:
1475 cookie name
1476 value:
1477 cookie value
1478 expire:
1479 cookie expiration time (seconds).
1480 If value is empty (meaning "delete cookie"),
1481 expiration time is forced in the past
1482 and this argument is ignored.
1483 If None, the cookie will expire at end-of-session.
1484 If omitted, the cookie will be kept for a year.
1485 path:
1486 cookie path (optional)
1488 """
1489 if path is None:
1490 path = self.cookie_path
1491 if not value:
1492 expire = -1
1493 self._cookies[(path, name)] = (value, expire)
1495 def set_cookie(self, user, expire=None):
1496 """Deprecated. Use session_api calls directly
1498 XXX remove
1499 """
1501 # insert the session in the session db
1502 self.session_api.set(user=user)
1503 # refresh session cookie
1504 self.session_api.update(set_cookie=True, expire=expire)
1506 def make_user_anonymous(self):
1507 """ Make us anonymous
1509 This method used to handle non-existence of the 'anonymous'
1510 user, but that user is mandatory now.
1511 """
1512 self.userid = self.db.user.lookup('anonymous')
1513 self.user = 'anonymous'
1515 def standard_message(self, to, subject, body, author=None):
1516 """Send a standard email message from Roundup.
1518 "to" - recipients list
1519 "subject" - Subject
1520 "body" - Message
1521 "author" - (name, address) tuple or None for admin email
1523 Arguments are passed to the Mailer.standard_message code.
1524 """
1525 try:
1526 self.mailer.standard_message(to, subject, body, author)
1527 except MessageSendError, e:
1528 self.error_message.append(str(e))
1529 return 0
1530 return 1
1532 def parsePropsFromForm(self, create=0):
1533 return FormParser(self).parse(create=create)
1535 # vim: set et sts=4 sw=4 :