/
/
/
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 routes.append(("HEAD", "/", self._handle_index))
261 # add logo
262 logo_path = str(RESOURCES_DIR.joinpath("logo.png"))
263 handler = partial(self._server.serve_static, logo_path)
264 routes.append(("GET", "/logo.png", handler))
265 # add common CSS for HTML resources
266 common_css_path = str(RESOURCES_DIR.joinpath("common.css"))
267 handler = partial(self._server.serve_static, common_css_path)
268 routes.append(("GET", "/resources/common.css", handler))
269 # add info
270 routes.append(("GET", "/info", self._handle_server_info))
271 routes.append(("OPTIONS", "/info", self._handle_cors_preflight))
272 # add websocket api
273 routes.append(("GET", "/ws", self._handle_ws_client))
274 # also host the image proxy on the webserver
275 routes.append(("GET", "/imageproxy", self.mass.metadata.handle_imageproxy))
276 # also host the audio preview service
277 routes.append(("GET", "/preview", self.serve_preview_stream))
278 # add jsonrpc api
279 routes.append(("POST", "/api", self._handle_jsonrpc_api_command))
280 # add api documentation
281 routes.append(("GET", "/api-docs", self._handle_api_intro))
282 routes.append(("GET", "/api-docs/", self._handle_api_intro))
283 routes.append(("GET", "/api-docs/commands", self._handle_commands_reference))
284 routes.append(("GET", "/api-docs/commands/", self._handle_commands_reference))
285 routes.append(("GET", "/api-docs/commands.json", self._handle_commands_json))
286 routes.append(("GET", "/api-docs/schemas", self._handle_schemas_reference))
287 routes.append(("GET", "/api-docs/schemas/", self._handle_schemas_reference))
288 routes.append(("GET", "/api-docs/schemas.json", self._handle_schemas_json))
289 routes.append(("GET", "/api-docs/openapi.json", self._handle_openapi_spec))
290 routes.append(("GET", "/api-docs/swagger", self._handle_swagger_ui))
291 routes.append(("GET", "/api-docs/swagger/", self._handle_swagger_ui))
292 # add authentication routes
293 routes.append(("GET", "/login", self._handle_login_page))
294 routes.append(("POST", "/auth/login", self._handle_auth_login))
295 routes.append(("OPTIONS", "/auth/login", self._handle_cors_preflight))
296 routes.append(("POST", "/auth/logout", self._handle_auth_logout))
297 routes.append(("GET", "/auth/me", self._handle_auth_me))
298 routes.append(("PATCH", "/auth/me", self._handle_auth_me_update))
299 routes.append(("GET", "/auth/providers", self._handle_auth_providers))
300 routes.append(("GET", "/auth/authorize", self._handle_auth_authorize))
301 routes.append(("GET", "/auth/callback", self._handle_auth_callback))
302 # add first-time setup routes
303 routes.append(("GET", "/setup", self._handle_setup_page))
304 routes.append(("POST", "/setup", self._handle_setup))
305 # add sendspin proxy route (authenticated WebSocket proxy to internal sendspin server)
306 routes.append(("GET", "/sendspin", self._sendspin_proxy.handle_sendspin_proxy))
307 await self.auth.setup()
308 # start the webserver
309 all_ip_addresses = await get_ip_addresses(include_ipv6=True)
310 default_publish_ip = all_ip_addresses[0]
311 if self.mass.running_as_hass_addon:
312 # if we're running on the HA supervisor we start an additional TCP site
313 # on the internal ("172.30.32.) IP for the HA ingress proxy
314 ingress_host = next(
315 (x for x in all_ip_addresses if x.startswith("172.30.32.")), default_publish_ip
316 )
317 ingress_tcp_site_params = (ingress_host, INGRESS_SERVER_PORT)
318 else:
319 ingress_tcp_site_params = None
320 base_url = str(config.get_value(CONF_BASE_URL))
321 port_value = config.get_value(CONF_BIND_PORT)
322 assert isinstance(port_value, int)
323 self.publish_port = port_value
324 self.publish_ip = default_publish_ip
325 bind_ip = cast("str | None", config.get_value(CONF_BIND_IP))
326 # print a big fat message in the log where the webserver is running
327 # because this is a common source of issues for people with more complex setups
328 if not self.auth.has_users:
329 self.logger.warning(
330 "\n\n################################################################################\n"
331 "### SETUP REQUIRED ###\n"
332 "################################################################################\n"
333 "\n"
334 "Music Assistant is running in setup mode.\n"
335 "Please complete the setup by visiting:\n"
336 "\n"
337 " %s/setup\n"
338 "\n"
339 "################################################################################\n",
340 base_url,
341 )
342 else:
343 self.logger.info(
344 "\n"
345 "################################################################################\n"
346 "\n"
347 "Webserver available on: %s\n"
348 "\n"
349 "If this address is incorrect, see the documentation on how to configure\n"
350 "the Webserver in Settings --> Core modules --> Webserver\n"
351 "\n"
352 "################################################################################\n",
353 base_url,
354 )
355
356 # Create SSL context if SSL is enabled
357 ssl_context = None
358 ssl_enabled = config.get_value(CONF_ENABLE_SSL, False)
359 if ssl_enabled:
360 ssl_context = await create_server_ssl_context(
361 str(config.get_value(CONF_SSL_CERTIFICATE) or ""),
362 str(config.get_value(CONF_SSL_PRIVATE_KEY) or ""),
363 logger=self.logger,
364 )
365
366 await self._server.setup(
367 bind_ip=bind_ip,
368 bind_port=self.publish_port,
369 base_url=base_url,
370 static_routes=routes,
371 # add assets subdir as static_content
372 static_content=("/assets", os.path.join(frontend_dir, "assets"), "assets"),
373 ingress_tcp_site_params=ingress_tcp_site_params,
374 # Add mass object to app for use in auth middleware
375 app_state={"mass": self.mass},
376 ssl_context=ssl_context,
377 )
378 if self.mass.running_as_hass_addon:
379 # (re)announce to HA supervisor to make sure that HA picks it up
380 await self._announce_to_homeassistant()
381
382 # Setup remote access after webserver is running
383 await self.remote_access.setup()
384
385 async def close(self) -> None:
386 """Cleanup on exit."""
387 await self.remote_access.close()
388 for client in set(self.clients):
389 await client.disconnect()
390 await self._server.close()
391 await self.auth.close()
392
393 def register_websocket_client(self, client: WebsocketClientHandler) -> None:
394 """Register a WebSocket client for tracking."""
395 self.clients.add(client)
396
397 def unregister_websocket_client(self, client: WebsocketClientHandler) -> None:
398 """Unregister a WebSocket client."""
399 self.clients.discard(client)
400
401 def disconnect_websockets_for_token(self, token_id: str) -> None:
402 """Disconnect all WebSocket clients using a specific token."""
403 for client in list(self.clients):
404 if hasattr(client, "_token_id") and client._token_id == token_id:
405 username = (
406 client._authenticated_user.username if client._authenticated_user else "unknown"
407 )
408 self.logger.warning(
409 "Disconnecting WebSocket client due to token revocation: %s",
410 username,
411 )
412 client._cancel()
413
414 def disconnect_websockets_for_user(self, user_id: str) -> None:
415 """Disconnect all WebSocket clients for a specific user."""
416 for client in list(self.clients):
417 if (
418 hasattr(client, "_authenticated_user")
419 and client._authenticated_user
420 and client._authenticated_user.user_id == user_id
421 ):
422 self.logger.warning(
423 "Disconnecting WebSocket client due to user action: %s",
424 client._authenticated_user.username,
425 )
426 client._cancel()
427
428 def set_sendspin_player_for_user(self, user_id: str, player_id: str) -> None:
429 """Set the sendspin player_id on websocket clients for a specific user.
430
431 This is called by the sendspin proxy when a client connects, allowing
432 the player controller to auto-whitelist the player for that user's session.
433
434 :param user_id: The user ID to set the sendspin player for.
435 :param player_id: The sendspin player ID to set.
436 """
437 for client in list(self.clients):
438 if client._authenticated_user and client._authenticated_user.user_id == user_id:
439 client._sendspin_player_id = player_id
440 self.logger.debug(
441 "Set sendspin player %s for websocket client of user %s",
442 player_id,
443 client._authenticated_user.username,
444 )
445
446 def set_sendspin_player_for_webrtc_session(self, session_id: str, player_id: str) -> None:
447 """Set the sendspin player_id on a websocket client for a WebRTC session.
448
449 This is called by the WebRTC gateway when it extracts the client_id from
450 the sendspin auth message, allowing auto-whitelisting of the player.
451
452 :param session_id: The WebRTC session ID.
453 :param player_id: The sendspin player ID to set.
454 """
455 for client in list(self.clients):
456 if client._webrtc_session_id == session_id:
457 client._sendspin_player_id = player_id
458 username = (
459 client._authenticated_user.username
460 if client._authenticated_user
461 else "unauthenticated"
462 )
463 self.logger.debug(
464 "Set sendspin player %s for WebRTC session %s (user: %s)",
465 player_id,
466 session_id,
467 username,
468 )
469 return
470
471 async def serve_preview_stream(self, request: web.Request) -> web.StreamResponse:
472 """Serve short preview sample."""
473 provider_instance_id_or_domain = request.query["provider"]
474 item_id = urllib.parse.unquote(request.query["item_id"])
475 resp = web.StreamResponse(status=200, reason="OK", headers={"Content-Type": "audio/aac"})
476 await resp.prepare(request)
477 async for chunk in get_preview_stream(self.mass, provider_instance_id_or_domain, item_id):
478 await resp.write(chunk)
479 return resp
480
481 async def _handle_cors_preflight(self, request: web.Request) -> web.Response:
482 """Handle CORS preflight OPTIONS request."""
483 return web.Response(
484 status=200,
485 headers={
486 "Access-Control-Allow-Origin": "*",
487 "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
488 "Access-Control-Allow-Headers": "Content-Type, Authorization",
489 "Access-Control-Max-Age": "86400", # Cache preflight for 24 hours
490 },
491 )
492
493 async def _handle_server_info(self, request: web.Request) -> web.Response:
494 """Handle request for server info."""
495 server_info = self.mass.get_server_info()
496 # Add CORS headers to allow frontend to call from any origin
497 return web.json_response(
498 server_info.to_dict(),
499 headers={
500 "Access-Control-Allow-Origin": "*",
501 "Access-Control-Allow-Methods": "GET, OPTIONS",
502 "Access-Control-Allow-Headers": "Content-Type, Authorization",
503 },
504 )
505
506 async def _handle_ws_client(self, request: web.Request) -> web.WebSocketResponse:
507 connection = WebsocketClientHandler(self, request)
508 if lang := request.headers.get("Accept-Language"):
509 self.mass.metadata.set_default_preferred_language(lang.split(",")[0])
510 try:
511 self.clients.add(connection)
512 return await connection.handle_client()
513 finally:
514 self.clients.discard(connection)
515
516 async def _handle_jsonrpc_api_command(self, request: web.Request) -> web.Response:
517 """Handle incoming JSON RPC API command."""
518 # Fail early if we don't have any users yet
519 if not self.auth.has_users:
520 return web.Response(status=503, text="Setup required")
521 if not request.can_read_body:
522 return web.Response(status=400, text="Body required")
523 cmd_data = await request.read()
524 self.logger.log(VERBOSE_LOG_LEVEL, "Received on JSONRPC API: %s", cmd_data)
525 try:
526 command_msg = CommandMessage.from_json(cmd_data)
527 except ValueError:
528 error = f"Invalid JSON: {cmd_data.decode()}"
529 self.logger.error("Unhandled JSONRPC API error: %s", error)
530 return web.Response(status=400, text=error)
531 except MissingField as e:
532 # be forgiving if message_id is missing
533 cmd_data_dict = json_loads(cmd_data)
534 if e.field_name == "message_id" and "command" in cmd_data_dict:
535 cmd_data_dict["message_id"] = "unknown"
536 command_msg = CommandMessage.from_dict(cmd_data_dict)
537 else:
538 error = f"Missing field in JSON: {e.field_name}"
539 self.logger.error("Unhandled JSONRPC API error: %s", error)
540 return web.Response(status=400, text="Invalid JSON: missing required field")
541
542 # work out handler for the given path/command
543 handler = self.mass.command_handlers.get(command_msg.command)
544 if handler is None:
545 error = f"Invalid Command: {command_msg.command}"
546 self.logger.error("Unhandled JSONRPC API error: %s", error)
547 return web.Response(status=400, text=error)
548
549 # Check authentication if required
550 if handler.authenticated or handler.required_role:
551 try:
552 user = await get_authenticated_user(request)
553 except Exception as e:
554 self.logger.exception("Authentication error: %s", e)
555 return web.Response(
556 status=401,
557 text="Authentication failed",
558 headers={"WWW-Authenticate": 'Bearer realm="Music Assistant"'},
559 )
560
561 if not user:
562 return web.Response(
563 status=401,
564 text="Authentication required",
565 headers={"WWW-Authenticate": 'Bearer realm="Music Assistant"'},
566 )
567
568 # Set user in context and check role
569 set_current_user(user)
570 if handler.required_role == "admin" and user.role != UserRole.ADMIN:
571 return web.Response(
572 status=403,
573 text="Admin access required",
574 )
575
576 try:
577 args = parse_arguments(handler.signature, handler.type_hints, command_msg.args)
578 result: Any = handler.target(**args)
579 if hasattr(result, "__anext__"):
580 # handle async generator (for really large listings)
581 result = [item async for item in result]
582 elif inspect.iscoroutine(result):
583 result = await result
584 return web.json_response(result, dumps=json_dumps)
585 except Exception as e:
586 # Return clean error message without stacktrace
587 error_type = type(e).__name__
588 error_msg = str(e)
589 error = f"{error_type}: {error_msg}"
590 self.logger.exception("Error executing command %s: %s", command_msg.command, error)
591 return web.Response(status=500, text="Internal server error")
592
593 async def _handle_api_intro(self, request: web.Request) -> web.Response:
594 """Handle request for API introduction/documentation page."""
595 intro_html_path = str(RESOURCES_DIR.joinpath("api_docs.html"))
596 # Read the template
597 async with aiofiles.open(intro_html_path) as f:
598 html_content = await f.read()
599
600 # Replace placeholders (escape values to prevent XSS)
601 html_content = html_content.replace("{VERSION}", html.escape(self.mass.version))
602 html_content = html_content.replace("{BASE_URL}", html.escape(self.base_url))
603 html_content = html_content.replace("{SERVER_HOST}", html.escape(request.host))
604
605 return web.Response(text=html_content, content_type="text/html")
606
607 async def _handle_openapi_spec(self, request: web.Request) -> web.Response:
608 """Handle request for OpenAPI specification (generated on-the-fly)."""
609 spec = generate_openapi_spec(
610 self.mass.command_handlers, server_url=self.base_url, version=self.mass.version
611 )
612 return web.json_response(spec)
613
614 async def _handle_commands_reference(self, request: web.Request) -> web.FileResponse:
615 """Handle request for commands reference page."""
616 commands_html_path = str(RESOURCES_DIR.joinpath("commands_reference.html"))
617 return await self._server.serve_static(commands_html_path, request)
618
619 async def _handle_commands_json(self, request: web.Request) -> web.Response:
620 """Handle request for commands JSON data (generated on-the-fly)."""
621 commands_data = generate_commands_json(self.mass.command_handlers)
622 return web.json_response(commands_data)
623
624 async def _handle_schemas_reference(self, request: web.Request) -> web.FileResponse:
625 """Handle request for schemas reference page."""
626 schemas_html_path = str(RESOURCES_DIR.joinpath("schemas_reference.html"))
627 return await self._server.serve_static(schemas_html_path, request)
628
629 async def _handle_schemas_json(self, request: web.Request) -> web.Response:
630 """Handle request for schemas JSON data (generated on-the-fly)."""
631 schemas_data = generate_schemas_json(self.mass.command_handlers)
632 return web.json_response(schemas_data)
633
634 async def _handle_swagger_ui(self, request: web.Request) -> web.FileResponse:
635 """Handle request for Swagger UI."""
636 swagger_html_path = str(RESOURCES_DIR.joinpath("swagger_ui.html"))
637 return await self._server.serve_static(swagger_html_path, request)
638
639 async def _render_error_page(self, error_message: str, status: int = 403) -> web.Response:
640 """Render a user-friendly error page with the given message.
641
642 :param error_message: The error message to display to the user.
643 :param status: HTTP status code for the response.
644 """
645 error_html_path = str(RESOURCES_DIR.joinpath("error.html"))
646 async with aiofiles.open(error_html_path) as f:
647 html_content = await f.read()
648 # Replace placeholder with the actual error message (escape to prevent XSS)
649 html_content = html_content.replace("{{ERROR_MESSAGE}}", html.escape(error_message))
650 return web.Response(text=html_content, content_type="text/html", status=status)
651
652 async def _handle_index(self, request: web.Request) -> web.StreamResponse:
653 """Handle request for index page (Vue frontend)."""
654 is_ingress_request = is_request_from_ingress(request)
655
656 if (not self.auth.has_users or not self.mass.config.onboard_done) and is_ingress_request:
657 # a non-admin user tries to access the index via HA ingress
658 # while we're not yet onboarded, prevent that as it leads to a bad UX
659 ingress_user_id = request.headers.get("X-Remote-User-ID", "")
660 role = await get_ha_user_role(self.mass, ingress_user_id)
661 if role != UserRole.ADMIN:
662 return await self._render_error_page(
663 "Administrator permissions are required to complete the initial setup. "
664 "Please ask a Home Assistant administrator to complete the setup first."
665 )
666 # NOTE: For ingress admin user,
667 # we allow access to index, user will be auto created and then forwarded to the
668 # frontend (which will take care of onboarding)
669
670 if not self.auth.has_users and not is_ingress_request:
671 # non ingress request and no users yet, redirect to setup
672 return web.Response(status=302, headers={"Location": "setup"})
673
674 # Serve the Vue frontend index.html
675 return await self._server.serve_static(self._index_path, request)
676
677 async def _handle_login_page(self, request: web.Request) -> web.Response:
678 """Handle request for login page (external client OAuth callback scenario)."""
679 if not self.auth.has_users:
680 # not yet onboarded (no first admin user exists), redirect to setup
681 return_url = request.query.get("return_url", "")
682 device_name = request.query.get("device_name", "")
683 setup_url = (
684 f"/setup?return_url={return_url}&device_name={device_name}"
685 if return_url
686 else "/setup"
687 )
688 return web.Response(status=302, headers={"Location": setup_url})
689 # Serve login page for external clients
690 login_html_path = str(RESOURCES_DIR.joinpath("login.html"))
691 async with aiofiles.open(login_html_path) as f:
692 html_content = await f.read()
693 return web.Response(text=html_content, content_type="text/html")
694
695 async def _handle_auth_login(self, request: web.Request) -> web.Response:
696 """Handle login request."""
697 # Block until onboarding is complete
698 if not self.auth.has_users:
699 return web.json_response(
700 {"success": False, "error": "Setup required"},
701 status=403,
702 headers={
703 "Access-Control-Allow-Origin": "*",
704 "Access-Control-Allow-Methods": "POST, OPTIONS",
705 "Access-Control-Allow-Headers": "Content-Type, Authorization",
706 },
707 )
708
709 try:
710 if not request.can_read_body:
711 return web.Response(status=400, text="Body required")
712
713 body = await request.json()
714 provider_id = body.get("provider_id", "builtin") # Default to built-in provider
715 credentials = body.get("credentials", {})
716 return_url = body.get("return_url") # Optional return URL for redirect after login
717
718 # Authenticate with provider
719 auth_result = await self.auth.authenticate_with_credentials(provider_id, credentials)
720
721 if not auth_result.success or not auth_result.user:
722 return web.json_response(
723 {"success": False, "error": auth_result.error},
724 status=401,
725 headers={
726 "Access-Control-Allow-Origin": "*",
727 "Access-Control-Allow-Methods": "POST, OPTIONS",
728 "Access-Control-Allow-Headers": "Content-Type, Authorization",
729 },
730 )
731
732 # Create token for user
733 device_name = body.get(
734 "device_name", f"{request.headers.get('User-Agent', 'Unknown')[:50]}"
735 )
736 token = await self.auth.create_token(auth_result.user, device_name)
737
738 # Prepare response data
739 response_data = {
740 "success": True,
741 "token": token,
742 "user": auth_result.user.to_dict(),
743 }
744
745 # If return_url provided, append code parameter and return as redirect_to
746 if return_url:
747 # Insert code parameter before any hash fragment
748 code_param = f"code={quote(token, safe='')}"
749 if "#" in return_url:
750 url_parts = return_url.split("#", 1)
751 base_part = url_parts[0]
752 hash_part = url_parts[1]
753 separator = "&" if "?" in base_part else "?"
754 redirect_url = f"{base_part}{separator}{code_param}#{hash_part}"
755 elif "?" in return_url:
756 redirect_url = f"{return_url}&{code_param}"
757 else:
758 redirect_url = f"{return_url}?{code_param}"
759
760 response_data["redirect_to"] = redirect_url
761 self.logger.debug(
762 "Login successful, returning redirect_to: %s",
763 redirect_url.replace(token, "***TOKEN***"),
764 )
765
766 # Add CORS headers to allow login from any origin
767 return web.json_response(
768 response_data,
769 headers={
770 "Access-Control-Allow-Origin": "*",
771 "Access-Control-Allow-Methods": "POST, OPTIONS",
772 "Access-Control-Allow-Headers": "Content-Type, Authorization",
773 },
774 )
775 except Exception:
776 self.logger.exception("Error during login")
777 return web.json_response(
778 {"success": False, "error": "Login failed"},
779 status=500,
780 headers={
781 "Access-Control-Allow-Origin": "*",
782 "Access-Control-Allow-Methods": "POST, OPTIONS",
783 "Access-Control-Allow-Headers": "Content-Type, Authorization",
784 },
785 )
786
787 async def _handle_auth_logout(self, request: web.Request) -> web.Response:
788 """Handle logout request."""
789 user = await get_authenticated_user(request)
790 if not user:
791 return web.Response(status=401, text="Not authenticated")
792
793 # Get token from request
794 auth_header = request.headers.get("Authorization", "")
795 if auth_header.startswith("Bearer "):
796 token = auth_header[7:]
797 # Find and revoke the token
798 token_hash = hashlib.sha256(token.encode()).hexdigest()
799 token_row = await self.auth.database.get_row("auth_tokens", {"token_hash": token_hash})
800 if token_row:
801 await self.auth.database.delete("auth_tokens", {"token_id": token_row["token_id"]})
802
803 return web.json_response({"success": True})
804
805 async def _handle_auth_me(self, request: web.Request) -> web.Response:
806 """Handle request for current user information."""
807 user = await get_authenticated_user(request)
808 if not user:
809 return web.Response(status=401, text="Not authenticated")
810
811 return web.json_response(user.to_dict())
812
813 async def _handle_auth_me_update(self, request: web.Request) -> web.Response:
814 """Handle request to update current user's profile."""
815 user = await get_authenticated_user(request)
816 if not user:
817 return web.Response(status=401, text="Not authenticated")
818
819 try:
820 if not request.can_read_body:
821 return web.Response(status=400, text="Body required")
822
823 body = await request.json()
824 username = body.get("username")
825 display_name = body.get("display_name")
826 avatar_url = body.get("avatar_url")
827
828 # Update user
829 updated_user = await self.auth.update_user(
830 user,
831 username=username,
832 display_name=display_name,
833 avatar_url=avatar_url,
834 )
835
836 return web.json_response({"success": True, "user": updated_user.to_dict()})
837 except Exception:
838 self.logger.exception("Error updating user profile")
839 return web.json_response(
840 {"success": False, "error": "Failed to update profile"}, status=500
841 )
842
843 async def _handle_auth_providers(self, request: web.Request) -> web.Response:
844 """Handle request for available login providers."""
845 try:
846 providers = await self.auth.get_login_providers()
847 return web.json_response(providers)
848 except Exception:
849 self.logger.exception("Error getting auth providers")
850 return web.json_response({"error": "Failed to get auth providers"}, status=500)
851
852 async def _handle_auth_authorize(self, request: web.Request) -> web.Response:
853 """Handle OAuth authorization request."""
854 try:
855 provider_id = request.query.get("provider_id")
856 return_url = request.query.get("return_url")
857
858 self.logger.debug(
859 "OAuth authorize request: provider_id=%s, return_url=%s", provider_id, return_url
860 )
861
862 if not provider_id:
863 return web.Response(status=400, text="provider_id required")
864
865 # Validate return_url if provided
866 if return_url:
867 is_valid, _ = is_allowed_redirect_url(return_url, request, self.base_url)
868 if not is_valid:
869 return web.Response(status=400, text="Invalid return_url")
870
871 auth_url = await self.auth.get_authorization_url(provider_id, return_url)
872 if not auth_url:
873 return web.Response(
874 status=400, text="Provider does not support OAuth or is not configured"
875 )
876
877 return web.json_response({"authorization_url": auth_url})
878 except Exception:
879 self.logger.exception("Error during OAuth authorization")
880 return web.json_response({"error": "Authorization failed"}, status=500)
881
882 async def _handle_auth_callback(self, request: web.Request) -> web.Response:
883 """Handle OAuth callback."""
884 try:
885 code = request.query.get("code")
886 state = request.query.get("state")
887 provider_id = request.query.get("provider_id")
888
889 if not code or not state or not provider_id:
890 return web.Response(status=400, text="code, state, and provider_id required")
891
892 redirect_uri = f"{self.base_url}/auth/callback?provider_id={provider_id}"
893 auth_result = await self.auth.handle_oauth_callback(
894 provider_id, code, state, redirect_uri
895 )
896
897 if not auth_result.success or not auth_result.user:
898 # Return error page
899 error_html = f"""
900 <html>
901 <body>
902 <h1>Authentication Failed</h1>
903 <p>{html.escape(auth_result.error or "Unknown error")}</p>
904 <a href="/login">Back to Login</a>
905 </body>
906 </html>
907 """
908 return web.Response(text=error_html, content_type="text/html", status=400)
909
910 # Create token
911 device_name = f"OAuth ({provider_id})"
912 token = await self.auth.create_token(auth_result.user, device_name)
913
914 # Determine redirect URL (use return_url from OAuth flow or default to root)
915 final_redirect_url = auth_result.return_url or "/"
916 requires_consent = False
917
918 # Validate redirect URL for security
919 if auth_result.return_url:
920 is_valid, category = is_allowed_redirect_url(
921 auth_result.return_url, request, self.base_url
922 )
923 if not is_valid:
924 self.logger.warning("Invalid return_url blocked: %s", auth_result.return_url)
925 final_redirect_url = "/"
926 elif category == "external":
927 # External domain - require user consent
928 requires_consent = True
929 # Add code parameter to redirect URL (the token URL-encoded)
930 # Important: Insert code BEFORE any hash fragment (e.g., #/) to ensure
931 # it's in query params, not inside the hash where Vue Router can't access it
932 code_param = f"code={quote(token, safe='')}"
933
934 # Split URL by hash to insert code in the right place
935 if "#" in final_redirect_url:
936 # URL has a hash fragment (e.g., http://example.com/#/ or http://example.com/path#section)
937 url_parts = final_redirect_url.split("#", 1)
938 base_url = url_parts[0]
939 hash_part = url_parts[1]
940
941 # Add code to base URL (before hash)
942 separator = "&" if "?" in base_url else "?"
943 final_redirect_url = f"{base_url}{separator}{code_param}#{hash_part}"
944 # No hash fragment, simple case
945 elif "?" in final_redirect_url:
946 final_redirect_url = f"{final_redirect_url}&{code_param}"
947 else:
948 final_redirect_url = f"{final_redirect_url}?{code_param}"
949
950 # Load OAuth callback success page template and inject token and redirect URL
951 oauth_callback_html_path = str(RESOURCES_DIR.joinpath("oauth_callback.html"))
952 async with aiofiles.open(oauth_callback_html_path) as f:
953 success_html = await f.read()
954
955 # Replace template placeholders
956 success_html = success_html.replace("{TOKEN}", token)
957 success_html = success_html.replace("{REDIRECT_URL}", final_redirect_url)
958 success_html = success_html.replace(
959 "{REQUIRES_CONSENT}", "true" if requires_consent else "false"
960 )
961
962 return web.Response(text=success_html, content_type="text/html")
963 except Exception:
964 self.logger.exception("Error during OAuth callback")
965 error_html = """
966 <html>
967 <body>
968 <h1>Authentication Failed</h1>
969 <p>An error occurred during authentication</p>
970 <a href="/login">Back to Login</a>
971 </body>
972 </html>
973 """
974 return web.Response(text=error_html, content_type="text/html", status=500)
975
976 async def _handle_setup_page(self, request: web.Request) -> web.Response:
977 """Handle request for first-time setup page."""
978 # Validate return_url if provided
979 return_url = request.query.get("return_url")
980 if return_url:
981 is_valid, _ = is_allowed_redirect_url(return_url, request, self.base_url)
982 if not is_valid:
983 return web.Response(status=400, text="Invalid return_url")
984 else:
985 return_url = "/"
986
987 if self.auth.has_users:
988 # this should not happen, but guard anyways
989 return await self._render_error_page("Setup has already been completed.")
990
991 setup_html_path = str(RESOURCES_DIR.joinpath("setup.html"))
992 async with aiofiles.open(setup_html_path) as f:
993 html_content = await f.read()
994
995 return web.Response(text=html_content, content_type="text/html")
996
997 async def _handle_setup(self, request: web.Request) -> web.Response:
998 """Handle first-time setup request to create admin user (non-ingress only)."""
999 if self.auth.has_users:
1000 return web.json_response(
1001 {"success": False, "error": "Setup already completed"}, status=400
1002 )
1003
1004 if not request.can_read_body:
1005 return web.Response(status=400, text="Body required")
1006
1007 body = await request.json()
1008 username = body.get("username", "").strip()
1009 password = body.get("password", "")
1010
1011 # Validation
1012 if not username or len(username) < 2:
1013 return web.json_response(
1014 {"success": False, "error": "Username must be at least 2 characters"}, status=400
1015 )
1016
1017 if not password or len(password) < 8:
1018 return web.json_response(
1019 {"success": False, "error": "Password must be at least 8 characters"}, status=400
1020 )
1021
1022 try:
1023 builtin_provider = self.auth.login_providers.get("builtin")
1024 if not builtin_provider:
1025 return web.json_response(
1026 {"success": False, "error": "Built-in auth provider not available"},
1027 status=500,
1028 )
1029
1030 if not isinstance(builtin_provider, BuiltinLoginProvider):
1031 return web.json_response(
1032 {"success": False, "error": "Built-in provider configuration error"},
1033 status=500,
1034 )
1035
1036 # Create admin user with password
1037 user = await builtin_provider.create_user_with_password(
1038 username, password, role=UserRole.ADMIN
1039 )
1040
1041 # Create token for the new admin
1042 device_name = body.get(
1043 "device_name", f"Setup ({request.headers.get('User-Agent', 'Unknown')[:50]})"
1044 )
1045 token = await self.auth.create_token(user, device_name)
1046
1047 self.logger.info("First admin user created: %s", username)
1048
1049 # Return token - frontend will complete onboarding via config/onboard_complete
1050 return web.json_response(
1051 {
1052 "success": True,
1053 "token": token,
1054 "user": user.to_dict(),
1055 }
1056 )
1057
1058 except Exception as e:
1059 self.logger.exception("Error during setup")
1060 return web.json_response(
1061 {"success": False, "error": f"Setup failed: {e!s}"}, status=500
1062 )
1063
1064 async def _announce_to_homeassistant(self) -> None:
1065 """Announce Music Assistant Ingress server to Home Assistant via Supervisor API."""
1066 supervisor_token = os.environ["SUPERVISOR_TOKEN"]
1067 addon_hostname = os.environ["HOSTNAME"]
1068 # Get or create auth token for the HA system user
1069 ha_integration_token = await self.auth.get_homeassistant_system_user_token()
1070 discovery_payload = {
1071 "service": "music_assistant",
1072 "config": {
1073 "host": addon_hostname,
1074 "port": INGRESS_SERVER_PORT,
1075 "auth_token": ha_integration_token,
1076 },
1077 }
1078 try:
1079 async with self.mass.http_session_no_ssl.post(
1080 "http://supervisor/discovery",
1081 headers={"Authorization": f"Bearer {supervisor_token}"},
1082 json=discovery_payload,
1083 timeout=ClientTimeout(total=10),
1084 ) as response:
1085 response.raise_for_status()
1086 result = await response.json()
1087 self.logger.debug(
1088 "Successfully announced to Home Assistant. Discovery UUID: %s",
1089 result.get("uuid"),
1090 )
1091 except Exception as err:
1092 self.logger.warning("Failed to announce to Home Assistant: %s", err)
1093