Add share token support with virtual filesystem and selection toolbar button
This commit is contained in:
+21
-3
@@ -6,8 +6,9 @@ from sanic import Blueprint, json
|
||||
from sanic.exceptions import BadRequest
|
||||
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 (
|
||||
create_share_token_handler,
|
||||
create_token_handler,
|
||||
delete_token_handler,
|
||||
list_tokens_handler,
|
||||
@@ -68,15 +69,27 @@ async def watch(req, ws):
|
||||
).decode()
|
||||
)
|
||||
uuid = token_bytes(16)
|
||||
share_token = auth.request_share_token(req)
|
||||
try:
|
||||
q, space, root = await asyncio.get_event_loop().run_in_executor(
|
||||
req.app.ctx.threadexec, subscribe, uuid, ws
|
||||
)
|
||||
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
|
||||
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:
|
||||
if str(e) == "cannot schedule new futures after shutdown":
|
||||
return # Server shutting down, drop the WebSocket
|
||||
@@ -153,3 +166,8 @@ async def create_api_token(request):
|
||||
@bp.delete("tokens/<token_id>")
|
||||
async def delete_api_token(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
@@ -16,7 +16,7 @@ from setproctitle import setproctitle
|
||||
from stream_zip import ZIP_AUTO, stream_zip
|
||||
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.preview import shutdown_preview_workers, start_preview_workers
|
||||
from cista.sanic_logging import (
|
||||
@@ -240,25 +240,43 @@ async def favicon(req):
|
||||
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()
|
||||
idx = 0
|
||||
ret = []
|
||||
level: int | None = None
|
||||
parent: PurePosixPath | None = None
|
||||
with watching.state.lock:
|
||||
root = watching.state.root
|
||||
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)
|
||||
ret.append((loc.relative_to(parent), watching.rootpath / loc))
|
||||
idx += 1
|
||||
token = auth.request_share_token(req)
|
||||
|
||||
if token is None:
|
||||
with watching.state.lock:
|
||||
root = watching.state.root
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
@@ -268,7 +286,7 @@ async def zip_download(req, keys, zipfile, ext):
|
||||
await auth.verify(req)
|
||||
|
||||
wanted = set(keys.split("+"))
|
||||
files = get_files(wanted)
|
||||
files = get_files(req, wanted)
|
||||
|
||||
if not files:
|
||||
raise NotFound(
|
||||
|
||||
+131
-1
@@ -5,6 +5,7 @@ import hmac
|
||||
import re
|
||||
import secrets
|
||||
import struct
|
||||
from pathlib import PurePosixPath
|
||||
from time import time
|
||||
from unicodedata import normalize
|
||||
|
||||
@@ -15,8 +16,9 @@ from sanic import Blueprint, html, json, redirect
|
||||
from sanic.exceptions import BadRequest, Forbidden, Unauthorized
|
||||
from sanic.log import logger
|
||||
|
||||
from cista import config, session
|
||||
from cista import config, session, sharefs
|
||||
from cista.util import pwgen
|
||||
from cista.util.filename import sanitize
|
||||
|
||||
_LOGIN_PAGE_CSS = """\
|
||||
/* ===========================================
|
||||
@@ -611,6 +613,8 @@ def _basic_auth_login(request):
|
||||
request.ctx.session = None
|
||||
request.ctx.username = token.username
|
||||
request.ctx.user = user
|
||||
request.ctx.auth_token_id = password
|
||||
request.ctx.auth_token = token
|
||||
user.lastSeen = int(time())
|
||||
return user
|
||||
raise Unauthorized("Invalid token", quiet=True)
|
||||
@@ -654,6 +658,9 @@ async def _token_auth_login(request, *, privileged=False):
|
||||
if not token:
|
||||
return False
|
||||
|
||||
request.ctx.auth_token_id = password
|
||||
request.ctx.auth_token = token
|
||||
|
||||
sso = _get_sso()
|
||||
if sso.paskia_enabled() and token.sso_user_id:
|
||||
perm = "cista:admin" if privileged else "cista:login"
|
||||
@@ -823,6 +830,8 @@ async def _ntlm_auth_login(request, *, privileged=False):
|
||||
try:
|
||||
data = await sso.check_permissions(token.sso_user_id, perm)
|
||||
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 {}
|
||||
user_info = ctx.get("user", {}) if isinstance(ctx, dict) else {}
|
||||
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.username = token.username
|
||||
request.ctx.user = user
|
||||
request.ctx.auth_token_id = tid
|
||||
request.ctx.auth_token = token
|
||||
request.ctx._create_session_username = token.username
|
||||
logger.debug(
|
||||
"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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -1310,6 +1341,8 @@ async def list_tokens_handler(request):
|
||||
"sso_user_id": t.sso_user_id,
|
||||
"name": t.name,
|
||||
"created": t.created,
|
||||
"kind": t.kind,
|
||||
"mode": t.mode,
|
||||
}
|
||||
)
|
||||
return json({"tokens": tokens})
|
||||
@@ -1358,6 +1391,9 @@ async def create_token_handler(request):
|
||||
"sso_user_id": sso_user_id or "",
|
||||
"name": name,
|
||||
"created": int(time()),
|
||||
"kind": "api",
|
||||
"mode": "rw",
|
||||
"share_paths": [],
|
||||
}
|
||||
config.update_token(token, changes)
|
||||
scheme = request.scheme
|
||||
@@ -1371,6 +1407,100 @@ async def create_token_handler(request):
|
||||
"username": username or "",
|
||||
"sso_user_id": sso_user_id or "",
|
||||
"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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -51,6 +51,9 @@ class Token(msgspec.Struct, omit_defaults=True):
|
||||
sso_user_id: str = "" # set in SSO mode
|
||||
name: str = ""
|
||||
created: int = 0
|
||||
kind: str = "api" # api | share
|
||||
mode: str = "rw" # ro | rw
|
||||
share_paths: list[str] = []
|
||||
|
||||
|
||||
# Global variables - initialized during application startup
|
||||
|
||||
+100
-26
@@ -14,7 +14,7 @@ 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 import auth, config, sharefs, watching
|
||||
from cista.api import fileserver
|
||||
from cista.util import filename
|
||||
|
||||
@@ -40,6 +40,7 @@ async def verify_fileserver(request):
|
||||
|
||||
@bp.put("/<name:path>")
|
||||
async def upload_file_chunk(request, name):
|
||||
auth.ensure_write_allowed(request)
|
||||
body = request.body
|
||||
header = request.headers.get("content-range")
|
||||
if header:
|
||||
@@ -49,7 +50,7 @@ async def upload_file_chunk(request, name):
|
||||
end = len(body)
|
||||
total = end
|
||||
|
||||
rel, _ = _safe_relpath(name)
|
||||
rel, path = _safe_relpath(name, request=request)
|
||||
rel_name = rel.as_posix()
|
||||
upload_info = await asyncio.to_thread(
|
||||
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:
|
||||
extras.append("resized")
|
||||
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(
|
||||
{
|
||||
"status": "ack",
|
||||
@@ -92,7 +94,8 @@ async def upload_file_chunk(request, name):
|
||||
|
||||
@bp.delete("/<name:path>")
|
||||
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:
|
||||
raise BadRequest("Refusing to delete root folder")
|
||||
|
||||
@@ -105,23 +108,27 @@ async def delete_file(request, name):
|
||||
path.unlink()
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@bp.route("/<name:path>", methods=["MKCOL"])
|
||||
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:
|
||||
raise BadRequest("Refusing to create root folder")
|
||||
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)
|
||||
|
||||
|
||||
@bp.post("/", name="post_root", strict_slashes=False)
|
||||
@bp.post("/<name:path>", name="post_path")
|
||||
async def copy_or_move(request, name=""):
|
||||
auth.ensure_write_allowed(request)
|
||||
provided_args = set(request.args.keys())
|
||||
if not provided_args:
|
||||
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:
|
||||
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_is_dir = dst_exists and dst_abs.is_dir()
|
||||
|
||||
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]
|
||||
if 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:
|
||||
try:
|
||||
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 not dst_is_dir:
|
||||
@@ -224,7 +231,7 @@ async def copy_or_move(request, name=""):
|
||||
)
|
||||
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":
|
||||
# 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]
|
||||
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(
|
||||
{
|
||||
@@ -299,7 +314,29 @@ async def dav_options(request, name=""):
|
||||
@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)
|
||||
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():
|
||||
raise NotFound(f"Not found: {name}")
|
||||
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("/<name:path>", methods=["COPY"], name="copy_path")
|
||||
async def dav_copy(request, name=""):
|
||||
auth.ensure_write_allowed(request)
|
||||
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)
|
||||
_src_rel, src_abs = _safe_relpath(name, request=request)
|
||||
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}"
|
||||
if not src_abs.exists():
|
||||
raise NotFound(f"Source not found: {name}")
|
||||
@@ -342,19 +382,25 @@ async def dav_copy(request, name=""):
|
||||
shutil.copy2(src_abs, dst_abs)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@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=""):
|
||||
auth.ensure_write_allowed(request)
|
||||
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)
|
||||
_src_rel, src_abs = _safe_relpath(name, request=request)
|
||||
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}"
|
||||
if not src_abs.exists():
|
||||
raise NotFound(f"Source not found: {name}")
|
||||
@@ -372,7 +418,15 @@ async def dav_move(request, name=""):
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
@@ -399,8 +453,15 @@ def _to_mib_int(value_bytes: int) -> int:
|
||||
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."""
|
||||
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()
|
||||
try:
|
||||
sanitized = filename.sanitize(unquote(path))
|
||||
@@ -413,8 +474,12 @@ def _safe_relpath(path: str) -> tuple[PurePosixPath, Path]:
|
||||
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."""
|
||||
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()
|
||||
resolved = (base / rel).resolve()
|
||||
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):
|
||||
_, 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():
|
||||
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
|
||||
|
||||
|
||||
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."""
|
||||
token = auth.request_share_token(request)
|
||||
if token is not None:
|
||||
return sharefs.key_paths_for_token(token, wanted)
|
||||
|
||||
loc = PurePosixPath()
|
||||
ret: dict[str, PurePosixPath] = {}
|
||||
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."""
|
||||
parsed = urlparse(dest_header)
|
||||
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 :]
|
||||
else:
|
||||
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:
|
||||
|
||||
+11
-3
@@ -25,7 +25,7 @@ from sanic import Blueprint, empty, raw, redirect
|
||||
from sanic.exceptions import NotFound
|
||||
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.util.filename import sanitize
|
||||
|
||||
@@ -388,8 +388,16 @@ async def preview(req, path):
|
||||
maxsize = int(req.args.get("px", 1024))
|
||||
maxzoom = float(req.args.get("zoom", 2.0))
|
||||
quality = int(req.args.get("q", 60))
|
||||
rel = PurePosixPath(sanitize(unquote(path)))
|
||||
filepath = config.config.path / rel
|
||||
share_token = auth.request_share_token(req)
|
||||
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:
|
||||
stat = filepath.lstat()
|
||||
except FileNotFoundError:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user