diff --git a/package-lock.json b/package-lock.json index de5a1b20..e07795ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "@tiptap/pm": "^2.12.0", "@tiptap/react": "^2.12.0", "@tiptap/starter-kit": "^2.12.0", + "@zumer/snapdom": "^2.24.1", "axios": "^0.27.2", "bootstrap": "^5.1.3", "css-loader": "^6.5.1", @@ -37,7 +38,6 @@ "file-loader": "^6.2.0", "geotiff": "2.1.3", "html-react-parser": "^5.1.18", - "html2canvas": "^1.4.1", "json5": "^2.2.3", "ol": "10.4.0", "ol-mapbox-style": "12.4.0", @@ -6639,6 +6639,15 @@ "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "license": "Apache-2.0" }, + "node_modules/@zumer/snapdom": { + "version": "2.24.1", + "resolved": "https://registry.npmjs.org/@zumer/snapdom/-/snapdom-2.24.1.tgz", + "integrity": "sha512-COYwb8hczQeevjyOSsTVT4P7CtKYtitXirmMbe/q4bkkULZdMHFFqmNDrlzeWPi5XBmk8+/ZBkNPWV/NX9LqtA==", + "license": "MIT", + "workspaces": [ + "packages/*" + ] + }, "node_modules/@zxing/text-encoding": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz", @@ -7518,6 +7527,7 @@ "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6.0" } @@ -8858,15 +8868,6 @@ "license": "MIT", "peer": true }, - "node_modules/css-line-break": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", - "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", - "license": "MIT", - "dependencies": { - "utrie": "^1.0.2" - } - }, "node_modules/css-loader": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.11.0.tgz", @@ -12926,19 +12927,6 @@ } } }, - "node_modules/html2canvas": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", - "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", - "license": "MIT", - "dependencies": { - "css-line-break": "^2.1.0", - "text-segmentation": "^1.0.3" - }, - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/htmlparser2": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", @@ -21561,15 +21549,6 @@ "deprecated": "no longer maintained", "license": "Unlicense" }, - "node_modules/text-segmentation": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", - "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", - "license": "MIT", - "dependencies": { - "utrie": "^1.0.2" - } - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -22288,15 +22267,6 @@ "node": ">= 0.4.0" } }, - "node_modules/utrie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", - "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", - "license": "MIT", - "dependencies": { - "base64-arraybuffer": "^1.0.2" - } - }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", diff --git a/package.json b/package.json index 92fc0705..22d877c9 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@tiptap/pm": "^2.12.0", "@tiptap/react": "^2.12.0", "@tiptap/starter-kit": "^2.12.0", + "@zumer/snapdom": "^2.24.1", "axios": "^0.27.2", "bootstrap": "^5.1.3", "css-loader": "^6.5.1", @@ -47,7 +48,6 @@ "file-loader": "^6.2.0", "geotiff": "2.1.3", "html-react-parser": "^5.1.18", - "html2canvas": "^1.4.1", "json5": "^2.2.3", "ol": "10.4.0", "ol-mapbox-style": "12.4.0", diff --git a/pyproject.toml b/pyproject.toml index 0364bef7..2a6aaed6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ authors = [{ name = "Corey Krewson", email = "ckrewson@aquaveo.com" }] license-files = ["LICENSE"] keywords = [""] readme = "README.md" +requires-python = ">=3.12" dependencies = [ "hjson==3.1", "nh3==0.2.21", diff --git a/reactapp/__tests__/components/dashboard/DashboardItem.test.js b/reactapp/__tests__/components/dashboard/DashboardItem.test.js index 721a45f2..fac62d65 100644 --- a/reactapp/__tests__/components/dashboard/DashboardItem.test.js +++ b/reactapp/__tests__/components/dashboard/DashboardItem.test.js @@ -1751,7 +1751,7 @@ test("Dashboard Item fill viewport does not force a z-index (stacks by grid orde ); }); -test("Dashboard Item fill viewport shows grid size with indicator while editing", async () => { +test("Dashboard Item fill viewport fills the content area while editing too", async () => { const mockedDashboard = JSON.parse(JSON.stringify(userDashboard)); const gridItem = mockedDashboard.tabs[0].gridItems[0]; gridItem.metadata_string = JSON.stringify({ fillViewport: true }); @@ -1784,12 +1784,17 @@ test("Dashboard Item fill viewport shows grid size with indicator while editing" const dashboardGridItem = await screen.findByLabelText("gridItemDiv"); expect(await screen.findByTestId("editing")).toHaveTextContent("editing"); - // In edit mode the item keeps grid sizing (not fixed) so it stays editable. - expect( - window.getComputedStyle(dashboardGridItem).getPropertyValue("position"), - ).not.toBe("fixed"); - // An indicator tells the creator the setting is active even though it is not - // rendered full-size while editing. + /* Filling applies while editing as well, so the creator sees the result as + soon as the cell is saved instead of having to leave edit mode. It also + keeps the item at its final size continuously: when filling was gated on + view mode, leaving edit mode resized the item and a map's canvas inside it + was still at grid size when the dashboard thumbnail was captured. */ + await waitFor(() => { + expect( + window.getComputedStyle(dashboardGridItem).getPropertyValue("position"), + ).toBe("fixed"); + }); + // The indicator still labels the setting while editing. expect( await screen.findByLabelText("fill-viewport-indicator"), ).toBeInTheDocument(); @@ -2913,3 +2918,41 @@ describe("validateGridItemBatch", () => { expect(result.errors).toEqual([]); }); }); + +// A fill item is position:fixed, and a DOM-to-image library has to reposition it +// to render it, which does not preserve where it sat among its siblings. Items +// meant to stay on top therefore say so with a z-index instead of relying on +// tree order, so a captured thumbnail matches the screen. +test("Dashboard Item context menu lives inside the item, not beside it", async () => { + const mockedDashboard = JSON.parse(JSON.stringify(userDashboard)); + const gridItem = mockedDashboard.tabs[0].gridItems[0]; + + render( + createLoadedComponent({ + children: ( + + + + ), + options: { initialDashboard: mockedDashboard, inEditing: true }, + }), + ); + + const dashboardGridItem = await screen.findByLabelText("gridItemDiv"); + const dropdownToggle = await screen.findByLabelText( + "dashboard-item-dropdown-toggle", + ); + /* As a sibling it was positioned against the react-grid-layout wrapper, so it + stayed at the old grid position when a fill-viewport item moved to cover the + content area, and it did not follow a cell lifted above a fill item. */ + expect(dashboardGridItem).toContainElement(dropdownToggle); +}); diff --git a/reactapp/__tests__/components/dashboard/DashboardLayout.test.js b/reactapp/__tests__/components/dashboard/DashboardLayout.test.js index bbf1d993..2fee2d39 100644 --- a/reactapp/__tests__/components/dashboard/DashboardLayout.test.js +++ b/reactapp/__tests__/components/dashboard/DashboardLayout.test.js @@ -630,3 +630,113 @@ test("Dashboard Responsive Layout with allowOverlap", async () => { layoutContextBefore, ); }); + +describe("fill-viewport stacking", () => { + const makeItem = (i, metadata) => ({ + id: Number(i), + uuid: `some-uuid-${i}`, + i, + x: 0, + y: 0, + w: 20, + h: 20, + source: "", + args_string: "{}", + metadata_string: JSON.stringify(metadata), + }); + + // Index 1 fills; index 0 sits before it, index 2 after it. + const gridItems = [ + makeItem("1", {}), + makeItem("2", { fillViewport: true }), + makeItem("3", {}), + ]; + + const renderLayout = (items, tabId = userDashboard.tabs[0].id) => { + const dashboard = JSON.parse(JSON.stringify(userDashboard)); + dashboard.tabs[0].gridItems = items; + return render( + createLoadedComponent({ + children: ( + + + + ), + options: { initialDashboard: dashboard, inEditing: true }, + }), + ); + }; + + /* Reading the grid item wrappers directly is the point of these tests: the + z-index sits on react-grid-layout's own element, which has no accessible + role or label to query by. */ + const gridItemStyles = (container) => + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + [...container.querySelectorAll(".react-grid-item")].map( + (element) => element.style.zIndex, + ); + + /* The lift has to sit on the grid item rather than anything inside it: + react-grid-layout renders its resize handles as siblings of the item's + content, so lifting only the content would paint over the handles and make + the tile impossible to resize. */ + it("lifts only the items ordered after the fill item", async () => { + const { container } = renderLayout(gridItems); + expect(await screen.findAllByText("Rendered Item")).not.toHaveLength(0); + expect(gridItemStyles(container)).toEqual(["", "", "1"]); + }); + + it("lifts nothing when no item fills", async () => { + const { container } = renderLayout([makeItem("1", {}), makeItem("2", {})]); + expect(await screen.findAllByText("Rendered Item")).not.toHaveLength(0); + expect(gridItemStyles(container)).toEqual(["", ""]); + }); + + // Fill-viewport does not apply on the popup surface, so nothing is lifted. + it("lifts nothing on the popup surface", async () => { + const { container } = renderLayout(gridItems, "popup"); + expect(await screen.findAllByText("Rendered Item")).not.toHaveLength(0); + expect(gridItemStyles(container)).toEqual(["", "", ""]); + }); + + /* The filling item sizes itself from the viewport, not the grid, so a resize + handle on it does nothing - and being a sibling of the item's content, it + would be stranded at the old grid position once the item goes + position:fixed. Marking it non-resizable is what removes the handle: + react-grid-layout keeps the element in the DOM and hides it by adding + react-resizable-hide, whose rule lives in its own stylesheet + (.react-resizable-hide > .react-resizable-handle { display: none }). */ + const gridItemFlags = (container) => + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + [...container.querySelectorAll(".react-grid-item")].map((element) => ({ + handleHidden: element.classList.contains("react-resizable-hide"), + draggable: element.classList.contains("react-draggable"), + })); + + it("hides the fill item's resize handle, while the others keep theirs", async () => { + const { container } = renderLayout(gridItems); + expect(await screen.findAllByText("Rendered Item")).not.toHaveLength(0); + // Index 1 fills; the tiles either side of it stay resizable and draggable. + expect(gridItemFlags(container)).toEqual([ + { handleHidden: false, draggable: true }, + { handleHidden: true, draggable: false }, + { handleHidden: false, draggable: true }, + ]); + }); + + /* The flags come off the item's metadata every render, so clearing the + setting has to restore dragging and the handle with no further action. */ + it("restores the handle and dragging once nothing fills", async () => { + const withoutFill = gridItems.map((item) => ({ + ...item, + metadata_string: JSON.stringify({}), + })); + const { container } = renderLayout(withoutFill); + expect(await screen.findAllByText("Rendered Item")).not.toHaveLength(0); + expect(gridItemFlags(container)).toEqual([ + { handleHidden: false, draggable: true }, + { handleHidden: false, draggable: true }, + { handleHidden: false, draggable: true }, + ]); + }); +}); diff --git a/reactapp/__tests__/components/landingPage/DashboardCard.test.js b/reactapp/__tests__/components/landingPage/DashboardCard.test.js index bc892d97..d791c523 100644 --- a/reactapp/__tests__/components/landingPage/DashboardCard.test.js +++ b/reactapp/__tests__/components/landingPage/DashboardCard.test.js @@ -873,6 +873,9 @@ test("DashboardCard editable, edit thumbnail", async () => { expect(mockUpdateDashboard).toHaveBeenCalledWith( { id: userDashboard.id, + // Uploading by hand turns off auto-capture, so the next save cannot + // overwrite the image just chosen. + autoThumbnail: false, image: "data:image/png;base64,testImage", }, "SxICmOkFldX4o4YVaySdZq9sgn0eRd3Ih6uFtY8BgU5tMyZc7n90oJ4M2My5i7cy", @@ -937,6 +940,9 @@ test("DashboardCard editable, edit thumbnail fail", async () => { expect(mockUpdateDashboard).toHaveBeenCalledWith( { id: userDashboard.id, + // Uploading by hand turns off auto-capture, so the next save cannot + // overwrite the image just chosen. + autoThumbnail: false, image: "data:image/png;base64,testImage", }, "SxICmOkFldX4o4YVaySdZq9sgn0eRd3Ih6uFtY8BgU5tMyZc7n90oJ4M2My5i7cy", @@ -1654,3 +1660,25 @@ TestingComponent.propTypes = { PropTypes.element, ]), }; + +test("DashboardCard renders no image until one exists", async () => { + const imagelessDashboard = JSON.parse(JSON.stringify(userDashboard)); + imagelessDashboard.image = null; + + render( + createLoadedComponent({ + children: ( + + + + ), + }), + ); + + expect(await screen.findByText(imagelessDashboard.name)).toBeInTheDocument(); + /* Rendering the element with no src would show a broken-image icon, which + reads worse than an empty card. */ + expect( + screen.queryByLabelText("Dashboard Card Image"), + ).not.toBeInTheDocument(); +}); diff --git a/reactapp/__tests__/components/layout/Header.test.js b/reactapp/__tests__/components/layout/Header.test.js index e23d5073..4de67623 100644 --- a/reactapp/__tests__/components/layout/Header.test.js +++ b/reactapp/__tests__/components/layout/Header.test.js @@ -9,6 +9,7 @@ import userEvent from "@testing-library/user-event"; import DashboardTabs from "components/dashboard/DashboardTabs"; import DashboardLayoutAlerts from "components/dashboard/DashboardLayoutAlerts"; import appAPI from "services/api/app"; +import captureThumbnail from "components/layout/captureThumbnail"; import { useNavigate } from "react-router-dom"; import { AppTourContext } from "components/contexts/Contexts"; import { @@ -24,7 +25,13 @@ jest.mock("uuid", () => ({ v4: () => "12345678", })); -jest.mock("html2canvas"); +/* jsdom has no layout and no real canvas, so the capture itself cannot run + here - it is stubbed out to a no-op. Whether a thumbnail actually renders + correctly can only be verified in a real browser. */ +jest.mock("components/layout/captureThumbnail", () => ({ + __esModule: true, + default: jest.fn().mockResolvedValue(null), +})); jest.mock("react-router-dom", () => ({ ...jest.requireActual("react-router-dom"), @@ -2224,3 +2231,52 @@ test("DashboardHeader, import tab without name uses fallback", async () => { ); }); }); + +// Auto-capture is off once a thumbnail has been uploaded by hand, so saving +// cannot overwrite it. +describe.each([ + ["skips the capture when auto thumbnail is off", false, 0], + ["captures when auto thumbnail is on", true, 1], +])("DashboardHeader %s", (_name, autoThumbnail, expectedCalls) => { + it(`calls captureThumbnail ${expectedCalls} time(s)`, async () => { + const mockedDashboard = JSON.parse(JSON.stringify(userDashboard)); + mockedDashboard.autoThumbnail = autoThumbnail; + + server.use( + rest.post( + "http://api.test/apps/tethysdash/dashboards/update/", + (req, res, ctx) => + res( + ctx.status(200), + ctx.json({ success: true, updated_dashboard: mockedDashboard }), + ctx.set("Content-Type", "application/json"), + ), + ), + ); + + render( + createLoadedComponent({ + children: ( + + + + + + + + ), + options: { initialDashboard: mockedDashboard }, + }), + ); + + await userEvent.click(await screen.findByLabelText("editButton")); + await userEvent.click(await screen.findByLabelText("saveButton")); + + expect( + await screen.findByText("Change have been saved."), + ).toBeInTheDocument(); + await waitFor(() => { + expect(captureThumbnail).toHaveBeenCalledTimes(expectedCalls); + }); + }); +}); diff --git a/reactapp/__tests__/components/map/ModuleLoader.test.js b/reactapp/__tests__/components/map/ModuleLoader.test.js index 538dda6f..cb61c805 100644 --- a/reactapp/__tests__/components/map/ModuleLoader.test.js +++ b/reactapp/__tests__/components/map/ModuleLoader.test.js @@ -11,6 +11,8 @@ import moduleLoader, { loadESRIJSON, buildPolygonFill, withAntimeridianFix, + withIsolatedCanvas, + withAutoCrossOrigin, zarrSourceToGeoTIFF, applyAutoRamp, } from "components/map/ModuleLoader"; @@ -1927,6 +1929,143 @@ describe("withAntimeridianFix", () => { }); }); +/* The CORS probe is stubbed throughout these blocks. Left unstubbed it would + reach msw with no handler registered, and every probing test uses its own + hostname because the module caches probe results per origin for the lifetime + of the module. */ +describe("withIsolatedCanvas", () => { + /* OpenLayers merges consecutive layers onto one canvas when their className + matches, so a unique className per raster layer is what stops a tainted + layer from blanking its neighbours. */ + it.each([["ImageLayer"], ["TileLayer"]])( + "gives %s its own className", + (type) => { + const first = withIsolatedCanvas(type, {}); + const second = withIsolatedCanvas(type, {}); + expect(first.className).not.toBe(second.className); + // The conventional class is retained for anything selecting on it. + expect(first.className).toMatch(/^ol-layer /); + }, + ); + + it("leaves an explicit className alone", () => { + expect( + withIsolatedCanvas("ImageLayer", { className: "mine" }).className, + ).toBe("mine"); + }); + + /* Vector layers cannot taint a canvas, and WebGLTile renders to its own WebGL + canvas regardless, so neither needs isolating. */ + it.each([["VectorLayer"], ["WebGLTile"], ["VectorTileLayer"]])( + "leaves %s sharing", + (type) => { + expect(withIsolatedCanvas(type, {})).not.toHaveProperty("className"); + }, + ); +}); + +describe("withAutoCrossOrigin", () => { + it("requests CORS when the server allows it", async () => { + jest.spyOn(global, "fetch").mockResolvedValue({ ok: true }); + const url = "https://cors-ok.example.com/wms"; + expect((await withAutoCrossOrigin("WMS", { url })).crossOrigin).toBe( + "anonymous", + ); + expect(global.fetch).toHaveBeenCalledWith( + url, + expect.objectContaining({ method: "HEAD", mode: "cors" }), + ); + }); + + /* Asking for CORS from a server that does not send the header makes the images + fail to load outright, so a rejected probe must leave the option unset. */ + it("leaves the option unset when the probe is rejected", async () => { + jest.spyOn(global, "fetch").mockRejectedValue(new TypeError("CORS")); + const result = await withAutoCrossOrigin("WMS", { + url: "https://no-cors.example.com/wms", + }); + expect(result).not.toHaveProperty("crossOrigin"); + }); + + it("probes each origin once and shares the result", async () => { + jest.spyOn(global, "fetch").mockResolvedValue({ ok: true }); + const url = "https://probe-once.example.com/MapServer"; + await Promise.all([ + withAutoCrossOrigin("ESRI Image and Map Service", { url }), + withAutoCrossOrigin("ESRI Image and Map Service", { url: `${url}/2` }), + ]); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it("does not probe source types that are already CORS-clean", async () => { + jest.spyOn(global, "fetch"); + const props = { url: "https://image-tile.example.com/tile/{z}/{y}/{x}" }; + expect(await withAutoCrossOrigin("Image Tile", props)).toBe(props); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it.each([[true], [false], ["anonymous"]])( + "lets an explicit setting (%p) win over detection", + async (crossOrigin) => { + jest.spyOn(global, "fetch"); + const props = { url: "https://explicit.example.com/wms", crossOrigin }; + expect(await withAutoCrossOrigin("WMS", props)).toBe(props); + expect(global.fetch).not.toHaveBeenCalled(); + }, + ); +}); + +/* Only the ol/source/Image* classes are covered here. ol/source/ImageTile (the + "Image Tile" type) defaults crossOrigin to "anonymous" of its own accord - see + DataTile.js, `options.crossOrigin || 'anonymous'` - so it is already CORS-clean + and deliberately exposes no toggle. These two default to null and are among the + sources that actually taint the map canvas. */ +describe.each([ + ["ESRI Image and Map Service", "https://esri-co.example.com/MapServer"], + ["WMS", "https://wms-co.example.com/wms"], +])("crossOrigin on %s", (sourceType, url) => { + beforeEach(() => { + // Detection off, so the unset cases below are about the explicit value only. + jest.spyOn(global, "fetch").mockRejectedValue(new TypeError("CORS")); + }); + + const buildSource = async (crossOrigin) => { + const layer = await moduleLoader( + { + type: "ImageLayer", + props: { + source: { + type: sourceType, + props: { + url, + ...(crossOrigin === undefined ? {} : { crossOrigin }), + }, + }, + }, + }, + "EPSG:3857", + ); + return layer.getSource(); + }; + + // The GUI renders crossOrigin as a checkbox, so a checked box arrives as + // boolean true rather than the attribute value OpenLayers expects. + it("translates a checked box into the crossorigin attribute value", async () => { + expect((await buildSource(true)).crossOrigin_).toBe("anonymous"); + }); + + it("passes an explicit 'anonymous' through", async () => { + expect((await buildSource("anonymous")).crossOrigin_).toBe("anonymous"); + }); + + it.each([[false], [undefined]])( + "leaves the option unset when not enabled (%p)", + async (value) => { + expect((await buildSource(value)).crossOrigin_).toBeNull(); + }, + ); +}); + describe("zarrSourceToGeoTIFF", () => { test("assembles the zarr/cog endpoint URL from the source fields", () => { const out = zarrSourceToGeoTIFF({ diff --git a/reactapp/__tests__/components/modals/DashboardThumbnail.test.js b/reactapp/__tests__/components/modals/DashboardThumbnail.test.js index 8fbaf8eb..162afc2b 100644 --- a/reactapp/__tests__/components/modals/DashboardThumbnail.test.js +++ b/reactapp/__tests__/components/modals/DashboardThumbnail.test.js @@ -5,7 +5,7 @@ import DashboardThumbnailModal from "components/modals/DashboardThumbnail"; import createLoadedComponent from "__tests__/utilities/customRender"; import PropTypes from "prop-types"; -const TestingComponent = ({ onUpdateThumbnail }) => { +const TestingComponent = ({ onUpdateThumbnail, autoThumbnail }) => { const [showModal, setShowModal] = useState(true); return ( @@ -14,6 +14,7 @@ const TestingComponent = ({ onUpdateThumbnail }) => { showModal={showModal} setShowModal={setShowModal} onUpdateThumbnail={onUpdateThumbnail} + autoThumbnail={autoThumbnail} /> ); @@ -56,8 +57,11 @@ test("DashboardThumbnailModal", async () => { await userEvent.click(updateThumbnailButton); await waitFor(async () => { + // Uploading turns auto-update off; leaving it on would let the next save + // overwrite the image that was just chosen. expect(onUpdateThumbnail).toHaveBeenCalledWith( "data:image/png;base64,testImage", + false, ); }); }); @@ -120,4 +124,85 @@ test("DashboardThumbnailModal close", async () => { TestingComponent.propTypes = { onUpdateThumbnail: PropTypes.func, + autoThumbnail: PropTypes.bool, }; + +test("DashboardThumbnailModal toggle reflects the saved setting", async () => { + render( + createLoadedComponent({ + children: ( + + ), + }), + ); + + const toggle = await screen.findByLabelText( + "Update Thumbnail On Save Toggle", + ); + expect(toggle).not.toBeChecked(); + // Nothing has changed yet, so there is nothing to save. + expect(screen.getByLabelText("Update Thumbnail Button")).toBeDisabled(); +}); + +test("DashboardThumbnailModal toggle can be changed without a file", async () => { + const onUpdateThumbnail = jest.fn(); + + render( + createLoadedComponent({ + children: ( + + ), + }), + ); + + const toggle = await screen.findByLabelText( + "Update Thumbnail On Save Toggle", + ); + await userEvent.click(toggle); + expect(toggle).toBeChecked(); + + const updateButton = screen.getByLabelText("Update Thumbnail Button"); + expect(updateButton).toBeEnabled(); + await userEvent.click(updateButton); + + // No image: turning auto-update back on is a change in its own right. + await waitFor(() => { + expect(onUpdateThumbnail).toHaveBeenCalledWith(null, true); + }); +}); + +test("DashboardThumbnailModal uploading a file clears the toggle", async () => { + global.FileReader = class { + readAsDataURL() { + this.onloadend(); + } + onloadend = jest.fn(); + result = "data:image/png;base64,testImage"; + }; + + render( + createLoadedComponent({ + children: ( + + ), + }), + ); + + const toggle = await screen.findByLabelText( + "Update Thumbnail On Save Toggle", + ); + expect(toggle).toBeChecked(); + + await userEvent.upload( + screen.getByTestId("file-input"), + new File(["dummy"], "test-image.png", { type: "image/png" }), + ); + + // Flipped visibly rather than silently on save. + await waitFor(() => { + expect(toggle).not.toBeChecked(); + }); +}); diff --git a/reactapp/__tests__/components/modals/MapLayer/SourcePane.test.js b/reactapp/__tests__/components/modals/MapLayer/SourcePane.test.js index 99000b0b..9ec44335 100644 --- a/reactapp/__tests__/components/modals/MapLayer/SourcePane.test.js +++ b/reactapp/__tests__/components/modals/MapLayer/SourcePane.test.js @@ -569,6 +569,10 @@ describe("generatePropertiesArrayWithValues", () => { property: "*url", value: "", }, + { + property: "crossOrigin", + value: "", + }, { property: "attributions", value: "", @@ -598,6 +602,10 @@ describe("generatePropertiesArrayWithValues", () => { { value: "ArcGIS Rest service URL", }, + { + value: + "Force CORS on this layer's requests so it can be captured in the dashboard thumbnail. Normally detected automatically, so leave this unchecked unless the layer still renders blank in thumbnails. Only enable if the server sends Access-Control-Allow-Origin - if it does not, the layer will fail to load entirely.", + }, { value: "Attributions", }, @@ -619,6 +627,7 @@ describe("generatePropertiesArrayWithValues", () => { ]); expect(types).toEqual([ "text", + "checkbox", "text", "text", "text", @@ -646,6 +655,10 @@ describe("generatePropertiesArrayWithValues", () => { property: "*url", value: "some_url", }, + { + property: "crossOrigin", + value: "", + }, { property: "attributions", value: "", @@ -675,6 +688,10 @@ describe("generatePropertiesArrayWithValues", () => { { value: "ArcGIS Rest service URL", }, + { + value: + "Force CORS on this layer's requests so it can be captured in the dashboard thumbnail. Normally detected automatically, so leave this unchecked unless the layer still renders blank in thumbnails. Only enable if the server sends Access-Control-Allow-Origin - if it does not, the layer will fail to load entirely.", + }, { value: "Attributions", }, @@ -696,6 +713,7 @@ describe("generatePropertiesArrayWithValues", () => { ]); expect(types).toEqual([ "text", + "checkbox", "text", "text", "text", diff --git a/reactapp/__tests__/utilities/handlers.js b/reactapp/__tests__/utilities/handlers.js index f5b8a83b..6a669a33 100644 --- a/reactapp/__tests__/utilities/handlers.js +++ b/reactapp/__tests__/utilities/handlers.js @@ -158,6 +158,15 @@ const handlers = [ ctx.set("Content-Type", "application/json"), ); }), + /* The map's CORS detection (withAutoCrossOrigin in components/map/ModuleLoader) + issues a HEAD to each layer's host while building its source. Fail every one + so it resolves to "no CORS" and no suite reaches the real network - without + this, building an ESRI/WMS/Static Image source in any test escapes to the + network and msw fails decoding the response. Tests that exercise detection + stub fetch directly instead. */ + rest.head(/.*/, (req, res) => + res.networkError("CORS probe blocked in tests"), + ), ]; export { handlers }; diff --git a/reactapp/components/dashboard/DashboardItem.js b/reactapp/components/dashboard/DashboardItem.js index d87a8586..e480c6d1 100644 --- a/reactapp/components/dashboard/DashboardItem.js +++ b/reactapp/components/dashboard/DashboardItem.js @@ -65,11 +65,9 @@ const StyledDiv = styled.div` /* Fill-viewport override: escape the react-grid-layout-positioned parent via position:fixed so the item spans the content area below the fixed header (and tab bar when shown), independent of screen size. No explicit z-index: - a fixed element with z-index:auto still paints in DOM/tree order with the - other z-index:auto grid tiles, so the fill item stacks by its position in - the gridItems array. Items ordered after it stay visible on top; items - ordered before it sit behind. Positive-z-index chrome (modals 1040+, - alerts 1000/1081) paints above it automatically. */ + items ordered before it sit behind by tree order, and items ordered after + it are lifted above by DashboardLayout, which puts the z-index on the whole + grid item so react-grid-layout's resize handles are lifted with it. */ ${(props) => props.$fillViewport && css` @@ -322,6 +320,7 @@ const DashboardItem = () => { const { gridItemSource, gridItemI, + gridItemUUID, gridItemMetadataString, gridItemIndex, enableFillViewport, @@ -365,27 +364,35 @@ const DashboardItem = () => { }, [gridItemMetadataString]); // Fill-viewport: a single item can be configured to fill the content area - // below the header. Only applies on the main dashboard surface, in view mode. + // below the header. Only applies on the main dashboard surface. const fillViewportRequested = !!gridItemStyling?.fillViewport && !!enableFillViewport; - // When more than one item on the tab has fill on, only the first in grid - // order renders as fill; the rest fall back to normal grid sizing. - const isFirstFillItem = (() => { - if (!fillViewportRequested) return false; - const activeGridItems = getActiveTab().gridItems; - const firstFill = activeGridItems.find((gi) => { + // Index of the item that fills, if any: the first on the tab in grid order. + const firstFillIndex = (() => { + if (!enableFillViewport) return -1; + return getActiveTab().gridItems.findIndex((gi) => { try { return JSON.parse(gi.metadata_string)?.fillViewport; } catch { return false; } }); - return firstFill?.i === gridItemI; })(); - const fillViewportActive = - fillViewportRequested && isFirstFillItem && !isEditing; + // When more than one item on the tab has fill on, only the first in grid + // order renders as fill; the rest fall back to normal grid sizing. + const isFirstFillItem = + fillViewportRequested && + getActiveTab().gridItems[firstFillIndex]?.i === gridItemI; + + /* Applies while editing too, so the creator sees the result the moment the + cell is saved rather than having to leave edit mode to find out. It also + means the item and whatever it contains — a map's canvas in particular — + have been at their final size since long before a save, instead of being + resized by the exit from edit mode and captured mid-resize by the dashboard + thumbnail. */ + const fillViewportActive = fillViewportRequested && isFirstFillItem; // Offset below the header, plus the tab bar when it is shown — which is // whenever more than one tab exists, in both view and edit mode. const fillOffset = @@ -545,7 +552,7 @@ const DashboardItem = () => { className="h-100 gridVisualization" aria-label="gridItem" > - + {gridItemStyling?.attribution !== false && attribution && ( { setShowGridItemMessage={setShowSuccessMessage} /> )} + {isEditing && ( + + + + )} - {isEditing && ( - - - - )} ); }; diff --git a/reactapp/components/dashboard/DashboardLayout.js b/reactapp/components/dashboard/DashboardLayout.js index 1f804e3a..1d3d87c7 100644 --- a/reactapp/components/dashboard/DashboardLayout.js +++ b/reactapp/components/dashboard/DashboardLayout.js @@ -75,19 +75,61 @@ const DashboardLayout = ({ const gridItemsUpdated = useRef(); gridItemsUpdated.current = gridItems; + /* Index of the item filling the content area, if any - the first with the + setting, matching DashboardItem. Fill-viewport does not apply on the popup + surface, where this layout is reused. */ + const firstFillIndex = useMemo( + () => + tabId === "popup" + ? -1 + : gridItems.findIndex((item) => { + try { + return JSON.parse(item.metadata_string)?.fillViewport; + } catch { + return false; + } + }), + [gridItems, tabId], + ); + // Memoize layout from gridItems const layout = useMemo( () => - gridItems.map((griditem) => ({ - h: griditem.h, - i: griditem.i, - w: griditem.w, - x: griditem.x, - y: griditem.y, - isDraggable: isWideBreakpoint && isEditing && !disabledEditingMovement, - isResizable: isWideBreakpoint && isEditing && !disabledEditingMovement, - })), - [gridItems, isEditing, disabledEditingMovement, isWideBreakpoint], + gridItems.map((griditem, index) => { + /* The filling item takes its position and size from the viewport rather + than the grid, so dragging and resizing it do nothing visible. Both + are turned off, which is also what hides the resize handle: + react-grid-layout marks a non-resizable item react-resizable-hide, + and its stylesheet hides the handle inside it. Leaving the handle + would have stranded it at the item's old grid position anyway, since + it is a sibling of the item's content and does not follow it once the + item goes position:fixed. + + Derived from the item's metadata every render, so clearing the + setting restores dragging and the handle immediately. */ + const isFillItem = index === firstFillIndex; + const movable = + isWideBreakpoint && + isEditing && + !disabledEditingMovement && + !isFillItem; + return { + h: griditem.h, + i: griditem.uuid ?? griditem.i, + w: griditem.w, + x: griditem.x, + y: griditem.y, + isDraggable: movable, + isResizable: movable, + }; + }), + [ + gridItems, + isEditing, + disabledEditingMovement, + isWideBreakpoint, + firstFillIndex, + ], ); // Responsive layouts (only computed when responsive=true). @@ -113,7 +155,7 @@ const DashboardLayout = ({ const updatedGridItems = []; for (let lay of newLayout) { const result = gridItems.find((obj) => { - return obj.i === lay.i; + return (obj.uuid ?? obj.i) === lay.i; }); updatedGridItems.push({ @@ -136,7 +178,7 @@ const DashboardLayout = ({ const handleResize = useCallback( (l, oldLayoutItem, layoutItem, placeholder) => { const result = gridItemsUpdated.current.find((obj) => { - return obj.i === layoutItem.i; + return (obj.uuid ?? obj.i) === layoutItem.i; }); const metadata = JSON.parse(result.metadata_string); const enforceAspectRatio = metadata.enforceAspectRatio; @@ -182,8 +224,25 @@ const DashboardLayout = ({ useCSSTransforms: false, }; + /* Items after the fill item get lifted above it. Paint order among the grid's + tiles is otherwise decided by tree order alone, which is enough on screen + but is not reproduced when a DOM-to-image library captures the dashboard: a + fill item is position:fixed, meaningless in the detached clone the library + renders from, so it gets repositioned and can paint over tiles that belong + above it. + + The lift goes on the grid item itself rather than anything inside it, so + that react-grid-layout's own resize handles - siblings of this component's + output within the item - are carried above the fill item as well. */ const children = parsedGridItems.map((item, index) => ( -
+
= 0 && index > firstFillIndex + ? { zIndex: 1 } + : undefined + } + > { const navigate = useNavigate(); const { deleteDashboard, copyDashboard, updateDashboard, exportDashboard } = @@ -201,6 +202,7 @@ const DashboardCard = ({ const [title, setTitle] = useState(name); const [desc, setDesc] = useState(description); const [dashboardImage, setDashboardImage] = useState(image); + const [autoUpdateThumbnail, setAutoUpdateThumbnail] = useState(autoThumbnail); const nameInput = useRef(); const descriptionInput = useRef(); const { activeAppTour } = useAppTourContext(); @@ -295,16 +297,16 @@ const DashboardCard = ({ } }; - const onUpdateThumbnail = async (newImage) => { + const onUpdateThumbnail = async (newImage, newAutoThumbnail) => { setShowThumbnailModal(false); - const apiResponse = await updateDashboard({ - id, - newProperties: { - image: newImage, - }, - }); + const newProperties = { autoThumbnail: newAutoThumbnail }; + // The toggle can be changed on its own, without picking a file. + if (newImage) newProperties.image = newImage; + + const apiResponse = await updateDashboard({ id, newProperties }); if (apiResponse.success) { - setDashboardImage(newImage); + if (newImage) setDashboardImage(newImage); + setAutoUpdateThumbnail(newAutoThumbnail); } else { setErrorMessage(apiResponse.message ?? "Failed to update dashboard"); } @@ -411,11 +413,14 @@ const DashboardCard = ({ {errorMessage} )} - + {/* Absent until a thumbnail is captured on save or uploaded. */} + {dashboardImage && ( + + )} )} {userPermission === "admin" && ( @@ -525,6 +531,7 @@ DashboardCard.propTypes = { description: PropTypes.string, publicDashboard: PropTypes.bool, image: PropTypes.string, + autoThumbnail: PropTypes.bool, userPermission: PropTypes.string, permissions: PropTypes.arrayOf( PropTypes.shape({ diff --git a/reactapp/components/layout/Header.js b/reactapp/components/layout/Header.js index 9c062c50..ac92f549 100644 --- a/reactapp/components/layout/Header.js +++ b/reactapp/components/layout/Header.js @@ -1,5 +1,5 @@ import PropTypes from "prop-types"; -import { useContext, useState, useEffect } from "react"; +import { useContext, useState, useEffect, useRef } from "react"; import { useNavigate } from "react-router-dom"; import Container from "react-bootstrap/Container"; import Navbar from "react-bootstrap/Navbar"; @@ -26,6 +26,7 @@ import { useLayoutErrorAlertContext, } from "components/contexts/LayoutAlertContext"; import { getTethysPortalBase } from "services/utilities"; +import captureThumbnail from "components/layout/captureThumbnail"; import { BsX, @@ -282,8 +283,13 @@ export const DashboardHeader = () => { ); const [showInfoModal, setShowInfoModal] = useState(false); const { user } = useContext(AppContext); - const { name, editable, saveLayoutContext, unrestrictedPlacement } = - useContext(LayoutContext); + const { + name, + editable, + saveLayoutContext, + unrestrictedPlacement, + autoThumbnail, + } = useContext(LayoutContext); const { tabs, addTab, updateTab, importTabs, resetTabs, getActiveTab } = useContext(TabContext); const { isEditing, setIsEditing } = useContext(EditingContext); @@ -296,7 +302,33 @@ export const DashboardHeader = () => { useLayoutSuccessAlertContext(); const { setErrorMessage, setShowErrorMessage } = useLayoutErrorAlertContext(); const [showImportModal, setShowImportModal] = useState(false); + const [thumbnailPending, setThumbnailPending] = useState(false); + const thumbnailInFlight = useRef(false); const navigate = useNavigate(); + useEffect(() => { + if (!thumbnailPending || isEditing || thumbnailInFlight.current) return; + + // Off once a thumbnail has been uploaded by hand, so a save cannot + // overwrite it. Cleared here rather than skipped earlier so the pending + // flag does not stay set. + if (autoThumbnail === false) { + setThumbnailPending(false); + return; + } + + thumbnailInFlight.current = true; + (async () => { + try { + const image = await captureThumbnail(); + if (image) await saveLayoutContext({ image }); + } catch (error) { + console.error("Dashboard thumbnail update failed:", error); + } finally { + thumbnailInFlight.current = false; + setThumbnailPending(false); + } + })(); + }, [thumbnailPending, isEditing, saveLayoutContext, autoThumbnail]); // Only show AppInfoModal on startup after public user modal check is complete and modal is dismissed useEffect(() => { @@ -455,6 +487,7 @@ export const DashboardHeader = () => { setSuccessMessage("Change have been saved."); setShowSuccessMessage(true); setIsEditing(false); + setThumbnailPending(true); } else { setErrorMessage( "Failed to save changes. Check server logs for more information.", diff --git a/reactapp/components/layout/captureThumbnail.js b/reactapp/components/layout/captureThumbnail.js new file mode 100644 index 00000000..6bdcaa1b --- /dev/null +++ b/reactapp/components/layout/captureThumbnail.js @@ -0,0 +1,181 @@ +import { snapdom } from "@zumer/snapdom"; +import { getTethysAppRoot } from "services/utilities"; + +const THUMBNAIL_WIDTH = 640; + +/* Fixed-position chrome that must not land in the thumbnail. The alerts in + particular are raised by the very save that triggers this capture. */ +const EXCLUDED_SELECTORS = [ + ".navbar.fixed-top", + '[data-testid="layout-alerts"]', +]; + +const IMAGE_PROXY = `${getTethysAppRoot()}images/proxy/?url=`; + +function isCrossOrigin(url) { + if (!url || /^(data|blob):/i.test(url)) return false; + try { + return new URL(url, window.location.href).origin !== window.location.origin; + } catch { + return false; + } +} + +function blobToDataUrl(blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => reject(new Error("Could not read the image")); + reader.readAsDataURL(blob); + }); +} + +/** + * Swap every cross-origin for an inline copy fetched through our own + * origin, and return a function that puts the originals back. + */ +async function inlineCrossOriginImages() { + const images = Array.from(document.querySelectorAll("img")).filter((image) => + isCrossOrigin(image.currentSrc || image.src), + ); + const originals = []; + + await Promise.all( + images.map(async (image) => { + const source = image.currentSrc || image.src; + const proxied = `${IMAGE_PROXY}${encodeURIComponent(source)}`; + try { + const response = await fetch(proxied, { credentials: "include" }); + const contentType = response.headers.get("Content-Type") || ""; + + /* An unauthenticated request is answered with a redirect to the login + page. */ + if (!response.ok || !contentType.startsWith("image/")) { + console.warn( + `Thumbnail: proxy returned ${response.status} ${contentType} for ${source} - leaving it as it is`, + ); + return; + } + + const dataUrl = await blobToDataUrl(await response.blob()); + originals.push([image, image.getAttribute("src")]); + image.setAttribute("src", dataUrl); + } catch (error) { + console.warn(`Thumbnail: could not fetch ${proxied}:`, error); + } + }), + ); + + return () => { + for (const [image, source] of originals) { + if (source === null) image.removeAttribute("src"); + else image.setAttribute("src", source); + } + }; +} + +function getHeaderHeight() { + const raw = getComputedStyle(document.documentElement).getPropertyValue( + "--ts-header-height", + ); + const parsed = parseFloat(raw); + return Number.isFinite(parsed) ? parsed : 0; +} + +function getActiveGrid() { + const grids = document.querySelectorAll(".react-grid-layout"); + for (const grid of grids) { + const rect = grid.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) return grid; + } + return null; +} + +function cropAndScale(captured, topFraction) { + const cropTop = Math.min( + Math.round(captured.height * Math.min(Math.max(topFraction, 0), 1)), + Math.max(0, captured.height - 1), + ); + const cropHeight = captured.height - cropTop; + if (cropHeight <= 0 || captured.width <= 0) return null; + + const width = THUMBNAIL_WIDTH; + const height = Math.max(1, Math.round((cropHeight / captured.width) * width)); + + const output = document.createElement("canvas"); + output.width = width; + output.height = height; + + const context = output.getContext("2d"); + /* The dashboard background is not necessarily opaque, and a PNG with + transparent regions reads as a broken thumbnail against the card. */ + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, width, height); + context.drawImage( + captured, + 0, + cropTop, + captured.width, + cropHeight, + 0, + 0, + width, + height, + ); + + return output.toDataURL("image/png"); +} + +function getScrollContainer(element) { + let node = element?.parentElement; + while (node && node !== document.body) { + if (node.scrollHeight > node.clientHeight) return node; + node = node.parentElement; + } + return null; +} + +export default async function captureThumbnail() { + let scroller = null; + let previousScrollTop = 0; + let restoreImages = null; + + try { + const grid = getActiveGrid(); + if (!grid) return null; + + /* Otherwise the thumbnail reflects wherever the user happened to be + scrolled when they hit save. */ + scroller = getScrollContainer(grid); + if (scroller) { + previousScrollTop = scroller.scrollTop; + scroller.scrollTop = 0; + } + + restoreImages = await inlineCrossOriginImages(); + + const result = await snapdom(document.body, { + clip: "viewport", + exclude: EXCLUDED_SELECTORS, + excludeMode: "remove", + backgroundColor: "#ffffff", + embedFonts: false, + }); + + /* Safe to read back: everything in this canvas came from snapdom's own + inlined SVG, so nothing in it is cross-origin. */ + const captured = await result.toCanvas(); + if (captured.width <= 0 || captured.height <= 0) return null; + + return cropAndScale( + captured, + getHeaderHeight() / (window.innerHeight || captured.height), + ); + } catch (error) { + console.error("Dashboard thumbnail capture failed:", error); + return null; + } finally { + restoreImages?.(); + if (scroller) scroller.scrollTop = previousScrollTop; + } +} diff --git a/reactapp/components/loader/DashboardLoader.js b/reactapp/components/loader/DashboardLoader.js index b125c0e7..47cac184 100644 --- a/reactapp/components/loader/DashboardLoader.js +++ b/reactapp/components/loader/DashboardLoader.js @@ -32,6 +32,7 @@ const DashboardLoader = ({ userPermission, permissions, unrestrictedPlacement, + autoThumbnail = true, description, owner, }) => { @@ -326,6 +327,7 @@ const DashboardLoader = ({ userPermission, permissions, unrestrictedPlacement, + autoThumbnail, description, owner, }), @@ -340,6 +342,7 @@ const DashboardLoader = ({ userPermission, permissions, unrestrictedPlacement, + autoThumbnail, description, owner, ], @@ -401,6 +404,7 @@ DashboardLoader.propTypes = { publicDashboard: PropTypes.bool, description: PropTypes.string, unrestrictedPlacement: PropTypes.bool, + autoThumbnail: PropTypes.bool, uuid: PropTypes.string, userPermission: PropTypes.string, permissions: PropTypes.arrayOf( diff --git a/reactapp/components/map/ModuleLoader.js b/reactapp/components/map/ModuleLoader.js index abfe6401..2aeb2197 100644 --- a/reactapp/components/map/ModuleLoader.js +++ b/reactapp/components/map/ModuleLoader.js @@ -42,6 +42,15 @@ import { const moduleCache = {}; const styleCache = new Map(); +// A "Zarr" source is sugar over the zarr/cog endpoint: the author supplies a +// store URL + variable (+ optional index/mask_below) and we assemble the COG +// URL, then render it as an ordinary GeoTIFF source. Variable inputs in the +// fields (e.g. index="${Storm}") are already substituted before this runs. +const ZARR_APP_ROOT = process.env.TETHYS_APP_ROOT_URL ?? "/apps/tethysdash/"; + +const ISOLATED_LAYER_TYPES = new Set(["ImageLayer", "TileLayer"]); +let isolatedLayerCount = 0; + // Inject an OpenLayers `imageLoadFunction` for ESRI Image and Map Service // sources that rewrites out-of-range BBOX requests to use a shifted Web // Mercator central meridian. Without this, panning past the antimeridian @@ -58,11 +67,71 @@ export function withAntimeridianFix(type, props) { }; } -// A "Zarr" source is sugar over the zarr/cog endpoint: the author supplies a -// store URL + variable (+ optional index/mask_below) and we assemble the COG -// URL, then render it as an ordinary GeoTIFF source. Variable inputs in the -// fields (e.g. index="${Storm}") are already substituted before this runs. -const ZARR_APP_ROOT = process.env.TETHYS_APP_ROOT_URL ?? "/apps/tethysdash/"; +export function withIsolatedCanvas(type, props) { + if (!ISOLATED_LAYER_TYPES.has(type)) return props; + if (props?.className) return props; + isolatedLayerCount += 1; + return { + ...props, + className: `ol-layer tethysdash-layer-${isolatedLayerCount}`, + }; +} + +const CORS_PROBED_SOURCE_TYPES = new Set([ + "ESRI Image and Map Service", + "WMS", + "Static Image", +]); + +const CORS_PROBE_TIMEOUT_MS = 4000; +const corsSupportByOrigin = new Map(); + +async function serverAllowsCors(url) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), CORS_PROBE_TIMEOUT_MS); + try { + await fetch(url, { + method: "HEAD", + mode: "cors", + signal: controller.signal, + }); + return true; + } catch { + return false; + } finally { + clearTimeout(timeout); + } +} + +function cachedCorsSupport(url) { + let origin; + try { + origin = new URL(url, window.location.href).origin; + } catch { + return Promise.resolve(false); + } + if (!corsSupportByOrigin.has(origin)) { + corsSupportByOrigin.set(origin, serverAllowsCors(url)); + } + return corsSupportByOrigin.get(origin); +} + +export async function withAutoCrossOrigin(type, props) { + if (!CORS_PROBED_SOURCE_TYPES.has(type)) return props; + // An explicit choice in the layer editor wins over detection. + if (props?.crossOrigin !== undefined) return props; + if (typeof props?.url !== "string" || props.url === "") return props; + return (await cachedCorsSupport(props.url)) + ? { ...props, crossOrigin: "anonymous" } + : props; +} + +async function prepareProps(type, props) { + return withIsolatedCanvas( + type, + await withAutoCrossOrigin(type, withAntimeridianFix(type, props)), + ); +} // Single place that turns Zarr source props into a COG URL, so the layer's // source and the stats pre-read below can never disagree about the slice. @@ -367,7 +436,7 @@ const moduleLoader = async (config, mapProjection, getMapProjection) => { if (type === "KML") { resolvedProps.format = new KML(); } - return new moduleCache[type](withAntimeridianFix(type, resolvedProps)); + return new moduleCache[type](await prepareProps(type, resolvedProps)); } } const importModule = getModuleImporter(type); @@ -403,7 +472,7 @@ const moduleLoader = async (config, mapProjection, getMapProjection) => { } else if (type === "ESRI Feature Service") { return loadESRIJSON(config); } else { - return new ModuleConstructor(withAntimeridianFix(type, resolvedProps)); + return new ModuleConstructor(await prepareProps(type, resolvedProps)); } } catch (error) { console.error(`Failed to load module '${type}':`, error); @@ -435,6 +504,13 @@ const resolveProps = async (props, mapProjection) => { if (key === "projection" && value === "") { continue; } + + if (key === "crossOrigin") { + if (value === true || value === "true" || value === "anonymous") { + resolvedProps[key] = "anonymous"; + } + continue; + } if (key === "overviews" && Array.isArray(value) && value.length === 0) { continue; } diff --git a/reactapp/components/map/utilities.js b/reactapp/components/map/utilities.js index 60c8ef24..c2b0b8ab 100644 --- a/reactapp/components/map/utilities.js +++ b/reactapp/components/map/utilities.js @@ -46,6 +46,11 @@ export const sourcePropertiesOptions = { }, }, optional: { + crossOrigin: { + type: "checkbox", + placeholder: + "Force CORS on this layer's requests so it can be captured in the dashboard thumbnail. Normally detected automatically, so leave this unchecked unless the layer still renders blank in thumbnails. Only enable if the server sends Access-Control-Allow-Origin - if it does not, the layer will fail to load entirely.", + }, attributions: { placeholder: "Attributions", }, @@ -80,6 +85,11 @@ export const sourcePropertiesOptions = { }, }, optional: { + crossOrigin: { + type: "checkbox", + placeholder: + "Force CORS on this layer's requests so it can be captured in the dashboard thumbnail. Normally detected automatically, so leave this unchecked unless the layer still renders blank in thumbnails. Only enable if the server sends Access-Control-Allow-Origin - if it does not, the layer will fail to load entirely.", + }, attributions: { placeholder: "Attributions", }, @@ -235,6 +245,11 @@ export const sourcePropertiesOptions = { imageExtent: { placeholder: "minX,minY,maxX,maxY" }, }, optional: { + crossOrigin: { + type: "checkbox", + placeholder: + "Force CORS on this layer's requests so it can be captured in the dashboard thumbnail. Normally detected automatically, so leave this unchecked unless the layer still renders blank in thumbnails. Only enable if the server sends Access-Control-Allow-Origin - if it does not, the layer will fail to load entirely.", + }, attributions: { placeholder: "Attributions", }, diff --git a/reactapp/components/modals/DashboardThumbnail.js b/reactapp/components/modals/DashboardThumbnail.js index 0d5b0182..30c06380 100644 --- a/reactapp/components/modals/DashboardThumbnail.js +++ b/reactapp/components/modals/DashboardThumbnail.js @@ -1,4 +1,5 @@ import Button from "react-bootstrap/Button"; +import Form from "react-bootstrap/Form"; import Modal from "react-bootstrap/Modal"; import { useState } from "react"; import PropTypes from "prop-types"; @@ -12,8 +13,10 @@ function DashboardThumbnailModal({ showModal, setShowModal, onUpdateThumbnail, + autoThumbnail = true, }) { const [imageSrc, setImageSrc] = useState(null); + const [autoUpdate, setAutoUpdate] = useState(autoThumbnail); const handleModalClose = () => { setShowModal(false); @@ -27,6 +30,9 @@ function DashboardThumbnailModal({ setImageSrc(reader.result); }; reader.readAsDataURL(file); + // Turning this off is the point of uploading one - otherwise the next + // save overwrites it. Flipped visibly rather than silently on save. + setAutoUpdate(false); } }; @@ -42,6 +48,15 @@ function DashboardThumbnailModal({ Update Dashboard Thumbnail + setAutoUpdate(event.target.checked)} + aria-label="Update Thumbnail On Save Toggle" + className="mb-3" + /> @@ -83,6 +98,7 @@ DashboardThumbnailModal.propTypes = { showModal: PropTypes.bool, setShowModal: PropTypes.func, onUpdateThumbnail: PropTypes.func, + autoThumbnail: PropTypes.bool, }; export default DashboardThumbnailModal; diff --git a/tethysapp/tethysdash/alembic/versions/7a3c91be04d2_add_auto_thumbnail.py b/tethysapp/tethysdash/alembic/versions/7a3c91be04d2_add_auto_thumbnail.py new file mode 100644 index 00000000..aaa60e66 --- /dev/null +++ b/tethysapp/tethysdash/alembic/versions/7a3c91be04d2_add_auto_thumbnail.py @@ -0,0 +1,35 @@ +"""add auto_thumbnail + +Revision ID: 7a3c91be04d2 +Revises: add270eff36c +Create Date: 2026-08-13 09:12:44.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "7a3c91be04d2" +down_revision: Union[str, None] = "add270eff36c" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Added nullable, backfilled, then pinned: existing rows have no value, and + # SQLite cannot add a NOT NULL column to a populated table in one step. + op.add_column("dashboards", sa.Column("auto_thumbnail", sa.Boolean(), nullable=True)) + op.execute("UPDATE dashboards SET auto_thumbnail = TRUE") + + if op.get_bind().dialect.name == "sqlite": + with op.batch_alter_table("dashboards") as batch_op: + batch_op.alter_column("auto_thumbnail", nullable=False) + else: + op.alter_column("dashboards", "auto_thumbnail", nullable=False) + + +def downgrade() -> None: + op.drop_column("dashboards", "auto_thumbnail") diff --git a/tethysapp/tethysdash/controllers.py b/tethysapp/tethysdash/controllers.py index 2b5f417f..7ec3ebc6 100644 --- a/tethysapp/tethysdash/controllers.py +++ b/tethysapp/tethysdash/controllers.py @@ -1,10 +1,14 @@ -from django.http import HttpResponse, JsonResponse +from django.http import JsonResponse, HttpResponse +import ipaddress import json import os import shutil +import socket import nh3 +import requests from rest_framework.decorators import api_view import uuid +from urllib.parse import urljoin, urlparse from datetime import datetime from django.core.exceptions import RequestDataTooBig from tethys_sdk.permissions import has_permission @@ -53,6 +57,15 @@ # Load the default wordlist profanity.load_censor_words() +# Bounds for the thumbnail image proxy. The timeout keeps a slow host from +# tying up a worker; the size cap keeps a large image from being read into +# memory, and comfortably exceeds anything worth showing in a 640px thumbnail. +IMAGE_PROXY_TIMEOUT = 10 +IMAGE_PROXY_MAX_BYTES = 10 * 1024 * 1024 +# Redirects are common for image CDNs, but each hop is re-checked, so the chain +# is kept short rather than unbounded. +IMAGE_PROXY_MAX_REDIRECTS = 3 + def _get_error_message(e, fallback): """Return the first arg of an exception, or ``fallback`` if unavailable. @@ -656,7 +669,9 @@ def add_dashboard(request, app_media): - description: Optional string description - notes: Optional string notes - public: Optional boolean for public access - - unrestrictedPlacement: Optional boolean for placement restrictions + - unrestrictedPlacement: Optional boolean for placement restrictions, + defaulting to True. Existing dashboards keep whatever they were + created with; this only sets the starting point for new ones. - gridItems: Optional list of grid items app_media: Tethys app media directory for storing dashboard images @@ -672,7 +687,7 @@ def add_dashboard(request, app_media): description = dashboard_metadata.get("description", "") notes = dashboard_metadata.get("notes", "") public = dashboard_metadata.get("public", False) - unrestricted_placement = dashboard_metadata.get("unrestrictedPlacement", False) + unrestricted_placement = dashboard_metadata.get("unrestrictedPlacement", True) tabs = dashboard_metadata.get("tabs", []) grid_items = dashboard_metadata.get("gridItems", []) owner = request.user @@ -692,12 +707,8 @@ def add_dashboard(request, app_media): tabs, ) - dashboard_image = os.path.join( - os.path.dirname(__file__), "default_dashboard.png" - ) - shutil.copyfile( - dashboard_image, os.path.join(app_media.path, f"{dashboard_uuid}.png") - ) + # No placeholder image is written: a dashboard has no thumbnail until one + # is captured on save or uploaded by hand. new_dashboard = get_dashboards(owner, id=new_dashboard_id) print(f"Successfully created the dashboard named {name}") @@ -1047,6 +1058,169 @@ def download_json(request, app_workspace): return JsonResponse({"success": False, "message": message}) +def _is_blocked_address(address_string): + """True for an address this server must never be aimed at. + + A proxy that fetches whatever URL it is handed is a server-side request + forgery primitive: without this it would reach services bound to the + server's own loopback, anything on the private network, and the cloud + metadata endpoint (169.254.169.254) that hands out instance credentials. + """ + try: + address = ipaddress.ip_address(address_string) + except ValueError: + return True + + return ( + address.is_private + or address.is_loopback + or address.is_link_local + or address.is_reserved + or address.is_multicast + or address.is_unspecified + ) + + +def _is_blocked_image_host(hostname): + """True when any address a hostname resolves to is off limits.""" + try: + addresses = socket.getaddrinfo(hostname, None) + except socket.gaierror: + # Fail closed: a name that will not resolve is refused, not attempted. + return True + + return any(_is_blocked_address(info[4][0]) for info in addresses) + + +def _connected_peer_is_blocked(response): + """True when the address actually connected to is off limits. + + Checking the hostname is not enough on its own. That check resolves the + name, and requests resolves it again when it opens the connection - two + lookups a hostile DNS server is free to answer differently, returning a + public address to be inspected and a private one to be connected to. This + inspects the socket, so it judges the connection that was really made + rather than a separate lookup that may no longer describe it. + + Only meaningful for a response whose body is still unread. urllib3 returns + the connection to the pool as soon as a body is finished, and a redirect's + body is empty, so by the time one is inspected there is no socket left to + look at. Redirect hops are therefore covered by the hostname check alone; + this guards the response that actually hands bytes back to the caller. + + Fails closed: if the peer cannot be determined the response is refused, + which costs one image in a thumbnail rather than trusting a connection that + was never verified. + """ + try: + peer = response.raw._connection.sock.getpeername()[0] + except Exception: + return True + + return _is_blocked_address(peer) + + +@controller(url="tethysdash/images/proxy", login_required=True) +def image_proxy(request): + """ + Fetch a cross-origin image and return it from this origin. + + Exists for dashboard thumbnails. Capturing one serialises the page, which + means re-fetching every from JavaScript, and that re-fetch is subject + to CORS - so an image whose host sends no Access-Control-Allow-Origin comes + out blank in the thumbnail even though it displays fine in the dashboard. + Nothing in the browser can work around that. Fetching server-side can, + because server-to-server requests are not subject to CORS. + + Only the capture path uses this (see components/layout/captureThumbnail), + and a capture only runs when a dashboard is saved, which already requires an + authenticated editor - hence login_required. + + Args: + request: Django HTTP request object with query parameters: + - url: The absolute http(s) image URL to fetch + + Returns: + HttpResponse: The image bytes with their upstream content type, or a + plain-text error with an appropriate status code. + """ + url = request.GET.get("url", "") + response = None + + for _ in range(IMAGE_PROXY_MAX_REDIRECTS + 1): + parsed = urlparse(url) + if parsed.scheme not in ("http", "https") or not parsed.hostname: + return HttpResponse("Only absolute http(s) URLs are allowed", status=400) + + if _is_blocked_image_host(parsed.hostname): + return HttpResponse("That host is not permitted", status=403) + + try: + response = requests.get( + url, + timeout=IMAGE_PROXY_TIMEOUT, + stream=True, + allow_redirects=False, + ) + except requests.RequestException as e: + print(f"Image proxy failed to fetch {url}: {e}") + return HttpResponse("Could not fetch the image", status=502) + + if not response.is_redirect: + break + + location = response.headers.get("Location", "") + response.close() + if not location: + return HttpResponse("Could not fetch the image", status=502) + # Relative Locations are legal, so resolve against the URL just fetched. + url = urljoin(url, location) + else: + return HttpResponse("Too many redirects", status=502) + + # Checked here rather than per hop: this is the response whose body is read, + # and the only one still holding a socket to inspect. + if _connected_peer_is_blocked(response): + response.close() + return HttpResponse("That host is not permitted", status=403) + + try: + response.raise_for_status() + except requests.RequestException as e: + print(f"Image proxy failed to fetch {url}: {e}") + response.close() + return HttpResponse("Could not fetch the image", status=502) + + content_type = response.headers.get("Content-Type", "").split(";")[0].strip() + if not content_type.startswith("image/"): + # An error page answering 200 must not be passed off as an image. + response.close() + return HttpResponse("That URL is not an image", status=415) + + # Read with a cap rather than trusting Content-Length, which a server may + # understate or omit entirely. decode_content bounds the decompressed size, + # so a compressed payload cannot expand past the cap. + content = response.raw.read(IMAGE_PROXY_MAX_BYTES + 1, decode_content=True) + response.close() + if len(content) > IMAGE_PROXY_MAX_BYTES: + return HttpResponse("That image is too large to proxy", status=413) + + proxied = HttpResponse(content, content_type=content_type) + # This body is written by whoever controls the remote host and is served + # from our own origin, so it is pinned down as tightly as an image allows. + # image/svg+xml matters most: an SVG can carry script, and opening one at + # the top level would run it as this origin. Marking every response an + # attachment stops that navigation from rendering, while leaving and + # the capture's own fetch unaffected. + proxied["X-Content-Type-Options"] = "nosniff" + proxied["Content-Security-Policy"] = "default-src 'none'; sandbox" + proxied["Content-Disposition"] = "attachment" + # No Access-Control-Allow-Origin: the capture is same-origin and needs none, + # and a wildcard is refused by browsers alongside credentialed requests + # anyway, so it would be surface without benefit. + return proxied + + def _cog_response(request, cog_bytes): """Serve COG bytes, honoring an HTTP Range header with 206 partial content so a COG reader can fetch byte ranges instead of the whole file.""" diff --git a/tethysapp/tethysdash/default_dashboard.png b/tethysapp/tethysdash/default_dashboard.png deleted file mode 100644 index 544d46bf..00000000 Binary files a/tethysapp/tethysdash/default_dashboard.png and /dev/null differ diff --git a/tethysapp/tethysdash/model.py b/tethysapp/tethysdash/model.py index a9e73d7f..24f848fe 100644 --- a/tethysapp/tethysdash/model.py +++ b/tethysapp/tethysdash/model.py @@ -68,6 +68,8 @@ class Dashboard(Base): notes = Column(String) owner = Column(String, nullable=False) unrestricted_placement = Column(Boolean) + # Cleared when a thumbnail is uploaded by hand, so a save cannot overwrite it. + auto_thumbnail = Column(Boolean, nullable=False, default=True) public = Column(Boolean, nullable=False, default=False) last_updated = Column(DateTime, default=datetime.now(timezone.utc)) @@ -688,6 +690,7 @@ def copy_named_dashboard(user, id, new_name, dashboard_uuid): public=original_dashboard.public, owner=user.username, unrestricted_placement=original_dashboard.unrestricted_placement, + auto_thumbnail=original_dashboard.auto_thumbnail, ) # Add and flush to generate new ID @@ -868,6 +871,9 @@ def update_named_dashboard(user, id, dashboard_updates): "unrestrictedPlacement" ] + if "autoThumbnail" in dashboard_updates: + db_dashboard.auto_thumbnail = dashboard_updates["autoThumbnail"] + if "permissions" in dashboard_updates: update_dashboard_permissions( session, db_dashboard, user, dashboard_updates["permissions"] @@ -1712,13 +1718,12 @@ def parse_db_dashboard(session, dashboards, user, dashboard_view): MEDIA_URL = f"/{PREFIX_URL}/{MEDIA_URL.strip('/')}/" for dashboard in dashboards: - dashboard_image = os.path.join( - MEDIA_URL, App.root_url, f"app/{dashboard.uuid}.png" - ) + # None until a thumbnail exists; the card renders without one. + dashboard_image = None app_media = get_app_media(App) - if not os.path.exists(os.path.join(app_media.path, f"{dashboard.uuid}.png")): + if os.path.exists(os.path.join(app_media.path, f"{dashboard.uuid}.png")): dashboard_image = os.path.join( - settings.STATIC_URL, App.root_url, "images", "default_dashboard.png" + MEDIA_URL, App.root_url, f"app/{dashboard.uuid}.png" ) # Find the user's permission level for this dashboard user_permission = get_dashboard_user_permission(session, dashboard, user) @@ -1749,6 +1754,7 @@ def parse_db_dashboard(session, dashboards, user, dashboard_view): "userPermission": user_permission, "permissions": permissions_list, "unrestrictedPlacement": dashboard.unrestricted_placement, + "autoThumbnail": dashboard.auto_thumbnail, "image": dashboard_image, "owner": dashboard.owner, } diff --git a/tethysapp/tethysdash/public/images/default_dashboard.png b/tethysapp/tethysdash/public/images/default_dashboard.png deleted file mode 100644 index 544d46bf..00000000 Binary files a/tethysapp/tethysdash/public/images/default_dashboard.png and /dev/null differ diff --git a/tethysapp/tethysdash/tests/integrated_tests/test_controllers.py b/tethysapp/tethysdash/tests/integrated_tests/test_controllers.py index d71119e4..2ac5dc9c 100644 --- a/tethysapp/tethysdash/tests/integrated_tests/test_controllers.py +++ b/tethysapp/tethysdash/tests/integrated_tests/test_controllers.py @@ -14,9 +14,14 @@ from tethysapp.tethysdash.controllers import ( VisualizationConsumer, _get_main_bundle_path, + _is_blocked_image_host, + _connected_peer_is_blocked, + _is_blocked_address, zarr_cog, zarr_meta, ) +import requests +import socket from tethysapp.tethysdash.zarr_utils import StoreOpenError, ZarrCogError from tethysapp.tethysdash.url_safety import UnsafeURLError from channels.layers import get_channel_layer @@ -410,8 +415,9 @@ def test_get_dashboard( "tabs": [], "uuid": dashboard.uuid, "notes": "some notes", - "image": "/static/tethysdash/images/default_dashboard.png", + "image": None, "unrestrictedPlacement": dashboard.unrestricted_placement, + "autoThumbnail": True, "owner": dashboard.owner, "permissions": [ {"permission": "admin", "username": test_owner_user.username}, @@ -516,9 +522,12 @@ def test_add_dashboard( "description": "description", "id": new_dashboard["id"], "name": "some_new_dashboard_name", - "image": "/media/tethysdash/app/123e4567-e89b-12d3-a456-426614174000.png", + # A new dashboard has no thumbnail until one is captured or uploaded. + "image": None, "uuid": "123e4567-e89b-12d3-a456-426614174000", - "unrestrictedPlacement": False, + # itemData omits unrestrictedPlacement, so this pins the creation default. + "unrestrictedPlacement": True, + "autoThumbnail": True, "owner": test_admin_user.username, "permissions": [{"permission": "admin", "username": test_admin_user.username}], "publicDashboard": False, @@ -560,7 +569,7 @@ def test_add_dashboard_failed(client, admin_user, mock_app, mocker, tmp_path): itemData["description"], "", False, - False, + True, [], [], ) @@ -599,7 +608,7 @@ def test_add_dashboard_failed_unknown_exception( itemData["description"], "", False, - False, + True, [], [], ) @@ -777,9 +786,10 @@ def test_update_dashboard( "id": dashboard.id, "name": "new_dashboard_name", "notes": dashboard.notes, - "image": "/static/tethysdash/images/default_dashboard.png", + "image": None, "uuid": "some_user_dashboard_uuid", "unrestrictedPlacement": dashboard.unrestricted_placement, + "autoThumbnail": True, "owner": dashboard.owner, "permissions": [ {"permission": "admin", "username": test_owner_user.username}, @@ -924,9 +934,10 @@ def test_copy_dashboard( "description": dashboard.description, "id": new_dashboard["id"], "name": "some_new_dashboard_name", - "image": "/static/tethysdash/images/default_dashboard.png", + "image": None, "uuid": dashboard_uuid, "unrestrictedPlacement": dashboard.unrestricted_placement, + "autoThumbnail": True, "owner": admin_user.username, "permissions": [{"permission": "admin", "username": admin_user.username}], "publicDashboard": dashboard.public, @@ -989,6 +1000,7 @@ def test_copy_dashboard_with_thumbnail( "image": "/media/tethysdash/app/123e4567-e89b-12d3-a456-426614174001.png", "uuid": "123e4567-e89b-12d3-a456-426614174001", "unrestrictedPlacement": dashboard.unrestricted_placement, + "autoThumbnail": True, "owner": admin_user.username, "permissions": [{"permission": "admin", "username": admin_user.username}], "publicDashboard": dashboard.public, @@ -2529,6 +2541,378 @@ def test_get_main_bundle_path_returns_hashed_from_manifest(): assert _get_main_bundle_path(request) == "frontend/main.abc123.js" +@pytest.mark.django_db +def test_image_proxy_returns_the_image(client, admin_user, mock_app, mocker): + mock_app("tethysapp.tethysdash.controllers.App") + mocker.patch( + "tethysapp.tethysdash.controllers._is_blocked_image_host", return_value=False + ) + mocker.patch( + "tethysapp.tethysdash.controllers._connected_peer_is_blocked", + return_value=False, + ) + mock_get = mocker.patch("tethysapp.tethysdash.controllers.requests.get") + upstream = mock_get.return_value + upstream.raise_for_status.return_value = None + upstream.is_redirect = False + upstream.headers = {"Content-Type": "image/png; charset=binary"} + upstream.raw.read.return_value = b"\x89PNG-bytes" + + client.force_login(admin_user) + response = client.get( + reverse("tethysdash:image_proxy"), {"url": "https://example.com/logo.png"} + ) + + assert response.status_code == 200 + assert response["Content-Type"] == "image/png" + assert response.content == b"\x89PNG-bytes" + + # The body is written by whoever controls the remote host but served from + # our origin, so it is pinned down as tightly as an image allows. An SVG can + # carry script, and "attachment" is what stops a top-level visit rendering + # it as this origin. + assert response["X-Content-Type-Options"] == "nosniff" + assert response["Content-Security-Policy"] == "default-src 'none'; sandbox" + assert response["Content-Disposition"] == "attachment" + # The capture is same-origin, so no CORS header is needed - and a wildcard + # is refused alongside credentialed requests anyway. + assert not response.has_header("Access-Control-Allow-Origin") + + +@pytest.mark.django_db +def test_image_proxy_requires_login(client, mock_app): + """Unauthenticated access would make this an open server-side fetcher.""" + mock_app("tethysapp.tethysdash.controllers.App") + response = client.get( + reverse("tethysdash:image_proxy"), {"url": "https://example.com/logo.png"} + ) + assert response.status_code in (302, 401, 403) + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "url", + [ + "file:///etc/passwd", + "ftp://example.com/logo.png", + "not-a-url", + "", + ], +) +def test_image_proxy_rejects_non_http_urls(client, admin_user, mock_app, url): + mock_app("tethysapp.tethysdash.controllers.App") + client.force_login(admin_user) + response = client.get(reverse("tethysdash:image_proxy"), {"url": url}) + assert response.status_code == 400 + + +@pytest.mark.django_db +def test_image_proxy_blocks_internal_hosts(client, admin_user, mock_app, mocker): + """Without this the endpoint is a server-side request forgery primitive.""" + mock_app("tethysapp.tethysdash.controllers.App") + mock_get = mocker.patch("tethysapp.tethysdash.controllers.requests.get") + client.force_login(admin_user) + + response = client.get( + reverse("tethysdash:image_proxy"), + {"url": "http://169.254.169.254/latest/meta-data/"}, + ) + + assert response.status_code == 403 + # Blocked before any request leaves the server. + mock_get.assert_not_called() + + +@pytest.mark.django_db +def test_image_proxy_reports_an_unreachable_host(client, admin_user, mock_app, mocker): + mock_app("tethysapp.tethysdash.controllers.App") + mocker.patch( + "tethysapp.tethysdash.controllers._is_blocked_image_host", return_value=False + ) + mocker.patch( + "tethysapp.tethysdash.controllers._connected_peer_is_blocked", + return_value=False, + ) + mocker.patch( + "tethysapp.tethysdash.controllers.requests.get", + side_effect=requests.exceptions.ConnectionError("boom"), + ) + + client.force_login(admin_user) + response = client.get( + reverse("tethysdash:image_proxy"), {"url": "https://example.com/logo.png"} + ) + assert response.status_code == 502 + + +@pytest.mark.django_db +def test_image_proxy_refuses_a_non_image(client, admin_user, mock_app, mocker): + """An error page answering 200 must not be passed off as an image.""" + mock_app("tethysapp.tethysdash.controllers.App") + mocker.patch( + "tethysapp.tethysdash.controllers._is_blocked_image_host", return_value=False + ) + mocker.patch( + "tethysapp.tethysdash.controllers._connected_peer_is_blocked", + return_value=False, + ) + mock_get = mocker.patch("tethysapp.tethysdash.controllers.requests.get") + mock_get.return_value.raise_for_status.return_value = None + mock_get.return_value.is_redirect = False + mock_get.return_value.headers = {"Content-Type": "text/html"} + + client.force_login(admin_user) + response = client.get( + reverse("tethysdash:image_proxy"), {"url": "https://example.com/oops"} + ) + assert response.status_code == 415 + + +@pytest.mark.django_db +def test_image_proxy_rejects_an_oversized_image(client, admin_user, mock_app, mocker): + """Size is capped by reading, not by trusting Content-Length.""" + mock_app("tethysapp.tethysdash.controllers.App") + mocker.patch( + "tethysapp.tethysdash.controllers._is_blocked_image_host", return_value=False + ) + mocker.patch( + "tethysapp.tethysdash.controllers._connected_peer_is_blocked", + return_value=False, + ) + mocker.patch("tethysapp.tethysdash.controllers.IMAGE_PROXY_MAX_BYTES", 10) + mock_get = mocker.patch("tethysapp.tethysdash.controllers.requests.get") + upstream = mock_get.return_value + upstream.raise_for_status.return_value = None + upstream.is_redirect = False + upstream.headers = {"Content-Type": "image/png"} + upstream.raw.read.return_value = b"x" * 11 + + client.force_login(admin_user) + response = client.get( + reverse("tethysdash:image_proxy"), {"url": "https://example.com/huge.png"} + ) + assert response.status_code == 413 + + +def test_is_blocked_image_host_allows_a_public_address(mocker): + mocker.patch( + "tethysapp.tethysdash.controllers.socket.getaddrinfo", + return_value=[(None, None, None, None, ("93.184.216.34", 0))], + ) + assert _is_blocked_image_host("example.com") is False + + +@pytest.mark.parametrize( + "address", + ["127.0.0.1", "10.0.0.5", "192.168.1.10", "169.254.169.254", "0.0.0.0"], +) +def test_is_blocked_image_host_blocks_internal_addresses(mocker, address): + mocker.patch( + "tethysapp.tethysdash.controllers.socket.getaddrinfo", + return_value=[(None, None, None, None, (address, 0))], + ) + assert _is_blocked_image_host(address) is True + + +def test_is_blocked_image_host_blocks_a_name_that_will_not_resolve(mocker): + """Fail closed: an unresolvable name is refused rather than attempted.""" + mocker.patch( + "tethysapp.tethysdash.controllers.socket.getaddrinfo", + side_effect=socket.gaierror("nope"), + ) + assert _is_blocked_image_host("nowhere.invalid") is True + + +def _redirect_to(location): + """An upstream response that redirects, as requests would report it.""" + hop = MagicMock() + hop.is_redirect = True + hop.headers = {"Location": location} + return hop + + +@pytest.mark.django_db +def test_image_proxy_rechecks_the_host_on_every_redirect( + client, admin_user, mock_app, mocker +): + """A permitted public URL must not be able to bounce us onto an internal one. + + Following redirects automatically is a complete bypass of the address + checks: only the first URL is ever inspected, so a public host answering + with a Location of 127.0.0.1 gets the server to fetch it anyway. + """ + mock_app("tethysapp.tethysdash.controllers.App") + mocker.patch( + "tethysapp.tethysdash.controllers._connected_peer_is_blocked", + return_value=False, + ) + mocker.patch( + "tethysapp.tethysdash.controllers._is_blocked_image_host", + side_effect=lambda host: host != "public.example.com", + ) + mock_get = mocker.patch("tethysapp.tethysdash.controllers.requests.get") + mock_get.return_value = _redirect_to("http://169.254.169.254/latest/meta-data/") + + client.force_login(admin_user) + response = client.get( + reverse("tethysdash:image_proxy"), + {"url": "https://public.example.com/innocent.png"}, + ) + + assert response.status_code == 403 + # The internal address was never requested, only the permitted first hop. + assert mock_get.call_count == 1 + + +@pytest.mark.django_db +def test_image_proxy_follows_a_redirect_to_a_permitted_host( + client, admin_user, mock_app, mocker +): + """Image CDNs redirect routinely, so permitted hops must still be followed.""" + mock_app("tethysapp.tethysdash.controllers.App") + mocker.patch( + "tethysapp.tethysdash.controllers._is_blocked_image_host", return_value=False + ) + mocker.patch( + "tethysapp.tethysdash.controllers._connected_peer_is_blocked", + return_value=False, + ) + final = MagicMock() + final.is_redirect = False + final.raise_for_status.return_value = None + final.headers = {"Content-Type": "image/png"} + final.raw.read.return_value = b"\x89PNG" + mock_get = mocker.patch("tethysapp.tethysdash.controllers.requests.get") + mock_get.side_effect = [_redirect_to("https://cdn.example.com/real.png"), final] + + client.force_login(admin_user) + response = client.get( + reverse("tethysdash:image_proxy"), {"url": "https://example.com/start.png"} + ) + + assert response.status_code == 200 + assert response.content == b"\x89PNG" + + +@pytest.mark.django_db +def test_image_proxy_gives_up_on_a_redirect_loop(client, admin_user, mock_app, mocker): + mock_app("tethysapp.tethysdash.controllers.App") + mocker.patch( + "tethysapp.tethysdash.controllers._is_blocked_image_host", return_value=False + ) + mock_get = mocker.patch("tethysapp.tethysdash.controllers.requests.get") + mock_get.return_value = _redirect_to("https://example.com/round-again.png") + + client.force_login(admin_user) + response = client.get( + reverse("tethysdash:image_proxy"), {"url": "https://example.com/start.png"} + ) + + assert response.status_code == 502 + assert mock_get.call_count == 4 # the first request plus MAX_REDIRECTS hops + + +@pytest.mark.django_db +def test_image_proxy_blocks_a_peer_that_resolved_somewhere_else( + client, admin_user, mock_app, mocker +): + """Guards against DNS rebinding: the socket, not a second lookup, decides. + + The hostname check resolves the name, and requests resolves it again when + it connects. A hostile DNS server is free to answer those two differently. + """ + mock_app("tethysapp.tethysdash.controllers.App") + mocker.patch( + "tethysapp.tethysdash.controllers._is_blocked_image_host", return_value=False + ) + mocker.patch( + "tethysapp.tethysdash.controllers._connected_peer_is_blocked", return_value=True + ) + upstream = MagicMock() + upstream.is_redirect = False + mocker.patch("tethysapp.tethysdash.controllers.requests.get", return_value=upstream) + + client.force_login(admin_user) + response = client.get( + reverse("tethysdash:image_proxy"), {"url": "https://rebind.example.com/x.png"} + ) + + assert response.status_code == 403 + # Refused before any of the body was read. + upstream.raw.read.assert_not_called() + + +@pytest.mark.parametrize( + "peer,expected", + [("93.184.216.34", False), ("127.0.0.1", True), ("169.254.169.254", True)], +) +def test_connected_peer_is_blocked_reads_the_socket(peer, expected): + response = MagicMock() + response.raw._connection.sock.getpeername.return_value = (peer, 443) + assert _connected_peer_is_blocked(response) is expected + + +def test_connected_peer_is_blocked_fails_closed(): + """An unverifiable connection is refused rather than trusted. + + urllib3 releases the connection once a body is read, so this can genuinely + happen; losing one image from a thumbnail is the right price. + """ + response = MagicMock() + response.raw._connection = None + assert _connected_peer_is_blocked(response) is True + + +def test_is_blocked_address_rejects_something_that_is_not_an_address(): + """Fail closed on anything unparseable rather than letting it through.""" + assert _is_blocked_address("not-an-address") is True + + +@pytest.mark.django_db +def test_image_proxy_handles_a_redirect_with_no_location( + client, admin_user, mock_app, mocker +): + mock_app("tethysapp.tethysdash.controllers.App") + mocker.patch( + "tethysapp.tethysdash.controllers._is_blocked_image_host", return_value=False + ) + hop = MagicMock() + hop.is_redirect = True + hop.headers = {} + mocker.patch("tethysapp.tethysdash.controllers.requests.get", return_value=hop) + + client.force_login(admin_user) + response = client.get( + reverse("tethysdash:image_proxy"), {"url": "https://example.com/x.png"} + ) + assert response.status_code == 502 + + +@pytest.mark.django_db +def test_image_proxy_reports_an_upstream_error_status( + client, admin_user, mock_app, mocker +): + """A 404 from the far end is an upstream failure, not a broken thumbnail.""" + mock_app("tethysapp.tethysdash.controllers.App") + mocker.patch( + "tethysapp.tethysdash.controllers._is_blocked_image_host", return_value=False + ) + mocker.patch( + "tethysapp.tethysdash.controllers._connected_peer_is_blocked", + return_value=False, + ) + upstream = MagicMock() + upstream.is_redirect = False + upstream.raise_for_status.side_effect = requests.exceptions.HTTPError("404") + mocker.patch("tethysapp.tethysdash.controllers.requests.get", return_value=upstream) + + client.force_login(admin_user) + response = client.get( + reverse("tethysdash:image_proxy"), {"url": "https://example.com/missing.png"} + ) + assert response.status_code == 502 + + # The zarr endpoints are exercised by calling the views directly with a # RequestFactory: they need no DB or URL routing, and the integrated # reverse()/client harness isn't available in every environment. @@ -2571,7 +2955,9 @@ def test_zarr_cog_requires_src_and_variable(): def test_zarr_cog_rejects_bad_index_and_mask(): r1 = zarr_cog( - RequestFactory().get("/zarr/cog", {"src": "https://x", "variable": "t", "index": "abc"}) + RequestFactory().get( + "/zarr/cog", {"src": "https://x", "variable": "t", "index": "abc"} + ) ) assert r1.status_code == 400 and "index" in json.loads(r1.content)["error"] r2 = zarr_cog( diff --git a/tethysapp/tethysdash/tests/integrated_tests/test_model.py b/tethysapp/tethysdash/tests/integrated_tests/test_model.py index 4d6c97a5..54f9fe0d 100644 --- a/tethysapp/tethysdash/tests/integrated_tests/test_model.py +++ b/tethysapp/tethysdash/tests/integrated_tests/test_model.py @@ -414,6 +414,7 @@ def test_update_named_dashboard_grid_items( "public": True, "tabs": tabs, "unrestrictedPlacement": True, + "autoThumbnail": True, "permissions": [ {"permission": "admin", "username": test_owner_user.username}, ], @@ -525,7 +526,7 @@ def test_update_named_dashboard_image( ) assert ( existing_dashboard[0]["image"] - == "/static/tethysdash/images/default_dashboard.png" + is None ) example_image = os.path.join( @@ -733,7 +734,8 @@ def test_get_dashboards_all( {"permission": "viewer", "group": permission_group["name"]}, ], "unrestrictedPlacement": dashboard.unrestricted_placement, - "image": "/static/tethysdash/images/default_dashboard.png", + "autoThumbnail": True, + "image": None, "owner": test_owner_user.username, }, { @@ -747,7 +749,8 @@ def test_get_dashboards_all( {"permission": "admin", "username": public_dashboard.owner} ], "unrestrictedPlacement": public_dashboard.unrestricted_placement, - "image": "/static/tethysdash/images/default_dashboard.png", + "autoThumbnail": True, + "image": None, "owner": public_dashboard.owner, }, ] @@ -776,9 +779,10 @@ def test_get_dashboards_specific_dashboard_view( "description": dashboard.description, "notes": dashboard.notes, "tabs": [], - "image": "/static/tethysdash/images/default_dashboard.png", + "image": None, "uuid": "some_user_dashboard_uuid", "unrestrictedPlacement": False, + "autoThumbnail": True, "owner": test_owner_user.username, "permissions": [ {"permission": "admin", "username": test_owner_user.username}, @@ -809,9 +813,10 @@ def test_get_dashboards_specific_landing_page_view( "id": dashboard.id, "name": dashboard.name, "description": dashboard.description, - "image": "/static/tethysdash/images/default_dashboard.png", + "image": None, "uuid": "some_user_dashboard_uuid", "unrestrictedPlacement": False, + "autoThumbnail": True, "owner": test_owner_user.username, "permissions": [ {"permission": "admin", "username": test_owner_user.username}, @@ -932,8 +937,9 @@ def test_parse_db_dashboard_landing_page_view( "uuid": dashboard.uuid, "name": dashboard.name, "description": dashboard.description, - "image": "/static/tethysdash/images/default_dashboard.png", + "image": None, "unrestrictedPlacement": False, + "autoThumbnail": True, "owner": test_owner_user.username, "permissions": [ {"permission": "admin", "username": test_owner_user.username}, @@ -972,6 +978,7 @@ def test_parse_db_dashboard_landing_page_view_with_prefix( "description": dashboard.description, "image": "/test/media/tethysdash/app/some_user_dashboard_uuid.png", "unrestrictedPlacement": False, + "autoThumbnail": True, "owner": test_owner_user.username, "permissions": [ {"permission": "admin", "username": test_owner_user.username}, @@ -1006,10 +1013,11 @@ def test_parse_db_dashboard_dashboard_view( "uuid": dashboard.uuid, "name": dashboard.name, "description": dashboard.description, - "image": "/static/tethysdash/images/default_dashboard.png", + "image": None, "notes": dashboard.notes, "tabs": [], "unrestrictedPlacement": False, + "autoThumbnail": True, "owner": test_owner_user.username, "permissions": [ {"permission": "admin", "username": test_owner_user.username}, @@ -2230,3 +2238,21 @@ def test_init_primary_db_moves_json_and_geojson_files( set_main_option_calls, any_order=True ) mock_command.ensure_version.assert_called_once() + + +def test_update_named_dashboard_auto_thumbnail( + db_session, dashboard, mock_app_get_ps_db, mocker, tmp_path, test_owner_user +): + """The toggle behind "Update thumbnail on save".""" + mock_app_get_ps_db("tethysapp.tethysdash.app.App") + mock_get_app_media = mocker.patch("tethysapp.tethysdash.model.get_app_media") + mock_get_app_media.return_value = MagicMock(path=tmp_path) + assert dashboard.auto_thumbnail is True + + update_named_dashboard(test_owner_user, dashboard.id, {"autoThumbnail": False}) + db_session.refresh(dashboard) + assert dashboard.auto_thumbnail is False + + update_named_dashboard(test_owner_user, dashboard.id, {"autoThumbnail": True}) + db_session.refresh(dashboard) + assert dashboard.auto_thumbnail is True