]> arthur.barton.de Git - bup.git/blob - cmd/web-cmd.py
Port web to vfs2
[bup.git] / cmd / web-cmd.py
1 #!/bin/sh
2 """": # -*-python-*-
3 bup_python="$(dirname "$0")/bup-python" || exit $?
4 exec "$bup_python" "$0" ${1+"$@"}
5 """
6 # end of bup preamble
7
8 from __future__ import print_function
9 from collections import namedtuple
10 import mimetypes, os, posixpath, signal, stat, sys, time, urllib, webbrowser
11
12 from bup import options, git, vfs2 as vfs
13 from bup.helpers import (chunkyreader, debug1, format_filesize, handle_ctrl_c,
14                          log, resource_path, saved_errors)
15 from bup.metadata import Metadata
16 from bup.repo import LocalRepo
17
18 try:
19     from tornado import gen
20     from tornado.httpserver import HTTPServer
21     from tornado.ioloop import IOLoop
22     from tornado.netutil import bind_unix_socket
23     import tornado.web
24 except ImportError:
25     log('error: cannot find the python "tornado" module; please install it\n')
26     sys.exit(1)
27
28
29 # FIXME: right now the way hidden files are handled causes every
30 # directory to be traversed twice.
31
32 handle_ctrl_c()
33
34
35 def http_date_from_utc_ns(utc_ns):
36     return time.strftime('%a, %d %b %Y %H:%M:%S', time.gmtime(utc_ns / 10**9))
37
38
39 def _compute_breadcrumbs(path, show_hidden=False):
40     """Returns a list of breadcrumb objects for a path."""
41     breadcrumbs = []
42     breadcrumbs.append(('[root]', '/'))
43     path_parts = path.split('/')[1:-1]
44     full_path = '/'
45     for part in path_parts:
46         full_path += part + "/"
47         url_append = ""
48         if show_hidden:
49             url_append = '?hidden=1'
50         breadcrumbs.append((part, full_path+url_append))
51     return breadcrumbs
52
53
54 def _contains_hidden_files(repo, dir_item):
55     """Return true if the directory contains items with names other than
56     '.' and '..' that begin with '.'
57
58     """
59     for name, item in vfs.contents(repo, dir_item, want_meta=False):
60         if name in ('.', '..'):
61             continue
62         if name.startswith('.'):
63             return True
64     return False
65
66
67 def _dir_contents(repo, resolution, show_hidden=False):
68     """Yield the display information for the contents of dir_item."""
69
70     url_query = '?hidden=1' if show_hidden else ''
71
72     def display_info(name, item, resolved_item, display_name=None):
73         # link should be based on fully resolved type to avoid extra
74         # HTTP redirect.
75         if stat.S_ISDIR(vfs.item_mode(resolved_item)):
76             link = urllib.quote(name) + '/'
77         else:
78             link = urllib.quote(name)
79
80         size = vfs.item_size(repo, item)
81         if opt.human_readable:
82             display_size = format_filesize(size)
83         else:
84             display_size = size
85
86         if not display_name:
87             mode = vfs.item_mode(item)
88             if stat.S_ISDIR(mode):
89                 display_name = name + '/'
90             elif stat.S_ISLNK(mode):
91                 display_name = name + '@'
92             else:
93                 display_name = name
94
95         return display_name, link + url_query, display_size
96
97     dir_item = resolution[-1][1]    
98     for name, item in vfs.contents(repo, dir_item):
99         if not show_hidden:
100             if (name not in ('.', '..')) and name.startswith('.'):
101                 continue
102         if name == '.':
103             yield display_info(name, item, item, '.')
104             parent_item = resolution[-2][1] if len(resolution) > 1 else dir_item
105             yield display_info('..', parent_item, parent_item, '..')
106             continue
107         res = vfs.try_resolve(repo, name, parent=resolution, want_meta=False)
108         res_name, res_item = res[-1]
109         yield display_info(name, item, res_item)
110
111
112 class BupRequestHandler(tornado.web.RequestHandler):
113
114     def initialize(self, repo=None):
115         self.repo = repo
116
117     def decode_argument(self, value, name=None):
118         if name == 'path':
119             return value
120         return super(BupRequestHandler, self).decode_argument(value, name)
121
122     def get(self, path):
123         return self._process_request(path)
124
125     def head(self, path):
126         return self._process_request(path)
127     
128     def _process_request(self, path):
129         path = urllib.unquote(path)
130         print('Handling request for %s' % path)
131         # Set want_meta because dir metadata won't be fetched, and if
132         # it's not a dir, then we're going to want the metadata.
133         res = vfs.resolve(self.repo, path, want_meta=True)
134         leaf_name, leaf_item = res[-1]
135         if not leaf_item:
136             self.send_error(404)
137             return
138         mode = vfs.item_mode(leaf_item)
139         if stat.S_ISDIR(mode):
140             self._list_directory(path, res)
141         else:
142             self._get_file(self.repo, path, res)
143
144     def _list_directory(self, path, resolution):
145         """Helper to produce a directory listing.
146
147         Return value is either a file object, or None (indicating an
148         error).  In either case, the headers are sent.
149         """
150         if not path.endswith('/') and len(path) > 0:
151             print('Redirecting from %s to %s' % (path, path + '/'))
152             return self.redirect(path + '/', permanent=True)
153
154         hidden_arg = self.request.arguments.get('hidden', [0])[-1]
155         try:
156             show_hidden = int(hidden_arg)
157         except ValueError as e:
158             show_hidden = False
159
160         self.render(
161             'list-directory.html',
162             path=path,
163             breadcrumbs=_compute_breadcrumbs(path, show_hidden),
164             files_hidden=_contains_hidden_files(self.repo, resolution[-1][1]),
165             hidden_shown=show_hidden,
166             dir_contents=_dir_contents(self.repo, resolution,
167                                        show_hidden=show_hidden))
168
169     @gen.coroutine
170     def _get_file(self, repo, path, resolved):
171         """Process a request on a file.
172
173         Return value is either a file object, or None (indicating an error).
174         In either case, the headers are sent.
175         """
176         file_item = resolved[-1][1]
177         file_item = vfs.augment_item_meta(repo, file_item, include_size=True)
178         meta = file_item.meta
179         ctype = self._guess_type(path)
180         self.set_header("Last-Modified", http_date_from_utc_ns(meta.mtime))
181         self.set_header("Content-Type", ctype)
182         
183         self.set_header("Content-Length", str(meta.size))
184         assert len(file_item.oid) == 20
185         self.set_header("Etag", file_item.oid.encode('hex'))
186         if self.request.method != 'HEAD':
187             with vfs.fopen(self.repo, file_item) as f:
188                 it = chunkyreader(f)
189                 for blob in chunkyreader(f):
190                     self.write(blob)
191         raise gen.Return()
192
193     def _guess_type(self, path):
194         """Guess the type of a file.
195
196         Argument is a PATH (a filename).
197
198         Return value is a string of the form type/subtype,
199         usable for a MIME Content-type header.
200
201         The default implementation looks the file's extension
202         up in the table self.extensions_map, using application/octet-stream
203         as a default; however it would be permissible (if
204         slow) to look inside the data to make a better guess.
205         """
206         base, ext = posixpath.splitext(path)
207         if ext in self.extensions_map:
208             return self.extensions_map[ext]
209         ext = ext.lower()
210         if ext in self.extensions_map:
211             return self.extensions_map[ext]
212         else:
213             return self.extensions_map['']
214
215     if not mimetypes.inited:
216         mimetypes.init() # try to read system mime.types
217     extensions_map = mimetypes.types_map.copy()
218     extensions_map.update({
219         '': 'text/plain', # Default
220         '.py': 'text/plain',
221         '.c': 'text/plain',
222         '.h': 'text/plain',
223         })
224
225
226 io_loop = None
227
228 def handle_sigterm(signum, frame):
229     global io_loop
230     debug1('\nbup-web: signal %d received\n' % signum)
231     log('Shutdown requested\n')
232     if not io_loop:
233         sys.exit(0)
234     io_loop.stop()
235
236
237 signal.signal(signal.SIGTERM, handle_sigterm)
238
239 UnixAddress = namedtuple('UnixAddress', ['path'])
240 InetAddress = namedtuple('InetAddress', ['host', 'port'])
241
242 optspec = """
243 bup web [[hostname]:port]
244 bup web unix://path
245 --
246 human-readable    display human readable file sizes (i.e. 3.9K, 4.7M)
247 browser           show repository in default browser (incompatible with unix://)
248 """
249 o = options.Options(optspec)
250 (opt, flags, extra) = o.parse(sys.argv[1:])
251
252 if len(extra) > 1:
253     o.fatal("at most one argument expected")
254
255 if len(extra) == 0:
256     address = InetAddress(host='127.0.0.1', port=8080)
257 else:
258     bind_url = extra[0]
259     if bind_url.startswith('unix://'):
260         address = UnixAddress(path=bind_url[len('unix://'):])
261     else:
262         addr_parts = extra[0].split(':', 1)
263         if len(addr_parts) == 1:
264             host = '127.0.0.1'
265             port = addr_parts[0]
266         else:
267             host, port = addr_parts
268         try:
269             port = int(port)
270         except (TypeError, ValueError) as ex:
271             o.fatal('port must be an integer, not %r', port)
272         address = InetAddress(host=host, port=port)
273
274 git.check_repo_or_die()
275
276 settings = dict(
277     debug = 1,
278     template_path = resource_path('web'),
279     static_path = resource_path('web/static')
280 )
281
282 # Disable buffering on stdout, for debug messages
283 sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', 0)
284
285 application = tornado.web.Application([
286     (r"(?P<path>/.*)", BupRequestHandler, dict(repo=LocalRepo())),
287 ], **settings)
288
289 http_server = HTTPServer(application)
290 io_loop_pending = IOLoop.instance()
291
292 if isinstance(address, InetAddress):
293     http_server.listen(address.port, address.host)
294     try:
295         sock = http_server._socket # tornado < 2.0
296     except AttributeError as e:
297         sock = http_server._sockets.values()[0]
298     print('Serving HTTP on %s:%d...' % sock.getsockname())
299     if opt.browser:
300         browser_addr = 'http://' + address[0] + ':' + str(address[1])
301         io_loop_pending.add_callback(lambda : webbrowser.open(browser_addr))
302 elif isinstance(address, UnixAddress):
303     unix_socket = bind_unix_socket(address.path)
304     http_server.add_socket(unix_socket)
305     print('Serving HTTP on filesystem socket %r' % address.path)
306 else:
307     log('error: unexpected address %r', address)
308     sys.exit(1)
309
310 io_loop = io_loop_pending
311 io_loop.start()
312
313 if saved_errors:
314     log('WARNING: %d errors encountered while saving.\n' % len(saved_errors))
315     sys.exit(1)