diff --git a/README.md b/README.md index 23dd327..1d49a2e 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ The server remembers its settings in the config folder (default `~/.local/share/ ## Authentication -Cista supports two authenticatioon mode, each of which supporting ordinary and privileged users. Either one can be combined with the public mode. +Cista supports two authentication modes, each supporting ordinary and privileged users. Either one can be combined with the public mode. ### Public Mode @@ -83,6 +83,30 @@ In Paskia mode: - Users with `cista:login` permission can access files - Users with `cista:admin` permission get privileged access (Admin Settings) +## WebDAV Access + +Cista supports WebDAV, so you can mount it as a network drive or browse it directly from your operating system's file manager. + +Connect to `https://cista.example.com/files/`. + +### Authentication + +- **Standard users:** Use your username and password with Basic auth. +- **API tokens:** For scripts, backup tools, or when your client requires NTLM (e.g. Windows File Explorer), create a token in the web interface via **🔑 API Tokens**. Authenticate with username `token` and the token secret as the password. + +### Supported clients + +| Client | Setup | +|--------|-------| +| **Windows File Explorer** | Map Network Drive → `https://cista.example.com/files/` (or Add a network location). Windows may try NTLM first; API tokens are recommended. | +| **macOS Finder** | Go → Connect to Server (⌘K) → `https://cista.example.com/files/` | +| **Linux (GNOME/KDE)** | Enter `davs://cista.example.com/files/` or `webdavs://cista.example.com/files/` in the location bar | +| **Android — Solid Explorer** | Tap **+** → New Cloud Connection → **WebDAV** → enter `https://cista.example.com/files/` and your credentials. | +| **Android — CX File Explorer** | Open the **Network** tab → **New location** → **WebDAV** → enter `https://cista.example.com/files/` and your credentials. | +| **Cyberduck, WinSCP, rclone** | Standard WebDAV profile with Basic auth | + +**Note on Windows NTLM:** Windows WebDAV clients often require NTLM authentication, which is incompatible with Cista's Argon2 password hashes. API tokens solve this — Cista uses the token secret as the NTLM password. + ### Internet Access Most admins find the [Caddy](https://caddyserver.com/) web server convenient for its auto TLS certificates and all. A proxy also allows running multiple web services or Cista instances on the same IP address but different (sub)domains. diff --git a/cista/api.py b/cista/api.py index aa764b9..82f8148 100644 --- a/cista/api.py +++ b/cista/api.py @@ -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/") +async def delete_api_token(request, token_id): + return await delete_token_handler(request, token_id) diff --git a/cista/app.py b/cista/app.py index 3c64b13..18b17ba 100644 --- a/cista/app.py +++ b/cista/app.py @@ -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/") - 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): diff --git a/cista/auth.py b/cista/auth.py index f558cd0..6c7788f 100644 --- a/cista/auth.py +++ b/cista/auth.py @@ -1,5 +1,10 @@ +import base64 +import binascii import hmac +import hashlib import re +import secrets +import struct from time import time from unicodedata import normalize @@ -8,6 +13,7 @@ import msgspec from html5tagger import Document from sanic import Blueprint, html, json, redirect from sanic.exceptions import BadRequest, Forbidden, Unauthorized +from sanic.log import logger from cista import config, session from cista.util import pwgen @@ -180,15 +186,351 @@ def _get_sso(): return _sso_module +def _set_auth_failure_log(request, auth_flow: list[str]) -> None: + parts = list(auth_flow) + # Only add request headers that are present and useful for debugging + for header, label in ( + ("accept", "accept"), + ("origin", "origin"), + ("referer", "referer"), + ("sec-fetch-site", "site"), + ("sec-fetch-mode", "mode"), + ("sec-fetch-dest", "dest"), + ): + value = request.headers.get(header) + if value: + parts.append(f"{label}={value}") + request.ctx._log_extra = " | ".join(parts) + + +def hydrate_request_auth_context(request, *, source: str) -> None: + auth_flow = getattr(request.ctx, "_auth_flow", None) + if auth_flow is None: + auth_flow = request.ctx._auth_flow = [] + + if hasattr(request.ctx, "session"): + # Already hydrated by an earlier caller (e.g., use_session middleware) + return + + request.ctx.session = session.get(request) + if request.ctx.session is None: + request.ctx.username = None + request.ctx.user = None + auth_flow.append(f"session:{source}(none)") + elif request.ctx.session is False: + request.ctx.username = None + request.ctx.user = None + auth_flow.append(f"session:{source}(invalid)") + else: + try: + request.ctx.username = request.ctx.session["username"] # type: ignore[index] + request.ctx.user = config.config.users[request.ctx.username] + auth_flow.append(f"session:{source}({request.ctx.username})") + except (AttributeError, KeyError, TypeError): + request.ctx.username = None + request.ctx.user = None + auth_flow.append(f"session:{source}(bad-jwt)") + + _argon = argon2.PasswordHasher() _droppyhash = re.compile(r"^([a-f0-9]{64})\$([a-f0-9]{8})$") +_AUTH_REALM = "cista" +_AUTH_CACHE_TTL = 10 +_auth_cache: dict[str, tuple[float, config.User]] = {} +_WINDOWS_UA_HINTS = ( + "windows", + "microsoft-webdav-miniredir", + "davclnt", +) +_WEBDAV_METHODS = { + "OPTIONS", + "PROPFIND", + "MKCOL", + "COPY", + "MOVE", + "LOCK", + "UNLOCK", +} +_seen_webdav_uas: set[str] = set() + +# NTLM challenge storage: global rolling window of random challenges. +# Challenges are always generated with secrets.token_bytes; no client-IP or +# request-order keying is used so parallel requests do not overwrite state. +_ntlm_challenges: list[tuple[float, bytes]] = [] +_NTLM_CHALLENGE_TTL = 30 +_NTLM_CHALLENGE_MAX = 64 + + +def _is_windows_auth_client(user_agent: str) -> bool: + ua = user_agent.casefold() + return any(marker in ua for marker in _WINDOWS_UA_HINTS) + + +def _log_webdav_user_agent_once(request, user_agent: str): + if request.method not in _WEBDAV_METHODS: + return + key = (user_agent or "").strip() or "" + if key in _seen_webdav_uas: + return + _seen_webdav_uas.add(key) + # 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]: + user_agent = request.headers.get("user-agent", "") + _log_webdav_user_agent_once(request, user_agent) + if _is_windows_auth_client(user_agent): + challenge = f'Basic realm="{_AUTH_REALM}", Negotiate' + else: + challenge = f'Basic realm="{_AUTH_REALM}"' + headers = {"WWW-Authenticate": challenge} + return headers + + +def _cleanup_ntlm_challenges(): + now = time() + _ntlm_challenges[:] = [ + (ts, challenge) + for ts, challenge in _ntlm_challenges + if now - ts <= _NTLM_CHALLENGE_TTL + ] + + +def _set_ntlm_challenge(challenge: bytes): + _cleanup_ntlm_challenges() + _ntlm_challenges.append((time(), challenge)) + if len(_ntlm_challenges) > _NTLM_CHALLENGE_MAX: + del _ntlm_challenges[:-_NTLM_CHALLENGE_MAX] + + +def _get_ntlm_challenges() -> list[bytes]: + _cleanup_ntlm_challenges() + # Try newest challenge first; older ones are fallback for request races. + return [challenge for _, challenge in reversed(_ntlm_challenges)] + + +def _ntlm_parse_type1(data: bytes) -> dict: + if len(data) < 16 or data[:7] != b"NTLMSSP" or data[7] != 0: + return {} + msg_type = struct.unpack(" bytes: + target = target_name.encode("utf-16le") + + # AV pairs for TargetInfo: NetBIOS + DNS names, terminated by EOL. + av_pairs = bytearray() + av_pairs.extend(struct.pack(" bytes: + if n < 0x80: + return bytes([n]) + b = n.to_bytes((n.bit_length() + 7) // 8, "big") + return bytes([0x80 | len(b)]) + b + + +def _der_tlv(tag: int, value: bytes) -> bytes: + return bytes([tag]) + _der_len(len(value)) + value + + +def _spnego_wrap_ntlm_challenge(ntlm_type2: bytes) -> bytes: + """Wrap an NTLM Type 2 token in SPNEGO NegTokenResp. + + Some Windows clients send SPNEGO-wrapped Negotiate tokens and require + a SPNEGO-wrapped response token rather than raw NTLMSSP. + """ + # OID 1.3.6.1.4.1.311.2.2.10 (NTLMSSP) + ntlm_oid = bytes.fromhex("060a2b06010401823702020a") + neg_state_accept_incomplete = _der_tlv(0xA0, _der_tlv(0x0A, b"\x01")) + supported_mech = _der_tlv(0xA1, ntlm_oid) + response_token = _der_tlv(0xA2, _der_tlv(0x04, ntlm_type2)) + neg_token_resp = _der_tlv( + 0xA1, + _der_tlv( + 0x30, + neg_state_accept_incomplete + supported_mech + response_token, + ), + ) + return neg_token_resp + + +def _ntlm_parse_type3(data: bytes) -> dict | None: + if len(data) < 64 or data[:7] != b"NTLMSSP" or data[7] != 0: + return None + msg_type = struct.unpack(" bytes: + length, max_len, buf_offset = struct.unpack(" len(data): + return b"" + return data[buf_offset : buf_offset + length] + + lm_response = read_buf(12) + nt_response = read_buf(20) + domain = read_buf(28) + username = read_buf(36) + workstation = read_buf(44) + + return { + "lm_response": lm_response, + "nt_response": nt_response, + "domain": domain.decode("utf-16le", errors="ignore"), + "username": username.decode("utf-16le", errors="ignore"), + "workstation": workstation.decode("utf-16le", errors="ignore"), + } + + +def _ntlmv2_verify( + token_secret: str, + username: str, + domain: str, + challenge: bytes, + nt_response: bytes, +) -> bool: + """Verify an NTLMv2 response using the plaintext token secret as the password.""" + try: + from Crypto.Hash import MD4 + except ImportError: + logger.error("pycryptodome MD4 not available, cannot verify NTLM") + return False + + if len(nt_response) < 16: + return False + + client_proof = nt_response[:16] + blob = nt_response[16:] + + # NT hash = MD4(UTF-16LE(password)) + nt_hash = MD4.new(token_secret.encode("utf-16le")).digest() + + raw_username = username or "" + raw_domain = domain or "" + + # Windows clients vary in how they populate Username/Domain fields. + user_candidates: list[str] = [] + domain_candidates: list[str] = [] + + def _add_user(value: str): + if value and value not in user_candidates: + user_candidates.append(value) + + def _add_domain(value: str): + if value not in domain_candidates: + domain_candidates.append(value) + + _add_user(raw_username) + _add_user(raw_username.upper()) + _add_domain(raw_domain) + _add_domain(raw_domain.upper()) + _add_domain("") + + if "\\" in raw_username: + dom_part, user_part = raw_username.split("\\", 1) + _add_user(user_part) + _add_user(user_part.upper()) + _add_domain(dom_part) + _add_domain(dom_part.upper()) + + if "@" in raw_username: + user_part, dom_part = raw_username.split("@", 1) + _add_user(user_part) + _add_user(user_part.upper()) + _add_domain(dom_part) + _add_domain(dom_part.upper()) + + for user_candidate in user_candidates: + for domain_candidate in domain_candidates: + # NTLMv2 hash = HMAC_MD5(NT_hash, UTF-16LE(username.upper() + domain)) + ntlmv2_hash = hmac.new( + nt_hash, + (user_candidate.upper() + domain_candidate).encode("utf-16le"), + hashlib.md5, + ).digest() + + # Expected proof = HMAC_MD5(NTLMv2_hash, challenge + blob) + expected_proof = hmac.new(ntlmv2_hash, challenge + blob, hashlib.md5).digest() + if hmac.compare_digest(client_proof, expected_proof): + return True + + return False + def _pwnorm(password): return normalize("NFC", password).strip().encode() +def _cache_key(username: str, password: str) -> str: + return hashlib.sha256(f"{username}\x00{password}".encode()).hexdigest() + + def login(username: str, password: str): + cache_key = _cache_key(username, password) + cached = _auth_cache.get(cache_key) + if cached: + ts, user = cached + if time() - ts < _AUTH_CACHE_TTL: + return user + del _auth_cache[cache_key] + un = _pwnorm(username) pw = _pwnorm(password) try: @@ -218,11 +560,13 @@ def login(username: str, password: str): set_password(u, password) now = int(time()) u.lastSeen = now + _auth_cache[cache_key] = (now, u) return u def set_password(user: config.User, password: str): user.hash = _argon.hash(_pwnorm(password)) + _auth_cache.clear() class LoginResponse(msgspec.Struct): @@ -231,6 +575,306 @@ class LoginResponse(msgspec.Struct): error: str = "" +def _basic_auth_login(request): + """Authenticate built-in users from an Authorization: Basic header. + + Supports two credential formats: + - Basic : (normal password login) + - Basic token: (token-based login) + """ + auth_header = request.headers.get("authorization") + if not auth_header: + return None + + scheme, _, encoded = auth_header.partition(" ") + if scheme.lower() != "basic": + return None # e.g. Negotiate/NTLM — ignore for this auth path + if not encoded: + raise Unauthorized("Invalid Authorization header", quiet=True) + + try: + raw = base64.b64decode(encoded, validate=True).decode("utf-8") + except (binascii.Error, UnicodeDecodeError) as e: + raise Unauthorized("Invalid Authorization header", quiet=True) from e + + username, sep, password = raw.partition(":") + if not sep: + raise Unauthorized("Invalid Authorization header", quiet=True) + + # Token auth: Basic token: + if username == "token": + token = config.config.tokens.get(password) + if token: + user = config.config.users.get(token.username) + if user: + request.ctx.session = None + request.ctx.username = token.username + request.ctx.user = user + user.lastSeen = int(time()) + return user + raise Unauthorized("Invalid token", quiet=True) + + # Password auth + try: + user = login(username, password) + except ValueError as e: + raise Unauthorized(str(e), quiet=True) from e + + request.ctx.session = None + request.ctx.username = username + request.ctx.user = user + return user + + + + + +async def _token_auth_login(request, privileged=False): + """Authenticate via Basic token: in SSO mode. + + Returns True if authenticated, False if no token matched. + Raises Unauthorized/Forbidden on invalid token or insufficient permissions. + """ + auth_header = request.headers.get("authorization", "") + if not auth_header: + return False + + scheme, _, value = auth_header.partition(" ") + if scheme.lower() != "basic": + return False + + try: + raw = base64.b64decode(value, validate=True).decode("utf-8") + username, _, password = raw.partition(":") + except Exception: + return False + + if username != "token" or not password: + return False + + token = config.config.tokens.get(password) + if not token: + return False + + sso = _get_sso() + if sso.paskia_enabled() and token.sso_user_id: + perm = "cista:admin" if privileged else "cista:login" + try: + data = await sso.check_permissions(token.sso_user_id, perm) + request.ctx.sso_user = data + ctx = data.get("ctx", {}) if isinstance(data, dict) else {} + user_info = ctx.get("user", {}) if isinstance(ctx, dict) else {} + request.ctx.username = user_info.get("display_name", "") + return True + except Forbidden: + raise + except Exception: + return False + + if token.username: + user = config.config.users.get(token.username) + if not user: + return False + if privileged and not user.privileged: + return False + request.ctx.session = None + request.ctx.username = token.username + request.ctx.user = user + user.lastSeen = int(time()) + return True + + return False + + +async def _ntlm_auth_login(request, privileged=False): + """Handle NTLM authentication for token-based login. + + Supports NTLMv2 responses where the token secret is used as the password. + State is kept in-memory keyed by client IP. + """ + auth_header = request.headers.get("authorization", "") + if not auth_header: + return None + + scheme, _, encoded = auth_header.partition(" ") + if scheme.lower() not in ("ntlm", "negotiate"): + return None + + www_auth_scheme = "Negotiate" if scheme.lower() == "negotiate" else "NTLM" + client_key = request.client_ip or "unknown" + spnego_wrapped = False + + try: + data = base64.b64decode(encoded) + except Exception: + logger.warning("NTLM decode failed: client=%s", client_key) + raise Unauthorized("Invalid NTLM message", www_auth_scheme, quiet=True) + + # Windows commonly sends SPNEGO-wrapped Negotiate tokens that embed NTLMSSP. + # Extract the NTLMSSP blob when present so downstream parsing sees raw Type 1/3. + marker = b"NTLMSSP\x00" + marker_pos = data.find(marker) + if marker_pos == 0: + pass + elif marker_pos > 0: + spnego_wrapped = True + data = data[marker_pos:] + else: + logger.warning("NTLM token missing NTLMSSP marker: client=%s", client_key) + + if len(data) < 12: + logger.warning("NTLM message too short: client=%s bytes=%d", client_key, len(data)) + raise Unauthorized("Invalid NTLM message", www_auth_scheme, quiet=True) + + msg_type = struct.unpack("") +async def delete_token(request, token_id): + return await delete_token_handler(request, token_id) diff --git a/cista/config.py b/cista/config.py index 9ae56c7..500f231 100644 --- a/cista/config.py +++ b/cista/config.py @@ -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) diff --git a/cista/fileserver.py b/cista/fileserver.py new file mode 100644 index 0000000..b270ecf --- /dev/null +++ b/cista/fileserver.py @@ -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("/") +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("/") +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("/", 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="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("/") +async def get_file(request, name=""): + return await _send_static_file(request, name, head_only=False) + + +@bp.head("/") +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("/", 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("/", 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("/", 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("/", 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'' + + 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) diff --git a/cista/preview.py b/cista/preview.py index d586440..1b74963 100644 --- a/cista/preview.py +++ b/cista/preview.py @@ -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() diff --git a/cista/protocol.py b/cista/protocol.py index 220e887..18b3391 100644 --- a/cista/protocol.py +++ b/cista/protocol.py @@ -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): diff --git a/cista/sanic_logging.py b/cista/sanic_logging.py index 0325d4a..4d91709 100644 --- a/cista/sanic_logging.py +++ b/cista/sanic_logging.py @@ -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: diff --git a/cista/session.py b/cista/session.py index 6075bcb..d5f3bb0 100644 --- a/cista/session.py +++ b/cista/session.py @@ -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): diff --git a/cista/sso.py b/cista/sso.py index 97c0588..3af4491 100644 --- a/cista/sso.py +++ b/cista/sso.py @@ -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. diff --git a/cista/util/apphelpers.py b/cista/util/apphelpers.py index 497c681..d1174cd 100644 --- a/cista/util/apphelpers.py +++ b/cista/util/apphelpers.py @@ -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 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 54ecd38..6171f11 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -7,6 +7,7 @@ +
@@ -35,6 +36,7 @@ import Router from '@/router/index' import type { SortOrder } from './utils/docsort' import type SettingsModalVue from './components/SettingsModal.vue' import UserManagementModal from './components/UserManagementModal.vue' +import UserTokensModal from './components/UserTokensModal.vue' import AccessDeniedModal from './components/AccessDeniedModal.vue' import SelectionToolbar from './components/SelectionToolbar.vue' diff --git a/frontend/src/components/FileExplorer.vue b/frontend/src/components/FileExplorer.vue index 0060b91..d1c8113 100644 --- a/frontend/src/components/FileExplorer.vue +++ b/frontend/src/components/FileExplorer.vue @@ -76,7 +76,7 @@ import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTic import { useMainStore } from '@/stores/main' import { Doc } from '@/repositories/Document' import FileRenameInput from './FileRenameInput.vue' -import { connect, controlUrl } from '@/repositories/WS' +import { apiFetch } from '@/repositories/Client' import { formatSize } from '@/utils' import { useRouter } from 'vue-router' import ContextMenu from '@imengyu/vue3-context-menu' @@ -87,31 +87,36 @@ const props = defineProps<{ }>() const store = useMainStore() const router = useRouter() + +const filesUrl = (path: string) => + '/files/' + path.split('/').map(part => encodeURIComponent(part)).join('/') + +const parseErrorMessage = async (res: Response) => { + try { + const data = await res.json() + return data.message || data.detail || `${res.status} ${res.statusText}` + } catch { + return `${res.status} ${res.statusText}` + } +} + // File rename const editing = shallowRef(null) -const rename = (doc: Doc, newName: string) => { +const rename = async (doc: Doc, newName: string) => { const oldName = doc.name - const control = connect(controlUrl, { - message(ev: MessageEvent) { - const msg = JSON.parse(ev.data) - if ('error' in msg) { - console.error('Rename failed', msg.error.message, msg.error) - doc.name = oldName - } else { - console.log('Rename succeeded', msg) - } - } - }) - control.onopen = () => { - control.send( - JSON.stringify({ - op: 'rename', - path: `${doc.loc}/${oldName}`, - to: newName - }) - ) - } doc.name = newName // We should get an update from watch but this is quicker + try { + const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/' + const res = await apiFetch( + `${dstUrl}?mv=${doc.key}&to=${encodeURIComponent(newName)}`, + { method: 'POST' } + ) + if (!res.ok) throw new Error(await parseErrorMessage(res)) + } catch (err) { + console.error('Rename failed', err) + doc.name = oldName + store.showToast(err instanceof Error ? err.message : 'Rename failed') + } } defineExpose({ newFolder() { @@ -253,31 +258,20 @@ onMounted(() => { } }) onUnmounted(() => { clearInterval(modifiedTimer) }) -const mkdir = (doc: Doc, name: string) => { - const control = connect(controlUrl, { - open() { - control.send( - JSON.stringify({ - op: 'mkdir', - path: `${doc.loc}/${name}` - }) - ) - }, - message(ev: MessageEvent) { - const msg = JSON.parse(ev.data) - if ('error' in msg) { - console.error('Mkdir failed', msg.error.message, msg.error) - editing.value = null - } else { - console.log('mkdir', msg) - router.push(doc.urlrouter) - } - } - }) +const mkdir = async (doc: Doc, name: string) => { doc.name = name doc.key = crypto.randomUUID() store.addGhost(doc) editing.value = null + const path = doc.loc ? `${doc.loc}/${name}` : name + try { + const res = await apiFetch(filesUrl(path), { method: 'MKCOL' }) + if (!res.ok) throw new Error(await parseErrorMessage(res)) + router.push(doc.urlrouter) + } catch (err) { + console.error('Mkdir failed', err) + store.showToast(err instanceof Error ? err.message : 'Mkdir failed') + } } const showFolderBreadcrumb = (i: number) => { const docs = props.documents @@ -373,24 +367,17 @@ const copyImage = async (doc: Doc) => { } } -const deleteFile = (doc: Doc) => { +const deleteFile = async (doc: Doc) => { const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name store.hideDoc(path) - const control = connect(controlUrl, { - message(ev: MessageEvent) { - const res = JSON.parse(ev.data) - if ('error' in res) { - console.error('Delete failed', res.error) - store.unhideDoc(path) - store.showToast(res.error.message || 'Delete failed') - } else if (res.status === 'ack') { - store.showToast(`🗑️ Deleted ${doc.name}`) - control.close() - } - } - }) - control.onopen = () => { - control.send(JSON.stringify({ op: 'rm', sel: [path] })) + try { + const res = await apiFetch(filesUrl(path), { method: 'DELETE' }) + if (!res.ok) throw new Error(await parseErrorMessage(res)) + store.showToast(`🗑️ Deleted ${doc.name}`) + } catch (err) { + console.error('Delete failed', err) + store.unhideDoc(path) + store.showToast(err instanceof Error ? err.message : 'Delete failed') } } diff --git a/frontend/src/components/Gallery.vue b/frontend/src/components/Gallery.vue index f1ca5dc..8083b47 100644 --- a/frontend/src/components/Gallery.vue +++ b/frontend/src/components/Gallery.vue @@ -12,7 +12,7 @@ import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue' import { useMainStore } from '@/stores/main' import { Doc } from '@/repositories/Document' -import { connect, controlUrl } from '@/repositories/WS' +import { apiFetch } from '@/repositories/Client' import { useRouter } from 'vue-router' import ContextMenu from '@imengyu/vue3-context-menu' import type { SortOrder } from '@/utils/docsort' @@ -23,32 +23,37 @@ const props = defineProps<{ }>() const store = useMainStore() const router = useRouter() + +const filesUrl = (path: string) => + '/files/' + path.split('/').map(part => encodeURIComponent(part)).join('/') + +const parseErrorMessage = async (res: Response) => { + try { + const data = await res.json() + return data.message || data.detail || `${res.status} ${res.statusText}` + } catch { + return `${res.status} ${res.statusText}` + } +} + // File rename const editing = shallowRef(null) const exit = () => { editing.value = null } -const rename = (doc: Doc, newName: string) => { +const rename = async (doc: Doc, newName: string) => { const oldName = doc.name - const control = connect(controlUrl, { - message(ev: MessageEvent) { - const msg = JSON.parse(ev.data) - if ('error' in msg) { - console.error('Rename failed', msg.error.message, msg.error) - doc.name = oldName - } else { - console.log('Rename succeeded', msg) - } - } - }) - control.onopen = () => { - control.send( - JSON.stringify({ - op: 'rename', - path: `${doc.loc}/${oldName}`, - to: newName - }) - ) - } doc.name = newName // We should get an update from watch but this is quicker + try { + const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/' + const res = await apiFetch( + `${dstUrl}?mv=${doc.key}&to=${encodeURIComponent(newName)}`, + { method: 'POST' } + ) + if (!res.ok) throw new Error(await parseErrorMessage(res)) + } catch (err) { + console.error('Rename failed', err) + doc.name = oldName + store.showToast(err instanceof Error ? err.message : 'Rename failed') + } } const gallery = ref() const columnCount = ref(1) @@ -202,31 +207,20 @@ onMounted(() => { onUnmounted(() => { resizeObserver?.disconnect() }) -const mkdir = (doc: Doc, name: string) => { - const control = connect(controlUrl, { - open() { - control.send( - JSON.stringify({ - op: 'mkdir', - path: `${doc.loc}/${name}` - }) - ) - }, - message(ev: MessageEvent) { - const msg = JSON.parse(ev.data) - if ('error' in msg) { - console.error('Mkdir failed', msg.error.message, msg.error) - editing.value = null - } else { - console.log('mkdir', msg) - router.push(doc.urlrouter) - } - } - }) +const mkdir = async (doc: Doc, name: string) => { doc.name = name doc.key = crypto.randomUUID() store.addGhost(doc) editing.value = null + const path = doc.loc ? `${doc.loc}/${name}` : name + try { + const res = await apiFetch(filesUrl(path), { method: 'MKCOL' }) + if (!res.ok) throw new Error(await parseErrorMessage(res)) + router.push(doc.urlrouter) + } catch (err) { + console.error('Mkdir failed', err) + store.showToast(err instanceof Error ? err.message : 'Mkdir failed') + } } const showFolderBreadcrumb = (i: number) => { const docs = props.documents @@ -312,24 +306,17 @@ const copyImage = async (doc: Doc) => { } } -const deleteFile = (doc: Doc) => { +const deleteFile = async (doc: Doc) => { const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name store.hideDoc(path) - const control = connect(controlUrl, { - message(ev: MessageEvent) { - const res = JSON.parse(ev.data) - if ('error' in res) { - console.error('Delete failed', res.error) - store.unhideDoc(path) - store.showToast(res.error.message || 'Delete failed') - } else if (res.status === 'ack') { - store.showToast(`🗑️ Deleted ${doc.name}`) - control.close() - } - } - }) - control.onopen = () => { - control.send(JSON.stringify({ op: 'rm', sel: [path] })) + try { + const res = await apiFetch(filesUrl(path), { method: 'DELETE' }) + if (!res.ok) throw new Error(await parseErrorMessage(res)) + store.showToast(`🗑️ Deleted ${doc.name}`) + } catch (err) { + console.error('Delete failed', err) + store.unhideDoc(path) + store.showToast(err instanceof Error ? err.message : 'Delete failed') } } diff --git a/frontend/src/components/HeaderMain.vue b/frontend/src/components/HeaderMain.vue index 42495d7..233a37c 100644 --- a/frontend/src/components/HeaderMain.vue +++ b/frontend/src/components/HeaderMain.vue @@ -105,6 +105,10 @@ const settingsMenu = (e: Event) => { items.push({ label: '🔑 Change Password', onClick: () => { store.dialog = 'settings' }}) } + if (store.user.isLoggedIn) { + items.push({ label: '🔑 API Tokens', onClick: () => { store.dialog = 'tokens' }}) + } + if (store.user.privileged) { items.push({ label: '⚙️ Admin Settings', onClick: () => { store.dialog = 'usermgmt' }}) } diff --git a/frontend/src/components/SelectionToolbar.vue b/frontend/src/components/SelectionToolbar.vue index 57476b5..ce032c0 100644 --- a/frontend/src/components/SelectionToolbar.vue +++ b/frontend/src/components/SelectionToolbar.vue @@ -29,7 +29,7 @@ + + diff --git a/frontend/src/repositories/User.ts b/frontend/src/repositories/User.ts index b2f6cc5..7fc6aad 100644 --- a/frontend/src/repositories/User.ts +++ b/frontend/src/repositories/User.ts @@ -65,3 +65,20 @@ export async function getServerConfig() { const data = await Client.get('/api/config') return data as { name: string, public: boolean } } + +export const url_tokens = '/api/tokens' + +export async function listTokens() { + const data = await Client.get(url_tokens) + return data +} + +export async function createToken(name: string) { + const data = await Client.post(url_tokens, { name }) + return data +} + +export async function deleteToken(tokenId: string) { + const data = await Client.delete(`${url_tokens}/${tokenId}`) + return data +} diff --git a/frontend/src/repositories/WS.ts b/frontend/src/repositories/WS.ts index 69e7da8..c3c51ac 100644 --- a/frontend/src/repositories/WS.ts +++ b/frontend/src/repositories/WS.ts @@ -2,7 +2,6 @@ import { useMainStore } from "@/stores/main" import { showAuthIframe, AuthCancelledError, isAuthIframeOpen } from 'paskia' import type { FileEntry, UpdateEntry, errorEvent } from "./Document" -export const controlUrl = '/api/control' export const watchUrl = '/api/watch' let tree = [] as FileEntry[] diff --git a/frontend/src/stores/main.ts b/frontend/src/stores/main.ts index 060aeb2..7438be9 100644 --- a/frontend/src/stores/main.ts +++ b/frontend/src/stores/main.ts @@ -80,7 +80,7 @@ export const useMainStore = defineStore('main', { authInProgress: false, cursor: '' as string, server: {} as Record & { public?: boolean, paskia?: boolean }, - dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied', + dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied' | 'tokens', uprogress: {} as any, dprogress: {} as any, prefs: { diff --git a/pyproject.toml b/pyproject.toml index 482f756..488a0be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -130,6 +130,7 @@ dev = [ "mypy>=1.13.0", "pre-commit>=4.0.0", "httpx>=0.28.1", + "sanic-testing>=24.6.0", ] [tool.coverage.run] diff --git a/scripts/devserver.py b/scripts/devserver.py index 43ee766..22c79e5 100755 --- a/scripts/devserver.py +++ b/scripts/devserver.py @@ -44,7 +44,9 @@ def setup_sanic_backend( port = opts.get("port", DEFAULT_BACKEND_PORT) host = opts.get("host", "localhost") or "localhost" - cmd = ["cista", "--dev", "-l", listen] + extra_args + # Use the current interpreter/module path so devserver always runs + # workspace source code instead of a potentially stale installed script. + cmd = [sys.executable, "-m", "cista", "--dev", "-l", listen] + extra_args return f"http://{host}:{port}", cmd diff --git a/tests/test_control.py b/tests/test_control.py deleted file mode 100644 index ebd77a8..0000000 --- a/tests/test_control.py +++ /dev/null @@ -1,53 +0,0 @@ -import tempfile -from pathlib import Path - -import pytest - -from cista import config -from cista.protocol import Cp, MkDir, Mv, Rename, Rm - - -@pytest.fixture() -def setup_temp_dir(): - with tempfile.TemporaryDirectory() as tmpdirname: - config.config = config.Config(path=Path(tmpdirname), listen=":0") - yield Path(tmpdirname) - - -def test_mkdir(setup_temp_dir): - cmd = MkDir(path="new_folder") - cmd() - assert (setup_temp_dir / "new_folder").is_dir() - - -def test_rename(setup_temp_dir): - (setup_temp_dir / "old_name").mkdir() - cmd = Rename(path="old_name", to="new_name") - cmd() - assert not (setup_temp_dir / "old_name").exists() - assert (setup_temp_dir / "new_name").is_dir() - - -def test_rm(setup_temp_dir): - (setup_temp_dir / "folder_to_remove").mkdir() - cmd = Rm(sel=["folder_to_remove"]) - cmd() - assert not (setup_temp_dir / "folder_to_remove").exists() - - -def test_mv(setup_temp_dir): - (setup_temp_dir / "folder_to_move").mkdir() - (setup_temp_dir / "destination").mkdir() - cmd = Mv(sel=["folder_to_move"], dst="destination") - cmd() - assert not (setup_temp_dir / "folder_to_move").exists() - assert (setup_temp_dir / "destination" / "folder_to_move").is_dir() - - -def test_cp(setup_temp_dir): - (setup_temp_dir / "folder_to_copy").mkdir() - (setup_temp_dir / "destination").mkdir() - cmd = Cp(sel=["folder_to_copy"], dst="destination") - cmd() - assert (setup_temp_dir / "folder_to_copy").is_dir() - assert (setup_temp_dir / "destination" / "folder_to_copy").is_dir() diff --git a/tests/test_files_auth.py b/tests/test_files_auth.py new file mode 100644 index 0000000..29fdf80 --- /dev/null +++ b/tests/test_files_auth.py @@ -0,0 +1,207 @@ +import base64 +import hashlib +import hmac +import re +import struct +from pathlib import Path +from time import time +from uuid import uuid4 + +import jwt +import pytest +import pytest_asyncio +from sanic import Sanic + +from cista import auth, config, session, watching +from cista.app import use_session +from cista.fileserver import bp as fileserver_bp + + +def _basic_auth(username: str, password: str) -> dict[str, str]: + creds = base64.b64encode(f"{username}:{password}".encode()).decode() + return {"Authorization": f"Basic {creds}"} + + +def _ntlm_type1() -> dict[str, str]: + msg = b"NTLMSSP\x00" + struct.pack(" dict[str, str]: + """Build an NTLMv2 Type 3 message for testing.""" + from Crypto.Hash import MD4 + + # NT hash + nt_hash = MD4.new(password.encode("utf-16le")).digest() + # NTLMv2 hash + ntlmv2_hash = hmac.new(nt_hash, (username.upper() + domain).encode("utf-16le"), hashlib.md5).digest() + + # Build a minimal blob + timestamp = struct.pack(" dict[str, str]: + token = jwt.encode( + {"exp": int(time()) + session.max_age, "username": username}, + session.session_secret(), + algorithm="HS256", + ) + return {"Cookie": f"s={token}"} + + +@pytest.fixture() +def setup_storage(tmp_path: Path): + user = config.User() + auth.set_password(user, "secret") + token = config.Token(key="test_token_123", username="alice") + config.config = config.Config( + path=tmp_path, + listen=":0", + public=False, + users={"alice": user}, + tokens={"test_token_123": token}, + ) + watching.state.root = [] + watching.rootpath = tmp_path + (tmp_path / "hello.txt").write_text("hello", encoding="utf-8") + yield tmp_path + watching.state.root = [] + + +@pytest_asyncio.fixture() +async def client(setup_storage: Path): + app = Sanic(f"files-auth-test-{uuid4().hex}", strict_slashes=True) + app.router.ALLOWED_METHODS = ( + *app.router.ALLOWED_METHODS, + "MKCOL", + "MOVE", + "COPY", + "PROPFIND", + ) + + @app.on_request + async def load_auth_context(request): + await use_session(request) + + app.blueprint(fileserver_bp) + yield app.asgi_client + + +@pytest.mark.asyncio +async def test_basic_auth_allows_private_file_access(client): + _, res = await client.get("/files/hello.txt", headers=_basic_auth("alice", "secret")) + + assert res.status_code == 200 + assert res.body == b"hello" + assert "set-cookie" not in res.headers + + +@pytest.mark.asyncio +async def test_basic_auth_with_invalid_creds_falls_back_to_session_cookie(client): + _, res = await client.get( + "/files/hello.txt", + headers={**_basic_auth("alice", "wrong"), **_session_cookie_header("alice")}, + ) + + assert res.status_code == 200 + + +@pytest.mark.asyncio +async def test_options_unauthenticated_allowed(client): + _, res = await client.options("/files/") + + assert res.status_code == 200 + + +@pytest.mark.asyncio +async def test_unauthenticated_sends_basic_auth_challenge(client): + _, res = await client.request("PROPFIND", "/files/") + + assert res.status_code == 401 + assert res.headers.get("www-authenticate", "").lower().startswith('basic realm="cista"') + + +@pytest.mark.asyncio +async def test_basic_auth_with_token(client): + _, res = await client.get("/files/hello.txt", headers=_basic_auth("token", "test_token_123")) + + assert res.status_code == 200 + assert res.body == b"hello" + + +@pytest.mark.asyncio +async def test_browser_unauthenticated_sends_cookie_challenge(client): + _, res = await client.get("/files/", headers={"Accept": "text/html,application/xhtml+xml"}) + + assert res.status_code == 401 + assert res.headers.get("www-authenticate", "").lower().startswith("cookie") + + +@pytest.mark.asyncio +async def test_ntlm_auth_with_token(client): + # Step 1: request without auth should NOT advertise NTLM + # (we prefer clients use BASIC; NTLM still works if client initiates it) + _, res1 = await client.get("/files/hello.txt") + assert res1.status_code == 401 + assert "ntlm" not in res1.headers.get("www-authenticate", "").lower() + + # Step 2: client proactively sends Type 1, gets Type 2 challenge + _, res2 = await client.get("/files/hello.txt", headers=_ntlm_type1()) + assert res2.status_code == 401 + auth_hdr = res2.headers.get("www-authenticate", "") + assert auth_hdr.lower().startswith("ntlm ") + type2_data = base64.b64decode(auth_hdr.split(" ", 1)[1]) + challenge = type2_data[24:32] + + # Step 3: send Type 3 with token as password + _, res3 = await client.get( + "/files/hello.txt", + headers=_ntlm_type3("anyuser", "test_token_123", "WORKGROUP", challenge), + ) + assert res3.status_code == 200 + assert res3.body == b"hello" diff --git a/tests/test_files_path_security.py b/tests/test_files_path_security.py new file mode 100644 index 0000000..07af0a6 --- /dev/null +++ b/tests/test_files_path_security.py @@ -0,0 +1,169 @@ +"""Path traversal and percent-encoding security tests for the fileserver.""" +from pathlib import Path +from uuid import uuid4 + +import pytest +import pytest_asyncio +from sanic import Sanic + +from cista import config, watching +from cista.fileserver import bp as fileserver_bp + + +@pytest.fixture() +def setup_storage(tmp_path: Path): + config.config = config.Config(path=tmp_path, listen=":0", public=True) + watching.state.root = [] + watching.rootpath = tmp_path + yield tmp_path + watching.state.root = [] + + +@pytest_asyncio.fixture() +async def client(setup_storage: Path): + app = Sanic(f"files-path-sec-test-{uuid4().hex}", strict_slashes=True) + app.router.ALLOWED_METHODS = (*app.router.ALLOWED_METHODS, "MKCOL", "MOVE", "COPY", "PROPFIND") + app.blueprint(fileserver_bp) + yield app.asgi_client + + +# --------------------------------------------------------------------------- +# %2F — encoded slash should be decoded as a path separator +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +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.""" + (setup_storage / "sub").mkdir() + (setup_storage / "sub" / "file.txt").write_text("hello", encoding="utf-8") + + _, res = await client.get("/files/sub%2Ffile.txt") + + assert res.status_code == 200 + assert res.text == "hello" + + +@pytest.mark.asyncio +async def test_mkcol_percent2F_creates_nested_directory(client, setup_storage: Path): + """%2F in MKCOL path is decoded as a separator, creating nested dirs.""" + _, res = await client.request("MKCOL", "/files/parent%2Fchild") + + assert res.status_code == 201 + assert (setup_storage / "parent" / "child").is_dir() + + +# --------------------------------------------------------------------------- +# %20 — encoded space in filename +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_percent20_in_filename(client, setup_storage: Path): + (setup_storage / "my file.txt").write_text("spaced", encoding="utf-8") + + _, res = await client.get("/files/my%20file.txt") + + assert res.status_code == 200 + assert res.text == "spaced" + + +@pytest.mark.asyncio +async def test_mkcol_percent20_in_folder_name(client, setup_storage: Path): + _, res = await client.request("MKCOL", "/files/my%20folder") + + assert res.status_code == 201 + assert (setup_storage / "my folder").is_dir() + + +# --------------------------------------------------------------------------- +# Path traversal — .. and encoded variants +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_dotdot_rejected(client): + """.. is path-normalised by the router before reaching the handler.""" + _, res = await client.get("/files/..") + assert res.status_code in (400, 404) + + +@pytest.mark.asyncio +async def test_get_dotdot_segment_rejected(client): + """Traversal via sub/../.. is path-normalised by the router.""" + _, res = await client.get("/files/sub/../..") + assert res.status_code in (400, 404) + + +@pytest.mark.asyncio +async def test_get_encoded_dotdot_rejected(client): + """%2E%2E (encoded ..) must be rejected.""" + _, res = await client.get("/files/%2E%2E") + assert res.status_code == 400 + + +@pytest.mark.asyncio +async def test_get_encoded_dotdot_segment_rejected(client): + """%2E%2E used as a segment in a longer path must be rejected.""" + _, res = await client.get("/files/sub%2F%2E%2E%2F..%2Fetc%2Fpasswd") + assert res.status_code == 400 + + +@pytest.mark.asyncio +async def test_mkcol_dotdot_rejected(client): + _, res = await client.request("MKCOL", "/files/..") + assert res.status_code in (400, 404) + + +@pytest.mark.asyncio +async def test_delete_dotdot_rejected(client): + _, res = await client.delete("/files/..") + assert res.status_code in (400, 404) + + +# --------------------------------------------------------------------------- +# Dot-prefixed filenames (.hidden, ...) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_hidden_file_rejected(client): + """Names starting with '.' are not allowed.""" + _, res = await client.get("/files/.hidden") + assert res.status_code == 400 + + +@pytest.mark.asyncio +async def test_mkcol_hidden_folder_rejected(client): + _, res = await client.request("MKCOL", "/files/.secret") + assert res.status_code == 400 + + +# --------------------------------------------------------------------------- +# Windows-style drive paths (c:/) — safe on Linux, stays inside storage root +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_mkcol_windows_drive_path_stays_within_root(client, setup_storage: Path): + """A Windows-style drive path like 'c:/foo' is treated as a relative path + on Linux and resolves safely inside the storage root.""" + _, res = await client.request("MKCOL", "/files/c:/secret") + + # Either created inside the storage root (201) or sanitised away (400/404). + # The important assertion: nothing was created outside the storage root. + assert not (Path("/c:") / "secret").exists() + assert not (Path("c:/secret")).exists() + if res.status_code == 201: + # Created safely inside tmp storage + assert (setup_storage / "c:" / "secret").is_dir() + + +@pytest.mark.asyncio +async def test_mkcol_backslash_in_path_sanitised(client, setup_storage: Path): + """Backslashes are replaced with dashes, not treated as path separators.""" + _, res = await client.request("MKCOL", "/files/foo\\..\\bar") + + assert res.status_code in (201, 400) + # Must not escape storage root + assert not (setup_storage.parent / "bar").exists() diff --git a/tests/test_files_rest_api.py b/tests/test_files_rest_api.py new file mode 100644 index 0000000..23aced4 --- /dev/null +++ b/tests/test_files_rest_api.py @@ -0,0 +1,234 @@ +from pathlib import Path +from uuid import uuid4 + +import pytest +import pytest_asyncio +from sanic import Sanic + +from cista import config, watching +from cista.fileserver import bp as fileserver_bp +from cista.protocol import FileEntry + + +@pytest.fixture() +def setup_storage(tmp_path: Path): + config.config = config.Config(path=tmp_path, listen=":0", public=True) + watching.state.root = [] + watching.rootpath = tmp_path + yield tmp_path + watching.state.root = [] + + +@pytest_asyncio.fixture() +async def client(setup_storage: Path): + app = Sanic(f"files-rest-test-{uuid4().hex}", strict_slashes=True) + app.router.ALLOWED_METHODS = (*app.router.ALLOWED_METHODS, "MKCOL", "MOVE", "COPY", "PROPFIND") + app.blueprint(fileserver_bp) + yield app.asgi_client + + +@pytest.mark.asyncio +async def test_mkcol_creates_directory(client, setup_storage: Path): + _, res = await client.request("MKCOL", "/files/new-folder") + + assert res.status_code == 201 + assert (setup_storage / "new-folder").is_dir() + + +@pytest.mark.asyncio +async def test_delete_removes_file(client, setup_storage: Path): + file_path = setup_storage / "delete-me.txt" + file_path.write_text("hello", encoding="utf-8") + + _, res = await client.delete("/files/delete-me.txt") + + assert res.status_code == 204 + assert not file_path.exists() + + +@pytest.mark.asyncio +async def test_post_mv_moves_keys_to_target(client, setup_storage: Path): + (setup_storage / "target").mkdir() + (setup_storage / "alpha.txt").write_text("alpha", encoding="utf-8") + (setup_storage / "beta.txt").write_text("beta", encoding="utf-8") + + watching.state.root = [ + FileEntry(1, "target", "k-target", 0, 0, 0, 0), + FileEntry(1, "alpha.txt", "k-alpha", 0, 5, 0, 1), + FileEntry(1, "beta.txt", "k-beta", 0, 4, 0, 1), + ] + + _, res = await client.post("/files/target?mv=k-alpha+k-beta") + + assert res.status_code == 200 + assert res.json["status"] == "ack" + assert not (setup_storage / "alpha.txt").exists() + assert not (setup_storage / "beta.txt").exists() + assert (setup_storage / "target" / "alpha.txt").is_file() + assert (setup_storage / "target" / "beta.txt").is_file() + + +@pytest.mark.asyncio +async def test_post_cp_copies_keys_to_target(client, setup_storage: Path): + (setup_storage / "target").mkdir() + (setup_storage / "copy-me.txt").write_text("copy", encoding="utf-8") + + watching.state.root = [ + FileEntry(1, "target", "k-target", 0, 0, 0, 0), + FileEntry(1, "copy-me.txt", "k-copy", 0, 4, 0, 1), + ] + + _, res = await client.post("/files/target?cp=k-copy") + + assert res.status_code == 200 + assert res.json["counts"] == {"cp": 1, "mv": 0} + assert (setup_storage / "copy-me.txt").is_file() + assert (setup_storage / "target" / "copy-me.txt").is_file() + + +@pytest.mark.asyncio +async def test_post_cp_repeated_params_and_plus_form_are_equivalent( + client, + setup_storage: Path, +): + (setup_storage / "target").mkdir() + (setup_storage / "one.txt").write_text("one", encoding="utf-8") + (setup_storage / "two.txt").write_text("two", encoding="utf-8") + + watching.state.root = [ + FileEntry(1, "target", "k-target", 0, 0, 0, 0), + FileEntry(1, "one.txt", "k-one", 0, 3, 0, 1), + FileEntry(1, "two.txt", "k-two", 0, 3, 0, 1), + ] + + _, res1 = await client.post("/files/target?cp=k-one&cp=k-two") + + assert res1.status_code == 200 + assert (setup_storage / "target" / "one.txt").is_file() + assert (setup_storage / "target" / "two.txt").is_file() + + (setup_storage / "target" / "one.txt").unlink() + (setup_storage / "target" / "two.txt").unlink() + + _, res2 = await client.post("/files/target?cp=k-one+k-two") + + assert res2.status_code == 200 + assert (setup_storage / "target" / "one.txt").is_file() + assert (setup_storage / "target" / "two.txt").is_file() + + +@pytest.mark.asyncio +async def test_post_mv_with_to_renames_single_key( + client, + setup_storage: Path, +): + (setup_storage / "dst").mkdir() + (setup_storage / "old-name.txt").write_text("x", encoding="utf-8") + + watching.state.root = [ + FileEntry(1, "dst", "k-dst", 0, 0, 0, 0), + FileEntry(1, "old-name.txt", "k-old", 0, 1, 0, 1), + ] + + _, res = await client.post("/files/dst/new-name.txt?mv=k-old") + + assert res.status_code == 200 + assert not (setup_storage / "old-name.txt").exists() + assert (setup_storage / "dst" / "new-name.txt").is_file() + + +@pytest.mark.asyncio +async def test_post_cp_single_key_to_file_path(client, setup_storage: Path): + (setup_storage / "dst").mkdir() + (setup_storage / "src.txt").write_text("copy", encoding="utf-8") + + watching.state.root = [ + FileEntry(1, "dst", "k-dst", 0, 0, 0, 0), + FileEntry(1, "src.txt", "k-src", 0, 4, 0, 1), + ] + + _, res = await client.post("/files/dst/copied.txt?cp=k-src") + + assert res.status_code == 200 + assert (setup_storage / "src.txt").is_file() + assert (setup_storage / "dst" / "copied.txt").is_file() + + +@pytest.mark.asyncio +async def test_post_supports_combined_cp_then_mv(client, setup_storage: Path): + (setup_storage / "target").mkdir() + (setup_storage / "copy-me.txt").write_text("copy", encoding="utf-8") + (setup_storage / "move-me.txt").write_text("move", encoding="utf-8") + + watching.state.root = [ + FileEntry(1, "target", "k-target", 0, 0, 0, 0), + FileEntry(1, "copy-me.txt", "k-copy", 0, 4, 0, 1), + FileEntry(1, "move-me.txt", "k-move", 0, 4, 0, 1), + ] + + _, res = await client.post("/files/target?cp=k-copy&mv=k-move") + + assert res.status_code == 200 + assert res.json["counts"] == {"cp": 1, "mv": 1} + assert (setup_storage / "copy-me.txt").is_file() + assert not (setup_storage / "move-me.txt").exists() + assert (setup_storage / "target" / "copy-me.txt").is_file() + assert (setup_storage / "target" / "move-me.txt").is_file() + + +@pytest.mark.asyncio +async def test_post_rejects_unknown_query_args(client): + _, res = await client.post("/files/?cp=k1&wat=1") + + assert res.status_code == 400 + assert "unknown query parameter" in res.json["message"].lower() + + +@pytest.mark.asyncio +async def test_post_requires_query_args(client): + _, res = await client.post("/files/") + + assert res.status_code == 400 + assert "no query arguments" in res.json["message"].lower() + + +@pytest.mark.asyncio +async def test_post_rejects_multiple_keys_to_file_target(client, setup_storage: Path): + (setup_storage / "a.txt").write_text("a", encoding="utf-8") + (setup_storage / "b.txt").write_text("b", encoding="utf-8") + (setup_storage / "target.txt").write_text("x", encoding="utf-8") + + watching.state.root = [ + FileEntry(1, "a.txt", "k-a", 0, 1, 0, 1), + FileEntry(1, "b.txt", "k-b", 0, 1, 0, 1), + FileEntry(1, "target.txt", "k-target", 0, 1, 0, 1), + ] + + _, cp_res = await client.post("/files/target.txt?cp=k-a+k-b") + _, mv_res = await client.post("/files/target.txt?mv=k-a+k-b") + + assert cp_res.status_code == 400 + assert "existing directory" in cp_res.json["message"].lower() + assert mv_res.status_code == 400 + assert "existing directory" in mv_res.json["message"].lower() + + +@pytest.mark.asyncio +async def test_post_rejects_directory_to_existing_file_target(client, setup_storage: Path): + (setup_storage / "folder").mkdir() + (setup_storage / "folder" / "nested.txt").write_text("n", encoding="utf-8") + (setup_storage / "existing.txt").write_text("e", encoding="utf-8") + + watching.state.root = [ + FileEntry(1, "folder", "k-folder", 0, 0, 0, 0), + FileEntry(2, "nested.txt", "k-nested", 0, 1, 0, 1), + FileEntry(1, "existing.txt", "k-existing", 0, 1, 0, 1), + ] + + _, cp_res = await client.post("/files/existing.txt?cp=k-folder") + _, mv_res = await client.post("/files/existing.txt?mv=k-folder") + + assert cp_res.status_code == 400 + assert "directory to an existing file" in cp_res.json["message"].lower() + assert mv_res.status_code == 400 + assert "directory to an existing file" in mv_res.json["message"].lower() diff --git a/tests/test_files_static_streaming.py b/tests/test_files_static_streaming.py new file mode 100644 index 0000000..9049931 --- /dev/null +++ b/tests/test_files_static_streaming.py @@ -0,0 +1,100 @@ +from pathlib import Path +from uuid import uuid4 + +import pytest +import pytest_asyncio +from sanic import Sanic + +from cista import config, watching +from cista.fileserver import bp as fileserver_bp + + +@pytest.fixture() +def setup_storage(tmp_path: Path): + config.config = config.Config(path=tmp_path, listen=":0", public=True) + watching.state.root = [] + watching.rootpath = tmp_path + yield tmp_path + watching.state.root = [] + + +@pytest_asyncio.fixture() +async def client(setup_storage: Path): + app = Sanic(f"files-static-test-{uuid4().hex}", strict_slashes=True) + app.router.ALLOWED_METHODS = (*app.router.ALLOWED_METHODS, "MKCOL", "MOVE", "COPY", "PROPFIND") + app.blueprint(fileserver_bp) + yield app.asgi_client + + +@pytest.mark.asyncio +async def test_get_file_full_content(client, setup_storage: Path): + path = setup_storage / "hello.txt" + path.write_bytes(b"hello world") + + _, res = await client.get("/files/hello.txt") + + assert res.status_code == 200 + assert res.body == b"hello world" + assert res.headers.get("accept-ranges") == "bytes" + assert res.headers.get("content-length") == "11" + + +@pytest.mark.asyncio +async def test_head_file_returns_headers_without_body(client, setup_storage: Path): + path = setup_storage / "hello.txt" + path.write_bytes(b"hello world") + + _, res = await client.head("/files/hello.txt") + + assert res.status_code == 200 + assert not res.body + assert res.headers.get("content-length") == "11" + + +@pytest.mark.asyncio +async def test_get_file_range_start_end(client, setup_storage: Path): + path = setup_storage / "hello.txt" + path.write_bytes(b"hello world") + + _, res = await client.get("/files/hello.txt", headers={"Range": "bytes=1-4"}) + + assert res.status_code == 206 + assert res.body == b"ello" + assert res.headers.get("content-range") == "bytes 1-4/11" + assert res.headers.get("content-length") == "4" + + +@pytest.mark.asyncio +async def test_get_file_suffix_range(client, setup_storage: Path): + path = setup_storage / "hello.txt" + path.write_bytes(b"hello world") + + _, res = await client.get("/files/hello.txt", headers={"Range": "bytes=-5"}) + + assert res.status_code == 206 + assert res.body == b"world" + assert res.headers.get("content-range") == "bytes 6-10/11" + + +@pytest.mark.asyncio +async def test_head_file_with_range(client, setup_storage: Path): + path = setup_storage / "hello.txt" + path.write_bytes(b"hello world") + + _, res = await client.head("/files/hello.txt", headers={"Range": "bytes=0-4"}) + + assert res.status_code == 206 + assert not res.body + assert res.headers.get("content-range") == "bytes 0-4/11" + assert res.headers.get("content-length") == "5" + + +@pytest.mark.asyncio +async def test_get_file_unsatisfiable_range_returns_416(client, setup_storage: Path): + path = setup_storage / "hello.txt" + path.write_bytes(b"hello world") + + _, res = await client.get("/files/hello.txt", headers={"Range": "bytes=99-100"}) + + assert res.status_code == 416 + assert res.headers.get("content-range") == "bytes */11" diff --git a/tests/test_files_webdav.py b/tests/test_files_webdav.py new file mode 100644 index 0000000..b3a5135 --- /dev/null +++ b/tests/test_files_webdav.py @@ -0,0 +1,276 @@ +"""WebDAV protocol tests: OPTIONS, PROPFIND, PROPPATCH, COPY, MOVE, LOCK, UNLOCK.""" +import xml.etree.ElementTree as ET +from pathlib import Path +from uuid import uuid4 + +import pytest +import pytest_asyncio +from sanic import Sanic + +from cista import config, watching +from cista.fileserver import bp as fileserver_bp +from cista.protocol import FileEntry + +_DAV_NS = "DAV:" +_METHODS = ("MKCOL", "MOVE", "COPY", "PROPFIND") + + +@pytest.fixture() +def setup_storage(tmp_path: Path): + config.config = config.Config(path=tmp_path, listen=":0", public=True) + watching.state.root = [] + watching.rootpath = tmp_path + yield tmp_path + watching.state.root = [] + + +@pytest_asyncio.fixture() +async def client(setup_storage: Path): + app = Sanic(f"files-dav-test-{uuid4().hex}", strict_slashes=True) + app.router.ALLOWED_METHODS = (*app.router.ALLOWED_METHODS, *_METHODS) + app.blueprint(fileserver_bp) + yield app.asgi_client + + +def _dav(tag: str) -> str: + return f"{{{_DAV_NS}}}{tag}" + + +def _parse_multistatus(body: bytes) -> list[ET.Element]: + root = ET.fromstring(body) + assert root.tag == _dav("multistatus") + return root.findall(_dav("response")) + + +# --------------------------------------------------------------------------- +# OPTIONS +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_options_advertises_dav_class(client): + _, res = await client.options("/files/") + + assert res.status_code == 200 + assert "1" in res.headers.get("dav", "") + assert "PROPFIND" in res.headers.get("allow", "") + assert "COPY" in res.headers.get("allow", "") + assert "MOVE" in res.headers.get("allow", "") + + +@pytest.mark.asyncio +async def test_options_without_trailing_slash(client): + """WebDAV clients (e.g. Windows) send OPTIONS /files without trailing slash.""" + _, res = await client.options("/files") + + assert res.status_code == 200 + assert "1" in res.headers.get("dav", "") + + +# --------------------------------------------------------------------------- +# PROPFIND +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_propfind_root_depth0(client, setup_storage: Path): + _, res = await client.request("PROPFIND", "/files/", headers={"Depth": "0"}) + + assert res.status_code == 207 + responses = _parse_multistatus(res.body) + assert len(responses) == 1 + href = responses[0].findtext(_dav("href")) + assert href == "/files/" + rt = responses[0].find(f".//{_dav('resourcetype')}/{_dav('collection')}") + assert rt is not None, "Root should be a collection" + + +@pytest.mark.asyncio +async def test_propfind_root_depth1_lists_children(client, setup_storage: Path): + (setup_storage / "alpha.txt").write_text("a", encoding="utf-8") + (setup_storage / "beta").mkdir() + + _, res = await client.request("PROPFIND", "/files/", headers={"Depth": "1"}) + + assert res.status_code == 207 + responses = _parse_multistatus(res.body) + hrefs = [r.findtext(_dav("href")) for r in responses] + assert "/files/" in hrefs + assert "/files/alpha.txt" in hrefs + assert "/files/beta/" in hrefs + + +@pytest.mark.asyncio +async def test_propfind_file_has_content_length(client, setup_storage: Path): + (setup_storage / "data.txt").write_text("hello", encoding="utf-8") + + _, res = await client.request("PROPFIND", "/files/data.txt", headers={"Depth": "0"}) + + assert res.status_code == 207 + responses = _parse_multistatus(res.body) + cl = responses[0].findtext(f".//{_dav('getcontentlength')}") + assert cl == "5" + + +@pytest.mark.asyncio +async def test_propfind_depth_infinity_rejected(client, setup_storage: Path): + _, res = await client.request( + "PROPFIND", "/files/", headers={"Depth": "infinity"} + ) + assert res.status_code == 403 + + +@pytest.mark.asyncio +async def test_propfind_missing_resource_returns_404(client): + _, res = await client.request("PROPFIND", "/files/no-such-file.txt") + assert res.status_code == 404 + + +# --------------------------------------------------------------------------- +# COPY +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_copy_file_to_new_path(client, setup_storage: Path): + (setup_storage / "src.txt").write_text("copy me", encoding="utf-8") + + _, res = await client.request( + "COPY", + "/files/src.txt", + headers={"Destination": "http://localhost/files/dst.txt"}, + ) + + assert res.status_code == 201 + assert (setup_storage / "src.txt").is_file() + assert (setup_storage / "dst.txt").read_text() == "copy me" + + +@pytest.mark.asyncio +async def test_copy_overwrites_existing_by_default(client, setup_storage: Path): + (setup_storage / "src.txt").write_text("new", encoding="utf-8") + (setup_storage / "dst.txt").write_text("old", encoding="utf-8") + + _, res = await client.request( + "COPY", + "/files/src.txt", + headers={"Destination": "http://localhost/files/dst.txt"}, + ) + + assert res.status_code == 204 + assert (setup_storage / "dst.txt").read_text() == "new" + + +@pytest.mark.asyncio +async def test_copy_overwrite_false_returns_412(client, setup_storage: Path): + (setup_storage / "src.txt").write_text("x", encoding="utf-8") + (setup_storage / "dst.txt").write_text("y", encoding="utf-8") + + _, res = await client.request( + "COPY", + "/files/src.txt", + headers={ + "Destination": "http://localhost/files/dst.txt", + "Overwrite": "F", + }, + ) + + assert res.status_code == 412 + assert (setup_storage / "dst.txt").read_text() == "y" + + +@pytest.mark.asyncio +async def test_copy_directory_recursively(client, setup_storage: Path): + (setup_storage / "src").mkdir() + (setup_storage / "src" / "child.txt").write_text("child", encoding="utf-8") + + _, res = await client.request( + "COPY", + "/files/src", + headers={"Destination": "http://localhost/files/dst"}, + ) + + assert res.status_code == 201 + assert (setup_storage / "dst" / "child.txt").read_text() == "child" + assert (setup_storage / "src" / "child.txt").is_file() + + +@pytest.mark.asyncio +async def test_copy_missing_parent_returns_409(client, setup_storage: Path): + (setup_storage / "src.txt").write_text("x", encoding="utf-8") + + _, res = await client.request( + "COPY", + "/files/src.txt", + headers={"Destination": "http://localhost/files/nodir/dst.txt"}, + ) + + assert res.status_code == 409 + + +# --------------------------------------------------------------------------- +# MOVE +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_move_renames_file(client, setup_storage: Path): + (setup_storage / "old.txt").write_text("data", encoding="utf-8") + + _, res = await client.request( + "MOVE", + "/files/old.txt", + headers={"Destination": "http://localhost/files/new.txt"}, + ) + + assert res.status_code == 201 + assert not (setup_storage / "old.txt").exists() + assert (setup_storage / "new.txt").read_text() == "data" + + +@pytest.mark.asyncio +async def test_move_overwrites_existing(client, setup_storage: Path): + (setup_storage / "src.txt").write_text("src", encoding="utf-8") + (setup_storage / "dst.txt").write_text("dst", encoding="utf-8") + + _, res = await client.request( + "MOVE", + "/files/src.txt", + headers={"Destination": "http://localhost/files/dst.txt"}, + ) + + assert res.status_code == 204 + assert not (setup_storage / "src.txt").exists() + assert (setup_storage / "dst.txt").read_text() == "src" + + +@pytest.mark.asyncio +async def test_move_overwrite_false_returns_412(client, setup_storage: Path): + (setup_storage / "src.txt").write_text("src", encoding="utf-8") + (setup_storage / "dst.txt").write_text("dst", encoding="utf-8") + + _, res = await client.request( + "MOVE", + "/files/src.txt", + headers={ + "Destination": "http://localhost/files/dst.txt", + "Overwrite": "F", + }, + ) + + assert res.status_code == 412 + assert (setup_storage / "src.txt").is_file() + + +@pytest.mark.asyncio +async def test_move_same_source_and_dest_is_noop(client, setup_storage: Path): + (setup_storage / "file.txt").write_text("x", encoding="utf-8") + + _, res = await client.request( + "MOVE", + "/files/file.txt", + headers={"Destination": "http://localhost/files/file.txt"}, + ) + + assert res.status_code == 204 + assert (setup_storage / "file.txt").is_file() diff --git a/tests/test_tokens.py b/tests/test_tokens.py new file mode 100644 index 0000000..738cb40 --- /dev/null +++ b/tests/test_tokens.py @@ -0,0 +1,192 @@ +from pathlib import Path +from time import time +from uuid import uuid4 + +import os + +import pytest +import pytest_asyncio +from sanic import Sanic + +from cista import auth, config, watching +from cista.auth import bp as auth_bp + + +def _persist_config(): + import msgspec + from pathlib import PurePath + + def enc_hook(obj): + if isinstance(obj, PurePath): + return obj.as_posix() + raise TypeError + + raw = msgspec.to_builtins(config.config, enc_hook=enc_hook) + config.conffile.write_bytes(msgspec.toml.encode(raw)) + + +@pytest.fixture() +def setup_storage(tmp_path: Path): + os.environ["CISTA_HOME"] = str(tmp_path) + config.init_confdir() + user = config.User() + auth.set_password(user, "secret") + admin = config.User(privileged=True) + auth.set_password(admin, "secret") + config.config = config.Config( + path=tmp_path, + listen=":0", + public=False, + users={"alice": user, "admin": admin}, + ) + _persist_config() + watching.state.root = [] + watching.rootpath = tmp_path + yield tmp_path + watching.state.root = [] + + +@pytest_asyncio.fixture() +async def client(setup_storage: Path): + app = Sanic(f"token-test-{uuid4().hex}", strict_slashes=True) + app.router.ALLOWED_METHODS = ( + *app.router.ALLOWED_METHODS, + "MKCOL", + "MOVE", + "COPY", + "PROPFIND", + ) + app.blueprint(auth_bp) + yield app.asgi_client + + +def _basic_auth(username: str, password: str) -> str: + return f"Basic {__import__('base64').b64encode(f'{username}:{password}'.encode()).decode()}" + + +@pytest.mark.asyncio +async def test_token_crud(client): + # Admin creates a token without specifying username (auto-assigned) + _, res = await client.post( + "/auth/tokens", + json={"name": "test"}, + headers={"Authorization": _basic_auth("admin", "secret")}, + ) + assert res.status_code == 200 + data = res.json + assert "id" in data + assert "key" in data + assert data["username"] == "admin" + assert data["name"] == "test" + token_id = data["id"] + token_key = data["key"] + + # List tokens - admin sees only their own + _, res = await client.get( + "/auth/tokens", + headers={"Authorization": _basic_auth("admin", "secret")}, + ) + assert res.status_code == 200 + tokens = res.json["tokens"] + assert len(tokens) == 1 + assert tokens[0]["id"] == token_id + assert tokens[0]["username"] == "admin" + + # Use token via Basic auth (token:) + _, res = await client.get( + "/auth/tokens", + headers={"Authorization": _basic_auth("token", token_key)}, + ) + assert res.status_code == 200 + + # Delete token + _, res = await client.delete( + f"/auth/tokens/{token_id}", + headers={"Authorization": _basic_auth("admin", "secret")}, + ) + assert res.status_code == 200 + + # List should be empty + _, res = await client.get( + "/auth/tokens", + headers={"Authorization": _basic_auth("admin", "secret")}, + ) + assert res.status_code == 200 + assert len(res.json["tokens"]) == 0 + + +@pytest.mark.asyncio +async def test_token_user_scoped(client): + # Alice creates a token for herself (no username specified) + _, res = await client.post( + "/auth/tokens", + json={"name": "alice-token"}, + headers={"Authorization": _basic_auth("alice", "secret")}, + ) + assert res.status_code == 200 + alice_token_id = res.json["id"] + alice_token_key = res.json["key"] + + # Admin creates a token for themselves + _, res = await client.post( + "/auth/tokens", + json={"name": "admin-token"}, + headers={"Authorization": _basic_auth("admin", "secret")}, + ) + assert res.status_code == 200 + admin_token_id = res.json["id"] + + # Alice lists tokens - sees only her own + _, res = await client.get( + "/auth/tokens", + headers={"Authorization": _basic_auth("alice", "secret")}, + ) + assert res.status_code == 200 + tokens = res.json["tokens"] + assert len(tokens) == 1 + assert tokens[0]["id"] == alice_token_id + assert tokens[0]["username"] == "alice" + + # Admin lists tokens - sees only their own + _, res = await client.get( + "/auth/tokens", + headers={"Authorization": _basic_auth("admin", "secret")}, + ) + assert res.status_code == 200 + tokens = res.json["tokens"] + assert len(tokens) == 1 + assert tokens[0]["id"] == admin_token_id + assert tokens[0]["username"] == "admin" + + # Alice cannot create a token for admin + _, res = await client.post( + "/auth/tokens", + json={"username": "admin", "name": "impersonation"}, + headers={"Authorization": _basic_auth("alice", "secret")}, + ) + assert res.status_code == 403 + + # Alice cannot delete admin's token + _, res = await client.delete( + f"/auth/tokens/{admin_token_id}", + headers={"Authorization": _basic_auth("alice", "secret")}, + ) + assert res.status_code == 403 + + # Alice can delete her own token + _, res = await client.delete( + f"/auth/tokens/{alice_token_id}", + headers={"Authorization": _basic_auth("alice", "secret")}, + ) + assert res.status_code == 200 + + # Alice's token auth still works until deletion is processed + # Verify token auth worked during the test + _, res = await client.get( + "/auth/tokens", + headers={"Authorization": _basic_auth("token", alice_token_key)}, + ) + # Token was deleted above, so this should now be unauthenticated + # Actually the token key lookup will fail, and since there's no session fallback... + # With auth header present but invalid, it should return 401 + assert res.status_code == 401