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