57fdd110c9ca6a4bd0a4bdb93b33257899b2c5f1
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 '<%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 = 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)