From db74cfb891834f8d24b559565e09e3160e3a85cb Mon Sep 17 00:00:00 2001 From: yharby Date: Thu, 24 Sep 2026 16:39:31 +0300 Subject: [PATCH 1/7] Support SVG icons and previews --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0a0833ca..620a36cf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - New config option `displayPreviewsForChildren` controls preview images on maps showing multiple Items +- SVG icons, thumbnails and previews (`image/svg+xml`) are shown ### Fixed From 1270949012c883819e7835611461604c054147b1 Mon Sep 17 00:00:00 2001 From: yharby Date: Fri, 25 Sep 2026 15:54:33 +0300 Subject: [PATCH 2/7] fix: serve authenticated SVG images as data URLs --- src/models/authMedia.js | 44 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/src/models/authMedia.js b/src/models/authMedia.js index 84e98b2af..e2ac31ae7 100644 --- a/src/models/authMedia.js +++ b/src/models/authMedia.js @@ -7,6 +7,10 @@ import { size } from 'stac-js/src/utils.js'; * header-based authentication methods (API key in header, HTTP Basic, OIDC) * require. For those, the media is requested through the regular * (authenticated) request pipeline and exposed as an object URL instead. + * SVG images are exposed as `data:` URLs instead of object URLs: an object URL + * has the origin of STAC Browser, so an SVG opened from it (e.g. through a + * download link) could run scripts with access to the storage and cookies of + * STAC Browser. A `data:` URL has an opaque origin. * * Object URLs are cached per source URL and reference-counted so that * repeated usage (e.g. the same icon in a list of cards) only requests @@ -21,6 +25,31 @@ import { size } from 'stac-js/src/utils.js'; const cache = new Map(); +/** + * Whether the given blob is an SVG image. + * + * @param {Blob} blob The blob. + * @returns {boolean} `true` if the blob is an SVG image. + */ +function isSvg(blob) { + return typeof blob.type === 'string' && blob.type.toLowerCase().startsWith('image/svg+xml'); +} + +/** + * Reads the given blob as a `data:` URL. + * + * @param {Blob} blob The blob. + * @returns {Promise} The `data:` URL. + */ +function toDataUrl(blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(blob); + }); +} + /** * Whether the given URL must be loaded through an authenticated request * to receive the credentials (i.e. request headers are configured and the @@ -52,7 +81,7 @@ export function release(store, url) { entry.refs--; if (entry.refs <= 0) { cache.delete(url); - if (entry.objectUrl) { + if (entry.objectUrl && entry.objectUrl.startsWith('blob:')) { URL.revokeObjectURL(entry.objectUrl); } } @@ -60,7 +89,7 @@ export function release(store, url) { /** * Requests the given URL with the configured credentials and returns an - * object URL for the response. + * object URL for the response (a `data:` URL for SVG images). * * Every successful call must be paired with a `release` call for the same * URL. Failed requests are not cached, but do NOT trigger the login dialog @@ -68,7 +97,7 @@ export function release(store, url) { * * @param {Object} store The Vuex store. * @param {string} url The absolute URL of the media. - * @returns {Promise} The object URL for the media. + * @returns {Promise} The object URL (or `data:` URL) for the media. */ export async function acquire(store, url) { let entry = cache.get(url); @@ -82,8 +111,13 @@ export async function acquire(store, url) { axiosOptions: { responseType: 'blob' }, noRetry: true }) - .then(response => { - entry.objectUrl = URL.createObjectURL(response.data); + .then(async response => { + if (isSvg(response.data)) { + entry.objectUrl = await toDataUrl(response.data); + } + else { + entry.objectUrl = URL.createObjectURL(response.data); + } return entry.objectUrl; }) }; From 9d242f19ca137de3bdc62e364aca5dd607c1921f Mon Sep 17 00:00:00 2001 From: yharby Date: Fri, 25 Sep 2026 15:54:33 +0300 Subject: [PATCH 3/7] fix: download SVG files instead of opening them --- src/components/HrefActions.vue | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/HrefActions.vue b/src/components/HrefActions.vue index b6dba6e0e..0381214ed 100644 --- a/src/components/HrefActions.vue +++ b/src/components/HrefActions.vue @@ -238,6 +238,10 @@ export default { if (this.useAltDownloadMethod) { return false; } + // SVG files opened in the browser can run scripts, so they are downloaded instead + if (typeof this.data?.type === 'string' && this.data.type.toLowerCase().startsWith('image/svg+xml')) { + return false; + } if (this.data.canBrowserDisplayImage()) { return true; } From 38a18e3b36734a68dae0a94aa848ac4fd2de16b1 Mon Sep 17 00:00:00 2001 From: yharby Date: Fri, 25 Sep 2026 15:54:33 +0300 Subject: [PATCH 4/7] fix: hide images that fail to load, fall back to the next thumbnail --- src/components/AuthImage.vue | 20 ++++++++++++++------ src/components/CardMixin.js | 21 +++++++++++++++++++-- src/components/Catalog.vue | 2 +- src/components/Item.vue | 2 +- src/components/Thumbnails.vue | 13 +++++++++---- 5 files changed, 44 insertions(+), 14 deletions(-) diff --git a/src/components/AuthImage.vue b/src/components/AuthImage.vue index 6c61e063b..dd5c95e67 100644 --- a/src/components/AuthImage.vue +++ b/src/components/AuthImage.vue @@ -1,9 +1,9 @@ @@ -22,8 +22,9 @@ import { acquire, needsAuthenticatedFetch, release } from '../models/authMedia'; * never receive credentials. * * Failed authenticated requests fall back to loading the image directly - * (without the headers) and never open the login dialog; errors are - * reported through the `error` event. + * (without the headers) and never open the login dialog. Images that fail + * to load are hidden and reported through the `error` event, e.g. so that + * parents can fall back to another image. */ export default { name: 'AuthImage', @@ -70,6 +71,8 @@ export default { // The URL currently held in the authMedia cache acquiredUrl: null, failed: false, + // Whether the image element failed to load the image + broken: false, disposed: false, // Whether a lazy image is close enough to the viewport to be loaded nearViewport: false, @@ -103,6 +106,7 @@ export default { }, src() { this.failed = false; + this.broken = false; this.resolve(); }, requestHeaders: { @@ -110,6 +114,7 @@ export default { handler() { // Re-resolve after login/logout this.failed = false; + this.broken = false; this.resolve(); } } @@ -149,6 +154,10 @@ export default { this.releaseAcquired(); }, methods: { + onError(event) { + this.broken = true; + this.$emit('error', event); + }, disconnectObserver() { if (this.observer) { this.observer.disconnect(); @@ -177,7 +186,7 @@ export default { this.releaseAcquired(); this.acquiredUrl = url; this.blobUrl = blobUrl; - } catch (error) { + } catch { if (this.disposed || url !== this.src || !this.useAuthenticatedFetch) { return; } @@ -188,7 +197,6 @@ export default { // reports the failure through the error event of the element. this.failed = true; this.blobUrl = null; - this.$emit('error', error); } }, releaseAcquired() { diff --git a/src/components/CardMixin.js b/src/components/CardMixin.js index 1c9343e02..27ce2a97a 100644 --- a/src/components/CardMixin.js +++ b/src/components/CardMixin.js @@ -17,6 +17,12 @@ export default { validator: value => value === null || ['list', 'cards'].includes(value) } }, + data() { + return { + // The index of the thumbnail to show, increased if a thumbnail fails to load + thumbnailIndex: 0 + }; + }, computed: { ...mapState(['cardViewMode', 'crossOriginMedia', 'defaultThumbnailSize']), isList() { @@ -28,8 +34,8 @@ export default { thumbnail() { if (this.data) { let thumbnails = this.data.getThumbnails(true, 'thumbnail', true); - if (thumbnails.length > 0) { - let t = thumbnails[0]; + if (thumbnails.length > this.thumbnailIndex) { + let t = thumbnails[this.thumbnailIndex]; let width, height; const shape = t.getMetadata('proj:shape'); if (Array.isArray(shape) && shape.length === 2) { @@ -65,5 +71,16 @@ export default { summarizeDescription() { return this.hasDescription ? Utils.summarizeMd(this.data.getMetadata('description'), 300) : ''; } + }, + watch: { + data() { + this.thumbnailIndex = 0; + } + }, + methods: { + // Falls back to the next thumbnail, hides the image if there's none + nextThumbnail() { + this.thumbnailIndex++; + } } }; diff --git a/src/components/Catalog.vue b/src/components/Catalog.vue index 310613b15..e2796c42e 100644 --- a/src/components/Catalog.vue +++ b/src/components/Catalog.vue @@ -1,7 +1,7 @@