3 bup_python="$(dirname "$0")/bup-python" || exit $?
4 exec "$bup_python" "$0" ${1+"$@"}
8 from __future__ import print_function
9 from collections import namedtuple
10 import mimetypes, os, posixpath, signal, stat, sys, time, urllib, webbrowser
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
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
25 log('error: cannot find the python "tornado" module; please install it\n')
29 # FIXME: right now the way hidden files are handled causes every
30 # directory to be traversed twice.
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))
39 def _compute_breadcrumbs(path, show_hidden=False):
40 """Returns a list of breadcrumb objects for a path."""
42 breadcrumbs.append(('[root]', '/'))
43 path_parts = path.split('/')[1:-1]
45 for part in path_parts:
46 full_path += part + "/"
49 url_append = '?hidden=1'
50 breadcrumbs.append((part, full_path+url_append))
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 '.'
59 for name, item in vfs.contents(repo, dir_item, want_meta=False):
60 if name in ('.', '..'):
62 if name.startswith('.'):
67 def _dir_contents(repo, resolution, show_hidden=False):
68 """Yield the display information for the contents of dir_item."""
70 url_query = '?hidden=1' if show_hidden else ''
72 def display_info(name, item, resolved_item, display_name=None):
73 # link should be based on fully resolved type to avoid extra
75 if stat.S_ISDIR(vfs.item_mode(resolved_item)):
76 link = urllib.quote(name) + '/'
78 link = urllib.quote(name)
80 size = vfs.item_size(repo, item)
81 if opt.human_readable:
82 display_size = format_filesize(size)
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 + '@'
95 return display_name, link + url_query, display_size
97 dir_item = resolution[-1][1]
98 for name, item in vfs.contents(repo, dir_item):
100 if (name not in ('.', '..')) and name.startswith('.'):
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, '..')
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)
112 class BupRequestHandler(tornado.web.RequestHandler):
114 def initialize(self, repo=None):
117 def decode_argument(self, value, name=None):
120 return super(BupRequestHandler, self).decode_argument(value, name)
123 return self._process_request(path)
125 def head(self, path):
126 return self._process_request(path)
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]
138 mode = vfs.item_mode(leaf_item)
139 if stat.S_ISDIR(mode):
140 self._list_directory(path, res)
142 self._get_file(self.repo, path, res)
144 def _list_directory(self, path, resolution):
145 """Helper to produce a directory listing.
147 Return value is either a file object, or None (indicating an
148 error). In either case, the headers are sent.
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)
154 hidden_arg = self.request.arguments.get('hidden', [0])[-1]
156 show_hidden = int(hidden_arg)
157 except ValueError as e:
161 'list-directory.html',
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))
170 def _get_file(self, repo, path, resolved):
171 """Process a request on a file.
173 Return value is either a file object, or None (indicating an error).
174 In either case, the headers are sent.
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)
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:
189 for blob in chunkyreader(f):
193 def _guess_type(self, path):
194 """Guess the type of a file.
196 Argument is a PATH (a filename).
198 Return value is a string of the form type/subtype,
199 usable for a MIME Content-type header.
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.
206 base, ext = posixpath.splitext(path)
207 if ext in self.extensions_map:
208 return self.extensions_map[ext]
210 if ext in self.extensions_map:
211 return self.extensions_map[ext]
213 return self.extensions_map['']
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
228 def handle_sigterm(signum, frame):
230 debug1('\nbup-web: signal %d received\n' % signum)
231 log('Shutdown requested\n')
237 signal.signal(signal.SIGTERM, handle_sigterm)
239 UnixAddress = namedtuple('UnixAddress', ['path'])
240 InetAddress = namedtuple('InetAddress', ['host', 'port'])
243 bup web [[hostname]:port]
246 human-readable display human readable file sizes (i.e. 3.9K, 4.7M)
247 browser show repository in default browser (incompatible with unix://)
249 o = options.Options(optspec)
250 (opt, flags, extra) = o.parse(sys.argv[1:])
253 o.fatal("at most one argument expected")
256 address = InetAddress(host='127.0.0.1', port=8080)
259 if bind_url.startswith('unix://'):
260 address = UnixAddress(path=bind_url[len('unix://'):])
262 addr_parts = extra[0].split(':', 1)
263 if len(addr_parts) == 1:
267 host, port = addr_parts
270 except (TypeError, ValueError) as ex:
271 o.fatal('port must be an integer, not %r', port)
272 address = InetAddress(host=host, port=port)
274 git.check_repo_or_die()
278 template_path = resource_path('web'),
279 static_path = resource_path('web/static')
282 # Disable buffering on stdout, for debug messages
283 sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', 0)
285 application = tornado.web.Application([
286 (r"(?P<path>/.*)", BupRequestHandler, dict(repo=LocalRepo())),
289 http_server = HTTPServer(application)
290 io_loop_pending = IOLoop.instance()
292 if isinstance(address, InetAddress):
293 http_server.listen(address.port, address.host)
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())
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)
307 log('error: unexpected address %r', address)
310 io_loop = io_loop_pending
314 log('WARNING: %d errors encountered while saving.\n' % len(saved_errors))