More efficient flat file list format and various UX improvements #3

Merged
leo merged 19 commits from major-upgrade into main 2023-11-12 23:20:40 +00:00
2 changed files with 53 additions and 35 deletions
Showing only changes of commit 84ce4b9220 - Show all commits

View File

@ -4,7 +4,7 @@ import mimetypes
from collections import deque
from concurrent.futures import ThreadPoolExecutor
from importlib.resources import files
from pathlib import Path
from pathlib import Path, PurePath
from stat import S_IFDIR, S_IFREG
from urllib.parse import unquote
from wsgiref.handlers import format_date_time
@ -14,13 +14,13 @@ import sanic.helpers
from blake3 import blake3
from natsort import natsorted, ns
from sanic import Blueprint, Sanic, empty, raw
from sanic.exceptions import Forbidden, NotFound
from sanic.exceptions import Forbidden, NotFound, ServerError, ServiceUnavailable
from sanic.log import logging
from stream_zip import ZIP_AUTO, stream_zip
from cista import auth, config, session, watching
from cista.api import bp
from cista.protocol import DirEntry
from cista.protocol import DirEntry, DirList
from cista.util.apphelpers import handle_sanic_exception
# Workaround until Sanic PR #2824 is merged
@ -36,7 +36,9 @@ app.exception(Exception)(handle_sanic_exception)
async def main_start(app, loop):
config.load_config()
await watching.start(app, loop)
app.ctx.threadexec = ThreadPoolExecutor(max_workers=8)
app.ctx.threadexec = ThreadPoolExecutor(
max_workers=8, thread_name_prefix="cista-ioworker"
)
@app.after_server_stop
@ -49,8 +51,8 @@ async def main_stop(app, loop):
async def use_session(req):
req.ctx.session = session.get(req)
try:
req.ctx.username = req.ctx.session["username"]
req.ctx.user = config.config.users[req.ctx.session["username"]] # type: ignore
req.ctx.username = req.ctx.session["username"] # type: ignore
req.ctx.user = config.config.users[req.ctx.username]
except (AttributeError, KeyError, TypeError):
req.ctx.username = None
req.ctx.user = None
@ -81,22 +83,16 @@ def http_fileserver(app, _):
www = {}
@app.before_server_start
async def load_wwwroot(*_ignored):
global www
www = await asyncio.get_event_loop().run_in_executor(None, _load_wwwroot, www)
def _load_wwwroot(www):
wwwnew = {}
base = files("cista") / "wwwroot"
paths = ["."]
base = Path(__file__).with_name("wwwroot")
paths = [PurePath()]
while paths:
path = paths.pop(0)
current = base / path
for p in current.iterdir():
if p.is_dir():
paths.append(current / p.parts[-1])
paths.append(p.relative_to(base))
continue
name = p.relative_to(base).as_posix()
mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
@ -127,15 +123,35 @@ def _load_wwwroot(www):
if len(br) >= len(data):
br = False
wwwnew[name] = data, br, headers
if not wwwnew:
raise ServerError(
"Web frontend missing. Did you forget npm run build?",
extra={"wwwroot": str(base)},
quiet=True,
)
return wwwnew
@app.add_task
@app.before_server_start
async def start(app):
await load_wwwroot(app)
if app.debug:
app.add_task(refresh_wwwroot())
async def load_wwwroot(app):
global www
www = await asyncio.get_event_loop().run_in_executor(
app.ctx.threadexec, _load_wwwroot, www
)
async def refresh_wwwroot():
while True:
await asyncio.sleep(0.5)
try:
wwwold = www
await load_wwwroot()
await load_wwwroot(app)
changes = ""
for name in sorted(www):
attr = www[name]
@ -151,7 +167,6 @@ async def refresh_wwwroot():
print("Error loading wwwroot", e)
if not app.debug:
return
await asyncio.sleep(0.5)
@app.route("/<path:path>", methods=["GET", "HEAD"])
@ -166,21 +181,19 @@ async def wwwroot(req, path=""):
return empty(304, headers=headers)
# Brotli compressed?
if br and "br" in req.headers.accept_encoding.split(", "):
headers = {
**headers,
"content-encoding": "br",
}
headers = {**headers, "content-encoding": "br"}
data = br
return raw(data, headers=headers)
@app.get("/zip/<keys>/<zipfile:ext=zip>")
async def zip_download(req, keys, zipfile, ext):
"""Download a zip archive of the given keys"""
wanted = set(keys.split("+"))
def get_files(wanted: set) -> list:
if not isinstance(watching.tree[""], DirEntry):
raise ServiceUnavailable(headers={"retry-after": 1})
root = Path(watching.rootpath)
with watching.tree_lock:
q = deque([([], None, watching.tree[""].dir)])
files = []
q: deque[tuple[list[str], None | list[str], DirList]] = deque(
[([], None, watching.tree[""].dir)]
)
while q:
locpar, relpar, d = q.pop()
for name, attr in d.items():
@ -193,9 +206,16 @@ async def zip_download(req, keys, zipfile, ext):
if isdir:
q.append((loc, rel, attr.dir))
if rel:
files.append(
("/".join(rel), Path(watching.rootpath.joinpath(*loc)))
)
files.append(("/".join(rel), root.joinpath(*loc)))
return natsorted(files, key=lambda f: "/".join(f[0]), alg=ns.IGNORECASE)
@app.get("/zip/<keys>/<zipfile:ext=zip>")
async def zip_download(req, keys, zipfile, ext):
"""Download a zip archive of the given keys"""
wanted = set(keys.split("+"))
files = get_files(wanted)
if not files:
raise NotFound(
@ -205,8 +225,6 @@ async def zip_download(req, keys, zipfile, ext):
if wanted:
raise NotFound("Files not found", context={"missing": wanted})
files = natsorted(files, key=lambda f: f[0], alg=ns.IGNORECASE)
def local_files(files):
for rel, p in files:
s = p.stat()

View File

@ -68,10 +68,10 @@ def verify(request, *, privileged=False):
if request.ctx.user:
if request.ctx.user.privileged:
return
raise Forbidden("Access Forbidden: Only for privileged users")
raise Forbidden("Access Forbidden: Only for privileged users", quiet=True)
elif config.config.public or request.ctx.user:
return
raise Unauthorized("Login required", "cookie")
raise Unauthorized("Login required", "cookie", quiet=True)
bp = Blueprint("auth")