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