diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 73a779816a3..5a1ce79c18c 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -36,7 +36,7 @@ async def async_setup(hass, config): hass.http.register_view(CalendarEventView(component)) # Doesn't work in prod builds of the frontend: home-assistant-polymer#1289 - # await hass.components.frontend.async_register_built_in_panel( + # hass.components.frontend.async_register_built_in_panel( # 'calendar', 'calendar', 'hass:calendar') await component.async_setup(config) diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 8cd8856c1ec..0cb76cc8c3b 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -30,7 +30,7 @@ ON_DEMAND = ('zwave',) async def async_setup(hass, config): """Set up the config component.""" - await hass.components.frontend.async_register_built_in_panel( + hass.components.frontend.async_register_built_in_panel( 'config', 'config', 'hass:settings', require_admin=True) async def setup_panel(panel_name): diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 8d7f7213787..8a692d6f272 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -1,5 +1,4 @@ """Handle the frontend for Home Assistant.""" -import asyncio import json import logging import os @@ -26,6 +25,7 @@ CONF_EXTRA_HTML_URL = 'extra_html_url' CONF_EXTRA_HTML_URL_ES5 = 'extra_html_url_es5' CONF_FRONTEND_REPO = 'development_repo' CONF_JS_VERSION = 'javascript_version' +EVENT_PANELS_UPDATED = 'panels_updated' DEFAULT_THEME_COLOR = '#03A9F4' @@ -97,6 +97,28 @@ SCHEMA_GET_TRANSLATIONS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ }) +def generate_negative_index_regex(): + """Generate regex for index.""" + skip = [ + # files + "service_worker.js", + "robots.txt", + "onboarding.html", + "manifest.json", + ] + for folder in ( + "static", + "frontend_latest", + "frontend_es5", + "local", + "auth", + "api", + ): + # Regex matching static, static/, static/index.html + skip.append("{}(/|/.+|)".format(folder)) + return r"(?!(" + "|".join(skip) + r")).*" + + class Panel: """Abstract class for panels.""" @@ -128,15 +150,6 @@ class Panel: self.config = config self.require_admin = require_admin - @callback - def async_register_index_routes(self, router, index_view): - """Register routes for panel to be served by index view.""" - router.add_route( - 'get', '/{}'.format(self.frontend_url_path), index_view.get) - router.add_route( - 'get', '/{}/{{extra:.+}}'.format(self.frontend_url_path), - index_view.get) - @callback def to_response(self): """Panel as dictionary.""" @@ -151,26 +164,36 @@ class Panel: @bind_hass -async def async_register_built_in_panel(hass, component_name, - sidebar_title=None, sidebar_icon=None, - frontend_url_path=None, config=None, - require_admin=False): +@callback +def async_register_built_in_panel(hass, component_name, + sidebar_title=None, sidebar_icon=None, + frontend_url_path=None, config=None, + require_admin=False): """Register a built-in panel.""" panel = Panel(component_name, sidebar_title, sidebar_icon, frontend_url_path, config, require_admin) - panels = hass.data.get(DATA_PANELS) - if panels is None: - panels = hass.data[DATA_PANELS] = {} + panels = hass.data.setdefault(DATA_PANELS, {}) if panel.frontend_url_path in panels: _LOGGER.warning("Overwriting component %s", panel.frontend_url_path) - if DATA_FINALIZE_PANEL in hass.data: - hass.data[DATA_FINALIZE_PANEL](panel) - panels[panel.frontend_url_path] = panel + hass.bus.async_fire(EVENT_PANELS_UPDATED) + + +@bind_hass +@callback +def async_remove_panel(hass, frontend_url_path): + """Remove a built-in panel.""" + panel = hass.data.get(DATA_PANELS, {}).pop(frontend_url_path, None) + + if panel is None: + _LOGGER.warning("Removing unknown panel %s", frontend_url_path) + + hass.bus.async_fire(EVENT_PANELS_UPDATED) + @bind_hass @callback @@ -233,28 +256,14 @@ async def async_setup(hass, config): if os.path.isdir(local): hass.http.register_static_path("/local", local, not is_dev) - index_view = IndexView(repo_path) - hass.http.register_view(index_view) + hass.http.register_view(IndexView(repo_path)) - @callback - def async_finalize_panel(panel): - """Finalize setup of a panel.""" - panel.async_register_index_routes(hass.http.app.router, index_view) + for panel in ('kiosk', 'states', 'profile'): + async_register_built_in_panel(hass, panel) - await asyncio.wait( - [async_register_built_in_panel(hass, panel) for panel in ( - 'kiosk', 'states', 'profile')]) - await asyncio.wait( - [async_register_built_in_panel(hass, panel, require_admin=True) - for panel in ('dev-event', 'dev-info', 'dev-service', 'dev-state', - 'dev-template', 'dev-mqtt')]) - - hass.data[DATA_FINALIZE_PANEL] = async_finalize_panel - - # Finalize registration of panels that registered before frontend was setup - # This includes the built-in panels from line above. - for panel in hass.data[DATA_PANELS].values(): - async_finalize_panel(panel) + for panel in ('dev-event', 'dev-info', 'dev-service', 'dev-state', + 'dev-template', 'dev-mqtt'): + async_register_built_in_panel(hass, panel, require_admin=True) if DATA_EXTRA_HTML_URL not in hass.data: hass.data[DATA_EXTRA_HTML_URL] = set() @@ -324,6 +333,9 @@ class IndexView(HomeAssistantView): url = '/' name = 'frontend:index' requires_auth = False + extra_urls = [ + "/{extra:%s}" % generate_negative_index_regex() + ] def __init__(self, repo_path): """Initialize the frontend view.""" @@ -349,6 +361,10 @@ class IndexView(HomeAssistantView): """Serve the index view.""" hass = request.app['hass'] + if (request.path != '/' and + request.url.parts[1] not in hass.data[DATA_PANELS]): + raise web.HTTPNotFound + if not hass.components.onboarding.async_is_onboarded(): return web.Response(status=302, headers={ 'location': '/onboarding.html' diff --git a/homeassistant/components/hassio/addon_panel.py b/homeassistant/components/hassio/addon_panel.py index 7291a87e954..e85c8f12247 100644 --- a/homeassistant/components/hassio/addon_panel.py +++ b/homeassistant/components/hassio/addon_panel.py @@ -61,7 +61,7 @@ class HassIOAddonPanel(HomeAssistantView): async def delete(self, request, addon): """Handle remove add-on panel requests.""" - # Currently not supported by backend / frontend + self.hass.components.frontend.async_remove_panel(addon) return web.Response() async def get_panels(self): diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index 7efe4f2beb2..d0dd098638f 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -252,7 +252,7 @@ async def async_setup(hass, config): use_include_order = conf.get(CONF_ORDER) hass.http.register_view(HistoryPeriodView(filters, use_include_order)) - await hass.components.frontend.async_register_built_in_panel( + hass.components.frontend.async_register_built_in_panel( 'history', 'history', 'hass:poll-box') return True diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py index 70fe31e64d6..43fe9cb2d52 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -102,7 +102,7 @@ async def async_setup(hass, config): hass.http.register_view(LogbookView(config.get(DOMAIN, {}))) - await hass.components.frontend.async_register_built_in_panel( + hass.components.frontend.async_register_built_in_panel( 'logbook', 'logbook', 'hass:format-list-bulleted-type') hass.services.async_register( diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index f550f85bcef..b1b9cf1a524 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -53,7 +53,7 @@ async def async_setup(hass, config): # Pass in default to `get` because defaults not set if loaded as dep mode = config.get(DOMAIN, {}).get(CONF_MODE, MODE_STORAGE) - await hass.components.frontend.async_register_built_in_panel( + hass.components.frontend.async_register_built_in_panel( DOMAIN, config={ 'mode': mode }) diff --git a/homeassistant/components/mailbox/__init__.py b/homeassistant/components/mailbox/__init__.py index 939cc4a2aa2..3b5012ec160 100644 --- a/homeassistant/components/mailbox/__init__.py +++ b/homeassistant/components/mailbox/__init__.py @@ -30,7 +30,7 @@ SCAN_INTERVAL = timedelta(seconds=30) async def async_setup(hass, config): """Track states and offer events for mailboxes.""" mailboxes = [] - await hass.components.frontend.async_register_built_in_panel( + hass.components.frontend.async_register_built_in_panel( 'mailbox', 'mailbox', 'mdi:mailbox') hass.http.register_view(MailboxPlatformsView(mailboxes)) hass.http.register_view(MailboxMessageView(mailboxes)) diff --git a/homeassistant/components/map/__init__.py b/homeassistant/components/map/__init__.py index df8ac49a6d5..ab89ccf23ce 100644 --- a/homeassistant/components/map/__init__.py +++ b/homeassistant/components/map/__init__.py @@ -4,6 +4,6 @@ DOMAIN = 'map' async def async_setup(hass, config): """Register the built-in map panel.""" - await hass.components.frontend.async_register_built_in_panel( + hass.components.frontend.async_register_built_in_panel( 'map', 'map', 'hass:tooltip-account') return True diff --git a/homeassistant/components/panel_custom/__init__.py b/homeassistant/components/panel_custom/__init__.py index f6a4fcdb733..275d80facf4 100644 --- a/homeassistant/components/panel_custom/__init__.py +++ b/homeassistant/components/panel_custom/__init__.py @@ -112,7 +112,7 @@ async def async_register_panel( config['_panel_custom'] = custom_panel_config - await hass.components.frontend.async_register_built_in_panel( + hass.components.frontend.async_register_built_in_panel( component_name='custom', sidebar_title=sidebar_title, sidebar_icon=sidebar_icon, diff --git a/homeassistant/components/panel_iframe/__init__.py b/homeassistant/components/panel_iframe/__init__.py index f4038c82f71..fca33b1cf98 100644 --- a/homeassistant/components/panel_iframe/__init__.py +++ b/homeassistant/components/panel_iframe/__init__.py @@ -32,7 +32,7 @@ CONFIG_SCHEMA = vol.Schema({ async def async_setup(hass, config): """Set up the iFrame frontend panels.""" for url_path, info in config[DOMAIN].items(): - await hass.components.frontend.async_register_built_in_panel( + hass.components.frontend.async_register_built_in_panel( 'iframe', info.get(CONF_TITLE), info.get(CONF_ICON), url_path, {'url': info[CONF_URL]}, require_admin=info[CONF_REQUIRE_ADMIN]) diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index cfcbfdd4224..6318d8581c3 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -117,7 +117,7 @@ def async_setup(hass, config): 'What is on my shopping list' ]) - yield from hass.components.frontend.async_register_built_in_panel( + hass.components.frontend.async_register_built_in_panel( 'shopping-list', 'shopping_list', 'mdi:cart') hass.components.websocket_api.async_register_command( diff --git a/homeassistant/components/websocket_api/permissions.py b/homeassistant/components/websocket_api/permissions.py index 753c5688d18..887573f4abb 100644 --- a/homeassistant/components/websocket_api/permissions.py +++ b/homeassistant/components/websocket_api/permissions.py @@ -14,11 +14,13 @@ from homeassistant.components.lovelace import EVENT_LOVELACE_UPDATED from homeassistant.helpers.area_registry import EVENT_AREA_REGISTRY_UPDATED from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED +from homeassistant.components.frontend import EVENT_PANELS_UPDATED # These are events that do not contain any sensitive data # Except for state_changed, which is handled accordingly. SUBSCRIBE_WHITELIST = { EVENT_COMPONENT_LOADED, + EVENT_PANELS_UPDATED, EVENT_PERSISTENT_NOTIFICATIONS_UPDATED, EVENT_SERVICE_REGISTERED, EVENT_SERVICE_REMOVED, diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index ee10b986697..09628b5d3fc 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -8,10 +8,11 @@ import pytest from homeassistant.setup import async_setup_component from homeassistant.components.frontend import ( DOMAIN, CONF_JS_VERSION, CONF_THEMES, CONF_EXTRA_HTML_URL, - CONF_EXTRA_HTML_URL_ES5) + CONF_EXTRA_HTML_URL_ES5, generate_negative_index_regex, + EVENT_PANELS_UPDATED) from homeassistant.components.websocket_api.const import TYPE_RESULT -from tests.common import mock_coro +from tests.common import mock_coro, async_capture_events CONFIG_THEMES = { @@ -232,12 +233,21 @@ def test_extra_urls(mock_http_client_with_urls, mock_onboarded): assert text.find('href="https://domain.com/my_extra_url.html"') >= 0 -async def test_get_panels(hass, hass_ws_client): +async def test_get_panels(hass, hass_ws_client, mock_http_client): """Test get_panels command.""" - await async_setup_component(hass, 'frontend', {}) - await hass.components.frontend.async_register_built_in_panel( + events = async_capture_events(hass, EVENT_PANELS_UPDATED) + + resp = await mock_http_client.get('/map') + assert resp.status == 404 + + hass.components.frontend.async_register_built_in_panel( 'map', 'Map', 'mdi:tooltip-account', require_admin=True) + resp = await mock_http_client.get('/map') + assert resp.status == 200 + + assert len(events) == 1 + client = await hass_ws_client(hass) await client.send_json({ 'id': 5, @@ -255,14 +265,21 @@ async def test_get_panels(hass, hass_ws_client): assert msg['result']['map']['title'] == 'Map' assert msg['result']['map']['require_admin'] is True + hass.components.frontend.async_remove_panel('map') + + resp = await mock_http_client.get('/map') + assert resp.status == 404 + + assert len(events) == 2 + async def test_get_panels_non_admin(hass, hass_ws_client, hass_admin_user): """Test get_panels command.""" hass_admin_user.groups = [] await async_setup_component(hass, 'frontend', {}) - await hass.components.frontend.async_register_built_in_panel( + hass.components.frontend.async_register_built_in_panel( 'map', 'Map', 'mdi:tooltip-account', require_admin=True) - await hass.components.frontend.async_register_built_in_panel( + hass.components.frontend.async_register_built_in_panel( 'history', 'History', 'mdi:history') client = await hass_ws_client(hass) @@ -331,3 +348,43 @@ async def test_auth_authorize(mock_http_client): resp = await mock_http_client.get(authorizejs.groups(0)[0]) assert resp.status == 200 assert 'public' in resp.headers.get('cache-control') + + +def test_index_regex(): + """Test the index regex.""" + pattern = re.compile('/' + generate_negative_index_regex()) + + for should_match in ( + '/', + '/lovelace', + '/lovelace/default_view', + '/map', + '/config', + ): + assert pattern.match(should_match), should_match + + for should_not_match in ( + '/service_worker.js', + '/manifest.json', + '/onboarding.html', + '/manifest.json', + 'static', + 'static/', + 'static/index.html', + 'frontend_latest', + 'frontend_latest/', + 'frontend_latest/index.html', + 'frontend_es5', + 'frontend_es5/', + 'frontend_es5/index.html', + 'local', + 'local/', + 'local/index.html', + 'auth', + 'auth/', + 'auth/index.html', + '/api', + '/api/', + '/api/logbook', + ): + assert not pattern.match(should_not_match), should_not_match