1 # $Id: client.py,v 1.168 2004-03-25 00:44:28 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 '<%s>'%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.Cookie(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)