lint: apply manual ruff cleanup (non-preview files)

This commit is contained in:
Leo Vasanko
2026-04-26 06:16:42 +00:00
parent 9321961a11
commit 3767fb0cec
29 changed files with 169 additions and 223 deletions
+1 -3
View File
@@ -1,3 +1 @@
from cista._version import __version__ from cista._version import __version__ as __version__
__version__ # Public API
+2 -6
View File
@@ -34,10 +34,7 @@ def create_startup_box(
location = f"{folder} @ {listen}" location = f"{folder} @ {listen}"
lines = [title, location] lines = [title, location]
# Auth line: Paskia <url> or Password, with optional Public suffix # Auth line: Paskia <url> or Password, with optional Public suffix
if paskia_url: auth_line = f"Auth: Paskia {paskia_url}" if paskia_url else "Auth: Password"
auth_line = f"Auth: Paskia {paskia_url}"
else:
auth_line = "Auth: Password"
if public: if public:
auth_line += ", Public" auth_line += ", Public"
lines.append(auth_line) lines.append(auth_line)
@@ -49,8 +46,7 @@ def create_startup_box(
# Build the box # Build the box
box = [f"{'' * inner_width}"] box = [f"{'' * inner_width}"]
for line in lines: box.extend(f"{line:<{inner_width - 1}}" for line in lines)
box.append(f"{line:<{inner_width - 1}}")
box.append(f"{'' * inner_width}") box.append(f"{'' * inner_width}")
return "\n".join(box) + "\n" return "\n".join(box) + "\n"
+3 -3
View File
@@ -1,10 +1,10 @@
import asyncio import asyncio
from pathlib import PurePosixPath
from secrets import token_bytes from secrets import token_bytes
import msgspec import msgspec
from sanic import Blueprint, json from sanic import Blueprint, json
from sanic.exceptions import BadRequest from sanic.exceptions import BadRequest
from sanic.log import logger
from cista import __version__, auth, config, sso, watching from cista import __version__, auth, config, sso, watching
from cista.auth import ( from cista.auth import (
@@ -38,8 +38,8 @@ async def watch(req, ws):
# SSO auth: call validation to get user info (don't enforce auth in public mode) # SSO auth: call validation to get user info (don't enforce auth in public mode)
try: try:
await sso.validate_sso_request(req) await sso.validate_sso_request(req)
except Exception: except Exception as e:
pass # Ignore auth errors, user_info stays None logger.debug("watch SSO validation failed: %s", e)
if sso_user := getattr(req.ctx, "sso_user", None): if sso_user := getattr(req.ctx, "sso_user", None):
ctx = sso_user.get("ctx", {}) ctx = sso_user.get("ctx", {})
perms = ctx.get("permissions", []) perms = ctx.get("permissions", [])
+4 -4
View File
@@ -16,10 +16,9 @@ from setproctitle import setproctitle
from stream_zip import ZIP_AUTO, stream_zip from stream_zip import ZIP_AUTO, stream_zip
from zstandard import ZstdCompressor from zstandard import ZstdCompressor
from cista import auth, config, preview, session, sso, watching from cista import auth, config, fileserver, preview, session, sso, watching
from cista.preview import shutdown_preview_workers, start_preview_workers
from cista.api import bp from cista.api import bp
from cista import fileserver from cista.preview import shutdown_preview_workers, start_preview_workers
from cista.sanic_logging import ( from cista.sanic_logging import (
configure_access_logging, configure_access_logging,
configure_main_logging, configure_main_logging,
@@ -295,7 +294,8 @@ async def zip_download(req, keys, zipfile, ext):
while size > 0 and (chunk := f.read(min(size, 1 << 20))): while size > 0 and (chunk := f.read(min(size, 1 << 20))):
size -= len(chunk) size -= len(chunk)
yield chunk yield chunk
assert size == 0 if size != 0:
raise OSError(f"stream ended early while zipping {name}")
pending_put = None # Current queue.put future, can be cancelled pending_put = None # Current queue.put future, can be cancelled
+35 -41
View File
@@ -1,7 +1,7 @@
import base64 import base64
import binascii import binascii
import hmac
import hashlib import hashlib
import hmac
import re import re
import secrets import secrets
import struct import struct
@@ -275,7 +275,6 @@ def _log_webdav_user_agent_once(request, user_agent: str):
return return
_seen_webdav_uas.add(key) _seen_webdav_uas.add(key)
# Temporary stdout print so operators can quickly capture real client UAs. # Temporary stdout print so operators can quickly capture real client UAs.
print(f"WebDAV User-Agent observed: {key} path={request.path}")
def _build_ua_auth_headers(request, *, include_hint=False) -> dict[str, str]: def _build_ua_auth_headers(request, *, include_hint=False) -> dict[str, str]:
@@ -285,8 +284,7 @@ def _build_ua_auth_headers(request, *, include_hint=False) -> dict[str, str]:
challenge = f'Basic realm="{_AUTH_REALM}", Negotiate' challenge = f'Basic realm="{_AUTH_REALM}", Negotiate'
else: else:
challenge = f'Basic realm="{_AUTH_REALM}"' challenge = f'Basic realm="{_AUTH_REALM}"'
headers = {"WWW-Authenticate": challenge} return {"WWW-Authenticate": challenge}
return headers
def _cleanup_ntlm_challenges(): def _cleanup_ntlm_challenges():
@@ -399,14 +397,13 @@ def _spnego_wrap_ntlm_challenge(ntlm_type2: bytes) -> bytes:
neg_state_accept_incomplete = _der_tlv(0xA0, _der_tlv(0x0A, b"\x01")) neg_state_accept_incomplete = _der_tlv(0xA0, _der_tlv(0x0A, b"\x01"))
supported_mech = _der_tlv(0xA1, ntlm_oid) supported_mech = _der_tlv(0xA1, ntlm_oid)
response_token = _der_tlv(0xA2, _der_tlv(0x04, ntlm_type2)) response_token = _der_tlv(0xA2, _der_tlv(0x04, ntlm_type2))
neg_token_resp = _der_tlv( return _der_tlv(
0xA1, 0xA1,
_der_tlv( _der_tlv(
0x30, 0x30,
neg_state_accept_incomplete + supported_mech + response_token, neg_state_accept_incomplete + supported_mech + response_token,
), ),
) )
return neg_token_resp
def _ntlm_parse_type3(data: bytes) -> dict | None: def _ntlm_parse_type3(data: bytes) -> dict | None:
@@ -417,7 +414,7 @@ def _ntlm_parse_type3(data: bytes) -> dict | None:
return None return None
def read_buf(offset: int) -> bytes: def read_buf(offset: int) -> bytes:
length, max_len, buf_offset = struct.unpack("<HHI", data[offset : offset + 8]) length, _max_len, buf_offset = struct.unpack("<HHI", data[offset : offset + 8])
if length == 0: if length == 0:
return b"" return b""
if buf_offset + length > len(data): if buf_offset + length > len(data):
@@ -460,7 +457,7 @@ def _ntlmv2_verify(
blob = nt_response[16:] blob = nt_response[16:]
# NT hash = MD4(UTF-16LE(password)) # NT hash = MD4(UTF-16LE(password))
nt_hash = MD4.new(token_secret.encode("utf-16le")).digest() nt_hash = MD4.new(token_secret.encode("utf-16le")).digest() # noqa: S303
raw_username = username or "" raw_username = username or ""
raw_domain = domain or "" raw_domain = domain or ""
@@ -629,7 +626,7 @@ def _basic_auth_login(request):
async def _token_auth_login(request, privileged=False): async def _token_auth_login(request, *, privileged=False):
"""Authenticate via Basic token:<secret> in SSO mode. """Authenticate via Basic token:<secret> in SSO mode.
Returns True if authenticated, False if no token matched. Returns True if authenticated, False if no token matched.
@@ -686,7 +683,7 @@ async def _token_auth_login(request, privileged=False):
return False return False
async def _ntlm_auth_login(request, privileged=False): async def _ntlm_auth_login(request, *, privileged=False):
"""Handle NTLM authentication for token-based login. """Handle NTLM authentication for token-based login.
Supports NTLMv2 responses where the token secret is used as the password. Supports NTLMv2 responses where the token secret is used as the password.
@@ -706,9 +703,9 @@ async def _ntlm_auth_login(request, privileged=False):
try: try:
data = base64.b64decode(encoded) data = base64.b64decode(encoded)
except Exception: except Exception as e:
logger.warning("NTLM decode failed: client=%s", client_key) logger.warning("NTLM decode failed: client=%s", client_key)
raise Unauthorized("Invalid NTLM message", www_auth_scheme, quiet=True) raise Unauthorized("Invalid NTLM message", www_auth_scheme, quiet=True) from e
# Windows commonly sends SPNEGO-wrapped Negotiate tokens that embed NTLMSSP. # Windows commonly sends SPNEGO-wrapped Negotiate tokens that embed NTLMSSP.
# Extract the NTLMSSP blob when present so downstream parsing sees raw Type 1/3. # Extract the NTLMSSP blob when present so downstream parsing sees raw Type 1/3.
@@ -797,7 +794,6 @@ async def _ntlm_auth_login(request, privileged=False):
secret_candidates.append(("token-key", token.key)) secret_candidates.append(("token-key", token.key))
matched_by = None matched_by = None
matched_challenge = None
for secret_kind, secret_value in secret_candidates: for secret_kind, secret_value in secret_candidates:
for challenge in challenges: for challenge in challenges:
if _ntlmv2_verify( if _ntlmv2_verify(
@@ -808,7 +804,6 @@ async def _ntlm_auth_login(request, privileged=False):
nt_response, nt_response,
): ):
matched_by = secret_kind matched_by = secret_kind
matched_challenge = challenge
break break
if matched_by: if matched_by:
break break
@@ -919,14 +914,14 @@ async def verify(request, *, privileged=False):
perm = "cista:admin" if privileged else "cista:login" perm = "cista:admin" if privileged else "cista:login"
await sso.validate_sso_request(request, perm=perm) await sso.validate_sso_request(request, perm=perm)
return return
except Unauthorized: except Unauthorized as e:
auth_flow.append(f"tried={','.join(tried)} result=failed") auth_flow.append(f"tried={','.join(tried)} result=failed")
_set_auth_failure_log(request, auth_flow) _set_auth_failure_log(request, auth_flow)
raise Unauthorized( raise Unauthorized(
"Invalid credentials", "Invalid credentials",
headers=_build_ua_auth_headers(request), headers=_build_ua_auth_headers(request),
quiet=True, quiet=True,
) ) from e
tried.append("sso") tried.append("sso")
perm = "cista:admin" if privileged else "cista:login" perm = "cista:admin" if privileged else "cista:login"
await sso.validate_sso_request(request, perm=perm) await sso.validate_sso_request(request, perm=perm)
@@ -969,7 +964,7 @@ async def verify(request, *, privileged=False):
user = await _ntlm_auth_login(request, privileged=privileged) user = await _ntlm_auth_login(request, privileged=privileged)
except Unauthorized as e: except Unauthorized as e:
auth_hdr = (e.headers or {}).get("WWW-Authenticate", "") auth_hdr = (e.headers or {}).get("WWW-Authenticate", "")
if (auth_hdr.startswith("NTLM ") or auth_hdr.startswith("Negotiate ")) and "realm=" not in auth_hdr: if (auth_hdr.startswith(("NTLM ", "Negotiate "))) and "realm=" not in auth_hdr:
raise raise
ntlm_failed = True ntlm_failed = True
user = None user = None
@@ -1067,27 +1062,28 @@ async def login_page(request):
doc.style(_LOGIN_PAGE_CSS) doc.style(_LOGIN_PAGE_CSS)
with doc.div(class_="login-card"): with doc.div(class_="login-card"):
doc.h1("Authentication Required") doc.h1("Authentication Required")
with doc.div(class_="content"): with doc.div(class_="content"), doc.form(
with doc.form(method="POST", id="loginForm", autocomplete="on"): method="POST", id="loginForm", autocomplete="on"
doc.label("Username:", for_="username") ):
doc.input( doc.label("Username:", for_="username")
type="text", doc.input(
id="username", type="text",
name="username", id="username",
autocomplete="username webauthn", name="username",
required=True, autocomplete="username webauthn",
) required=True,
doc.label("Password:", for_="password") )
doc.input( doc.label("Password:", for_="password")
type="password", doc.input(
id="password", type="password",
name="password", id="password",
autocomplete="current-password webauthn", name="password",
required=True, autocomplete="current-password webauthn",
) required=True,
with doc.div(class_="button-row"): )
doc.button("Log in", type="submit", id="submitBtn") with doc.div(class_="button-row"):
doc.p("", class_="error", id="error") doc.button("Log in", type="submit", id="submitBtn")
doc.p("", class_="error", id="error")
# JavaScript for AJAX login and postMessage communication # JavaScript for AJAX login and postMessage communication
doc.script_(_LOGIN_PAGE_JS) doc.script_(_LOGIN_PAGE_JS)
@@ -1289,9 +1285,7 @@ def _token_belongs_to_user(token, username, sso_user_id):
"""Check if a token belongs to the given user.""" """Check if a token belongs to the given user."""
if username is not None and token.username == username: if username is not None and token.username == username:
return True return True
if sso_user_id is not None and token.sso_user_id == sso_user_id: return bool(sso_user_id is not None and token.sso_user_id == sso_user_id)
return True
return False
# Token management handlers (shared between /auth and /api blueprints) # Token management handlers (shared between /auth and /api blueprints)
+5 -4
View File
@@ -3,12 +3,13 @@ from __future__ import annotations
import os import os
import secrets import secrets
import sys import sys
from collections.abc import Callable
from contextlib import suppress from contextlib import suppress
from functools import wraps from functools import wraps
from hashlib import sha256 from hashlib import sha256
from pathlib import Path, PurePath from pathlib import Path, PurePath
from time import sleep, time from time import sleep, time
from typing import Callable, Concatenate, Literal, ParamSpec from typing import Concatenate, Literal, ParamSpec
import msgspec import msgspec
import msgspec.toml import msgspec.toml
@@ -49,7 +50,7 @@ class Token(msgspec.Struct, omit_defaults=True):
username: str = "" # set in built-in mode username: str = "" # set in built-in mode
sso_user_id: str = "" # set in SSO mode sso_user_id: str = "" # set in SSO mode
name: str = "" name: str = ""
created: int = 0 # noqa: N815 created: int = 0
# Global variables - initialized during application startup # Global variables - initialized during application startup
@@ -72,7 +73,7 @@ def init_confdir() -> None:
conffile = home / "db.toml" conffile = home / "db.toml"
def derived_secret(*params, len=8) -> bytes: def derived_secret(*params, size=8) -> bytes:
"""Used to derive secret keys from the main secret""" """Used to derive secret keys from the main secret"""
# Each part is made the same length by hashing first # Each part is made the same length by hashing first
combined = b"".join( combined = b"".join(
@@ -80,7 +81,7 @@ def derived_secret(*params, len=8) -> bytes:
for p in [config.secret, *params] for p in [config.secret, *params]
) )
# Output a bytes of the desired length # Output a bytes of the desired length
return sha256(combined).digest()[:len] return sha256(combined).digest()[:size]
def enc_hook(obj): def enc_hook(obj):
+1 -1
View File
@@ -17,7 +17,7 @@ def _droppy_listeners(cf):
for listener in cf["listeners"]: for listener in cf["listeners"]:
try: try:
if listener["protocol"] == "https": if listener["protocol"] == "https":
# TODO: Add support for TLS # TLS listeners are currently ignored here.
continue continue
socket = listener.get("socket") socket = listener.get("socket")
if socket: if socket:
+11 -7
View File
@@ -1,5 +1,6 @@
import os import os
import threading import threading
from pathlib import Path
from cista import config from cista import config
from cista.util import filename from cista.util import filename
@@ -31,20 +32,23 @@ class File:
if not self.writable: if not self.writable:
# Create/open file # Create/open file
self.open_rw() self.open_rw()
assert self.fd is not None if self.fd is None:
raise RuntimeError("file descriptor is not available for write")
if file_size is not None: if file_size is not None:
assert pos + len(buffer) <= file_size if pos + len(buffer) > file_size:
raise ValueError("write exceeds declared file size")
os.ftruncate(self.fd, file_size) os.ftruncate(self.fd, file_size)
if buffer: if buffer:
os.lseek(self.fd, pos, os.SEEK_SET) os.lseek(self.fd, pos, os.SEEK_SET)
os.write(self.fd, buffer) os.write(self.fd, buffer)
def __getitem__(self, slice): def __getitem__(self, slc):
if self.fd is None: if self.fd is None:
self.open_ro() self.open_ro()
assert self.fd is not None if self.fd is None:
os.lseek(self.fd, slice.start, os.SEEK_SET) raise RuntimeError("file descriptor is not available for read")
size = slice.stop - slice.start os.lseek(self.fd, slc.start, os.SEEK_SET)
size = slc.stop - slc.start
data = os.read(self.fd, size) data = os.read(self.fd, size)
if len(data) < size: if len(data) < size:
raise EOFError("Error reading requested range") raise EOFError("Error reading requested range")
@@ -71,7 +75,7 @@ class FileServer:
@staticmethod @staticmethod
def _stat_size(path): def _stat_size(path):
try: try:
return os.stat(path).st_size return Path(path).stat().st_size
except FileNotFoundError: except FileNotFoundError:
return None return None
+13 -13
View File
@@ -1,12 +1,14 @@
import asyncio import asyncio
import contextlib
import mimetypes import mimetypes
import os import os
import re import re
import shutil import shutil
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from datetime import datetime, timezone from datetime import UTC, datetime
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
from urllib.parse import quote as url_quote, unquote, urlparse from urllib.parse import quote as url_quote
from urllib.parse import unquote, urlparse
from wsgiref.handlers import format_date_time from wsgiref.handlers import format_date_time
from sanic import Blueprint, HTTPResponse, empty, json from sanic import Blueprint, HTTPResponse, empty, json
@@ -155,7 +157,7 @@ async def copy_or_move(request, name=""):
raise NotFound("Files not found", context={"missing": missing}) raise NotFound("Files not found", context={"missing": missing})
# Validate target shape/type before mutating anything. # Validate target shape/type before mutating anything.
for op_name, op_keys in (("cp", cp_keys), ("mv", mv_keys)): for _op_name, op_keys in (("cp", cp_keys), ("mv", mv_keys)):
if len(op_keys) > 1 and not dst_is_dir: if len(op_keys) > 1 and not dst_is_dir:
raise BadRequest("Destination must be an existing directory for multiple keys") raise BadRequest("Destination must be an existing directory for multiple keys")
if not op_keys: if not op_keys:
@@ -175,7 +177,7 @@ async def copy_or_move(request, name=""):
changed: set[PurePosixPath] = set() changed: set[PurePosixPath] = set()
completed: list[dict[str, str]] = [] completed: list[dict[str, str]] = []
class _FileOpFailed(Exception): class _FileOpError(Exception):
def __init__(self, op_name: str, key: str, error: Exception): def __init__(self, op_name: str, key: str, error: Exception):
self.op_name = op_name self.op_name = op_name
self.key = key self.key = key
@@ -236,11 +238,11 @@ async def copy_or_move(request, name=""):
changed.add(dst_item_rel.parent) changed.add(dst_item_rel.parent)
completed.append({"op": op_name, "key": key}) completed.append({"op": op_name, "key": key})
except Exception as e: except Exception as e:
raise _FileOpFailed(op_name, key, e) from e raise _FileOpError(op_name, key, e) from e
try: try:
await asyncio.to_thread(_apply) await asyncio.to_thread(_apply)
except _FileOpFailed as e: except _FileOpError as e:
raise BadRequest( raise BadRequest(
"File operation failed after partial progress", "File operation failed after partial progress",
context={ context={
@@ -310,7 +312,7 @@ async def dav_copy(request, name=""):
if not dest_header: if not dest_header:
raise BadRequest("Missing Destination header") raise BadRequest("Missing Destination header")
overwrite = request.headers.get("overwrite", "T").strip().upper() != "F" overwrite = request.headers.get("overwrite", "T").strip().upper() != "F"
src_rel, src_abs = _safe_relpath(name) _src_rel, src_abs = _safe_relpath(name)
dst_rel, dst_abs = _parse_webdav_destination(dest_header) dst_rel, dst_abs = _parse_webdav_destination(dest_header)
request.ctx._log_extra = f"{dst_rel}" request.ctx._log_extra = f"{dst_rel}"
if not src_abs.exists(): if not src_abs.exists():
@@ -537,7 +539,7 @@ def _parse_webdav_destination(dest_header: str) -> tuple[PurePosixPath, Path]:
return _safe_relpath(rel_str) return _safe_relpath(rel_str)
def _rel_to_href(rel: PurePosixPath, is_dir: bool) -> str: def _rel_to_href(rel: PurePosixPath, *, is_dir: bool) -> str:
"""Build a DAV href from a storage-relative path.""" """Build a DAV href from a storage-relative path."""
parts = rel.parts parts = rel.parts
if not parts: if not parts:
@@ -560,10 +562,8 @@ def _collect_propfind_entries(rel: PurePosixPath, path: Path, depth: str) -> lis
if depth == "1" and path.is_dir(): if depth == "1" and path.is_dir():
for child in sorted(path.iterdir(), key=lambda p: (p.is_file(), p.name)): 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) child_rel = rel / child.name if rel.parts else PurePosixPath(child.name)
try: with contextlib.suppress(OSError):
entries.append(_propfind_entry(child_rel, child)) entries.append(_propfind_entry(child_rel, child))
except OSError:
pass
return entries return entries
@@ -571,14 +571,14 @@ def _propfind_entry(rel: PurePosixPath, path: Path) -> dict:
st = path.stat() st = path.stat()
is_dir = path.is_dir() is_dir = path.is_dir()
return { return {
"href": _rel_to_href(rel, is_dir), "href": _rel_to_href(rel, is_dir=is_dir),
"name": rel.parts[-1] if rel.parts else "", "name": rel.parts[-1] if rel.parts else "",
"is_dir": is_dir, "is_dir": is_dir,
"size": st.st_size, "size": st.st_size,
"etag": f'"{st.st_mtime:.0f}-{st.st_size}"', "etag": f'"{st.st_mtime:.0f}-{st.st_size}"',
"content_type": mimetypes.guess_type(path.name)[0] or "application/octet-stream", "content_type": mimetypes.guess_type(path.name)[0] or "application/octet-stream",
"last_modified": format_date_time(st.st_mtime), "last_modified": format_date_time(st.st_mtime),
"created": datetime.fromtimestamp(st.st_ctime, tz=timezone.utc).strftime( "created": datetime.fromtimestamp(st.st_ctime, tz=UTC).strftime(
"%Y-%m-%dT%H:%M:%SZ" "%Y-%m-%dT%H:%M:%SZ"
), ),
} }
-2
View File
@@ -4,8 +4,6 @@ from typing import Any
import msgspec import msgspec
from cista import config
class ErrorMsg(msgspec.Struct): class ErrorMsg(msgspec.Struct):
error: dict[str, Any] error: dict[str, Any]
+1 -1
View File
@@ -232,7 +232,7 @@ def configure_access_logging() -> None:
_LEVEL_EMOJI = { _LEVEL_EMOJI = {
logging.DEBUG: "🔍", logging.DEBUG: "🔍",
logging.INFO: "", logging.INFO: "i",
logging.WARNING: "⚠️", logging.WARNING: "⚠️",
logging.ERROR: "🛑", logging.ERROR: "🛑",
logging.CRITICAL: "🛑", logging.CRITICAL: "🛑",
+4 -4
View File
@@ -12,7 +12,7 @@ def run(*, dev=False):
"""Run Sanic main process that spawns worker processes to serve HTTP requests.""" """Run Sanic main process that spawns worker processes to serve HTTP requests."""
from .app import app from .app import app
url, opts = parse_listen(config.config.listen) _url, opts = parse_listen(config.config.listen)
# Silence Sanic's warning about running in production rather than debug # Silence Sanic's warning about running in production rather than debug
os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1" os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1"
confdir = config.conffile.parent confdir = config.conffile.parent
@@ -21,14 +21,14 @@ def run(*, dev=False):
server80.app.prepare(port=80, motd=False) server80.app.prepare(port=80, motd=False)
domain = opts["host"] domain = opts["host"]
check_cert(confdir / domain, domain) check_cert(confdir / domain, domain)
opts["ssl"] = str(confdir / domain) # type: ignore opts["ssl"] = str(confdir / domain) # type: ignore[assignment]
app.prepare( app.prepare(
**opts, **opts,
motd=False, motd=False,
dev=dev, dev=dev,
auto_reload=dev, auto_reload=dev,
access_log=False, access_log=False,
) # type: ignore ) # type: ignore[call-arg]
if dev: if dev:
Sanic.serve() Sanic.serve()
else: else:
@@ -38,7 +38,7 @@ def run(*, dev=False):
def check_cert(certdir, domain): def check_cert(certdir, domain):
if (certdir / "privkey.pem").exist() and (certdir / "fullchain.pem").exists(): if (certdir / "privkey.pem").exist() and (certdir / "fullchain.pem").exists():
return return
# TODO: Use certbot to fetch a cert # Certificate provisioning is external; files must exist before startup.
raise ValueError( raise ValueError(
f"TLS certificate files privkey.pem and fullchain.pem needed in {certdir}", f"TLS certificate files privkey.pem and fullchain.pem needed in {certdir}",
) )
+3 -3
View File
@@ -31,9 +31,9 @@ def create(res, username, *, secure: bool = True, **kwargs):
def update(res, s, *, secure: bool = True, **kwargs): def update(res, s, *, secure: bool = True, **kwargs):
s.update(kwargs) s.update(kwargs)
s = jwt.encode(s, session_secret()) max_age = max(1, s["exp"] - int(time()))
max_age = max(1, s["exp"] - int(time())) # type: ignore token = jwt.encode(s, session_secret())
res.cookies.add_cookie("s", s, httponly=True, max_age=max_age, secure=secure) res.cookies.add_cookie("s", token, httponly=True, max_age=max_age, secure=secure)
def delete(res): def delete(res):
+20 -22
View File
@@ -126,22 +126,21 @@ async def validate_sso_request(request, *, perm: str = "cista:login") -> dict |
context=error_data, context=error_data,
quiet=True, quiet=True,
) )
elif response.status_code == 403: if response.status_code == 403:
raise Forbidden( raise Forbidden(
error_data.get("detail", "Access denied"), error_data.get("detail", "Access denied"),
context=error_data, context=error_data,
quiet=True, quiet=True,
) )
else: detail = error_data.get("detail", "")
detail = error_data.get("detail", "") logger.warning(
logger.warning( f"SSO validation {url} returned {response.status_code}: {detail}"
f"SSO validation {url} returned {response.status_code}: {detail}" )
) raise Forbidden(
raise Forbidden( detail or "Authentication error",
detail or "Authentication error", context=error_data,
context=error_data, quiet=True,
quiet=True, )
)
except httpx.RequestError as e: except httpx.RequestError as e:
logger.error(f"SSO validation {url} network error: {e}") logger.error(f"SSO validation {url} network error: {e}")
@@ -149,7 +148,7 @@ async def validate_sso_request(request, *, perm: str = "cista:login") -> dict |
"Authentication service unavailable", "Authentication service unavailable",
status_code=502, status_code=502,
quiet=True, quiet=True,
) ) from e
async def check_permissions(user_id: str, perm: str) -> dict: async def check_permissions(user_id: str, perm: str) -> dict:
@@ -194,11 +193,10 @@ async def check_permissions(user_id: str, perm: str) -> dict:
error_data.get("detail", "Access denied"), error_data.get("detail", "Access denied"),
quiet=True, quiet=True,
) )
else: raise Forbidden(
raise Forbidden( error_data.get("detail", "Permission check failed"),
error_data.get("detail", "Permission check failed"), quiet=True,
quiet=True, )
)
except httpx.RequestError as e: except httpx.RequestError as e:
logger.error(f"Permission check {url} network error: {e}") logger.error(f"Permission check {url} network error: {e}")
@@ -206,7 +204,7 @@ async def check_permissions(user_id: str, perm: str) -> dict:
"Authentication service unavailable", "Authentication service unavailable",
status_code=502, status_code=502,
quiet=True, quiet=True,
) ) from e
async def proxy_auth_request(request): async def proxy_auth_request(request):
@@ -324,15 +322,15 @@ async def proxy_auth_websocket(request, ws):
try: try:
async for message in ws: async for message in ws:
await backend_ws.send(message) await backend_ws.send(message)
except Exception: except Exception as e:
pass logger.debug("WebSocket forward_to_backend ended: %s", e)
async def forward_to_client(): async def forward_to_client():
try: try:
async for message in backend_ws: async for message in backend_ws:
await ws.send(message) await ws.send(message)
except Exception: except Exception as e:
pass logger.debug("WebSocket forward_to_client ended: %s", e)
await asyncio.gather( await asyncio.gather(
forward_to_backend(), forward_to_backend(),
+1 -1
View File
@@ -30,7 +30,7 @@ async def handle_sanic_exception(request, e):
context = e.context or {} context = e.context or {}
code = e.status_code code = e.status_code
headers = getattr(e, "headers", None) headers = getattr(e, "headers", None)
if not message or not request.app.debug and code == 500: if not message or (not request.app.debug and code == 500):
message = "Internal Server Error" message = "Internal Server Error"
message = f"⚠️ {message}" if code < 500 else f"🛑 {message}" message = f"⚠️ {message}" if code < 500 else f"🛑 {message}"
if code == 500: if code == 500:
+1 -1
View File
@@ -40,7 +40,7 @@ class AsyncLink:
async def stop(self): async def stop(self):
"""Stop worker and clean up.""" """Stop worker and clean up."""
while not self.queue.empty(): while not self.queue.empty():
command, future = self.queue.get_nowait() _command, future = self.queue.get_nowait()
if not future.done(): if not future.done():
future.set_exception(Exception("AsyncLink stopped")) future.set_exception(Exception("AsyncLink stopped"))
self.queue.task_done() self.queue.task_done()
+7 -7
View File
@@ -1,5 +1,5 @@
from collections.abc import Callable
from time import monotonic from time import monotonic
from typing import Callable
class LRUCache: class LRUCache:
@@ -7,22 +7,22 @@ class LRUCache:
LRUCache is a least-recently-used (LRU) cache with expiry time. LRUCache is a least-recently-used (LRU) cache with expiry time.
Attributes: Attributes:
open (callable): Function to open a new handle. opener (callable): Function to open a new handle.
capacity (int): Max number of items in the cache. capacity (int): Max number of items in the cache.
maxage (float): Max age for items in cache in seconds. maxage (float): Max age for items in cache in seconds.
cache (list): Internal list storing the cache items. cache (list): Internal list storing the cache items.
""" """
def __init__(self, open: Callable, *, capacity: int, maxage: float): def __init__(self, opener: Callable, *, capacity: int, maxage: float):
""" """
Initialize LRUCache. Initialize LRUCache.
Args: Args:
open (callable): Function to open a new handle. opener (callable): Function to open a new handle.
capacity (int): Maximum capacity of the cache. capacity (int): Maximum capacity of the cache.
maxage (float): Max age for items in cache in seconds. maxage (float): Max age for items in cache in seconds.
""" """
self.open = open self.opener = opener
self.capacity = capacity self.capacity = capacity
self.maxage = maxage self.maxage = maxage
self.cache = [] # Each item is a tuple: (key, handle, timestamp), recent items first self.cache = [] # Each item is a tuple: (key, handle, timestamp), recent items first
@@ -47,7 +47,7 @@ class LRUCache:
self.cache.pop(i) self.cache.pop(i)
break break
else: else:
f = self.open(key) f = self.opener(key)
# Add/restore to end of cache # Add/restore to end of cache
self.cache.insert(0, (key, f, monotonic())) self.cache.insert(0, (key, f, monotonic()))
self.expire_items() self.expire_items()
@@ -58,7 +58,7 @@ class LRUCache:
Expire items that are either too old or exceed cache capacity. Expire items that are either too old or exceed cache capacity.
""" """
ts = monotonic() - self.maxage ts = monotonic() - self.maxage
while len(self.cache) > self.capacity or self.cache and self.cache[-1][2] < ts: while len(self.cache) > self.capacity or (self.cache and self.cache[-1][2] < ts):
self.cache.pop()[1].close() self.cache.pop()[1].close()
def close(self): def close(self):
+3 -53
View File
File diff suppressed because one or more lines are too long
+16 -13
View File
@@ -141,8 +141,8 @@ def treeinspos(rootmod: list[FileEntry], relpath: PurePosixPath, relfile: int):
state = State() state = State()
rootpath: Path = None # type: ignore rootpath: Path | None = None
quit = threading.Event() stop_event = threading.Event()
# Thread-safe queue for signaling path updates from websockets # Thread-safe queue for signaling path updates from websockets
_update_queue: queue.Queue[PurePosixPath] = queue.Queue() _update_queue: queue.Queue[PurePosixPath] = queue.Queue()
@@ -150,9 +150,8 @@ _update_queue: queue.Queue[PurePosixPath] = queue.Queue()
def notify_change(*paths: PurePosixPath | str): def notify_change(*paths: PurePosixPath | str):
"""Signal that paths have changed. Called from control/upload websockets.""" """Signal that paths have changed. Called from control/upload websockets."""
for path in paths: for raw_path in paths:
if isinstance(path, str): path = PurePosixPath(raw_path) if isinstance(raw_path, str) else raw_path
path = PurePosixPath(path)
# Convert absolute paths to relative (strip leading /) # Convert absolute paths to relative (strip leading /)
if path.is_absolute(): if path.is_absolute():
path = ( path = (
@@ -192,10 +191,10 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
if isfile: if isfile:
return [entry] return [entry]
# Walk all entries of the directory # Walk all entries of the directory
ret: list[FileEntry] = [...] # type: ignore ret: list[FileEntry] = []
li = [] li = []
for f in path.iterdir(): for f in path.iterdir():
if quit.is_set(): if stop_event.is_set():
raise SystemExit("quit") raise SystemExit("quit")
if f.name.startswith("."): if f.name.startswith("."):
continue # No dotfiles continue # No dotfiles
@@ -508,7 +507,7 @@ class PathIndex:
if lo < len(children): if lo < len(children):
return children[lo] return children[lo]
elif children: if children:
# Insert after last child's subtree # Insert after last child's subtree
last_idx = children[-1] last_idx = children[-1]
last_entry = self.root[last_idx] last_entry = self.root[last_idx]
@@ -656,7 +655,7 @@ def watcher(loop):
) )
) )
while not quit.is_set(): while not stop_event.is_set():
if use_inotify: if use_inotify:
import inotify.adapters import inotify.adapters
@@ -674,7 +673,11 @@ def watcher(loop):
first_event_time: float | None = None first_event_time: float | None = None
last_event_time: float | None = None last_event_time: float | None = None
def add_dirty(path: PurePosixPath, source: str) -> bool: def add_dirty(
path: PurePosixPath,
source: str,
dirty_paths=dirty_paths,
) -> bool:
"""Add path to dirty set. Returns True if added, False if redundant.""" """Add path to dirty set. Returns True if added, False if redundant."""
nonlocal first_event_time, last_event_time nonlocal first_event_time, last_event_time
# Check if already covered by an existing dirty path # Check if already covered by an existing dirty path
@@ -708,7 +711,7 @@ def watcher(loop):
last_event_time = now last_event_time = now
return True return True
while not quit.is_set(): while not stop_event.is_set():
now = time.monotonic() now = time.monotonic()
# Full refresh every 300s # Full refresh every 300s
@@ -779,7 +782,7 @@ def watcher(loop):
# Collect inotify events if available (short timeout for responsiveness) # Collect inotify events if available (short timeout for responsiveness)
if inotify_tree: if inotify_tree:
for event in inotify_tree.event_gen(yield_nones=False, timeout_s=0.05): for event in inotify_tree.event_gen(yield_nones=False, timeout_s=0.05):
if quit.is_set(): if stop_event.is_set():
return return
if not (modified_flags & set(event[1])): if not (modified_flags & set(event[1])):
continue continue
@@ -823,5 +826,5 @@ def start(app):
def stop(app): def stop(app):
quit.set() stop_event.set()
app.ctx.watcher.join() app.ctx.watcher.join()
+9 -4
View File
@@ -23,7 +23,12 @@ from pathlib import Path
# Import devutil from scripts/fastapi-vue (not a package, so we adjust sys.path) # Import devutil from scripts/fastapi-vue (not a package, so we adjust sys.path)
sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue"))) sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue")))
from devutil import ProcessGroup, logger, ready, setup_vite # type: ignore from devutil import ( # type: ignore[import-not-found]
ProcessGroup,
logger,
ready,
setup_vite,
)
from cista import config from cista import config
from cista.serve import parse_listen from cista.serve import parse_listen
@@ -40,13 +45,13 @@ def setup_sanic_backend(
""" """
config.load_config() config.load_config()
listen = listen or config.config.listen or f":{DEFAULT_BACKEND_PORT}" listen = listen or config.config.listen or f":{DEFAULT_BACKEND_PORT}"
url, opts = parse_listen(listen) _url, opts = parse_listen(listen)
port = opts.get("port", DEFAULT_BACKEND_PORT) port = opts.get("port", DEFAULT_BACKEND_PORT)
host = opts.get("host", "localhost") or "localhost" host = opts.get("host", "localhost") or "localhost"
# Use the current interpreter/module path so devserver always runs # Use the current interpreter/module path so devserver always runs
# workspace source code instead of a potentially stale installed script. # workspace source code instead of a potentially stale installed script.
cmd = [sys.executable, "-m", "cista", "--dev", "-l", listen] + extra_args cmd = [sys.executable, "-m", "cista", "--dev", "-l", listen, *extra_args]
return f"http://{host}:{port}", cmd return f"http://{host}:{port}", cmd
@@ -59,7 +64,7 @@ async def run_devserver(
logger.warning("Frontend source not found at %s", front) logger.warning("Frontend source not found at %s", front)
raise SystemExit(1) raise SystemExit(1)
frontend_url, npm_install, vite = setup_vite(frontend or "") _frontend_url, npm_install, vite = setup_vite(frontend or "")
backend_url, sanic_cmd = setup_sanic_backend(backend, extra_args) backend_url, sanic_cmd = setup_sanic_backend(backend, extra_args)
# Tell vite where to proxy API requests # Tell vite where to proxy API requests
+3 -1
View File
@@ -3,7 +3,9 @@
import sys import sys
from pathlib import Path from pathlib import Path
from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore from hatchling.builders.hooks.plugin.interface import (
BuildHookInterface, # type: ignore[import-not-found]
)
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
from buildutil import build from buildutil import build
+9 -6
View File
@@ -30,8 +30,11 @@ def _check_node_version(node_path: str) -> None:
Raises RuntimeError if version is too old or cannot be determined. Raises RuntimeError if version is too old or cannot be determined.
""" """
try: try:
result = subprocess.run( result = subprocess.run( # noqa: S603
[node_path, "--version"], capture_output=True, text=True, check=True [node_path, "--version"],
capture_output=True,
text=True,
check=True,
) )
version_str = result.stdout.strip() version_str = result.stdout.strip()
# Parse version like "v20.10.0" or "v18.17.1" # Parse version like "v20.10.0" or "v18.17.1"
@@ -176,16 +179,16 @@ def build(folder: str = "frontend") -> None:
install_cmd, build_cmd = find_build_tool() install_cmd, build_cmd = find_build_tool()
except RuntimeError as e: except RuntimeError as e:
logger.warning(e) logger.warning(e)
raise SystemExit(1) raise SystemExit(1) from e
def run(cmd): def run(cmd):
display_cmd = [Path(cmd[0]).name, *cmd[1:]] display_cmd = [Path(cmd[0]).name, *cmd[1:]]
logger.info("### %s", " ".join(display_cmd)) logger.info("### %s", " ".join(display_cmd))
subprocess.run(cmd, check=True, cwd=folder) subprocess.run(cmd, check=True, cwd=folder) # noqa: S603
try: try:
run(install_cmd) run(install_cmd)
logger.info("") logger.info("")
run(build_cmd) run(build_cmd)
except subprocess.CalledProcessError: except subprocess.CalledProcessError as e:
raise SystemExit(1) raise SystemExit(1) from e
+5 -8
View File
@@ -1,6 +1,7 @@
"""Utilities meant for devserver script, used only in source repository with dev deps.""" """Utilities meant for devserver script, used only in source repository with dev deps."""
import asyncio import asyncio
import contextlib
from pathlib import Path from pathlib import Path
import httpx import httpx
@@ -58,10 +59,8 @@ class ProcessGroup:
# Terminate remaining processes # Terminate remaining processes
for p in self._procs: for p in self._procs:
if p.returncode is None: if p.returncode is None:
try: with contextlib.suppress(ProcessLookupError):
p.terminate() p.terminate()
except ProcessLookupError:
pass
# Wait for all to finish (with overall timeout) # Wait for all to finish (with overall timeout)
still_running = [p for p in self._procs if p.returncode is None] still_running = [p for p in self._procs if p.returncode is None]
@@ -74,10 +73,8 @@ class ProcessGroup:
except TimeoutError: except TimeoutError:
for p in self._procs: for p in self._procs:
if p.returncode is None: if p.returncode is None:
try: with contextlib.suppress(ProcessLookupError):
p.kill() p.kill()
except ProcessLookupError:
pass
await p.wait() await p.wait()
@@ -95,10 +92,10 @@ async def ready(url: str, path: str = "") -> None:
await client.get(full_url, timeout=1.0) await client.get(full_url, timeout=1.0)
logger.info("✓ Backend ready!") logger.info("✓ Backend ready!")
return return
except httpx.RequestError: except httpx.RequestError as e:
if attempt == max_attempts - 1: if attempt == max_attempts - 1:
logger.warning("Backend didn't start in time") logger.warning("Backend didn't start in time")
raise SystemExit(1) raise SystemExit(1) from e
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
+1 -2
View File
@@ -1,7 +1,6 @@
import base64 import base64
import hashlib import hashlib
import hmac import hmac
import re
import struct import struct
from pathlib import Path from pathlib import Path
from time import time from time import time
@@ -94,7 +93,7 @@ def _session_cookie_header(username: str) -> dict[str, str]:
return {"Cookie": f"s={token}"} return {"Cookie": f"s={token}"}
@pytest.fixture() @pytest.fixture
def setup_storage(tmp_path: Path): def setup_storage(tmp_path: Path):
user = config.User() user = config.User()
auth.set_password(user, "secret") auth.set_password(user, "secret")
+3 -3
View File
@@ -10,7 +10,7 @@ from cista import config, watching
from cista.fileserver import bp as fileserver_bp from cista.fileserver import bp as fileserver_bp
@pytest.fixture() @pytest.fixture
def setup_storage(tmp_path: Path): def setup_storage(tmp_path: Path):
config.config = config.Config(path=tmp_path, listen=":0", public=True) config.config = config.Config(path=tmp_path, listen=":0", public=True)
watching.state.root = [] watching.state.root = []
@@ -33,7 +33,7 @@ async def client(setup_storage: Path):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_percent2F_decoded_as_path_separator(client, setup_storage: Path): async def test_get_percent2f_decoded_as_path_separator(client, setup_storage: Path):
"""%2F in the URL path is decoded to '/' and treated as a path separator.""" """%2F in the URL path is decoded to '/' and treated as a path separator."""
(setup_storage / "sub").mkdir() (setup_storage / "sub").mkdir()
(setup_storage / "sub" / "file.txt").write_text("hello", encoding="utf-8") (setup_storage / "sub" / "file.txt").write_text("hello", encoding="utf-8")
@@ -45,7 +45,7 @@ async def test_get_percent2F_decoded_as_path_separator(client, setup_storage: Pa
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mkcol_percent2F_creates_nested_directory(client, setup_storage: Path): async def test_mkcol_percent2f_creates_nested_directory(client, setup_storage: Path):
"""%2F in MKCOL path is decoded as a separator, creating nested dirs.""" """%2F in MKCOL path is decoded as a separator, creating nested dirs."""
_, res = await client.request("MKCOL", "/files/parent%2Fchild") _, res = await client.request("MKCOL", "/files/parent%2Fchild")
+1 -1
View File
@@ -10,7 +10,7 @@ from cista.fileserver import bp as fileserver_bp
from cista.protocol import FileEntry from cista.protocol import FileEntry
@pytest.fixture() @pytest.fixture
def setup_storage(tmp_path: Path): def setup_storage(tmp_path: Path):
config.config = config.Config(path=tmp_path, listen=":0", public=True) config.config = config.Config(path=tmp_path, listen=":0", public=True)
watching.state.root = [] watching.state.root = []
+1 -1
View File
@@ -9,7 +9,7 @@ from cista import config, watching
from cista.fileserver import bp as fileserver_bp from cista.fileserver import bp as fileserver_bp
@pytest.fixture() @pytest.fixture
def setup_storage(tmp_path: Path): def setup_storage(tmp_path: Path):
config.config = config.Config(path=tmp_path, listen=":0", public=True) config.config = config.Config(path=tmp_path, listen=":0", public=True)
watching.state.root = [] watching.state.root = []
+1 -2
View File
@@ -9,13 +9,12 @@ from sanic import Sanic
from cista import config, watching from cista import config, watching
from cista.fileserver import bp as fileserver_bp from cista.fileserver import bp as fileserver_bp
from cista.protocol import FileEntry
_DAV_NS = "DAV:" _DAV_NS = "DAV:"
_METHODS = ("MKCOL", "MOVE", "COPY", "PROPFIND") _METHODS = ("MKCOL", "MOVE", "COPY", "PROPFIND")
@pytest.fixture() @pytest.fixture
def setup_storage(tmp_path: Path): def setup_storage(tmp_path: Path):
config.config = config.Config(path=tmp_path, listen=":0", public=True) config.config = config.Config(path=tmp_path, listen=":0", public=True)
watching.state.root = [] watching.state.root = []
+5 -6
View File
@@ -1,8 +1,6 @@
from pathlib import Path
from time import time
from uuid import uuid4
import os import os
from pathlib import Path
from uuid import uuid4
import pytest import pytest
import pytest_asyncio import pytest_asyncio
@@ -13,9 +11,10 @@ from cista.auth import bp as auth_bp
def _persist_config(): def _persist_config():
import msgspec
from pathlib import PurePath from pathlib import PurePath
import msgspec
def enc_hook(obj): def enc_hook(obj):
if isinstance(obj, PurePath): if isinstance(obj, PurePath):
return obj.as_posix() return obj.as_posix()
@@ -25,7 +24,7 @@ def _persist_config():
config.conffile.write_bytes(msgspec.toml.encode(raw)) config.conffile.write_bytes(msgspec.toml.encode(raw))
@pytest.fixture() @pytest.fixture
def setup_storage(tmp_path: Path): def setup_storage(tmp_path: Path):
os.environ["CISTA_HOME"] = str(tmp_path) os.environ["CISTA_HOME"] = str(tmp_path)
config.init_confdir() config.init_confdir()