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