Code

Forward-porting of fixes from the maintenance branch.
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.163 2004-02-25 03:24:43 richard Exp $
3 """WWW request handler (also used in the stand-alone server).
4 """
5 __docformat__ = 'restructuredtext'
7 import os, os.path, cgi, StringIO, urlparse, re, traceback, mimetypes, urllib
8 import binascii, Cookie, time, random, stat, rfc822
10 from roundup import roundupdb, date, hyperdb, password
11 from roundup.i18n import _
12 from roundup.cgi import templating, cgitb
13 from roundup.cgi.actions import *
14 from roundup.cgi.exceptions import *
15 from roundup.cgi.form_parser import FormParser
16 from roundup.mailer import Mailer, MessageSendError
18 def initialiseSecurity(security):
19     '''Create some Permissions and Roles on the security object
21     This function is directly invoked by security.Security.__init__()
22     as a part of the Security object instantiation.
23     '''
24     security.addPermission(name="Web Registration",
25         description="User may register through the web")
26     p = security.addPermission(name="Web Access",
27         description="User may access the web interface")
28     security.addPermissionToRole('Admin', p)
30     # doing Role stuff through the web - make sure Admin can
31     p = security.addPermission(name="Web Roles",
32         description="User may manipulate user Roles through the web")
33     security.addPermissionToRole('Admin', p)
35 # used to clean messages passed through CGI variables - HTML-escape any tag
36 # that isn't <a href="">, <i>, <b> and <br> (including XHTML variants) so
37 # that people can't pass through nasties like <script>, <iframe>, ...
38 CLEAN_MESSAGE_RE = r'(<(/?(.*?)(\s*href="[^"]")?\s*/?)>)'
39 def clean_message(message, mc=re.compile(CLEAN_MESSAGE_RE, re.I)):
40     return mc.sub(clean_message_callback, message)
41 def clean_message_callback(match, ok={'a':1,'i':1,'b':1,'br':1}):
42     ''' Strip all non <a>,<i>,<b> and <br> tags from a string
43     '''
44     if ok.has_key(match.group(3).lower()):
45         return match.group(1)
46     return '&lt;%s&gt;'%match.group(2)
48 class Client:
49     '''Instantiate to handle one CGI request.
51     See inner_main for request processing.
53     Client attributes at instantiation:
55     - "path" is the PATH_INFO inside the instance (with no leading '/')
56     - "base" is the base URL for the instance
57     - "form" is the cgi form, an instance of FieldStorage from the standard
58       cgi module
59     - "additional_headers" is a dictionary of additional HTTP headers that
60       should be sent to the client
61     - "response_code" is the HTTP response code to send to the client
63     During the processing of a request, the following attributes are used:
65     - "error_message" holds a list of error messages
66     - "ok_message" holds a list of OK messages
67     - "session" is the current user session id
68     - "user" is the current user's name
69     - "userid" is the current user's id
70     - "template" is the current :template context
71     - "classname" is the current class context name
72     - "nodeid" is the current context item id
74     User Identification:
75      If the user has no login cookie, then they are anonymous and are logged
76      in as that user. This typically gives them all Permissions assigned to the
77      Anonymous Role.
79      Once a user logs in, they are assigned a session. The Client instance
80      keeps the nodeid of the session as the "session" attribute.
82     Special form variables:
83      Note that in various places throughout this code, special form
84      variables of the form :<name> are used. The colon (":") part may
85      actually be one of either ":" or "@".
86     '''
88     #
89     # special form variables
90     #
91     FV_TEMPLATE = re.compile(r'[@:]template')
92     FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
93     FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
95     # Note: index page stuff doesn't appear here:
96     # columns, sort, sortdir, filter, group, groupdir, search_text,
97     # pagesize, startwith
99     def __init__(self, instance, request, env, form=None):
100         hyperdb.traceMark()
101         self.instance = instance
102         self.request = request
103         self.env = env
104         self.mailer = Mailer(instance.config)
106         # save off the path
107         self.path = env['PATH_INFO']
109         # this is the base URL for this tracker
110         self.base = self.instance.config.TRACKER_WEB
112         # this is the "cookie path" for this tracker (ie. the path part of
113         # the "base" url)
114         self.cookie_path = urlparse.urlparse(self.base)[2]
115         self.cookie_name = 'roundup_session_' + re.sub('[^a-zA-Z]', '',
116             self.instance.config.TRACKER_NAME)
118         # see if we need to re-parse the environment for the form (eg Zope)
119         if form is None:
120             self.form = cgi.FieldStorage(environ=env)
121         else:
122             self.form = form
124         # turn debugging on/off
125         try:
126             self.debug = int(env.get("ROUNDUP_DEBUG", 0))
127         except ValueError:
128             # someone gave us a non-int debug level, turn it off
129             self.debug = 0
131         # flag to indicate that the HTTP headers have been sent
132         self.headers_done = 0
134         # additional headers to send with the request - must be registered
135         # before the first write
136         self.additional_headers = {}
137         self.response_code = 200
140     def main(self):
141         ''' Wrap the real main in a try/finally so we always close off the db.
142         '''
143         try:
144             self.inner_main()
145         finally:
146             if hasattr(self, 'db'):
147                 self.db.close()
149     def inner_main(self):
150         '''Process a request.
152         The most common requests are handled like so:
154         1. figure out who we are, defaulting to the "anonymous" user
155            see determine_user
156         2. figure out what the request is for - the context
157            see determine_context
158         3. handle any requested action (item edit, search, ...)
159            see handle_action
160         4. render a template, resulting in HTML output
162         In some situations, exceptions occur:
164         - HTTP Redirect  (generally raised by an action)
165         - SendFile       (generally raised by determine_context)
166           serve up a FileClass "content" property
167         - SendStaticFile (generally raised by determine_context)
168           serve up a file from the tracker "html" directory
169         - Unauthorised   (generally raised by an action)
170           the action is cancelled, the request is rendered and an error
171           message is displayed indicating that permission was not
172           granted for the action to take place
173         - templating.Unauthorised   (templating action not permitted)
174           raised by an attempted rendering of a template when the user
175           doesn't have permission
176         - NotFound       (raised wherever it needs to be)
177           percolates up to the CGI interface that called the client
178         '''
179         self.ok_message = []
180         self.error_message = []
181         try:
182             # figure out the context and desired content template
183             # do this first so we don't authenticate for static files
184             # Note: this method opens the database as "admin" in order to
185             # perform context checks
186             self.determine_context()
188             # make sure we're identified (even anonymously)
189             self.determine_user()
191             # possibly handle a form submit action (may change self.classname
192             # and self.template, and may also append error/ok_messages)
193             html = self.handle_action()
195             if html:
196                 self.write(html)
197                 return
199             # now render the page
200             # we don't want clients caching our dynamic pages
201             self.additional_headers['Cache-Control'] = 'no-cache'
202 # Pragma: no-cache makes Mozilla and its ilk double-load all pages!!
203 #            self.additional_headers['Pragma'] = 'no-cache'
205             # expire this page 5 seconds from now
206             date = rfc822.formatdate(time.time() + 5)
207             self.additional_headers['Expires'] = date
209             # render the content
210             self.write(self.renderContext())
211         except Redirect, url:
212             # let's redirect - if the url isn't None, then we need to do
213             # the headers, otherwise the headers have been set before the
214             # exception was raised
215             if url:
216                 self.additional_headers['Location'] = url
217                 self.response_code = 302
218             self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
219         except SendFile, designator:
220             self.serve_file(designator)
221         except SendStaticFile, file:
222             try:
223                 self.serve_static_file(str(file))
224             except NotModified:
225                 # send the 304 response
226                 self.request.send_response(304)
227                 self.request.end_headers()
228         except Unauthorised, message:
229             # users may always see the front page
230             self.classname = self.nodeid = None
231             self.template = ''
232             self.error_message.append(message)
233             self.write(self.renderContext())
234         except NotFound:
235             # pass through
236             raise
237         except FormError, e:
238             self.error_message.append(_('Form Error: ') + str(e))
239             self.write(self.renderContext())
240         except:
241             # everything else
242             self.write(cgitb.html())
244     def clean_sessions(self):
245         """Age sessions, remove when they haven't been used for a week.
247         Do it only once an hour.
249         Note: also cleans One Time Keys, and other "session" based stuff.
250         """
251         sessions = self.db.sessions
252         last_clean = sessions.get('last_clean', 'last_use') or 0
254         week = 60*60*24*7
255         hour = 60*60
256         now = time.time()
257         if now - last_clean > hour:
258             # remove aged sessions
259             for sessid in sessions.list():
260                 interval = now - sessions.get(sessid, 'last_use')
261                 if interval > week:
262                     sessions.destroy(sessid)
263             # remove aged otks
264             otks = self.db.otks
265             for sessid in otks.list():
266                 interval = now - otks.get(sessid, '__time')
267                 if interval > week:
268                     otks.destroy(sessid)
269             sessions.set('last_clean', last_use=time.time())
271     def determine_user(self):
272         ''' Determine who the user is
273         '''
274         # determine the uid to use
275         self.opendb('admin')
277         # make sure we have the session Class
278         self.clean_sessions()
279         sessions = self.db.sessions
281         # first up, try the REMOTE_USER var (from HTTP Basic Auth handled
282         # by a front-end HTTP server)
283         try:
284             user = os.getenv('REMOTE_USER')
285         except KeyError:
286             pass
288         # look up the user session cookie (may override the REMOTE_USER)
289         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
290         user = 'anonymous'
291         if (cookie.has_key(self.cookie_name) and
292                 cookie[self.cookie_name].value != 'deleted'):
294             # get the session key from the cookie
295             self.session = cookie[self.cookie_name].value
296             # get the user from the session
297             try:
298                 # update the lifetime datestamp
299                 sessions.set(self.session, last_use=time.time())
300                 sessions.commit()
301                 user = sessions.get(self.session, 'user')
302             except KeyError:
303                 # not valid, ignore id
304                 pass
306         # sanity check on the user still being valid, getting the userid
307         # at the same time
308         try:
309             self.userid = self.db.user.lookup(user)
310         except (KeyError, TypeError):
311             user = 'anonymous'
313         # make sure the anonymous user is valid if we're using it
314         if user == 'anonymous':
315             self.make_user_anonymous()
316         else:
317             self.user = user
319         # reopen the database as the correct user
320         self.opendb(self.user)
322     def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
323         """Determine the context of this page from the URL:
325         The URL path after the instance identifier is examined. The path
326         is generally only one entry long.
328         - if there is no path, then we are in the "home" context.
329         - if the path is "_file", then the additional path entry
330           specifies the filename of a static file we're to serve up
331           from the instance "html" directory. Raises a SendStaticFile
332           exception.(*)
333         - if there is something in the path (eg "issue"), it identifies
334           the tracker class we're to display.
335         - if the path is an item designator (eg "issue123"), then we're
336           to display a specific item.
337         - if the path starts with an item designator and is longer than
338           one entry, then we're assumed to be handling an item of a
339           FileClass, and the extra path information gives the filename
340           that the client is going to label the download with (ie
341           "file123/image.png" is nicer to download than "file123"). This
342           raises a SendFile exception.(*)
344         Both of the "*" types of contexts stop before we bother to
345         determine the template we're going to use. That's because they
346         don't actually use templates.
348         The template used is specified by the :template CGI variable,
349         which defaults to:
351         - only classname suplied:          "index"
352         - full item designator supplied:   "item"
354         We set:
356              self.classname  - the class to display, can be None
358              self.template   - the template to render the current context with
360              self.nodeid     - the nodeid of the class we're displaying
361         """
362         # default the optional variables
363         self.classname = None
364         self.nodeid = None
366         # see if a template or messages are specified
367         template_override = ok_message = error_message = None
368         for key in self.form.keys():
369             if self.FV_TEMPLATE.match(key):
370                 template_override = self.form[key].value
371             elif self.FV_OK_MESSAGE.match(key):
372                 ok_message = self.form[key].value
373                 ok_message = clean_message(ok_message)
374             elif self.FV_ERROR_MESSAGE.match(key):
375                 error_message = self.form[key].value
376                 error_message = clean_message(error_message)
378         # see if we were passed in a message
379         if ok_message:
380             self.ok_message.append(ok_message)
381         if error_message:
382             self.error_message.append(error_message)
384         # determine the classname and possibly nodeid
385         path = self.path.split('/')
386         if not path or path[0] in ('', 'home', 'index'):
387             if template_override is not None:
388                 self.template = template_override
389             else:
390                 self.template = ''
391             return
392         elif path[0] in ('_file', '@@file'):
393             raise SendStaticFile, os.path.join(*path[1:])
394         else:
395             self.classname = path[0]
396             if len(path) > 1:
397                 # send the file identified by the designator in path[0]
398                 raise SendFile, path[0]
400         # we need the db for further context stuff - open it as admin
401         self.opendb('admin')
403         # see if we got a designator
404         m = dre.match(self.classname)
405         if m:
406             self.classname = m.group(1)
407             self.nodeid = m.group(2)
408             if not self.db.getclass(self.classname).hasnode(self.nodeid):
409                 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
410             # with a designator, we default to item view
411             self.template = 'item'
412         else:
413             # with only a class, we default to index view
414             self.template = 'index'
416         # make sure the classname is valid
417         try:
418             self.db.getclass(self.classname)
419         except KeyError:
420             raise NotFound, self.classname
422         # see if we have a template override
423         if template_override is not None:
424             self.template = template_override
426     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
427         ''' Serve the file from the content property of the designated item.
428         '''
429         m = dre.match(str(designator))
430         if not m:
431             raise NotFound, str(designator)
432         classname, nodeid = m.group(1), m.group(2)
434         self.opendb('admin')
435         klass = self.db.getclass(classname)
437         # make sure we have the appropriate properties
438         props = klass.getprops()
439         if not props.has_key('type'):
440             raise NotFound, designator
441         if not props.has_key('content'):
442             raise NotFound, designator
444         mime_type = klass.get(nodeid, 'type')
445         content = klass.get(nodeid, 'content')
446         lmt = klass.get(nodeid, 'activity').timestamp()
448         self._serve_file(lmt, mime_type, content)
450     def serve_static_file(self, file):
451         ''' Serve up the file named from the templates dir
452         '''
453         filename = os.path.join(self.instance.config.TEMPLATES, file)
455         # last-modified time
456         lmt = os.stat(filename)[stat.ST_MTIME]
458         # detemine meta-type
459         file = str(file)
460         mime_type = mimetypes.guess_type(file)[0]
461         if not mime_type:
462             if file.endswith('.css'):
463                 mime_type = 'text/css'
464             else:
465                 mime_type = 'text/plain'
467         # snarf the content
468         f = open(filename, 'rb')
469         try:
470             content = f.read()
471         finally:
472             f.close()
474         self._serve_file(lmt, mime_type, content)
476     def _serve_file(self, last_modified, mime_type, content):
477         ''' guts of serve_file() and serve_static_file()
478         '''
479         ims = None
480         # see if there's an if-modified-since...
481         if hasattr(self.request, 'headers'):
482             ims = self.request.headers.getheader('if-modified-since')
483         elif self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
484             # cgi will put the header in the env var
485             ims = self.env['HTTP_IF_MODIFIED_SINCE']
486         if ims:
487             ims = rfc822.parsedate(ims)[:6]
488             lmtt = time.gmtime(lmt)[:6]
489             if lmtt <= ims:
490                 raise NotModified
492         # spit out headers
493         self.additional_headers['Content-Type'] = mime_type
494         self.additional_headers['Content-Length'] = len(content)
495         lmt = rfc822.formatdate(last_modified)
496         self.additional_headers['Last-Modifed'] = lmt
497         self.write(content)
499     def renderContext(self):
500         ''' Return a PageTemplate for the named page
501         '''
502         name = self.classname
503         extension = self.template
504         pt = templating.Templates(self.instance.config.TEMPLATES).get(name,
505             extension)
507         # catch errors so we can handle PT rendering errors more nicely
508         args = {
509             'ok_message': self.ok_message,
510             'error_message': self.error_message
511         }
512         try:
513             # let the template render figure stuff out
514             result = pt.render(self, None, None, **args)
515             self.additional_headers['Content-Type'] = pt.content_type
516             return result
517         except templating.NoTemplate, message:
518             return '<strong>%s</strong>'%message
519         except templating.Unauthorised, message:
520             raise Unauthorised, str(message)
521         except:
522             # everything else
523             return cgitb.pt_html()
525     # these are the actions that are available
526     actions = (
527         ('edit',     EditItemAction),
528         ('editcsv',  EditCSVAction),
529         ('new',      NewItemAction),
530         ('register', RegisterAction),
531         ('confrego', ConfRegoAction),
532         ('passrst',  PassResetAction),
533         ('login',    LoginAction),
534         ('logout',   LogoutAction),
535         ('search',   SearchAction),
536         ('retire',   RetireAction),
537         ('show',     ShowAction),
538     )
539     def handle_action(self):
540         ''' Determine whether there should be an Action called.
542             The action is defined by the form variable :action which
543             identifies the method on this object to call. The actions
544             are defined in the "actions" sequence on this class.
546             Actions may return a page (by default HTML) to return to the
547             user, bypassing the usual template rendering.
548         '''
549         if self.form.has_key(':action'):
550             action = self.form[':action'].value.lower()
551         elif self.form.has_key('@action'):
552             action = self.form['@action'].value.lower()
553         else:
554             return None
555         try:
556             # get the action, validate it
557             for name, action_klass in self.actions:
558                 if name == action:
559                     break
560             else:
561                 raise ValueError, 'No such action "%s"'%action
563             # call the mapped action
564             if isinstance(action_klass, type('')):
565                 # old way of specifying actions
566                 return getattr(self, action_klass)()
567             else:
568                 return action_klass(self).execute()
570         except ValueError, err:
571             self.error_message.append(str(err))
573     def write(self, content):
574         if not self.headers_done:
575             self.header()
576         self.request.wfile.write(content)
578     def header(self, headers=None, response=None):
579         '''Put up the appropriate header.
580         '''
581         if headers is None:
582             headers = {'Content-Type':'text/html'}
583         if response is None:
584             response = self.response_code
586         # update with additional info
587         headers.update(self.additional_headers)
589         if not headers.has_key('Content-Type'):
590             headers['Content-Type'] = 'text/html'
591         self.request.send_response(response)
592         for entry in headers.items():
593             self.request.send_header(*entry)
594         self.request.end_headers()
595         self.headers_done = 1
596         if self.debug:
597             self.headers_sent = headers
599     def set_cookie(self, user):
600         """Set up a session cookie for the user.
602         Also store away the user's login info against the session.
603         """
604         # TODO generate a much, much stronger session key ;)
605         self.session = binascii.b2a_base64(repr(random.random())).strip()
607         # clean up the base64
608         if self.session[-1] == '=':
609             if self.session[-2] == '=':
610                 self.session = self.session[:-2]
611             else:
612                 self.session = self.session[:-1]
614         # insert the session in the sessiondb
615         self.db.sessions.set(self.session, user=user, last_use=time.time())
617         # and commit immediately
618         self.db.sessions.commit()
620         # expire us in a long, long time
621         expire = Cookie._getdate(86400*365)
623         # generate the cookie path - make sure it has a trailing '/'
624         self.additional_headers['Set-Cookie'] = \
625           '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
626             expire, self.cookie_path)
628     def make_user_anonymous(self):
629         ''' Make us anonymous
631             This method used to handle non-existence of the 'anonymous'
632             user, but that user is mandatory now.
633         '''
634         self.userid = self.db.user.lookup('anonymous')
635         self.user = 'anonymous'
637     def opendb(self, user):
638         ''' Open the database.
639         '''
640         # open the db if the user has changed
641         if not hasattr(self, 'db') or user != self.db.journaltag:
642             if hasattr(self, 'db'):
643                 self.db.close()
644             self.db = self.instance.open(user)
646     def standard_message(self, to, subject, body, author=None):
647         try:
648             self.mailer.standard_message(to, subject, body, author)
649             return 1
650         except MessageSendError, e:
651             self.error_message.append(str(e))
653     def parsePropsFromForm(self, create=False):
654         return FormParser(self).parse(create=create)