diff --git a/cista/app.py b/cista/app.py index 2b8d80d..5672a17 100644 --- a/cista/app.py +++ b/cista/app.py @@ -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("/", 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//") -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//") +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() diff --git a/cista/auth.py b/cista/auth.py index b3d27d4..7efeb8c 100644 --- a/cista/auth.py +++ b/cista/auth.py @@ -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")