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