Clean up file headers.
[jelmer/dulwich-libgit2.git] / dulwich / web.py
1 # web.py -- WSGI smart-http server
2 # Copyright (C) 2010 Google, Inc.
3 #
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; version 2
7 # or (at your option) any later version of the License.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
17 # MA  02110-1301, USA.
18
19 """HTTP server for dulwich that implements the git smart HTTP protocol."""
20
21 from cStringIO import StringIO
22 import re
23 import time
24
25 try:
26     from urlparse import parse_qs
27 except ImportError:
28     from dulwich.misc import parse_qs
29 from dulwich.protocol import (
30     ReceivableProtocol,
31     )
32 from dulwich.server import (
33     ReceivePackHandler,
34     UploadPackHandler,
35     DEFAULT_HANDLERS,
36     )
37
38
39 # HTTP error strings
40 HTTP_OK = '200 OK'
41 HTTP_NOT_FOUND = '404 Not Found'
42 HTTP_FORBIDDEN = '403 Forbidden'
43
44
45 def date_time_string(timestamp=None):
46     # Based on BaseHTTPServer.py in python2.5
47     weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
48     months = [None,
49               'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
50               'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
51     if timestamp is None:
52         timestamp = time.time()
53     year, month, day, hh, mm, ss, wd, y, z = time.gmtime(timestamp)
54     return '%s, %02d %3s %4d %02d:%02d:%02d GMD' % (
55             weekdays[wd], day, months[month], year, hh, mm, ss)
56
57
58 def url_prefix(mat):
59     """Extract the URL prefix from a regex match.
60
61     :param mat: A regex match object.
62     :returns: The URL prefix, defined as the text before the match in the
63         original string. Normalized to start with one leading slash and end with
64         zero.
65     """
66     return '/' + mat.string[:mat.start()].strip('/')
67
68
69 def get_repo(backend, mat):
70     """Get a Repo instance for the given backend and URL regex match."""
71     return backend.open_repository(url_prefix(mat))
72
73
74 def send_file(req, f, content_type):
75     """Send a file-like object to the request output.
76
77     :param req: The HTTPGitRequest object to send output to.
78     :param f: An open file-like object to send; will be closed.
79     :param content_type: The MIME type for the file.
80     :yield: The contents of the file.
81     """
82     if f is None:
83         yield req.not_found('File not found')
84         return
85     try:
86         req.respond(HTTP_OK, content_type)
87         while True:
88             data = f.read(10240)
89             if not data:
90                 break
91             yield data
92         f.close()
93     except IOError:
94         f.close()
95         yield req.not_found('Error reading file')
96     except:
97         f.close()
98         raise
99
100
101 def get_text_file(req, backend, mat):
102     req.nocache()
103     return send_file(req, get_repo(backend, mat).get_named_file(mat.group()),
104                      'text/plain')
105
106
107 def get_loose_object(req, backend, mat):
108     sha = mat.group(1) + mat.group(2)
109     object_store = get_repo(backend, mat).object_store
110     if not object_store.contains_loose(sha):
111         yield req.not_found('Object not found')
112         return
113     try:
114         data = object_store[sha].as_legacy_object()
115     except IOError:
116         yield req.not_found('Error reading object')
117     req.cache_forever()
118     req.respond(HTTP_OK, 'application/x-git-loose-object')
119     yield data
120
121
122 def get_pack_file(req, backend, mat):
123     req.cache_forever()
124     return send_file(req, get_repo(backend, mat).get_named_file(mat.group()),
125                      'application/x-git-packed-objects')
126
127
128 def get_idx_file(req, backend, mat):
129     req.cache_forever()
130     return send_file(req, get_repo(backend, mat).get_named_file(mat.group()),
131                      'application/x-git-packed-objects-toc')
132
133
134 def get_info_refs(req, backend, mat):
135     params = parse_qs(req.environ['QUERY_STRING'])
136     service = params.get('service', [None])[0]
137     if service and not req.dumb:
138         handler_cls = req.handlers.get(service, None)
139         if handler_cls is None:
140             yield req.forbidden('Unsupported service %s' % service)
141             return
142         req.nocache()
143         req.respond(HTTP_OK, 'application/x-%s-advertisement' % service)
144         output = StringIO()
145         proto = ReceivableProtocol(StringIO().read, output.write)
146         handler = handler_cls(backend, [url_prefix(mat)], proto,
147                               stateless_rpc=True, advertise_refs=True)
148         handler.proto.write_pkt_line('# service=%s\n' % service)
149         handler.proto.write_pkt_line(None)
150         handler.handle()
151         yield output.getvalue()
152     else:
153         # non-smart fallback
154         # TODO: select_getanyfile() (see http-backend.c)
155         req.nocache()
156         req.respond(HTTP_OK, 'text/plain')
157         repo = get_repo(backend, mat)
158         refs = repo.get_refs()
159         for name in sorted(refs.iterkeys()):
160             # get_refs() includes HEAD as a special case, but we don't want to
161             # advertise it
162             if name == 'HEAD':
163                 continue
164             sha = refs[name]
165             o = repo[sha]
166             if not o:
167                 continue
168             yield '%s\t%s\n' % (sha, name)
169             peeled_sha = repo.get_peeled(name)
170             if peeled_sha != sha:
171                 yield '%s\t%s^{}\n' % (peeled_sha, name)
172
173
174 def get_info_packs(req, backend, mat):
175     req.nocache()
176     req.respond(HTTP_OK, 'text/plain')
177     for pack in get_repo(backend, mat).object_store.packs:
178         yield 'P pack-%s.pack\n' % pack.name()
179
180
181 class _LengthLimitedFile(object):
182     """Wrapper class to limit the length of reads from a file-like object.
183
184     This is used to ensure EOF is read from the wsgi.input object once
185     Content-Length bytes are read. This behavior is required by the WSGI spec
186     but not implemented in wsgiref as of 2.5.
187     """
188
189     def __init__(self, input, max_bytes):
190         self._input = input
191         self._bytes_avail = max_bytes
192
193     def read(self, size=-1):
194         if self._bytes_avail <= 0:
195             return ''
196         if size == -1 or size > self._bytes_avail:
197             size = self._bytes_avail
198         self._bytes_avail -= size
199         return self._input.read(size)
200
201     # TODO: support more methods as necessary
202
203
204 def handle_service_request(req, backend, mat):
205     service = mat.group().lstrip('/')
206     handler_cls = req.handlers.get(service, None)
207     if handler_cls is None:
208         yield req.forbidden('Unsupported service %s' % service)
209         return
210     req.nocache()
211     req.respond(HTTP_OK, 'application/x-%s-response' % service)
212
213     output = StringIO()
214     input = req.environ['wsgi.input']
215     # This is not necessary if this app is run from a conforming WSGI server.
216     # Unfortunately, there's no way to tell that at this point.
217     # TODO: git may used HTTP/1.1 chunked encoding instead of specifying
218     # content-length
219     if 'CONTENT_LENGTH' in req.environ:
220         input = _LengthLimitedFile(input, int(req.environ['CONTENT_LENGTH']))
221     proto = ReceivableProtocol(input.read, output.write)
222     handler = handler_cls(backend, [url_prefix(mat)], proto, stateless_rpc=True)
223     handler.handle()
224     yield output.getvalue()
225
226
227 class HTTPGitRequest(object):
228     """Class encapsulating the state of a single git HTTP request.
229
230     :ivar environ: the WSGI environment for the request.
231     """
232
233     def __init__(self, environ, start_response, dumb=False, handlers=None):
234         self.environ = environ
235         self.dumb = dumb
236         self.handlers = handlers and handlers or DEFAULT_HANDLERS
237         self._start_response = start_response
238         self._cache_headers = []
239         self._headers = []
240
241     def add_header(self, name, value):
242         """Add a header to the response."""
243         self._headers.append((name, value))
244
245     def respond(self, status=HTTP_OK, content_type=None, headers=None):
246         """Begin a response with the given status and other headers."""
247         if headers:
248             self._headers.extend(headers)
249         if content_type:
250             self._headers.append(('Content-Type', content_type))
251         self._headers.extend(self._cache_headers)
252
253         self._start_response(status, self._headers)
254
255     def not_found(self, message):
256         """Begin a HTTP 404 response and return the text of a message."""
257         self._cache_headers = []
258         self.respond(HTTP_NOT_FOUND, 'text/plain')
259         return message
260
261     def forbidden(self, message):
262         """Begin a HTTP 403 response and return the text of a message."""
263         self._cache_headers = []
264         self.respond(HTTP_FORBIDDEN, 'text/plain')
265         return message
266
267     def nocache(self):
268         """Set the response to never be cached by the client."""
269         self._cache_headers = [
270           ('Expires', 'Fri, 01 Jan 1980 00:00:00 GMT'),
271           ('Pragma', 'no-cache'),
272           ('Cache-Control', 'no-cache, max-age=0, must-revalidate'),
273           ]
274
275     def cache_forever(self):
276         """Set the response to be cached forever by the client."""
277         now = time.time()
278         self._cache_headers = [
279           ('Date', date_time_string(now)),
280           ('Expires', date_time_string(now + 31536000)),
281           ('Cache-Control', 'public, max-age=31536000'),
282           ]
283
284
285 class HTTPGitApplication(object):
286     """Class encapsulating the state of a git WSGI application.
287
288     :ivar backend: the Backend object backing this application
289     """
290
291     services = {
292       ('GET', re.compile('/HEAD$')): get_text_file,
293       ('GET', re.compile('/info/refs$')): get_info_refs,
294       ('GET', re.compile('/objects/info/alternates$')): get_text_file,
295       ('GET', re.compile('/objects/info/http-alternates$')): get_text_file,
296       ('GET', re.compile('/objects/info/packs$')): get_info_packs,
297       ('GET', re.compile('/objects/([0-9a-f]{2})/([0-9a-f]{38})$')): get_loose_object,
298       ('GET', re.compile('/objects/pack/pack-([0-9a-f]{40})\\.pack$')): get_pack_file,
299       ('GET', re.compile('/objects/pack/pack-([0-9a-f]{40})\\.idx$')): get_idx_file,
300
301       ('POST', re.compile('/git-upload-pack$')): handle_service_request,
302       ('POST', re.compile('/git-receive-pack$')): handle_service_request,
303     }
304
305     def __init__(self, backend, dumb=False, handlers=None):
306         self.backend = backend
307         self.dumb = dumb
308         self.handlers = handlers
309
310     def __call__(self, environ, start_response):
311         path = environ['PATH_INFO']
312         method = environ['REQUEST_METHOD']
313         req = HTTPGitRequest(environ, start_response, dumb=self.dumb,
314                              handlers=self.handlers)
315         # environ['QUERY_STRING'] has qs args
316         handler = None
317         for smethod, spath in self.services.iterkeys():
318             if smethod != method:
319                 continue
320             mat = spath.search(path)
321             if mat:
322                 handler = self.services[smethod, spath]
323                 break
324         if handler is None:
325             return req.not_found('Sorry, that method is not supported')
326         return handler(req, self.backend, mat)