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