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.
156 lines
4.6 KiB
Python
156 lines
4.6 KiB
Python
import asyncio
|
|
from pathlib import PurePosixPath
|
|
from secrets import token_bytes
|
|
|
|
import msgspec
|
|
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.util.apphelpers import websocket_wrapper
|
|
|
|
bp = Blueprint("api", url_prefix="/api")
|
|
fileserver = FileServer()
|
|
|
|
|
|
@bp.before_server_start
|
|
async def start_fileserver(app):
|
|
await fileserver.start()
|
|
|
|
|
|
@bp.after_server_stop
|
|
async def stop_fileserver(app):
|
|
await fileserver.stop()
|
|
|
|
|
|
@bp.websocket("watch")
|
|
@websocket_wrapper
|
|
async def watch(req, ws):
|
|
# Build user info from either built-in auth or SSO
|
|
user_info = None
|
|
if sso.paskia_enabled():
|
|
# SSO auth: call validation to get user info (don't enforce auth in public mode)
|
|
try:
|
|
await sso.validate_sso_request(req)
|
|
except Exception:
|
|
pass # Ignore auth errors, user_info stays None
|
|
if sso_user := getattr(req.ctx, "sso_user", None):
|
|
ctx = sso_user.get("ctx", {})
|
|
perms = ctx.get("permissions", [])
|
|
user_info = {
|
|
"username": ctx.get("user", {}).get("display_name", ""),
|
|
"privileged": "cista:admin" in perms,
|
|
}
|
|
elif req.ctx.user:
|
|
# Built-in auth: use local user database
|
|
user_info = {
|
|
"username": req.ctx.username,
|
|
"privileged": req.ctx.user.privileged,
|
|
}
|
|
|
|
await ws.send(
|
|
msgspec.json.encode(
|
|
{
|
|
"server": {
|
|
"name": config.config.name or config.config.path.name,
|
|
"version": __version__,
|
|
"public": config.config.public,
|
|
"paskia": sso.paskia_enabled(),
|
|
},
|
|
"user": user_info,
|
|
}
|
|
).decode()
|
|
)
|
|
uuid = token_bytes(16)
|
|
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)
|
|
# Send updates
|
|
while True:
|
|
await ws.send(await q.get())
|
|
except RuntimeError as e:
|
|
if str(e) == "cannot schedule new futures after shutdown":
|
|
return # Server shutting down, drop the WebSocket
|
|
raise
|
|
finally:
|
|
watching.pubsub.pop(uuid, None) # Remove whether it got added yet or not
|
|
|
|
|
|
def subscribe(uuid, ws):
|
|
with watching.state.lock:
|
|
q = watching.pubsub[uuid] = asyncio.Queue()
|
|
# Init with disk usage and full tree
|
|
return (
|
|
q,
|
|
watching.format_space(watching.state.space),
|
|
watching.format_root(watching.state.root),
|
|
)
|
|
|
|
|
|
@bp.get("config")
|
|
async def get_config(request):
|
|
await auth.verify(request, privileged=True)
|
|
return json(
|
|
{
|
|
"name": config.config.name,
|
|
"public": config.config.public,
|
|
}
|
|
)
|
|
|
|
|
|
@bp.put("config/public")
|
|
async def update_public(request):
|
|
await auth.verify(request, privileged=True)
|
|
try:
|
|
public = request.json["public"]
|
|
if not isinstance(public, bool):
|
|
raise ValueError("public must be a boolean")
|
|
except KeyError:
|
|
raise BadRequest("Missing public field") from None
|
|
except ValueError as e:
|
|
raise BadRequest(str(e)) from None
|
|
config.update_config({"public": public})
|
|
return json({"message": "Public access setting updated", "public": public})
|
|
|
|
|
|
@bp.put("config/name")
|
|
async def update_name(request):
|
|
await auth.verify(request, privileged=True)
|
|
try:
|
|
name = request.json["name"]
|
|
if not isinstance(name, str):
|
|
raise ValueError("name must be a string")
|
|
except KeyError:
|
|
raise BadRequest("Missing name field") from None
|
|
except ValueError as e:
|
|
raise BadRequest(str(e)) from None
|
|
config.update_config({"name": name})
|
|
# 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)
|