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