Code

c34d4266208fa8c2dd2b5a7aad92ed1ba6f23788
[roundup.git] / roundup / cgi / client.py
1 # $Id: client.py,v 1.169 2004-03-26 05:16:03 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 SeriousError, message:
212             self.write(str(message))
213         except Redirect, url:
214             # let's redirect - if the url isn't None, then we need to do
215             # the headers, otherwise the headers have been set before the
216             # exception was raised
217             if url:
218                 self.additional_headers['Location'] = url
219                 self.response_code = 302
220             self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
221         except SendFile, designator:
222             self.serve_file(designator)
223         except SendStaticFile, file:
224             try:
225                 self.serve_static_file(str(file))
226             except NotModified:
227                 # send the 304 response
228                 self.request.send_response(304)
229                 self.request.end_headers()
230         except Unauthorised, message:
231             # users may always see the front page
232             self.classname = self.nodeid = None
233             self.template = ''
234             self.error_message.append(message)
235             self.write(self.renderContext())
236         except NotFound:
237             # pass through
238             raise
239         except FormError, e:
240             self.error_message.append(_('Form Error: ') + str(e))
241             self.write(self.renderContext())
242         except:
243             # everything else
244             self.write(cgitb.html())
246     def clean_sessions(self):
247         """Age sessions, remove when they haven't been used for a week.
249         Do it only once an hour.
251         Note: also cleans One Time Keys, and other "session" based stuff.
252         """
253         sessions = self.db.getSessionManager()
254         last_clean = sessions.get('last_clean', 'last_use', 0)
256         # time to clean?
257         week = 60*60*24*7
258         hour = 60*60
259         now = time.time()
260         if now - last_clean < hour:
261             return
263         sessions.clean(now)
264         self.db.getOTKManager().clean(now)
265         sessions.set('last_clean', last_use=time.time())
266         self.db.commit()
268     def determine_user(self):
269         ''' Determine who the user is
270         '''
271         # determine the uid to use
272         self.opendb('admin')
274         # make sure we have the session Class
275         self.clean_sessions()
276         sessions = self.db.getSessionManager()
278         # first up, try the REMOTE_USER var (from HTTP Basic Auth handled
279         # by a front-end HTTP server)
280         try:
281             user = os.getenv('REMOTE_USER')
282         except KeyError:
283             pass
285         # look up the user session cookie (may override the REMOTE_USER)
286         cookie = Cookie.SimpleCookie(self.env.get('HTTP_COOKIE', ''))
287         user = 'anonymous'
288         if (cookie.has_key(self.cookie_name) and
289                 cookie[self.cookie_name].value != 'deleted'):
291             # get the session key from the cookie
292             self.session = cookie[self.cookie_name].value
293             # get the user from the session
294             try:
295                 # update the lifetime datestamp
296                 sessions.updateTimestamp(self.session)
297                 user = sessions.get(self.session, 'user')
298             except KeyError:
299                 # not valid, ignore id
300                 pass
302         # sanity check on the user still being valid, getting the userid
303         # at the same time
304         try:
305             self.userid = self.db.user.lookup(user)
306         except (KeyError, TypeError):
307             user = 'anonymous'
309         # make sure the anonymous user is valid if we're using it
310         if user == 'anonymous':
311             self.make_user_anonymous()
312         else:
313             self.user = user
315         # reopen the database as the correct user
316         self.opendb(self.user)
318     def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
319         """Determine the context of this page from the URL:
321         The URL path after the instance identifier is examined. The path
322         is generally only one entry long.
324         - if there is no path, then we are in the "home" context.
325         - if the path is "_file", then the additional path entry
326           specifies the filename of a static file we're to serve up
327           from the instance "html" directory. Raises a SendStaticFile
328           exception.(*)
329         - if there is something in the path (eg "issue"), it identifies
330           the tracker class we're to display.
331         - if the path is an item designator (eg "issue123"), then we're
332           to display a specific item.
333         - if the path starts with an item designator and is longer than
334           one entry, then we're assumed to be handling an item of a
335           FileClass, and the extra path information gives the filename
336           that the client is going to label the download with (ie
337           "file123/image.png" is nicer to download than "file123"). This
338           raises a SendFile exception.(*)
340         Both of the "*" types of contexts stop before we bother to
341         determine the template we're going to use. That's because they
342         don't actually use templates.
344         The template used is specified by the :template CGI variable,
345         which defaults to:
347         - only classname suplied:          "index"
348         - full item designator supplied:   "item"
350         We set:
352              self.classname  - the class to display, can be None
354              self.template   - the template to render the current context with
356              self.nodeid     - the nodeid of the class we're displaying
357         """
358         # default the optional variables
359         self.classname = None
360         self.nodeid = None
362         # see if a template or messages are specified
363         template_override = ok_message = error_message = None
364         for key in self.form.keys():
365             if self.FV_TEMPLATE.match(key):
366                 template_override = self.form[key].value
367             elif self.FV_OK_MESSAGE.match(key):
368                 ok_message = self.form[key].value
369                 ok_message = clean_message(ok_message)
370             elif self.FV_ERROR_MESSAGE.match(key):
371                 error_message = self.form[key].value
372                 error_message = clean_message(error_message)
374         # see if we were passed in a message
375         if ok_message:
376             self.ok_message.append(ok_message)
377         if error_message:
378             self.error_message.append(error_message)
380         # determine the classname and possibly nodeid
381         path = self.path.split('/')
382         if not path or path[0] in ('', 'home', 'index'):
383             if template_override is not None:
384                 self.template = template_override
385             else:
386                 self.template = ''
387             return
388         elif path[0] in ('_file', '@@file'):
389             raise SendStaticFile, os.path.join(*path[1:])
390         else:
391             self.classname = path[0]
392             if len(path) > 1:
393                 # send the file identified by the designator in path[0]
394                 raise SendFile, path[0]
396         # we need the db for further context stuff - open it as admin
397         self.opendb('admin')
399         # see if we got a designator
400         m = dre.match(self.classname)
401         if m:
402             self.classname = m.group(1)
403             self.nodeid = m.group(2)
404             if not self.db.getclass(self.classname).hasnode(self.nodeid):
405                 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
406             # with a designator, we default to item view
407             self.template = 'item'
408         else:
409             # with only a class, we default to index view
410             self.template = 'index'
412         # make sure the classname is valid
413         try:
414             self.db.getclass(self.classname)
415         except KeyError:
416             raise NotFound, self.classname
418         # see if we have a template override
419         if template_override is not None:
420             self.template = template_override
422     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
423         ''' Serve the file from the content property of the designated item.
424         '''
425         m = dre.match(str(designator))
426         if not m:
427             raise NotFound, str(designator)
428         classname, nodeid = m.group(1), m.group(2)
430         self.opendb('admin')
431         klass = self.db.getclass(classname)
433         # make sure we have the appropriate properties
434         props = klass.getprops()
435         if not props.has_key('type'):
436             raise NotFound, designator
437         if not props.has_key('content'):
438             raise NotFound, designator
440         mime_type = klass.get(nodeid, 'type')
441         content = klass.get(nodeid, 'content')
442         lmt = klass.get(nodeid, 'activity').timestamp()
444         self._serve_file(lmt, mime_type, content)
446     def serve_static_file(self, file):
447         ''' Serve up the file named from the templates dir
448         '''
449         filename = os.path.join(self.instance.config.TEMPLATES, file)
451         # last-modified time
452         lmt = os.stat(filename)[stat.ST_MTIME]
454         # detemine meta-type
455         file = str(file)
456         mime_type = mimetypes.guess_type(file)[0]
457         if not mime_type:
458             if file.endswith('.css'):
459                 mime_type = 'text/css'
460             else:
461                 mime_type = 'text/plain'
463         # snarf the content
464         f = open(filename, 'rb')
465         try:
466             content = f.read()
467         finally:
468             f.close()
470         self._serve_file(lmt, mime_type, content)
472     def _serve_file(self, last_modified, mime_type, content):
473         ''' guts of serve_file() and serve_static_file()
474         '''
475         ims = None
476         # see if there's an if-modified-since...
477         if hasattr(self.request, 'headers'):
478             ims = self.request.headers.getheader('if-modified-since')
479         elif self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
480             # cgi will put the header in the env var
481             ims = self.env['HTTP_IF_MODIFIED_SINCE']
482         if ims:
483             ims = rfc822.parsedate(ims)[:6]
484             lmtt = time.gmtime(lmt)[:6]
485             if lmtt <= ims:
486                 raise NotModified
488         # spit out headers
489         self.additional_headers['Content-Type'] = mime_type
490         self.additional_headers['Content-Length'] = len(content)
491         lmt = rfc822.formatdate(last_modified)
492         self.additional_headers['Last-Modifed'] = lmt
493         self.write(content)
495     def renderContext(self):
496         ''' Return a PageTemplate for the named page
497         '''
498         name = self.classname
499         extension = self.template
500         pt = templating.Templates(self.instance.config.TEMPLATES).get(name,
501             extension)
503         # catch errors so we can handle PT rendering errors more nicely
504         args = {
505             'ok_message': self.ok_message,
506             'error_message': self.error_message
507         }
508         try:
509             # let the template render figure stuff out
510             result = pt.render(self, None, None, **args)
511             self.additional_headers['Content-Type'] = pt.content_type
512             return result
513         except templating.NoTemplate, message:
514             return '<strong>%s</strong>'%message
515         except templating.Unauthorised, message:
516             raise Unauthorised, str(message)
517         except:
518             # everything else
519             return cgitb.pt_html()
521     # these are the actions that are available
522     actions = (
523         ('edit',        EditItemAction),
524         ('editcsv',     EditCSVAction),
525         ('new',         NewItemAction),
526         ('register',    RegisterAction),
527         ('confrego',    ConfRegoAction),
528         ('passrst',     PassResetAction),
529         ('login',       LoginAction),
530         ('logout',      LogoutAction),
531         ('search',      SearchAction),
532         ('retire',      RetireAction),
533         ('show',        ShowAction),
534         ('export_csv',  ExportCSVAction),
535     )
536     def handle_action(self):
537         ''' Determine whether there should be an Action called.
539             The action is defined by the form variable :action which
540             identifies the method on this object to call. The actions
541             are defined in the "actions" sequence on this class.
543             Actions may return a page (by default HTML) to return to the
544             user, bypassing the usual template rendering.
545         '''
546         if self.form.has_key(':action'):
547             action = self.form[':action'].value.lower()
548         elif self.form.has_key('@action'):
549             action = self.form['@action'].value.lower()
550         else:
551             return None
552         try:
553             # get the action, validate it
554             for name, action_klass in self.actions:
555                 if name == action:
556                     break
557             else:
558                 raise ValueError, 'No such action "%s"'%action
560             # call the mapped action
561             if isinstance(action_klass, type('')):
562                 # old way of specifying actions
563                 return getattr(self, action_klass)()
564             else:
565                 return action_klass(self).execute()
567         except ValueError, err:
568             self.error_message.append(str(err))
570     def write(self, content):
571         if not self.headers_done:
572             self.header()
573         self.request.wfile.write(content)
575     def setHeader(self, header, value):
576         '''Override a header to be returned to the user's browser.
577         '''
578         self.additional_headers[header] = value
580     def header(self, headers=None, response=None):
581         '''Put up the appropriate header.
582         '''
583         if headers is None:
584             headers = {'Content-Type':'text/html'}
585         if response is None:
586             response = self.response_code
588         # update with additional info
589         headers.update(self.additional_headers)
591         if not headers.has_key('Content-Type'):
592             headers['Content-Type'] = 'text/html'
593         self.request.send_response(response)
594         for entry in headers.items():
595             self.request.send_header(*entry)
596         self.request.end_headers()
597         self.headers_done = 1
598         if self.debug:
599             self.headers_sent = headers
601     def set_cookie(self, user):
602         """Set up a session cookie for the user.
604         Also store away the user's login info against the session.
605         """
606         # TODO generate a much, much stronger session key ;)
607         self.session = binascii.b2a_base64(repr(random.random())).strip()
609         # clean up the base64
610         if self.session[-1] == '=':
611             if self.session[-2] == '=':
612                 self.session = self.session[:-2]
613             else:
614                 self.session = self.session[:-1]
616         # insert the session in the sessiondb
617         sessions = self.db.getSessionManager()
618         sessions.set(self.session, user=user)
619         self.db.commit()
621         # expire us in a long, long time
622         expire = Cookie._getdate(86400*365)
624         # generate the cookie path - make sure it has a trailing '/'
625         self.additional_headers['Set-Cookie'] = \
626           '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
627             expire, self.cookie_path)
629     def make_user_anonymous(self):
630         ''' Make us anonymous
632             This method used to handle non-existence of the 'anonymous'
633             user, but that user is mandatory now.
634         '''
635         self.userid = self.db.user.lookup('anonymous')
636         self.user = 'anonymous'
638     def opendb(self, user):
639         ''' Open the database.
640         '''
641         # open the db if the user has changed
642         if not hasattr(self, 'db') or user != self.db.journaltag:
643             if hasattr(self, 'db'):
644                 self.db.close()
645             self.db = self.instance.open(user)
647     def standard_message(self, to, subject, body, author=None):
648         try:
649             self.mailer.standard_message(to, subject, body, author)
650             return 1
651         except MessageSendError, e:
652             self.error_message.append(str(e))
654     def parsePropsFromForm(self, create=0):
655         return FormParser(self).parse(create=create)