def test_range_parser(self): r = lambda rs: list(parse_range_header(rs, 100)) self.assertEqual([(90, 100)], r('bytes=-10')) self.assertEqual([(10, 100)], r('bytes=10-')) self.assertEqual([(5, 11)], r('bytes=5-10')) self.assertEqual([(10, 100), (90, 100), (5, 11)], r('bytes=10-,-10,5-10'))
def video(): print(list(bottle.request.headers.items())) print(self.transcoder.fn) ranges = list(bottle.parse_range_header(bottle.request.environ['HTTP_RANGE'], 1000000000000)) print('ranges', ranges) offset, end = ranges[0] self.transcoder.wait_for_byte(offset) response = bottle.static_file(self.transcoder.fn, root='/') response.headers['Access-Control-Allow-Origin'] = '*' response.headers['Access-Control-Allow-Methods'] = 'GET, HEAD' response.headers['Access-Control-Allow-Headers'] = 'Content-Type' return response
def send_file(content, filename, size, timestamp): """ Send a file represented by file object The code is partly based on ``bottle.static_file``. :param content: file-like object :param filename: filename to use :param size: file size in bytes :param timestamp: file's timestamp seconds since epoch """ headers = {} ctype = get_mimetype(filename) if ctype.startswith('text/'): # We expect and assume all text files are encoded UTF-8. It's # broadcaster's job to ensure this is true. ctype += '; charset=%s' % CHARSET # Set basic headers headers['Content-Type'] = ctype headers['Content-Length'] = size headers['Last-Modified'] = format_ts(timestamp) # Check if If-Modified-Since header is in request and respond early if so modsince = request.environ.get('HTTP_IF_MODIFIED_SINCE') modsince = modsince and parse_date(modsince.split(';')[0].strip()) if modsince is not None and modsince >= timestamp: headers['Date'] = format_ts() return HTTPResponse(status=304, **headers) if request.method == 'HEAD': # Request is a HEAD, so remove any content body content = '' headers['Accept-Ranges'] = 'bytes' ranges = request.environ.get('HTTP_RANGE') if ranges: ranges = list(parse_range_header(ranges, size)) if not ranges: return HTTPError(416, "Request Range Not Satisfiable") start, end = ranges[0] headers['Content-Range'] = 'bytes %d-%d/%d' % (start, end - 1, size) headers['Content-Length'] = str(end - start) content = iter_read_range(content, start, end - start) return HTTPResponse(content, **headers)
def get_item_file(self, id): item = self.lib.get_item(id) if item is None: return HTTPError(404, 'File does not exist.') if not os.access(item.path, os.R_OK): return HTTPError(403, 'You do not have permission to access this file.') stats = os.stat(item.path) headers = { 'Content-Type': 'application/octet-stream', 'Content-Length': stats.st_size, 'Last-Modified': time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(stats.st_mtime)), 'Date': time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime()) } ims = request.environ.get('HTTP_IF_MODIFIED_SINCE') if ims: ims = parse_date(ims.split(";")[0].strip()) if ims is not None and ims >= int(stats.st_mtime): return HTTPResponse(status=304, **headers) body = '' if request.method == 'HEAD' else open(item.path, 'rb') headers["Accept-Ranges"] = "bytes" range_header = request.environ.get('HTTP_RANGE') if range_header: ranges = list(parse_range_header(range_header, stats.st_size)) if not ranges: return HTTPError(416, "Requested Range Not Satisfiable") offset, end = ranges[0] headers["Content-Range"] = "bytes %d-%d/%d" % (offset, end - 1, stats.st_size) headers["Content-Length"] = str(end - offset) if body: body = _file_iter_range(body, offset, end - offset) return HTTPResponse(body, status=206, **headers) return HTTPResponse(body, **headers)
def serve_file(filename, data=None, mimetype='auto', download=False, charset='UTF-8'): headers = dict() if mimetype == 'auto': mimetype, encoding = mimetypes.guess_type(filename) if encoding: headers['Content-Encoding'] = encoding if mimetype: if mimetype[:5] == 'text/' and charset and 'charset' not in mimetype: mimetype += '; charset=%s' % charset headers['Content-Type'] = mimetype if download: headers['Content-Disposition'] = 'attachment; filename="%s"' % filename data.seek(0, os.SEEK_END) clen = data.tell() headers['Content-Length'] = clen lm = datetime.datetime.now().strftime("%a, %d %b %Y %H:%M:%S GMT") headers['Last-Modified'] = lm if bottle.request.method == 'HEAD': body = '' else: data.seek(0) body = data.read() headers["Accept-Ranges"] = "bytes" ranges = bottle.request.environ.get('HTTP_RANGE') if 'HTTP_RANGE' in bottle.request.environ: ranges = list(bottle.parse_range_header(bottle.request.environ['HTTP_RANGE'], clen)) if not ranges: return bottle.HTTPError(416, "Requested Range Not Satisfiable") offset, end = ranges[0] headers["Content-Range"] = "bytes %d-%d/%d" % (offset, end - 1, clen) headers["Content-Length"] = str(end - offset) if body: body = bottle._file_iter_range(body, offset, end - offset) return bottle.HTTPResponse(body, status=206, **headers) return bottle.HTTPResponse(body, **headers)
def static_file(filename, root, mimetype=True, download=False, charset='UTF-8', etag=None): """ Open a file in a safe way and return an instance of :exc:`HTTPResponse` that can be sent back to the client. :param filename: Name or path of the file to send, relative to ``root``. :param root: Root path for file lookups. Should be an absolute directory path. :param mimetype: Provide the content-type header (default: guess from file extension) :param download: If True, ask the browser to open a `Save as...` dialog instead of opening the file with the associated program. You can specify a custom filename as a string. If not specified, the original filename is used (default: False). :param charset: The charset for files with a ``text/*`` mime-type. (default: UTF-8) :param etag: Provide a pre-computed ETag header. If set to ``False``, ETag handling is disabled. (default: auto-generate ETag header) While checking user input is always a good idea, this function provides additional protection against malicious ``filename`` parameters from breaking out of the ``root`` directory and leaking sensitive information to an attacker. Read-protected files or files outside of the ``root`` directory are answered with ``403 Access Denied``. Missing files result in a ``404 Not Found`` response. Conditional requests (``If-Modified-Since``, ``If-None-Match``) are answered with ``304 Not Modified`` whenever possible. ``HEAD`` and ``Range`` requests (used by download managers to check or continue partial downloads) are also handled automatically. """ root = os.path.join(os.path.abspath(root), '') filename = os.path.abspath(os.path.join(root, filename.strip('/\\'))) headers = dict() if not filename.startswith(root): return HTTPError(403, "Access denied.") if not os.path.exists(filename) or not os.path.isfile(filename): return HTTPError(404, "File does not exist.") if not os.access(filename, os.R_OK): return HTTPError( 403, "You do not have permission to access this file.") if mimetype is True: if download and download is not True: mimetype, encoding = mimetypes.guess_type(download) else: mimetype, encoding = mimetypes.guess_type(filename) if encoding: headers['Content-Encoding'] = encoding if mimetype: if (mimetype[:5] == 'text/' or mimetype == 'application/javascript')\ and charset and 'charset' not in mimetype: mimetype += '; charset=%s' % charset headers['Content-Type'] = mimetype if download: download = os.path.basename( filename if download is True else download) headers[ 'Content-Disposition'] = 'attachment; filename="%s"' % download stats = os.stat(filename) headers['Content-Length'] = clen = stats.st_size headers['Last-Modified'] = email.utils.formatdate(stats.st_mtime, usegmt=True) headers['Date'] = email.utils.formatdate(time.time(), usegmt=True) getenv = request.environ.get if etag is None: etag = '%d:%d:%d:%d:%s' % (stats.st_dev, stats.st_ino, stats.st_mtime, clen, filename) etag = hashlib.sha1(tob(etag)).hexdigest() if etag: headers['ETag'] = etag check = getenv('HTTP_IF_NONE_MATCH') if check and check == etag: return HTTPResponse(status=304, **headers) if not (etag and check): ims = getenv('HTTP_IF_MODIFIED_SINCE') if ims: ims = parse_date(ims.split(";")[0].strip()) if ims is not None and ims >= int(stats.st_mtime): return HTTPResponse(status=304, **headers) body = '' if request.method == 'HEAD' else open(filename, 'rb') headers["Accept-Ranges"] = "bytes" range_header = getenv('HTTP_RANGE') if range_header: ranges = list(parse_range_header(range_header, clen)) if not ranges: return HTTPError(416, "Requested Range Not Satisfiable") offset, end = ranges[0] headers["Content-Range"] = "bytes %d-%d/%d" % (offset, end - 1, clen) headers["Content-Length"] = str(end - offset) if body: body = _file_iter_range(body, offset, end - offset) return HTTPResponse(body, status=206, **headers) return HTTPResponse(body, **headers)
def handle_subarchive_path(archivefile, subarchivepath, mimetype, encoding, download=False, charset='UTF-8', etag=None, format=None): """Show content of a path in a zip file. """ from bottle import parse_range_header, parse_date, _file_iter_range, tob if not os.access(archivefile, os.R_OK): return http_error(403, "You do not have permission to access this file.", format=format) try: zip = zipfile.ZipFile(archivefile) except: return http_error(500, "Unable to open the ZIP file.", format=format) try: # KeyError is raised if subarchivepath does not exist info = zip.getinfo(subarchivepath) except KeyError: # subarchivepath does not exist # possibility a missing directory entry? return handle_zip_directory_listing(zip, archivefile, subarchivepath) fh = zip.open(subarchivepath, 'r') headers = dict() if encoding: headers['Content-Encoding'] = encoding if mimetype is True: if download and download is not True: mimetype, encoding = mimetypes.guess_type(download) else: mimetype, encoding = mimetypes.guess_type(subarchivepath) if encoding: headers['Content-Encoding'] = encoding if mimetype: if (mimetype[:5] == 'text/' or mimetype == 'application/javascript')\ and charset and 'charset' not in mimetype: mimetype += '; charset=%s' % charset headers['Content-Type'] = mimetype if download: download = os.path.basename( subarchivepath if download is True else download) headers['Content-Disposition'] = 'attachment; filename="%s"' % download headers['Content-Length'] = clen = info.file_size lm = info.date_time epoch = int( time.mktime((lm[0], lm[1], lm[2], lm[3], lm[4], lm[5], 0, 0, -1))) headers['Last-Modified'] = email.utils.formatdate(epoch, usegmt=True) headers['Date'] = email.utils.formatdate(time.time(), usegmt=True) getenv = request.environ.get if etag is None: etag = '%d:%d:%s' % (epoch, clen, subarchivepath) etag = hashlib.sha1(tob(etag)).hexdigest() if etag: headers['ETag'] = etag check = getenv('HTTP_IF_NONE_MATCH') if check and check == etag: return HTTPResponse(status=304, **headers) if not (etag and check): ims = getenv('HTTP_IF_MODIFIED_SINCE') if ims: ims = parse_date(ims.split(";")[0].strip()) if ims is not None and ims >= int(epoch): return HTTPResponse(status=304, **headers) body = '' if request.method == 'HEAD' else fh headers["Accept-Ranges"] = "bytes" range_header = getenv('HTTP_RANGE') if range_header: ranges = list(parse_range_header(range_header, clen)) if not ranges: return http_error(416, "Requested Range Not Satisfiable") offset, end = ranges[0] headers["Content-Range"] = "bytes %d-%d/%d" % (offset, end - 1, clen) headers["Content-Length"] = str(end - offset) if body: body = _file_iter_range(body, offset, end - offset) return HTTPResponse(body, status=206, **headers) return HTTPResponse(body, **headers)
def send_file(content, filename, size=None, timestamp=None): """ Convert file data into an HTTP response. This method is used when the file data does not exist on disk, such as when it is dynamically generated. Because the file does not exist on disk, the basic metadata which is usually read from the file itself must be supplied as arguments. The ``filename`` argument is the supposed filename of the file data. It is only used to set the Content-Type header, and you may safely pass in just the extension with leading period. The ``size`` argument is the payload size in bytes. For streaming files, this can be particularly important as the ranges are calculated baed on content length. If ``size`` is omitted, then support for ranges is not advertise and ranges are never returned. ``timestamp`` is expected to be in seconds since UNIX epoch, and is used to calculate Last-Modified HTTP headers, as well as handle If-Modified-Since header. If omitted, current time is used, and If-Modified-Since is never checked. .. note:: The returned response is a completely new response object. Modifying the reponse object in the current request context is not going to affect the object returned by this function. You should modify the object returned by this function instead. Example:: def some_handler(): import StringIO f = StringIO.StringIO('foo') return send_file(f, 'file.txt', 3, 1293281312) The code is partly based on ``bottle.static_file``, with the main difference being the use of file-like objects instead of files on disk. """ headers = {} ctype = get_mimetype(filename) if ctype.startswith('text/'): # We expect and assume all text files are encoded UTF-8. It's # user's job to ensure this is true. ctype += '; charset=UTF-8' # Set basic headers headers['Content-Type'] = ctype if size: headers['Content-Length'] = size headers['Last-Modified'] = format_ts(timestamp) # Check if If-Modified-Since header is in request and respond early if so if timestamp: modsince = request.environ.get('HTTP_IF_MODIFIED_SINCE') modsince = modsince and parse_date(modsince.split(';')[0].strip()) if modsince is not None and modsince >= timestamp: headers['Date'] = format_ts() return HTTPResponse(status=304, **headers) if request.method == 'HEAD': # Request is a HEAD, so remove any content body content = '' if size: headers['Accept-Ranges'] = 'bytes' ranges = request.environ.get('HTTP_RANGE') if ranges and size: ranges = list(parse_range_header(ranges, size)) if not ranges: return HTTPError(416, "Request Range Not Satisfiable") start, end = ranges[0] headers['Content-Range'] = 'bytes %d-%d/%d' % (start, end - 1, size) headers['Content-Length'] = str(end - start) content = iter_read_range(content, start, end - start) return HTTPResponse(content, **headers)
def process_response(self, content, ranges_specifier=None): """ Determine if and what partial content should be sent to the client. Modifies response headers to accomodate partial content. Since we only care about ranges for proxied content, only process the ranges of content of a specific type (i.e., bytes and iterables of bytes). References: https://www.ietf.org/rfc/rfc2616.txt https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Ranges Arguments: content -- response content of a type that Bottle accepts ranges_specifier -- str of RFC2616 ranges specified by client Returns: bytes or None -- partial content specified by ranges """ if response.status_code == codes.PARTIAL_CONTENT: return # Already dealt with. elif response.status_code >= codes.bad: return # Do not process a bad response. # Assume a proxied content type will only be bytes or iterable of bytes. # TODO: consider content size and use a buffer or file object. if not isinstance(content, bytes): try: content = b''.join( b for b in (content() if callable(content) else content)) except TypeError: return # Show support for ranges. response.set_header('Accept-Ranges', UNITS) clen = len(content) # `parse_range_header` follows RFC2616 specification for parsing byte # ranges. ranges = list(parse_range_header(ranges_specifier, clen)) if ranges_specifier and not ranges: self.abort(ranges_specifier=ranges_specifier) elif ranges: response.status = codes.PARTIAL_CONTENT if len(ranges) == 1: # Single-part ranges. start, end = ranges[0] response.set_header( 'Content-Range', '{units} {start}-{end}/{clen}'.format( units=UNITS, start=start, end=end - 1, clen=clen, )) # Set partial content. content = content[start:end] clen = end - start else: # Multi-part ranges. ctype = response.content_type charset = response.charset while True: boundary = uuid4().hex.encode(response.charset) if boundary not in content: break response.set_header( 'Content-Type', 'multipart/byteranges; boundary={0}'.format( boundary.decode())) # Build partial content bytes that will be joined by newlines. parts = [] for start, end in ranges: parts.extend([ b'--%s' % boundary, b'Content-Type: %s' % ctype.encode(charset), b'Content-Range: %(units)s %(start)d-%(end)d/%(clen)d' % { b'units': UNITS.encode(charset), b'start': start, b'end': end - 1, b'clen': clen, }, b'', # Separate content from headers with a newline. content[start:end], ]) parts.append(b'--%s--' % boundary) # Set partial content. content = b'\n'.join(parts) clen = len(content) response.content_length = str(clen) return content
def send_file(fd, filename=None, size=None, timestamp=None, ctype=None, charset=CHARSET, attachment=False, wrapper=DEFAULT_WRAPPER): """ Send a file represented by file object This function constcuts a HTTPResponse object that uses a file descriptor as response body. The file descriptor is suppled as ``fd`` argument and it must have a ``read()`` method. ``ValueError`` is raised when this is not the case. It supports `byte serving`_ using Range header, and makes the best effort to set all appropriate headers. It also supports HEAD queries. Because we are dealing with file descriptors and not physical files, the user must also supply the file metadata such as filename, size, and timestamp. The ``filename`` argument is an arbitrary filename. It is used to guess the content type, and also to set the content disposition in case of attachments. The ``size`` argument is the payload size in bytes. If it is omitted, the content length header is not set, and byte serving does not work. The ``timestamp`` argument is the number of seconds since Unix epoch when the file was created or last modified. If this argument is omitted, If-Modified-Since request headers cannot be honored. To explicitly specify the content type, the ``ctype`` argument can be used. This should be a valid MIME type of the payload. Default encoding (used as charset parameter in Content-Type header) is 'UTF-8'. This can be overridden by using the ``charset`` argument. The ``attachment`` argumnet can be set to ``True`` to add the Content-Dispositon response header. Value of the header is then set to the filename. The ``wrapper`` argument is used to wrap the file descriptor when doing byte serving. The default is to use ``fdsend.rangewrapper.RangeWrapper`` class, but there are alternatives as ``fdsend.rangewrapper.range_iter`` and ``bottle._file_iter_range``. The wrappers provided by this package are written to specifically handle file handles that do not have a ``seek()`` method. If this is not your case, you may safely use the bottle's wrapper. The primary difference between ``fdsend.rangewrapper.RangeWrapper`` and ``fdsend.rangewrapper.range_iter`` is that the former returns a file-like object with ``read()`` method, which may or may not increase performance when used on a WSGI server that supports ``wsgi.file_wrapper`` feature. The latter returns an iterator and the response is returned as is without the use of a ``file_wrapper``. This may have some benefits when it comes to memory usage. Benchmarking and profiling is the best way to determine which wrapper you want to use, or you need to implement your own. To implement your own wrapper, you need to create a callable or a class that takes the following arguments: - file descriptor - offset (in bytes from start of the file) - length (total number of bytes in the range) The return value of the wrapper must be either an iterable or file-like object that implements ``read()`` and ``close()`` methods with the usual semantics. The code is partly based on ``bottle.static_file``. .. _byte serving: https://tools.ietf.org/html/rfc2616#page-138 """ if not hasattr(fd, 'read'): raise ValueError("Object '{}' has no read() method".format(fd)) headers = {} status = 200 if not ctype and filename is not None: ctype, enc = mimetypes.guess_type(filename) if enc: headers['Content-Encoding'] = enc if ctype: if ctype.startswith('text/'): # We expect and assume all text files are encoded UTF-8. It's # broadcaster's job to ensure this is true. ctype += '; charset=%s' % charset headers['Content-Type'] = ctype if size: headers['Content-Length'] = size headers['Accept-Ranges'] = 'bytes' if timestamp: headers['Last-Modified'] = format_ts(timestamp) # Check if If-Modified-Since header is in request and respond early modsince = request.environ.get('HTTP_IF_MODIFIED_SINCE') print(modsince) modsince = modsince and parse_date(modsince.split(';')[0].strip()) if modsince is not None and modsince >= timestamp: headers['Date'] = format_ts() return HTTPResponse(status=304, **headers) if attachment and filename: headers['Content-Disposition'] = 'attachment; filename="%s"' % filename if request.method == 'HEAD': # Request is a HEAD, so remove any fd body fd = '' ranges = request.environ.get('HTTP_RANGE') if size and ranges: ranges = list(parse_range_header(ranges, size)) if not ranges: return HTTPError(416, 'Request Range Not Satisfiable') start, end = ranges[0] headers['Content-Range'] = 'bytes %d-%d/%d' % (start, end - 1, size) length = end - start headers['Content-Length'] = str(length) fd = wrapper(fd, start, length) status = 206 return HTTPResponse(fd, status=status, **headers)
def static_file(filename, root, mimetype="auto", download=False, charset="UTF-8"): """ Open a file in a safe way and return :exc:`HTTPResponse` with status code 200, 305, 403 or 404. The ``Content-Type``, ``Content-Encoding``, ``Content-Length`` and ``Last-Modified`` headers are set if possible. Special support for ``If-Modified-Since``, ``Range`` and ``HEAD`` requests. :param filename: Name or path of the file to send. :param root: Root path for file lookups. Should be an absolute directory path. :param mimetype: Defines the content-type header (default: guess from file extension) :param download: If True, ask the browser to open a `Save as...` dialog instead of opening the file with the associated program. You can specify a custom filename as a string. If not specified, the original filename is used (default: False). :param charset: The charset to use for files with a ``text/*`` mime-type. (default: UTF-8) """ root = os.path.abspath(root) + os.sep filename = os.path.abspath(os.path.join(root, filename.strip("/\\"))) headers = dict() if not filename.startswith(root): return HTTPError(403, "Access denied.") if not os.path.exists(filename) or not os.path.isfile(filename): return HTTPError(404, "File does not exist.") if not os.access(filename, os.R_OK): return HTTPError(403, "You do not have permission to access this file.") if mimetype == "auto": mimetype, encoding = mimetypes.guess_type(filename) if encoding: headers["Content-Encoding"] = encoding if mimetype: if mimetype[:5] == "text/" and charset and "charset" not in mimetype: mimetype += "; charset=%s" % charset headers["Content-Type"] = mimetype if download: download = os.path.basename(filename if download == True else download) headers["Content-Disposition"] = 'attachment; filename="%s"' % download stats = os.stat(filename) headers["Content-Length"] = clen = stats.st_size lm = time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(stats.st_mtime)) headers["Last-Modified"] = lm ### Added here: headers["Cache-Control"] = "max-age=3600, public" ims = request.environ.get("HTTP_IF_MODIFIED_SINCE") if ims: ims = parse_date(ims.split(";")[0].strip()) if ims is not None and ims >= int(stats.st_mtime): headers["Date"] = time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime()) return HTTPResponse(status=304, **headers) body = "" if request.method == "HEAD" else open(filename, "rb") headers["Accept-Ranges"] = "bytes" ranges = request.environ.get("HTTP_RANGE") if "HTTP_RANGE" in request.environ: ranges = list(parse_range_header(request.environ["HTTP_RANGE"], clen)) if not ranges: return HTTPError(416, "Requested Range Not Satisfiable") offset, end = ranges[0] headers["Content-Range"] = "bytes %d-%d/%d" % (offset, end - 1, clen) headers["Content-Length"] = str(end - offset) if body: body = _file_iter_range(body, offset, end - offset) return HTTPResponse(body, status=206, **headers) return HTTPResponse(body, **headers)