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