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