diff --git a/electron/main.js b/electron/main.js index aae18005..c2b6d50f 100644 --- a/electron/main.js +++ b/electron/main.js @@ -22,6 +22,7 @@ const { formatRenderProcessGoneDetails, isLocalBackendUrl, shouldOpenInElectronWindow, + shouldAllowInWindowNavigation, } = require("./mainHelpers"); const { isAllowedShellPath } = require("./shellPathGuard"); const { normalizeExternalUrlForOpen } = require("./safeExternalUrl"); @@ -370,6 +371,19 @@ function attachWindowOpenHandler(browserWindow) { browserWindow.webContents.setWindowOpenHandler(({ url }) => handleWindowOpenRequest(url)); } +function attachInWindowNavigationGuard(browserWindow) { + browserWindow.webContents.on("will-navigate", (event, url) => { + if (shouldAllowInWindowNavigation(url)) { + return; + } + event.preventDefault(); + const safe = normalizeExternalUrlForOpen(url); + if (safe) { + shell.openExternal(safe); + } + }); +} + function attachDevToolsF12Shortcut(browserWindow) { browserWindow.webContents.on("before-input-event", (event, input) => { if (input.type !== "keyDown" || input.key !== "F12") { @@ -610,6 +624,7 @@ app.whenReady().then(async () => { attachDefaultContextMenu(browserWindow); attachDevToolsF12Shortcut(browserWindow); attachWindowOpenHandler(browserWindow); + attachInWindowNavigationGuard(browserWindow); }); // Security: Enforce CSP for all requests as a shell-level fallback diff --git a/electron/mainHelpers.js b/electron/mainHelpers.js index 0c76c0cb..2064268a 100644 --- a/electron/mainHelpers.js +++ b/electron/mainHelpers.js @@ -69,9 +69,26 @@ function shouldOpenInElectronWindow(url) { return url.includes("#/popout/"); } +/** + * Whether the main frame may navigate to this URL inside Electron (local app shell). + * External http(s) links must open in the system browser instead. + * @param {unknown} url + * @returns {boolean} + */ +function shouldAllowInWindowNavigation(url) { + if (!url || typeof url !== "string") { + return false; + } + if (url.startsWith("blob:")) { + return true; + } + return isLocalBackendUrl(url); +} + module.exports = { getUserProvidedArguments, formatRenderProcessGoneDetails, isLocalBackendUrl, shouldOpenInElectronWindow, + shouldAllowInWindowNavigation, }; diff --git a/meshchatx/__init__.py b/meshchatx/__init__.py index c6948501..7d7d9bf9 100644 --- a/meshchatx/__init__.py +++ b/meshchatx/__init__.py @@ -3,4 +3,4 @@ """Reticulum MeshChatX - A mesh network communications app.""" # Synced from package.json via scripts/sync_version.js (also writes meshchatx/src/version.py). -__version__ = "4.7.2" \ No newline at end of file +__version__ = "4.7.2" diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py index 55f3223f..39bd9b55 100644 --- a/meshchatx/meshchat.py +++ b/meshchatx/meshchat.py @@ -10999,7 +10999,13 @@ class ReticulumMeshChat: node = self.page_node_manager.get_node(node_id) if not node: return web.json_response({"message": "Node not found"}, status=404) - data = await request.json() + try: + data = await request.json() + except Exception as e: + return web.json_response( + {"message": f"Invalid request body: {e}"}, + status=400, + ) name = data.get("name", "") content = data.get("content", "") if not name: @@ -11011,6 +11017,16 @@ class ReticulumMeshChat: saved_name = node.add_page(name, content) except ValueError as e: return web.json_response({"message": str(e)}, status=400) + except OSError as e: + return web.json_response( + {"message": f"Failed to write page: {e}"}, + status=500, + ) + except Exception as e: + return web.json_response( + {"message": f"Failed to save page: {e}"}, + status=500, + ) return web.json_response({"name": saved_name, "message": "Page saved"}) @routes.get("/api/v1/page-nodes/{node_id}/pages/{page_name}") diff --git a/meshchatx/src/backend/page_node.py b/meshchatx/src/backend/page_node.py index fd367020..058e6897 100644 --- a/meshchatx/src/backend/page_node.py +++ b/meshchatx/src/backend/page_node.py @@ -314,7 +314,12 @@ class PageNode: with open(page_path, "wb") as f: f.write(content) if self.running: - self._register_page_handler(name) + try: + self._register_page_handler(name) + except Exception as e: + raise RuntimeError( + f"Page written but failed to register on mesh: {e}" + ) from e return name def remove_page(self, name): diff --git a/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue b/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue index e5919ab8..a535bc4b 100644 --- a/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue +++ b/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue @@ -162,6 +162,7 @@
@@ -176,8 +177,12 @@ import MicronParser from "../../js/MicronParser.js"; import { micronStorage } from "../../js/MicronStorage"; import { preloadNomadMicronWasm, isMicronWasmBundled } from "../../js/MicronWasmLoader"; import DialogUtils from "../../js/DialogUtils"; +import LinkUtils from "../../js/LinkUtils.js"; import ToolsPageHeader from "../tools/ToolsPageHeader.vue"; +const NOMAD_DESTINATION_HASH = /^[a-fA-F0-9]{32}$/; +const PAGE_EXTENSIONS = [".mu", ".html", ".md", ".txt"]; + export default { name: "MicronEditorPage", components: { @@ -227,6 +232,101 @@ export default { this.renderActiveTab(); this.saveContent(); }, + openExternalHttpUrl(url) { + if (!url) { + return; + } + window.open(url, "_blank", "noopener,noreferrer"); + }, + openNomadDestination(destination) { + const raw = String(destination || "") + .trim() + .replace(/^nomadnetwork:\/\//i, "") + .replace(/^lxmf:\/\//i, ""); + if (!raw) { + return; + } + const httpHref = LinkUtils.httpUrlHrefOrNull(raw); + if (httpHref) { + this.openExternalHttpUrl(httpHref); + return; + } + const [hash, ...pathParts] = raw.split(":"); + if (!NOMAD_DESTINATION_HASH.test(hash)) { + return; + } + const pathPart = pathParts.join(":") || "/page/index.mu"; + const pagePath = pathPart.split("`")[0].split("?")[0]; + this.$router.push({ + name: "nomadnetwork", + params: { destinationHash: hash }, + query: { path: pagePath }, + }); + }, + onPreviewClick(event) { + const nomadLink = event.target.closest("a.nomadnet-link[data-nomadnet-url]"); + if (nomadLink) { + event.preventDefault(); + event.stopPropagation(); + const url = nomadLink.getAttribute("data-nomadnet-url"); + if (url) { + this.openNomadDestination(url); + } + return; + } + + const externalAnchor = event.target.closest("a[href]"); + if (externalAnchor && !externalAnchor.classList.contains("nomadnet-link")) { + const href = externalAnchor.getAttribute("href"); + const httpHref = href ? LinkUtils.httpUrlHrefOrNull(href.trim()) : null; + if (httpHref) { + event.preventDefault(); + event.stopPropagation(); + this.openExternalHttpUrl(httpHref); + return; + } + } + + const fragAnchor = event.target.closest("a[href]"); + if ( + fragAnchor && + fragAnchor.getAttribute("href") && + fragAnchor.getAttribute("href") !== "#" && + fragAnchor.getAttribute("href").startsWith("#") && + !fragAnchor.getAttribute("data-nomadnet-url") + ) { + event.preventDefault(); + event.stopPropagation(); + const raw = fragAnchor.getAttribute("href").slice(1); + const id = decodeURIComponent(raw); + const root = this.$refs.previewRef; + const el = root ? root.querySelector(`#${CSS.escape(id)}`) : document.getElementById(id); + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } + return; + } + + const nodeLink = event.target.closest('[data-action="openNode"]'); + if (nodeLink) { + event.preventDefault(); + event.stopPropagation(); + const destination = nodeLink.getAttribute("data-destination"); + if (destination) { + this.openNomadDestination(destination); + } + return; + } + + const anchor = event.target.closest("a[href]"); + if (anchor) { + const href = (anchor.getAttribute("href") || "").trim(); + if (href && href !== "#" && !href.startsWith("#")) { + event.preventDefault(); + event.stopPropagation(); + } + } + }, renderActiveTab() { if (this.tabs.length === 0 || !this.tabs[this.activeTabIndex]) { this.renderedContent = ""; @@ -958,11 +1058,34 @@ ${b}= }, tabNameToPageBase(tab) { let name = (tab.name || "").trim().replace(/\s+/g, "_"); - if (name.toLowerCase().endsWith(".mu")) { - name = name.slice(0, -3); + const lower = name.toLowerCase(); + for (const ext of PAGE_EXTENSIONS) { + if (lower.endsWith(ext)) { + return name.slice(0, -ext.length); + } } return name; }, + pageBaseWithExtension(base, tab) { + const trimmed = String(base || "").trim(); + if (!trimmed) { + return trimmed; + } + const lower = trimmed.toLowerCase(); + for (const ext of PAGE_EXTENSIONS) { + if (lower.endsWith(ext)) { + return trimmed; + } + } + const tabName = (tab?.name || "").trim(); + const tabLower = tabName.toLowerCase(); + for (const ext of PAGE_EXTENSIONS) { + if (tabLower.endsWith(ext)) { + return `${trimmed}${ext}`; + } + } + return trimmed; + }, isUnsetMicronTabName(name) { const trimmed = (name || "").trim(); if (!trimmed) { @@ -994,8 +1117,11 @@ ${b}= return null; } let base = String(entered).trim().replace(/\s+/g, "_"); - if (base.toLowerCase().endsWith(".mu")) { - base = base.slice(0, -3); + const lower = base.toLowerCase(); + for (const ext of PAGE_EXTENSIONS) { + if (lower.endsWith(ext)) { + return base; + } } return base || null; }, @@ -1007,12 +1133,13 @@ ${b}= if (!pageBase) { return; } + const publishName = this.pageBaseWithExtension(pageBase, tab); const response = await window.api.post(`/api/v1/page-nodes/${node.node_id}/pages`, { - name: pageBase, + name: publishName, content: tab.content, }); this.showPublishMenu = false; - const savedName = response.data?.name || `${pageBase}.mu`; + const savedName = response.data?.name || publishName; DialogUtils.alert( this.$t("tools.micron_editor.publish_published", { page: savedName, server: node.name }) ); @@ -1042,9 +1169,10 @@ ${b}= if (!pageBase) { continue; } + const publishName = this.pageBaseWithExtension(pageBase, tab); try { const response = await window.api.post(`/api/v1/page-nodes/${node.node_id}/pages`, { - name: pageBase, + name: publishName, content: tab.content, }); const savedName = response.data?.name; diff --git a/tests/backend/test_telemetry_integration.py b/tests/backend/test_telemetry_integration.py index 35964252..e1efc27e 100644 --- a/tests/backend/test_telemetry_integration.py +++ b/tests/backend/test_telemetry_integration.py @@ -43,7 +43,9 @@ def mock_app(): ReticulumMeshChat.process_incoming_telemetry.__get__(app, ReticulumMeshChat) ) app._resolve_location_for_telemetry = ( - ReticulumMeshChat._resolve_location_for_telemetry.__get__(app, ReticulumMeshChat) + ReticulumMeshChat._resolve_location_for_telemetry.__get__( + app, ReticulumMeshChat + ) ) return app diff --git a/tests/electron/mainHelpers.test.js b/tests/electron/mainHelpers.test.js index deb6d67f..aaa7f46a 100644 --- a/tests/electron/mainHelpers.test.js +++ b/tests/electron/mainHelpers.test.js @@ -42,4 +42,13 @@ describe("electron/mainHelpers", () => { expect(shouldOpenInElectronWindow("https://example.com/#/popout/map")).toBe(false); expect(shouldOpenInElectronWindow("")).toBe(false); }); + + it("shouldAllowInWindowNavigation keeps local backend URLs in Electron", () => { + const { shouldAllowInWindowNavigation } = require("../../electron/mainHelpers.js"); + expect(shouldAllowInWindowNavigation("https://127.0.0.1:9337/#/tools/micron-editor")).toBe(true); + expect(shouldAllowInWindowNavigation("http://localhost:9337/")).toBe(true); + expect(shouldAllowInWindowNavigation("blob:https://127.0.0.1:9337/print")).toBe(true); + expect(shouldAllowInWindowNavigation("https://example.com/")).toBe(false); + expect(shouldAllowInWindowNavigation("file:///etc/passwd")).toBe(false); + }); }); diff --git a/tests/frontend/MicronEditorPage.test.js b/tests/frontend/MicronEditorPage.test.js index b5714f78..f8cc40e2 100644 --- a/tests/frontend/MicronEditorPage.test.js +++ b/tests/frontend/MicronEditorPage.test.js @@ -146,6 +146,25 @@ describe("MicronEditorPage.vue", () => { }); }); + it("pageBaseWithExtension preserves html tab extension when publishing", async () => { + const wrapper = mountMicronEditorPage(); + await vi.waitFor(() => expect(wrapper.vm.tabs.length).toBeGreaterThan(0)); + const tab = { name: "Landing.html", content: "

hi

" }; + expect(wrapper.vm.pageBaseWithExtension("landing", tab)).toBe("landing.html"); + }); + + it("onPreviewClick opens http links externally", async () => { + const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); + const wrapper = mountMicronEditorPage(); + await vi.waitFor(() => expect(wrapper.vm.tabs.length).toBeGreaterThan(0)); + const preview = wrapper.find(".nodeContainer"); + preview.element.innerHTML = 'Example'; + const link = preview.find("a"); + await link.trigger("click"); + expect(openSpy).toHaveBeenCalledWith("https://example.com/page.html", "_blank", "noopener,noreferrer"); + openSpy.mockRestore(); + }); + it("resets all content", async () => { DialogUtils.confirm.mockResolvedValue(true); const wrapper = mountMicronEditorPage();