Files
media-player-server/media_server/routes/links.py
T
alexei.dolgolyov d131ba461c
Lint & Test / test (push) Successful in 20s
fix: production-readiness hardening — security, perf, a11y, observability
Security
- Default scripts_management, callbacks_management, links_management, and
  media_folders_management to False so a leaked token cannot escalate to RCE
  through admin CRUD endpoints.
- TokenSpec + scope hierarchy (read | control | admin); legacy bare-string
  api_tokens entries promote to admin for back-compat. Management endpoints
  now require admin scope.
- WebSocket subprotocol auth (Sec-WebSocket-Protocol: media-server.token.<T>)
  preferred over ?token= query so the token no longer lands in URL/history/
  Referer; query fallback retained for HA integration back-compat.
- Origin allow-list check on the WS endpoint (CSWSH defence).
- In-process token-bucket rate limiter: 5/min for failed auths,
  10/min for /api/scripts/execute and /api/callbacks/execute.
- shell=False subprocess path (shlex.split) + per-parameter regex `pattern`
  in ScriptParameterConfig to harden shell=true scripts against parameter
  injection (Windows cmd.exe env-var expansion).
- CSP gains form-action, worker-src, manifest-src directives.
- Refuse cors_origins=["*"] at startup; strip token=... from uvicorn access
  logs; validate Gitea release tag against strict SemVer regex.
- noopener noreferrer + no-referrer referrerpolicy on every outbound link.
- icacls hardening of config.yaml on Windows (current user + SYSTEM +
  Administrators only); 0600 still enforced on POSIX.
- WS volume handler clamps input and never drops the socket on bad messages.

Performance
- Album-art read in windows_media gated by track key — was decoding the
  WinRT thumbnail twice per second regardless of track changes.
- /api/media/artwork returns content-derived ETag + Cache-Control so the
  browser sends If-None-Match and gets 304s on track repeats.
- Foreground-service ctypes argtypes hoisted to one-time module init
  (was re-declaring ~14 prototypes per probe).
- display_service _static_cache keyed by (edid_hash, ...) tuple with
  eviction of disappeared monitors — fixes stale capabilities on hot-plug
  swaps where the new topology has the same monitor count.
- Visualizer rAF loop paused on document.hidden, resumed on visible.

Reliability / bug fixes
- Lifespan rewritten as try/yield/finally so a partial-startup failure
  cannot orphan background tasks or executors.
- _run_callback in routes/media.py keeps a strong task ref (GC-safe) and
  uses the dedicated callback executor instead of the default pool.
- macos_media.set_volume() no longer always returns True.
- TrayManager._restart_requested initialised in __init__; set before
  signalling exit so the main thread observes it correctly.
- Missing static_dir now logs a WARNING instead of silent UI disable.

UX / accessibility / PWA
- manifest.json theme_color and background_color match the Studio Reference
  base (#0E0D0B); added id and scope for PWA installability.
- ARIA on mini-player icon buttons; inner SVGs marked aria-hidden.
- OS mediaSession API wired so headset / lockscreen / Bluetooth buttons
  drive play/pause/next/prev/seek and show track metadata + artwork.

Observability
- X-Request-ID middleware (accept upstream id if it matches a safe regex,
  otherwise UUID4); request_id_var added to ContextVars and included in
  every log line alongside the token label.
- Audit log (append-only JSONL) for every script + callback execution,
  including the on_play/on_pause/etc. event callbacks. Background-thread
  writer; queue capped; flushed in lifespan teardown.

Deployment
- proxy_headers + forwarded_allow_ips plumbed through Settings →
  uvicorn.Config for reverse-proxy installs.
- HTTPS support via ssl_certfile + ssl_keyfile (+ optional password);
  startup refuses to launch with only one of the pair set.
- Thumbnail cache moved from project-root .cache to
  %LOCALAPPDATA%/media-server/cache (Windows) and
  $XDG_CACHE_HOME/media-server/thumbnails (POSIX).

Tests
- 35 new tests across auth scopes, rate limiter, browser path traversal
  (../ NUL UNC absolute), script-param validation incl. regex, Gitea tag
  whitelist, config atomic write + POSIX perms. 47 passed / 4 skipped.
2026-05-22 22:25:54 +03:00

234 lines
7.0 KiB
Python

"""Header quick links management API endpoints."""
import logging
import re
from typing import Any
from urllib.parse import urlparse
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field, field_validator
from ..auth import verify_token
from ..config import LinkConfig, settings
from ..config_manager import config_manager
from ..services.websocket_manager import ws_manager
router = APIRouter(prefix="/api/links", tags=["links"])
logger = logging.getLogger(__name__)
# Only allow MDI iconify slugs and safe `http(s)`-ish URLs through the API.
_MDI_ICON_RE = re.compile(r"^mdi:[a-z0-9][a-z0-9-]{0,63}$")
_ALLOWED_URL_SCHEMES = {"http", "https"}
def _validate_url(url: str) -> str:
"""Ensure the URL is well-formed http(s) — no ``javascript:`` etc."""
parsed = urlparse(url)
if parsed.scheme.lower() not in _ALLOWED_URL_SCHEMES:
raise ValueError("URL must start with http:// or https://")
if not parsed.netloc:
raise ValueError("URL must include a host")
return url
def _validate_icon(icon: str) -> str:
"""Restrict icon names to safe Material Design Icons slugs."""
if not _MDI_ICON_RE.match(icon):
raise ValueError("Icon must be of the form 'mdi:<lowercase-slug>'")
return icon
def _require_links_management() -> None:
"""Authorise a links-CRUD operation. Operator flag + per-token admin scope."""
if not settings.links_management:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Links management is disabled. Set links_management: true in config.yaml to enable.",
)
from ..auth import auth_enabled, token_has_scope, token_label_var
if auth_enabled():
label = token_label_var.get("unknown")
if not token_has_scope(label, "admin"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Token '{label}' lacks required scope: admin",
)
class LinkInfo(BaseModel):
"""Information about a configured link."""
name: str
url: str
icon: str
label: str
description: str
class LinkCreateRequest(BaseModel):
"""Request model for creating or updating a link."""
url: str = Field(..., description="URL to open", min_length=1, max_length=2048)
icon: str = Field(default="mdi:link", description="MDI icon name (e.g., 'mdi:led-strip-variant')")
label: str = Field(default="", description="Tooltip text", max_length=128)
description: str = Field(default="", description="Optional description", max_length=512)
@field_validator("url")
@classmethod
def _check_url(cls, v: str) -> str:
return _validate_url(v)
@field_validator("icon")
@classmethod
def _check_icon(cls, v: str) -> str:
return _validate_icon(v)
def _validate_link_name(name: str) -> None:
"""Validate link name."""
if not re.match(r"^[a-zA-Z0-9_]+$", name):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Link name must contain only letters, numbers, and underscores",
)
if len(name) > 64:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Link name must be 64 characters or less",
)
@router.get("/list")
async def list_links(_: str = Depends(verify_token)) -> list[LinkInfo]:
"""List all configured links.
Returns:
List of configured links.
"""
return [
LinkInfo(
name=name,
url=config.url,
icon=config.icon,
label=config.label,
description=config.description,
)
for name, config in settings.links.items()
]
@router.post("/create/{link_name}")
async def create_link(
link_name: str,
request: LinkCreateRequest,
_: str = Depends(verify_token),
) -> dict[str, Any]:
"""Create a new link.
Args:
link_name: Link name (alphanumeric and underscores only).
request: Link configuration.
Returns:
Success response with link name.
"""
_require_links_management()
_validate_link_name(link_name)
if link_name in settings.links:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Link '{link_name}' already exists. Use PUT /api/links/update/{link_name} to update it.",
)
link_config = LinkConfig(**request.model_dump())
try:
config_manager.add_link(link_name, link_config)
except Exception as e:
logger.error(f"Failed to add link '{link_name}': {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to add link: {str(e)}",
)
await ws_manager.broadcast_links_changed()
logger.info(f"Link '{link_name}' created successfully")
return {"success": True, "link": link_name}
@router.put("/update/{link_name}")
async def update_link(
link_name: str,
request: LinkCreateRequest,
_: str = Depends(verify_token),
) -> dict[str, Any]:
"""Update an existing link.
Args:
link_name: Link name.
request: Updated link configuration.
Returns:
Success response with link name.
"""
_require_links_management()
_validate_link_name(link_name)
if link_name not in settings.links:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Link '{link_name}' not found. Use POST /api/links/create/{link_name} to create it.",
)
link_config = LinkConfig(**request.model_dump())
try:
config_manager.update_link(link_name, link_config)
except Exception as e:
logger.error(f"Failed to update link '{link_name}': {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update link: {str(e)}",
)
await ws_manager.broadcast_links_changed()
logger.info(f"Link '{link_name}' updated successfully")
return {"success": True, "link": link_name}
@router.delete("/delete/{link_name}")
async def delete_link(
link_name: str,
_: str = Depends(verify_token),
) -> dict[str, Any]:
"""Delete a link.
Args:
link_name: Link name.
Returns:
Success response with link name.
"""
_require_links_management()
_validate_link_name(link_name)
if link_name not in settings.links:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Link '{link_name}' not found",
)
try:
config_manager.delete_link(link_name)
except Exception as e:
logger.error(f"Failed to delete link '{link_name}': {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete link: {str(e)}",
)
await ws_manager.broadcast_links_changed()
logger.info(f"Link '{link_name}' deleted successfully")
return {"success": True, "link": link_name}