"""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:'") 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}