/
/
/
1"""
2Controller that manages the builtin webserver that hosts the api and frontend.
3
4Unlike the streamserver (which is as simple and unprotected as possible),
5this webserver allows for more fine grained configuration to better secure it.
6"""
7
8from __future__ import annotations
9
10import asyncio
11import hashlib
12import html
13import inspect
14import os
15import urllib.parse
16from collections.abc import Awaitable, Callable
17from concurrent import futures
18from functools import partial
19from typing import TYPE_CHECKING, Any, Final, cast
20from urllib.parse import quote
21
22import aiofiles
23from aiohttp import ClientTimeout, web
24from mashumaro.exceptions import MissingField
25from music_assistant_frontend import where as locate_frontend
26from music_assistant_models.api import CommandMessage
27from music_assistant_models.auth import UserRole
28from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption
29from music_assistant_models.enums import ConfigEntryType
30
31from music_assistant.constants import (
32 CONF_AUTH_ALLOW_SELF_REGISTRATION,
33 CONF_BIND_IP,
34 CONF_BIND_PORT,
35 RESOURCES_DIR,
36 VERBOSE_LOG_LEVEL,
37)
38from music_assistant.controllers.webserver.helpers.ssl import (
39 create_server_ssl_context,
40 format_certificate_info,
41 verify_ssl_certificate,
42)
43from music_assistant.helpers.api import parse_arguments
44from music_assistant.helpers.audio import get_preview_stream
45from music_assistant.helpers.json import json_dumps, json_loads
46from music_assistant.helpers.redirect_validation import is_allowed_redirect_url
47from music_assistant.helpers.util import format_ip_for_url, get_ip_addresses
48from music_assistant.helpers.webserver import Webserver
49from music_assistant.models.core_controller import CoreController
50
51from .api_docs import generate_commands_json, generate_openapi_spec, generate_schemas_json
52from .auth import AuthenticationManager
53from .helpers.auth_middleware import (
54 get_authenticated_user,
55 is_request_from_ingress,
56 set_current_user,
57)
58from .helpers.auth_providers import BuiltinLoginProvider, get_ha_user_role
59from .remote_access import RemoteAccessManager
60from .sendspin_proxy import SendspinProxyHandler
61from .websocket_client import WebsocketClientHandler
62
63if TYPE_CHECKING:
64 from music_assistant_models.config_entries import ConfigValueType, CoreConfig
65
66 from music_assistant import MusicAssistant
67
68DEFAULT_SERVER_PORT = 8095
69INGRESS_SERVER_PORT = 8094
70CONF_BASE_URL = "base_url"
71CONF_ENABLE_SSL = "enable_ssl"
72CONF_SSL_CERTIFICATE = "ssl_certificate"
73CONF_SSL_PRIVATE_KEY = "ssl_private_key"
74CONF_ACTION_VERIFY_SSL = "verify_ssl"
75MAX_PENDING_MSG = 512
76CANCELLATION_ERRORS: Final = (asyncio.CancelledError, futures.CancelledError)
77
78
79class WebserverController(CoreController):
80 """Core Controller that manages the builtin webserver that hosts the api and frontend."""
81
82 domain: str = "webserver"
83
84 def __init__(self, mass: MusicAssistant) -> None:
85 """Initialize instance."""
86 super().__init__(mass)
87 self._server = Webserver(self.logger, enable_dynamic_routes=True)
88 self.register_dynamic_route = self._server.register_dynamic_route
89 self.unregister_dynamic_route = self._server.unregister_dynamic_route
90 self.clients: set[WebsocketClientHandler] = set()
91 self.manifest.name = "Web Server (frontend and api)"
92 self.manifest.description = (
93 "The built-in webserver that hosts the Music Assistant Websockets API and frontend"
94 )
95 self.manifest.icon = "web-box"
96 self.auth = AuthenticationManager(self)
97 self.remote_access = RemoteAccessManager(self)
98 self._sendspin_proxy = SendspinProxyHandler(self)
99
100 @property
101 def base_url(self) -> str:
102 """Return the base_url for the webserver."""
103 return str(self.config.get_value(CONF_BASE_URL)).removesuffix("/")
104
105 async def get_config_entries(
106 self,
107 action: str | None = None,
108 values: dict[str, ConfigValueType] | None = None,
109 ) -> tuple[ConfigEntry, ...]:
110 """Return all Config Entries for this core module (if any)."""
111 ip_addresses = await get_ip_addresses(include_ipv6=True)
112 default_publish_ip = ip_addresses[0]
113
114 # Handle verify SSL action
115 ssl_verify_result = ""
116 if action == CONF_ACTION_VERIFY_SSL and values:
117 cert_info = await verify_ssl_certificate(
118 str(values.get(CONF_SSL_CERTIFICATE, "")),
119 str(values.get(CONF_SSL_PRIVATE_KEY, "")),
120 )
121 ssl_verify_result = format_certificate_info(cert_info)
122
123 # Determine if SSL is enabled from values
124 ssl_enabled = values.get(CONF_ENABLE_SSL, False) if values else False
125 protocol = "https" if ssl_enabled else "http"
126 default_base_url = (
127 f"{protocol}://{format_ip_for_url(default_publish_ip)}:{DEFAULT_SERVER_PORT}"
128 )
129 return (
130 ConfigEntry(
131 key=CONF_AUTH_ALLOW_SELF_REGISTRATION,
132 type=ConfigEntryType.BOOLEAN,
133 default_value=True,
134 label="Allow User Self-Registration",
135 description="Allow users to create accounts via Home Assistant OAuth.",
136 hidden=not any(provider.domain == "hass" for provider in self.mass.providers),
137 requires_reload=False,
138 ),
139 ConfigEntry(
140 key=CONF_BASE_URL,
141 type=ConfigEntryType.STRING,
142 default_value=default_base_url,
143 label="Base URL",
144 description="The (base) URL to reach this webserver in the network. \n"
145 "Override this in advanced scenarios where for example you're running "
146 "the webserver behind a reverse proxy.",
147 requires_reload=False,
148 ),
149 ConfigEntry(
150 key=CONF_BIND_PORT,
151 type=ConfigEntryType.INTEGER,
152 default_value=DEFAULT_SERVER_PORT,
153 label="TCP Port",
154 description="The TCP port to run the webserver.",
155 requires_reload=True,
156 ),
157 ConfigEntry(
158 key="webserver_warn",
159 type=ConfigEntryType.ALERT,
160 label="Please note that the webserver is by default unencrypted. "
161 "Never ever expose the webserver directly to the internet! \n\n"
162 "Enable SSL below or use a reverse proxy or VPN to secure access. \n\n"
163 "As an alternative, consider using the Remote Access feature which "
164 "secures access to your Music Assistant instance without the need to "
165 "expose your webserver directly.",
166 required=False,
167 depends_on=CONF_ENABLE_SSL,
168 depends_on_value=False,
169 hidden=bool(values.get(CONF_ENABLE_SSL, False)) if values else False,
170 ),
171 ConfigEntry(
172 key=CONF_ENABLE_SSL,
173 type=ConfigEntryType.BOOLEAN,
174 default_value=False,
175 label="Enable SSL/TLS",
176 description="Enable HTTPS by providing an SSL certificate and private key. \n"
177 "This encrypts all communication with the webserver.",
178 requires_reload=True,
179 ),
180 ConfigEntry(
181 key=CONF_SSL_CERTIFICATE,
182 type=ConfigEntryType.STRING,
183 label="SSL Certificate",
184 description="Provide your SSL certificate in PEM format. You can either:\n"
185 "- Paste the full contents of your certificate file, or\n"
186 "- Enter an absolute file path (e.g., /ssl/fullchain.pem)\n\n"
187 "This should include the full certificate chain if applicable.\n"
188 "Both RSA and ECDSA certificates are supported.",
189 required=False,
190 depends_on=CONF_ENABLE_SSL,
191 requires_reload=True,
192 ),
193 ConfigEntry(
194 key=CONF_SSL_PRIVATE_KEY,
195 type=ConfigEntryType.SECURE_STRING,
196 label="SSL Private Key",
197 description="Provide your SSL private key in PEM format. You can either:\n"
198 "- Paste the full contents of your private key file, or\n"
199 "- Enter an absolute file path (e.g., /ssl/privkey.pem)\n\n"
200 "Both RSA and ECDSA keys are supported. The key must be unencrypted.\n"
201 "This is securely encrypted and stored.",
202 required=False,
203 depends_on=CONF_ENABLE_SSL,
204 requires_reload=True,
205 ),
206 ConfigEntry(
207 key=CONF_ACTION_VERIFY_SSL,
208 type=ConfigEntryType.ACTION,
209 label="Verify SSL Certificate",
210 description="Test your certificate and private key to verify they are valid "
211 "and match each other.",
212 action=CONF_ACTION_VERIFY_SSL,
213 action_label="Verify",
214 depends_on=CONF_ENABLE_SSL,
215 required=False,
216 ),
217 ConfigEntry(
218 key="ssl_verify_result",
219 type=ConfigEntryType.LABEL,
220 label=ssl_verify_result,
221 hidden=not ssl_verify_result,
222 depends_on=CONF_ENABLE_SSL,
223 required=False,
224 ),
225 ConfigEntry(
226 key=CONF_BIND_IP,
227 type=ConfigEntryType.STRING,
228 default_value="0.0.0.0",
229 options=[ConfigValueOption(x, x) for x in {"0.0.0.0", "::", *ip_addresses}],
230 label="Bind to IP/interface",
231 description="Bind the (web)server to this specific interface. \n"
232 "Use 0.0.0.0 or :: to bind to all interfaces. \n"
233 "Set this address for example to a docker-internal network, "
234 "when you are running a reverse proxy to enhance security and "
235 "protect outside access to the webinterface and API. \n\n"
236 "This is an advanced setting that should normally "
237 "not be adjusted in regular setups.",
238 category="generic",
239 advanced=True,
240 requires_reload=True,
241 ),
242 )
243
244 async def setup(self, config: CoreConfig) -> None: # noqa: PLR0915
245 """Async initialize of module."""
246 self.config = config
247 # work out all routes
248 routes: list[tuple[str, str, Callable[[web.Request], Awaitable[web.StreamResponse]]]] = []
249 # frontend routes
250 frontend_dir = locate_frontend()
251 for filename in next(os.walk(frontend_dir))[2]:
252 if filename.endswith(".py"):
253 continue
254 filepath = os.path.join(frontend_dir, filename)
255 handler = partial(self._server.serve_static, filepath)
256 routes.append(("GET", f"/{filename}", handler))
257 # add index (with onboarding check)
258 self._index_path = os.path.join(frontend_dir, "index.html")
259 routes.append(("GET", "/", self._handle_index))
260 # add logo
261 logo_path = str(RESOURCES_DIR.joinpath("logo.png"))
262 handler = partial(self._server.serve_static, logo_path)
263 routes.append(("GET", "/logo.png", handler))
264 # add common CSS for HTML resources
265 common_css_path = str(RESOURCES_DIR.joinpath("common.css"))
266 handler = partial(self._server.serve_static, common_css_path)
267 routes.append(("GET", "/resources/common.css", handler))
268 # add info
269 routes.append(("GET", "/info", self._handle_server_info))
270 routes.append(("OPTIONS", "/info", self._handle_cors_preflight))
271 # add websocket api
272 routes.append(("GET", "/ws", self._handle_ws_client))
273 # also host the image proxy on the webserver
274 routes.append(("GET", "/imageproxy", self.mass.metadata.handle_imageproxy))
275 # also host the audio preview service
276 routes.append(("GET", "/preview", self.serve_preview_stream))
277 # add jsonrpc api
278 routes.append(("POST", "/api", self._handle_jsonrpc_api_command))
279 # add api documentation
280 routes.append(("GET", "/api-docs", self._handle_api_intro))
281 routes.append(("GET", "/api-docs/", self._handle_api_intro))
282 routes.append(("GET", "/api-docs/commands", self._handle_commands_reference))
283 routes.append(("GET", "/api-docs/commands/", self._handle_commands_reference))
284 routes.append(("GET", "/api-docs/commands.json", self._handle_commands_json))
285 routes.append(("GET", "/api-docs/schemas", self._handle_schemas_reference))
286 routes.append(("GET", "/api-docs/schemas/", self._handle_schemas_reference))
287 routes.append(("GET", "/api-docs/schemas.json", self._handle_schemas_json))
288 routes.append(("GET", "/api-docs/openapi.json", self._handle_openapi_spec))
289 routes.append(("GET", "/api-docs/swagger", self._handle_swagger_ui))
290 routes.append(("GET", "/api-docs/swagger/", self._handle_swagger_ui))
291 # add authentication routes
292 routes.append(("GET", "/login", self._handle_login_page))
293 routes.append(("POST", "/auth/login", self._handle_auth_login))
294 routes.append(("OPTIONS", "/auth/login", self._handle_cors_preflight))
295 routes.append(("POST", "/auth/logout", self._handle_auth_logout))
296 routes.append(("GET", "/auth/me", self._handle_auth_me))
297 routes.append(("PATCH", "/auth/me", self._handle_auth_me_update))
298 routes.append(("GET", "/auth/providers", self._handle_auth_providers))
299 routes.append(("GET", "/auth/authorize", self._handle_auth_authorize))
300 routes.append(("GET", "/auth/callback", self._handle_auth_callback))
301 # add first-time setup routes
302 routes.append(("GET", "/setup", self._handle_setup_page))
303 routes.append(("POST", "/setup", self._handle_setup))
304 # add sendspin proxy route (authenticated WebSocket proxy to internal sendspin server)
305 routes.append(("GET", "/sendspin", self._sendspin_proxy.handle_sendspin_proxy))
306 await self.auth.setup()
307 # start the webserver
308 all_ip_addresses = await get_ip_addresses(include_ipv6=True)
309 default_publish_ip = all_ip_addresses[0]
310 if self.mass.running_as_hass_addon:
311 # if we're running on the HA supervisor we start an additional TCP site
312 # on the internal ("172.30.32.) IP for the HA ingress proxy
313 ingress_host = next(
314 (x for x in all_ip_addresses if x.startswith("172.30.32.")), default_publish_ip
315 )
316 ingress_tcp_site_params = (ingress_host, INGRESS_SERVER_PORT)
317 else:
318 ingress_tcp_site_params = None
319 base_url = str(config.get_value(CONF_BASE_URL))
320 port_value = config.get_value(CONF_BIND_PORT)
321 assert isinstance(port_value, int)
322 self.publish_port = port_value
323 self.publish_ip = default_publish_ip
324 bind_ip = cast("str | None", config.get_value(CONF_BIND_IP))
325 # print a big fat message in the log where the webserver is running
326 # because this is a common source of issues for people with more complex setups
327 if not self.auth.has_users:
328 self.logger.warning(
329 "\n\n################################################################################\n"
330 "### SETUP REQUIRED ###\n"
331 "################################################################################\n"
332 "\n"
333 "Music Assistant is running in setup mode.\n"
334 "Please complete the setup by visiting:\n"
335 "\n"
336 " %s/setup\n"
337 "\n"
338 "################################################################################\n",
339 base_url,
340 )
341 else:
342 self.logger.info(
343 "\n"
344 "################################################################################\n"
345 "\n"
346 "Webserver available on: %s\n"
347 "\n"
348 "If this address is incorrect, see the documentation on how to configure\n"
349 "the Webserver in Settings --> Core modules --> Webserver\n"
350 "\n"
351 "################################################################################\n",
352 base_url,
353 )
354
355 # Create SSL context if SSL is enabled
356 ssl_context = None
357 ssl_enabled = config.get_value(CONF_ENABLE_SSL, False)
358 if ssl_enabled:
359 ssl_context = await create_server_ssl_context(
360 str(config.get_value(CONF_SSL_CERTIFICATE) or ""),
361 str(config.get_value(CONF_SSL_PRIVATE_KEY) or ""),
362 logger=self.logger,
363 )
364
365 await self._server.setup(
366 bind_ip=bind_ip,
367 bind_port=self.publish_port,
368 base_url=base_url,
369 static_routes=routes,
370 # add assets subdir as static_content
371 static_content=("/assets", os.path.join(frontend_dir, "assets"), "assets"),
372 ingress_tcp_site_params=ingress_tcp_site_params,
373 # Add mass object to app for use in auth middleware
374 app_state={"mass": self.mass},
375 ssl_context=ssl_context,
376 )
377 if self.mass.running_as_hass_addon:
378 # (re)announce to HA supervisor to make sure that HA picks it up
379 await self._announce_to_homeassistant()
380
381 # Setup remote access after webserver is running
382 await self.remote_access.setup()
383
384 async def close(self) -> None:
385 """Cleanup on exit."""
386 await self.remote_access.close()
387 for client in set(self.clients):
388 await client.disconnect()
389 await self._server.close()
390 await self.auth.close()
391
392 def register_websocket_client(self, client: WebsocketClientHandler) -> None:
393 """Register a WebSocket client for tracking."""
394 self.clients.add(client)
395
396 def unregister_websocket_client(self, client: WebsocketClientHandler) -> None:
397 """Unregister a WebSocket client."""
398 self.clients.discard(client)
399
400 def disconnect_websockets_for_token(self, token_id: str) -> None:
401 """Disconnect all WebSocket clients using a specific token."""
402 for client in list(self.clients):
403 if hasattr(client, "_token_id") and client._token_id == token_id:
404 username = (
405 client._authenticated_user.username if client._authenticated_user else "unknown"
406 )
407 self.logger.warning(
408 "Disconnecting WebSocket client due to token revocation: %s",
409 username,
410 )
411 client._cancel()
412
413 def disconnect_websockets_for_user(self, user_id: str) -> None:
414 """Disconnect all WebSocket clients for a specific user."""
415 for client in list(self.clients):
416 if (
417 hasattr(client, "_authenticated_user")
418 and client._authenticated_user
419 and client._authenticated_user.user_id == user_id
420 ):
421 self.logger.warning(
422 "Disconnecting WebSocket client due to user action: %s",
423 client._authenticated_user.username,
424 )
425 client._cancel()
426
427 def set_sendspin_player_for_user(self, user_id: str, player_id: str) -> None:
428 """Set the sendspin player_id on websocket clients for a specific user.
429
430 This is called by the sendspin proxy when a client connects, allowing
431 the player controller to auto-whitelist the player for that user's session.
432
433 :param user_id: The user ID to set the sendspin player for.
434 :param player_id: The sendspin player ID to set.
435 """
436 for client in list(self.clients):
437 if client._authenticated_user and client._authenticated_user.user_id == user_id:
438 client._sendspin_player_id = player_id
439 self.logger.debug(
440 "Set sendspin player %s for websocket client of user %s",
441 player_id,
442 client._authenticated_user.username,
443 )
444
445 def set_sendspin_player_for_webrtc_session(self, session_id: str, player_id: str) -> None:
446 """Set the sendspin player_id on a websocket client for a WebRTC session.
447
448 This is called by the WebRTC gateway when it extracts the client_id from
449 the sendspin auth message, allowing auto-whitelisting of the player.
450
451 :param session_id: The WebRTC session ID.
452 :param player_id: The sendspin player ID to set.
453 """
454 for client in list(self.clients):
455 if client._webrtc_session_id == session_id:
456 client._sendspin_player_id = player_id
457 username = (
458 client._authenticated_user.username
459 if client._authenticated_user
460 else "unauthenticated"
461 )
462 self.logger.debug(
463 "Set sendspin player %s for WebRTC session %s (user: %s)",
464 player_id,
465 session_id,
466 username,
467 )
468 return
469
470 async def serve_preview_stream(self, request: web.Request) -> web.StreamResponse:
471 """Serve short preview sample."""
472 provider_instance_id_or_domain = request.query["provider"]
473 item_id = urllib.parse.unquote(request.query["item_id"])
474 resp = web.StreamResponse(status=200, reason="OK", headers={"Content-Type": "audio/aac"})
475 await resp.prepare(request)
476 async for chunk in get_preview_stream(self.mass, provider_instance_id_or_domain, item_id):
477 await resp.write(chunk)
478 return resp
479
480 async def _handle_cors_preflight(self, request: web.Request) -> web.Response:
481 """Handle CORS preflight OPTIONS request."""
482 return web.Response(
483 status=200,
484 headers={
485 "Access-Control-Allow-Origin": "*",
486 "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
487 "Access-Control-Allow-Headers": "Content-Type, Authorization",
488 "Access-Control-Max-Age": "86400", # Cache preflight for 24 hours
489 },
490 )
491
492 async def _handle_server_info(self, request: web.Request) -> web.Response:
493 """Handle request for server info."""
494 server_info = self.mass.get_server_info()
495 # Add CORS headers to allow frontend to call from any origin
496 return web.json_response(
497 server_info.to_dict(),
498 headers={
499 "Access-Control-Allow-Origin": "*",
500 "Access-Control-Allow-Methods": "GET, OPTIONS",
501 "Access-Control-Allow-Headers": "Content-Type, Authorization",
502 },
503 )
504
505 async def _handle_ws_client(self, request: web.Request) -> web.WebSocketResponse:
506 connection = WebsocketClientHandler(self, request)
507 if lang := request.headers.get("Accept-Language"):
508 self.mass.metadata.set_default_preferred_language(lang.split(",")[0])
509 try:
510 self.clients.add(connection)
511 return await connection.handle_client()
512 finally:
513 self.clients.discard(connection)
514
515 async def _handle_jsonrpc_api_command(self, request: web.Request) -> web.Response:
516 """Handle incoming JSON RPC API command."""
517 # Fail early if we don't have any users yet
518 if not self.auth.has_users:
519 return web.Response(status=503, text="Setup required")
520 if not request.can_read_body:
521 return web.Response(status=400, text="Body required")
522 cmd_data = await request.read()
523 self.logger.log(VERBOSE_LOG_LEVEL, "Received on JSONRPC API: %s", cmd_data)
524 try:
525 command_msg = CommandMessage.from_json(cmd_data)
526 except ValueError:
527 error = f"Invalid JSON: {cmd_data.decode()}"
528 self.logger.error("Unhandled JSONRPC API error: %s", error)
529 return web.Response(status=400, text=error)
530 except MissingField as e:
531 # be forgiving if message_id is missing
532 cmd_data_dict = json_loads(cmd_data)
533 if e.field_name == "message_id" and "command" in cmd_data_dict:
534 cmd_data_dict["message_id"] = "unknown"
535 command_msg = CommandMessage.from_dict(cmd_data_dict)
536 else:
537 error = f"Missing field in JSON: {e.field_name}"
538 self.logger.error("Unhandled JSONRPC API error: %s", error)
539 return web.Response(status=400, text="Invalid JSON: missing required field")
540
541 # work out handler for the given path/command
542 handler = self.mass.command_handlers.get(command_msg.command)
543 if handler is None:
544 error = f"Invalid Command: {command_msg.command}"
545 self.logger.error("Unhandled JSONRPC API error: %s", error)
546 return web.Response(status=400, text=error)
547
548 # Check authentication if required
549 if handler.authenticated or handler.required_role:
550 try:
551 user = await get_authenticated_user(request)
552 except Exception as e:
553 self.logger.exception("Authentication error: %s", e)
554 return web.Response(
555 status=401,
556 text="Authentication failed",
557 headers={"WWW-Authenticate": 'Bearer realm="Music Assistant"'},
558 )
559
560 if not user:
561 return web.Response(
562 status=401,
563 text="Authentication required",
564 headers={"WWW-Authenticate": 'Bearer realm="Music Assistant"'},
565 )
566
567 # Set user in context and check role
568 set_current_user(user)
569 if handler.required_role == "admin" and user.role != UserRole.ADMIN:
570 return web.Response(
571 status=403,
572 text="Admin access required",
573 )
574
575 try:
576 args = parse_arguments(handler.signature, handler.type_hints, command_msg.args)
577 result: Any = handler.target(**args)
578 if hasattr(result, "__anext__"):
579 # handle async generator (for really large listings)
580 result = [item async for item in result]
581 elif inspect.iscoroutine(result):
582 result = await result
583 return web.json_response(result, dumps=json_dumps)
584 except Exception as e:
585 # Return clean error message without stacktrace
586 error_type = type(e).__name__
587 error_msg = str(e)
588 error = f"{error_type}: {error_msg}"
589 self.logger.exception("Error executing command %s: %s", command_msg.command, error)
590 return web.Response(status=500, text="Internal server error")
591
592 async def _handle_api_intro(self, request: web.Request) -> web.Response:
593 """Handle request for API introduction/documentation page."""
594 intro_html_path = str(RESOURCES_DIR.joinpath("api_docs.html"))
595 # Read the template
596 async with aiofiles.open(intro_html_path) as f:
597 html_content = await f.read()
598
599 # Replace placeholders (escape values to prevent XSS)
600 html_content = html_content.replace("{VERSION}", html.escape(self.mass.version))
601 html_content = html_content.replace("{BASE_URL}", html.escape(self.base_url))
602 html_content = html_content.replace("{SERVER_HOST}", html.escape(request.host))
603
604 return web.Response(text=html_content, content_type="text/html")
605
606 async def _handle_openapi_spec(self, request: web.Request) -> web.Response:
607 """Handle request for OpenAPI specification (generated on-the-fly)."""
608 spec = generate_openapi_spec(
609 self.mass.command_handlers, server_url=self.base_url, version=self.mass.version
610 )
611 return web.json_response(spec)
612
613 async def _handle_commands_reference(self, request: web.Request) -> web.FileResponse:
614 """Handle request for commands reference page."""
615 commands_html_path = str(RESOURCES_DIR.joinpath("commands_reference.html"))
616 return await self._server.serve_static(commands_html_path, request)
617
618 async def _handle_commands_json(self, request: web.Request) -> web.Response:
619 """Handle request for commands JSON data (generated on-the-fly)."""
620 commands_data = generate_commands_json(self.mass.command_handlers)
621 return web.json_response(commands_data)
622
623 async def _handle_schemas_reference(self, request: web.Request) -> web.FileResponse:
624 """Handle request for schemas reference page."""
625 schemas_html_path = str(RESOURCES_DIR.joinpath("schemas_reference.html"))
626 return await self._server.serve_static(schemas_html_path, request)
627
628 async def _handle_schemas_json(self, request: web.Request) -> web.Response:
629 """Handle request for schemas JSON data (generated on-the-fly)."""
630 schemas_data = generate_schemas_json(self.mass.command_handlers)
631 return web.json_response(schemas_data)
632
633 async def _handle_swagger_ui(self, request: web.Request) -> web.FileResponse:
634 """Handle request for Swagger UI."""
635 swagger_html_path = str(RESOURCES_DIR.joinpath("swagger_ui.html"))
636 return await self._server.serve_static(swagger_html_path, request)
637
638 async def _render_error_page(self, error_message: str, status: int = 403) -> web.Response:
639 """Render a user-friendly error page with the given message.
640
641 :param error_message: The error message to display to the user.
642 :param status: HTTP status code for the response.
643 """
644 error_html_path = str(RESOURCES_DIR.joinpath("error.html"))
645 async with aiofiles.open(error_html_path) as f:
646 html_content = await f.read()
647 # Replace placeholder with the actual error message (escape to prevent XSS)
648 html_content = html_content.replace("{{ERROR_MESSAGE}}", html.escape(error_message))
649 return web.Response(text=html_content, content_type="text/html", status=status)
650
651 async def _handle_index(self, request: web.Request) -> web.StreamResponse:
652 """Handle request for index page (Vue frontend)."""
653 is_ingress_request = is_request_from_ingress(request)
654
655 if (not self.auth.has_users or not self.mass.config.onboard_done) and is_ingress_request:
656 # a non-admin user tries to access the index via HA ingress
657 # while we're not yet onboarded, prevent that as it leads to a bad UX
658 ingress_user_id = request.headers.get("X-Remote-User-ID", "")
659 role = await get_ha_user_role(self.mass, ingress_user_id)
660 if role != UserRole.ADMIN:
661 return await self._render_error_page(
662 "Administrator permissions are required to complete the initial setup. "
663 "Please ask a Home Assistant administrator to complete the setup first."
664 )
665 # NOTE: For ingress admin user,
666 # we allow access to index, user will be auto created and then forwarded to the
667 # frontend (which will take care of onboarding)
668
669 if not self.auth.has_users and not is_ingress_request:
670 # non ingress request and no users yet, redirect to setup
671 return web.Response(status=302, headers={"Location": "setup"})
672
673 # Serve the Vue frontend index.html
674 return await self._server.serve_static(self._index_path, request)
675
676 async def _handle_login_page(self, request: web.Request) -> web.Response:
677 """Handle request for login page (external client OAuth callback scenario)."""
678 if not self.auth.has_users:
679 # not yet onboarded (no first admin user exists), redirect to setup
680 return_url = request.query.get("return_url", "")
681 device_name = request.query.get("device_name", "")
682 setup_url = (
683 f"/setup?return_url={return_url}&device_name={device_name}"
684 if return_url
685 else "/setup"
686 )
687 return web.Response(status=302, headers={"Location": setup_url})
688 # Serve login page for external clients
689 login_html_path = str(RESOURCES_DIR.joinpath("login.html"))
690 async with aiofiles.open(login_html_path) as f:
691 html_content = await f.read()
692 return web.Response(text=html_content, content_type="text/html")
693
694 async def _handle_auth_login(self, request: web.Request) -> web.Response:
695 """Handle login request."""
696 # Block until onboarding is complete
697 if not self.auth.has_users:
698 return web.json_response(
699 {"success": False, "error": "Setup required"},
700 status=403,
701 headers={
702 "Access-Control-Allow-Origin": "*",
703 "Access-Control-Allow-Methods": "POST, OPTIONS",
704 "Access-Control-Allow-Headers": "Content-Type, Authorization",
705 },
706 )
707
708 try:
709 if not request.can_read_body:
710 return web.Response(status=400, text="Body required")
711
712 body = await request.json()
713 provider_id = body.get("provider_id", "builtin") # Default to built-in provider
714 credentials = body.get("credentials", {})
715 return_url = body.get("return_url") # Optional return URL for redirect after login
716
717 # Authenticate with provider
718 auth_result = await self.auth.authenticate_with_credentials(provider_id, credentials)
719
720 if not auth_result.success or not auth_result.user:
721 return web.json_response(
722 {"success": False, "error": auth_result.error},
723 status=401,
724 headers={
725 "Access-Control-Allow-Origin": "*",
726 "Access-Control-Allow-Methods": "POST, OPTIONS",
727 "Access-Control-Allow-Headers": "Content-Type, Authorization",
728 },
729 )
730
731 # Create token for user
732 device_name = body.get(
733 "device_name", f"{request.headers.get('User-Agent', 'Unknown')[:50]}"
734 )
735 token = await self.auth.create_token(auth_result.user, device_name)
736
737 # Prepare response data
738 response_data = {
739 "success": True,
740 "token": token,
741 "user": auth_result.user.to_dict(),
742 }
743
744 # If return_url provided, append code parameter and return as redirect_to
745 if return_url:
746 # Insert code parameter before any hash fragment
747 code_param = f"code={quote(token, safe='')}"
748 if "#" in return_url:
749 url_parts = return_url.split("#", 1)
750 base_part = url_parts[0]
751 hash_part = url_parts[1]
752 separator = "&" if "?" in base_part else "?"
753 redirect_url = f"{base_part}{separator}{code_param}#{hash_part}"
754 elif "?" in return_url:
755 redirect_url = f"{return_url}&{code_param}"
756 else:
757 redirect_url = f"{return_url}?{code_param}"
758
759 response_data["redirect_to"] = redirect_url
760 self.logger.debug(
761 "Login successful, returning redirect_to: %s",
762 redirect_url.replace(token, "***TOKEN***"),
763 )
764
765 # Add CORS headers to allow login from any origin
766 return web.json_response(
767 response_data,
768 headers={
769 "Access-Control-Allow-Origin": "*",
770 "Access-Control-Allow-Methods": "POST, OPTIONS",
771 "Access-Control-Allow-Headers": "Content-Type, Authorization",
772 },
773 )
774 except Exception:
775 self.logger.exception("Error during login")
776 return web.json_response(
777 {"success": False, "error": "Login failed"},
778 status=500,
779 headers={
780 "Access-Control-Allow-Origin": "*",
781 "Access-Control-Allow-Methods": "POST, OPTIONS",
782 "Access-Control-Allow-Headers": "Content-Type, Authorization",
783 },
784 )
785
786 async def _handle_auth_logout(self, request: web.Request) -> web.Response:
787 """Handle logout request."""
788 user = await get_authenticated_user(request)
789 if not user:
790 return web.Response(status=401, text="Not authenticated")
791
792 # Get token from request
793 auth_header = request.headers.get("Authorization", "")
794 if auth_header.startswith("Bearer "):
795 token = auth_header[7:]
796 # Find and revoke the token
797 token_hash = hashlib.sha256(token.encode()).hexdigest()
798 token_row = await self.auth.database.get_row("auth_tokens", {"token_hash": token_hash})
799 if token_row:
800 await self.auth.database.delete("auth_tokens", {"token_id": token_row["token_id"]})
801
802 return web.json_response({"success": True})
803
804 async def _handle_auth_me(self, request: web.Request) -> web.Response:
805 """Handle request for current user information."""
806 user = await get_authenticated_user(request)
807 if not user:
808 return web.Response(status=401, text="Not authenticated")
809
810 return web.json_response(user.to_dict())
811
812 async def _handle_auth_me_update(self, request: web.Request) -> web.Response:
813 """Handle request to update current user's profile."""
814 user = await get_authenticated_user(request)
815 if not user:
816 return web.Response(status=401, text="Not authenticated")
817
818 try:
819 if not request.can_read_body:
820 return web.Response(status=400, text="Body required")
821
822 body = await request.json()
823 username = body.get("username")
824 display_name = body.get("display_name")
825 avatar_url = body.get("avatar_url")
826
827 # Update user
828 updated_user = await self.auth.update_user(
829 user,
830 username=username,
831 display_name=display_name,
832 avatar_url=avatar_url,
833 )
834
835 return web.json_response({"success": True, "user": updated_user.to_dict()})
836 except Exception:
837 self.logger.exception("Error updating user profile")
838 return web.json_response(
839 {"success": False, "error": "Failed to update profile"}, status=500
840 )
841
842 async def _handle_auth_providers(self, request: web.Request) -> web.Response:
843 """Handle request for available login providers."""
844 try:
845 providers = await self.auth.get_login_providers()
846 return web.json_response(providers)
847 except Exception:
848 self.logger.exception("Error getting auth providers")
849 return web.json_response({"error": "Failed to get auth providers"}, status=500)
850
851 async def _handle_auth_authorize(self, request: web.Request) -> web.Response:
852 """Handle OAuth authorization request."""
853 try:
854 provider_id = request.query.get("provider_id")
855 return_url = request.query.get("return_url")
856
857 self.logger.debug(
858 "OAuth authorize request: provider_id=%s, return_url=%s", provider_id, return_url
859 )
860
861 if not provider_id:
862 return web.Response(status=400, text="provider_id required")
863
864 # Validate return_url if provided
865 if return_url:
866 is_valid, _ = is_allowed_redirect_url(return_url, request, self.base_url)
867 if not is_valid:
868 return web.Response(status=400, text="Invalid return_url")
869
870 auth_url = await self.auth.get_authorization_url(provider_id, return_url)
871 if not auth_url:
872 return web.Response(
873 status=400, text="Provider does not support OAuth or is not configured"
874 )
875
876 return web.json_response({"authorization_url": auth_url})
877 except Exception:
878 self.logger.exception("Error during OAuth authorization")
879 return web.json_response({"error": "Authorization failed"}, status=500)
880
881 async def _handle_auth_callback(self, request: web.Request) -> web.Response:
882 """Handle OAuth callback."""
883 try:
884 code = request.query.get("code")
885 state = request.query.get("state")
886 provider_id = request.query.get("provider_id")
887
888 if not code or not state or not provider_id:
889 return web.Response(status=400, text="code, state, and provider_id required")
890
891 redirect_uri = f"{self.base_url}/auth/callback?provider_id={provider_id}"
892 auth_result = await self.auth.handle_oauth_callback(
893 provider_id, code, state, redirect_uri
894 )
895
896 if not auth_result.success or not auth_result.user:
897 # Return error page
898 error_html = f"""
899 <html>
900 <body>
901 <h1>Authentication Failed</h1>
902 <p>{html.escape(auth_result.error or "Unknown error")}</p>
903 <a href="/login">Back to Login</a>
904 </body>
905 </html>
906 """
907 return web.Response(text=error_html, content_type="text/html", status=400)
908
909 # Create token
910 device_name = f"OAuth ({provider_id})"
911 token = await self.auth.create_token(auth_result.user, device_name)
912
913 # Determine redirect URL (use return_url from OAuth flow or default to root)
914 final_redirect_url = auth_result.return_url or "/"
915 requires_consent = False
916
917 # Validate redirect URL for security
918 if auth_result.return_url:
919 is_valid, category = is_allowed_redirect_url(
920 auth_result.return_url, request, self.base_url
921 )
922 if not is_valid:
923 self.logger.warning("Invalid return_url blocked: %s", auth_result.return_url)
924 final_redirect_url = "/"
925 elif category == "external":
926 # External domain - require user consent
927 requires_consent = True
928 # Add code parameter to redirect URL (the token URL-encoded)
929 # Important: Insert code BEFORE any hash fragment (e.g., #/) to ensure
930 # it's in query params, not inside the hash where Vue Router can't access it
931 code_param = f"code={quote(token, safe='')}"
932
933 # Split URL by hash to insert code in the right place
934 if "#" in final_redirect_url:
935 # URL has a hash fragment (e.g., http://example.com/#/ or http://example.com/path#section)
936 url_parts = final_redirect_url.split("#", 1)
937 base_url = url_parts[0]
938 hash_part = url_parts[1]
939
940 # Add code to base URL (before hash)
941 separator = "&" if "?" in base_url else "?"
942 final_redirect_url = f"{base_url}{separator}{code_param}#{hash_part}"
943 # No hash fragment, simple case
944 elif "?" in final_redirect_url:
945 final_redirect_url = f"{final_redirect_url}&{code_param}"
946 else:
947 final_redirect_url = f"{final_redirect_url}?{code_param}"
948
949 # Load OAuth callback success page template and inject token and redirect URL
950 oauth_callback_html_path = str(RESOURCES_DIR.joinpath("oauth_callback.html"))
951 async with aiofiles.open(oauth_callback_html_path) as f:
952 success_html = await f.read()
953
954 # Replace template placeholders
955 success_html = success_html.replace("{TOKEN}", token)
956 success_html = success_html.replace("{REDIRECT_URL}", final_redirect_url)
957 success_html = success_html.replace(
958 "{REQUIRES_CONSENT}", "true" if requires_consent else "false"
959 )
960
961 return web.Response(text=success_html, content_type="text/html")
962 except Exception:
963 self.logger.exception("Error during OAuth callback")
964 error_html = """
965 <html>
966 <body>
967 <h1>Authentication Failed</h1>
968 <p>An error occurred during authentication</p>
969 <a href="/login">Back to Login</a>
970 </body>
971 </html>
972 """
973 return web.Response(text=error_html, content_type="text/html", status=500)
974
975 async def _handle_setup_page(self, request: web.Request) -> web.Response:
976 """Handle request for first-time setup page."""
977 # Validate return_url if provided
978 return_url = request.query.get("return_url")
979 if return_url:
980 is_valid, _ = is_allowed_redirect_url(return_url, request, self.base_url)
981 if not is_valid:
982 return web.Response(status=400, text="Invalid return_url")
983 else:
984 return_url = "/"
985
986 if self.auth.has_users:
987 # this should not happen, but guard anyways
988 return await self._render_error_page("Setup has already been completed.")
989
990 setup_html_path = str(RESOURCES_DIR.joinpath("setup.html"))
991 async with aiofiles.open(setup_html_path) as f:
992 html_content = await f.read()
993
994 return web.Response(text=html_content, content_type="text/html")
995
996 async def _handle_setup(self, request: web.Request) -> web.Response:
997 """Handle first-time setup request to create admin user (non-ingress only)."""
998 if self.auth.has_users:
999 return web.json_response(
1000 {"success": False, "error": "Setup already completed"}, status=400
1001 )
1002
1003 if not request.can_read_body:
1004 return web.Response(status=400, text="Body required")
1005
1006 body = await request.json()
1007 username = body.get("username", "").strip()
1008 password = body.get("password", "")
1009
1010 # Validation
1011 if not username or len(username) < 2:
1012 return web.json_response(
1013 {"success": False, "error": "Username must be at least 2 characters"}, status=400
1014 )
1015
1016 if not password or len(password) < 8:
1017 return web.json_response(
1018 {"success": False, "error": "Password must be at least 8 characters"}, status=400
1019 )
1020
1021 try:
1022 builtin_provider = self.auth.login_providers.get("builtin")
1023 if not builtin_provider:
1024 return web.json_response(
1025 {"success": False, "error": "Built-in auth provider not available"},
1026 status=500,
1027 )
1028
1029 if not isinstance(builtin_provider, BuiltinLoginProvider):
1030 return web.json_response(
1031 {"success": False, "error": "Built-in provider configuration error"},
1032 status=500,
1033 )
1034
1035 # Create admin user with password
1036 user = await builtin_provider.create_user_with_password(
1037 username, password, role=UserRole.ADMIN
1038 )
1039
1040 # Create token for the new admin
1041 device_name = body.get(
1042 "device_name", f"Setup ({request.headers.get('User-Agent', 'Unknown')[:50]})"
1043 )
1044 token = await self.auth.create_token(user, device_name)
1045
1046 self.logger.info("First admin user created: %s", username)
1047
1048 # Return token - frontend will complete onboarding via config/onboard_complete
1049 return web.json_response(
1050 {
1051 "success": True,
1052 "token": token,
1053 "user": user.to_dict(),
1054 }
1055 )
1056
1057 except Exception as e:
1058 self.logger.exception("Error during setup")
1059 return web.json_response(
1060 {"success": False, "error": f"Setup failed: {e!s}"}, status=500
1061 )
1062
1063 async def _announce_to_homeassistant(self) -> None:
1064 """Announce Music Assistant Ingress server to Home Assistant via Supervisor API."""
1065 supervisor_token = os.environ["SUPERVISOR_TOKEN"]
1066 addon_hostname = os.environ["HOSTNAME"]
1067 # Get or create auth token for the HA system user
1068 ha_integration_token = await self.auth.get_homeassistant_system_user_token()
1069 discovery_payload = {
1070 "service": "music_assistant",
1071 "config": {
1072 "host": addon_hostname,
1073 "port": INGRESS_SERVER_PORT,
1074 "auth_token": ha_integration_token,
1075 },
1076 }
1077 try:
1078 async with self.mass.http_session_no_ssl.post(
1079 "http://supervisor/discovery",
1080 headers={"Authorization": f"Bearer {supervisor_token}"},
1081 json=discovery_payload,
1082 timeout=ClientTimeout(total=10),
1083 ) as response:
1084 response.raise_for_status()
1085 result = await response.json()
1086 self.logger.debug(
1087 "Successfully announced to Home Assistant. Discovery UUID: %s",
1088 result.get("uuid"),
1089 )
1090 except Exception as err:
1091 self.logger.warning("Failed to announce to Home Assistant: %s", err)
1092