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