Establish basic file browser and index fallback
This commit is contained in:
parent
4ad8168bb0
commit
6673acf544
@ -702,6 +702,8 @@ class RouteMixin(metaclass=SanicMeta):
|
|||||||
content_type: Optional[bool] = None,
|
content_type: Optional[bool] = None,
|
||||||
apply: bool = True,
|
apply: bool = True,
|
||||||
resource_type: Optional[str] = None,
|
resource_type: Optional[str] = None,
|
||||||
|
autoindex: bool = False,
|
||||||
|
index_name: str = "",
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Register a root to serve files from. The input can either be a
|
Register a root to serve files from. The input can either be a
|
||||||
@ -752,6 +754,8 @@ class RouteMixin(metaclass=SanicMeta):
|
|||||||
strict_slashes,
|
strict_slashes,
|
||||||
content_type,
|
content_type,
|
||||||
resource_type,
|
resource_type,
|
||||||
|
autoindex,
|
||||||
|
index_name,
|
||||||
)
|
)
|
||||||
self._future_statics.add(static)
|
self._future_statics.add(static)
|
||||||
|
|
||||||
@ -825,6 +829,8 @@ class RouteMixin(metaclass=SanicMeta):
|
|||||||
request,
|
request,
|
||||||
content_type=None,
|
content_type=None,
|
||||||
__file_uri__=None,
|
__file_uri__=None,
|
||||||
|
autoindex=False,
|
||||||
|
index_name="",
|
||||||
):
|
):
|
||||||
not_found = FileNotFound(
|
not_found = FileNotFound(
|
||||||
"File not found",
|
"File not found",
|
||||||
@ -897,7 +903,13 @@ class RouteMixin(metaclass=SanicMeta):
|
|||||||
return await file_stream(
|
return await file_stream(
|
||||||
file_path, headers=headers, _range=_range
|
file_path, headers=headers, _range=_range
|
||||||
)
|
)
|
||||||
return await file(file_path, headers=headers, _range=_range)
|
return await file(
|
||||||
|
file_path,
|
||||||
|
headers=headers,
|
||||||
|
_range=_range,
|
||||||
|
autoindex=autoindex,
|
||||||
|
index_name=index_name,
|
||||||
|
)
|
||||||
except RangeNotSatisfiable:
|
except RangeNotSatisfiable:
|
||||||
raise
|
raise
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
@ -992,6 +1004,8 @@ class RouteMixin(metaclass=SanicMeta):
|
|||||||
static.use_content_range,
|
static.use_content_range,
|
||||||
static.stream_large_files,
|
static.stream_large_files,
|
||||||
content_type=static.content_type,
|
content_type=static.content_type,
|
||||||
|
autoindex=static.autoindex,
|
||||||
|
index_name=static.index_name,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -56,6 +56,8 @@ class FutureStatic(NamedTuple):
|
|||||||
strict_slashes: Optional[bool]
|
strict_slashes: Optional[bool]
|
||||||
content_type: Optional[bool]
|
content_type: Optional[bool]
|
||||||
resource_type: Optional[str]
|
resource_type: Optional[str]
|
||||||
|
autoindex: bool
|
||||||
|
index_name: str
|
||||||
|
|
||||||
|
|
||||||
class FutureSignal(NamedTuple):
|
class FutureSignal(NamedTuple):
|
||||||
|
@ -3,10 +3,12 @@ from __future__ import annotations
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from email.utils import formatdate, parsedate_to_datetime
|
from email.utils import formatdate, parsedate_to_datetime
|
||||||
from mimetypes import guess_type
|
from mimetypes import guess_type
|
||||||
|
from operator import itemgetter
|
||||||
from os import path
|
from os import path
|
||||||
from pathlib import PurePath
|
from pathlib import Path, PurePath
|
||||||
|
from stat import S_ISDIR
|
||||||
from time import time
|
from time import time
|
||||||
from typing import Any, AnyStr, Callable, Dict, Optional, Union
|
from typing import Any, AnyStr, Callable, Dict, Optional, Tuple, Union
|
||||||
from urllib.parse import quote_plus
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
from sanic.compat import Header, open_async, stat_async
|
from sanic.compat import Header, open_async, stat_async
|
||||||
@ -164,6 +166,8 @@ async def file(
|
|||||||
max_age: Optional[Union[float, int]] = None,
|
max_age: Optional[Union[float, int]] = None,
|
||||||
no_store: Optional[bool] = None,
|
no_store: Optional[bool] = None,
|
||||||
_range: Optional[Range] = None,
|
_range: Optional[Range] = None,
|
||||||
|
autoindex: bool = False,
|
||||||
|
index_name: str = "",
|
||||||
) -> HTTPResponse:
|
) -> HTTPResponse:
|
||||||
"""Return a response object with file data.
|
"""Return a response object with file data.
|
||||||
:param status: HTTP response code. Won't enforce the passed in
|
:param status: HTTP response code. Won't enforce the passed in
|
||||||
@ -226,16 +230,25 @@ async def file(
|
|||||||
|
|
||||||
filename = filename or path.split(location)[-1]
|
filename = filename or path.split(location)[-1]
|
||||||
|
|
||||||
async with await open_async(location, mode="rb") as f:
|
try:
|
||||||
if _range:
|
async with await open_async(location, mode="rb") as f:
|
||||||
await f.seek(_range.start)
|
if _range:
|
||||||
out_stream = await f.read(_range.size)
|
await f.seek(_range.start)
|
||||||
headers[
|
out_stream = await f.read(_range.size)
|
||||||
"Content-Range"
|
headers[
|
||||||
] = f"bytes {_range.start}-{_range.end}/{_range.total}"
|
"Content-Range"
|
||||||
status = 206
|
] = f"bytes {_range.start}-{_range.end}/{_range.total}"
|
||||||
else:
|
status = 206
|
||||||
out_stream = await f.read()
|
else:
|
||||||
|
out_stream = await f.read()
|
||||||
|
except IsADirectoryError:
|
||||||
|
if autoindex or index_name:
|
||||||
|
maybe_response = await AutoIndex(
|
||||||
|
Path(location), autoindex, index_name
|
||||||
|
).handle()
|
||||||
|
if maybe_response:
|
||||||
|
return maybe_response
|
||||||
|
raise
|
||||||
|
|
||||||
mime_type = mime_type or guess_type(filename)[0] or "text/plain"
|
mime_type = mime_type or guess_type(filename)[0] or "text/plain"
|
||||||
return HTTPResponse(
|
return HTTPResponse(
|
||||||
@ -331,3 +344,85 @@ async def file_stream(
|
|||||||
headers=headers,
|
headers=headers,
|
||||||
content_type=mime_type,
|
content_type=mime_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AutoIndex:
|
||||||
|
INDEX_STYLE = """
|
||||||
|
html { font-family: sans-serif }
|
||||||
|
h2 { color: #888; }
|
||||||
|
ul { padding: 0; list-style: none; }
|
||||||
|
li {
|
||||||
|
display: flex; justify-content: space-between;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
li > span { padding: 0.1rem 0.6rem; }
|
||||||
|
li > span:first-child { flex: 4; }
|
||||||
|
li > span:last-child { flex: 1; }
|
||||||
|
"""
|
||||||
|
OUTPUT_HTML = (
|
||||||
|
"<!DOCTYPE html><html lang=en>"
|
||||||
|
"<meta charset=UTF-8><title>{title} - {location}</title>\n"
|
||||||
|
"<style>{style}</style>\n"
|
||||||
|
"<h1>{title}</h1>\n"
|
||||||
|
"<h2>{location}</h2>\n"
|
||||||
|
"{body}"
|
||||||
|
)
|
||||||
|
FILE_WRAPPER_HTML = "<ul>{first_line}{files}</ul>"
|
||||||
|
FILE_LINE_HTML = (
|
||||||
|
"<li>"
|
||||||
|
"<span>{icon} <a href={file_name}>{file_name}</a></span>"
|
||||||
|
"<span>{file_access}</span>"
|
||||||
|
"<span>{file_size}</span>"
|
||||||
|
"</li>"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, directory: Path, autoindex: bool, index_name: str
|
||||||
|
) -> None:
|
||||||
|
self.directory = directory
|
||||||
|
self.autoindex = autoindex
|
||||||
|
self.index_name = index_name
|
||||||
|
|
||||||
|
async def handle(self):
|
||||||
|
index_file = self.directory / self.index_name
|
||||||
|
if self.autoindex and (not index_file.exists() or not self.index_name):
|
||||||
|
return await self.index()
|
||||||
|
|
||||||
|
if self.index_name:
|
||||||
|
return await file(index_file)
|
||||||
|
|
||||||
|
async def index(self):
|
||||||
|
return html(
|
||||||
|
self.OUTPUT_HTML.format(
|
||||||
|
title="📁 File browser",
|
||||||
|
style=self.INDEX_STYLE,
|
||||||
|
location=self.directory.absolute(),
|
||||||
|
body=self._list_files(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _list_files(self) -> str:
|
||||||
|
prepared = [self._prepare_file(f) for f in self.directory.iterdir()]
|
||||||
|
files = "".join(itemgetter(2)(p) for p in sorted(prepared))
|
||||||
|
return self.FILE_WRAPPER_HTML.format(
|
||||||
|
files=files,
|
||||||
|
first_line=self.FILE_LINE_HTML.format(
|
||||||
|
icon="📁", file_name="..", file_access="", file_size=""
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _prepare_file(self, path: Path) -> Tuple[int, str, str]:
|
||||||
|
stat = path.stat()
|
||||||
|
modified = datetime.fromtimestamp(stat.st_mtime)
|
||||||
|
is_dir = S_ISDIR(stat.st_mode)
|
||||||
|
icon = "📁" if is_dir else "📄"
|
||||||
|
file_name = path.name
|
||||||
|
if is_dir:
|
||||||
|
file_name += "/"
|
||||||
|
display = self.FILE_LINE_HTML.format(
|
||||||
|
icon=icon,
|
||||||
|
file_name=file_name,
|
||||||
|
file_access=modified.isoformat(),
|
||||||
|
file_size=stat.st_size,
|
||||||
|
)
|
||||||
|
return is_dir * -1, file_name, display
|
||||||
|
Loading…
x
Reference in New Issue
Block a user