diff --git a/src/meshapi/admin/urls.py b/src/meshapi/admin/urls.py index 1e9892d5..29d15b4e 100644 --- a/src/meshapi/admin/urls.py +++ b/src/meshapi/admin/urls.py @@ -7,11 +7,13 @@ AdminPasswordResetDoneView, AdminPasswordResetView, ) +from meshdb.views import admin_iframe_view urlpatterns = [ path("password_reset/", AdminPasswordResetView.as_view(), name="admin_password_reset"), path("password_reset/done/", AdminPasswordResetDoneView.as_view(), name="password_reset_done"), path("password_reset///", AdminPasswordResetConfirmView.as_view(), name="password_reset_confirm"), path("password_reset/done/", AdminPasswordResetCompleteView.as_view(), name="password_reset_complete"), + path("iframe_wrapper/", admin_iframe_view), path("", admin.site.urls), ] diff --git a/src/meshapi/tests/test_admin_panel.py b/src/meshapi/tests/test_admin_panel.py new file mode 100644 index 00000000..3979d0ce --- /dev/null +++ b/src/meshapi/tests/test_admin_panel.py @@ -0,0 +1,107 @@ +import datetime + +from bs4 import BeautifulSoup +from django.contrib.auth.models import Group, User +from django.test import Client, TestCase +from rest_framework.authtoken.models import TokenProxy + +from meshapi.models import LOS, AccessPoint, Building, Device, Install, Link, Member, Node, Sector +from meshapi.tests.sample_data import sample_building, sample_device, sample_install, sample_member, sample_node +from meshapi_hooks.hooks import CelerySerializerHook + + +class TestAdminPanel(TestCase): + c = Client() + + def setUp(self) -> None: + sample_install_copy = sample_install.copy() + self.building_1 = Building(**sample_building) + self.building_1.save() + sample_install_copy["building"] = self.building_1 + + self.building_2 = Building(**sample_building) + self.building_2.save() + + self.los = LOS( + from_building=self.building_1, + to_building=self.building_2, + analysis_date=datetime.date(2024, 1, 1), + source=LOS.LOSSource.HUMAN_ANNOTATED, + ) + self.los.save() + + self.member = Member(**sample_member) + self.member.save() + sample_install_copy["member"] = self.member + + self.install = Install(**sample_install_copy) + self.install.save() + + self.node1 = Node(**sample_node) + self.node1.save() + self.node2 = Node(**sample_node) + self.node2.save() + + self.device1 = Device(**sample_device) + self.device1.node = self.node1 + self.device1.save() + + self.device2 = Device(**sample_device) + self.device2.node = self.node2 + self.device2.save() + + self.sector = Sector( + radius=1, + azimuth=45, + width=180, + **sample_device, + ) + self.sector.node = self.node2 + self.sector.save() + + self.access_point = AccessPoint( + **sample_device, + latitude=0, + longitude=0, + ) + self.access_point.node = self.node2 + self.access_point.save() + + self.link = Link( + from_device=self.device1, + to_device=self.device2, + status=Link.LinkStatus.ACTIVE, + ) + self.link.save() + + self.admin_user = User.objects.create_superuser( + username="admin", password="admin_password", email="admin@example.com" + ) + self.c.login(username="admin", password="admin_password") + + self.test_group = Group.objects.create(name="Test group") + + self.test_auth_token = TokenProxy.objects.create(user=self.admin_user) + + self.test_webhook = CelerySerializerHook.objects.create( + user=self.admin_user, target="http://example.com", event="building.created", headers="" + ) + + def test_iframe_loads(self): + route = "/admin/iframe_wrapper/" + code = 200 + response = self.c.get(route) + self.assertEqual(code, response.status_code, f"Could not view {route} in the admin panel.") + + decoded_panel = response.content.decode() + soup = BeautifulSoup(decoded_panel, "html.parser") + iframe = soup.find(id="admin_panel_iframe") + iframe_src = iframe.attrs["src"] + self.assertEqual("/admin/", iframe_src) + iframe_response = self.c.get(iframe_src) + self.assertEqual(code, iframe_response.status_code, f"Could not view {route} in the admin panel.") + + # TODO (wdn): Add more tests checking if navigating to xyz page works + # Unfortunately, because that is a lot of javascript, it's tricky to test. + # It may be possible to run selenium integration tests or something to validate + # that functionality diff --git a/src/meshdb/settings.py b/src/meshdb/settings.py index 29733457..eb35dd6d 100644 --- a/src/meshdb/settings.py +++ b/src/meshdb/settings.py @@ -61,6 +61,8 @@ USE_X_FORWARDED_HOST = True +X_FRAME_OPTIONS = "SAMEORIGIN" + SECURE_HSTS_SECONDS = 31536000 SECURE_HSTS_PRELOAD = False SECURE_HSTS_INCLUDE_SUBDOMAINS = False @@ -170,6 +172,7 @@ CORS_ALLOWED_ORIGINS += [ "http://127.0.0.1:3000", + "http://127.0.0.1:3001", "http://localhost:3000", "http://127.0.0.1:80", "http://localhost:80", diff --git a/src/meshdb/templates/admin/base.html b/src/meshdb/templates/admin/base.html index 0a52f5f8..46f6299b 100644 --- a/src/meshdb/templates/admin/base.html +++ b/src/meshdb/templates/admin/base.html @@ -2,6 +2,9 @@ {% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %} + + + {% block title %}{% endblock %} {% block dark-mode-vars %} @@ -82,40 +85,35 @@ {% endblock %} {% endif %} -
-
- {% if not is_popup and is_nav_sidebar_enabled %} - {% block nav-sidebar %} - {% include "admin/nav_sidebar.html" %} - {% endblock %} +
+ {% if not is_popup and is_nav_sidebar_enabled %} + {% block nav-sidebar %} + {% include "admin/nav_sidebar.html" %} + {% endblock %} + {% endif %} +
+ {% block messages %} + {% if messages %} +
    {% for message in messages %} + {{ message|capfirst }} + {% endfor %}
{% endif %} -
- {% block messages %} - {% if messages %} -
    {% for message in messages %} - {{ message|capfirst }} - {% endfor %}
- {% endif %} - {% endblock messages %} - -
- {% block pretitle %}{% endblock %} - {% block content_title %}{% if title %}

{{ title }}

{% endif %}{% endblock %} - {% block content_subtitle %}{% if subtitle %}

{{ subtitle }}

{% endif %}{% endblock %} - {% block content %} - {% block object-tools %}{% endblock %} - {{ content }} - {% endblock %} - {% block sidebar %}{% endblock %} -
-
- - {% block footer %}{% endblock %} -
+ {% endblock messages %} + +
+ {% block pretitle %}{% endblock %} + {% block content_title %}{% if title %}

{{ title }}

{% endif %}{% endblock %} + {% block content_subtitle %}{% if subtitle %}

{{ subtitle }}

{% endif %}{% endblock %} + {% block content %} + {% block object-tools %}{% endblock %} + {{ content }} + {% endblock %} + {% block sidebar %}{% endblock %} +
- {% block map_sidebar %} - {% include "admin/map_sidebar.html" %} - {% endblock %} + + {% block footer %}{% endblock %} +
diff --git a/src/meshdb/templates/admin/base_site.html b/src/meshdb/templates/admin/base_site.html index 6f2a5490..8e49dbfe 100644 --- a/src/meshdb/templates/admin/base_site.html +++ b/src/meshdb/templates/admin/base_site.html @@ -17,9 +17,6 @@

- - {% endblock %} {% block footer %} diff --git a/src/meshdb/templates/admin/iframed.html b/src/meshdb/templates/admin/iframed.html new file mode 100644 index 00000000..46344148 --- /dev/null +++ b/src/meshdb/templates/admin/iframed.html @@ -0,0 +1,82 @@ +{% load static %} + +{% load env_extras %} + + + + + + + + + + + {% block title %}{% endblock %} + + {% block dark-mode-vars %} + + + {% endblock %} + {% if not is_popup and is_nav_sidebar_enabled %} + + + {% endif %} + {% block extrastyle %}{% endblock %} + + {% if LANGUAGE_BIDI %}{% endif %} + {% block extrahead %}{% endblock %} + {% block responsive %} + + + {% if LANGUAGE_BIDI %}{% endif %} + {% endblock %} + {% block blockbots %}{% endblock %} + + + +
+
+ +
+ +
+ + + +
+ +
+ +
+ + +
+ + +
+ + + +
+
+ + + +
+
+ +
+ +
+
+ + diff --git a/src/meshdb/templates/admin/login.html b/src/meshdb/templates/admin/login.html index 58bc8a80..47fdc63d 100644 --- a/src/meshdb/templates/admin/login.html +++ b/src/meshdb/templates/admin/login.html @@ -9,5 +9,3 @@

-
- - -
-
-
- - - -
-
- - - - -

- diff --git a/src/meshdb/views.py b/src/meshdb/views.py new file mode 100644 index 00000000..a1c36e41 --- /dev/null +++ b/src/meshdb/views.py @@ -0,0 +1,8 @@ +from django.contrib.admin.views.decorators import staff_member_required +from django.http import HttpRequest, HttpResponse +from django.shortcuts import render + + +@staff_member_required +def admin_iframe_view(request: HttpRequest) -> HttpResponse: + return render(request, "admin/iframed.html") diff --git a/src/meshweb/static/admin/admin_ext.css b/src/meshweb/static/admin/admin_ext.css index daac1558..91d1dd36 100644 --- a/src/meshweb/static/admin/admin_ext.css +++ b/src/meshweb/static/admin/admin_ext.css @@ -52,10 +52,6 @@ height: auto; } -.login #content { - width: 37em !important; -} - /* This is an override of the styles at admin/css/login.css in the admin site */ .login #main { width: 28em; @@ -63,10 +59,6 @@ margin-top: 0px; } -.main.shifted { - min-width: 975px !important; -} - /* Fixes: delete button looks weird */ #main a { box-sizing: content-box !important; @@ -137,6 +129,23 @@ margin-left: -4px; /* IDK why this is needed CSS is voodoo */ } +.bigBar { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: transparent; +} + +.floating-button-above { + z-index: 100; + position: absolute; + top: 15px; + right: 10px; + padding: 0; +} + .floating-button { z-index: 100; position: absolute; diff --git a/src/meshweb/static/admin/iframe_check.js b/src/meshweb/static/admin/iframe_check.js new file mode 100644 index 00000000..4f711be3 --- /dev/null +++ b/src/meshweb/static/admin/iframe_check.js @@ -0,0 +1,53 @@ +async function checkIframed() { + // Check if we're in an iframe + const inIframe = window.self !== window.top; + + // If we are, do nothing + if (inIframe || mobileCheck()) { + return; + } + + // Also do nothing if we're not in the admin panel proper (eg not logged in) + const escURLs = ["login", "password_reset"] + const currentURL = window.location.href; + + var shouldEscape = escURLs.some(url => currentURL.includes(url)); + if (shouldEscape) { + return; + } + + // If we're not in an iframe, then we'll want to swap the user to the iframed + // view + try { + response = await fetch(PANEL_URL); + if (!response.ok) { + throw new Error( + `Error loading new contents for page: ${response.status} ${response.statusText}` + ); + } + } catch (e) { + console.error(`Error during page nav to %s`, PANEL_URL, e) + const mapWrapper = document.getElementById("container"); + + const pageLink = document.createElement("a"); + pageLink.className = "capture-exclude"; + pageLink.href = PANEL_URL; + pageLink.textContent = PANEL_URL; + + const errorNotice = document.createElement("p"); + errorNotice.className = "error-box"; + errorNotice.innerHTML = `Error loading page: ${pageLink.outerHTML}
${e}` + + mapWrapper.parentNode.insertBefore( + errorNotice, + mapWrapper + ); + return; + } + + document.open(); // Clears the screen + document.write(await response.text()); + document.close(); +} + +checkIframed(); diff --git a/src/meshweb/static/admin/iframed.css b/src/meshweb/static/admin/iframed.css new file mode 100644 index 00000000..385c4f5d --- /dev/null +++ b/src/meshweb/static/admin/iframed.css @@ -0,0 +1,19 @@ +iframe { + height: 100%; + width: 100%; + border: none; + z-index: 1; +} + +.frameGrow { + flex-grow: 1; + border: none; + margin: 0; + padding: 0; +} + +#page_container { + display: flex; + flex-direction: row; + height: 100%; +} diff --git a/src/meshweb/static/admin/map.js b/src/meshweb/static/admin/map.js index 0a6863b3..3be0c06a 100644 --- a/src/meshweb/static/admin/map.js +++ b/src/meshweb/static/admin/map.js @@ -1,28 +1,52 @@ - +const ADMIN_PANEL_HOME = "/admin/" +const MESHDB_LAST_PAGE_VISITED = "MESHDB_LAST_PAGE_VISITED" +const adminPanelIframe = document.getElementById("admin_panel_iframe"); let currentSplit = parseFloat(localStorage.getItem("MESHDB_MAP_SIZE")); if (isNaN(currentSplit)) { currentSplit = 60; } +// Navigation Stuff -// Taken from: https://stackoverflow.com/a/11381730 -const mobileCheck = function() { - let check = false; - (function(a){if(/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a)||/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0,4))) check = true;})(navigator.userAgent||navigator.vendor||window.opera); - return check; -}; - -function getCurrentTarget(){ - let path = location.pathname.replace(/^\/admin\/meshapi\//, ""); - path = path.replace(/\/$/, ""); +// Gets the UUID of the object the Admin Panel is currently viewing +function extractUUIDs(url) { + // Regular expression to match UUIDs + const uuidRegex = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/g; + // Find all matches in the input string + const matches = url.match(uuidRegex); + // Return the matches or an empty array if none found + return matches || []; +} - const [type, id, action] = path.split("/"); - return [type, id, action]; +// Checks what model the Admin Panel is looking at +function extractModel(url) { + const relevantModels = [ + "member", + "building", + "install", + "node", + "device", + "sector", + "accesspoint", + "link", + "los" + ]; + return relevantModels.find(element => url.includes(element)); } -async function getNewSelectedNodes(){ - const [type, id, action] = getCurrentTarget(); +// Based on the current URL of the Admin Panel, figures out what node the map +// should focus on +async function getNewSelectedNodes(url){ + const objectUUIDs = extractUUIDs(url); + const type = extractModel(url); + + // Guard against looking up an empty UUID + if (objectUUIDs.length == 0) { + console.log("[Admin Panel] Found no UUID") + return null; + } + const id = objectUUIDs[0]; let nodeId = null; @@ -162,301 +186,140 @@ async function getNewSelectedNodes(){ return nodeId ? `${nodeId}` : null; } -async function updateMapForLocation(selectedNodes) { - const selectedEvent = new Event("setMapNode");//, {detail: {selectedNodes: selectedNodes}}); - if (!selectedNodes) selectedNodes = await getNewSelectedNodes() - selectedEvent.selectedNodes = selectedNodes; - window.dispatchEvent(selectedEvent); -} - -async function loadScripts(scripts, destination) { - const scriptsArray = []; - for (const script of scripts){ - scriptsArray.push(script); - } - for (const script of scriptsArray) { - const scriptLoadPromise = new Promise((resolve, reject) => { - const scriptElement = document.createElement('script'); - if (script.src) { - scriptElement.src = script.src; - } else { - scriptElement.innerText = script.innerText; - } - scriptElement.onload = resolve; - scriptElement.onerror = reject; - destination.appendChild(scriptElement); - script.remove(); - - // onload will never fire for in-lined scripts since they don't fetch(), so resolve the - // promise right away - if (!script.src) resolve(); - }); - await scriptLoadPromise; - } -} +async function updateAdminPanelLocation(selectedNodes) { + if (!selectedNodes) return; + if (selectedNodes.indexOf("-") !== -1) return; -async function updateAdminContent(newUrl, options, updateHistory = true) { - let response = null; - try { - response = await fetch(newUrl, options); - if (!response.ok) { - throw new Error("Error loading new contents for page: " + response.status + " " + response.statusText); - } - } catch (e) { - console.error(`Error during page nav to %s`, newUrl, e) - const mapWrapper = document.getElementById("map-wrapper"); - - const pageLink = document.createElement("a"); - pageLink.className = "capture-exclude"; - pageLink.href = newUrl; - pageLink.textContent = newUrl; - - const errorNotice = document.createElement("p"); - errorNotice.className = "error-box"; - errorNotice.innerHTML = `Error loading page: ${pageLink.outerHTML}
${e}` - - mapWrapper.parentNode.insertBefore( - errorNotice, - mapWrapper - ); + let selectedNodeInt = parseInt(selectedNodes); + if (selectedNodeInt >= 1000000) { + /* Hack for APs to not break things. We unfortantely can't do a lot better than this without much pain*/ return; } + const installResponse = await fetch(`/api/v1/installs/${selectedNodes}/`); + const nodeResponse = await fetch(`/api/v1/nodes/${selectedNodes}/`); - if (updateHistory) { - if (response.redirected) { - window.history.pushState(null, '', response.url) + // Disable onLoad for Admin Panel while we navigate to a new page + dontListenForAdminPanelLoad(); + + if (installResponse.ok){ + const installJson = await installResponse.json(); + if (installJson.node && installJson.node.network_number) { + adminPanelIframe.src = `/admin/meshapi/node/${installJson.node.id}/change`; } else { - window.history.pushState(null, '', newUrl); + adminPanelIframe.src = `/admin/meshapi/install/${installJson.id}/change`; } - } - - const current_map = document.getElementById("map"); - - const text = await response.text(); - const parser = new DOMParser(); - const doc = parser.parseFromString(text, "text/html"); - - doc.getElementById("map").replaceWith(current_map); - - // Keep the elements Google Maps injected in the header, otherwise the map breaks - const headerElementsToKeep = []; - for (const el of document.getElementsByTagName("head")[0].getElementsByTagName("script")){ - if (el.src && el.src.startsWith("https://maps.googleapis.com/")) headerElementsToKeep.push(el); - } - for (const el of document.getElementsByTagName("head")[0].getElementsByTagName("style")){ - if (el.textContent.indexOf(".gm") !== -1) headerElementsToKeep.push(el); - } - - for (const el of headerElementsToKeep){ - doc.getElementsByTagName("head")[0].appendChild(el); - } - - // Replace the whole page with the new one - const newHTML = doc.getElementsByTagName("html")[0]; - document.getElementsByTagName("html")[0].replaceWith(newHTML); - - setMapProportions(currentSplit); - - // Re-run other javascript to make page happy - if (window.DateTimeShortcuts) window.removeEventListener('load', window.DateTimeShortcuts.init); - - const scriptsToReload = []; - for (const script of document.head.querySelectorAll('script')){ - if (!script.src || !script.src.startsWith("https://maps.googleapis.com/")) scriptsToReload.push(script); - } - await loadScripts(scriptsToReload, document.head); - - dispatchEvent(new Event('load')); -} - - -function shouldNotIntercept(target) { - const isForm = target.tagName === "FORM"; - let url = "" - if (isForm){ - url = new URL(target.action); } else { - url = new URL(target.href); + if (nodeResponse.ok) { + const nodeJson = await nodeResponse.json(); + adminPanelIframe.src = `/admin/meshapi/node/${nodeJson.id}/change`; + } } - if (target.className === "capture-exclude") return true; - if (!url.pathname.startsWith("/admin/")) return true; - if (url.pathname.startsWith("/admin/login")) return true; - if (url.pathname.startsWith("/admin/logout")) return true; - if (url.pathname.endsWith("/export/") && isForm) return true; - if (url.host !== location.host) return true; - - return false; + // Restore the listener + listenForAdminPanelLoad(); } -function interceptLinks() { - // Link clicks - interceptClicks(function(event, el) { - // Exit early if this navigation shouldn't be intercepted, - // e.g. if the navigation is cross-origin, or a download request - if (shouldNotIntercept(el)) return; - async function handler() { - await updateAdminContent(el.href); - updateMapForLocation(); - } - handler() - // console.log("Intercepting " + el.href) - event.preventDefault() +// Configures the listener that updates the admin panel based on map activity +async function listenForMapClick() { + window.addEventListener("message", ({ data, source }) => { + updateAdminPanelLocation(data.selectedNodes); }); +} - // Browser back - window.addEventListener('popstate', function(event) { - async function handler() { - await updateAdminContent(location.href, {}, false); - updateMapForLocation(); - } - handler() - // console.log(location.href); - event.preventDefault() - }, false) +// Prompts the map to change its view to focus on whatever +// node the admin panel is currently viewing. +async function updateMapLocation(url) { + const selectedNodes = await getNewSelectedNodes(url); - // Form submissions - window.addEventListener("submit", function (event) { - const form = event.target; - // Exit early if this navigation shouldn't be intercepted - if (shouldNotIntercept(form)) return; + if (selectedNodes === null) { + console.log("[Admin Panel] No node is selected."); + return; + } - async function handler() { - const formData = new FormData(form); - const method = form.method; + console.debug(`[Admin Panel] Updating map location: ${selectedNodes}`); - if (method.toUpperCase() === "POST") { - const submitButton = event.submitter; - if (submitButton.getAttribute('name')) { - formData.append(submitButton.getAttribute('name'), submitButton.getAttribute('value')); - } - await updateAdminContent(form.action, {method: "POST", body: formData}); - } else if (method.toUpperCase() === "GET") { - const params = new URLSearchParams(formData).toString(); - const actionWithoutParams = new URL(form.action); - actionWithoutParams.search = ""; - await updateAdminContent(`${actionWithoutParams.href}?${params}`); - } - - updateMapForLocation(); - } - handler() - event.preventDefault(); - }) + // MAP_BASE_URL comes from iframed.html + document.getElementById("map_panel").contentWindow.postMessage({selectedNodes: selectedNodes}, MAP_BASE_URL); } -async function nodeSelectedOnMap(selectedNodes) { - if (!selectedNodes) return; - if (selectedNodes.indexOf("-") !== -1) return; - - let selectedNodeInt = parseInt(selectedNodes); - if (selectedNodeInt >= 1000000) { - /* Hack for APs to not break things. We unfortantely can't do a lot better than this without much pain*/ - return; +// Helper function to wrap everything that needs to happen when the admin panel +// loads +async function onAdminPanelLoad() { + const adminPanelIframeUrl = new URL(adminPanelIframe.contentWindow.location.href); + + // If the admin panel iframe leaves the admin panel (by logging out, going to homescreen, etc) + // we should leave this iframed view and go there. + const escURLs = ["login", "password_reset"] + var shouldEscape = escURLs.some(url => adminPanelIframeUrl.pathname.includes(url)); + if (!adminPanelIframeUrl.pathname.includes("admin") || shouldEscape) { + window.location.href = adminPanelIframeUrl; } - const [installResponse, nodeResponse] = await Promise.all( - [ - fetch(`/api/v1/installs/${selectedNodes}/`), - fetch(`/api/v1/nodes/${selectedNodes}/`) - ] - ); - if (installResponse.ok){ - const installJson = await installResponse.json(); - if (installJson.status !== "NN Reassigned" && installJson.status !== "Closed") { - if (installJson.node && installJson.node.network_number) { - await updateAdminContent(new URL(`/admin/meshapi/node/${installJson.node.id}/change`, document.location).href); - updateMapForLocation(installJson.node.network_number.toString()); - return; - } + // Save the new admin location. We do this here because it means that the admin panel has + // recently reloaded. + localStorage.setItem(MESHDB_LAST_PAGE_VISITED, adminPanelIframeUrl.pathname); - updateAdminContent(new URL(`/admin/meshapi/install/${installJson.id}/change`, document.location).href); - return; - } - } + // Update the URL bar in the browser for viz + window.history.pushState("MeshDB Admin Panel", "", adminPanelIframeUrl.pathname); - if (nodeResponse.ok) { - const nodeJson = await nodeResponse.json(); - updateAdminContent(new URL(`/admin/meshapi/node/${nodeJson.id}/change`, document.location).href); - return; - } + // Finally, update the map view + updateMapLocation(adminPanelIframeUrl.toString()); +} +// Configures the listener that updates the map based on admin panel activity +async function listenForAdminPanelLoad() { + adminPanelIframe.addEventListener("load", onAdminPanelLoad); } -function listenForMapNavigation() { - window.addEventListener("nodeSelectedOnMap", (event) => { - nodeSelectedOnMap(event.selectedNodes); - }) +// See above +async function dontListenForAdminPanelLoad() { + adminPanelIframe.removeEventListener("load", onAdminPanelLoad); } +// Checks local storage for the last page the user navigated to, and directs them +// there +async function readURLBar() { + // If the window's URL has more than just /admin/, then we wanna + // override our stored page and replace it with that. + const entryPath = new URL(window.location.href).pathname; + const entrypointRegex = /^(\/?admin\/?)$/; + if (!entryPath.match(entrypointRegex)) { + const newEntryPath = entryPath.replace(PANEL_URL, "admin"); + adminPanelIframe.src = newEntryPath; + localStorage.setItem(MESHDB_LAST_PAGE_VISITED, newEntryPath); + } +} + +// Interface Stuff + function listenForRecenterClick() { const recenterButton = document.querySelector("#map_recenter_button"); function onRecenterClick(event) { console.log("recenterclick"); - updateMapForLocation(); + updateMapLocation(); event.preventDefault(); } recenterButton.addEventListener("click", onRecenterClick, false); } -async function load_map() { - const map_host = MAP_BASE_URL; - - if (!map_host) { - document.getElementById("map-inner").innerHTML = "Cannot load map due to missing environment " + - "variable ADMIN_MAP_BASE_URL. Make sure this is set in your .env file and reload the django server"; - document.getElementById("map-inner").style = "text-align: center; align-items: center;" - return; - } - - - const map_url = `${map_host}/index.html`; - let response; - try { - response = await fetch(map_url); - } catch (e) { - document.getElementById("map-inner").innerHTML = `

Error loading map from ${map_url}. ` + - "Is this host up, and serving CORS headers that allow a request from this domain?

"; - document.getElementById("map-inner").style = "text-align: center; align-items: center;" - return; - } - - const parser = new DOMParser(); - const text = await response.text(); - const remote_map_doc = parser.parseFromString(text, "text/html"); - - const map_scripts_div = document.getElementById("map-scripts"); - - for (const el of remote_map_doc.querySelectorAll('script')){ - let src = el.getAttribute("src") ?? ""; - if (src) { - if (!src.match(/https?:\/\//)){ - el.src = map_host + src; - } - } - } - - for (const el of remote_map_doc.querySelectorAll('link')){ - let href = el.getAttribute("href") ?? ""; - if (!href.match(/https?:\/\//)){ - el.href = map_host + href +function interceptLinks() { + // Browser back + window.addEventListener('popstate', function(event) { + async function handler() { + adminPanelIframe.src = location.href; } - } - - for (const el of remote_map_doc.querySelectorAll('link')){ - map_scripts_div.appendChild(el); - } - - await loadScripts(remote_map_doc.querySelectorAll('script'), map_scripts_div); + handler() + event.preventDefault() + }, false); } function setMapProportions(leftWidth){ // Apply new widths to left and right divs - const leftDiv = document.getElementById('main'); - const rightDiv = document.getElementById('map'); + const leftDiv = document.getElementById('admin_panel_div'); + const rightDiv = document.getElementById('map_panel_div'); currentSplit = leftWidth; leftDiv.style.width = `${leftWidth}%`; @@ -465,6 +328,16 @@ function setMapProportions(leftWidth){ localStorage.setItem("MESHDB_MAP_SIZE", leftWidth.toString()); } +function toggleIframeInteractivity() { + const handle = document.getElementById('handle'); + handle.classList.toggle("bigBar"); + + const handlebar = document.getElementById('handlebar'); + handlebar.classList.toggle("hidden"); + + const substituteHandle = document.getElementById('substituteHandle'); + substituteHandle.classList.toggle("hidden"); +} function allowMapResize() { // Event listener for mouse down on handle @@ -473,12 +346,13 @@ function allowMapResize() { e.preventDefault(); window.addEventListener('mousemove', resize); window.addEventListener('mouseup', stopResize); + toggleIframeInteractivity(); }); // Function to resize divs function resize(e) { // Get elements - const container = document.getElementById('map-wrapper'); + const container = document.getElementById('page_container'); const rect = container.getBoundingClientRect(); const containerLeft = rect.left; @@ -496,18 +370,21 @@ function allowMapResize() { // Event listener for mouse up to stop resizing function stopResize() { window.removeEventListener('mousemove', resize); + window.removeEventListener('mouseup', stopResize); + toggleIframeInteractivity(); } setMapProportions(currentSplit); } +// Checks for mobile/manual map hiding and configures the admin panel interface as appropriate function hideMapIfAppropriate() { const isMobile = mobileCheck(); const mapDisabled = localStorage.getItem("MESHDB_MAP_DISABLED") === "true" || isMobile; if (mapDisabled) { - document.getElementById('map').classList.add("hidden"); - document.getElementById('main').classList.remove("flex"); + document.getElementById('map_panel_div').classList.add("hidden"); + document.getElementById('map_controls').classList.add("hidden"); if (!isMobile) { const showMapButton = document.getElementById('show_map_button'); @@ -521,6 +398,10 @@ function hideMapIfAppropriate() { showMapButton.addEventListener("click", onShowMapClick, false); } } else { + // Hide the show map button + const showMapButton = document.getElementById('show_map_button'); + showMapButton.classList.toggle("hidden"); + const hideMapButton = document.getElementById("map_hide_button"); function onHideMapClick(event) { @@ -535,16 +416,17 @@ function hideMapIfAppropriate() { return mapDisabled; } -async function start() { +function start() { + readURLBar(); if (hideMapIfAppropriate()) { return; } allowMapResize(); - await load_map(); - updateMapForLocation(); interceptLinks(); - listenForMapNavigation(); + listenForAdminPanelLoad(); + listenForMapClick(); listenForRecenterClick(); + } start(); diff --git a/src/meshweb/static/admin/mobile_check.js b/src/meshweb/static/admin/mobile_check.js new file mode 100644 index 00000000..8e4912d8 --- /dev/null +++ b/src/meshweb/static/admin/mobile_check.js @@ -0,0 +1,6 @@ +// Taken from: https://stackoverflow.com/a/11381730 +const mobileCheck = function() { + let check = false; + (function(a){if(/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a)||/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0,4))) check = true;})(navigator.userAgent||navigator.vendor||window.opera); + return check; +}; diff --git a/src/meshweb/static/admin/panel_url_check.js b/src/meshweb/static/admin/panel_url_check.js new file mode 100644 index 00000000..888eff35 --- /dev/null +++ b/src/meshweb/static/admin/panel_url_check.js @@ -0,0 +1,11 @@ +// Used to check if a user has accessed the PANEL_URL directly, and adjusts the +// URL for them +// This file is meant to be loaded from iframe.html where that variable is defined +function checkForPanelURL() { + const entryPath = new URL(window.location.href).pathname; + if (entryPath.includes(PANEL_URL)) { + window.history.pushState("MeshDB Admin Panel", "", entryPath.replace(PANEL_URL, "/admin/")); + } +} + +checkForPanelURL();