| 1 | # -*- coding: iso-8859-1 -*- |
|---|
| 2 | # |
|---|
| 3 | # Copyright (C) 2005 Edgewall Software |
|---|
| 4 | # Copyright (C) 2005 Christopher Lenz <cmlenz@gmx.de> |
|---|
| 5 | # All rights reserved. |
|---|
| 6 | # |
|---|
| 7 | # This software is licensed as described in the file COPYING, which |
|---|
| 8 | # you should have received as part of this distribution. The terms |
|---|
| 9 | # are also available at http://trac.edgewall.com/license.html. |
|---|
| 10 | # |
|---|
| 11 | # This software consists of voluntary contributions made by many |
|---|
| 12 | # individuals. For the exact contribution history, see the revision |
|---|
| 13 | # history and logs, available at http://projects.edgewall.com/trac/. |
|---|
| 14 | # |
|---|
| 15 | # Author: Christopher Lenz <cmlenz@gmx.de> |
|---|
| 16 | |
|---|
| 17 | from Cookie import SimpleCookie as Cookie |
|---|
| 18 | import mimetypes |
|---|
| 19 | import os |
|---|
| 20 | import urlparse |
|---|
| 21 | |
|---|
| 22 | from trac.core import Interface |
|---|
| 23 | from trac.util import http_date, TracError |
|---|
| 24 | |
|---|
| 25 | |
|---|
| 26 | class RequestDone(Exception): |
|---|
| 27 | """Marker exception that indicates whether request processing has completed |
|---|
| 28 | and a response was sent. |
|---|
| 29 | """ |
|---|
| 30 | |
|---|
| 31 | |
|---|
| 32 | class Request(object): |
|---|
| 33 | """This class is used to abstract the interface between different frontends. |
|---|
| 34 | |
|---|
| 35 | Trac modules must use this interface. It is not allowed to have |
|---|
| 36 | frontend (cgi, tracd, mod_python) specific code in the modules. |
|---|
| 37 | """ |
|---|
| 38 | |
|---|
| 39 | method = None |
|---|
| 40 | scheme = None |
|---|
| 41 | server_name = None |
|---|
| 42 | server_port = None |
|---|
| 43 | remote_addr = None |
|---|
| 44 | remote_user = None |
|---|
| 45 | |
|---|
| 46 | args = None |
|---|
| 47 | hdf = None |
|---|
| 48 | authname = None |
|---|
| 49 | perm = None |
|---|
| 50 | session = None |
|---|
| 51 | _headers = None # additional headers to send |
|---|
| 52 | |
|---|
| 53 | def __init__(self): |
|---|
| 54 | self.incookie = Cookie() |
|---|
| 55 | self.outcookie = Cookie() |
|---|
| 56 | self._headers = [] |
|---|
| 57 | |
|---|
| 58 | def get_header(self, name): |
|---|
| 59 | """Return the value of the specified HTTP header, or `None` if there's |
|---|
| 60 | no such header in the request. |
|---|
| 61 | """ |
|---|
| 62 | raise NotImplementedError |
|---|
| 63 | |
|---|
| 64 | def send_response(self, code): |
|---|
| 65 | """Set the status code of the response.""" |
|---|
| 66 | raise NotImplementedError |
|---|
| 67 | |
|---|
| 68 | def send_header(self, name, value): |
|---|
| 69 | """Send the response header with the specified name and value.""" |
|---|
| 70 | raise NotImplementedError |
|---|
| 71 | |
|---|
| 72 | def end_headers(self): |
|---|
| 73 | """Must be called after all headers have been sent and before the actual |
|---|
| 74 | content is written. |
|---|
| 75 | """ |
|---|
| 76 | raise NotImplementedError |
|---|
| 77 | |
|---|
| 78 | def _send_cookie_headers(self): |
|---|
| 79 | # Cookie values can not contain " ,;" characters, so escape them |
|---|
| 80 | for name in self.outcookie.keys(): |
|---|
| 81 | path = self.outcookie[name].get('path') |
|---|
| 82 | if path: |
|---|
| 83 | path = path.replace(' ', '%20') \ |
|---|
| 84 | .replace(';', '%3B') \ |
|---|
| 85 | .replace(',', '%3C') |
|---|
| 86 | self.outcookie[name]['path'] = path |
|---|
| 87 | |
|---|
| 88 | cookies = self.outcookie.output(header='') |
|---|
| 89 | for cookie in cookies.splitlines(): |
|---|
| 90 | self.send_header('Set-Cookie', cookie.strip()) |
|---|
| 91 | |
|---|
| 92 | def check_modified(self, timesecs, extra=''): |
|---|
| 93 | """Check the request "If-None-Match" header against an entity tag |
|---|
| 94 | generated from the specified last modified time in seconds (`timesecs`), |
|---|
| 95 | optionally appending an `extra` string to indicate variants of the |
|---|
| 96 | requested resource. |
|---|
| 97 | |
|---|
| 98 | If the generated tag matches the "If-None-Match" header of the request, |
|---|
| 99 | this method sends a "304 Not Modified" response to the client. |
|---|
| 100 | Otherwise, it adds the entity tag as as "ETag" header to the response so |
|---|
| 101 | that consequetive requests can be cached. |
|---|
| 102 | """ |
|---|
| 103 | etag = 'W"%s/%d/%s"' % (self.authname, timesecs, extra) |
|---|
| 104 | inm = self.get_header('If-None-Match') |
|---|
| 105 | if (not inm or inm != etag): |
|---|
| 106 | self._headers.append(('ETag', etag)) |
|---|
| 107 | else: |
|---|
| 108 | self.send_response(304) |
|---|
| 109 | self.end_headers() |
|---|
| 110 | raise RequestDone() |
|---|
| 111 | |
|---|
| 112 | def redirect(self, url): |
|---|
| 113 | """Send a redirect to the client, forwarding to the specified URL. The |
|---|
| 114 | `url` may be relative or absolute, relative URLs will be translated |
|---|
| 115 | appropriately. |
|---|
| 116 | """ |
|---|
| 117 | if self.session: |
|---|
| 118 | self.session.save() # has to be done before the redirect is sent |
|---|
| 119 | self.send_response(302) |
|---|
| 120 | if not url.startswith('http://') and not url.startswith('https://'): |
|---|
| 121 | # Make sure the URL is absolute |
|---|
| 122 | url = absolute_url(self, url) |
|---|
| 123 | self.send_header('Location', url) |
|---|
| 124 | self.send_header('Content-Type', 'text/plain') |
|---|
| 125 | self.send_header('Pragma', 'no-cache') |
|---|
| 126 | self.send_header('Cache-control', 'no-cache') |
|---|
| 127 | self.send_header('Expires', 'Fri, 01 Jan 1999 00:00:00 GMT') |
|---|
| 128 | self._send_cookie_headers() |
|---|
| 129 | self.end_headers() |
|---|
| 130 | |
|---|
| 131 | if self.method != 'HEAD': |
|---|
| 132 | self.write('Redirecting...') |
|---|
| 133 | |
|---|
| 134 | raise RequestDone |
|---|
| 135 | |
|---|
| 136 | def display(self, template, content_type='text/html', response=200): |
|---|
| 137 | """Render the response using the ClearSilver template given by the |
|---|
| 138 | `template` parameter, which can be either the name of the template file, |
|---|
| 139 | or an already parsed `neo_cs.CS` object. |
|---|
| 140 | """ |
|---|
| 141 | assert self.hdf, 'HDF dataset not available' |
|---|
| 142 | if self.args.has_key('hdfdump'): |
|---|
| 143 | # FIXME: the administrator should probably be able to disable HDF |
|---|
| 144 | # dumps |
|---|
| 145 | content_type = 'text/plain' |
|---|
| 146 | data = str(self.hdf) |
|---|
| 147 | else: |
|---|
| 148 | data = self.hdf.render(template) |
|---|
| 149 | |
|---|
| 150 | self.send_response(response) |
|---|
| 151 | self.send_header('Cache-control', 'must-revalidate') |
|---|
| 152 | self.send_header('Expires', 'Fri, 01 Jan 1999 00:00:00 GMT') |
|---|
| 153 | self.send_header('Content-Type', content_type + ';charset=utf-8') |
|---|
| 154 | self.send_header('Content-Length', len(data)) |
|---|
| 155 | for name, value in self._headers: |
|---|
| 156 | self.send_header(name, value) |
|---|
| 157 | self._send_cookie_headers() |
|---|
| 158 | self.end_headers() |
|---|
| 159 | |
|---|
| 160 | if self.method != 'HEAD': |
|---|
| 161 | self.write(data) |
|---|
| 162 | |
|---|
| 163 | raise RequestDone |
|---|
| 164 | |
|---|
| 165 | def send_file(self, path, mimetype=None): |
|---|
| 166 | """Send a local file to the browser. |
|---|
| 167 | |
|---|
| 168 | This method includes the "Last-Modified", "Content-Type" and |
|---|
| 169 | "Content-Length" headers in the response, corresponding to the file |
|---|
| 170 | attributes. It also checks the last modification time of the local file |
|---|
| 171 | against the "If-Modified-Since" provided by the user agent, and sends a |
|---|
| 172 | "304 Not Modified" response if it matches. |
|---|
| 173 | """ |
|---|
| 174 | if not os.path.isfile(path): |
|---|
| 175 | raise TracError, "File %s not found" % path |
|---|
| 176 | |
|---|
| 177 | stat = os.stat(path) |
|---|
| 178 | last_modified = http_date(stat.st_mtime) |
|---|
| 179 | if last_modified == self.get_header('If-Modified-Since'): |
|---|
| 180 | self.send_response(304) |
|---|
| 181 | self.end_headers() |
|---|
| 182 | raise RequestDone |
|---|
| 183 | |
|---|
| 184 | self.send_response(200) |
|---|
| 185 | if not mimetype: |
|---|
| 186 | mimetype = mimetypes.guess_type(path)[0] |
|---|
| 187 | self.send_header('Content-Type', mimetype) |
|---|
| 188 | self.send_header('Content-Length', stat.st_size) |
|---|
| 189 | self.send_header('Last-Modified', last_modified) |
|---|
| 190 | for name, value in self._headers: |
|---|
| 191 | self.send_header(name, value) |
|---|
| 192 | self._send_cookie_headers() |
|---|
| 193 | self.end_headers() |
|---|
| 194 | |
|---|
| 195 | if self.method != 'HEAD': |
|---|
| 196 | try: |
|---|
| 197 | fd = open(path, 'rb') |
|---|
| 198 | while True: |
|---|
| 199 | data = fd.read(4096) |
|---|
| 200 | if not data: |
|---|
| 201 | break |
|---|
| 202 | self.write(data) |
|---|
| 203 | finally: |
|---|
| 204 | fd.close() |
|---|
| 205 | |
|---|
| 206 | raise RequestDone |
|---|
| 207 | |
|---|
| 208 | def read(self, size): |
|---|
| 209 | """Read the specified number of bytes from the request body.""" |
|---|
| 210 | raise NotImplementedError |
|---|
| 211 | |
|---|
| 212 | def write(self, data): |
|---|
| 213 | """Write the given data to the response body.""" |
|---|
| 214 | raise NotImplementedError |
|---|
| 215 | |
|---|
| 216 | |
|---|
| 217 | class IAuthenticator(Interface): |
|---|
| 218 | """Extension point interface for components that can provide the name |
|---|
| 219 | of the remote user.""" |
|---|
| 220 | |
|---|
| 221 | def authenticate(req): |
|---|
| 222 | """Return the name of the remote user, or `None` if the identity of the |
|---|
| 223 | user is unknown.""" |
|---|
| 224 | |
|---|
| 225 | |
|---|
| 226 | class IRequestHandler(Interface): |
|---|
| 227 | """Extension point interface for request handlers.""" |
|---|
| 228 | |
|---|
| 229 | def match_request(req): |
|---|
| 230 | """Return whether the handler wants to process the given request.""" |
|---|
| 231 | |
|---|
| 232 | def process_request(req): |
|---|
| 233 | """Process the request. Should return a (template_name, content_type) |
|---|
| 234 | tuple, where `template` is the ClearSilver template to use (either |
|---|
| 235 | a `neo_cs.CS` object, or the file name of the template), and |
|---|
| 236 | `content_type` is the MIME type of the content. If `content_type` is |
|---|
| 237 | `None`, "text/html" is assumed. |
|---|
| 238 | |
|---|
| 239 | Note that if template processing should not occur, this method can |
|---|
| 240 | simply send the response itself and not return anything. |
|---|
| 241 | """ |
|---|
| 242 | |
|---|
| 243 | |
|---|
| 244 | def absolute_url(req, path=None): |
|---|
| 245 | """Reconstruct the absolute URL of the given request. |
|---|
| 246 | |
|---|
| 247 | If the `path` parameter is specified, the path is appended to the URL. |
|---|
| 248 | Otherwise, only a URL with the components scheme, host and port is returned. |
|---|
| 249 | """ |
|---|
| 250 | if hasattr(req, 'base_url'): |
|---|
| 251 | scheme, host, _, _, _, _ = urlparse.urlparse(req.base_url) |
|---|
| 252 | else: |
|---|
| 253 | scheme = req.scheme |
|---|
| 254 | host = req.get_header('Host') |
|---|
| 255 | if not host: |
|---|
| 256 | # Missing host header, so reconstruct the host from the |
|---|
| 257 | # server name and port |
|---|
| 258 | default_port = {'http': 80, 'https': 443} |
|---|
| 259 | if req.server_port and req.server_port != default_port[scheme]: |
|---|
| 260 | host = '%s:%d' % (req.server_name, req.server_port) |
|---|
| 261 | else: |
|---|
| 262 | host = req.server_name |
|---|
| 263 | if not path: |
|---|
| 264 | path = req.cgi_location |
|---|
| 265 | return urlparse.urlunparse((scheme, host, path, None, None, None)) |
|---|