Files
media-player-server/media_server/routes/callbacks.py
T
alexei.dolgolyov bcc6d40ed7
Lint & Test / test (push) Successful in 20s
fix: comprehensive security, bug, performance, and UI/UX audit
Security
- Default bind 127.0.0.1; first-run bootstrap generates random api_token
  and refuses to bind non-loopback without auth unless explicitly opted in
- Path-traversal hardened: BrowserService.validate_path rejects absolute
  paths, drive letters, UNC, NUL bytes. /api/browser/{play,metadata,
  thumbnail} now require folder_id and a folder-relative path
- Pydantic validators on links: http(s) URLs only, mdi:<slug> icons only
- Scripts/callbacks/links create/update/delete gated by *_management flags
- Strict CSP, X-Frame-Options DENY, Referrer-Policy no-referrer,
  X-Content-Type-Options nosniff
- CORS locked to localhost:<port> + 127.0.0.1:<port> by default; configurable
- config.yaml writes atomic (tmp + os.replace) and 0o600 on POSIX
- Subprocesses spawned in their own process group / new session so timeout
  kills the whole tree (Windows CREATE_NEW_PROCESS_GROUP, POSIX
  start_new_session=True)
- Frontend XSS: monitor name + details escapeHtml'd; power button moved to
  delegated data-action handler; remote MDI SVGs parsed and sanitized
  (strip script/foreignObject/on*/javascript: hrefs) before innerHTML
- All dynamic URL segments now wrapped in encodeURIComponent

Bugs
- WebSocket reconnect: close previous socket before opening new, clear
  ping interval per-socket, clear reconnectTimeout up-front, retry on
  online/visibilitychange, try/catch JSON.parse
- Artwork fetch race: AbortController + generation guard
- _broadcast_after_open: initialize status, swallow per-poll errors,
  background tasks tracked in a strong-ref set with done-callback cleanup
- Audio analyzer: sticky _unavailable flag prevents infinite start/stop
  spin when no loopback device exists; cleared by set_device()
- Volume short-circuit cache invalidated when server reports remote volume
- Browser thumbnail race: per-folder generation counter + isConnected
  checks; aborts in-flight fetches on navigation
- Track-skip uses cached title instead of full WinRT status round-trip

Performance
- Linux MPRIS/pactl and /api/display DDC-CI handlers wrapped in
  asyncio.to_thread so blocking IO never stalls the event loop
- browse_directory moved off the event loop (SMB shares could freeze it)
- Windows status poll caches one asyncio loop per worker thread via
  threading.local instead of new_event_loop/close on every 0.5s tick
- broadcast() serializes JSON once and uses send_text to all clients
- Hourly thumbnail cache cleanup scheduled in lifespan (was never invoked
  — cache grew unbounded)
- Progress drag listeners attached only while dragging

Quality
- All asyncio.get_event_loop() in coroutines → get_running_loop()
- ThreadPoolExecutors shut down cleanly during lifespan teardown
- config_manager dedup: 12 near-identical methods collapsed onto generic
  _upsert/_delete helpers (~290 lines removed)
- Service worker no longer pass-throughs every fetch
- M3U playlist written via NamedTemporaryFile (no fixed-path symlink
  clobber race)
- __version__ now prefers live pyproject.toml in dev checkouts so
  pip install -e . users see the source-of-truth version, not the stale
  package-metadata version baked in at install time

UI/UX (Studio Reference)
- Green leftover focus rings (rgba(29,185,84,...)) all replaced with
  copper accent (rgba(var(--copper-rgb),...))
- Dialogs: square corners, copper top hairline, unified with editorial
  chrome
- .browser-item: transparent with copper hover border (was filled card)
- Audio device select uses var(--sans) instead of generic system font
- Mobile container padding tuned for ≤480px screens
- Breadcrumb home is a real <button> with aria-label; aria-current on root
- i18n: filled display.msg.power_*, execution.*, scripts.params.execute,
  callbacks.empty in both en + ru
2026-05-16 13:22:46 +03:00

372 lines
11 KiB
Python

"""Callback management API endpoints."""
import asyncio
import logging
import subprocess
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from ..auth import verify_token
from ..config import CallbackConfig, settings
from ..config_manager import config_manager
router = APIRouter(prefix="/api/callbacks", tags=["callbacks"])
logger = logging.getLogger(__name__)
# Dedicated executor for callback/subprocess execution
_callback_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="callback")
def shutdown_callback_executor() -> None:
"""Shut down the callback executor cleanly on application teardown."""
_callback_executor.shutdown(wait=False, cancel_futures=True)
def _require_callbacks_management() -> None:
if not settings.callbacks_management:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=(
"Callbacks management is disabled. Set callbacks_management: true"
" in config.yaml to enable."
),
)
class CallbackInfo(BaseModel):
"""Information about a configured callback."""
name: str
command: str
timeout: int
working_dir: str | None = None
shell: bool
class CallbackCreateRequest(BaseModel):
"""Request model for creating or updating a callback."""
command: str = Field(..., description="Command to execute", min_length=1)
timeout: int = Field(default=30, description="Execution timeout in seconds", ge=1, le=300)
working_dir: str | None = Field(default=None, description="Working directory")
shell: bool = Field(default=True, description="Run command in shell")
class CallbackExecuteResponse(BaseModel):
"""Response model for callback execution."""
success: bool
callback: str
exit_code: int | None = None
stdout: str = ""
stderr: str = ""
error: str | None = None
execution_time: float | None = None
def _validate_callback_name(name: str) -> None:
"""Validate callback name.
Args:
name: Callback name to validate.
Raises:
HTTPException: If name is invalid.
"""
# All available callback events
valid_names = {
"on_play",
"on_pause",
"on_stop",
"on_next",
"on_previous",
"on_volume",
"on_mute",
"on_seek",
"on_turn_on",
"on_turn_off",
"on_toggle",
}
if name not in valid_names:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Callback name must be one of: {', '.join(sorted(valid_names))}",
)
@router.get("/list")
async def list_callbacks(_: str = Depends(verify_token)) -> list[CallbackInfo]:
"""List all configured callbacks.
Returns:
List of configured callbacks.
"""
return [
CallbackInfo(
name=name,
command=config.command,
timeout=config.timeout,
working_dir=config.working_dir,
shell=config.shell,
)
for name, config in settings.callbacks.items()
]
@router.post("/execute/{callback_name}")
async def execute_callback(
callback_name: str,
_: str = Depends(verify_token),
) -> CallbackExecuteResponse:
"""Execute a callback for debugging purposes.
Args:
callback_name: Name of the callback to execute
Returns:
Execution result including stdout, stderr, and exit code
"""
# Validate callback name
_validate_callback_name(callback_name)
# Check if callback exists
if callback_name not in settings.callbacks:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Callback '{callback_name}' not found. Use /api/callbacks/list to see configured callbacks.",
)
callback_config = settings.callbacks[callback_name]
logger.info(f"Executing callback for debugging: {callback_name}")
try:
# Execute in dedicated thread pool to not block the default executor
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(
_callback_executor,
lambda: _run_callback(
command=callback_config.command,
timeout=callback_config.timeout,
shell=callback_config.shell,
working_dir=callback_config.working_dir,
),
)
return CallbackExecuteResponse(
success=result["exit_code"] == 0,
callback=callback_name,
exit_code=result["exit_code"],
stdout=result["stdout"],
stderr=result["stderr"],
execution_time=result.get("execution_time"),
)
except Exception as e:
logger.error(f"Callback execution error: {e}")
return CallbackExecuteResponse(
success=False,
callback=callback_name,
error=str(e),
)
def _run_callback(
command: str,
timeout: int,
shell: bool,
working_dir: str | None,
) -> dict[str, Any]:
"""Run a callback synchronously.
Args:
command: Command to execute
timeout: Timeout in seconds
shell: Whether to run in shell
working_dir: Working directory
Returns:
Dict with exit_code, stdout, stderr, execution_time
"""
start_time = time.time()
popen_kwargs: dict[str, Any] = {}
if sys.platform == "win32":
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
else:
popen_kwargs["start_new_session"] = True
try:
result = subprocess.run(
command,
shell=shell,
cwd=working_dir,
capture_output=True,
text=True,
timeout=timeout,
**popen_kwargs,
)
execution_time = time.time() - start_time
return {
"exit_code": result.returncode,
"stdout": result.stdout[:10000], # Limit output size
"stderr": result.stderr[:10000],
"execution_time": execution_time,
}
except subprocess.TimeoutExpired:
execution_time = time.time() - start_time
return {
"exit_code": -1,
"stdout": "",
"stderr": f"Callback timed out after {timeout} seconds",
"execution_time": execution_time,
}
except Exception as e:
execution_time = time.time() - start_time
return {
"exit_code": -1,
"stdout": "",
"stderr": str(e),
"execution_time": execution_time,
}
@router.post("/create/{callback_name}")
async def create_callback(
callback_name: str,
request: CallbackCreateRequest,
_: str = Depends(verify_token),
) -> dict[str, Any]:
"""Create a new callback.
Args:
callback_name: Callback event name (on_turn_on, on_turn_off, on_toggle).
request: Callback configuration.
Returns:
Success response with callback name.
Raises:
HTTPException: If callback already exists or name is invalid.
"""
_require_callbacks_management()
_validate_callback_name(callback_name)
# Check if callback already exists
if callback_name in settings.callbacks:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"Callback '{callback_name}' already exists."
f" Use PUT /api/callbacks/update/{callback_name} to update it."
),
)
# Create callback config
callback_config = CallbackConfig(**request.model_dump())
# Add to config file and in-memory
try:
config_manager.add_callback(callback_name, callback_config)
except Exception as e:
logger.error(f"Failed to add callback '{callback_name}': {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to add callback: {str(e)}",
)
logger.info(f"Callback '{callback_name}' created successfully")
return {"success": True, "callback": callback_name}
@router.put("/update/{callback_name}")
async def update_callback(
callback_name: str,
request: CallbackCreateRequest,
_: str = Depends(verify_token),
) -> dict[str, Any]:
"""Update an existing callback.
Args:
callback_name: Callback event name.
request: Updated callback configuration.
Returns:
Success response with callback name.
Raises:
HTTPException: If callback does not exist.
"""
_require_callbacks_management()
_validate_callback_name(callback_name)
# Check if callback exists
if callback_name not in settings.callbacks:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=(
f"Callback '{callback_name}' not found."
f" Use POST /api/callbacks/create/{callback_name} to create it."
),
)
# Create updated callback config
callback_config = CallbackConfig(**request.model_dump())
# Update config file and in-memory
try:
config_manager.update_callback(callback_name, callback_config)
except Exception as e:
logger.error(f"Failed to update callback '{callback_name}': {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update callback: {str(e)}",
)
logger.info(f"Callback '{callback_name}' updated successfully")
return {"success": True, "callback": callback_name}
@router.delete("/delete/{callback_name}")
async def delete_callback(
callback_name: str,
_: str = Depends(verify_token),
) -> dict[str, Any]:
"""Delete a callback.
Args:
callback_name: Callback event name.
Returns:
Success response with callback name.
Raises:
HTTPException: If callback does not exist.
"""
_require_callbacks_management()
_validate_callback_name(callback_name)
# Check if callback exists
if callback_name not in settings.callbacks:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Callback '{callback_name}' not found",
)
# Delete from config file and in-memory
try:
config_manager.delete_callback(callback_name)
except Exception as e:
logger.error(f"Failed to delete callback '{callback_name}': {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete callback: {str(e)}",
)
logger.info(f"Callback '{callback_name}' deleted successfully")
return {"success": True, "callback": callback_name}