More efficient flat file list format and various UX improvements #3
84
cista/app.py
84
cista/app.py
|
@ -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()
|
||||||
|
|
|
@ -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")
|
||||||
|
|
Loading…
Reference in New Issue
Block a user