Add share token support with virtual filesystem and selection toolbar button

This commit is contained in:
2026-04-26 07:46:14 +00:00
parent 87a92838c2
commit 6242c76be8
11 changed files with 675 additions and 50 deletions
+21 -3
View File
@@ -6,8 +6,9 @@ from sanic import Blueprint, json
from sanic.exceptions import BadRequest from sanic.exceptions import BadRequest
from sanic.log import logger from sanic.log import logger
from cista import __version__, auth, config, sso, watching from cista import __version__, auth, config, sharefs, sso, watching
from cista.auth import ( from cista.auth import (
create_share_token_handler,
create_token_handler, create_token_handler,
delete_token_handler, delete_token_handler,
list_tokens_handler, list_tokens_handler,
@@ -68,15 +69,27 @@ async def watch(req, ws):
).decode() ).decode()
) )
uuid = token_bytes(16) uuid = token_bytes(16)
share_token = auth.request_share_token(req)
try: try:
q, space, root = await asyncio.get_event_loop().run_in_executor( q, space, root = await asyncio.get_event_loop().run_in_executor(
req.app.ctx.threadexec, subscribe, uuid, ws req.app.ctx.threadexec, subscribe, uuid, ws
) )
await ws.send(space) await ws.send(space)
await ws.send(root) if share_token is None:
await ws.send(root)
else:
await ws.send(watching.format_root(sharefs.build_virtual_root(share_token)))
# Send updates # Send updates
while True: while True:
await ws.send(await q.get()) msg = await q.get()
if share_token is None or (
isinstance(msg, str) and msg.startswith('{"space"')
):
await ws.send(msg)
else:
await ws.send(
watching.format_root(sharefs.build_virtual_root(share_token))
)
except RuntimeError as e: except RuntimeError as e:
if str(e) == "cannot schedule new futures after shutdown": if str(e) == "cannot schedule new futures after shutdown":
return # Server shutting down, drop the WebSocket return # Server shutting down, drop the WebSocket
@@ -153,3 +166,8 @@ async def create_api_token(request):
@bp.delete("tokens/<token_id>") @bp.delete("tokens/<token_id>")
async def delete_api_token(request, token_id): async def delete_api_token(request, token_id):
return await delete_token_handler(request, token_id) return await delete_token_handler(request, token_id)
@bp.post("share-tokens")
async def create_share_token(request):
return await create_share_token_handler(request)
+34 -16
View File
@@ -16,7 +16,7 @@ from setproctitle import setproctitle
from stream_zip import ZIP_AUTO, stream_zip from stream_zip import ZIP_AUTO, stream_zip
from zstandard import ZstdCompressor from zstandard import ZstdCompressor
from cista import auth, config, fileserver, preview, session, sso, watching from cista import auth, config, fileserver, preview, session, sharefs, sso, watching
from cista.api import bp from cista.api import bp
from cista.preview import shutdown_preview_workers, start_preview_workers from cista.preview import shutdown_preview_workers, start_preview_workers
from cista.sanic_logging import ( from cista.sanic_logging import (
@@ -240,25 +240,43 @@ async def favicon(req):
return redirect("/assets/logo-ctv8tVwU.svg", status=308) return redirect("/assets/logo-ctv8tVwU.svg", status=308)
def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]: def get_files(req, wanted: set) -> list[tuple[PurePosixPath, Path]]:
loc = PurePosixPath() loc = PurePosixPath()
idx = 0 idx = 0
ret = [] ret = []
level: int | None = None level: int | None = None
parent: PurePosixPath | None = None parent: PurePosixPath | None = None
with watching.state.lock: token = auth.request_share_token(req)
root = watching.state.root
while idx < len(root): if token is None:
f = root[idx] with watching.state.lock:
loc = PurePosixPath(*loc.parts[: f.level - 1]) / f.name root = watching.state.root
if parent is not None and f.level <= level: while idx < len(root):
level = parent = None f = root[idx]
if f.key in wanted: loc = PurePosixPath(*loc.parts[: f.level - 1]) / f.name
level, parent = f.level, loc.parent if parent is not None and f.level <= level:
if parent is not None: level = parent = None
wanted.discard(f.key) if f.key in wanted:
ret.append((loc.relative_to(parent), watching.rootpath / loc)) level, parent = f.level, loc.parent
idx += 1 if parent is not None:
wanted.discard(f.key)
ret.append((loc.relative_to(parent), watching.rootpath / loc))
idx += 1
return ret
root = sharefs.build_virtual_root(token)
while idx < len(root):
f = root[idx]
loc = PurePosixPath(*loc.parts[: f.level - 1]) / f.name
if parent is not None and f.level <= level:
level = parent = None
if f.key in wanted:
level, parent = f.level, loc.parent
if parent is not None:
wanted.discard(f.key)
real_path = sharefs.resolve_virtual_rel_to_real(token, loc)
ret.append((loc.relative_to(parent), real_path))
idx += 1
return ret return ret
@@ -268,7 +286,7 @@ async def zip_download(req, keys, zipfile, ext):
await auth.verify(req) await auth.verify(req)
wanted = set(keys.split("+")) wanted = set(keys.split("+"))
files = get_files(wanted) files = get_files(req, wanted)
if not files: if not files:
raise NotFound( raise NotFound(
+131 -1
View File
@@ -5,6 +5,7 @@ import hmac
import re import re
import secrets import secrets
import struct import struct
from pathlib import PurePosixPath
from time import time from time import time
from unicodedata import normalize from unicodedata import normalize
@@ -15,8 +16,9 @@ from sanic import Blueprint, html, json, redirect
from sanic.exceptions import BadRequest, Forbidden, Unauthorized from sanic.exceptions import BadRequest, Forbidden, Unauthorized
from sanic.log import logger from sanic.log import logger
from cista import config, session from cista import config, session, sharefs
from cista.util import pwgen from cista.util import pwgen
from cista.util.filename import sanitize
_LOGIN_PAGE_CSS = """\ _LOGIN_PAGE_CSS = """\
/* =========================================== /* ===========================================
@@ -611,6 +613,8 @@ def _basic_auth_login(request):
request.ctx.session = None request.ctx.session = None
request.ctx.username = token.username request.ctx.username = token.username
request.ctx.user = user request.ctx.user = user
request.ctx.auth_token_id = password
request.ctx.auth_token = token
user.lastSeen = int(time()) user.lastSeen = int(time())
return user return user
raise Unauthorized("Invalid token", quiet=True) raise Unauthorized("Invalid token", quiet=True)
@@ -654,6 +658,9 @@ async def _token_auth_login(request, *, privileged=False):
if not token: if not token:
return False return False
request.ctx.auth_token_id = password
request.ctx.auth_token = token
sso = _get_sso() sso = _get_sso()
if sso.paskia_enabled() and token.sso_user_id: if sso.paskia_enabled() and token.sso_user_id:
perm = "cista:admin" if privileged else "cista:login" perm = "cista:admin" if privileged else "cista:login"
@@ -823,6 +830,8 @@ async def _ntlm_auth_login(request, *, privileged=False):
try: try:
data = await sso.check_permissions(token.sso_user_id, perm) data = await sso.check_permissions(token.sso_user_id, perm)
request.ctx.sso_user = data request.ctx.sso_user = data
request.ctx.auth_token_id = tid
request.ctx.auth_token = token
ctx = data.get("ctx", {}) if isinstance(data, dict) else {} ctx = data.get("ctx", {}) if isinstance(data, dict) else {}
user_info = ctx.get("user", {}) if isinstance(ctx, dict) else {} user_info = ctx.get("user", {}) if isinstance(ctx, dict) else {}
request.ctx.username = user_info.get("display_name", "") request.ctx.username = user_info.get("display_name", "")
@@ -854,6 +863,8 @@ async def _ntlm_auth_login(request, *, privileged=False):
request.ctx.session = None request.ctx.session = None
request.ctx.username = token.username request.ctx.username = token.username
request.ctx.user = user request.ctx.user = user
request.ctx.auth_token_id = tid
request.ctx.auth_token = token
request.ctx._create_session_username = token.username request.ctx._create_session_username = token.username
logger.debug( logger.debug(
"NTLM auth success for local user %s (token=%s...)", "NTLM auth success for local user %s (token=%s...)",
@@ -1294,6 +1305,26 @@ def _token_belongs_to_user(token, username, sso_user_id):
return bool(sso_user_id is not None and token.sso_user_id == sso_user_id) return bool(sso_user_id is not None and token.sso_user_id == sso_user_id)
def request_token(request) -> config.Token | None:
token = getattr(request.ctx, "auth_token", None)
return token if isinstance(token, config.Token) else None
def request_share_token(request) -> config.Token | None:
token = request_token(request)
if token is None:
return None
return token if sharefs.is_share_token(token) else None
def ensure_write_allowed(request) -> None:
token = request_share_token(request)
if token is None:
return
if token.mode != "rw":
raise Forbidden("Share token is read-only", quiet=True)
# Token management handlers (shared between /auth and /api blueprints) # Token management handlers (shared between /auth and /api blueprints)
@@ -1310,6 +1341,8 @@ async def list_tokens_handler(request):
"sso_user_id": t.sso_user_id, "sso_user_id": t.sso_user_id,
"name": t.name, "name": t.name,
"created": t.created, "created": t.created,
"kind": t.kind,
"mode": t.mode,
} }
) )
return json({"tokens": tokens}) return json({"tokens": tokens})
@@ -1358,6 +1391,9 @@ async def create_token_handler(request):
"sso_user_id": sso_user_id or "", "sso_user_id": sso_user_id or "",
"name": name, "name": name,
"created": int(time()), "created": int(time()),
"kind": "api",
"mode": "rw",
"share_paths": [],
} }
config.update_token(token, changes) config.update_token(token, changes)
scheme = request.scheme scheme = request.scheme
@@ -1371,6 +1407,100 @@ async def create_token_handler(request):
"username": username or "", "username": username or "",
"sso_user_id": sso_user_id or "", "sso_user_id": sso_user_id or "",
"name": name, "name": name,
"kind": "api",
"mode": "rw",
}
)
async def create_share_token_handler(request):
await verify(request)
current_username, current_sso_user_id = _current_user_id(request)
try:
if request.headers.content_type == "application/json":
paths = request.json.get("paths")
mode = request.json.get("mode", "ro")
name = request.json.get("name", "")
else:
paths = request.form.get("paths", [])
mode = request.form.get("mode", ["ro"])[0]
name = request.form.get("name", [""])[0]
except (KeyError, IndexError):
raise BadRequest("Missing fields") from None
if not isinstance(paths, list) or not paths:
raise BadRequest("paths must be a non-empty array")
if mode not in ("ro", "rw"):
raise BadRequest("mode must be ro or rw")
clean_paths: list[str] = []
seen: set[str] = set()
base = config.config.path.resolve()
for raw_path in paths:
if not isinstance(raw_path, str):
raise BadRequest("paths must contain strings")
try:
clean = sanitize(raw_path)
except ValueError as e:
raise BadRequest(f"Invalid path: {e}") from e
if not clean:
continue
rel = PurePosixPath(clean)
resolved = (base / rel).resolve()
if not resolved.is_relative_to(base):
raise BadRequest("Invalid path")
if not resolved.exists():
raise BadRequest(f"Path does not exist: {clean}")
key = rel.as_posix()
if key in seen:
continue
seen.add(key)
clean_paths.append(key)
if not clean_paths:
raise BadRequest("No valid paths selected")
sso = _get_sso()
username = ""
sso_user_id = ""
if sso.paskia_enabled():
sso_user_id = current_sso_user_id or ""
if not sso_user_id:
raise BadRequest("Could not determine SSO user")
else:
username = current_username or ""
if not username:
raise BadRequest("Could not determine user")
if username not in config.config.users:
raise BadRequest("User does not exist")
token = secrets.token_urlsafe(12)
changes = {
"key": token,
"username": username,
"sso_user_id": sso_user_id,
"name": name,
"created": int(time()),
"kind": "share",
"mode": mode,
"share_paths": clean_paths,
}
config.update_token(token, changes)
scheme = request.scheme
host = request.host or "localhost"
share_url = f"{scheme}://token:{token}@{host}/#/"
return json(
{
"id": token,
"key": token,
"url": share_url,
"username": username,
"sso_user_id": sso_user_id,
"name": name,
"kind": "share",
"mode": mode,
"paths": clean_paths,
} }
) )
+3
View File
@@ -51,6 +51,9 @@ class Token(msgspec.Struct, omit_defaults=True):
sso_user_id: str = "" # set in SSO mode sso_user_id: str = "" # set in SSO mode
name: str = "" name: str = ""
created: int = 0 created: int = 0
kind: str = "api" # api | share
mode: str = "rw" # ro | rw
share_paths: list[str] = []
# Global variables - initialized during application startup # Global variables - initialized during application startup
+100 -26
View File
@@ -14,7 +14,7 @@ from wsgiref.handlers import format_date_time
from sanic import Blueprint, HTTPResponse, empty, json from sanic import Blueprint, HTTPResponse, empty, json
from sanic.exceptions import BadRequest, NotFound from sanic.exceptions import BadRequest, NotFound
from cista import auth, config, watching from cista import auth, config, sharefs, watching
from cista.api import fileserver from cista.api import fileserver
from cista.util import filename from cista.util import filename
@@ -40,6 +40,7 @@ async def verify_fileserver(request):
@bp.put("/<name:path>") @bp.put("/<name:path>")
async def upload_file_chunk(request, name): async def upload_file_chunk(request, name):
auth.ensure_write_allowed(request)
body = request.body body = request.body
header = request.headers.get("content-range") header = request.headers.get("content-range")
if header: if header:
@@ -49,7 +50,7 @@ async def upload_file_chunk(request, name):
end = len(body) end = len(body)
total = end total = end
rel, _ = _safe_relpath(name) rel, path = _safe_relpath(name, request=request)
rel_name = rel.as_posix() rel_name = rel.as_posix()
upload_info = await asyncio.to_thread( upload_info = await asyncio.to_thread(
fileserver.upload_info, fileserver.upload_info,
@@ -76,7 +77,8 @@ async def upload_file_chunk(request, name):
if size_before is not None and size_after is not None and size_before != size_after: if size_before is not None and size_after is not None and size_before != size_after:
extras.append("resized") extras.append("resized")
request.ctx._log_extra = " ".join(extras) if extras else None request.ctx._log_extra = " ".join(extras) if extras else None
watching.notify_change(rel, *rel.parents) real_rel = PurePosixPath(path.relative_to(config.config.path.resolve()).as_posix())
watching.notify_change(real_rel, *real_rel.parents)
return json( return json(
{ {
"status": "ack", "status": "ack",
@@ -92,7 +94,8 @@ async def upload_file_chunk(request, name):
@bp.delete("/<name:path>") @bp.delete("/<name:path>")
async def delete_file(request, name): async def delete_file(request, name):
rel, path = _safe_relpath(name) auth.ensure_write_allowed(request)
rel, path = _safe_relpath(name, request=request)
if not rel.parts: if not rel.parts:
raise BadRequest("Refusing to delete root folder") raise BadRequest("Refusing to delete root folder")
@@ -105,23 +108,27 @@ async def delete_file(request, name):
path.unlink() path.unlink()
await asyncio.to_thread(_delete) await asyncio.to_thread(_delete)
watching.notify_change(rel, *rel.parents) real_rel = PurePosixPath(path.relative_to(config.config.path.resolve()).as_posix())
watching.notify_change(real_rel, *real_rel.parents)
return empty(status=204) return empty(status=204)
@bp.route("/<name:path>", methods=["MKCOL"]) @bp.route("/<name:path>", methods=["MKCOL"])
async def create_folder(request, name): async def create_folder(request, name):
rel, path = _safe_relpath(name) auth.ensure_write_allowed(request)
rel, path = _safe_relpath(name, request=request)
if not rel.parts: if not rel.parts:
raise BadRequest("Refusing to create root folder") raise BadRequest("Refusing to create root folder")
await asyncio.to_thread(path.mkdir, parents=True, exist_ok=False) await asyncio.to_thread(path.mkdir, parents=True, exist_ok=False)
watching.notify_change(rel, *rel.parents) real_rel = PurePosixPath(path.relative_to(config.config.path.resolve()).as_posix())
watching.notify_change(real_rel, *real_rel.parents)
return empty(status=201) return empty(status=201)
@bp.post("/", name="post_root", strict_slashes=False) @bp.post("/", name="post_root", strict_slashes=False)
@bp.post("/<name:path>", name="post_path") @bp.post("/<name:path>", name="post_path")
async def copy_or_move(request, name=""): async def copy_or_move(request, name=""):
auth.ensure_write_allowed(request)
provided_args = set(request.args.keys()) provided_args = set(request.args.keys())
if not provided_args: if not provided_args:
raise BadRequest("No query arguments passed") raise BadRequest("No query arguments passed")
@@ -145,13 +152,13 @@ async def copy_or_move(request, name=""):
if not mv_keys and not cp_keys: if not mv_keys and not cp_keys:
raise BadRequest("No keys given") raise BadRequest("No keys given")
dst_rel, dst_abs = _safe_relpath(name) dst_rel, dst_abs = _safe_relpath(name, request=request)
dst_exists = dst_abs.exists() dst_exists = dst_abs.exists()
dst_is_dir = dst_exists and dst_abs.is_dir() dst_is_dir = dst_exists and dst_abs.is_dir()
ordered_keys = cp_keys + mv_keys ordered_keys = cp_keys + mv_keys
key_paths = _get_key_paths(set(ordered_keys)) key_paths = _get_key_paths(request, set(ordered_keys))
missing = [key for key in ordered_keys if key not in key_paths] missing = [key for key in ordered_keys if key not in key_paths]
if missing: if missing:
raise NotFound("Files not found", context={"missing": missing}) raise NotFound("Files not found", context={"missing": missing})
@@ -194,7 +201,7 @@ async def copy_or_move(request, name=""):
for key in op_keys: for key in op_keys:
try: try:
src_rel = key_paths[key] src_rel = key_paths[key]
src_abs = _resolve_from_relpath(src_rel) src_abs = _resolve_from_relpath(src_rel, request=request)
if op_multi: if op_multi:
if not dst_is_dir: if not dst_is_dir:
@@ -224,7 +231,7 @@ async def copy_or_move(request, name=""):
) )
dst_item_rel = dst_rel dst_item_rel = dst_rel
dst_item_abs = _resolve_from_relpath(dst_item_rel) dst_item_abs = _resolve_from_relpath(dst_item_rel, request=request)
if op_name == "mv": if op_name == "mv":
# A no-op rename should still return success. # A no-op rename should still return success.
@@ -263,7 +270,15 @@ async def copy_or_move(request, name=""):
notify_paths = [p for p in changed if p.parts] notify_paths = [p for p in changed if p.parts]
if notify_paths: if notify_paths:
watching.notify_change(*notify_paths) real_notify_paths: list[PurePosixPath] = []
for p in notify_paths:
real_abs = _resolve_from_relpath(p, request=request)
real_notify_paths.append(
PurePosixPath(
real_abs.relative_to(config.config.path.resolve()).as_posix()
)
)
watching.notify_change(*real_notify_paths)
return json( return json(
{ {
@@ -299,7 +314,29 @@ async def dav_options(request, name=""):
@bp.route("/", methods=["PROPFIND"], name="propfind_root", strict_slashes=False) @bp.route("/", methods=["PROPFIND"], name="propfind_root", strict_slashes=False)
@bp.route("/<name:path>", methods=["PROPFIND"], name="propfind_path") @bp.route("/<name:path>", methods=["PROPFIND"], name="propfind_path")
async def dav_propfind(request, name=""): async def dav_propfind(request, name=""):
rel, path = _safe_relpath(name) rel, path = _safe_relpath(name, request=request)
token = auth.request_share_token(request)
if token is not None and not rel.parts:
base = config.config.path.resolve()
entries = [_propfind_entry(PurePosixPath(), base)]
depth = request.headers.get("depth", "1").strip()
if depth == "infinity":
return HTTPResponse(status=403)
if depth == "1":
for root in sharefs.build_share_roots(token):
child_abs = (base / root.real_rel).resolve()
if not child_abs.exists() or not child_abs.is_relative_to(base):
continue
with contextlib.suppress(OSError):
entries.append(
_propfind_entry(PurePosixPath(root.alias), child_abs)
)
return HTTPResponse(
body=_build_propfind_xml(entries),
status=207,
content_type='application/xml; charset="utf-8"',
)
if not path.exists(): if not path.exists():
raise NotFound(f"Not found: {name}") raise NotFound(f"Not found: {name}")
depth = request.headers.get("depth", "1").strip() depth = request.headers.get("depth", "1").strip()
@@ -316,12 +353,15 @@ async def dav_propfind(request, name=""):
@bp.route("/", methods=["COPY"], name="copy_root", strict_slashes=False) @bp.route("/", methods=["COPY"], name="copy_root", strict_slashes=False)
@bp.route("/<name:path>", methods=["COPY"], name="copy_path") @bp.route("/<name:path>", methods=["COPY"], name="copy_path")
async def dav_copy(request, name=""): async def dav_copy(request, name=""):
auth.ensure_write_allowed(request)
dest_header = request.headers.get("destination") dest_header = request.headers.get("destination")
if not dest_header: if not dest_header:
raise BadRequest("Missing Destination header") raise BadRequest("Missing Destination header")
overwrite = request.headers.get("overwrite", "T").strip().upper() != "F" overwrite = request.headers.get("overwrite", "T").strip().upper() != "F"
_src_rel, src_abs = _safe_relpath(name) _src_rel, src_abs = _safe_relpath(name, request=request)
dst_rel, dst_abs = _parse_webdav_destination(dest_header) dst_rel, dst_abs = _parse_webdav_destination(dest_header, request=request)
if auth.request_share_token(request) is not None and not dst_rel.parts:
raise BadRequest("Destination cannot be virtual root")
request.ctx._log_extra = f"{dst_rel}" request.ctx._log_extra = f"{dst_rel}"
if not src_abs.exists(): if not src_abs.exists():
raise NotFound(f"Source not found: {name}") raise NotFound(f"Source not found: {name}")
@@ -342,19 +382,25 @@ async def dav_copy(request, name=""):
shutil.copy2(src_abs, dst_abs) shutil.copy2(src_abs, dst_abs)
await asyncio.to_thread(_do_copy) await asyncio.to_thread(_do_copy)
watching.notify_change(dst_rel, *dst_rel.parents) real_dst_rel = PurePosixPath(
dst_abs.relative_to(config.config.path.resolve()).as_posix()
)
watching.notify_change(real_dst_rel, *real_dst_rel.parents)
return HTTPResponse(status=201 if not dst_existed else 204) return HTTPResponse(status=201 if not dst_existed else 204)
@bp.route("/", methods=["MOVE"], name="move_root", strict_slashes=False) @bp.route("/", methods=["MOVE"], name="move_root", strict_slashes=False)
@bp.route("/<name:path>", methods=["MOVE"], name="move_path") @bp.route("/<name:path>", methods=["MOVE"], name="move_path")
async def dav_move(request, name=""): async def dav_move(request, name=""):
auth.ensure_write_allowed(request)
dest_header = request.headers.get("destination") dest_header = request.headers.get("destination")
if not dest_header: if not dest_header:
raise BadRequest("Missing Destination header") raise BadRequest("Missing Destination header")
overwrite = request.headers.get("overwrite", "T").strip().upper() != "F" overwrite = request.headers.get("overwrite", "T").strip().upper() != "F"
src_rel, src_abs = _safe_relpath(name) _src_rel, src_abs = _safe_relpath(name, request=request)
dst_rel, dst_abs = _parse_webdav_destination(dest_header) dst_rel, dst_abs = _parse_webdav_destination(dest_header, request=request)
if auth.request_share_token(request) is not None and not dst_rel.parts:
raise BadRequest("Destination cannot be virtual root")
request.ctx._log_extra = f"{dst_rel}" request.ctx._log_extra = f"{dst_rel}"
if not src_abs.exists(): if not src_abs.exists():
raise NotFound(f"Source not found: {name}") raise NotFound(f"Source not found: {name}")
@@ -372,7 +418,15 @@ async def dav_move(request, name=""):
shutil.move(src_abs, dst_abs) shutil.move(src_abs, dst_abs)
await asyncio.to_thread(_do_move) await asyncio.to_thread(_do_move)
watching.notify_change(src_rel, *src_rel.parents, dst_rel, *dst_rel.parents) real_src_rel = PurePosixPath(
src_abs.relative_to(config.config.path.resolve()).as_posix()
)
real_dst_rel = PurePosixPath(
dst_abs.relative_to(config.config.path.resolve()).as_posix()
)
watching.notify_change(
real_src_rel, *real_src_rel.parents, real_dst_rel, *real_dst_rel.parents
)
return HTTPResponse(status=201 if not dst_existed else 204) return HTTPResponse(status=201 if not dst_existed else 204)
@@ -399,8 +453,15 @@ def _to_mib_int(value_bytes: int) -> int:
return round(value_bytes / (1 << 20)) return round(value_bytes / (1 << 20))
def _safe_relpath(path: str) -> tuple[PurePosixPath, Path]: def _safe_relpath(path: str, *, request=None) -> tuple[PurePosixPath, Path]:
"""Resolve a user path under storage root and enforce containment.""" """Resolve a user path under storage root and enforce containment."""
token = auth.request_share_token(request) if request is not None else None
if token is not None:
vrel, _rrel, resolved, is_root = sharefs.resolve_virtual_path(token, path)
if is_root:
return vrel, config.config.path.resolve()
return vrel, resolved
base = config.config.path.resolve() base = config.config.path.resolve()
try: try:
sanitized = filename.sanitize(unquote(path)) sanitized = filename.sanitize(unquote(path))
@@ -413,8 +474,12 @@ def _safe_relpath(path: str) -> tuple[PurePosixPath, Path]:
return rel, resolved return rel, resolved
def _resolve_from_relpath(rel: PurePosixPath) -> Path: def _resolve_from_relpath(rel: PurePosixPath, *, request=None) -> Path:
"""Resolve a relative path under storage root and enforce containment.""" """Resolve a relative path under storage root and enforce containment."""
token = auth.request_share_token(request) if request is not None else None
if token is not None:
return sharefs.resolve_virtual_rel_to_real(token, rel)
base = config.config.path.resolve() base = config.config.path.resolve()
resolved = (base / rel).resolve() resolved = (base / rel).resolve()
if not resolved.is_relative_to(base): if not resolved.is_relative_to(base):
@@ -423,9 +488,12 @@ def _resolve_from_relpath(rel: PurePosixPath) -> Path:
async def _send_static_file(request, name: str, *, head_only: bool): async def _send_static_file(request, name: str, *, head_only: bool):
_, path = _safe_relpath(name) _, path = _safe_relpath(name, request=request)
st = await asyncio.to_thread(path.stat) try:
st = await asyncio.to_thread(path.stat)
except FileNotFoundError:
raise NotFound(f"File not found: {name}") from None
if path.is_dir(): if path.is_dir():
raise NotFound(f"Not a file: {name}") raise NotFound(f"Not a file: {name}")
@@ -513,8 +581,12 @@ def _parse_range_header(header: str, size: int) -> tuple[int, int] | None:
return start, size return start, size
def _get_key_paths(wanted: set[str]) -> dict[str, PurePosixPath]: def _get_key_paths(request, wanted: set[str]) -> dict[str, PurePosixPath]:
"""Map file keys to their current relative filesystem paths.""" """Map file keys to their current relative filesystem paths."""
token = auth.request_share_token(request)
if token is not None:
return sharefs.key_paths_for_token(token, wanted)
loc = PurePosixPath() loc = PurePosixPath()
ret: dict[str, PurePosixPath] = {} ret: dict[str, PurePosixPath] = {}
with watching.state.lock: with watching.state.lock:
@@ -533,7 +605,9 @@ def _get_key_paths(wanted: set[str]) -> dict[str, PurePosixPath]:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _parse_webdav_destination(dest_header: str) -> tuple[PurePosixPath, Path]: def _parse_webdav_destination(
dest_header: str, *, request=None
) -> tuple[PurePosixPath, Path]:
"""Parse a WebDAV Destination header and resolve it to a storage path.""" """Parse a WebDAV Destination header and resolve it to a storage path."""
parsed = urlparse(dest_header) parsed = urlparse(dest_header)
raw_path = parsed.path # still percent-encoded raw_path = parsed.path # still percent-encoded
@@ -544,7 +618,7 @@ def _parse_webdav_destination(dest_header: str) -> tuple[PurePosixPath, Path]:
rel_str = raw_path[len(prefix) + 1 :] rel_str = raw_path[len(prefix) + 1 :]
else: else:
raise BadRequest("Destination must be within /files") raise BadRequest("Destination must be within /files")
return _safe_relpath(rel_str) return _safe_relpath(rel_str, request=request)
def _rel_to_href(rel: PurePosixPath, *, is_dir: bool) -> str: def _rel_to_href(rel: PurePosixPath, *, is_dir: bool) -> str:
+11 -3
View File
@@ -25,7 +25,7 @@ from sanic import Blueprint, empty, raw, redirect
from sanic.exceptions import NotFound from sanic.exceptions import NotFound
from sanic.log import logger from sanic.log import logger
from cista import auth, config from cista import auth, config, sharefs
from cista.preview_worker import PreviewRequest, PreviewResponse from cista.preview_worker import PreviewRequest, PreviewResponse
from cista.util.filename import sanitize from cista.util.filename import sanitize
@@ -388,8 +388,16 @@ async def preview(req, path):
maxsize = int(req.args.get("px", 1024)) maxsize = int(req.args.get("px", 1024))
maxzoom = float(req.args.get("zoom", 2.0)) maxzoom = float(req.args.get("zoom", 2.0))
quality = int(req.args.get("q", 60)) quality = int(req.args.get("q", 60))
rel = PurePosixPath(sanitize(unquote(path))) share_token = auth.request_share_token(req)
filepath = config.config.path / rel if share_token is not None:
rel, _real_rel, filepath, is_root = sharefs.resolve_virtual_path(
share_token, path
)
if is_root:
raise NotFound from None
else:
rel = PurePosixPath(sanitize(unquote(path)))
filepath = config.config.path / rel
try: try:
stat = filepath.lstat() stat = filepath.lstat()
except FileNotFoundError: except FileNotFoundError:
+230
View File
@@ -0,0 +1,230 @@
from __future__ import annotations
from pathlib import Path, PurePosixPath
from stat import S_ISDIR, S_ISREG
from time import time
from typing import NamedTuple
from natsort import humansorted
from sanic.exceptions import BadRequest, NotFound
from cista import config, watching
from cista.fileio import fuid
from cista.protocol import FileEntry
from cista.util.filename import sanitize
class ShareRootEntry(NamedTuple):
alias: str
real_rel: PurePosixPath
def _token_is_share(token: config.Token) -> bool:
return token.kind == "share" and bool(token.share_paths)
def is_share_token(token: config.Token | None) -> bool:
return bool(token and _token_is_share(token))
def build_share_roots(token: config.Token) -> list[ShareRootEntry]:
if not _token_is_share(token):
return []
base = config.config.path.resolve()
roots: list[ShareRootEntry] = []
used_aliases: set[str] = set()
for raw_path in token.share_paths:
try:
clean = sanitize(raw_path)
except ValueError:
continue
if not clean:
continue
rel = PurePosixPath(clean)
resolved = (base / rel).resolve()
if not resolved.is_relative_to(base) or not resolved.exists():
continue
display = rel.name or config.config.path.name
alias = display
suffix = 2
while alias in used_aliases:
alias = f"{display} ({suffix})"
suffix += 1
used_aliases.add(alias)
roots.append(ShareRootEntry(alias=alias, real_rel=rel))
return roots
def resolve_virtual_path(
token: config.Token,
raw_path: str,
) -> tuple[PurePosixPath, PurePosixPath, Path, bool]:
"""Resolve a share-virtual path to real path.
Returns (virtual_rel, real_rel, real_abs, is_virtual_root).
"""
base = config.config.path.resolve()
if raw_path.strip("/") == "":
return PurePosixPath(), PurePosixPath(), base, True
try:
clean = sanitize(raw_path)
except ValueError as e:
raise BadRequest(f"Invalid path: {e}") from e
if not clean:
return PurePosixPath(), PurePosixPath(), base, True
virtual_rel = PurePosixPath(clean)
roots = build_share_roots(token)
if not roots:
raise NotFound("Share token has no visible files")
root_by_alias = {r.alias: r.real_rel for r in roots}
first = virtual_rel.parts[0]
real_root = root_by_alias.get(first)
if real_root is None:
raise NotFound(f"Not found: {raw_path}")
rest = virtual_rel.parts[1:]
real_rel = real_root.joinpath(*rest) if rest else real_root
resolved = (base / real_rel).resolve()
if not resolved.is_relative_to(base):
raise BadRequest("Invalid path")
return virtual_rel, real_rel, resolved, False
def real_to_virtual_aliases(token: config.Token) -> dict[PurePosixPath, str]:
return {entry.real_rel: entry.alias for entry in build_share_roots(token)}
def _walk_virtual_entry(path: Path, name: str, level: int) -> list[FileEntry]:
st = path.lstat()
is_dir = S_ISDIR(st.st_mode)
is_file = S_ISREG(st.st_mode)
if not is_dir and not is_file:
return []
if is_file:
try:
allocated = watching.get_allocated_size(path, st)
except Exception:
allocated = st.st_size
return [
FileEntry(
level=level,
name=name,
key=fuid(st),
mtime=int(st.st_mtime),
size=st.st_size,
allocated=allocated,
isfile=1,
)
]
children: list[tuple[int, str, object]] = []
for child in path.iterdir():
if child.name.startswith("."):
continue
try:
cst = child.lstat()
except FileNotFoundError:
continue
c_is_file = S_ISREG(cst.st_mode)
c_is_dir = S_ISDIR(cst.st_mode)
if not c_is_file and not c_is_dir:
continue
children.append((int(c_is_file), child.name, cst))
entries: list[FileEntry] = []
agg_mtime = int(st.st_mtime)
agg_size = 0
agg_alloc = 0
for _, child_name, _ in humansorted(children):
child_path = path / child_name
child_entries = _walk_virtual_entry(child_path, child_name, level + 1)
if not child_entries:
continue
head = child_entries[0]
agg_mtime = max(agg_mtime, head.mtime)
agg_size += head.size
agg_alloc += head.allocated
entries.extend(child_entries)
head = FileEntry(
level=level,
name=name,
key=fuid(st),
mtime=agg_mtime,
size=agg_size,
allocated=agg_alloc,
isfile=0,
)
return [head, *entries]
def build_virtual_root(token: config.Token) -> list[FileEntry]:
roots = build_share_roots(token)
now = int(time())
root_key = config.derived_secret("share-root", token.key or "", token.created).hex()
entries: list[FileEntry] = []
total_size = 0
total_alloc = 0
root_mtime = 0
base = config.config.path.resolve()
for entry in roots:
real_abs = (base / entry.real_rel).resolve()
if not real_abs.is_relative_to(base) or not real_abs.exists():
continue
try:
subtree = _walk_virtual_entry(real_abs, entry.alias, 1)
except OSError:
continue
if not subtree:
continue
head = subtree[0]
total_size += head.size
total_alloc += head.allocated
root_mtime = max(root_mtime, head.mtime)
entries.extend(subtree)
root = FileEntry(
level=0,
name="",
key=root_key,
mtime=root_mtime or now,
size=total_size,
allocated=total_alloc,
isfile=0,
)
return [root, *entries]
def key_paths_for_token(
token: config.Token, wanted: set[str]
) -> dict[str, PurePosixPath]:
ret: dict[str, PurePosixPath] = {}
loc = PurePosixPath()
root = build_virtual_root(token)
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
def resolve_virtual_rel_to_real(token: config.Token, rel: PurePosixPath) -> Path:
_vrel, _rrel, real_abs, is_root = resolve_virtual_path(token, rel.as_posix())
if is_root:
raise BadRequest("Virtual root is not a writable filesystem path")
return real_abs
@@ -15,6 +15,11 @@
</div> </div>
<span class="select-size">{{ selectionDisplay.size }}</span> <span class="select-size">{{ selectionDisplay.size }}</span>
<DownloadButton /> <DownloadButton />
<button
class="action-button"
title="Copy share link (Alt-click for read/write)"
@click="copyShareLink"
>share</button>
<SvgButton name="copy" tooltip="Copy here" @click="op('cp', dst)" /> <SvgButton name="copy" tooltip="Copy here" @click="op('cp', dst)" />
<SvgButton name="paste" tooltip="Move here" @click="op('mv', dst)" /> <SvgButton name="paste" tooltip="Move here" @click="op('mv', dst)" />
<SvgButton name="trash" tooltip="Delete ⚠️" @click="op('rm')" /> <SvgButton name="trash" tooltip="Delete ⚠️" @click="op('rm')" />
@@ -30,6 +35,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { apiFetch } from '@/repositories/Client' import { apiFetch } from '@/repositories/Client'
import type { ISimpleError } from '@/repositories/Client'
import { createShareToken } from '@/repositories/User'
import router from '@/router' import router from '@/router'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { formatSize } from '@/utils' import { formatSize } from '@/utils'
@@ -170,6 +177,35 @@ const op = async (opName: string, dst?: string) => {
} }
} }
} }
const copyShareLink = async (ev: MouseEvent) => {
const mode: 'ro' | 'rw' = ev.altKey ? 'rw' : 'ro'
const sel = store.selectedFiles
const paths = sel.keys
.map(key => {
const doc = sel.docs[key]
if (!doc) return ''
if (doc.loc === '/' || !doc.loc) return doc.name
return `${doc.loc}/${doc.name}`
})
.filter(Boolean)
if (!paths.length) {
store.showToast('No selected files')
return
}
try {
const token = await createShareToken(paths, mode)
await navigator.clipboard.writeText(token.url)
store.showToast(
mode === 'rw' ? 'Copied read/write share link' : 'Copied share link'
)
} catch (e) {
const httpError = e as ISimpleError
store.showToast(httpError.message || 'Failed to create share link')
}
}
</script> </script>
<style> <style>
+11
View File
@@ -93,3 +93,14 @@ export async function deleteToken(tokenId: string) {
const data = await Client.delete(`${url_tokens}/${tokenId}`) const data = await Client.delete(`${url_tokens}/${tokenId}`)
return data return data
} }
export async function createShareToken(paths: string[], mode: 'ro' | 'rw' = 'ro') {
const data = await Client.post('/api/share-tokens', { paths, mode })
return data as {
id: string
key: string
url: string
mode: 'ro' | 'rw'
paths: string[]
}
}
+69 -1
View File
@@ -106,16 +106,38 @@ def setup_storage(tmp_path: Path):
user = config.User() user = config.User()
auth.set_password(user, "secret") auth.set_password(user, "secret")
token = config.Token(key="test_token_123", username="alice") token = config.Token(key="test_token_123", username="alice")
share_ro = config.Token(
key="share_ro_123",
username="alice",
kind="share",
mode="ro",
share_paths=["hello.txt", "docs"],
)
share_rw = config.Token(
key="share_rw_123",
username="alice",
kind="share",
mode="rw",
share_paths=["docs"],
)
config.config = config.Config( config.config = config.Config(
path=tmp_path, path=tmp_path,
listen=":0", listen=":0",
public=False, public=False,
users={"alice": user}, users={"alice": user},
tokens={"test_token_123": token}, tokens={
"test_token_123": token,
"share_ro_123": share_ro,
"share_rw_123": share_rw,
},
) )
watching.state.root = [] watching.state.root = []
watching.rootpath = tmp_path watching.rootpath = tmp_path
(tmp_path / "hello.txt").write_text("hello", encoding="utf-8") (tmp_path / "hello.txt").write_text("hello", encoding="utf-8")
(tmp_path / "secret.txt").write_text("secret", encoding="utf-8")
(tmp_path / "docs").mkdir()
(tmp_path / "docs" / "a.txt").write_text("A", encoding="utf-8")
(tmp_path / "docs" / "b.txt").write_text("B", encoding="utf-8")
yield tmp_path yield tmp_path
watching.state.root = [] watching.state.root = []
@@ -222,3 +244,49 @@ async def test_ntlm_auth_with_token(client):
) )
assert res3.status_code == 200 assert res3.status_code == 200
assert res3.body == b"hello" assert res3.body == b"hello"
@pytest.mark.asyncio
async def test_share_token_limits_visible_paths(client):
_, res = await client.get(
"/files/docs/a.txt", headers=_basic_auth("token", "share_ro_123")
)
assert res.status_code == 200
assert res.body == b"A"
_, res = await client.get(
"/files/hello.txt", headers=_basic_auth("token", "share_ro_123")
)
assert res.status_code == 200
assert res.body == b"hello"
_, res = await client.get(
"/files/secret.txt", headers=_basic_auth("token", "share_ro_123")
)
assert res.status_code == 404
@pytest.mark.asyncio
async def test_share_token_read_only_blocks_writes(client):
_, res = await client.delete(
"/files/hello.txt", headers=_basic_auth("token", "share_ro_123")
)
assert res.status_code == 403
@pytest.mark.asyncio
async def test_share_token_rw_allows_writes_in_scope_only(client):
_, res = await client.delete(
"/files/docs/a.txt", headers=_basic_auth("token", "share_rw_123")
)
assert res.status_code == 204
_, res = await client.get(
"/files/docs/a.txt", headers=_basic_auth("token", "share_rw_123")
)
assert res.status_code == 404
_, res = await client.delete(
"/files/secret.txt", headers=_basic_auth("token", "share_rw_123")
)
assert res.status_code == 404
+29
View File
@@ -7,6 +7,7 @@ import pytest_asyncio
from sanic import Sanic from sanic import Sanic
from cista import auth, config, watching from cista import auth, config, watching
from cista.api import bp as api_bp
from cista.auth import bp as auth_bp from cista.auth import bp as auth_bp
@@ -41,6 +42,9 @@ def setup_storage(tmp_path: Path):
_persist_config() _persist_config()
watching.state.root = [] watching.state.root = []
watching.rootpath = tmp_path watching.rootpath = tmp_path
(tmp_path / "hello.txt").write_text("hello", encoding="utf-8")
(tmp_path / "docs").mkdir()
(tmp_path / "docs" / "a.txt").write_text("A", encoding="utf-8")
yield tmp_path yield tmp_path
watching.state.root = [] watching.state.root = []
@@ -56,6 +60,7 @@ async def client(setup_storage: Path):
"PROPFIND", "PROPFIND",
) )
app.blueprint(auth_bp) app.blueprint(auth_bp)
app.blueprint(api_bp)
yield app.asgi_client yield app.asgi_client
@@ -189,3 +194,27 @@ async def test_token_user_scoped(client):
# Actually the token key lookup will fail, and since there's no session fallback... # Actually the token key lookup will fail, and since there's no session fallback...
# With auth header present but invalid, it should return 401 # With auth header present but invalid, it should return 401
assert res.status_code == 401 assert res.status_code == 401
@pytest.mark.asyncio
async def test_create_share_token(client):
_, res = await client.post(
"/api/share-tokens",
json={"paths": ["hello.txt", "docs"], "mode": "ro", "name": "selection"},
headers={"Authorization": _basic_auth("alice", "secret")},
)
assert res.status_code == 200
data = res.json
assert data["kind"] == "share"
assert data["mode"] == "ro"
assert data["paths"] == ["hello.txt", "docs"]
assert "token:" in data["url"]
_, res = await client.get(
"/auth/tokens",
headers={"Authorization": _basic_auth("alice", "secret")},
)
assert res.status_code == 200
share_tokens = [t for t in res.json["tokens"] if t.get("kind") == "share"]
assert len(share_tokens) == 1
assert share_tokens[0]["mode"] == "ro"