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,
apply: bool = True,
resource_type: Optional[str] = None,
autoindex: bool = False,
index_name: str = "",
):
"""
Register a root to serve files from. The input can either be a
@ -752,6 +754,8 @@ class RouteMixin(metaclass=SanicMeta):
strict_slashes,
content_type,
resource_type,
autoindex,
index_name,
)
self._future_statics.add(static)
@ -825,6 +829,8 @@ class RouteMixin(metaclass=SanicMeta):
request,
content_type=None,
__file_uri__=None,
autoindex=False,
index_name="",
):
not_found = FileNotFound(
"File not found",
@ -897,7 +903,13 @@ class RouteMixin(metaclass=SanicMeta):
return await file_stream(
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:
raise
except FileNotFoundError:
@ -992,6 +1004,8 @@ class RouteMixin(metaclass=SanicMeta):
static.use_content_range,
static.stream_large_files,
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]
content_type: Optional[bool]
resource_type: Optional[str]
autoindex: bool
index_name: str
class FutureSignal(NamedTuple):

View File

@ -3,10 +3,12 @@ from __future__ import annotations
from datetime import datetime, timezone
from email.utils import formatdate, parsedate_to_datetime
from mimetypes import guess_type
from operator import itemgetter
from os import path
from pathlib import PurePath
from pathlib import Path, PurePath
from stat import S_ISDIR
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 sanic.compat import Header, open_async, stat_async
@ -164,6 +166,8 @@ async def file(
max_age: Optional[Union[float, int]] = None,
no_store: Optional[bool] = None,
_range: Optional[Range] = None,
autoindex: bool = False,
index_name: str = "",
) -> HTTPResponse:
"""Return a response object with file data.
: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]
async with await open_async(location, mode="rb") as f:
if _range:
await f.seek(_range.start)
out_stream = await f.read(_range.size)
headers[
"Content-Range"
] = f"bytes {_range.start}-{_range.end}/{_range.total}"
status = 206
else:
out_stream = await f.read()
try:
async with await open_async(location, mode="rb") as f:
if _range:
await f.seek(_range.start)
out_stream = await f.read(_range.size)
headers[
"Content-Range"
] = f"bytes {_range.start}-{_range.end}/{_range.total}"
status = 206
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"
return HTTPResponse(
@ -331,3 +344,85 @@ async def file_stream(
headers=headers,
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