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)