Streaming content from a Flask route wrapped into a Tornado route is a buffering nightmare. `/file` has now been migrated to a pure Tornado asynchronous route instead.
This commit is contained in:
parent
c7ee97bb0b
commit
6faa845afd
3 changed files with 122 additions and 68 deletions
|
@ -1,68 +0,0 @@
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from typing import Generator
|
|
||||||
|
|
||||||
from flask import Blueprint, abort, request
|
|
||||||
from flask.wrappers import Response
|
|
||||||
|
|
||||||
from platypush.backend.http.app import template_folder
|
|
||||||
from platypush.backend.http.app.utils import authenticate, logger
|
|
||||||
from platypush.utils import get_mime_type
|
|
||||||
|
|
||||||
file = Blueprint('file', __name__, template_folder=template_folder)
|
|
||||||
|
|
||||||
# Declare routes list
|
|
||||||
__routes__ = [
|
|
||||||
file,
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@file.route('/file', methods=['GET', 'HEAD'])
|
|
||||||
@authenticate()
|
|
||||||
def get_file_route():
|
|
||||||
"""
|
|
||||||
Endpoint to read the content of a file on the server.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def read_file(path: str) -> Generator[bytes, None, None]:
|
|
||||||
with open(path, 'rb') as f:
|
|
||||||
yield from iter(lambda: f.read(4096), b'')
|
|
||||||
|
|
||||||
path = os.sep + os.path.join(
|
|
||||||
*[
|
|
||||||
token
|
|
||||||
for token in re.sub(
|
|
||||||
r'^\.\./',
|
|
||||||
'',
|
|
||||||
re.sub(
|
|
||||||
r'^\./',
|
|
||||||
'',
|
|
||||||
request.args.get('path', '').lstrip(os.sep).lstrip(' ') or '',
|
|
||||||
),
|
|
||||||
).split(os.sep)
|
|
||||||
if token
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
logger().debug('Received file read request for %r', request.path)
|
|
||||||
|
|
||||||
if not os.path.isfile(path):
|
|
||||||
logger().warning('File not found: %r', path)
|
|
||||||
abort(404, 'File not found')
|
|
||||||
|
|
||||||
try:
|
|
||||||
headers = {
|
|
||||||
'Content-Length': str(os.path.getsize(path)),
|
|
||||||
'Content-Type': (get_mime_type(path) or 'application/octet-stream'),
|
|
||||||
}
|
|
||||||
|
|
||||||
if request.method == 'HEAD':
|
|
||||||
return Response(status=200, headers=headers)
|
|
||||||
|
|
||||||
return read_file(path), 200, headers
|
|
||||||
except PermissionError:
|
|
||||||
logger().warning('Permission denied to read file %r', path)
|
|
||||||
abort(403, 'Permission denied')
|
|
||||||
|
|
||||||
|
|
||||||
# vim:sw=4:ts=4:et:
|
|
|
@ -42,6 +42,7 @@ class StreamingRoute(RequestHandler, PubSubMixin, ABC):
|
||||||
Make sure that errors are always returned in JSON format.
|
Make sure that errors are always returned in JSON format.
|
||||||
"""
|
"""
|
||||||
self.set_header("Content-Type", "application/json")
|
self.set_header("Content-Type", "application/json")
|
||||||
|
self.set_status(status_code)
|
||||||
self.finish(
|
self.finish(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{"status": status_code, "error": error or responses.get(status_code)}
|
{"status": status_code, "error": error or responses.get(status_code)}
|
||||||
|
|
121
platypush/backend/http/app/streaming/plugins/file.py
Normal file
121
platypush/backend/http/app/streaming/plugins/file.py
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
import os
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime as dt
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
from tornado.web import stream_request_body
|
||||||
|
|
||||||
|
from platypush.utils import get_mime_type
|
||||||
|
|
||||||
|
from .. import StreamingRoute
|
||||||
|
|
||||||
|
|
||||||
|
@stream_request_body
|
||||||
|
class FileRoute(StreamingRoute):
|
||||||
|
"""
|
||||||
|
Generic route to read the content of a file on the server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
BUFSIZE = 1024
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def path(cls) -> str:
|
||||||
|
"""
|
||||||
|
Route: GET /file?path=<path>[&download]
|
||||||
|
"""
|
||||||
|
return r"^/file$"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def download(self) -> bool:
|
||||||
|
return 'download' in self.request.arguments
|
||||||
|
|
||||||
|
@property
|
||||||
|
def file_path(self) -> str:
|
||||||
|
return os.path.expanduser(
|
||||||
|
self.request.arguments.get('path', [b''])[0].decode('utf-8')
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def file_size(self) -> int:
|
||||||
|
return os.path.getsize(self.file_path)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def range(self) -> Tuple[Optional[int], Optional[int]]:
|
||||||
|
range_hdr = self.request.headers.get('Range')
|
||||||
|
if not range_hdr:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
start, end = range_hdr.split('=')[-1].split('-')
|
||||||
|
start = int(start) if start else 0
|
||||||
|
end = int(end) if end else self.file_size - 1
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
def set_headers(self):
|
||||||
|
self.set_header('Content-Length', str(os.path.getsize(self.file_path)))
|
||||||
|
self.set_header(
|
||||||
|
'Content-Type', get_mime_type(self.file_path) or 'application/octet-stream'
|
||||||
|
)
|
||||||
|
self.set_header('Accept-Ranges', 'bytes')
|
||||||
|
self.set_header(
|
||||||
|
'Last-Modified',
|
||||||
|
dt.fromtimestamp(os.path.getmtime(self.file_path)).strftime(
|
||||||
|
'%a, %d %b %Y %H:%M:%S GMT'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.download:
|
||||||
|
self.set_header(
|
||||||
|
'Content-Disposition',
|
||||||
|
f'attachment; filename="{os.path.basename(self.file_path)}"',
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.range[0] is not None:
|
||||||
|
start, end = self.range
|
||||||
|
self.set_header(
|
||||||
|
'Content-Range',
|
||||||
|
f'bytes {start}-{end}/{self.file_size}',
|
||||||
|
)
|
||||||
|
self.set_status(206)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _serve(self):
|
||||||
|
path = self.file_path
|
||||||
|
if not path:
|
||||||
|
self.write_error(400, 'Missing path argument')
|
||||||
|
return
|
||||||
|
|
||||||
|
self.logger.debug('Received file read request for %r', path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(path, 'rb') as f:
|
||||||
|
self.set_headers()
|
||||||
|
yield f
|
||||||
|
except FileNotFoundError:
|
||||||
|
self.write_error(404, 'File not found')
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
except PermissionError:
|
||||||
|
self.write_error(403, 'Permission denied')
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
self.write_error(500, str(e))
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
|
||||||
|
self.finish()
|
||||||
|
|
||||||
|
def get(self) -> None:
|
||||||
|
with self._serve() as f:
|
||||||
|
if f:
|
||||||
|
while True:
|
||||||
|
chunk = f.read(self.BUFSIZE)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
|
||||||
|
self.write(chunk)
|
||||||
|
self.flush()
|
||||||
|
|
||||||
|
def head(self) -> None:
|
||||||
|
with self._serve():
|
||||||
|
pass
|
Loading…
Reference in a new issue