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

View File

@ -68,10 +68,10 @@ def verify(request, *, privileged=False):
if request.ctx.user: if request.ctx.user:
if request.ctx.user.privileged: if request.ctx.user.privileged:
return 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: elif config.config.public or request.ctx.user:
return return
raise Unauthorized("Login required", "cookie") raise Unauthorized("Login required", "cookie", quiet=True)
bp = Blueprint("auth") bp = Blueprint("auth")