1 # $Id: client.py,v 1.164 2004-02-25 03:39:53 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 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 = self.db.sessions.get('last_clean', 'last_use') or 0
254 # time to clean?
255 week = 60*60*24*7
256 hour = 60*60
257 now = time.time()
258 if now - last_clean < hour:
259 return
261 self.db.sessions.clean(now)
262 self.db.otks.clean(now)
263 self.db.sessions.set('last_clean', last_use=time.time())
265 def determine_user(self):
266 ''' Determine who the user is
267 '''
268 # determine the uid to use
269 self.opendb('admin')
271 # make sure we have the session Class
272 self.clean_sessions()
273 sessions = self.db.sessions
275 # first up, try the REMOTE_USER var (from HTTP Basic Auth handled
276 # by a front-end HTTP server)
277 try:
278 user = os.getenv('REMOTE_USER')
279 except KeyError:
280 pass
282 # look up the user session cookie (may override the REMOTE_USER)
283 cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
284 user = 'anonymous'
285 if (cookie.has_key(self.cookie_name) and
286 cookie[self.cookie_name].value != 'deleted'):
288 # get the session key from the cookie
289 self.session = cookie[self.cookie_name].value
290 # get the user from the session
291 try:
292 # update the lifetime datestamp
293 sessions.updateTimestamp(self.session)
294 sessions.commit()
295 user = sessions.get(self.session, 'user')
296 except KeyError:
297 # not valid, ignore id
298 pass
300 # sanity check on the user still being valid, getting the userid
301 # at the same time
302 try:
303 self.userid = self.db.user.lookup(user)
304 except (KeyError, TypeError):
305 user = 'anonymous'
307 # make sure the anonymous user is valid if we're using it
308 if user == 'anonymous':
309 self.make_user_anonymous()
310 else:
311 self.user = user
313 # reopen the database as the correct user
314 self.opendb(self.user)
316 def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
317 """Determine the context of this page from the URL:
319 The URL path after the instance identifier is examined. The path
320 is generally only one entry long.
322 - if there is no path, then we are in the "home" context.
323 - if the path is "_file", then the additional path entry
324 specifies the filename of a static file we're to serve up
325 from the instance "html" directory. Raises a SendStaticFile
326 exception.(*)
327 - if there is something in the path (eg "issue"), it identifies
328 the tracker class we're to display.
329 - if the path is an item designator (eg "issue123"), then we're
330 to display a specific item.
331 - if the path starts with an item designator and is longer than
332 one entry, then we're assumed to be handling an item of a
333 FileClass, and the extra path information gives the filename
334 that the client is going to label the download with (ie
335 "file123/image.png" is nicer to download than "file123"). This
336 raises a SendFile exception.(*)
338 Both of the "*" types of contexts stop before we bother to
339 determine the template we're going to use. That's because they
340 don't actually use templates.
342 The template used is specified by the :template CGI variable,
343 which defaults to:
345 - only classname suplied: "index"
346 - full item designator supplied: "item"
348 We set:
350 self.classname - the class to display, can be None
352 self.template - the template to render the current context with
354 self.nodeid - the nodeid of the class we're displaying
355 """
356 # default the optional variables
357 self.classname = None
358 self.nodeid = None
360 # see if a template or messages are specified
361 template_override = ok_message = error_message = None
362 for key in self.form.keys():
363 if self.FV_TEMPLATE.match(key):
364 template_override = self.form[key].value
365 elif self.FV_OK_MESSAGE.match(key):
366 ok_message = self.form[key].value
367 ok_message = clean_message(ok_message)
368 elif self.FV_ERROR_MESSAGE.match(key):
369 error_message = self.form[key].value
370 error_message = clean_message(error_message)
372 # see if we were passed in a message
373 if ok_message:
374 self.ok_message.append(ok_message)
375 if error_message:
376 self.error_message.append(error_message)
378 # determine the classname and possibly nodeid
379 path = self.path.split('/')
380 if not path or path[0] in ('', 'home', 'index'):
381 if template_override is not None:
382 self.template = template_override
383 else:
384 self.template = ''
385 return
386 elif path[0] in ('_file', '@@file'):
387 raise SendStaticFile, os.path.join(*path[1:])
388 else:
389 self.classname = path[0]
390 if len(path) > 1:
391 # send the file identified by the designator in path[0]
392 raise SendFile, path[0]
394 # we need the db for further context stuff - open it as admin
395 self.opendb('admin')
397 # see if we got a designator
398 m = dre.match(self.classname)
399 if m:
400 self.classname = m.group(1)
401 self.nodeid = m.group(2)
402 if not self.db.getclass(self.classname).hasnode(self.nodeid):
403 raise NotFound, '%s/%s'%(self.classname, self.nodeid)
404 # with a designator, we default to item view
405 self.template = 'item'
406 else:
407 # with only a class, we default to index view
408 self.template = 'index'
410 # make sure the classname is valid
411 try:
412 self.db.getclass(self.classname)
413 except KeyError:
414 raise NotFound, self.classname
416 # see if we have a template override
417 if template_override is not None:
418 self.template = template_override
420 def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
421 ''' Serve the file from the content property of the designated item.
422 '''
423 m = dre.match(str(designator))
424 if not m:
425 raise NotFound, str(designator)
426 classname, nodeid = m.group(1), m.group(2)
428 self.opendb('admin')
429 klass = self.db.getclass(classname)
431 # make sure we have the appropriate properties
432 props = klass.getprops()
433 if not props.has_key('type'):
434 raise NotFound, designator
435 if not props.has_key('content'):
436 raise NotFound, designator
438 mime_type = klass.get(nodeid, 'type')
439 content = klass.get(nodeid, 'content')
440 lmt = klass.get(nodeid, 'activity').timestamp()
442 self._serve_file(lmt, mime_type, content)
444 def serve_static_file(self, file):
445 ''' Serve up the file named from the templates dir
446 '''
447 filename = os.path.join(self.instance.config.TEMPLATES, file)
449 # last-modified time
450 lmt = os.stat(filename)[stat.ST_MTIME]
452 # detemine meta-type
453 file = str(file)
454 mime_type = mimetypes.guess_type(file)[0]
455 if not mime_type:
456 if file.endswith('.css'):
457 mime_type = 'text/css'
458 else:
459 mime_type = 'text/plain'
461 # snarf the content
462 f = open(filename, 'rb')
463 try:
464 content = f.read()
465 finally:
466 f.close()
468 self._serve_file(lmt, mime_type, content)
470 def _serve_file(self, last_modified, mime_type, content):
471 ''' guts of serve_file() and serve_static_file()
472 '''
473 ims = None
474 # see if there's an if-modified-since...
475 if hasattr(self.request, 'headers'):
476 ims = self.request.headers.getheader('if-modified-since')
477 elif self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
478 # cgi will put the header in the env var
479 ims = self.env['HTTP_IF_MODIFIED_SINCE']
480 if ims:
481 ims = rfc822.parsedate(ims)[:6]
482 lmtt = time.gmtime(lmt)[:6]
483 if lmtt <= ims:
484 raise NotModified
486 # spit out headers
487 self.additional_headers['Content-Type'] = mime_type
488 self.additional_headers['Content-Length'] = len(content)
489 lmt = rfc822.formatdate(last_modified)
490 self.additional_headers['Last-Modifed'] = lmt
491 self.write(content)
493 def renderContext(self):
494 ''' Return a PageTemplate for the named page
495 '''
496 name = self.classname
497 extension = self.template
498 pt = templating.Templates(self.instance.config.TEMPLATES).get(name,
499 extension)
501 # catch errors so we can handle PT rendering errors more nicely
502 args = {
503 'ok_message': self.ok_message,
504 'error_message': self.error_message
505 }
506 try:
507 # let the template render figure stuff out
508 result = pt.render(self, None, None, **args)
509 self.additional_headers['Content-Type'] = pt.content_type
510 return result
511 except templating.NoTemplate, message:
512 return '<strong>%s</strong>'%message
513 except templating.Unauthorised, message:
514 raise Unauthorised, str(message)
515 except:
516 # everything else
517 return cgitb.pt_html()
519 # these are the actions that are available
520 actions = (
521 ('edit', EditItemAction),
522 ('editcsv', EditCSVAction),
523 ('new', NewItemAction),
524 ('register', RegisterAction),
525 ('confrego', ConfRegoAction),
526 ('passrst', PassResetAction),
527 ('login', LoginAction),
528 ('logout', LogoutAction),
529 ('search', SearchAction),
530 ('retire', RetireAction),
531 ('show', ShowAction),
532 )
533 def handle_action(self):
534 ''' Determine whether there should be an Action called.
536 The action is defined by the form variable :action which
537 identifies the method on this object to call. The actions
538 are defined in the "actions" sequence on this class.
540 Actions may return a page (by default HTML) to return to the
541 user, bypassing the usual template rendering.
542 '''
543 if self.form.has_key(':action'):
544 action = self.form[':action'].value.lower()
545 elif self.form.has_key('@action'):
546 action = self.form['@action'].value.lower()
547 else:
548 return None
549 try:
550 # get the action, validate it
551 for name, action_klass in self.actions:
552 if name == action:
553 break
554 else:
555 raise ValueError, 'No such action "%s"'%action
557 # call the mapped action
558 if isinstance(action_klass, type('')):
559 # old way of specifying actions
560 return getattr(self, action_klass)()
561 else:
562 return action_klass(self).execute()
564 except ValueError, err:
565 self.error_message.append(str(err))
567 def write(self, content):
568 if not self.headers_done:
569 self.header()
570 self.request.wfile.write(content)
572 def setHeader(self, header, value):
573 '''Override a header to be returned to the user's browser.
574 '''
575 self.additional_headers[header] = value
577 def header(self, headers=None, response=None):
578 '''Put up the appropriate header.
579 '''
580 if headers is None:
581 headers = {'Content-Type':'text/html'}
582 if response is None:
583 response = self.response_code
585 # update with additional info
586 headers.update(self.additional_headers)
588 if not headers.has_key('Content-Type'):
589 headers['Content-Type'] = 'text/html'
590 self.request.send_response(response)
591 for entry in headers.items():
592 self.request.send_header(*entry)
593 self.request.end_headers()
594 self.headers_done = 1
595 if self.debug:
596 self.headers_sent = headers
598 def set_cookie(self, user):
599 """Set up a session cookie for the user.
601 Also store away the user's login info against the session.
602 """
603 # TODO generate a much, much stronger session key ;)
604 self.session = binascii.b2a_base64(repr(random.random())).strip()
606 # clean up the base64
607 if self.session[-1] == '=':
608 if self.session[-2] == '=':
609 self.session = self.session[:-2]
610 else:
611 self.session = self.session[:-1]
613 # insert the session in the sessiondb
614 self.db.sessions.set(self.session, user=user, last_use=time.time())
616 # and commit immediately
617 self.db.sessions.commit()
619 # expire us in a long, long time
620 expire = Cookie._getdate(86400*365)
622 # generate the cookie path - make sure it has a trailing '/'
623 self.additional_headers['Set-Cookie'] = \
624 '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
625 expire, self.cookie_path)
627 def make_user_anonymous(self):
628 ''' Make us anonymous
630 This method used to handle non-existence of the 'anonymous'
631 user, but that user is mandatory now.
632 '''
633 self.userid = self.db.user.lookup('anonymous')
634 self.user = 'anonymous'
636 def opendb(self, user):
637 ''' Open the database.
638 '''
639 # open the db if the user has changed
640 if not hasattr(self, 'db') or user != self.db.journaltag:
641 if hasattr(self, 'db'):
642 self.db.close()
643 self.db = self.instance.open(user)
645 def standard_message(self, to, subject, body, author=None):
646 try:
647 self.mailer.standard_message(to, subject, body, author)
648 return 1
649 except MessageSendError, e:
650 self.error_message.append(str(e))
652 def parsePropsFromForm(self, create=False):
653 return FormParser(self).parse(create=create)