Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Establish basic file browser and index fallback #2662

Merged
merged 54 commits into from
Feb 5, 2023
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
6673acf
Establish basic file browser and index fallback
ahopkins Jan 24, 2023
fed2ef3
Remove location information
ahopkins Jan 24, 2023
36e3cc9
Use html5tagger for AutoIndex
ahopkins Jan 25, 2023
e8bb283
Valid HTML5
ahopkins Jan 25, 2023
39a4a75
Add new pages module
ahopkins Jan 26, 2023
2e36507
Refactor to allow for common pages
ahopkins Jan 26, 2023
ca0e933
No logging of exception
ahopkins Jan 26, 2023
2c8f180
squash
ahopkins Jan 26, 2023
fa6dbdd
Style fixes for file table
ahopkins Jan 26, 2023
10d4f28
Simple server to include autoindex
ahopkins Jan 26, 2023
d9c883e
Add a header bar with Sanic logo. Remove duplicate title element. Avo…
Tronic Jan 27, 2023
e328d44
Timestamps a bit less ugly.
Tronic Jan 27, 2023
41da8bb
Improve navigation with breadcrumbs
Tronic Jan 27, 2023
2038799
Remove parent directory link from table
Tronic Jan 27, 2023
859a813
Fix Sanic brand colour in breadcrumbs.
Tronic Jan 27, 2023
a00ec8a
Better UX for empty folders.
Tronic Jan 27, 2023
32d62c2
Style tweaks
Tronic Jan 27, 2023
b517523
URL sanitation.
Tronic Jan 27, 2023
faf1ff8
Fix the document title (needs positional argument).
Tronic Jan 27, 2023
f30f53f
Move DirectoryHandler to app instance
ahopkins Jan 28, 2023
4f000ab
Define colors for light/default mode as well.
Tronic Jan 28, 2023
b46b81d
Set styles
ahopkins Jan 31, 2023
713abe3
Merge branch 'issue2661' of github.com:sanic-org/sanic into issue2661
ahopkins Jan 31, 2023
1b43aa5
No auto registration of fallback error handlers
ahopkins Jan 31, 2023
ddf3a49
Remove accidental file
ahopkins Jan 31, 2023
5dfd48f
Merge branch 'main' of github.com:sanic-org/sanic into issue2661
ahopkins Jan 31, 2023
2df5b19
Sans serif w/ autoindex monospace
ahopkins Jan 31, 2023
b4ba22f
Move styles to CSS files
ahopkins Feb 4, 2023
29e0ec1
Use autoindex and index as params
ahopkins Feb 4, 2023
58dc525
Return BrowserResponse
ahopkins Feb 4, 2023
bd92382
Branding changes
ahopkins Feb 4, 2023
a7491a4
Overhaul of architecture
ahopkins Feb 5, 2023
6cf338b
Remove default index
ahopkins Feb 5, 2023
630e4f8
Language change
ahopkins Feb 5, 2023
8e2faf6
Fix comment
ahopkins Feb 5, 2023
4cef5ee
Add setup.py git req
ahopkins Feb 5, 2023
a77c598
Add testing
ahopkins Feb 5, 2023
a81742a
Rename property/arg
ahopkins Feb 5, 2023
11bb4dd
CSS changes
ahopkins Feb 5, 2023
bc8697a
Add permission error for Windows
ahopkins Feb 5, 2023
d029790
Breadcrumb slashes
ahopkins Feb 5, 2023
af755ae
Add docstring
ahopkins Feb 5, 2023
9d4f026
Read text as utf-8
ahopkins Feb 5, 2023
d95a070
Update git repo
ahopkins Feb 5, 2023
4302774
Move 🏠 to CSS
ahopkins Feb 5, 2023
a6c033e
Update sanic/pages/directory_page.py
ahopkins Feb 5, 2023
45a8a55
Breadcrumb style
ahopkins Feb 5, 2023
0e56c11
squash
ahopkins Feb 5, 2023
bdfa11a
upgrade black and run
ahopkins Feb 5, 2023
0961d20
Fix dep
ahopkins Feb 5, 2023
24a284e
html5tagger to pypi version
ahopkins Feb 5, 2023
5b6a1de
Typing fixes
ahopkins Feb 5, 2023
a565b6f
Simpler compat
ahopkins Feb 5, 2023
b69a2ea
py3.7 support
ahopkins Feb 5, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion sanic/mixins/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,8 @@ def static(
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
Expand Down Expand Up @@ -752,6 +754,8 @@ def static(
strict_slashes,
content_type,
resource_type,
autoindex,
index_name,
)
self._future_statics.add(static)

Expand Down Expand Up @@ -825,6 +829,8 @@ async def _static_request_handler(
request,
content_type=None,
__file_uri__=None,
autoindex=False,
index_name="",
):
not_found = FileNotFound(
"File not found",
Expand Down Expand Up @@ -897,7 +903,13 @@ async def _static_request_handler(
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:
Expand Down Expand Up @@ -992,6 +1004,8 @@ def _register_static(
static.use_content_range,
static.stream_large_files,
content_type=static.content_type,
autoindex=static.autoindex,
index_name=static.index_name,
)
)

Expand Down
2 changes: 2 additions & 0 deletions sanic/models/futures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
130 changes: 118 additions & 12 deletions sanic/response/convenience.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@
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, Iterable, Optional, Union
from urllib.parse import quote_plus

from html5tagger import Document, E

from sanic.compat import Header, open_async, stat_async
from sanic.constants import DEFAULT_HTTP_CONTENT_TYPE
from sanic.helpers import Default, _default
Expand Down Expand Up @@ -164,6 +168,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
Expand Down Expand Up @@ -226,16 +232,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(
Expand Down Expand Up @@ -331,3 +346,94 @@ async def _streaming_fn(response):
headers=headers,
content_type=mime_type,
)


class AutoIndex:
STYLE = """
html { font-family: sans-serif; }
main { padding: 1rem; }
table { width: 100%; max-width: 1200px; }
td { font-family: monospace; }
table.autoindex td:first-child { width: 65% }
table.autoindex td:last-child { text-align: right; width: 15%; }
span.icon { margin-right: 1rem; }
@media (prefers-color-scheme: dark) {
html { background: #111; color: #ccc; }
a { color: #ccc; }
a:visited { color: #777; }
}
"""
TITLE = "📁 File browser"

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.render())

def render(self) -> str:
doc = Document(title=self.TITLE, lang="en")
self._head(doc)
with doc.main:
self._headline(doc)
self._file_table(doc)
return str(doc)

def _head(self, doc: Document):
doc.head.title(self.TITLE).style(self.STYLE)

def _headline(self, doc: Document):
doc.h1(self.TITLE)

def _file_table(self, doc: Document):
with doc.table(class_="autoindex"):
self._parent(doc)
for f in self._iter_files():
del f["priority"]
self._file_cell(doc, **f)

def _parent(self, doc: Document):
self._file_cell(doc, "📁", "..", "", "")

def _file_cell(
self,
doc: Document,
icon: str,
file_name: str,
file_access: str,
file_size: str,
):
first = E.span(icon, class_="icon").a(file_name, href=file_name)
doc.tr.td(first).td(file_access).td(file_size)

def _prepare_file(self, path: Path) -> Dict[str, Union[int, 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 += "/"
return {
"priority": is_dir * -1,
"file_name": file_name,
"icon": icon,
"file_access": modified.isoformat(),
"file_size": stat.st_size,
}

def _iter_files(self) -> Iterable[Dict[str, Any]]:
prepared = [self._prepare_file(f) for f in self.directory.iterdir()]
return sorted(prepared, key=itemgetter("priority"))