Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ 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, SVG files are offered for download instead of being opened
- Item Search: Support for the conformance class that requires the `collections` parameter (`item-search#require-collections`)

### Fixed

- Icons, logos and card thumbnails that fail to load are hidden
- A page that is still loading no longer replaces the page that was navigated to in the meantime
- Maps showing multiple Items now send the configured authentication (headers and query parameters) and apply the source/layer customizations and `maxDisplayPixels` for each Item
- The `crossOriginMedia` option now applies to images on maps
Expand Down
10 changes: 10 additions & 0 deletions docs/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ The override order for the configuration is:
- [allowExternalAccess](#allowexternalaccess)
- [allowedDomains](#alloweddomains)
- [crossOriginMedia](#crossoriginmedia)
- [SVG images](#svg-images)
- [authConfig](#authconfig)
- [API Keys](#api-keys)
- [HTTP Basic](#http-basic)
Expand Down Expand Up @@ -249,6 +250,15 @@ Domain and subdomain patterns ignore schema, userinfo, port, path, query and fra

The value for the [`crossorigin` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin) that is sent when loading images through the browser. Default to `null`. If you encounter issues with loading images, you may want to try setting this to `anonymous`.

#### SVG images

STAC Browser shows SVG images (`image/svg+xml`), e.g. icons and thumbnails.
SVG files can contain scripts, which don't run when the image is shown in STAC Browser, but may run when a user opens the file directly.
We recommend serving SVG data from a different origin (domain) than STAC Browser.
Otherwise, serve the SVG files with a [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) that blocks scripts, e.g. `script-src 'none'`.

If STAC Browser is served with a Content Security Policy, `img-src` must allow `blob:` and `data:`, as images that require authentication headers are shown from such URLs.

### authConfig

***experimental***
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
"proj4": "^2.16.1",
"remove-markdown": "^0.6.2",
"shiki": "^3.22.0",
"stac-js": "^0.5.6",
"stac-js": "^0.5.8",
Comment thread
m-mohr marked this conversation as resolved.
"stac-node-validator": "2.0.0-rc.3",
"streamsaver-js": "^2.0.7",
"urijs": "^1.19.11",
Expand Down
29 changes: 23 additions & 6 deletions src/components/AuthImage.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<template>
<img
v-if="displaySrc" :src="displaySrc" :alt="alt" :title="title"
v-if="showImage" :src="displaySrc" :alt="alt" :title="title"
:crossorigin="crossorigin" :class="placement ? `card-img-${placement}` : null"
:loading="lazy ? 'lazy' : null" :width="width" :height="height"
@load="$emit('load', $event)" @error="$emit('error', $event)"
@load="$emit('load', $event)" @error="onError"
>
</template>

Expand All @@ -22,8 +22,10 @@ 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 reported through the `error` event and hidden, unless
* `showBroken` is set. They are shown again once their URL changes, e.g.
* after login.
*/
export default {
name: 'AuthImage',
Expand Down Expand Up @@ -61,6 +63,11 @@ export default {
height: {
type: [Number, String],
default: null
},
// Keeps images that fail to load visible
showBroken: {
type: Boolean,
default: false
}
},
emits: ['load', 'error', 'resolved'],
Expand All @@ -70,6 +77,9 @@ export default {
// The URL currently held in the authMedia cache
acquiredUrl: null,
failed: false,
// The URL that the image element failed to load. Also covers header
// credentials: new ones lead to a new object URL (or data URL for new content).
brokenSrc: null,
Comment thread
m-mohr marked this conversation as resolved.
disposed: false,
// Whether a lazy image is close enough to the viewport to be loaded
nearViewport: false,
Expand All @@ -91,6 +101,9 @@ export default {
return this.blobUrl; // null (= not rendered) while loading
}
return this.requestUrl;
},
showImage() {
return Boolean(this.displaySrc) && (this.showBroken || this.displaySrc !== this.brokenSrc);
}
},
watch: {
Expand All @@ -103,6 +116,7 @@ export default {
},
src() {
this.failed = false;
this.brokenSrc = null;
this.resolve();
},
requestHeaders: {
Expand Down Expand Up @@ -149,6 +163,10 @@ export default {
this.releaseAcquired();
},
methods: {
onError(event) {
this.brokenSrc = this.displaySrc;
this.$emit('error', event);
Comment thread
m-mohr marked this conversation as resolved.
},
disconnectObserver() {
if (this.observer) {
this.observer.disconnect();
Expand Down Expand Up @@ -177,7 +195,7 @@ export default {
this.releaseAcquired();
this.acquiredUrl = url;
this.blobUrl = blobUrl;
} catch (error) {
} catch {
if (this.disposed || url !== this.src || !this.useAuthenticatedFetch) {
return;
}
Expand All @@ -188,7 +206,6 @@ export default {
// reports the failure through the error event of the element.
this.failed = true;
this.blobUrl = null;
this.$emit('error', error);
}
},
releaseAcquired() {
Expand Down
6 changes: 5 additions & 1 deletion src/components/HrefActions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ import AssetActions from '../../assetActions.config';
import LinkActions from '../../linkActions.config';
import AuthUtils from './auth/utils';
import { browserProtocols } from 'stac-js/src/http';
import { imageMediaTypes, geojsonMediaType, wozMediaTypes, zarrMediaTypes } from 'stac-js/src/mediatypes';
import { imageMediaTypes, geojsonMediaType, svgMediaType, wozMediaTypes, zarrMediaTypes } from 'stac-js/src/mediatypes';
import { getGeoZarrSourceOptionsFromAsset } from 'ol-stac/util.js';

const disableDownloadTypes = [...zarrMediaTypes];
Expand Down Expand Up @@ -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 (this.data.isType(svgMediaType)) {
return false;
Comment thread
m-mohr marked this conversation as resolved.
Comment thread
m-mohr marked this conversation as resolved.
}
if (this.data.canBrowserDisplayImage()) {
return true;
}
Expand Down
2 changes: 1 addition & 1 deletion src/components/Thumbnails.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
target="_blank" rel="noopener noreferrer" download
>
<AuthImage
class="thumbnail" :src="thumbnail.getAbsoluteUrl()" :crossorigin="crossOriginMedia"
class="thumbnail" :src="thumbnail.getAbsoluteUrl()" :crossorigin="crossOriginMedia" show-broken
@resolved="url => hrefs[thumbnail.href] = url"
/>
</a>
Expand Down
36 changes: 31 additions & 5 deletions src/models/authMedia.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { size } from 'stac-js/src/utils.js';
import { svgMediaType } from 'stac-js/src/mediatypes.js';

/**
* Loads media (e.g. images) through an authenticated request when needed.
Expand All @@ -7,6 +8,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
Expand All @@ -21,6 +26,21 @@ import { size } from 'stac-js/src/utils.js';

const cache = new Map();

/**
* Reads the given blob as a `data:` URL.
*
* @param {Blob} blob The blob.
* @returns {Promise<string>} 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
Expand Down Expand Up @@ -52,23 +72,23 @@ 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);
}
}
}

/**
* 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
* (a page full of protected images must not open a login dialog per image).
*
* @param {Object} store The Vuex store.
* @param {string} url The absolute URL of the media.
* @returns {Promise<string>} The object URL for the media.
* @returns {Promise<string>} The object URL (or `data:` URL) for the media.
*/
export async function acquire(store, url) {
let entry = cache.get(url);
Expand All @@ -82,8 +102,14 @@ export async function acquire(store, url) {
axiosOptions: { responseType: 'blob' },
noRetry: true
})
.then(response => {
entry.objectUrl = URL.createObjectURL(response.data);
.then(async response => {
// Prefix match as the content type may have parameters (e.g. charset)
if (response.data.type.toLowerCase().startsWith(svgMediaType)) {
entry.objectUrl = await toDataUrl(response.data);
}
else {
entry.objectUrl = URL.createObjectURL(response.data);
}
return entry.objectUrl;
})
};
Expand Down
40 changes: 40 additions & 0 deletions tests/e2e/authentication.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,29 @@ test.describe('Authenticated media', () => {
await expect(img).toHaveAttribute('src', /^blob:/);
});

test('SVG thumbnails load with the auth header via data URLs', async ({ page, worker }) => {
await configureBrowser(page, {
authConfig: { type: 'apiKey', in: 'header', name: 'X-API-Key' },
});
const { catalog, item } = createCatalogWithItem();
await catalog.createServer(worker);
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10"/></svg>';
await worker.use(
http.get(THUMB_URL, ({ request }) => {
if (!hasHeader('x-api-key', 'secret')(request)) {
return new HttpResponse(null, { status: 401 });
}
return new HttpResponse(svg, { headers: { 'Content-Type': 'image/svg+xml' } });
}),
);
await loginOnItemPage(page, worker, catalog, item);

const img = await openThumbnailsTab(page);
await expect.poll(() => img.evaluate(el => el.naturalWidth)).toBeGreaterThan(0);
// A data URL has an opaque origin, unlike an object URL
await expect(img).toHaveAttribute('src', /^data:image\/svg\+xml/);
});

test('thumbnails carry the private query parameter without object URLs', async ({ page, worker }) => {
await configureBrowser(page, {
authConfig: { type: 'apiKey', in: 'query', name: 'API_KEY' },
Expand Down Expand Up @@ -278,6 +301,23 @@ test.describe('Authenticated media', () => {
await expect(img).toHaveAttribute('src', THUMB_URL);
});

test('failed thumbnails are hidden on cards, but kept in the thumbnails tab', async ({ page, worker }) => {
const { catalog, item } = createCatalogWithItem();
await catalog.createServer(worker);
const requests = await mockImage(worker, THUMB_URL, () => false);

await page.goto(catalog.root.getBrowserPath());
await waitForBrowserReady(page);
await expect.poll(() => requests.length).toBeGreaterThan(0);
await expect(page.locator('.card img.thumbnail')).toHaveCount(0);

await page.goto(item.getBrowserPath());
await waitForBrowserReady(page);
const img = await openThumbnailsTab(page);
// Keeps the link to open or download the file
await expect(img).toHaveAttribute('src', THUMB_URL);
});

test('card thumbnails on the catalog page load with the auth header', async ({ page, worker }) => {
await configureBrowser(page, {
catalogUrl: ROOT_URL,
Expand Down
32 changes: 32 additions & 0 deletions tests/e2e/item.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,38 @@ test.describe('Item view - Alternate thumbnails', () => {
});
});

test.describe('Item view - Asset actions', () => {
test('SVG assets are offered for download instead of being opened', async ({ page, worker }) => {
const catalog = new StaticCatalog({ url: 'https://stac.example/catalog.json' });
const item = catalog.addItem({ url: 'https://stac.example/item.json' });
item.data.assets = {
legend: { href: 'https://stac.example/legend.svg', type: 'image/svg+xml', roles: ['metadata'], title: 'Legend' },
chart: { href: 'https://stac.example/chart.png', type: 'image/png', roles: ['metadata'], title: 'Chart' }
};
await catalog.createServer(worker);

await page.goto(item.getBrowserPath());
await waitForBrowserReady(page);

const expandAsset = async (title) => {
const asset = page.locator('.asset', { hasText: title });
await asset.getByRole('button', { name: title }).click();
return asset;
};

// SVG files opened in the browser can run scripts
const legend = await expandAsset('Legend');
const download = legend.getByRole('button', { name: /download/i });
await expect(download).toBeVisible();
await expect(download).toHaveAttribute('download');
await expect(legend.getByRole('button', { name: /^open$/i })).toHaveCount(0);

// Other images that browsers can show are still opened
const chart = await expandAsset('Chart');
await expect(chart.getByRole('button', { name: /^open$/i })).toBeVisible();
});
});

test.describe('Item view - Geometry', () => {
function createCatalog() {
const catalog = new StaticCatalog({url: 'https://stac.example/catalog.json'});
Expand Down
Loading