Establish basic file browser and index fallback

This commit is contained in:
Adam Hopkins 2023-01-24 10:27:55 +02:00
parent 4ad8168bb0
commit 6673acf544
No known key found for this signature in database
GPG Key ID: 9F85EE6C807303FB
3 changed files with 124 additions and 13 deletions

View File

@ -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,
) )
) )

View File

@ -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):

View File

@ -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