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