Commit | Line | Data |
---|---|---|
c638d827 CR |
1 | """WWW request handler (also used in the stand-alone server). |
2 | """ | |
3 | __docformat__ = 'restructuredtext' | |
4 | ||
5 | import base64, binascii, cgi, codecs, mimetypes, os | |
6 | import quopri, random, re, rfc822, stat, sys, time | |
7 | import socket, errno | |
8 | ||
9 | from roundup import roundupdb, date, hyperdb, password | |
10 | from roundup.cgi import templating, cgitb, TranslationService | |
11 | from roundup.cgi.actions import * | |
12 | from roundup.exceptions import * | |
13 | from roundup.cgi.exceptions import * | |
14 | from roundup.cgi.form_parser import FormParser | |
15 | from roundup.mailer import Mailer, MessageSendError, encode_quopri | |
16 | from roundup.cgi import accept_language | |
17 | from roundup import xmlrpc | |
18 | ||
19 | from roundup.anypy.cookie_ import CookieError, BaseCookie, SimpleCookie, \ | |
20 | get_cookie_date | |
21 | from roundup.anypy.io_ import StringIO | |
22 | from roundup.anypy import http_ | |
23 | from roundup.anypy import urllib_ | |
24 | ||
25 | def initialiseSecurity(security): | |
26 | '''Create some Permissions and Roles on the security object | |
27 | ||
28 | This function is directly invoked by security.Security.__init__() | |
29 | as a part of the Security object instantiation. | |
30 | ''' | |
31 | p = security.addPermission(name="Web Access", | |
32 | description="User may access the web interface") | |
33 | security.addPermissionToRole('Admin', p) | |
34 | ||
35 | # doing Role stuff through the web - make sure Admin can | |
36 | # TODO: deprecate this and use a property-based control | |
37 | p = security.addPermission(name="Web Roles", | |
38 | description="User may manipulate user Roles through the web") | |
39 | security.addPermissionToRole('Admin', p) | |
40 | ||
41 | # used to clean messages passed through CGI variables - HTML-escape any tag | |
42 | # that isn't <a href="">, <i>, <b> and <br> (including XHTML variants) so | |
43 | # that people can't pass through nasties like <script>, <iframe>, ... | |
44 | CLEAN_MESSAGE_RE = r'(<(/?(.*?)(\s*href="[^"]")?\s*/?)>)' | |
45 | def clean_message(message, mc=re.compile(CLEAN_MESSAGE_RE, re.I)): | |
46 | return mc.sub(clean_message_callback, message) | |
47 | def clean_message_callback(match, ok={'a':1,'i':1,'b':1,'br':1}): | |
48 | """ Strip all non <a>,<i>,<b> and <br> tags from a string | |
49 | """ | |
50 | if match.group(3).lower() in ok: | |
51 | return match.group(1) | |
52 | return '<%s>'%match.group(2) | |
53 | ||
54 | ||
55 | error_message = ''"""<html><head><title>An error has occurred</title></head> | |
56 | <body><h1>An error has occurred</h1> | |
57 | <p>A problem was encountered processing your request. | |
58 | The tracker maintainers have been notified of the problem.</p> | |
59 | </body></html>""" | |
60 | ||
61 | ||
62 | class LiberalCookie(SimpleCookie): | |
63 | """ Python's SimpleCookie throws an exception if the cookie uses invalid | |
64 | syntax. Other applications on the same server may have done precisely | |
65 | this, preventing roundup from working through no fault of roundup. | |
66 | Numerous other python apps have run into the same problem: | |
67 | ||
68 | trac: http://trac.edgewall.org/ticket/2256 | |
69 | mailman: http://bugs.python.org/issue472646 | |
70 | ||
71 | This particular implementation comes from trac's solution to the | |
72 | problem. Unfortunately it requires some hackery in SimpleCookie's | |
73 | internals to provide a more liberal __set method. | |
74 | """ | |
75 | def load(self, rawdata, ignore_parse_errors=True): | |
76 | if ignore_parse_errors: | |
77 | self.bad_cookies = [] | |
78 | self._BaseCookie__set = self._loose_set | |
79 | SimpleCookie.load(self, rawdata) | |
80 | if ignore_parse_errors: | |
81 | self._BaseCookie__set = self._strict_set | |
82 | for key in self.bad_cookies: | |
83 | del self[key] | |
84 | ||
85 | _strict_set = BaseCookie._BaseCookie__set | |
86 | ||
87 | def _loose_set(self, key, real_value, coded_value): | |
88 | try: | |
89 | self._strict_set(key, real_value, coded_value) | |
90 | except CookieError: | |
91 | self.bad_cookies.append(key) | |
92 | dict.__setitem__(self, key, None) | |
93 | ||
94 | ||
95 | class Session: | |
96 | """ | |
97 | Needs DB to be already opened by client | |
98 | ||
99 | Session attributes at instantiation: | |
100 | ||
101 | - "client" - reference to client for add_cookie function | |
102 | - "session_db" - session DB manager | |
103 | - "cookie_name" - name of the cookie with session id | |
104 | - "_sid" - session id for current user | |
105 | - "_data" - session data cache | |
106 | ||
107 | session = Session(client) | |
108 | session.set(name=value) | |
109 | value = session.get(name) | |
110 | ||
111 | session.destroy() # delete current session | |
112 | session.clean_up() # clean up session table | |
113 | ||
114 | session.update(set_cookie=True, expire=3600*24*365) | |
115 | # refresh session expiration time, setting persistent | |
116 | # cookie if needed to last for 'expire' seconds | |
117 | ||
118 | """ | |
119 | ||
120 | def __init__(self, client): | |
121 | self._data = {} | |
122 | self._sid = None | |
123 | ||
124 | self.client = client | |
125 | self.session_db = client.db.getSessionManager() | |
126 | ||
127 | # parse cookies for session id | |
128 | self.cookie_name = 'roundup_session_%s' % \ | |
129 | re.sub('[^a-zA-Z]', '', client.instance.config.TRACKER_NAME) | |
130 | cookies = LiberalCookie(client.env.get('HTTP_COOKIE', '')) | |
131 | if self.cookie_name in cookies: | |
132 | if not self.session_db.exists(cookies[self.cookie_name].value): | |
133 | self._sid = None | |
134 | # remove old cookie | |
135 | self.client.add_cookie(self.cookie_name, None) | |
136 | else: | |
137 | self._sid = cookies[self.cookie_name].value | |
138 | self._data = self.session_db.getall(self._sid) | |
139 | ||
140 | def _gen_sid(self): | |
141 | """ generate a unique session key """ | |
142 | while 1: | |
143 | s = '%s%s'%(time.time(), random.random()) | |
144 | s = binascii.b2a_base64(s).strip() | |
145 | if not self.session_db.exists(s): | |
146 | break | |
147 | ||
148 | # clean up the base64 | |
149 | if s[-1] == '=': | |
150 | if s[-2] == '=': | |
151 | s = s[:-2] | |
152 | else: | |
153 | s = s[:-1] | |
154 | return s | |
155 | ||
156 | def clean_up(self): | |
157 | """Remove expired sessions""" | |
158 | self.session_db.clean() | |
159 | ||
160 | def destroy(self): | |
161 | self.client.add_cookie(self.cookie_name, None) | |
162 | self._data = {} | |
163 | self.session_db.destroy(self._sid) | |
164 | self.client.db.commit() | |
165 | ||
166 | def get(self, name, default=None): | |
167 | return self._data.get(name, default) | |
168 | ||
169 | def set(self, **kwargs): | |
170 | self._data.update(kwargs) | |
171 | if not self._sid: | |
172 | self._sid = self._gen_sid() | |
173 | self.session_db.set(self._sid, **self._data) | |
174 | # add session cookie | |
175 | self.update(set_cookie=True) | |
176 | ||
177 | # XXX added when patching 1.4.4 for backward compatibility | |
178 | # XXX remove | |
179 | self.client.session = self._sid | |
180 | else: | |
181 | self.session_db.set(self._sid, **self._data) | |
182 | self.client.db.commit() | |
183 | ||
184 | def update(self, set_cookie=False, expire=None): | |
185 | """ update timestamp in db to avoid expiration | |
186 | ||
187 | if 'set_cookie' is True, set cookie with 'expire' seconds lifetime | |
188 | if 'expire' is None - session will be closed with the browser | |
189 | ||
190 | XXX the session can be purged within a week even if a cookie | |
191 | lifetime is longer | |
192 | """ | |
193 | self.session_db.updateTimestamp(self._sid) | |
194 | self.client.db.commit() | |
195 | ||
196 | if set_cookie: | |
197 | self.client.add_cookie(self.cookie_name, self._sid, expire=expire) | |
198 | ||
199 | ||
200 | ||
201 | class Client: | |
202 | """Instantiate to handle one CGI request. | |
203 | ||
204 | See inner_main for request processing. | |
205 | ||
206 | Client attributes at instantiation: | |
207 | ||
208 | - "path" is the PATH_INFO inside the instance (with no leading '/') | |
209 | - "base" is the base URL for the instance | |
210 | - "form" is the cgi form, an instance of FieldStorage from the standard | |
211 | cgi module | |
212 | - "additional_headers" is a dictionary of additional HTTP headers that | |
213 | should be sent to the client | |
214 | - "response_code" is the HTTP response code to send to the client | |
215 | - "translator" is TranslationService instance | |
216 | ||
217 | During the processing of a request, the following attributes are used: | |
218 | ||
219 | - "db" | |
220 | - "error_message" holds a list of error messages | |
221 | - "ok_message" holds a list of OK messages | |
222 | - "session" is deprecated in favor of session_api (XXX remove) | |
223 | - "session_api" is the interface to store data in session | |
224 | - "user" is the current user's name | |
225 | - "userid" is the current user's id | |
226 | - "template" is the current :template context | |
227 | - "classname" is the current class context name | |
228 | - "nodeid" is the current context item id | |
229 | ||
230 | User Identification: | |
231 | Users that are absent in session data are anonymous and are logged | |
232 | in as that user. This typically gives them all Permissions assigned to the | |
233 | Anonymous Role. | |
234 | ||
235 | Every user is assigned a session. "session_api" is the interface to work | |
236 | with session data. | |
237 | ||
238 | Special form variables: | |
239 | Note that in various places throughout this code, special form | |
240 | variables of the form :<name> are used. The colon (":") part may | |
241 | actually be one of either ":" or "@". | |
242 | """ | |
243 | ||
244 | # charset used for data storage and form templates | |
245 | # Note: must be in lower case for comparisons! | |
246 | # XXX take this from instance.config? | |
247 | STORAGE_CHARSET = 'utf-8' | |
248 | ||
249 | # | |
250 | # special form variables | |
251 | # | |
252 | FV_TEMPLATE = re.compile(r'[@:]template') | |
253 | FV_OK_MESSAGE = re.compile(r'[@:]ok_message') | |
254 | FV_ERROR_MESSAGE = re.compile(r'[@:]error_message') | |
255 | ||
256 | # Note: index page stuff doesn't appear here: | |
257 | # columns, sort, sortdir, filter, group, groupdir, search_text, | |
258 | # pagesize, startwith | |
259 | ||
260 | # list of network error codes that shouldn't be reported to tracker admin | |
261 | # (error descriptions from FreeBSD intro(2)) | |
262 | IGNORE_NET_ERRORS = ( | |
263 | # A write on a pipe, socket or FIFO for which there is | |
264 | # no process to read the data. | |
265 | errno.EPIPE, | |
266 | # A connection was forcibly closed by a peer. | |
267 | # This normally results from a loss of the connection | |
268 | # on the remote socket due to a timeout or a reboot. | |
269 | errno.ECONNRESET, | |
270 | # Software caused connection abort. A connection abort | |
271 | # was caused internal to your host machine. | |
272 | errno.ECONNABORTED, | |
273 | # A connect or send request failed because the connected party | |
274 | # did not properly respond after a period of time. | |
275 | errno.ETIMEDOUT, | |
276 | ) | |
277 | ||
278 | def __init__(self, instance, request, env, form=None, translator=None): | |
279 | # re-seed the random number generator | |
280 | random.seed() | |
281 | self.start = time.time() | |
282 | self.instance = instance | |
283 | self.request = request | |
284 | self.env = env | |
285 | self.setTranslator(translator) | |
286 | self.mailer = Mailer(instance.config) | |
287 | ||
288 | # save off the path | |
289 | self.path = env['PATH_INFO'] | |
290 | ||
291 | # this is the base URL for this tracker | |
292 | self.base = self.instance.config.TRACKER_WEB | |
293 | ||
294 | # check the tracker_we setting | |
295 | if not self.base.endswith('/'): | |
296 | self.base = self.base + '/' | |
297 | ||
298 | # this is the "cookie path" for this tracker (ie. the path part of | |
299 | # the "base" url) | |
300 | self.cookie_path = urllib_.urlparse(self.base)[2] | |
301 | # cookies to set in http responce | |
302 | # {(path, name): (value, expire)} | |
303 | self._cookies = {} | |
304 | ||
305 | # see if we need to re-parse the environment for the form (eg Zope) | |
306 | if form is None: | |
307 | self.form = cgi.FieldStorage(fp=request.rfile, environ=env) | |
308 | else: | |
309 | self.form = form | |
310 | ||
311 | # turn debugging on/off | |
312 | try: | |
313 | self.debug = int(env.get("ROUNDUP_DEBUG", 0)) | |
314 | except ValueError: | |
315 | # someone gave us a non-int debug level, turn it off | |
316 | self.debug = 0 | |
317 | ||
318 | # flag to indicate that the HTTP headers have been sent | |
319 | self.headers_done = 0 | |
320 | ||
321 | # additional headers to send with the request - must be registered | |
322 | # before the first write | |
323 | self.additional_headers = {} | |
324 | self.response_code = 200 | |
325 | ||
326 | # default character set | |
327 | self.charset = self.STORAGE_CHARSET | |
328 | ||
329 | # parse cookies (used for charset lookups) | |
330 | # use our own LiberalCookie to handle bad apps on the same | |
331 | # server that have set cookies that are out of spec | |
332 | self.cookie = LiberalCookie(self.env.get('HTTP_COOKIE', '')) | |
333 | ||
334 | self.user = None | |
335 | self.userid = None | |
336 | self.nodeid = None | |
337 | self.classname = None | |
338 | self.template = None | |
339 | ||
340 | def setTranslator(self, translator=None): | |
341 | """Replace the translation engine | |
342 | ||
343 | 'translator' | |
344 | is TranslationService instance. | |
345 | It must define methods 'translate' (TAL-compatible i18n), | |
346 | 'gettext' and 'ngettext' (gettext-compatible i18n). | |
347 | ||
348 | If omitted, create default TranslationService. | |
349 | """ | |
350 | if translator is None: | |
351 | translator = TranslationService.get_translation( | |
352 | language=self.instance.config["TRACKER_LANGUAGE"], | |
353 | tracker_home=self.instance.config["TRACKER_HOME"]) | |
354 | self.translator = translator | |
355 | self._ = self.gettext = translator.gettext | |
356 | self.ngettext = translator.ngettext | |
357 | ||
358 | def main(self): | |
359 | """ Wrap the real main in a try/finally so we always close off the db. | |
360 | """ | |
361 | try: | |
362 | if self.env.get('CONTENT_TYPE') == 'text/xml': | |
363 | self.handle_xmlrpc() | |
364 | else: | |
365 | self.inner_main() | |
366 | finally: | |
367 | if hasattr(self, 'db'): | |
368 | self.db.close() | |
369 | ||
370 | ||
371 | def handle_xmlrpc(self): | |
372 | ||
373 | # Pull the raw XML out of the form. The "value" attribute | |
374 | # will be the raw content of the POST request. | |
375 | assert self.form.file | |
376 | input = self.form.value | |
377 | # So that the rest of Roundup can query the form in the | |
378 | # usual way, we create an empty list of fields. | |
379 | self.form.list = [] | |
380 | ||
381 | # Set the charset and language, since other parts of | |
382 | # Roundup may depend upon that. | |
383 | self.determine_charset() | |
384 | self.determine_language() | |
385 | # Open the database as the correct user. | |
386 | self.determine_user() | |
387 | self.check_anonymous_access() | |
388 | ||
389 | # Call the appropriate XML-RPC method. | |
390 | handler = xmlrpc.RoundupDispatcher(self.db, | |
391 | self.instance.actions, | |
392 | self.translator, | |
393 | allow_none=True) | |
394 | output = handler.dispatch(input) | |
395 | ||
396 | self.setHeader("Content-Type", "text/xml") | |
397 | self.setHeader("Content-Length", str(len(output))) | |
398 | self.write(output) | |
399 | ||
400 | def inner_main(self): | |
401 | """Process a request. | |
402 | ||
403 | The most common requests are handled like so: | |
404 | ||
405 | 1. look for charset and language preferences, set up user locale | |
406 | see determine_charset, determine_language | |
407 | 2. figure out who we are, defaulting to the "anonymous" user | |
408 | see determine_user | |
409 | 3. figure out what the request is for - the context | |
410 | see determine_context | |
411 | 4. handle any requested action (item edit, search, ...) | |
412 | see handle_action | |
413 | 5. render a template, resulting in HTML output | |
414 | ||
415 | In some situations, exceptions occur: | |
416 | ||
417 | - HTTP Redirect (generally raised by an action) | |
418 | - SendFile (generally raised by determine_context) | |
419 | serve up a FileClass "content" property | |
420 | - SendStaticFile (generally raised by determine_context) | |
421 | serve up a file from the tracker "html" directory | |
422 | - Unauthorised (generally raised by an action) | |
423 | the action is cancelled, the request is rendered and an error | |
424 | message is displayed indicating that permission was not | |
425 | granted for the action to take place | |
426 | - templating.Unauthorised (templating action not permitted) | |
427 | raised by an attempted rendering of a template when the user | |
428 | doesn't have permission | |
429 | - NotFound (raised wherever it needs to be) | |
430 | percolates up to the CGI interface that called the client | |
431 | """ | |
432 | self.ok_message = [] | |
433 | self.error_message = [] | |
434 | try: | |
435 | self.determine_charset() | |
436 | self.determine_language() | |
437 | ||
438 | try: | |
439 | # make sure we're identified (even anonymously) | |
440 | self.determine_user() | |
441 | ||
442 | # figure out the context and desired content template | |
443 | self.determine_context() | |
444 | ||
445 | # if we've made it this far the context is to a bit of | |
446 | # Roundup's real web interface (not a file being served up) | |
447 | # so do the Anonymous Web Acess check now | |
448 | self.check_anonymous_access() | |
449 | ||
450 | # possibly handle a form submit action (may change self.classname | |
451 | # and self.template, and may also append error/ok_messages) | |
452 | html = self.handle_action() | |
453 | ||
454 | if html: | |
455 | self.write_html(html) | |
456 | return | |
457 | ||
458 | # now render the page | |
459 | # we don't want clients caching our dynamic pages | |
460 | self.additional_headers['Cache-Control'] = 'no-cache' | |
461 | # Pragma: no-cache makes Mozilla and its ilk | |
462 | # double-load all pages!! | |
463 | # self.additional_headers['Pragma'] = 'no-cache' | |
464 | ||
465 | # pages with messages added expire right now | |
466 | # simple views may be cached for a small amount of time | |
467 | # TODO? make page expire time configurable | |
468 | # <rj> always expire pages, as IE just doesn't seem to do the | |
469 | # right thing here :( | |
470 | date = time.time() - 1 | |
471 | #if self.error_message or self.ok_message: | |
472 | # date = time.time() - 1 | |
473 | #else: | |
474 | # date = time.time() + 5 | |
475 | self.additional_headers['Expires'] = rfc822.formatdate(date) | |
476 | ||
477 | # render the content | |
478 | self.write_html(self.renderContext()) | |
479 | except SendFile, designator: | |
480 | # The call to serve_file may result in an Unauthorised | |
481 | # exception or a NotModified exception. Those | |
482 | # exceptions will be handled by the outermost set of | |
483 | # exception handlers. | |
484 | self.serve_file(designator) | |
485 | except SendStaticFile, file: | |
486 | self.serve_static_file(str(file)) | |
487 | except IOError: | |
488 | # IOErrors here are due to the client disconnecting before | |
489 | # recieving the reply. | |
490 | pass | |
491 | ||
492 | except SeriousError, message: | |
493 | self.write_html(str(message)) | |
494 | except Redirect, url: | |
495 | # let's redirect - if the url isn't None, then we need to do | |
496 | # the headers, otherwise the headers have been set before the | |
497 | # exception was raised | |
498 | if url: | |
499 | self.additional_headers['Location'] = str(url) | |
500 | self.response_code = 302 | |
501 | self.write_html('Redirecting to <a href="%s">%s</a>'%(url, url)) | |
502 | except LoginError, message: | |
503 | # The user tried to log in, but did not provide a valid | |
504 | # username and password. If we support HTTP | |
505 | # authorization, send back a response that will cause the | |
506 | # browser to prompt the user again. | |
507 | if self.instance.config.WEB_HTTP_AUTH: | |
508 | self.response_code = http_.client.UNAUTHORIZED | |
509 | realm = self.instance.config.TRACKER_NAME | |
510 | self.setHeader("WWW-Authenticate", | |
511 | "Basic realm=\"%s\"" % realm) | |
512 | else: | |
513 | self.response_code = http_.client.FORBIDDEN | |
514 | self.renderFrontPage(message) | |
515 | except Unauthorised, message: | |
516 | # users may always see the front page | |
517 | self.response_code = 403 | |
518 | self.renderFrontPage(message) | |
519 | except NotModified: | |
520 | # send the 304 response | |
521 | self.response_code = 304 | |
522 | self.header() | |
523 | except NotFound, e: | |
524 | self.response_code = 404 | |
525 | self.template = '404' | |
526 | try: | |
527 | cl = self.db.getclass(self.classname) | |
528 | self.write_html(self.renderContext()) | |
529 | except KeyError: | |
530 | # we can't map the URL to a class we know about | |
531 | # reraise the NotFound and let roundup_server | |
532 | # handle it | |
533 | raise NotFound(e) | |
534 | except FormError, e: | |
535 | self.error_message.append(self._('Form Error: ') + str(e)) | |
536 | self.write_html(self.renderContext()) | |
537 | except: | |
538 | # Something has gone badly wrong. Therefore, we should | |
539 | # make sure that the response code indicates failure. | |
540 | if self.response_code == http_.client.OK: | |
541 | self.response_code = http_.client.INTERNAL_SERVER_ERROR | |
542 | # Help the administrator work out what went wrong. | |
543 | html = ("<h1>Traceback</h1>" | |
544 | + cgitb.html(i18n=self.translator) | |
545 | + ("<h1>Environment Variables</h1><table>%s</table>" | |
546 | % cgitb.niceDict("", self.env))) | |
547 | if not self.instance.config.WEB_DEBUG: | |
548 | exc_info = sys.exc_info() | |
549 | subject = "Error: %s" % exc_info[1] | |
550 | self.send_html_to_admin(subject, html) | |
551 | self.write_html(self._(error_message)) | |
552 | else: | |
553 | self.write_html(html) | |
554 | ||
555 | def clean_sessions(self): | |
556 | """Deprecated | |
557 | XXX remove | |
558 | """ | |
559 | self.clean_up() | |
560 | ||
561 | def clean_up(self): | |
562 | """Remove expired sessions and One Time Keys. | |
563 | ||
564 | Do it only once an hour. | |
565 | """ | |
566 | hour = 60*60 | |
567 | now = time.time() | |
568 | ||
569 | # XXX: hack - use OTK table to store last_clean time information | |
570 | # 'last_clean' string is used instead of otk key | |
571 | last_clean = self.db.getOTKManager().get('last_clean', 'last_use', 0) | |
572 | if now - last_clean < hour: | |
573 | return | |
574 | ||
575 | self.session_api.clean_up() | |
576 | self.db.getOTKManager().clean() | |
577 | self.db.getOTKManager().set('last_clean', last_use=now) | |
578 | self.db.commit(fail_ok=True) | |
579 | ||
580 | def determine_charset(self): | |
581 | """Look for client charset in the form parameters or browser cookie. | |
582 | ||
583 | If no charset requested by client, use storage charset (utf-8). | |
584 | ||
585 | If the charset is found, and differs from the storage charset, | |
586 | recode all form fields of type 'text/plain' | |
587 | """ | |
588 | # look for client charset | |
589 | charset_parameter = 0 | |
590 | if '@charset' in self.form: | |
591 | charset = self.form['@charset'].value | |
592 | if charset.lower() == "none": | |
593 | charset = "" | |
594 | charset_parameter = 1 | |
595 | elif 'roundup_charset' in self.cookie: | |
596 | charset = self.cookie['roundup_charset'].value | |
597 | else: | |
598 | charset = None | |
599 | if charset: | |
600 | # make sure the charset is recognized | |
601 | try: | |
602 | codecs.lookup(charset) | |
603 | except LookupError: | |
604 | self.error_message.append(self._('Unrecognized charset: %r') | |
605 | % charset) | |
606 | charset_parameter = 0 | |
607 | else: | |
608 | self.charset = charset.lower() | |
609 | # If we've got a character set in request parameters, | |
610 | # set the browser cookie to keep the preference. | |
611 | # This is done after codecs.lookup to make sure | |
612 | # that we aren't keeping a wrong value. | |
613 | if charset_parameter: | |
614 | self.add_cookie('roundup_charset', charset) | |
615 | ||
616 | # if client charset is different from the storage charset, | |
617 | # recode form fields | |
618 | # XXX this requires FieldStorage from Python library. | |
619 | # mod_python FieldStorage is not supported! | |
620 | if self.charset != self.STORAGE_CHARSET: | |
621 | decoder = codecs.getdecoder(self.charset) | |
622 | encoder = codecs.getencoder(self.STORAGE_CHARSET) | |
623 | re_charref = re.compile('&#([0-9]+|x[0-9a-f]+);', re.IGNORECASE) | |
624 | def _decode_charref(matchobj): | |
625 | num = matchobj.group(1) | |
626 | if num[0].lower() == 'x': | |
627 | uc = int(num[1:], 16) | |
628 | else: | |
629 | uc = int(num) | |
630 | return unichr(uc) | |
631 | ||
632 | for field_name in self.form: | |
633 | field = self.form[field_name] | |
634 | if (field.type == 'text/plain') and not field.filename: | |
635 | try: | |
636 | value = decoder(field.value)[0] | |
637 | except UnicodeError: | |
638 | continue | |
639 | value = re_charref.sub(_decode_charref, value) | |
640 | field.value = encoder(value)[0] | |
641 | ||
642 | def determine_language(self): | |
643 | """Determine the language""" | |
644 | # look for language parameter | |
645 | # then for language cookie | |
646 | # last for the Accept-Language header | |
647 | if "@language" in self.form: | |
648 | language = self.form["@language"].value | |
649 | if language.lower() == "none": | |
650 | language = "" | |
651 | self.add_cookie("roundup_language", language) | |
652 | elif "roundup_language" in self.cookie: | |
653 | language = self.cookie["roundup_language"].value | |
654 | elif self.instance.config["WEB_USE_BROWSER_LANGUAGE"]: | |
655 | hal = self.env.get('HTTP_ACCEPT_LANGUAGE') | |
656 | language = accept_language.parse(hal) | |
657 | else: | |
658 | language = "" | |
659 | ||
660 | self.language = language | |
661 | if language: | |
662 | self.setTranslator(TranslationService.get_translation( | |
663 | language, | |
664 | tracker_home=self.instance.config["TRACKER_HOME"])) | |
665 | ||
666 | def determine_user(self): | |
667 | """Determine who the user is""" | |
668 | self.opendb('admin') | |
669 | ||
670 | # get session data from db | |
671 | # XXX: rename | |
672 | self.session_api = Session(self) | |
673 | ||
674 | # take the opportunity to cleanup expired sessions and otks | |
675 | self.clean_up() | |
676 | ||
677 | user = None | |
678 | # first up, try http authorization if enabled | |
679 | if self.instance.config['WEB_HTTP_AUTH']: | |
680 | if 'REMOTE_USER' in self.env: | |
681 | # we have external auth (e.g. by Apache) | |
682 | user = self.env['REMOTE_USER'] | |
683 | elif self.env.get('HTTP_AUTHORIZATION', ''): | |
684 | # try handling Basic Auth ourselves | |
685 | auth = self.env['HTTP_AUTHORIZATION'] | |
686 | scheme, challenge = auth.split(' ', 1) | |
687 | if scheme.lower() == 'basic': | |
688 | try: | |
689 | decoded = base64.decodestring(challenge) | |
690 | except TypeError: | |
691 | # invalid challenge | |
692 | pass | |
693 | username, password = decoded.split(':') | |
694 | try: | |
695 | login = self.get_action_class('login')(self) | |
696 | login.verifyLogin(username, password) | |
697 | except LoginError, err: | |
698 | self.make_user_anonymous() | |
699 | raise | |
700 | user = username | |
701 | ||
702 | # if user was not set by http authorization, try session lookup | |
703 | if not user: | |
704 | user = self.session_api.get('user') | |
705 | if user: | |
706 | # update session lifetime datestamp | |
707 | self.session_api.update() | |
708 | ||
709 | # if no user name set by http authorization or session lookup | |
710 | # the user is anonymous | |
711 | if not user: | |
712 | user = 'anonymous' | |
713 | ||
714 | # sanity check on the user still being valid, | |
715 | # getting the userid at the same time | |
716 | try: | |
717 | self.userid = self.db.user.lookup(user) | |
718 | except (KeyError, TypeError): | |
719 | user = 'anonymous' | |
720 | ||
721 | # make sure the anonymous user is valid if we're using it | |
722 | if user == 'anonymous': | |
723 | self.make_user_anonymous() | |
724 | else: | |
725 | self.user = user | |
726 | ||
727 | # reopen the database as the correct user | |
728 | self.opendb(self.user) | |
729 | ||
730 | def check_anonymous_access(self): | |
731 | """Check that the Anonymous user is actually allowed to use the web | |
732 | interface and short-circuit all further processing if they're not. | |
733 | """ | |
734 | # allow Anonymous to use the "login" and "register" actions (noting | |
735 | # that "register" has its own "Register" permission check) | |
736 | ||
737 | if ':action' in self.form: | |
738 | action = self.form[':action'] | |
739 | elif '@action' in self.form: | |
740 | action = self.form['@action'] | |
741 | else: | |
742 | action = '' | |
743 | if isinstance(action, list): | |
744 | raise SeriousError('broken form: multiple @action values submitted') | |
745 | elif action: | |
746 | action = action.value.lower() | |
747 | if action in ('login', 'register'): | |
748 | return | |
749 | ||
750 | # allow Anonymous to view the "user" "register" template if they're | |
751 | # allowed to register | |
752 | if (self.db.security.hasPermission('Register', self.userid, 'user') | |
753 | and self.classname == 'user' and self.template == 'register'): | |
754 | return | |
755 | ||
756 | # otherwise for everything else | |
757 | if self.user == 'anonymous': | |
758 | if not self.db.security.hasPermission('Web Access', self.userid): | |
759 | raise Unauthorised(self._("Anonymous users are not " | |
760 | "allowed to use the web interface")) | |
761 | ||
762 | def opendb(self, username): | |
763 | """Open the database and set the current user. | |
764 | ||
765 | Opens a database once. On subsequent calls only the user is set on | |
766 | the database object the instance.optimize is set. If we are in | |
767 | "Development Mode" (cf. roundup_server) then the database is always | |
768 | re-opened. | |
769 | """ | |
770 | # don't do anything if the db is open and the user has not changed | |
771 | if hasattr(self, 'db') and self.db.isCurrentUser(username): | |
772 | return | |
773 | ||
774 | # open the database or only set the user | |
775 | if not hasattr(self, 'db'): | |
776 | self.db = self.instance.open(username) | |
777 | else: | |
778 | if self.instance.optimize: | |
779 | self.db.setCurrentUser(username) | |
780 | else: | |
781 | self.db.close() | |
782 | self.db = self.instance.open(username) | |
783 | # The old session API refers to the closed database; | |
784 | # we can no longer use it. | |
785 | self.session_api = Session(self) | |
786 | ||
787 | ||
788 | def determine_context(self, dre=re.compile(r'([^\d]+)0*(\d+)')): | |
789 | """Determine the context of this page from the URL: | |
790 | ||
791 | The URL path after the instance identifier is examined. The path | |
792 | is generally only one entry long. | |
793 | ||
794 | - if there is no path, then we are in the "home" context. | |
795 | - if the path is "_file", then the additional path entry | |
796 | specifies the filename of a static file we're to serve up | |
797 | from the instance "html" directory. Raises a SendStaticFile | |
798 | exception.(*) | |
799 | - if there is something in the path (eg "issue"), it identifies | |
800 | the tracker class we're to display. | |
801 | - if the path is an item designator (eg "issue123"), then we're | |
802 | to display a specific item. | |
803 | - if the path starts with an item designator and is longer than | |
804 | one entry, then we're assumed to be handling an item of a | |
805 | FileClass, and the extra path information gives the filename | |
806 | that the client is going to label the download with (ie | |
807 | "file123/image.png" is nicer to download than "file123"). This | |
808 | raises a SendFile exception.(*) | |
809 | ||
810 | Both of the "*" types of contexts stop before we bother to | |
811 | determine the template we're going to use. That's because they | |
812 | don't actually use templates. | |
813 | ||
814 | The template used is specified by the :template CGI variable, | |
815 | which defaults to: | |
816 | ||
817 | - only classname suplied: "index" | |
818 | - full item designator supplied: "item" | |
819 | ||
820 | We set: | |
821 | ||
822 | self.classname - the class to display, can be None | |
823 | ||
824 | self.template - the template to render the current context with | |
825 | ||
826 | self.nodeid - the nodeid of the class we're displaying | |
827 | """ | |
828 | # default the optional variables | |
829 | self.classname = None | |
830 | self.nodeid = None | |
831 | ||
832 | # see if a template or messages are specified | |
833 | template_override = ok_message = error_message = None | |
834 | for key in self.form: | |
835 | if self.FV_TEMPLATE.match(key): | |
836 | template_override = self.form[key].value | |
837 | elif self.FV_OK_MESSAGE.match(key): | |
838 | ok_message = self.form[key].value | |
839 | ok_message = clean_message(ok_message) | |
840 | elif self.FV_ERROR_MESSAGE.match(key): | |
841 | error_message = self.form[key].value | |
842 | error_message = clean_message(error_message) | |
843 | ||
844 | # see if we were passed in a message | |
845 | if ok_message: | |
846 | self.ok_message.append(ok_message) | |
847 | if error_message: | |
848 | self.error_message.append(error_message) | |
849 | ||
850 | # determine the classname and possibly nodeid | |
851 | path = self.path.split('/') | |
852 | if not path or path[0] in ('', 'home', 'index'): | |
853 | if template_override is not None: | |
854 | self.template = template_override | |
855 | else: | |
856 | self.template = '' | |
857 | return | |
858 | elif path[0] in ('_file', '@@file'): | |
859 | raise SendStaticFile(os.path.join(*path[1:])) | |
860 | else: | |
861 | self.classname = path[0] | |
862 | if len(path) > 1: | |
863 | # send the file identified by the designator in path[0] | |
864 | raise SendFile(path[0]) | |
865 | ||
866 | # see if we got a designator | |
867 | m = dre.match(self.classname) | |
868 | if m: | |
869 | self.classname = m.group(1) | |
870 | self.nodeid = m.group(2) | |
871 | try: | |
872 | klass = self.db.getclass(self.classname) | |
873 | except KeyError: | |
874 | raise NotFound('%s/%s'%(self.classname, self.nodeid)) | |
875 | if not klass.hasnode(self.nodeid): | |
876 | raise NotFound('%s/%s'%(self.classname, self.nodeid)) | |
877 | # with a designator, we default to item view | |
878 | self.template = 'item' | |
879 | else: | |
880 | # with only a class, we default to index view | |
881 | self.template = 'index' | |
882 | ||
883 | # make sure the classname is valid | |
884 | try: | |
885 | self.db.getclass(self.classname) | |
886 | except KeyError: | |
887 | raise NotFound(self.classname) | |
888 | ||
889 | # see if we have a template override | |
890 | if template_override is not None: | |
891 | self.template = template_override | |
892 | ||
893 | def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')): | |
894 | """ Serve the file from the content property of the designated item. | |
895 | """ | |
896 | m = dre.match(str(designator)) | |
897 | if not m: | |
898 | raise NotFound(str(designator)) | |
899 | classname, nodeid = m.group(1), m.group(2) | |
900 | ||
901 | try: | |
902 | klass = self.db.getclass(classname) | |
903 | except KeyError: | |
904 | # The classname was not valid. | |
905 | raise NotFound(str(designator)) | |
906 | ||
907 | # perform the Anonymous user access check | |
908 | self.check_anonymous_access() | |
909 | ||
910 | # make sure we have the appropriate properties | |
911 | props = klass.getprops() | |
912 | if 'type' not in props: | |
913 | raise NotFound(designator) | |
914 | if 'content' not in props: | |
915 | raise NotFound(designator) | |
916 | ||
917 | # make sure we have permission | |
918 | if not self.db.security.hasPermission('View', self.userid, | |
919 | classname, 'content', nodeid): | |
920 | raise Unauthorised(self._("You are not allowed to view " | |
921 | "this file.")) | |
922 | ||
923 | mime_type = klass.get(nodeid, 'type') | |
924 | # Can happen for msg class: | |
925 | if not mime_type: | |
926 | mime_type = 'text/plain' | |
927 | ||
928 | # if the mime_type is HTML-ish then make sure we're allowed to serve up | |
929 | # HTML-ish content | |
930 | if mime_type in ('text/html', 'text/x-html'): | |
931 | if not self.instance.config['WEB_ALLOW_HTML_FILE']: | |
932 | # do NOT serve the content up as HTML | |
933 | mime_type = 'application/octet-stream' | |
934 | ||
935 | # If this object is a file (i.e., an instance of FileClass), | |
936 | # see if we can find it in the filesystem. If so, we may be | |
937 | # able to use the more-efficient request.sendfile method of | |
938 | # sending the file. If not, just get the "content" property | |
939 | # in the usual way, and use that. | |
940 | content = None | |
941 | filename = None | |
942 | if isinstance(klass, hyperdb.FileClass): | |
943 | try: | |
944 | filename = self.db.filename(classname, nodeid) | |
945 | except AttributeError: | |
946 | # The database doesn't store files in the filesystem | |
947 | # and therefore doesn't provide the "filename" method. | |
948 | pass | |
949 | except IOError: | |
950 | # The file does not exist. | |
951 | pass | |
952 | if not filename: | |
953 | content = klass.get(nodeid, 'content') | |
954 | ||
955 | lmt = klass.get(nodeid, 'activity').timestamp() | |
956 | ||
957 | self._serve_file(lmt, mime_type, content, filename) | |
958 | ||
959 | def serve_static_file(self, file): | |
960 | """ Serve up the file named from the templates dir | |
961 | """ | |
962 | # figure the filename - try STATIC_FILES, then TEMPLATES dir | |
963 | for dir_option in ('STATIC_FILES', 'TEMPLATES'): | |
964 | prefix = self.instance.config[dir_option] | |
965 | if not prefix: | |
966 | continue | |
967 | # ensure the load doesn't try to poke outside | |
968 | # of the static files directory | |
969 | prefix = os.path.normpath(prefix) | |
970 | filename = os.path.normpath(os.path.join(prefix, file)) | |
971 | if os.path.isfile(filename) and filename.startswith(prefix): | |
972 | break | |
973 | else: | |
974 | raise NotFound(file) | |
975 | ||
976 | # last-modified time | |
977 | lmt = os.stat(filename)[stat.ST_MTIME] | |
978 | ||
979 | # detemine meta-type | |
980 | file = str(file) | |
981 | mime_type = mimetypes.guess_type(file)[0] | |
982 | if not mime_type: | |
983 | if file.endswith('.css'): | |
984 | mime_type = 'text/css' | |
985 | else: | |
986 | mime_type = 'text/plain' | |
987 | ||
988 | self._serve_file(lmt, mime_type, '', filename) | |
989 | ||
990 | def _serve_file(self, lmt, mime_type, content=None, filename=None): | |
991 | """ guts of serve_file() and serve_static_file() | |
992 | """ | |
993 | ||
994 | # spit out headers | |
995 | self.additional_headers['Content-Type'] = mime_type | |
996 | self.additional_headers['Last-Modified'] = rfc822.formatdate(lmt) | |
997 | ||
998 | ims = None | |
999 | # see if there's an if-modified-since... | |
1000 | # XXX see which interfaces set this | |
1001 | #if hasattr(self.request, 'headers'): | |
1002 | #ims = self.request.headers.getheader('if-modified-since') | |
1003 | if 'HTTP_IF_MODIFIED_SINCE' in self.env: | |
1004 | # cgi will put the header in the env var | |
1005 | ims = self.env['HTTP_IF_MODIFIED_SINCE'] | |
1006 | if ims: | |
1007 | ims = rfc822.parsedate(ims)[:6] | |
1008 | lmtt = time.gmtime(lmt)[:6] | |
1009 | if lmtt <= ims: | |
1010 | raise NotModified | |
1011 | ||
1012 | if filename: | |
1013 | self.write_file(filename) | |
1014 | else: | |
1015 | self.additional_headers['Content-Length'] = str(len(content)) | |
1016 | self.write(content) | |
1017 | ||
1018 | def send_html_to_admin(self, subject, content): | |
1019 | ||
1020 | to = [self.mailer.config.ADMIN_EMAIL] | |
1021 | message = self.mailer.get_standard_message(to, subject) | |
1022 | # delete existing content-type headers | |
1023 | del message['Content-type'] | |
1024 | message['Content-type'] = 'text/html; charset=utf-8' | |
1025 | message.set_payload(content) | |
1026 | encode_quopri(message) | |
1027 | self.mailer.smtp_send(to, str(message)) | |
1028 | ||
1029 | def renderFrontPage(self, message): | |
1030 | """Return the front page of the tracker.""" | |
1031 | ||
1032 | self.classname = self.nodeid = None | |
1033 | self.template = '' | |
1034 | self.error_message.append(message) | |
1035 | self.write_html(self.renderContext()) | |
1036 | ||
1037 | def renderContext(self): | |
1038 | """ Return a PageTemplate for the named page | |
1039 | """ | |
1040 | name = self.classname | |
1041 | extension = self.template | |
1042 | ||
1043 | # catch errors so we can handle PT rendering errors more nicely | |
1044 | args = { | |
1045 | 'ok_message': self.ok_message, | |
1046 | 'error_message': self.error_message | |
1047 | } | |
1048 | try: | |
1049 | pt = self.instance.templates.get(name, extension) | |
1050 | # let the template render figure stuff out | |
1051 | result = pt.render(self, None, None, **args) | |
1052 | self.additional_headers['Content-Type'] = pt.content_type | |
1053 | if self.env.get('CGI_SHOW_TIMING', ''): | |
1054 | if self.env['CGI_SHOW_TIMING'].upper() == 'COMMENT': | |
1055 | timings = {'starttag': '<!-- ', 'endtag': ' -->'} | |
1056 | else: | |
1057 | timings = {'starttag': '<p>', 'endtag': '</p>'} | |
1058 | timings['seconds'] = time.time()-self.start | |
1059 | s = self._('%(starttag)sTime elapsed: %(seconds)fs%(endtag)s\n' | |
1060 | ) % timings | |
1061 | if hasattr(self.db, 'stats'): | |
1062 | timings.update(self.db.stats) | |
1063 | s += self._("%(starttag)sCache hits: %(cache_hits)d," | |
1064 | " misses %(cache_misses)d." | |
1065 | " Loading items: %(get_items)f secs." | |
1066 | " Filtering: %(filtering)f secs." | |
1067 | "%(endtag)s\n") % timings | |
1068 | s += '</body>' | |
1069 | result = result.replace('</body>', s) | |
1070 | return result | |
1071 | except templating.NoTemplate, message: | |
1072 | return '<strong>%s</strong>'%message | |
1073 | except templating.Unauthorised, message: | |
1074 | raise Unauthorised(str(message)) | |
1075 | except: | |
1076 | # everything else | |
1077 | if self.instance.config.WEB_DEBUG: | |
1078 | return cgitb.pt_html(i18n=self.translator) | |
1079 | exc_info = sys.exc_info() | |
1080 | try: | |
1081 | # If possible, send the HTML page template traceback | |
1082 | # to the administrator. | |
1083 | subject = "Templating Error: %s" % exc_info[1] | |
1084 | self.send_html_to_admin(subject, cgitb.pt_html()) | |
1085 | # Now report the error to the user. | |
1086 | return self._(error_message) | |
1087 | except: | |
1088 | # Reraise the original exception. The user will | |
1089 | # receive an error message, and the adminstrator will | |
1090 | # receive a traceback, albeit with less information | |
1091 | # than the one we tried to generate above. | |
1092 | raise exc_info[0](exc_info[1]).with_traceback(exc_info[2]) | |
1093 | ||
1094 | # these are the actions that are available | |
1095 | actions = ( | |
1096 | ('edit', EditItemAction), | |
1097 | ('editcsv', EditCSVAction), | |
1098 | ('new', NewItemAction), | |
1099 | ('register', RegisterAction), | |
1100 | ('confrego', ConfRegoAction), | |
1101 | ('passrst', PassResetAction), | |
1102 | ('login', LoginAction), | |
1103 | ('logout', LogoutAction), | |
1104 | ('search', SearchAction), | |
1105 | ('retire', RetireAction), | |
1106 | ('show', ShowAction), | |
1107 | ('export_csv', ExportCSVAction), | |
1108 | ) | |
1109 | def handle_action(self): | |
1110 | """ Determine whether there should be an Action called. | |
1111 | ||
1112 | The action is defined by the form variable :action which | |
1113 | identifies the method on this object to call. The actions | |
1114 | are defined in the "actions" sequence on this class. | |
1115 | ||
1116 | Actions may return a page (by default HTML) to return to the | |
1117 | user, bypassing the usual template rendering. | |
1118 | ||
1119 | We explicitly catch Reject and ValueError exceptions and | |
1120 | present their messages to the user. | |
1121 | """ | |
1122 | if ':action' in self.form: | |
1123 | action = self.form[':action'] | |
1124 | elif '@action' in self.form: | |
1125 | action = self.form['@action'] | |
1126 | else: | |
1127 | return None | |
1128 | ||
1129 | if isinstance(action, list): | |
1130 | raise SeriousError('broken form: multiple @action values submitted') | |
1131 | else: | |
1132 | action = action.value.lower() | |
1133 | ||
1134 | try: | |
1135 | action_klass = self.get_action_class(action) | |
1136 | ||
1137 | # call the mapped action | |
1138 | if isinstance(action_klass, type('')): | |
1139 | # old way of specifying actions | |
1140 | return getattr(self, action_klass)() | |
1141 | else: | |
1142 | return action_klass(self).execute() | |
1143 | ||
1144 | except (ValueError, Reject), err: | |
1145 | self.error_message.append(str(err)) | |
1146 | ||
1147 | def get_action_class(self, action_name): | |
1148 | if (hasattr(self.instance, 'cgi_actions') and | |
1149 | action_name in self.instance.cgi_actions): | |
1150 | # tracker-defined action | |
1151 | action_klass = self.instance.cgi_actions[action_name] | |
1152 | else: | |
1153 | # go with a default | |
1154 | for name, action_klass in self.actions: | |
1155 | if name == action_name: | |
1156 | break | |
1157 | else: | |
1158 | raise ValueError('No such action "%s"'%action_name) | |
1159 | return action_klass | |
1160 | ||
1161 | def _socket_op(self, call, *args, **kwargs): | |
1162 | """Execute socket-related operation, catch common network errors | |
1163 | ||
1164 | Parameters: | |
1165 | call: a callable to execute | |
1166 | args, kwargs: call arguments | |
1167 | ||
1168 | """ | |
1169 | try: | |
1170 | call(*args, **kwargs) | |
1171 | except socket.error, err: | |
1172 | err_errno = getattr (err, 'errno', None) | |
1173 | if err_errno is None: | |
1174 | try: | |
1175 | err_errno = err[0] | |
1176 | except TypeError: | |
1177 | pass | |
1178 | if err_errno not in self.IGNORE_NET_ERRORS: | |
1179 | raise | |
1180 | except IOError: | |
1181 | # Apache's mod_python will raise IOError -- without an | |
1182 | # accompanying errno -- when a write to the client fails. | |
1183 | # A common case is that the client has closed the | |
1184 | # connection. There's no way to be certain that this is | |
1185 | # the situation that has occurred here, but that is the | |
1186 | # most likely case. | |
1187 | pass | |
1188 | ||
1189 | def write(self, content): | |
1190 | if not self.headers_done: | |
1191 | self.header() | |
1192 | if self.env['REQUEST_METHOD'] != 'HEAD': | |
1193 | self._socket_op(self.request.wfile.write, content) | |
1194 | ||
1195 | def write_html(self, content): | |
1196 | if not self.headers_done: | |
1197 | # at this point, we are sure about Content-Type | |
1198 | if 'Content-Type' not in self.additional_headers: | |
1199 | self.additional_headers['Content-Type'] = \ | |
1200 | 'text/html; charset=%s' % self.charset | |
1201 | self.header() | |
1202 | ||
1203 | if self.env['REQUEST_METHOD'] == 'HEAD': | |
1204 | # client doesn't care about content | |
1205 | return | |
1206 | ||
1207 | if self.charset != self.STORAGE_CHARSET: | |
1208 | # recode output | |
1209 | content = content.decode(self.STORAGE_CHARSET, 'replace') | |
1210 | content = content.encode(self.charset, 'xmlcharrefreplace') | |
1211 | ||
1212 | # and write | |
1213 | self._socket_op(self.request.wfile.write, content) | |
1214 | ||
1215 | def http_strip(self, content): | |
1216 | """Remove HTTP Linear White Space from 'content'. | |
1217 | ||
1218 | 'content' -- A string. | |
1219 | ||
1220 | returns -- 'content', with all leading and trailing LWS | |
1221 | removed.""" | |
1222 | ||
1223 | # RFC 2616 2.2: Basic Rules | |
1224 | # | |
1225 | # LWS = [CRLF] 1*( SP | HT ) | |
1226 | return content.strip(" \r\n\t") | |
1227 | ||
1228 | def http_split(self, content): | |
1229 | """Split an HTTP list. | |
1230 | ||
1231 | 'content' -- A string, giving a list of items. | |
1232 | ||
1233 | returns -- A sequence of strings, containing the elements of | |
1234 | the list.""" | |
1235 | ||
1236 | # RFC 2616 2.1: Augmented BNF | |
1237 | # | |
1238 | # Grammar productions of the form "#rule" indicate a | |
1239 | # comma-separated list of elements matching "rule". LWS | |
1240 | # is then removed from each element, and empty elements | |
1241 | # removed. | |
1242 | ||
1243 | # Split at commas. | |
1244 | elements = content.split(",") | |
1245 | # Remove linear whitespace at either end of the string. | |
1246 | elements = [self.http_strip(e) for e in elements] | |
1247 | # Remove any now-empty elements. | |
1248 | return [e for e in elements if e] | |
1249 | ||
1250 | def handle_range_header(self, length, etag): | |
1251 | """Handle the 'Range' and 'If-Range' headers. | |
1252 | ||
1253 | 'length' -- the length of the content available for the | |
1254 | resource. | |
1255 | ||
1256 | 'etag' -- the entity tag for this resources. | |
1257 | ||
1258 | returns -- If the request headers (including 'Range' and | |
1259 | 'If-Range') indicate that only a portion of the entity should | |
1260 | be returned, then the return value is a pair '(offfset, | |
1261 | length)' indicating the first byte and number of bytes of the | |
1262 | content that should be returned to the client. In addition, | |
1263 | this method will set 'self.response_code' to indicate Partial | |
1264 | Content. In all other cases, the return value is 'None'. If | |
1265 | appropriate, 'self.response_code' will be | |
1266 | set to indicate 'REQUESTED_RANGE_NOT_SATISFIABLE'. In that | |
1267 | case, the caller should not send any data to the client.""" | |
1268 | ||
1269 | # RFC 2616 14.35: Range | |
1270 | # | |
1271 | # See if the Range header is present. | |
1272 | ranges_specifier = self.env.get("HTTP_RANGE") | |
1273 | if ranges_specifier is None: | |
1274 | return None | |
1275 | # RFC 2616 14.27: If-Range | |
1276 | # | |
1277 | # Check to see if there is an If-Range header. | |
1278 | # Because the specification says: | |
1279 | # | |
1280 | # The If-Range header ... MUST be ignored if the request | |
1281 | # does not include a Range header, we check for If-Range | |
1282 | # after checking for Range. | |
1283 | if_range = self.env.get("HTTP_IF_RANGE") | |
1284 | if if_range: | |
1285 | # The grammar for the If-Range header is: | |
1286 | # | |
1287 | # If-Range = "If-Range" ":" ( entity-tag | HTTP-date ) | |
1288 | # entity-tag = [ weak ] opaque-tag | |
1289 | # weak = "W/" | |
1290 | # opaque-tag = quoted-string | |
1291 | # | |
1292 | # We only support strong entity tags. | |
1293 | if_range = self.http_strip(if_range) | |
1294 | if (not if_range.startswith('"') | |
1295 | or not if_range.endswith('"')): | |
1296 | return None | |
1297 | # If the condition doesn't match the entity tag, then we | |
1298 | # must send the client the entire file. | |
1299 | if if_range != etag: | |
1300 | return | |
1301 | # The grammar for the Range header value is: | |
1302 | # | |
1303 | # ranges-specifier = byte-ranges-specifier | |
1304 | # byte-ranges-specifier = bytes-unit "=" byte-range-set | |
1305 | # byte-range-set = 1#( byte-range-spec | suffix-byte-range-spec ) | |
1306 | # byte-range-spec = first-byte-pos "-" [last-byte-pos] | |
1307 | # first-byte-pos = 1*DIGIT | |
1308 | # last-byte-pos = 1*DIGIT | |
1309 | # suffix-byte-range-spec = "-" suffix-length | |
1310 | # suffix-length = 1*DIGIT | |
1311 | # | |
1312 | # Look for the "=" separating the units from the range set. | |
1313 | specs = ranges_specifier.split("=", 1) | |
1314 | if len(specs) != 2: | |
1315 | return None | |
1316 | # Check that the bytes-unit is in fact "bytes". If it is not, | |
1317 | # we do not know how to process this range. | |
1318 | bytes_unit = self.http_strip(specs[0]) | |
1319 | if bytes_unit != "bytes": | |
1320 | return None | |
1321 | # Seperate the range-set into range-specs. | |
1322 | byte_range_set = self.http_strip(specs[1]) | |
1323 | byte_range_specs = self.http_split(byte_range_set) | |
1324 | # We only handle exactly one range at this time. | |
1325 | if len(byte_range_specs) != 1: | |
1326 | return None | |
1327 | # Parse the spec. | |
1328 | byte_range_spec = byte_range_specs[0] | |
1329 | pos = byte_range_spec.split("-", 1) | |
1330 | if len(pos) != 2: | |
1331 | return None | |
1332 | # Get the first and last bytes. | |
1333 | first = self.http_strip(pos[0]) | |
1334 | last = self.http_strip(pos[1]) | |
1335 | # We do not handle suffix ranges. | |
1336 | if not first: | |
1337 | return None | |
1338 | # Convert the first and last positions to integers. | |
1339 | try: | |
1340 | first = int(first) | |
1341 | if last: | |
1342 | last = int(last) | |
1343 | else: | |
1344 | last = length - 1 | |
1345 | except: | |
1346 | # The positions could not be parsed as integers. | |
1347 | return None | |
1348 | # Check that the range makes sense. | |
1349 | if (first < 0 or last < 0 or last < first): | |
1350 | return None | |
1351 | if last >= length: | |
1352 | # RFC 2616 10.4.17: 416 Requested Range Not Satisfiable | |
1353 | # | |
1354 | # If there is an If-Range header, RFC 2616 says that we | |
1355 | # should just ignore the invalid Range header. | |
1356 | if if_range: | |
1357 | return None | |
1358 | # Return code 416 with a Content-Range header giving the | |
1359 | # allowable range. | |
1360 | self.response_code = http_.client.REQUESTED_RANGE_NOT_SATISFIABLE | |
1361 | self.setHeader("Content-Range", "bytes */%d" % length) | |
1362 | return None | |
1363 | # RFC 2616 10.2.7: 206 Partial Content | |
1364 | # | |
1365 | # Tell the client that we are honoring the Range request by | |
1366 | # indicating that we are providing partial content. | |
1367 | self.response_code = http_.client.PARTIAL_CONTENT | |
1368 | # RFC 2616 14.16: Content-Range | |
1369 | # | |
1370 | # Tell the client what data we are providing. | |
1371 | # | |
1372 | # content-range-spec = byte-content-range-spec | |
1373 | # byte-content-range-spec = bytes-unit SP | |
1374 | # byte-range-resp-spec "/" | |
1375 | # ( instance-length | "*" ) | |
1376 | # byte-range-resp-spec = (first-byte-pos "-" last-byte-pos) | |
1377 | # | "*" | |
1378 | # instance-length = 1 * DIGIT | |
1379 | self.setHeader("Content-Range", | |
1380 | "bytes %d-%d/%d" % (first, last, length)) | |
1381 | return (first, last - first + 1) | |
1382 | ||
1383 | def write_file(self, filename): | |
1384 | """Send the contents of 'filename' to the user.""" | |
1385 | ||
1386 | # Determine the length of the file. | |
1387 | stat_info = os.stat(filename) | |
1388 | length = stat_info[stat.ST_SIZE] | |
1389 | # Assume we will return the entire file. | |
1390 | offset = 0 | |
1391 | # If the headers have not already been finalized, | |
1392 | if not self.headers_done: | |
1393 | # RFC 2616 14.19: ETag | |
1394 | # | |
1395 | # Compute the entity tag, in a format similar to that | |
1396 | # used by Apache. | |
1397 | etag = '"%x-%x-%x"' % (stat_info[stat.ST_INO], | |
1398 | length, | |
1399 | stat_info[stat.ST_MTIME]) | |
1400 | self.setHeader("ETag", etag) | |
1401 | # RFC 2616 14.5: Accept-Ranges | |
1402 | # | |
1403 | # Let the client know that we will accept range requests. | |
1404 | self.setHeader("Accept-Ranges", "bytes") | |
1405 | # RFC 2616 14.35: Range | |
1406 | # | |
1407 | # If there is a Range header, we may be able to avoid | |
1408 | # sending the entire file. | |
1409 | content_range = self.handle_range_header(length, etag) | |
1410 | if content_range: | |
1411 | offset, length = content_range | |
1412 | # RFC 2616 14.13: Content-Length | |
1413 | # | |
1414 | # Tell the client how much data we are providing. | |
1415 | self.setHeader("Content-Length", str(length)) | |
1416 | # Send the HTTP header. | |
1417 | self.header() | |
1418 | # If the client doesn't actually want the body, or if we are | |
1419 | # indicating an invalid range. | |
1420 | if (self.env['REQUEST_METHOD'] == 'HEAD' | |
1421 | or self.response_code == http_.client.REQUESTED_RANGE_NOT_SATISFIABLE): | |
1422 | return | |
1423 | # Use the optimized "sendfile" operation, if possible. | |
1424 | if hasattr(self.request, "sendfile"): | |
1425 | self._socket_op(self.request.sendfile, filename, offset, length) | |
1426 | return | |
1427 | # Fallback to the "write" operation. | |
1428 | f = open(filename, 'rb') | |
1429 | try: | |
1430 | if offset: | |
1431 | f.seek(offset) | |
1432 | content = f.read(length) | |
1433 | finally: | |
1434 | f.close() | |
1435 | self.write(content) | |
1436 | ||
1437 | def setHeader(self, header, value): | |
1438 | """Override a header to be returned to the user's browser. | |
1439 | """ | |
1440 | self.additional_headers[header] = value | |
1441 | ||
1442 | def header(self, headers=None, response=None): | |
1443 | """Put up the appropriate header. | |
1444 | """ | |
1445 | if headers is None: | |
1446 | headers = {'Content-Type':'text/html; charset=utf-8'} | |
1447 | if response is None: | |
1448 | response = self.response_code | |
1449 | ||
1450 | # update with additional info | |
1451 | headers.update(self.additional_headers) | |
1452 | ||
1453 | if headers.get('Content-Type', 'text/html') == 'text/html': | |
1454 | headers['Content-Type'] = 'text/html; charset=utf-8' | |
1455 | ||
1456 | headers = list(headers.items()) | |
1457 | ||
1458 | for ((path, name), (value, expire)) in self._cookies.iteritems(): | |
1459 | cookie = "%s=%s; Path=%s;"%(name, value, path) | |
1460 | if expire is not None: | |
1461 | cookie += " expires=%s;"%get_cookie_date(expire) | |
1462 | headers.append(('Set-Cookie', cookie)) | |
1463 | ||
1464 | self._socket_op(self.request.start_response, headers, response) | |
1465 | ||
1466 | self.headers_done = 1 | |
1467 | if self.debug: | |
1468 | self.headers_sent = headers | |
1469 | ||
1470 | def add_cookie(self, name, value, expire=86400*365, path=None): | |
1471 | """Set a cookie value to be sent in HTTP headers | |
1472 | ||
1473 | Parameters: | |
1474 | name: | |
1475 | cookie name | |
1476 | value: | |
1477 | cookie value | |
1478 | expire: | |
1479 | cookie expiration time (seconds). | |
1480 | If value is empty (meaning "delete cookie"), | |
1481 | expiration time is forced in the past | |
1482 | and this argument is ignored. | |
1483 | If None, the cookie will expire at end-of-session. | |
1484 | If omitted, the cookie will be kept for a year. | |
1485 | path: | |
1486 | cookie path (optional) | |
1487 | ||
1488 | """ | |
1489 | if path is None: | |
1490 | path = self.cookie_path | |
1491 | if not value: | |
1492 | expire = -1 | |
1493 | self._cookies[(path, name)] = (value, expire) | |
1494 | ||
1495 | def set_cookie(self, user, expire=None): | |
1496 | """Deprecated. Use session_api calls directly | |
1497 | ||
1498 | XXX remove | |
1499 | """ | |
1500 | ||
1501 | # insert the session in the session db | |
1502 | self.session_api.set(user=user) | |
1503 | # refresh session cookie | |
1504 | self.session_api.update(set_cookie=True, expire=expire) | |
1505 | ||
1506 | def make_user_anonymous(self): | |
1507 | """ Make us anonymous | |
1508 | ||
1509 | This method used to handle non-existence of the 'anonymous' | |
1510 | user, but that user is mandatory now. | |
1511 | """ | |
1512 | self.userid = self.db.user.lookup('anonymous') | |
1513 | self.user = 'anonymous' | |
1514 | ||
1515 | def standard_message(self, to, subject, body, author=None): | |
1516 | """Send a standard email message from Roundup. | |
1517 | ||
1518 | "to" - recipients list | |
1519 | "subject" - Subject | |
1520 | "body" - Message | |
1521 | "author" - (name, address) tuple or None for admin email | |
1522 | ||
1523 | Arguments are passed to the Mailer.standard_message code. | |
1524 | """ | |
1525 | try: | |
1526 | self.mailer.standard_message(to, subject, body, author) | |
1527 | except MessageSendError, e: | |
1528 | self.error_message.append(str(e)) | |
1529 | return 0 | |
1530 | return 1 | |
1531 | ||
1532 | def parsePropsFromForm(self, create=0): | |
1533 | return FormParser(self).parse(create=create) | |
1534 | ||
1535 | # vim: set et sts=4 sw=4 : |