WebDAV sync support, access tokens, REST control endpoints (#10)

Implement complete WebDAV file serving compatible with various clients from Windows File Explorer to more specialized sync tools. The old control WebSocket has been updated to part-DAV, part REST API instead. Implemented user:pass BASIC auth. Added UI and backend for creating tokens that avoid the need to use actual username and password for requests from CLI or DAV.

Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
2026-04-26 06:22:52 +01:00
parent 91e07d61b6
commit 123cb45daa
31 changed files with 3377 additions and 529 deletions
+25 -1
View File
@@ -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.
+22 -13
View File
@@ -7,9 +7,13 @@ from sanic import Blueprint, json
from sanic.exceptions import BadRequest
from cista import __version__, auth, config, sso, watching
from cista.auth import (
create_token_handler,
delete_token_handler,
list_tokens_handler,
)
from cista.fileio import FileServer
from cista.protocol import ControlTypes, StatusMsg
from cista.util.apphelpers import asend, websocket_wrapper
from cista.util.apphelpers import websocket_wrapper
bp = Blueprint("api", url_prefix="/api")
fileserver = FileServer()
@@ -25,17 +29,6 @@ async def stop_fileserver(app):
await fileserver.stop()
@bp.websocket("control")
@websocket_wrapper
async def control(req, ws):
while True:
cmd = msgspec.json.decode(await ws.recv(), type=ControlTypes)
await asyncio.to_thread(cmd)
# Signal the watcher about affected paths
watching.notify_change(*cmd.affected_paths())
await asend(ws, StatusMsg(status="ack", req=cmd))
@bp.websocket("watch")
@websocket_wrapper
async def watch(req, ws):
@@ -144,3 +137,19 @@ async def update_name(request):
# Return the effective name (fallback to path.name if empty)
effective_name = name or config.config.path.name
return json({"message": "Server name updated", "name": effective_name})
# Token management endpoints (available in all modes; primary path in SSO mode)
@bp.get("tokens")
async def list_api_tokens(request):
return await list_tokens_handler(request)
@bp.post("tokens")
async def create_api_token(request):
return await create_token_handler(request)
@bp.delete("tokens/<token_id>")
async def delete_api_token(request, token_id):
return await delete_token_handler(request, token_id)
+78 -164
View File
@@ -1,19 +1,16 @@
import asyncio
import datetime
import mimetypes
import re
import time
from concurrent.futures import ThreadPoolExecutor
from multiprocessing import cpu_count
from pathlib import Path, PurePath, PurePosixPath
from stat import S_IFDIR, S_IFREG
from urllib.parse import unquote
from wsgiref.handlers import format_date_time
import sanic.helpers
from blake3 import blake3
from sanic import Blueprint, Sanic, empty, json, raw, redirect
from sanic.exceptions import BadRequest, Forbidden, NotFound
from sanic import Sanic, empty, raw, redirect
from sanic.exceptions import Forbidden, NotFound
from sanic.log import logger
from setproctitle import setproctitle
from stream_zip import ZIP_AUTO, stream_zip
@@ -21,18 +18,88 @@ from zstandard import ZstdCompressor
from cista import auth, config, preview, session, sso, watching
from cista.preview import shutdown_preview_workers, start_preview_workers
from cista.api import bp, fileserver
from cista.sanic_logging import configure_access_logging, configure_main_logging, format_access_log
from cista.api import bp
from cista import fileserver
from cista.sanic_logging import (
configure_access_logging,
configure_main_logging,
format_access_log,
)
from cista.sanic_logging import logger as access_logger
from cista.util.apphelpers import handle_sanic_exception
# Workaround until Sanic PR #2824 is merged
sanic.helpers._ENTITY_HEADERS = frozenset()
configure_access_logging()
app = Sanic("cista", strict_slashes=True)
app.router.ALLOWED_METHODS = (
*app.router.ALLOWED_METHODS,
"MKCOL",
"MOVE",
"COPY",
"PROPFIND",
)
configure_main_logging()
@app.on_request
async def use_session(req):
req.ctx._log_start = time.perf_counter()
req.ctx._auth_flow = ["session: start"]
auth.hydrate_request_auth_context(req, source="app.on_request")
# CSRF protection
if req.method == "GET" and req.headers.upgrade != "websocket":
return # Ordinary GET requests are fine
# Check that origin matches host, for browsers which should all send Origin.
# Curl doesn't send any Origin header, so we allow it anyway.
origin = req.headers.origin
if origin and origin.split("//", 1)[1] != req.host:
raise Forbidden("Invalid origin: Cross-Site requests not permitted")
@app.on_response
async def log_access(req, res):
"""Log HTTP access in a clean single-line format."""
if req.headers.get("upgrade", "").lower() == "websocket":
return res
start = getattr(req.ctx, "_log_start", None)
duration_ms = (time.perf_counter() - start) * 1000 if start is not None else 0.0
client = req.client_ip or "-"
host = req.host or "-"
path = req.path
if req.query_string:
qs = req.query_string
if isinstance(qs, bytes):
qs = qs.decode(errors="replace")
path = f"{path}?{qs}"
extra = getattr(req.ctx, "_log_extra", None)
line = format_access_log(
client, res.status, req.method, host, path, duration_ms, extra=extra
)
access_logger.info(line)
return res
@app.on_response
async def forward_sso_cookies(req, res):
"""Forward Set-Cookie headers from SSO validation to client."""
if cookies := getattr(req.ctx, "sso_cookies", None):
for cookie in cookies:
res.headers.add("set-cookie", cookie)
@app.on_response
async def persist_auth_session(req, res):
"""Persist a session cookie after successful Authorization-based auth."""
username = getattr(req.ctx, "_create_session_username", None)
if not username or res.status >= 400:
return
existing = getattr(req.ctx, "session", None)
if isinstance(existing, dict) and existing.get("username") == username:
return
session.create(res, username, secure=req.scheme == "https")
# Register either SSO proxy or built-in auth routes based on PASKIA_BACKEND_URL
if sso.paskia_enabled():
app.blueprint(sso.bp) # SSO proxy for /auth/* routes
@@ -40,6 +107,7 @@ else:
app.blueprint(auth.bp) # Built-in auth routes
app.blueprint(preview.bp)
app.blueprint(bp)
app.blueprint(fileserver.bp)
app.exception(Exception)(handle_sanic_exception)
@@ -70,161 +138,7 @@ async def main_stop(app):
logger.debug("Cista worker threads all finished")
@app.on_request
async def use_session(req):
req.ctx._log_start = time.perf_counter()
req.ctx.session = session.get(req)
try:
req.ctx.username = req.ctx.session["username"] # type: ignore
req.ctx.user = config.config.users[req.ctx.username]
except (AttributeError, KeyError, TypeError):
req.ctx.username = None
req.ctx.user = None
# CSRF protection
if req.method == "GET" and req.headers.upgrade != "websocket":
return # Ordinary GET requests are fine
# Check that origin matches host, for browsers which should all send Origin.
# Curl doesn't send any Origin header, so we allow it anyway.
origin = req.headers.origin
if origin and origin.split("//", 1)[1] != req.host:
raise Forbidden("Invalid origin: Cross-Site requests not permitted")
@app.on_response
async def log_access(req, res):
"""Log HTTP access in a clean single-line format."""
if req.headers.get("upgrade", "").lower() == "websocket":
return res
start = getattr(req.ctx, "_log_start", None)
duration_ms = (time.perf_counter() - start) * 1000 if start is not None else 0.0
client = req.client_ip or "-"
host = req.host or "-"
path = req.path
if req.query_string:
qs = req.query_string
if isinstance(qs, bytes):
qs = qs.decode(errors="replace")
path = f"{path}?{qs}"
extra = getattr(req.ctx, "_log_extra", None)
line = format_access_log(client, res.status, req.method, host, path, duration_ms, extra=extra)
access_logger.info(line)
return res
@app.on_response
async def forward_sso_cookies(req, res):
"""Forward Set-Cookie headers from SSO validation to client."""
if cookies := getattr(req.ctx, "sso_cookies", None):
for cookie in cookies:
res.headers.add("set-cookie", cookie)
@app.before_server_start
def http_fileserver(app):
bp = Blueprint("fileserver")
@bp.on_request
async def verify_fileserver(request):
"""Verify access to file server routes."""
await auth.verify(request)
@bp.put("/files/<name:path>")
async def upload_file_chunk(request, *args, **kwargs):
body = request.body
header = request.headers.get("content-range")
if header:
start, end, total = _parse_content_range(header, len(body))
else:
start = 0
end = len(body)
total = end
raw_name = kwargs.get("name")
if raw_name is None and args:
raw_name = args[0]
if not isinstance(raw_name, str) or not raw_name:
prefix = "/files/"
if not request.path.startswith(prefix):
raise BadRequest("Invalid upload path")
raw_name = request.path[len(prefix) :]
rel_name = unquote(raw_name)
upload_info = await asyncio.to_thread(
fileserver.upload_info,
rel_name,
start,
body,
total,
)
extras = []
chunk_len = end - start
whole_file = start == 0 and end == total
if not whole_file:
start_mib = _to_mib_int(start)
chunk_mib = _to_mib_int(chunk_len)
# Keep range logs compact for fixed-size upload blocks.
if chunk_mib == 16:
extras.append(f"{start_mib}MiB")
else:
extras.append(f"{start_mib}+{chunk_mib}MiB")
if upload_info.get("created"):
extras.append(f"created {_to_mib_int(total)}MiB")
size_before = upload_info.get("size_before")
size_after = upload_info.get("size_after")
if (
size_before is not None
and size_after is not None
and size_before != size_after
):
extras.append("resized")
request.ctx._log_extra = " ".join(extras) if extras else None
path = PurePosixPath(rel_name)
watching.notify_change(path, *path.parents)
return json(
{
"status": "ack",
"req": {
"name": rel_name,
"size": total,
"start": start,
"end": end,
},
}
)
bp.static(
"/files/",
config.config.path,
use_content_range=True,
stream_large_files=True,
directory_view=True,
)
app.blueprint(bp)
www = {}
_CONTENT_RANGE_RE = re.compile(r"^bytes (\d+)-(\d+)/(\d+)$")
def _parse_content_range(header: str, body_len: int) -> tuple[int, int, int]:
m = _CONTENT_RANGE_RE.fullmatch(header.strip())
if m is None:
raise BadRequest("Invalid Content-Range format")
start, end_inclusive, total = (int(v) for v in m.groups())
if total <= 0:
raise BadRequest("Invalid Content-Range total size")
if start > end_inclusive:
raise BadRequest("Invalid Content-Range range")
if end_inclusive >= total:
raise BadRequest("Content-Range exceeds total size")
expected_len = end_inclusive - start + 1
if expected_len != body_len:
raise BadRequest(
f"Content length mismatch for range: expected {expected_len}, got {body_len}"
)
return start, end_inclusive + 1, total
def _to_mib_int(value_bytes: int) -> int:
return round(value_bytes / (1 << 20))
def _load_wwwroot(www):
+917 -5
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -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)
+605
View File
@@ -0,0 +1,605 @@
import asyncio
import mimetypes
import os
import re
import shutil
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from pathlib import Path, PurePosixPath
from urllib.parse import quote as url_quote, unquote, urlparse
from wsgiref.handlers import format_date_time
from sanic import Blueprint, HTTPResponse, empty, json
from sanic.exceptions import BadRequest, NotFound
from cista import auth, config, watching
from cista.api import fileserver
from cista.util import filename
bp = Blueprint("fileserver", url_prefix="/files")
_CONTENT_RANGE_RE = re.compile(r"^bytes (\d+)-(\d+)/(\d+)$")
_RANGE_RE = re.compile(r"^bytes=(\d*)-(\d*)$")
_FILE_CHUNK_SIZE = 1 << 20
_DAV_NS = "DAV:"
ET.register_namespace("D", _DAV_NS)
def _dav_tag(name: str) -> str:
return f"{{{_DAV_NS}}}{name}"
@bp.on_request
async def verify_fileserver(request):
"""Verify access to file server routes."""
await auth.verify(request)
@bp.put("/<name:path>")
async def upload_file_chunk(request, name):
body = request.body
header = request.headers.get("content-range")
if header:
start, end, total = _parse_content_range(header, len(body))
else:
start = 0
end = len(body)
total = end
rel, _ = _safe_relpath(name)
rel_name = rel.as_posix()
upload_info = await asyncio.to_thread(
fileserver.upload_info,
rel_name,
start,
body,
total,
)
extras = []
chunk_len = end - start
whole_file = start == 0 and end == total
if not whole_file:
start_mib = _to_mib_int(start)
chunk_mib = _to_mib_int(chunk_len)
# Keep range logs compact for fixed-size upload blocks.
if chunk_mib == 16:
extras.append(f"{start_mib}MiB")
else:
extras.append(f"{start_mib}+{chunk_mib}MiB")
if upload_info.get("created"):
extras.append(f"created {_to_mib_int(total)}MiB")
size_before = upload_info.get("size_before")
size_after = upload_info.get("size_after")
if size_before is not None and size_after is not None and size_before != size_after:
extras.append("resized")
request.ctx._log_extra = " ".join(extras) if extras else None
watching.notify_change(rel, *rel.parents)
return json(
{
"status": "ack",
"req": {
"name": rel_name,
"size": total,
"start": start,
"end": end,
},
}
)
@bp.delete("/<name:path>")
async def delete_file(request, name):
rel, path = _safe_relpath(name)
if not rel.parts:
raise BadRequest("Refusing to delete root folder")
def _delete():
if not path.exists():
raise NotFound(f"File not found: {name}")
if path.is_dir():
shutil.rmtree(path)
else:
path.unlink()
await asyncio.to_thread(_delete)
watching.notify_change(rel, *rel.parents)
return empty(status=204)
@bp.route("/<name:path>", methods=["MKCOL"])
async def create_folder(request, name):
rel, path = _safe_relpath(name)
if not rel.parts:
raise BadRequest("Refusing to create root folder")
await asyncio.to_thread(path.mkdir, parents=True, exist_ok=False)
watching.notify_change(rel, *rel.parents)
return empty(status=201)
@bp.post("/", name="post_root", strict_slashes=False)
@bp.post("/<name:path>", name="post_path")
async def copy_or_move(request, name=""):
provided_args = set(request.args.keys())
if not provided_args:
raise BadRequest("No query arguments passed")
allowed_args = {"cp", "mv"}
unknown_args = sorted(provided_args - allowed_args)
if unknown_args:
raise BadRequest(f"Unknown query parameter(s): {', '.join(unknown_args)}")
mv_vals = request.args.getlist("mv")
cp_vals = request.args.getlist("cp")
mv_keys: list[str] = []
for value in mv_vals:
mv_keys.extend(k for k in value.split() if k)
cp_keys: list[str] = []
for value in cp_vals:
cp_keys.extend(k for k in value.split() if k)
if not mv_keys and not cp_keys:
raise BadRequest("No keys given")
dst_rel, dst_abs = _safe_relpath(name)
dst_exists = dst_abs.exists()
dst_is_dir = dst_exists and dst_abs.is_dir()
ordered_keys = cp_keys + mv_keys
key_paths = _get_key_paths(set(ordered_keys))
missing = [key for key in ordered_keys if key not in key_paths]
if missing:
raise NotFound("Files not found", context={"missing": missing})
# Validate target shape/type before mutating anything.
for op_name, op_keys in (("cp", cp_keys), ("mv", mv_keys)):
if len(op_keys) > 1 and not dst_is_dir:
raise BadRequest("Destination must be an existing directory for multiple keys")
if not op_keys:
continue
if not dst_is_dir:
if not dst_rel.parts:
raise BadRequest("Destination file path is required")
parent_abs = dst_abs.parent
if not parent_abs.is_dir():
raise BadRequest("Destination parent folder does not exist")
if dst_exists and dst_abs.is_file():
for key in op_keys:
src_abs = _resolve_from_relpath(key_paths[key])
if src_abs.is_dir():
raise BadRequest("Cannot move/copy a directory to an existing file")
changed: set[PurePosixPath] = set()
completed: list[dict[str, str]] = []
class _FileOpFailed(Exception):
def __init__(self, op_name: str, key: str, error: Exception):
self.op_name = op_name
self.key = key
self.error = error
super().__init__(str(error))
def _apply():
for op_name, op_keys in (("cp", cp_keys), ("mv", mv_keys)):
op_multi = len(op_keys) > 1
for key in op_keys:
try:
src_rel = key_paths[key]
src_abs = _resolve_from_relpath(src_rel)
if op_multi:
if not dst_is_dir:
raise BadRequest(
"Destination must be an existing directory for multiple keys"
)
dst_item_rel = (
dst_rel / src_rel.name if dst_rel.parts else PurePosixPath(src_rel.name)
)
elif dst_is_dir:
dst_item_rel = (
dst_rel / src_rel.name if dst_rel.parts else PurePosixPath(src_rel.name)
)
else:
if not dst_rel.parts:
raise BadRequest("Destination file path is required")
parent_abs = dst_abs.parent
if not parent_abs.is_dir():
raise BadRequest("Destination parent folder does not exist")
if src_abs.is_dir() and dst_exists and dst_abs.is_file():
raise BadRequest(
"Cannot move/copy a directory to an existing file"
)
dst_item_rel = dst_rel
dst_item_abs = _resolve_from_relpath(dst_item_rel)
if op_name == "mv":
# A no-op rename should still return success.
if src_abs != dst_item_abs:
shutil.move(src_abs, dst_item_abs)
changed.add(src_rel)
changed.add(src_rel.parent)
elif src_abs.is_dir():
shutil.copytree(
src_abs,
dst_item_abs,
dirs_exist_ok=True,
ignore_dangling_symlinks=True,
)
else:
shutil.copy2(src_abs, dst_item_abs)
changed.add(dst_item_rel)
changed.add(dst_item_rel.parent)
completed.append({"op": op_name, "key": key})
except Exception as e:
raise _FileOpFailed(op_name, key, e) from e
try:
await asyncio.to_thread(_apply)
except _FileOpFailed as e:
raise BadRequest(
"File operation failed after partial progress",
context={
"failed_op": e.op_name,
"failed_key": e.key,
"error": str(e.error),
"completed": completed,
},
) from e
notify_paths = [p for p in changed if p.parts]
if notify_paths:
watching.notify_change(*notify_paths)
return json(
{
"status": "ack",
"counts": {"cp": len(cp_keys), "mv": len(mv_keys)},
}
)
@bp.get("/<name:path>")
async def get_file(request, name=""):
return await _send_static_file(request, name, head_only=False)
@bp.head("/<name:path>")
async def head_file(request, name=""):
return await _send_static_file(request, name, head_only=True)
@bp.route("/", methods=["OPTIONS"], name="options_root", strict_slashes=False)
@bp.route("/<name:path>", methods=["OPTIONS"], name="options_path")
async def dav_options(request, name=""):
return HTTPResponse(
status=200,
headers={
"Allow": "OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND, POST",
"DAV": "1",
"MS-Author-Via": "DAV",
},
)
@bp.route("/", methods=["PROPFIND"], name="propfind_root", strict_slashes=False)
@bp.route("/<name:path>", methods=["PROPFIND"], name="propfind_path")
async def dav_propfind(request, name=""):
rel, path = _safe_relpath(name)
if not path.exists():
raise NotFound(f"Not found: {name}")
depth = request.headers.get("depth", "1").strip()
if depth == "infinity":
return HTTPResponse(status=403)
entries = await asyncio.to_thread(_collect_propfind_entries, rel, path, depth)
return HTTPResponse(
body=_build_propfind_xml(entries),
status=207,
content_type='application/xml; charset="utf-8"',
)
@bp.route("/", methods=["COPY"], name="copy_root", strict_slashes=False)
@bp.route("/<name:path>", methods=["COPY"], name="copy_path")
async def dav_copy(request, name=""):
dest_header = request.headers.get("destination")
if not dest_header:
raise BadRequest("Missing Destination header")
overwrite = request.headers.get("overwrite", "T").strip().upper() != "F"
src_rel, src_abs = _safe_relpath(name)
dst_rel, dst_abs = _parse_webdav_destination(dest_header)
request.ctx._log_extra = f"{dst_rel}"
if not src_abs.exists():
raise NotFound(f"Source not found: {name}")
if src_abs == dst_abs:
raise BadRequest("Source and destination are the same")
dst_existed = dst_abs.exists()
if dst_existed and not overwrite:
return HTTPResponse(status=412)
if not dst_abs.parent.is_dir():
return HTTPResponse(status=409)
def _do_copy():
if dst_existed:
shutil.rmtree(dst_abs) if dst_abs.is_dir() else dst_abs.unlink()
if src_abs.is_dir():
shutil.copytree(src_abs, dst_abs, ignore_dangling_symlinks=True)
else:
shutil.copy2(src_abs, dst_abs)
await asyncio.to_thread(_do_copy)
watching.notify_change(dst_rel, *dst_rel.parents)
return HTTPResponse(status=201 if not dst_existed else 204)
@bp.route("/", methods=["MOVE"], name="move_root", strict_slashes=False)
@bp.route("/<name:path>", methods=["MOVE"], name="move_path")
async def dav_move(request, name=""):
dest_header = request.headers.get("destination")
if not dest_header:
raise BadRequest("Missing Destination header")
overwrite = request.headers.get("overwrite", "T").strip().upper() != "F"
src_rel, src_abs = _safe_relpath(name)
dst_rel, dst_abs = _parse_webdav_destination(dest_header)
request.ctx._log_extra = f"{dst_rel}"
if not src_abs.exists():
raise NotFound(f"Source not found: {name}")
if src_abs == dst_abs:
return HTTPResponse(status=204)
dst_existed = dst_abs.exists()
if dst_existed and not overwrite:
return HTTPResponse(status=412)
if not dst_abs.parent.is_dir():
return HTTPResponse(status=409)
def _do_move():
if dst_existed:
shutil.rmtree(dst_abs) if dst_abs.is_dir() else dst_abs.unlink()
shutil.move(src_abs, dst_abs)
await asyncio.to_thread(_do_move)
watching.notify_change(src_rel, *src_rel.parents, dst_rel, *dst_rel.parents)
return HTTPResponse(status=201 if not dst_existed else 204)
def _parse_content_range(header: str, body_len: int) -> tuple[int, int, int]:
m = _CONTENT_RANGE_RE.fullmatch(header.strip())
if m is None:
raise BadRequest("Invalid Content-Range format")
start, end_inclusive, total = (int(v) for v in m.groups())
if total <= 0:
raise BadRequest("Invalid Content-Range total size")
if start > end_inclusive:
raise BadRequest("Invalid Content-Range range")
if end_inclusive >= total:
raise BadRequest("Content-Range exceeds total size")
expected_len = end_inclusive - start + 1
if expected_len != body_len:
raise BadRequest(
f"Content length mismatch for range: expected {expected_len}, got {body_len}"
)
return start, end_inclusive + 1, total
def _to_mib_int(value_bytes: int) -> int:
return round(value_bytes / (1 << 20))
def _safe_relpath(path: str) -> tuple[PurePosixPath, Path]:
"""Resolve a user path under storage root and enforce containment."""
base = config.config.path.resolve()
try:
sanitized = filename.sanitize(unquote(path))
except ValueError as e:
raise BadRequest(f"Invalid path: {e}") from e
resolved = (base / sanitized).resolve()
if not resolved.is_relative_to(base):
raise BadRequest("Invalid path")
rel = PurePosixPath(resolved.relative_to(base).as_posix())
return rel, resolved
def _resolve_from_relpath(rel: PurePosixPath) -> Path:
"""Resolve a relative path under storage root and enforce containment."""
base = config.config.path.resolve()
resolved = (base / rel).resolve()
if not resolved.is_relative_to(base):
raise BadRequest("Invalid path")
return resolved
async def _send_static_file(request, name: str, *, head_only: bool):
_, path = _safe_relpath(name)
st = await asyncio.to_thread(path.stat)
if path.is_dir():
raise NotFound(f"Not a file: {name}")
size = st.st_size
start = 0
end_excl = size
status = 200
range_header = request.headers.get("range")
if range_header is not None:
parsed = _parse_range_header(range_header, size)
if parsed is None:
return empty(
status=416,
headers={
"accept-ranges": "bytes",
"content-range": f"bytes */{size}",
},
)
start, end_excl = parsed
status = 206
length = end_excl - start
mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
headers = {
"accept-ranges": "bytes",
"cache-control": "no-cache",
"content-length": str(length),
"content-type": mime,
"last-modified": format_date_time(st.st_mtime),
}
if status == 206:
headers["content-range"] = f"bytes {start}-{end_excl - 1}/{size}"
if head_only:
return empty(status=status, headers=headers)
res = await request.respond(status=status, headers=headers)
fd = await asyncio.to_thread(os.open, path, os.O_RDONLY)
try:
pos = start
while pos < end_excl:
chunk = await asyncio.to_thread(
os.pread,
fd,
min(_FILE_CHUNK_SIZE, end_excl - pos),
pos,
)
if not chunk:
break
pos += len(chunk)
await res.send(chunk)
finally:
await asyncio.to_thread(os.close, fd)
def _parse_range_header(header: str, size: int) -> tuple[int, int] | None:
value = header.strip()
if "," in value:
return None
m = _RANGE_RE.fullmatch(value)
if m is None:
return None
start_s, end_s = m.groups()
if not start_s and not end_s:
return None
if start_s:
start = int(start_s)
if start >= size:
return None
end_inclusive = int(end_s) if end_s else (size - 1)
if end_inclusive < start:
return None
end_inclusive = min(end_inclusive, size - 1)
return start, end_inclusive + 1
suffix_len = int(end_s)
if suffix_len <= 0:
return None
if suffix_len >= size:
return 0, size
start = size - suffix_len
return start, size
def _get_key_paths(wanted: set[str]) -> dict[str, PurePosixPath]:
"""Map file keys to their current relative filesystem paths."""
loc = PurePosixPath()
ret: dict[str, PurePosixPath] = {}
with watching.state.lock:
root = watching.state.root
for f in root:
loc = PurePosixPath(*loc.parts[: f.level - 1]) / f.name
if f.key in wanted and f.key not in ret:
ret[f.key] = loc
if len(ret) == len(wanted):
break
return ret
# ---------------------------------------------------------------------------
# WebDAV helpers
# ---------------------------------------------------------------------------
def _parse_webdav_destination(dest_header: str) -> tuple[PurePosixPath, Path]:
"""Parse a WebDAV Destination header and resolve it to a storage path."""
parsed = urlparse(dest_header)
raw_path = parsed.path # still percent-encoded
prefix = "/files"
if raw_path in (prefix, prefix + "/"):
rel_str = ""
elif raw_path.startswith(prefix + "/"):
rel_str = raw_path[len(prefix) + 1:]
else:
raise BadRequest("Destination must be within /files")
return _safe_relpath(rel_str)
def _rel_to_href(rel: PurePosixPath, is_dir: bool) -> str:
"""Build a DAV href from a storage-relative path."""
parts = rel.parts
if not parts:
return "/files/"
encoded = "/".join(url_quote(p, safe="") for p in parts)
href = f"/files/{encoded}"
return href + "/" if is_dir else href
def _dav_xml(element: ET.Element) -> bytes:
"""Serialise an ElementTree element to UTF-8 bytes with XML declaration."""
return (
b'<?xml version="1.0" encoding="UTF-8"?>'
+ ET.tostring(element, encoding="unicode").encode("utf-8")
)
def _collect_propfind_entries(rel: PurePosixPath, path: Path, depth: str) -> list[dict]:
entries = [_propfind_entry(rel, path)]
if depth == "1" and path.is_dir():
for child in sorted(path.iterdir(), key=lambda p: (p.is_file(), p.name)):
child_rel = rel / child.name if rel.parts else PurePosixPath(child.name)
try:
entries.append(_propfind_entry(child_rel, child))
except OSError:
pass
return entries
def _propfind_entry(rel: PurePosixPath, path: Path) -> dict:
st = path.stat()
is_dir = path.is_dir()
return {
"href": _rel_to_href(rel, is_dir),
"name": rel.parts[-1] if rel.parts else "",
"is_dir": is_dir,
"size": st.st_size,
"etag": f'"{st.st_mtime:.0f}-{st.st_size}"',
"content_type": mimetypes.guess_type(path.name)[0] or "application/octet-stream",
"last_modified": format_date_time(st.st_mtime),
"created": datetime.fromtimestamp(st.st_ctime, tz=timezone.utc).strftime(
"%Y-%m-%dT%H:%M:%SZ"
),
}
def _build_propfind_xml(entries: list[dict]) -> bytes:
multistatus = ET.Element(_dav_tag("multistatus"))
for e in entries:
response = ET.SubElement(multistatus, _dav_tag("response"))
ET.SubElement(response, _dav_tag("href")).text = e["href"]
propstat = ET.SubElement(response, _dav_tag("propstat"))
prop = ET.SubElement(propstat, _dav_tag("prop"))
rt = ET.SubElement(prop, _dav_tag("resourcetype"))
if e["is_dir"]:
ET.SubElement(rt, _dav_tag("collection"))
ET.SubElement(prop, _dav_tag("displayname")).text = e["name"]
ET.SubElement(prop, _dav_tag("getlastmodified")).text = e["last_modified"]
ET.SubElement(prop, _dav_tag("creationdate")).text = e["created"]
if not e["is_dir"]:
ET.SubElement(prop, _dav_tag("getcontentlength")).text = str(e["size"])
ET.SubElement(prop, _dav_tag("getcontenttype")).text = e["content_type"]
ET.SubElement(prop, _dav_tag("getetag")).text = e["etag"]
ET.SubElement(propstat, _dav_tag("status")).text = "HTTP/1.1 200 OK"
return _dav_xml(multistatus)
+2 -6
View File
@@ -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()
-115
View File
@@ -1,125 +1,10 @@
from __future__ import annotations
import shutil
from pathlib import PurePosixPath
from typing import Any
import msgspec
from sanic import BadRequest
from cista import config
from cista.util import filename
## Control commands
class ControlBase(msgspec.Struct, tag_field="op", tag=str.lower):
def __call__(self):
raise NotImplementedError
def affected_paths(self) -> list[str]:
"""Return list of paths affected by this operation for change notification."""
return []
class MkDir(ControlBase):
path: str
def __call__(self):
path = config.config.path / filename.sanitize(self.path)
path.mkdir(parents=True, exist_ok=False)
def affected_paths(self) -> list[str]:
return [filename.sanitize(self.path)]
class Rename(ControlBase):
path: str
to: str
def __call__(self):
to = filename.sanitize(self.to)
if "/" in to:
raise BadRequest("Rename 'to' name should only contain filename, not path")
path = config.config.path / filename.sanitize(self.path)
path.rename(path.with_name(to))
def affected_paths(self) -> list[str]:
sanitized = filename.sanitize(self.path)
new_path = str(PurePosixPath(sanitized).with_name(filename.sanitize(self.to)))
return [sanitized, new_path]
class Rm(ControlBase):
sel: list[str]
def __call__(self):
root = config.config.path
sel = [root / filename.sanitize(p) for p in self.sel]
for p in sel:
if p.is_dir():
shutil.rmtree(p)
else:
p.unlink()
def affected_paths(self) -> list[str]:
return [filename.sanitize(p) for p in self.sel]
class Mv(ControlBase):
sel: list[str]
dst: str
def __call__(self):
root = config.config.path
sel = [root / filename.sanitize(p) for p in self.sel]
dst = root / filename.sanitize(self.dst)
if not dst.is_dir():
raise BadRequest("The destination must be a directory")
for p in sel:
shutil.move(p, dst)
def affected_paths(self) -> list[str]:
dst = filename.sanitize(self.dst)
paths = [filename.sanitize(p) for p in self.sel]
# Include new locations in dst
paths.extend(f"{dst}/{PurePosixPath(p).name}" for p in self.sel)
return paths
class Cp(ControlBase):
sel: list[str]
dst: str
def __call__(self):
root = config.config.path
sel = [root / filename.sanitize(p) for p in self.sel]
dst = root / filename.sanitize(self.dst)
if not dst.is_dir():
raise BadRequest("The destination must be a directory")
for p in sel:
if p.is_dir():
# Note: copies as dst rather than in dst unless name is appended.
shutil.copytree(
p,
dst / p.name,
dirs_exist_ok=True,
ignore_dangling_symlinks=True,
)
else:
shutil.copy2(p, dst)
def affected_paths(self) -> list[str]:
dst = filename.sanitize(self.dst)
# Only destinations are new (sources unchanged)
return [f"{dst}/{PurePosixPath(filename.sanitize(p)).name}" for p in self.sel]
ControlTypes = MkDir | Rename | Rm | Mv | Cp
class StatusMsg(msgspec.Struct):
status: str
req: Any
class ErrorMsg(msgspec.Struct):
+23 -15
View File
@@ -8,18 +8,18 @@ from ipaddress import IPv6Address
logger = logging.getLogger("cista.access")
_RESET = "\033[0m"
_STATUS_INFO = "\033[32m" # 1xx (green)
_STATUS_OK = "\033[1;92m" # 2xx (bright green)
_STATUS_INFO = "\033[32m" # 1xx (green)
_STATUS_OK = "\033[1;92m" # 2xx (bright green)
_STATUS_REDIRECT = "\033[32m" # 3xx (green)
_STATUS_CLIENT_ERR = "\033[0;31m" # 4xx (red)
_STATUS_SERVER_ERR = "\033[1;91m" # 5xx (bold bright red)
_METHOD_READ = "\033[0;34m" # GET, HEAD, OPTIONS (blue)
_METHOD_WRITE = "\033[1;94m" # POST, PUT, DELETE, PATCH (bold bright blue)
_HOST = "\033[38;5;242m" # hostname (dark grey)
_PATH = "\033[38;5;250m" # path (light grey)
_TIMING = "\033[38;5;242m" # timing (dark grey)
_WS_OPEN = "\033[1;93m" # WebSocket connect (bold bright yellow)
_WS_CLOSE = "\033[33m" # WebSocket disconnect (yellow)
_STATUS_CLIENT_ERR = "\033[0;31m" # 4xx (red)
_STATUS_SERVER_ERR = "\033[1;91m" # 5xx (bold bright red)
_METHOD_READ = "\033[0;34m" # GET, HEAD, OPTIONS (blue)
_METHOD_WRITE = "\033[1;94m" # POST, PUT, DELETE, PATCH (bold bright blue)
_HOST = "\033[38;5;242m" # hostname (dark grey)
_PATH = "\033[38;5;250m" # path (light grey)
_TIMING = "\033[38;5;242m" # timing (dark grey)
_WS_OPEN = "\033[1;93m" # WebSocket connect (bold bright yellow)
_WS_CLOSE = "\033[33m" # WebSocket disconnect (yellow)
_WS_STATUS = "\033[38;5;250m" # WebSocket close status (normal white)
@@ -113,7 +113,12 @@ def _format_method_label(label: str, *, color: str | None = None) -> str:
def format_access_log(
client: str, status: int, method: str, host: str, path: str, duration_ms: float,
client: str,
status: int,
method: str,
host: str,
path: str,
duration_ms: float,
extra: str | None = None,
) -> str:
ip = _format_left(format_client_ip(client))
@@ -123,7 +128,9 @@ def format_access_log(
path_str = f"{_PATH}{path}{_RESET}"
timing_str = f"{_TIMING}{format_duration_ms(duration_ms)}{_RESET}"
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
return f"{ip} {status_str} {method_str} {host_str}{path_str}{extra_str} {timing_str}"
return (
f"{ip} {status_str} {method_str} {host_str}{path_str}{extra_str} {timing_str}"
)
_ws_counter = 1
@@ -194,7 +201,7 @@ WS_CLOSE_CODES = {
}
def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
def log_ws_close(ws_id: int, close_code: int | None, duration: float, extra: str | None = None) -> None:
"""Log WebSocket connection close with duration and status."""
id_str = _format_ws_id(ws_id)
timing = format_duration_ms(duration * 1000)
@@ -209,8 +216,9 @@ def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
method_str = _format_method_label("closed", color=_TIMING)
status_str = f"{_WS_STATUS}{code} {status}{_RESET}"
timing_str = f"{_TIMING}{timing}{_RESET}"
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
logger.info("%s %s %s %s %s", " " * 19, id_str, method_str, status_str, timing_str)
logger.info("%s %s %s %s %s%s", " " * 19, id_str, method_str, status_str, timing_str, extra_str)
def configure_access_logging() -> None:
+4 -4
View File
@@ -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):
+55
View File
@@ -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.
+6 -1
View File
@@ -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
+2
View File
@@ -7,6 +7,7 @@
</div>
<SettingsModal />
<UserManagementModal />
<UserTokensModal />
<AccessDeniedModal />
<header>
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query" />
@@ -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'
+46 -59
View File
@@ -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<Doc | null>(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')
}
}
+46 -59
View File
@@ -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<Doc | null>(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<HTMLElement>()
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')
}
}
+4
View File
@@ -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' }})
}
+40 -28
View File
@@ -29,7 +29,7 @@
</template>
<script setup lang="ts">
import {connect, controlUrl} from '@/repositories/WS'
import { apiFetch } from '@/repositories/Client'
import { useMainStore } from '@/stores/main'
import { computed, ref } from 'vue'
import { formatSize } from '@/utils'
@@ -49,6 +49,18 @@ const navigateTo = (path: string) => {
router.push('/' + path)
}
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}`
}
}
// Truncate long names to reasonable length
const truncateName = (name: string, maxLen = 20): string => {
if (name.length <= maxLen) return name
@@ -115,43 +127,43 @@ const selectionDisplay = computed<SelectionDisplay>(() => {
}
})
const op = (opName: string, dst?: string) => {
const op = async (opName: string, dst?: string) => {
const sel = store.selectedFiles
const keys = sel.keys
const paths = sel.keys.map(key => {
const doc = sel.docs[key]!
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
})
const msg = {
op: opName,
sel: paths
}
// @ts-ignore
if (dst !== undefined) msg.dst = dst
// Hide items being deleted or moved (optimistic update)
if (opName === 'rm' || opName === 'mv') {
for (const path of paths) store.hideDoc(path)
}
const control = connect(controlUrl, {
message(ev: MessageEvent) {
const res = JSON.parse(ev.data)
if ('error' in res) {
console.error('Control socket error', msg, res.error)
store.error = res.error.message
// Restore hidden items on error
if (opName === 'rm' || opName === 'mv') {
for (const path of paths) store.unhideDoc(path)
}
return
} else if (res.status === 'ack') {
console.log('Control ack OK', res)
control.close()
store.selected.clear()
return
} else console.log('Unknown control response', msg, res)
try {
if (opName === 'rm') {
for (const path of paths) {
const res = await apiFetch(filesUrl(path), { method: 'DELETE' })
if (!res.ok) throw new Error(await parseErrorMessage(res))
}
} else if (opName === 'mv' || opName === 'cp') {
if (keys.length === 0) throw new Error('No selected files')
const dstUrl = dst ? filesUrl(dst) : '/files/'
const query = `${opName}=${keys.join('+')}`
const res = await apiFetch(`${dstUrl}?${query}`, { method: 'POST' })
if (!res.ok) throw new Error(await parseErrorMessage(res))
} else {
throw new Error(`Unsupported operation: ${opName}`)
}
store.selected.clear()
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error('REST file operation failed', opName, err)
store.error = message
if (opName === 'rm' || opName === 'mv') {
for (const path of paths) store.unhideDoc(path)
}
})
control.onopen = () => {
control.send(JSON.stringify(msg))
}
}
@@ -188,14 +188,14 @@ const deleteUserAction = async (username: string) => {
}
const copySuccess = async (isButtonClick: boolean = false) => {
const passwordMatch = success.value.match(/(?:Password|New password): (.+)/)
const passwordMatch = success.value.match(/(?:Password|New password|Key): (.+)/)
if (passwordMatch) {
await navigator.clipboard.writeText(passwordMatch[1]!)
if (isButtonClick) {
// Show "Copied!" indication on button
copyButtonText.value = '✅ Copied!'
// Hide password and button immediately after copying
const baseMessage = success.value.replace(/(?:Password|New password): .+/, 'Password copied to clipboard!')
// Hide password/key and button immediately after copying
const baseMessage = success.value.replace(/(?:Password|New password|Key): .+/, 'Copied to clipboard!')
success.value = baseMessage
// Hide the entire message after 3 seconds
setTimeout(() => {
+264
View File
@@ -0,0 +1,264 @@
<template>
<ModalDialog name=tokens title="My API Tokens">
<div v-if="loading" class="loading">Loading...</div>
<div v-else>
<p class="hint">Create tokens to access Cista from scripts or other apps. Tokens are tied to your account.</p>
<!-- Creation form -->
<div v-if="mode === 'creating'" class="create-form">
<label for="token-name">Token name (optional)</label>
<input
id="token-name"
v-model="newTokenName"
type="text"
placeholder="e.g. backup-script"
@keyup.enter="submitCreate"
ref="nameInput"
/>
<div class="form-actions">
<button @click="submitCreate" class="button primary" :disabled="creating">Create</button>
<button @click="cancelCreate" class="button">Cancel</button>
</div>
</div>
<!-- Creation result -->
<div v-else-if="mode === 'created' && createdToken" class="created-result">
<p class="success-title"> Token created</p>
<p class="hint">Copy this URL it will not be shown again.</p>
<div class="url-box">
<code class="token-url">{{ createdToken.url }}</code>
<button @click="copyUrl" class="button small">{{ copyButtonText }}</button>
</div>
<p class="hint">Use it like: <code>curl {{ createdToken.url }}/...</code></p>
<div class="form-actions">
<button @click="finishCreate" class="button primary">Done</button>
</div>
</div>
<!-- Token list -->
<div v-else>
<button @click="startCreate" class="button" title="Add new token"> Add Token</button>
<table v-if="tokens.length">
<thead>
<tr>
<th>Name</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="token in tokens" :key="token.id">
<td>{{ token.name || 'Unnamed' }}</td>
<td>{{ formatDate(token.created) }}</td>
<td>
<button @click="deleteTokenAction(token.id)" class="button small danger" title="Revoke token">🗑</button>
</td>
</tr>
</tbody>
</table>
<p v-else class="empty">You have no API tokens.</p>
</div>
<div class="dialog-buttons">
<button @click="close" class="button">Close</button>
</div>
</div>
</ModalDialog>
</template>
<script lang="ts" setup>
import { ref, watch, nextTick } from 'vue'
import { listTokens, createToken, deleteToken } from '@/repositories/User'
import type { ISimpleError } from '@/repositories/Client'
import { useMainStore } from '@/stores/main'
interface Token {
id: string
username: string
sso_user_id: string
name: string
created: number
}
interface CreatedToken extends Token {
key: string
url: string
}
const store = useMainStore()
const loading = ref(true)
const tokens = ref<Token[]>([])
const mode = ref<'list' | 'creating' | 'created'>('list')
const newTokenName = ref('')
const creating = ref(false)
const createdToken = ref<CreatedToken | null>(null)
const copyButtonText = ref('📋')
const nameInput = ref<HTMLInputElement | null>(null)
const close = () => {
store.dialog = ''
resetCreate()
}
const resetCreate = () => {
mode.value = 'list'
newTokenName.value = ''
creating.value = false
createdToken.value = null
copyButtonText.value = '📋'
}
const loadTokens = async () => {
try {
loading.value = true
const data = await listTokens()
tokens.value = data.tokens
} catch (e) {
const httpError = e as ISimpleError
store.showToast(httpError.message || 'Failed to load tokens')
} finally {
loading.value = false
}
}
const startCreate = () => {
mode.value = 'creating'
nextTick(() => nameInput.value?.focus())
}
const cancelCreate = () => {
resetCreate()
}
const ensureFilesBaseUrl = (url: string) => {
const trimmed = url.replace(/\/+$/, '')
if (trimmed.endsWith('/files')) return trimmed
return `${trimmed}/files`
}
const submitCreate = async () => {
if (creating.value) return
creating.value = true
try {
const result = await createToken(newTokenName.value)
await loadTokens()
if (result.url) {
createdToken.value = {
...(result as CreatedToken),
url: ensureFilesBaseUrl((result as CreatedToken).url),
}
mode.value = 'created'
}
} catch (e) {
const httpError = e as ISimpleError
store.showToast(httpError.message || 'Failed to create token')
mode.value = 'list'
} finally {
creating.value = false
}
}
const finishCreate = () => {
resetCreate()
}
const copyUrl = async () => {
if (!createdToken.value) return
await navigator.clipboard.writeText(createdToken.value.url)
copyButtonText.value = '✅ Copied!'
setTimeout(() => {
copyButtonText.value = '📋'
}, 2000)
}
const deleteTokenAction = async (tokenId: string) => {
if (!confirm('Revoke this token? It will no longer work.')) return
try {
await deleteToken(tokenId)
await loadTokens()
} catch (e) {
const httpError = e as ISimpleError
store.showToast(httpError.message || 'Failed to revoke token')
}
}
const formatDate = (ts: number) => {
if (!ts) return '—'
return new Date(ts * 1000).toLocaleString()
}
// Load tokens when dialog opens
watch(() => store.dialog, (newVal) => {
if (newVal === 'tokens') {
resetCreate()
loadTokens()
}
})
</script>
<style scoped>
.hint {
color: #666;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.empty {
color: #888;
font-style: italic;
margin: 1rem 0;
}
.create-form {
margin-bottom: 1rem;
}
.create-form label {
display: block;
margin-bottom: 0.25rem;
font-size: 0.875rem;
color: #444;
}
.create-form input {
width: 100%;
padding: 0.5rem;
font-size: 1rem;
border: 2px solid #888;
border-radius: 0.25rem;
background: #fff;
color: #000;
margin-bottom: 0.5rem;
}
.create-form input:focus {
outline: none;
border-color: #f80;
}
.form-actions {
display: flex;
gap: 0.5rem;
}
.created-result {
margin-bottom: 1rem;
}
.success-title {
color: #080;
font-weight: bold;
margin: 0 0 0.5rem 0;
}
.url-box {
display: flex;
gap: 0.5rem;
align-items: center;
background: #f0f0f0;
padding: 0.75rem;
border-radius: 0.25rem;
margin: 0.5rem 0;
}
.token-url {
flex: 1;
word-break: break-all;
font-size: 0.875rem;
color: #222;
}
.dialog-buttons {
margin-top: 1rem;
text-align: right;
}
</style>
+17
View File
@@ -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
}
-1
View File
@@ -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[]
+1 -1
View File
@@ -80,7 +80,7 @@ export const useMainStore = defineStore('main', {
authInProgress: false,
cursor: '' as string,
server: {} as Record<string, any> & { public?: boolean, paskia?: boolean },
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied',
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied' | 'tokens',
uprogress: {} as any,
dprogress: {} as any,
prefs: {
+1
View File
@@ -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]
+3 -1
View File
@@ -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
-53
View File
@@ -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()
+207
View File
@@ -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("<I", 1) + struct.pack("<I", 0x20080205)
return {"Authorization": f"NTLM {base64.b64encode(msg).decode()}"}
def _ntlm_type3(username: str, password: str, domain: str, challenge: bytes) -> 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("<Q", 0)
client_nonce = b"\x01" * 8
blob = b"\x01\x01\x00\x00\x00\x00\x00\x00" + timestamp + client_nonce + b"\x00\x00\x00\x00"
# NT proof
nt_proof = hmac.new(ntlmv2_hash, challenge + blob, hashlib.md5).digest()
nt_response = nt_proof + blob
domain_enc = domain.encode("utf-16le")
username_enc = username.encode("utf-16le")
workstation_enc = b""
lm_response = b"" # Empty for NTLMv2
# Build Type 3 message
msg = bytearray()
msg.extend(b"NTLMSSP\x00")
msg.extend(struct.pack("<I", 3))
# Security buffers offsets will be calculated
payload_start = 64
payloads = []
def add_buf(data: bytes):
offset = payload_start + sum(len(p) for p in payloads)
payloads.append(data)
return struct.pack("<HHI", len(data), len(data), offset)
lm_buf = add_buf(lm_response)
nt_buf = add_buf(nt_response)
domain_buf = add_buf(domain_enc)
user_buf = add_buf(username_enc)
ws_buf = add_buf(workstation_enc)
session_buf = add_buf(b"")
msg.extend(lm_buf)
msg.extend(nt_buf)
msg.extend(domain_buf)
msg.extend(user_buf)
msg.extend(ws_buf)
msg.extend(session_buf)
msg.extend(struct.pack("<I", 0x20080205))
for p in payloads:
msg.extend(p)
return {"Authorization": f"NTLM {base64.b64encode(bytes(msg)).decode()}"}
def _session_cookie_header(username: str) -> 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"
+169
View File
@@ -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()
+234
View File
@@ -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()
+100
View File
@@ -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"
+276
View File
@@ -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()
+192
View File
@@ -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:<secret>)
_, 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