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