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