WebDAV sync support, access tokens, REST control endpoints (#10)
Implement complete WebDAV file serving compatible with various clients from Windows File Explorer to more specialized sync tools. The old control WebSocket has been updated to part-DAV, part REST API instead. Implemented user:pass BASIC auth. Added UI and backend for creating tokens that avoid the need to use actual username and password for requests from CLI or DAV.
This commit is contained in:
+22
-13
@@ -7,9 +7,13 @@ from sanic import Blueprint, json
|
||||
from sanic.exceptions import BadRequest
|
||||
|
||||
from cista import __version__, auth, config, sso, watching
|
||||
from cista.auth import (
|
||||
create_token_handler,
|
||||
delete_token_handler,
|
||||
list_tokens_handler,
|
||||
)
|
||||
from cista.fileio import FileServer
|
||||
from cista.protocol import ControlTypes, StatusMsg
|
||||
from cista.util.apphelpers import asend, websocket_wrapper
|
||||
from cista.util.apphelpers import websocket_wrapper
|
||||
|
||||
bp = Blueprint("api", url_prefix="/api")
|
||||
fileserver = FileServer()
|
||||
@@ -25,17 +29,6 @@ async def stop_fileserver(app):
|
||||
await fileserver.stop()
|
||||
|
||||
|
||||
@bp.websocket("control")
|
||||
@websocket_wrapper
|
||||
async def control(req, ws):
|
||||
while True:
|
||||
cmd = msgspec.json.decode(await ws.recv(), type=ControlTypes)
|
||||
await asyncio.to_thread(cmd)
|
||||
# Signal the watcher about affected paths
|
||||
watching.notify_change(*cmd.affected_paths())
|
||||
await asend(ws, StatusMsg(status="ack", req=cmd))
|
||||
|
||||
|
||||
@bp.websocket("watch")
|
||||
@websocket_wrapper
|
||||
async def watch(req, ws):
|
||||
@@ -144,3 +137,19 @@ async def update_name(request):
|
||||
# Return the effective name (fallback to path.name if empty)
|
||||
effective_name = name or config.config.path.name
|
||||
return json({"message": "Server name updated", "name": effective_name})
|
||||
|
||||
|
||||
# Token management endpoints (available in all modes; primary path in SSO mode)
|
||||
@bp.get("tokens")
|
||||
async def list_api_tokens(request):
|
||||
return await list_tokens_handler(request)
|
||||
|
||||
|
||||
@bp.post("tokens")
|
||||
async def create_api_token(request):
|
||||
return await create_token_handler(request)
|
||||
|
||||
|
||||
@bp.delete("tokens/<token_id>")
|
||||
async def delete_api_token(request, token_id):
|
||||
return await delete_token_handler(request, token_id)
|
||||
|
||||
+78
-164
@@ -1,19 +1,16 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import mimetypes
|
||||
import re
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from multiprocessing import cpu_count
|
||||
from pathlib import Path, PurePath, PurePosixPath
|
||||
from stat import S_IFDIR, S_IFREG
|
||||
from urllib.parse import unquote
|
||||
from wsgiref.handlers import format_date_time
|
||||
|
||||
import sanic.helpers
|
||||
from blake3 import blake3
|
||||
from sanic import Blueprint, Sanic, empty, json, raw, redirect
|
||||
from sanic.exceptions import BadRequest, Forbidden, NotFound
|
||||
from sanic import Sanic, empty, raw, redirect
|
||||
from sanic.exceptions import Forbidden, NotFound
|
||||
from sanic.log import logger
|
||||
from setproctitle import setproctitle
|
||||
from stream_zip import ZIP_AUTO, stream_zip
|
||||
@@ -21,18 +18,88 @@ from zstandard import ZstdCompressor
|
||||
|
||||
from cista import auth, config, preview, session, sso, watching
|
||||
from cista.preview import shutdown_preview_workers, start_preview_workers
|
||||
from cista.api import bp, fileserver
|
||||
from cista.sanic_logging import configure_access_logging, configure_main_logging, format_access_log
|
||||
from cista.api import bp
|
||||
from cista import fileserver
|
||||
from cista.sanic_logging import (
|
||||
configure_access_logging,
|
||||
configure_main_logging,
|
||||
format_access_log,
|
||||
)
|
||||
from cista.sanic_logging import logger as access_logger
|
||||
from cista.util.apphelpers import handle_sanic_exception
|
||||
|
||||
# Workaround until Sanic PR #2824 is merged
|
||||
sanic.helpers._ENTITY_HEADERS = frozenset()
|
||||
|
||||
configure_access_logging()
|
||||
|
||||
app = Sanic("cista", strict_slashes=True)
|
||||
app.router.ALLOWED_METHODS = (
|
||||
*app.router.ALLOWED_METHODS,
|
||||
"MKCOL",
|
||||
"MOVE",
|
||||
"COPY",
|
||||
"PROPFIND",
|
||||
)
|
||||
|
||||
configure_main_logging()
|
||||
|
||||
|
||||
@app.on_request
|
||||
async def use_session(req):
|
||||
req.ctx._log_start = time.perf_counter()
|
||||
req.ctx._auth_flow = ["session: start"]
|
||||
auth.hydrate_request_auth_context(req, source="app.on_request")
|
||||
# CSRF protection
|
||||
if req.method == "GET" and req.headers.upgrade != "websocket":
|
||||
return # Ordinary GET requests are fine
|
||||
# Check that origin matches host, for browsers which should all send Origin.
|
||||
# Curl doesn't send any Origin header, so we allow it anyway.
|
||||
origin = req.headers.origin
|
||||
if origin and origin.split("//", 1)[1] != req.host:
|
||||
raise Forbidden("Invalid origin: Cross-Site requests not permitted")
|
||||
|
||||
|
||||
@app.on_response
|
||||
async def log_access(req, res):
|
||||
"""Log HTTP access in a clean single-line format."""
|
||||
if req.headers.get("upgrade", "").lower() == "websocket":
|
||||
return res
|
||||
start = getattr(req.ctx, "_log_start", None)
|
||||
duration_ms = (time.perf_counter() - start) * 1000 if start is not None else 0.0
|
||||
client = req.client_ip or "-"
|
||||
host = req.host or "-"
|
||||
path = req.path
|
||||
if req.query_string:
|
||||
qs = req.query_string
|
||||
if isinstance(qs, bytes):
|
||||
qs = qs.decode(errors="replace")
|
||||
path = f"{path}?{qs}"
|
||||
extra = getattr(req.ctx, "_log_extra", None)
|
||||
line = format_access_log(
|
||||
client, res.status, req.method, host, path, duration_ms, extra=extra
|
||||
)
|
||||
access_logger.info(line)
|
||||
return res
|
||||
|
||||
|
||||
@app.on_response
|
||||
async def forward_sso_cookies(req, res):
|
||||
"""Forward Set-Cookie headers from SSO validation to client."""
|
||||
if cookies := getattr(req.ctx, "sso_cookies", None):
|
||||
for cookie in cookies:
|
||||
res.headers.add("set-cookie", cookie)
|
||||
|
||||
|
||||
@app.on_response
|
||||
async def persist_auth_session(req, res):
|
||||
"""Persist a session cookie after successful Authorization-based auth."""
|
||||
username = getattr(req.ctx, "_create_session_username", None)
|
||||
if not username or res.status >= 400:
|
||||
return
|
||||
existing = getattr(req.ctx, "session", None)
|
||||
if isinstance(existing, dict) and existing.get("username") == username:
|
||||
return
|
||||
session.create(res, username, secure=req.scheme == "https")
|
||||
|
||||
|
||||
# Register either SSO proxy or built-in auth routes based on PASKIA_BACKEND_URL
|
||||
if sso.paskia_enabled():
|
||||
app.blueprint(sso.bp) # SSO proxy for /auth/* routes
|
||||
@@ -40,6 +107,7 @@ else:
|
||||
app.blueprint(auth.bp) # Built-in auth routes
|
||||
app.blueprint(preview.bp)
|
||||
app.blueprint(bp)
|
||||
app.blueprint(fileserver.bp)
|
||||
app.exception(Exception)(handle_sanic_exception)
|
||||
|
||||
|
||||
@@ -70,161 +138,7 @@ async def main_stop(app):
|
||||
logger.debug("Cista worker threads all finished")
|
||||
|
||||
|
||||
@app.on_request
|
||||
async def use_session(req):
|
||||
req.ctx._log_start = time.perf_counter()
|
||||
req.ctx.session = session.get(req)
|
||||
try:
|
||||
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
|
||||
# CSRF protection
|
||||
if req.method == "GET" and req.headers.upgrade != "websocket":
|
||||
return # Ordinary GET requests are fine
|
||||
# Check that origin matches host, for browsers which should all send Origin.
|
||||
# Curl doesn't send any Origin header, so we allow it anyway.
|
||||
origin = req.headers.origin
|
||||
if origin and origin.split("//", 1)[1] != req.host:
|
||||
raise Forbidden("Invalid origin: Cross-Site requests not permitted")
|
||||
|
||||
|
||||
@app.on_response
|
||||
async def log_access(req, res):
|
||||
"""Log HTTP access in a clean single-line format."""
|
||||
if req.headers.get("upgrade", "").lower() == "websocket":
|
||||
return res
|
||||
start = getattr(req.ctx, "_log_start", None)
|
||||
duration_ms = (time.perf_counter() - start) * 1000 if start is not None else 0.0
|
||||
client = req.client_ip or "-"
|
||||
host = req.host or "-"
|
||||
path = req.path
|
||||
if req.query_string:
|
||||
qs = req.query_string
|
||||
if isinstance(qs, bytes):
|
||||
qs = qs.decode(errors="replace")
|
||||
path = f"{path}?{qs}"
|
||||
extra = getattr(req.ctx, "_log_extra", None)
|
||||
line = format_access_log(client, res.status, req.method, host, path, duration_ms, extra=extra)
|
||||
access_logger.info(line)
|
||||
return res
|
||||
|
||||
|
||||
@app.on_response
|
||||
async def forward_sso_cookies(req, res):
|
||||
"""Forward Set-Cookie headers from SSO validation to client."""
|
||||
if cookies := getattr(req.ctx, "sso_cookies", None):
|
||||
for cookie in cookies:
|
||||
res.headers.add("set-cookie", cookie)
|
||||
|
||||
|
||||
@app.before_server_start
|
||||
def http_fileserver(app):
|
||||
bp = Blueprint("fileserver")
|
||||
|
||||
@bp.on_request
|
||||
async def verify_fileserver(request):
|
||||
"""Verify access to file server routes."""
|
||||
await auth.verify(request)
|
||||
|
||||
@bp.put("/files/<name:path>")
|
||||
async def upload_file_chunk(request, *args, **kwargs):
|
||||
body = request.body
|
||||
header = request.headers.get("content-range")
|
||||
if header:
|
||||
start, end, total = _parse_content_range(header, len(body))
|
||||
else:
|
||||
start = 0
|
||||
end = len(body)
|
||||
total = end
|
||||
raw_name = kwargs.get("name")
|
||||
if raw_name is None and args:
|
||||
raw_name = args[0]
|
||||
if not isinstance(raw_name, str) or not raw_name:
|
||||
prefix = "/files/"
|
||||
if not request.path.startswith(prefix):
|
||||
raise BadRequest("Invalid upload path")
|
||||
raw_name = request.path[len(prefix) :]
|
||||
rel_name = unquote(raw_name)
|
||||
upload_info = await asyncio.to_thread(
|
||||
fileserver.upload_info,
|
||||
rel_name,
|
||||
start,
|
||||
body,
|
||||
total,
|
||||
)
|
||||
extras = []
|
||||
chunk_len = end - start
|
||||
whole_file = start == 0 and end == total
|
||||
if not whole_file:
|
||||
start_mib = _to_mib_int(start)
|
||||
chunk_mib = _to_mib_int(chunk_len)
|
||||
# Keep range logs compact for fixed-size upload blocks.
|
||||
if chunk_mib == 16:
|
||||
extras.append(f"{start_mib}MiB")
|
||||
else:
|
||||
extras.append(f"{start_mib}+{chunk_mib}MiB")
|
||||
if upload_info.get("created"):
|
||||
extras.append(f"created {_to_mib_int(total)}MiB")
|
||||
size_before = upload_info.get("size_before")
|
||||
size_after = upload_info.get("size_after")
|
||||
if (
|
||||
size_before is not None
|
||||
and size_after is not None
|
||||
and size_before != size_after
|
||||
):
|
||||
extras.append("resized")
|
||||
request.ctx._log_extra = " ".join(extras) if extras else None
|
||||
path = PurePosixPath(rel_name)
|
||||
watching.notify_change(path, *path.parents)
|
||||
return json(
|
||||
{
|
||||
"status": "ack",
|
||||
"req": {
|
||||
"name": rel_name,
|
||||
"size": total,
|
||||
"start": start,
|
||||
"end": end,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
bp.static(
|
||||
"/files/",
|
||||
config.config.path,
|
||||
use_content_range=True,
|
||||
stream_large_files=True,
|
||||
directory_view=True,
|
||||
)
|
||||
app.blueprint(bp)
|
||||
|
||||
|
||||
www = {}
|
||||
_CONTENT_RANGE_RE = re.compile(r"^bytes (\d+)-(\d+)/(\d+)$")
|
||||
|
||||
|
||||
def _parse_content_range(header: str, body_len: int) -> tuple[int, int, int]:
|
||||
m = _CONTENT_RANGE_RE.fullmatch(header.strip())
|
||||
if m is None:
|
||||
raise BadRequest("Invalid Content-Range format")
|
||||
start, end_inclusive, total = (int(v) for v in m.groups())
|
||||
if total <= 0:
|
||||
raise BadRequest("Invalid Content-Range total size")
|
||||
if start > end_inclusive:
|
||||
raise BadRequest("Invalid Content-Range range")
|
||||
if end_inclusive >= total:
|
||||
raise BadRequest("Content-Range exceeds total size")
|
||||
expected_len = end_inclusive - start + 1
|
||||
if expected_len != body_len:
|
||||
raise BadRequest(
|
||||
f"Content length mismatch for range: expected {expected_len}, got {body_len}"
|
||||
)
|
||||
return start, end_inclusive + 1, total
|
||||
|
||||
|
||||
def _to_mib_int(value_bytes: int) -> int:
|
||||
return round(value_bytes / (1 << 20))
|
||||
|
||||
|
||||
def _load_wwwroot(www):
|
||||
|
||||
+917
-5
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ class Config(msgspec.Struct):
|
||||
name: str = ""
|
||||
users: dict[str, User] = {}
|
||||
links: dict[str, Link] = {}
|
||||
tokens: dict[str, Token] = {}
|
||||
|
||||
|
||||
# Typing: arguments for config-modifying functions
|
||||
@@ -43,6 +44,14 @@ class Link(msgspec.Struct, omit_defaults=True):
|
||||
expires: int = 0
|
||||
|
||||
|
||||
class Token(msgspec.Struct, omit_defaults=True):
|
||||
key: str = "" # plain text secret (shown once on creation)
|
||||
username: str = "" # set in built-in mode
|
||||
sso_user_id: str = "" # set in SSO mode
|
||||
name: str = ""
|
||||
created: int = 0 # noqa: N815
|
||||
|
||||
|
||||
# Global variables - initialized during application startup
|
||||
config: Config
|
||||
conffile: Path
|
||||
@@ -204,3 +213,29 @@ def del_user(conf: Config, name: str) -> Config:
|
||||
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
|
||||
settings["users"].pop(name)
|
||||
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
||||
|
||||
|
||||
@modifies_config
|
||||
def update_token(conf: Config, token_id: str, changes: dict) -> Config:
|
||||
"""Create or update a token."""
|
||||
try:
|
||||
t = msgspec.convert(
|
||||
msgspec.to_builtins(conf.tokens[token_id], enc_hook=enc_hook),
|
||||
Token,
|
||||
dec_hook=dec_hook,
|
||||
)
|
||||
except KeyError:
|
||||
t = Token()
|
||||
tdict = msgspec.to_builtins(t, enc_hook=enc_hook)
|
||||
tdict.update(changes)
|
||||
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
|
||||
settings["tokens"][token_id] = msgspec.convert(tdict, Token, dec_hook=dec_hook)
|
||||
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
||||
|
||||
|
||||
@modifies_config
|
||||
def del_token(conf: Config, token_id: str) -> Config:
|
||||
"""Delete a token by its stable id."""
|
||||
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
|
||||
settings["tokens"].pop(token_id, None)
|
||||
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
import asyncio
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
from urllib.parse import quote as url_quote, unquote, urlparse
|
||||
from wsgiref.handlers import format_date_time
|
||||
|
||||
from sanic import Blueprint, HTTPResponse, empty, json
|
||||
from sanic.exceptions import BadRequest, NotFound
|
||||
|
||||
from cista import auth, config, watching
|
||||
from cista.api import fileserver
|
||||
from cista.util import filename
|
||||
|
||||
bp = Blueprint("fileserver", url_prefix="/files")
|
||||
|
||||
_CONTENT_RANGE_RE = re.compile(r"^bytes (\d+)-(\d+)/(\d+)$")
|
||||
_RANGE_RE = re.compile(r"^bytes=(\d*)-(\d*)$")
|
||||
_FILE_CHUNK_SIZE = 1 << 20
|
||||
|
||||
_DAV_NS = "DAV:"
|
||||
ET.register_namespace("D", _DAV_NS)
|
||||
|
||||
|
||||
def _dav_tag(name: str) -> str:
|
||||
return f"{{{_DAV_NS}}}{name}"
|
||||
|
||||
|
||||
@bp.on_request
|
||||
async def verify_fileserver(request):
|
||||
"""Verify access to file server routes."""
|
||||
await auth.verify(request)
|
||||
|
||||
|
||||
@bp.put("/<name:path>")
|
||||
async def upload_file_chunk(request, name):
|
||||
body = request.body
|
||||
header = request.headers.get("content-range")
|
||||
if header:
|
||||
start, end, total = _parse_content_range(header, len(body))
|
||||
else:
|
||||
start = 0
|
||||
end = len(body)
|
||||
total = end
|
||||
|
||||
rel, _ = _safe_relpath(name)
|
||||
rel_name = rel.as_posix()
|
||||
upload_info = await asyncio.to_thread(
|
||||
fileserver.upload_info,
|
||||
rel_name,
|
||||
start,
|
||||
body,
|
||||
total,
|
||||
)
|
||||
extras = []
|
||||
chunk_len = end - start
|
||||
whole_file = start == 0 and end == total
|
||||
if not whole_file:
|
||||
start_mib = _to_mib_int(start)
|
||||
chunk_mib = _to_mib_int(chunk_len)
|
||||
# Keep range logs compact for fixed-size upload blocks.
|
||||
if chunk_mib == 16:
|
||||
extras.append(f"{start_mib}MiB")
|
||||
else:
|
||||
extras.append(f"{start_mib}+{chunk_mib}MiB")
|
||||
if upload_info.get("created"):
|
||||
extras.append(f"created {_to_mib_int(total)}MiB")
|
||||
size_before = upload_info.get("size_before")
|
||||
size_after = upload_info.get("size_after")
|
||||
if size_before is not None and size_after is not None and size_before != size_after:
|
||||
extras.append("resized")
|
||||
request.ctx._log_extra = " ".join(extras) if extras else None
|
||||
watching.notify_change(rel, *rel.parents)
|
||||
return json(
|
||||
{
|
||||
"status": "ack",
|
||||
"req": {
|
||||
"name": rel_name,
|
||||
"size": total,
|
||||
"start": start,
|
||||
"end": end,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.delete("/<name:path>")
|
||||
async def delete_file(request, name):
|
||||
rel, path = _safe_relpath(name)
|
||||
if not rel.parts:
|
||||
raise BadRequest("Refusing to delete root folder")
|
||||
|
||||
def _delete():
|
||||
if not path.exists():
|
||||
raise NotFound(f"File not found: {name}")
|
||||
if path.is_dir():
|
||||
shutil.rmtree(path)
|
||||
else:
|
||||
path.unlink()
|
||||
|
||||
await asyncio.to_thread(_delete)
|
||||
watching.notify_change(rel, *rel.parents)
|
||||
return empty(status=204)
|
||||
|
||||
|
||||
@bp.route("/<name:path>", methods=["MKCOL"])
|
||||
async def create_folder(request, name):
|
||||
rel, path = _safe_relpath(name)
|
||||
if not rel.parts:
|
||||
raise BadRequest("Refusing to create root folder")
|
||||
await asyncio.to_thread(path.mkdir, parents=True, exist_ok=False)
|
||||
watching.notify_change(rel, *rel.parents)
|
||||
return empty(status=201)
|
||||
|
||||
|
||||
@bp.post("/", name="post_root", strict_slashes=False)
|
||||
@bp.post("/<name:path>", name="post_path")
|
||||
async def copy_or_move(request, name=""):
|
||||
provided_args = set(request.args.keys())
|
||||
if not provided_args:
|
||||
raise BadRequest("No query arguments passed")
|
||||
|
||||
allowed_args = {"cp", "mv"}
|
||||
unknown_args = sorted(provided_args - allowed_args)
|
||||
if unknown_args:
|
||||
raise BadRequest(f"Unknown query parameter(s): {', '.join(unknown_args)}")
|
||||
|
||||
mv_vals = request.args.getlist("mv")
|
||||
cp_vals = request.args.getlist("cp")
|
||||
|
||||
mv_keys: list[str] = []
|
||||
for value in mv_vals:
|
||||
mv_keys.extend(k for k in value.split() if k)
|
||||
|
||||
cp_keys: list[str] = []
|
||||
for value in cp_vals:
|
||||
cp_keys.extend(k for k in value.split() if k)
|
||||
|
||||
if not mv_keys and not cp_keys:
|
||||
raise BadRequest("No keys given")
|
||||
|
||||
dst_rel, dst_abs = _safe_relpath(name)
|
||||
|
||||
dst_exists = dst_abs.exists()
|
||||
dst_is_dir = dst_exists and dst_abs.is_dir()
|
||||
|
||||
ordered_keys = cp_keys + mv_keys
|
||||
key_paths = _get_key_paths(set(ordered_keys))
|
||||
missing = [key for key in ordered_keys if key not in key_paths]
|
||||
if missing:
|
||||
raise NotFound("Files not found", context={"missing": missing})
|
||||
|
||||
# Validate target shape/type before mutating anything.
|
||||
for op_name, op_keys in (("cp", cp_keys), ("mv", mv_keys)):
|
||||
if len(op_keys) > 1 and not dst_is_dir:
|
||||
raise BadRequest("Destination must be an existing directory for multiple keys")
|
||||
if not op_keys:
|
||||
continue
|
||||
if not dst_is_dir:
|
||||
if not dst_rel.parts:
|
||||
raise BadRequest("Destination file path is required")
|
||||
parent_abs = dst_abs.parent
|
||||
if not parent_abs.is_dir():
|
||||
raise BadRequest("Destination parent folder does not exist")
|
||||
if dst_exists and dst_abs.is_file():
|
||||
for key in op_keys:
|
||||
src_abs = _resolve_from_relpath(key_paths[key])
|
||||
if src_abs.is_dir():
|
||||
raise BadRequest("Cannot move/copy a directory to an existing file")
|
||||
|
||||
changed: set[PurePosixPath] = set()
|
||||
completed: list[dict[str, str]] = []
|
||||
|
||||
class _FileOpFailed(Exception):
|
||||
def __init__(self, op_name: str, key: str, error: Exception):
|
||||
self.op_name = op_name
|
||||
self.key = key
|
||||
self.error = error
|
||||
super().__init__(str(error))
|
||||
|
||||
def _apply():
|
||||
for op_name, op_keys in (("cp", cp_keys), ("mv", mv_keys)):
|
||||
op_multi = len(op_keys) > 1
|
||||
for key in op_keys:
|
||||
try:
|
||||
src_rel = key_paths[key]
|
||||
src_abs = _resolve_from_relpath(src_rel)
|
||||
|
||||
if op_multi:
|
||||
if not dst_is_dir:
|
||||
raise BadRequest(
|
||||
"Destination must be an existing directory for multiple keys"
|
||||
)
|
||||
dst_item_rel = (
|
||||
dst_rel / src_rel.name if dst_rel.parts else PurePosixPath(src_rel.name)
|
||||
)
|
||||
elif dst_is_dir:
|
||||
dst_item_rel = (
|
||||
dst_rel / src_rel.name if dst_rel.parts else PurePosixPath(src_rel.name)
|
||||
)
|
||||
else:
|
||||
if not dst_rel.parts:
|
||||
raise BadRequest("Destination file path is required")
|
||||
parent_abs = dst_abs.parent
|
||||
if not parent_abs.is_dir():
|
||||
raise BadRequest("Destination parent folder does not exist")
|
||||
if src_abs.is_dir() and dst_exists and dst_abs.is_file():
|
||||
raise BadRequest(
|
||||
"Cannot move/copy a directory to an existing file"
|
||||
)
|
||||
dst_item_rel = dst_rel
|
||||
|
||||
dst_item_abs = _resolve_from_relpath(dst_item_rel)
|
||||
|
||||
if op_name == "mv":
|
||||
# A no-op rename should still return success.
|
||||
if src_abs != dst_item_abs:
|
||||
shutil.move(src_abs, dst_item_abs)
|
||||
changed.add(src_rel)
|
||||
changed.add(src_rel.parent)
|
||||
elif src_abs.is_dir():
|
||||
shutil.copytree(
|
||||
src_abs,
|
||||
dst_item_abs,
|
||||
dirs_exist_ok=True,
|
||||
ignore_dangling_symlinks=True,
|
||||
)
|
||||
else:
|
||||
shutil.copy2(src_abs, dst_item_abs)
|
||||
|
||||
changed.add(dst_item_rel)
|
||||
changed.add(dst_item_rel.parent)
|
||||
completed.append({"op": op_name, "key": key})
|
||||
except Exception as e:
|
||||
raise _FileOpFailed(op_name, key, e) from e
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(_apply)
|
||||
except _FileOpFailed as e:
|
||||
raise BadRequest(
|
||||
"File operation failed after partial progress",
|
||||
context={
|
||||
"failed_op": e.op_name,
|
||||
"failed_key": e.key,
|
||||
"error": str(e.error),
|
||||
"completed": completed,
|
||||
},
|
||||
) from e
|
||||
|
||||
notify_paths = [p for p in changed if p.parts]
|
||||
if notify_paths:
|
||||
watching.notify_change(*notify_paths)
|
||||
|
||||
return json(
|
||||
{
|
||||
"status": "ack",
|
||||
"counts": {"cp": len(cp_keys), "mv": len(mv_keys)},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/<name:path>")
|
||||
async def get_file(request, name=""):
|
||||
return await _send_static_file(request, name, head_only=False)
|
||||
|
||||
|
||||
@bp.head("/<name:path>")
|
||||
async def head_file(request, name=""):
|
||||
return await _send_static_file(request, name, head_only=True)
|
||||
|
||||
|
||||
@bp.route("/", methods=["OPTIONS"], name="options_root", strict_slashes=False)
|
||||
@bp.route("/<name:path>", methods=["OPTIONS"], name="options_path")
|
||||
async def dav_options(request, name=""):
|
||||
return HTTPResponse(
|
||||
status=200,
|
||||
headers={
|
||||
"Allow": "OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND, POST",
|
||||
"DAV": "1",
|
||||
"MS-Author-Via": "DAV",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/", methods=["PROPFIND"], name="propfind_root", strict_slashes=False)
|
||||
@bp.route("/<name:path>", methods=["PROPFIND"], name="propfind_path")
|
||||
async def dav_propfind(request, name=""):
|
||||
rel, path = _safe_relpath(name)
|
||||
if not path.exists():
|
||||
raise NotFound(f"Not found: {name}")
|
||||
depth = request.headers.get("depth", "1").strip()
|
||||
if depth == "infinity":
|
||||
return HTTPResponse(status=403)
|
||||
entries = await asyncio.to_thread(_collect_propfind_entries, rel, path, depth)
|
||||
return HTTPResponse(
|
||||
body=_build_propfind_xml(entries),
|
||||
status=207,
|
||||
content_type='application/xml; charset="utf-8"',
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/", methods=["COPY"], name="copy_root", strict_slashes=False)
|
||||
@bp.route("/<name:path>", methods=["COPY"], name="copy_path")
|
||||
async def dav_copy(request, name=""):
|
||||
dest_header = request.headers.get("destination")
|
||||
if not dest_header:
|
||||
raise BadRequest("Missing Destination header")
|
||||
overwrite = request.headers.get("overwrite", "T").strip().upper() != "F"
|
||||
src_rel, src_abs = _safe_relpath(name)
|
||||
dst_rel, dst_abs = _parse_webdav_destination(dest_header)
|
||||
request.ctx._log_extra = f"→ {dst_rel}"
|
||||
if not src_abs.exists():
|
||||
raise NotFound(f"Source not found: {name}")
|
||||
if src_abs == dst_abs:
|
||||
raise BadRequest("Source and destination are the same")
|
||||
dst_existed = dst_abs.exists()
|
||||
if dst_existed and not overwrite:
|
||||
return HTTPResponse(status=412)
|
||||
if not dst_abs.parent.is_dir():
|
||||
return HTTPResponse(status=409)
|
||||
|
||||
def _do_copy():
|
||||
if dst_existed:
|
||||
shutil.rmtree(dst_abs) if dst_abs.is_dir() else dst_abs.unlink()
|
||||
if src_abs.is_dir():
|
||||
shutil.copytree(src_abs, dst_abs, ignore_dangling_symlinks=True)
|
||||
else:
|
||||
shutil.copy2(src_abs, dst_abs)
|
||||
|
||||
await asyncio.to_thread(_do_copy)
|
||||
watching.notify_change(dst_rel, *dst_rel.parents)
|
||||
return HTTPResponse(status=201 if not dst_existed else 204)
|
||||
|
||||
|
||||
@bp.route("/", methods=["MOVE"], name="move_root", strict_slashes=False)
|
||||
@bp.route("/<name:path>", methods=["MOVE"], name="move_path")
|
||||
async def dav_move(request, name=""):
|
||||
dest_header = request.headers.get("destination")
|
||||
if not dest_header:
|
||||
raise BadRequest("Missing Destination header")
|
||||
overwrite = request.headers.get("overwrite", "T").strip().upper() != "F"
|
||||
src_rel, src_abs = _safe_relpath(name)
|
||||
dst_rel, dst_abs = _parse_webdav_destination(dest_header)
|
||||
request.ctx._log_extra = f"→ {dst_rel}"
|
||||
if not src_abs.exists():
|
||||
raise NotFound(f"Source not found: {name}")
|
||||
if src_abs == dst_abs:
|
||||
return HTTPResponse(status=204)
|
||||
dst_existed = dst_abs.exists()
|
||||
if dst_existed and not overwrite:
|
||||
return HTTPResponse(status=412)
|
||||
if not dst_abs.parent.is_dir():
|
||||
return HTTPResponse(status=409)
|
||||
|
||||
def _do_move():
|
||||
if dst_existed:
|
||||
shutil.rmtree(dst_abs) if dst_abs.is_dir() else dst_abs.unlink()
|
||||
shutil.move(src_abs, dst_abs)
|
||||
|
||||
await asyncio.to_thread(_do_move)
|
||||
watching.notify_change(src_rel, *src_rel.parents, dst_rel, *dst_rel.parents)
|
||||
return HTTPResponse(status=201 if not dst_existed else 204)
|
||||
|
||||
|
||||
def _parse_content_range(header: str, body_len: int) -> tuple[int, int, int]:
|
||||
m = _CONTENT_RANGE_RE.fullmatch(header.strip())
|
||||
if m is None:
|
||||
raise BadRequest("Invalid Content-Range format")
|
||||
start, end_inclusive, total = (int(v) for v in m.groups())
|
||||
if total <= 0:
|
||||
raise BadRequest("Invalid Content-Range total size")
|
||||
if start > end_inclusive:
|
||||
raise BadRequest("Invalid Content-Range range")
|
||||
if end_inclusive >= total:
|
||||
raise BadRequest("Content-Range exceeds total size")
|
||||
expected_len = end_inclusive - start + 1
|
||||
if expected_len != body_len:
|
||||
raise BadRequest(
|
||||
f"Content length mismatch for range: expected {expected_len}, got {body_len}"
|
||||
)
|
||||
return start, end_inclusive + 1, total
|
||||
|
||||
|
||||
def _to_mib_int(value_bytes: int) -> int:
|
||||
return round(value_bytes / (1 << 20))
|
||||
|
||||
|
||||
def _safe_relpath(path: str) -> tuple[PurePosixPath, Path]:
|
||||
"""Resolve a user path under storage root and enforce containment."""
|
||||
base = config.config.path.resolve()
|
||||
try:
|
||||
sanitized = filename.sanitize(unquote(path))
|
||||
except ValueError as e:
|
||||
raise BadRequest(f"Invalid path: {e}") from e
|
||||
resolved = (base / sanitized).resolve()
|
||||
if not resolved.is_relative_to(base):
|
||||
raise BadRequest("Invalid path")
|
||||
rel = PurePosixPath(resolved.relative_to(base).as_posix())
|
||||
return rel, resolved
|
||||
|
||||
|
||||
def _resolve_from_relpath(rel: PurePosixPath) -> Path:
|
||||
"""Resolve a relative path under storage root and enforce containment."""
|
||||
base = config.config.path.resolve()
|
||||
resolved = (base / rel).resolve()
|
||||
if not resolved.is_relative_to(base):
|
||||
raise BadRequest("Invalid path")
|
||||
return resolved
|
||||
|
||||
|
||||
async def _send_static_file(request, name: str, *, head_only: bool):
|
||||
_, path = _safe_relpath(name)
|
||||
|
||||
st = await asyncio.to_thread(path.stat)
|
||||
if path.is_dir():
|
||||
raise NotFound(f"Not a file: {name}")
|
||||
|
||||
size = st.st_size
|
||||
start = 0
|
||||
end_excl = size
|
||||
status = 200
|
||||
|
||||
range_header = request.headers.get("range")
|
||||
if range_header is not None:
|
||||
parsed = _parse_range_header(range_header, size)
|
||||
if parsed is None:
|
||||
return empty(
|
||||
status=416,
|
||||
headers={
|
||||
"accept-ranges": "bytes",
|
||||
"content-range": f"bytes */{size}",
|
||||
},
|
||||
)
|
||||
start, end_excl = parsed
|
||||
status = 206
|
||||
|
||||
length = end_excl - start
|
||||
mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
||||
headers = {
|
||||
"accept-ranges": "bytes",
|
||||
"cache-control": "no-cache",
|
||||
"content-length": str(length),
|
||||
"content-type": mime,
|
||||
"last-modified": format_date_time(st.st_mtime),
|
||||
}
|
||||
if status == 206:
|
||||
headers["content-range"] = f"bytes {start}-{end_excl - 1}/{size}"
|
||||
|
||||
if head_only:
|
||||
return empty(status=status, headers=headers)
|
||||
|
||||
res = await request.respond(status=status, headers=headers)
|
||||
fd = await asyncio.to_thread(os.open, path, os.O_RDONLY)
|
||||
try:
|
||||
pos = start
|
||||
while pos < end_excl:
|
||||
chunk = await asyncio.to_thread(
|
||||
os.pread,
|
||||
fd,
|
||||
min(_FILE_CHUNK_SIZE, end_excl - pos),
|
||||
pos,
|
||||
)
|
||||
if not chunk:
|
||||
break
|
||||
pos += len(chunk)
|
||||
await res.send(chunk)
|
||||
finally:
|
||||
await asyncio.to_thread(os.close, fd)
|
||||
|
||||
|
||||
def _parse_range_header(header: str, size: int) -> tuple[int, int] | None:
|
||||
value = header.strip()
|
||||
if "," in value:
|
||||
return None
|
||||
m = _RANGE_RE.fullmatch(value)
|
||||
if m is None:
|
||||
return None
|
||||
|
||||
start_s, end_s = m.groups()
|
||||
if not start_s and not end_s:
|
||||
return None
|
||||
|
||||
if start_s:
|
||||
start = int(start_s)
|
||||
if start >= size:
|
||||
return None
|
||||
end_inclusive = int(end_s) if end_s else (size - 1)
|
||||
if end_inclusive < start:
|
||||
return None
|
||||
end_inclusive = min(end_inclusive, size - 1)
|
||||
return start, end_inclusive + 1
|
||||
|
||||
suffix_len = int(end_s)
|
||||
if suffix_len <= 0:
|
||||
return None
|
||||
if suffix_len >= size:
|
||||
return 0, size
|
||||
start = size - suffix_len
|
||||
return start, size
|
||||
|
||||
|
||||
def _get_key_paths(wanted: set[str]) -> dict[str, PurePosixPath]:
|
||||
"""Map file keys to their current relative filesystem paths."""
|
||||
loc = PurePosixPath()
|
||||
ret: dict[str, PurePosixPath] = {}
|
||||
with watching.state.lock:
|
||||
root = watching.state.root
|
||||
for f in root:
|
||||
loc = PurePosixPath(*loc.parts[: f.level - 1]) / f.name
|
||||
if f.key in wanted and f.key not in ret:
|
||||
ret[f.key] = loc
|
||||
if len(ret) == len(wanted):
|
||||
break
|
||||
return ret
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WebDAV helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_webdav_destination(dest_header: str) -> tuple[PurePosixPath, Path]:
|
||||
"""Parse a WebDAV Destination header and resolve it to a storage path."""
|
||||
parsed = urlparse(dest_header)
|
||||
raw_path = parsed.path # still percent-encoded
|
||||
prefix = "/files"
|
||||
if raw_path in (prefix, prefix + "/"):
|
||||
rel_str = ""
|
||||
elif raw_path.startswith(prefix + "/"):
|
||||
rel_str = raw_path[len(prefix) + 1:]
|
||||
else:
|
||||
raise BadRequest("Destination must be within /files")
|
||||
return _safe_relpath(rel_str)
|
||||
|
||||
|
||||
def _rel_to_href(rel: PurePosixPath, is_dir: bool) -> str:
|
||||
"""Build a DAV href from a storage-relative path."""
|
||||
parts = rel.parts
|
||||
if not parts:
|
||||
return "/files/"
|
||||
encoded = "/".join(url_quote(p, safe="") for p in parts)
|
||||
href = f"/files/{encoded}"
|
||||
return href + "/" if is_dir else href
|
||||
|
||||
|
||||
def _dav_xml(element: ET.Element) -> bytes:
|
||||
"""Serialise an ElementTree element to UTF-8 bytes with XML declaration."""
|
||||
return (
|
||||
b'<?xml version="1.0" encoding="UTF-8"?>'
|
||||
+ ET.tostring(element, encoding="unicode").encode("utf-8")
|
||||
)
|
||||
|
||||
|
||||
def _collect_propfind_entries(rel: PurePosixPath, path: Path, depth: str) -> list[dict]:
|
||||
entries = [_propfind_entry(rel, path)]
|
||||
if depth == "1" and path.is_dir():
|
||||
for child in sorted(path.iterdir(), key=lambda p: (p.is_file(), p.name)):
|
||||
child_rel = rel / child.name if rel.parts else PurePosixPath(child.name)
|
||||
try:
|
||||
entries.append(_propfind_entry(child_rel, child))
|
||||
except OSError:
|
||||
pass
|
||||
return entries
|
||||
|
||||
|
||||
def _propfind_entry(rel: PurePosixPath, path: Path) -> dict:
|
||||
st = path.stat()
|
||||
is_dir = path.is_dir()
|
||||
return {
|
||||
"href": _rel_to_href(rel, is_dir),
|
||||
"name": rel.parts[-1] if rel.parts else "",
|
||||
"is_dir": is_dir,
|
||||
"size": st.st_size,
|
||||
"etag": f'"{st.st_mtime:.0f}-{st.st_size}"',
|
||||
"content_type": mimetypes.guess_type(path.name)[0] or "application/octet-stream",
|
||||
"last_modified": format_date_time(st.st_mtime),
|
||||
"created": datetime.fromtimestamp(st.st_ctime, tz=timezone.utc).strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _build_propfind_xml(entries: list[dict]) -> bytes:
|
||||
multistatus = ET.Element(_dav_tag("multistatus"))
|
||||
for e in entries:
|
||||
response = ET.SubElement(multistatus, _dav_tag("response"))
|
||||
ET.SubElement(response, _dav_tag("href")).text = e["href"]
|
||||
propstat = ET.SubElement(response, _dav_tag("propstat"))
|
||||
prop = ET.SubElement(propstat, _dav_tag("prop"))
|
||||
rt = ET.SubElement(prop, _dav_tag("resourcetype"))
|
||||
if e["is_dir"]:
|
||||
ET.SubElement(rt, _dav_tag("collection"))
|
||||
ET.SubElement(prop, _dav_tag("displayname")).text = e["name"]
|
||||
ET.SubElement(prop, _dav_tag("getlastmodified")).text = e["last_modified"]
|
||||
ET.SubElement(prop, _dav_tag("creationdate")).text = e["created"]
|
||||
if not e["is_dir"]:
|
||||
ET.SubElement(prop, _dav_tag("getcontentlength")).text = str(e["size"])
|
||||
ET.SubElement(prop, _dav_tag("getcontenttype")).text = e["content_type"]
|
||||
ET.SubElement(prop, _dav_tag("getetag")).text = e["etag"]
|
||||
ET.SubElement(propstat, _dav_tag("status")).text = "HTTP/1.1 200 OK"
|
||||
return _dav_xml(multistatus)
|
||||
+2
-6
@@ -221,9 +221,7 @@ class _PreviewWorkerPool:
|
||||
logger.warning(
|
||||
"Preview worker protocol failure for %s: %s", filepath.name, e
|
||||
)
|
||||
raise PreviewError(
|
||||
f"worker protocol failure for {filepath.name}: {e}"
|
||||
)
|
||||
raise PreviewError(f"worker protocol failure for {filepath.name}: {e}")
|
||||
finally:
|
||||
if replace:
|
||||
await self._replace_worker(worker)
|
||||
@@ -470,9 +468,7 @@ def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
|
||||
img = pyvips.Image.new_from_memory(
|
||||
pix.samples_mv, pix.width, pix.height, pix.n, "uchar"
|
||||
)
|
||||
ret = img.write_to_buffer(
|
||||
".avif", Q=quality, effort=AVIF_FAST_EFFORT, strip=True
|
||||
)
|
||||
ret = img.write_to_buffer(".avif", Q=quality, effort=AVIF_FAST_EFFORT, strip=True)
|
||||
backend = "pdf+pyvips"
|
||||
t_save_end = perf_counter()
|
||||
|
||||
|
||||
@@ -1,125 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
import msgspec
|
||||
from sanic import BadRequest
|
||||
|
||||
from cista import config
|
||||
from cista.util import filename
|
||||
|
||||
## Control commands
|
||||
|
||||
class ControlBase(msgspec.Struct, tag_field="op", tag=str.lower):
|
||||
def __call__(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def affected_paths(self) -> list[str]:
|
||||
"""Return list of paths affected by this operation for change notification."""
|
||||
return []
|
||||
|
||||
|
||||
class MkDir(ControlBase):
|
||||
path: str
|
||||
|
||||
def __call__(self):
|
||||
path = config.config.path / filename.sanitize(self.path)
|
||||
path.mkdir(parents=True, exist_ok=False)
|
||||
|
||||
def affected_paths(self) -> list[str]:
|
||||
return [filename.sanitize(self.path)]
|
||||
|
||||
|
||||
class Rename(ControlBase):
|
||||
path: str
|
||||
to: str
|
||||
|
||||
def __call__(self):
|
||||
to = filename.sanitize(self.to)
|
||||
if "/" in to:
|
||||
raise BadRequest("Rename 'to' name should only contain filename, not path")
|
||||
path = config.config.path / filename.sanitize(self.path)
|
||||
path.rename(path.with_name(to))
|
||||
|
||||
def affected_paths(self) -> list[str]:
|
||||
sanitized = filename.sanitize(self.path)
|
||||
new_path = str(PurePosixPath(sanitized).with_name(filename.sanitize(self.to)))
|
||||
return [sanitized, new_path]
|
||||
|
||||
|
||||
class Rm(ControlBase):
|
||||
sel: list[str]
|
||||
|
||||
def __call__(self):
|
||||
root = config.config.path
|
||||
sel = [root / filename.sanitize(p) for p in self.sel]
|
||||
for p in sel:
|
||||
if p.is_dir():
|
||||
shutil.rmtree(p)
|
||||
else:
|
||||
p.unlink()
|
||||
|
||||
def affected_paths(self) -> list[str]:
|
||||
return [filename.sanitize(p) for p in self.sel]
|
||||
|
||||
|
||||
class Mv(ControlBase):
|
||||
sel: list[str]
|
||||
dst: str
|
||||
|
||||
def __call__(self):
|
||||
root = config.config.path
|
||||
sel = [root / filename.sanitize(p) for p in self.sel]
|
||||
dst = root / filename.sanitize(self.dst)
|
||||
if not dst.is_dir():
|
||||
raise BadRequest("The destination must be a directory")
|
||||
for p in sel:
|
||||
shutil.move(p, dst)
|
||||
|
||||
def affected_paths(self) -> list[str]:
|
||||
dst = filename.sanitize(self.dst)
|
||||
paths = [filename.sanitize(p) for p in self.sel]
|
||||
# Include new locations in dst
|
||||
paths.extend(f"{dst}/{PurePosixPath(p).name}" for p in self.sel)
|
||||
return paths
|
||||
|
||||
|
||||
class Cp(ControlBase):
|
||||
sel: list[str]
|
||||
dst: str
|
||||
|
||||
def __call__(self):
|
||||
root = config.config.path
|
||||
sel = [root / filename.sanitize(p) for p in self.sel]
|
||||
dst = root / filename.sanitize(self.dst)
|
||||
if not dst.is_dir():
|
||||
raise BadRequest("The destination must be a directory")
|
||||
for p in sel:
|
||||
if p.is_dir():
|
||||
# Note: copies as dst rather than in dst unless name is appended.
|
||||
shutil.copytree(
|
||||
p,
|
||||
dst / p.name,
|
||||
dirs_exist_ok=True,
|
||||
ignore_dangling_symlinks=True,
|
||||
)
|
||||
else:
|
||||
shutil.copy2(p, dst)
|
||||
|
||||
def affected_paths(self) -> list[str]:
|
||||
dst = filename.sanitize(self.dst)
|
||||
# Only destinations are new (sources unchanged)
|
||||
return [f"{dst}/{PurePosixPath(filename.sanitize(p)).name}" for p in self.sel]
|
||||
|
||||
|
||||
ControlTypes = MkDir | Rename | Rm | Mv | Cp
|
||||
|
||||
|
||||
class StatusMsg(msgspec.Struct):
|
||||
status: str
|
||||
req: Any
|
||||
|
||||
|
||||
class ErrorMsg(msgspec.Struct):
|
||||
|
||||
+23
-15
@@ -8,18 +8,18 @@ from ipaddress import IPv6Address
|
||||
logger = logging.getLogger("cista.access")
|
||||
|
||||
_RESET = "\033[0m"
|
||||
_STATUS_INFO = "\033[32m" # 1xx (green)
|
||||
_STATUS_OK = "\033[1;92m" # 2xx (bright green)
|
||||
_STATUS_INFO = "\033[32m" # 1xx (green)
|
||||
_STATUS_OK = "\033[1;92m" # 2xx (bright green)
|
||||
_STATUS_REDIRECT = "\033[32m" # 3xx (green)
|
||||
_STATUS_CLIENT_ERR = "\033[0;31m" # 4xx (red)
|
||||
_STATUS_SERVER_ERR = "\033[1;91m" # 5xx (bold bright red)
|
||||
_METHOD_READ = "\033[0;34m" # GET, HEAD, OPTIONS (blue)
|
||||
_METHOD_WRITE = "\033[1;94m" # POST, PUT, DELETE, PATCH (bold bright blue)
|
||||
_HOST = "\033[38;5;242m" # hostname (dark grey)
|
||||
_PATH = "\033[38;5;250m" # path (light grey)
|
||||
_TIMING = "\033[38;5;242m" # timing (dark grey)
|
||||
_WS_OPEN = "\033[1;93m" # WebSocket connect (bold bright yellow)
|
||||
_WS_CLOSE = "\033[33m" # WebSocket disconnect (yellow)
|
||||
_STATUS_CLIENT_ERR = "\033[0;31m" # 4xx (red)
|
||||
_STATUS_SERVER_ERR = "\033[1;91m" # 5xx (bold bright red)
|
||||
_METHOD_READ = "\033[0;34m" # GET, HEAD, OPTIONS (blue)
|
||||
_METHOD_WRITE = "\033[1;94m" # POST, PUT, DELETE, PATCH (bold bright blue)
|
||||
_HOST = "\033[38;5;242m" # hostname (dark grey)
|
||||
_PATH = "\033[38;5;250m" # path (light grey)
|
||||
_TIMING = "\033[38;5;242m" # timing (dark grey)
|
||||
_WS_OPEN = "\033[1;93m" # WebSocket connect (bold bright yellow)
|
||||
_WS_CLOSE = "\033[33m" # WebSocket disconnect (yellow)
|
||||
_WS_STATUS = "\033[38;5;250m" # WebSocket close status (normal white)
|
||||
|
||||
|
||||
@@ -113,7 +113,12 @@ def _format_method_label(label: str, *, color: str | None = None) -> str:
|
||||
|
||||
|
||||
def format_access_log(
|
||||
client: str, status: int, method: str, host: str, path: str, duration_ms: float,
|
||||
client: str,
|
||||
status: int,
|
||||
method: str,
|
||||
host: str,
|
||||
path: str,
|
||||
duration_ms: float,
|
||||
extra: str | None = None,
|
||||
) -> str:
|
||||
ip = _format_left(format_client_ip(client))
|
||||
@@ -123,7 +128,9 @@ def format_access_log(
|
||||
path_str = f"{_PATH}{path}{_RESET}"
|
||||
timing_str = f"{_TIMING}{format_duration_ms(duration_ms)}{_RESET}"
|
||||
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
|
||||
return f"{ip} {status_str} {method_str} {host_str}{path_str}{extra_str} {timing_str}"
|
||||
return (
|
||||
f"{ip} {status_str} {method_str} {host_str}{path_str}{extra_str} {timing_str}"
|
||||
)
|
||||
|
||||
|
||||
_ws_counter = 1
|
||||
@@ -194,7 +201,7 @@ WS_CLOSE_CODES = {
|
||||
}
|
||||
|
||||
|
||||
def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
|
||||
def log_ws_close(ws_id: int, close_code: int | None, duration: float, extra: str | None = None) -> None:
|
||||
"""Log WebSocket connection close with duration and status."""
|
||||
id_str = _format_ws_id(ws_id)
|
||||
timing = format_duration_ms(duration * 1000)
|
||||
@@ -209,8 +216,9 @@ def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
|
||||
method_str = _format_method_label("closed", color=_TIMING)
|
||||
status_str = f"{_WS_STATUS}{code} {status}{_RESET}"
|
||||
timing_str = f"{_TIMING}{timing}{_RESET}"
|
||||
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
|
||||
|
||||
logger.info("%s %s %s %s %s", " " * 19, id_str, method_str, status_str, timing_str)
|
||||
logger.info("%s %s %s %s %s%s", " " * 19, id_str, method_str, status_str, timing_str, extra_str)
|
||||
|
||||
|
||||
def configure_access_logging() -> None:
|
||||
|
||||
+4
-4
@@ -19,21 +19,21 @@ def get(request):
|
||||
return False if "s" in request.cookies else None
|
||||
|
||||
|
||||
def create(res, username, **kwargs):
|
||||
def create(res, username, *, secure: bool = True, **kwargs):
|
||||
data = {
|
||||
"exp": int(time()) + max_age,
|
||||
"username": username,
|
||||
**kwargs,
|
||||
}
|
||||
s = jwt.encode(data, session_secret())
|
||||
res.cookies.add_cookie("s", s, httponly=True, max_age=max_age)
|
||||
res.cookies.add_cookie("s", s, httponly=True, max_age=max_age, secure=secure)
|
||||
|
||||
|
||||
def update(res, s, **kwargs):
|
||||
def update(res, s, *, secure: bool = True, **kwargs):
|
||||
s.update(kwargs)
|
||||
s = jwt.encode(s, session_secret())
|
||||
max_age = max(1, s["exp"] - int(time())) # type: ignore
|
||||
res.cookies.add_cookie("s", s, httponly=True, max_age=max_age)
|
||||
res.cookies.add_cookie("s", s, httponly=True, max_age=max_age, secure=secure)
|
||||
|
||||
|
||||
def delete(res):
|
||||
|
||||
@@ -152,6 +152,61 @@ async def validate_sso_request(request, *, perm: str = "cista:login") -> dict |
|
||||
)
|
||||
|
||||
|
||||
async def check_permissions(user_id: str, perm: str) -> dict:
|
||||
"""Check if a Paskia user has the given permission.
|
||||
|
||||
Args:
|
||||
user_id: The Paskia user UUID
|
||||
perm: Permission to check (e.g. cista:login or cista:admin)
|
||||
|
||||
Returns:
|
||||
User info dict if permission is granted
|
||||
|
||||
Raises:
|
||||
Forbidden: If permission is denied or check fails
|
||||
SanicException: If the auth service is unreachable
|
||||
"""
|
||||
if not paskia_enabled():
|
||||
raise ValueError("Paskia not enabled")
|
||||
|
||||
client = await get_client()
|
||||
url = f"{PASKIA_BACKEND_URL}/auth/api/check-permissions"
|
||||
|
||||
try:
|
||||
response = await client.post(
|
||||
url,
|
||||
json={"user_id": user_id, "perm": perm},
|
||||
headers={"accept": "application/json"},
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
|
||||
try:
|
||||
error_data = response.json()
|
||||
except Exception:
|
||||
error_data = {"detail": response.text or "Permission check failed"}
|
||||
|
||||
if response.status_code == 403:
|
||||
raise Forbidden(
|
||||
error_data.get("detail", "Access denied"),
|
||||
quiet=True,
|
||||
)
|
||||
else:
|
||||
raise Forbidden(
|
||||
error_data.get("detail", "Permission check failed"),
|
||||
quiet=True,
|
||||
)
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Permission check {url} network error: {e}")
|
||||
raise SanicException(
|
||||
"Authentication service unavailable",
|
||||
status_code=502,
|
||||
quiet=True,
|
||||
)
|
||||
|
||||
|
||||
async def proxy_auth_request(request):
|
||||
"""Proxy a request to the auth backend.
|
||||
|
||||
|
||||
@@ -24,10 +24,12 @@ def jres(data, **kwargs):
|
||||
|
||||
async def handle_sanic_exception(request, e):
|
||||
context, code = {}, 500
|
||||
headers = None
|
||||
message = str(e)
|
||||
if isinstance(e, SanicException):
|
||||
context = e.context or {}
|
||||
code = e.status_code
|
||||
headers = getattr(e, "headers", None)
|
||||
if not message or not request.app.debug and code == 500:
|
||||
message = "Internal Server Error"
|
||||
message = f"⚠️ {message}" if code < 500 else f"🛑 {message}"
|
||||
@@ -41,6 +43,7 @@ async def handle_sanic_exception(request, e):
|
||||
return jres(
|
||||
response_data,
|
||||
status=code,
|
||||
headers=headers,
|
||||
)
|
||||
# Redirections flash the error message via cookies
|
||||
if "redirect" in context:
|
||||
@@ -60,6 +63,7 @@ def websocket_wrapper(handler):
|
||||
extra = username if username else None
|
||||
start = time.perf_counter()
|
||||
ws_id = log_ws_open(request, extra=extra)
|
||||
close_extra = None
|
||||
try:
|
||||
await auth.verify(request)
|
||||
await handler(request, ws, *args, **kwargs)
|
||||
@@ -72,6 +76,7 @@ def websocket_wrapper(handler):
|
||||
await asend(ws, ErrorMsg({"code": code, "message": message, **context}))
|
||||
if not getattr(e, "quiet", False) or code == 500:
|
||||
logger.exception(f"{code} {e!r}")
|
||||
close_extra = f"{code} {message}"
|
||||
raise
|
||||
finally:
|
||||
duration = time.perf_counter() - start
|
||||
@@ -86,6 +91,6 @@ def websocket_wrapper(handler):
|
||||
close_code = p.close_code
|
||||
except AttributeError:
|
||||
pass
|
||||
log_ws_close(ws_id, close_code, duration)
|
||||
log_ws_close(ws_id, close_code, duration, extra=close_extra)
|
||||
|
||||
return wrapper
|
||||
|
||||
Reference in New Issue
Block a user