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
17 changes: 14 additions & 3 deletions e2e/auth/session-expiry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,22 @@ test.describe('Session expiry', () => {
await page.goto('/');
await waitForHydration(page);

// The storage API answers 401 when no storage connection header is sent
// (requires the storage browser to be enabled, as in .env.test).
const status = await fetchAndAwaitSessionCheck(page, '/api/storage/buckets');
await page.route('**/api/trino/catalog?*', (route) =>
route.fulfill({ status: 401, json: { message: 'Authentication required' } })
);
const status = await fetchAndAwaitSessionCheck(page, '/api/trino/catalog?level=catalogs');
expect(status).toBe(401);

await expect(page.getByRole('dialog', { name: 'Session expired' })).toBeHidden();
});

test('a storage call without a connection is not mistaken for an expired session', async ({
page
}) => {
// Requires the storage browser to be enabled, as in .env.test.
const res = await page.request.get('/api/storage/buckets');

expect(res.status()).toBe(409);
expect(await res.json()).toMatchObject({ reason: 'not-connected' });
});
});
9 changes: 5 additions & 4 deletions messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -648,11 +648,12 @@
"storage_details_unavailable": "Konnte nicht geladen werden",
"storage_details_custom_metadata": "Benutzerdefinierte Metadaten",
"storage_details_error_not_connected": "Keine Speicherverbindung konfiguriert",
"storage_details_error_fetch_file": "Dateidetails konnten nicht abgerufen werden ({status})",
"storage_details_error_fetch_bucket": "Bucket-Details konnten nicht abgerufen werden ({status})",
"storage_details_error_fetch_dir_meta": "Verzeichnismetadaten konnten nicht abgerufen werden ({status})",
"storage_details_error_fetch_file": "Die Dateidetails konnten nicht geladen werden.",
"storage_details_error_fetch_bucket": "Die Bucket-Details konnten nicht geladen werden.",
"storage_details_error_fetch_dir_meta": "Die Verzeichnismetadaten konnten nicht geladen werden.",
"storage_details_error_access_denied": "Sie haben keine Berechtigung, diese Details anzuzeigen.",
"storage_details_error_not_found": "Nicht gefunden. Möglicherweise wurde es verschoben oder gelöscht.",
"storage_details_error_calc_size": "Die Verzeichnisgröße konnte nicht berechnet werden. Bitte versuchen Sie es erneut.",
"storage_details_error_unknown": "Unbekannter Fehler",
"storage_details_error_no_body": "Kein Antworttext",
"storage_details_unnamed_rule": "(unbenannt)",
"storage_details_treemap_aria": "Treemap-Visualisierung der Verzeichnisgrößenverteilung",
Expand Down
9 changes: 5 additions & 4 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -648,11 +648,12 @@
"storage_details_unavailable": "Could not load",
"storage_details_custom_metadata": "Custom Metadata",
"storage_details_error_not_connected": "No storage connection configured",
"storage_details_error_fetch_file": "Failed to fetch file details ({status})",
"storage_details_error_fetch_bucket": "Failed to fetch bucket details ({status})",
"storage_details_error_fetch_dir_meta": "Failed to fetch directory metadata ({status})",
"storage_details_error_fetch_file": "Failed to load the file details.",
"storage_details_error_fetch_bucket": "Failed to load the bucket details.",
"storage_details_error_fetch_dir_meta": "Failed to load the directory metadata.",
"storage_details_error_access_denied": "You do not have permission to view these details.",
"storage_details_error_not_found": "Not found. It may have been moved or deleted.",
"storage_details_error_calc_size": "Failed to calculate the directory size. Please try again.",
"storage_details_error_unknown": "Unknown error",
"storage_details_error_no_body": "No response body",
"storage_details_unnamed_rule": "(unnamed)",
"storage_details_treemap_aria": "Treemap visualization of directory size composition",
Expand Down
4 changes: 0 additions & 4 deletions src/architecture-tests/i18n-compliance.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,6 @@ const KNOWN_UNUSED_KEYS = [
'storage_details_tree_visualization',
'storage_details_lifecycle_rule_id',
'storage_details_lifecycle_status',
'storage_details_error_not_connected',
'storage_details_error_fetch_file',
'storage_details_error_fetch_bucket',
'storage_details_error_fetch_dir_meta',
'storage_details_tab_overview',
'storage_details_tab_lifecycle',
'storage_details_bucket_name',
Expand Down
3 changes: 2 additions & 1 deletion src/hooks.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { getConnectionFromHeader } from '$lib/server/storage/connection.js';
import { STORAGE_CONNECTION_ID_HEADER } from '$lib/storage/connection-id-header.js';
import { storageBrowserEnabled } from '$lib/server/feature-flags.js';
import { storageEncryptionKey } from '$lib/server/storage/encryption-key.js';
import { notConnected } from '$lib/server/storage/not-connected.js';
import { runMigrations } from '$lib/server/migrate.js';

// Allow self-signed TLS certificates in development (e.g. local Trino with self-signed certs).
Expand Down Expand Up @@ -102,7 +103,7 @@ const handleStorageConnection: Handle = async ({ event, resolve }) => {
!event.route.id?.startsWith('/(app)/api/storage/download/manifests/[manifestId]/') &&
event.route.id !== '/(app)/api/storage/download/manifests';
if (event.locals.storageConfig === null && requiresConnectionHeader) {
throw error(401, 'No storage connection configured');
notConnected();
}
return resolve(event);
};
Expand Down
8 changes: 2 additions & 6 deletions src/lib/components/storage/modals/DetailsModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import IconBucket from '$lib/components/storage/shared/BucketIcon.svelte';
import * as m from '$lib/paraglide/messages.js';
import { getStorageState } from '$lib/storage/context.js';
import { StorageError } from '$lib/storage/errors.js';
import { getDetailsErrorMessage } from '$lib/storage/errors.js';
import type { FileDetails as FileDetailsType } from '$lib/storage/details-types.js';
import FileDetails from './details/FileDetails.svelte';
import DirectoryDetails from './details/DirectoryDetails.svelte';
Expand Down Expand Up @@ -54,11 +54,7 @@
try {
fileDetails = await storage.api.details({ bucket, key });
} catch (err) {
if (err instanceof StorageError) {
loadError = err.message;
} else {
loadError = err instanceof Error ? err.message : m.storage_details_error_unknown();
}
loadError = getDetailsErrorMessage(err, m.storage_details_error_fetch_file());
} finally {
loading = false;
}
Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/storage/modals/PreviewModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -616,7 +616,7 @@
m.storage_editor_too_large({ limit: formatFileSize(maxEditableFileSize) })
);
} else {
addToast('error', err.message || m.storage_editor_error());
addToast('error', m.storage_editor_error());
}
} else {
addToast('error', m.storage_editor_error());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import IconPerson from 'virtual:icons/material-symbols/person';
import IconKey from 'virtual:icons/material-symbols/key';
import { getStorageState } from '$lib/storage/context.js';
import { StorageError } from '$lib/storage/errors.js';
import { getDetailsErrorMessage } from '$lib/storage/errors.js';
import type { BucketDetails as BucketDetailsType } from '$lib/storage/details-types.js';

interface Props {
Expand All @@ -32,11 +32,7 @@
if (!cancelled) details = result;
} catch (err) {
if (cancelled) return;
if (err instanceof StorageError) {
error = err.message;
} else {
error = err instanceof Error ? err.message : m.storage_details_error_unknown();
}
error = getDetailsErrorMessage(err, m.storage_details_error_fetch_bucket());
} finally {
if (!cancelled) loading = false;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import IconWarning from 'virtual:icons/material-symbols/warning';
import { formatFileSize, keyToName } from '$lib/storage/utils.js';
import { getStorageState } from '$lib/storage/context.js';
import { StorageError } from '$lib/storage/errors.js';
import { getDetailsErrorMessage } from '$lib/storage/errors.js';
import type {
DirectorySizeEvent,
DirectoryMetadata,
Expand Down Expand Up @@ -54,11 +54,7 @@
if (bucket === requestedBucket && prefix === requestedPrefix) meta = result;
} catch (err) {
if (bucket !== requestedBucket || prefix !== requestedPrefix) return;
if (err instanceof StorageError) {
metaError = err.message;
} else {
metaError = err instanceof Error ? err.message : m.storage_details_error_unknown();
}
metaError = getDetailsErrorMessage(err, m.storage_details_error_fetch_dir_meta());
}
}

Expand Down Expand Up @@ -105,7 +101,7 @@
} else if (event.type === 'complete') {
result = event;
} else if (event.type === 'error') {
error = event.message;
error = m.storage_details_error_calc_size();
}
} catch {
// skip malformed events
Expand Down
3 changes: 2 additions & 1 deletion src/lib/server/storage/connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ describe('getConnectionFromHeader', () => {
it('rejects an unknown connection', async () => {
selectRows([]);
await expect(getConnectionFromHeader(request(), 'user-id')).rejects.toMatchObject({
status: 401
status: 409,
body: { reason: 'not-connected' }
});
});

Expand Down
5 changes: 3 additions & 2 deletions src/lib/server/storage/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { db } from '$lib/server/db.js';
import { userStorageConnections } from '$lib/server/schema.js';
import { decrypt } from './encryption.js';
import { storageEncryptionKey } from './encryption-key.js';
import { notConnected } from './not-connected.js';
import type { S3ConnectionConfig } from './types.js';
import { logger } from '$lib/server/logging';
import { STORAGE_CONNECTION_ID_HEADER } from '$lib/storage/connection-id-header.js';
Expand All @@ -27,7 +28,7 @@ const AdditionalBucketsSchema = z.array(z.string());
* {@link LAST_USED_TOUCH_INTERVAL_MS}, so not every storage request hits the database.
*
* Returns `null` if the header is absent.
* Throws 401 if the connection is not found or does not belong to the user.
* Throws 409 (`notConnected`) if the connection is not found or does not belong to the user.
* Throws 500 if decryption fails or the payload does not match the stored schema.
*/
export async function getConnectionFromHeader(
Expand All @@ -48,7 +49,7 @@ export async function getConnectionFromHeader(

if (rows.length === 0) {
log.warn({ connection_id: connectionId }, 'storage connection not found or unauthorised');
throw error(401, 'Storage connection not found');
notConnected('Storage connection not found');
}

const row = rows[0];
Expand Down
10 changes: 10 additions & 0 deletions src/lib/server/storage/not-connected.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { error } from '@sveltejs/kit';

/**
* Reject a storage request that has no usable storage connection. A 409 with
* `reason: 'not-connected'` keeps it apart from a 401, which means the session
* expired and makes the client check the session.
*/
export function notConnected(message = 'No storage connection configured'): never {
error(409, { message, reason: 'not-connected' });
}
5 changes: 3 additions & 2 deletions src/lib/server/storage/request-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { error } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import { getProvider } from './utils.js';
import { withStorageHttpErrors } from './wrap-provider.js';
import { notConnected } from './not-connected.js';
import type { StorageProvider } from './provider.js';
import { STORAGE_CONNECTION_ID_HEADER } from '$lib/storage/connection-id-header.js';
import { StorageObjectKeySchema } from '$lib/storage/schemas.js';
Expand All @@ -23,14 +24,14 @@ export function requireObjectKey(event: RequestEvent): string {

export function requireConfig(event: RequestEvent) {
const config = event.locals.storageConfig;
if (!config) throw error(401, 'No storage connection configured');
if (!config) notConnected();
return config;
}

/** The connection id is captured for a manifest, never its decrypted config. */
export function requireStorageConnectionId(event: RequestEvent): string {
const connectionId = event.request.headers.get(STORAGE_CONNECTION_ID_HEADER);
if (!connectionId) throw error(401, 'No storage connection configured');
if (!connectionId) notConnected();
return connectionId;
}

Expand Down
8 changes: 4 additions & 4 deletions src/lib/storage/archive.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export class ArchiveState {

try {
await this._fetchArchiveListing();
} catch (err) {
} catch {
if (this._archiveNestedPath) {
this._archiveNestedPath = null;
} else {
Expand All @@ -89,7 +89,7 @@ export class ArchiveState {
}
this.archivePrefix = '';
this.archiveLoading = false;
addToast('error', err instanceof Error ? err.message : m.storage_archive_open_error());
addToast('error', m.storage_archive_open_error());
}
}

Expand All @@ -102,9 +102,9 @@ export class ArchiveState {

try {
await this._fetchArchiveListing();
} catch (err) {
} catch {
this.archiveLoading = false;
addToast('error', err instanceof Error ? err.message : m.storage_archive_open_error());
addToast('error', m.storage_archive_open_error());
}
}

Expand Down
29 changes: 27 additions & 2 deletions src/lib/storage/errors.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { describe, it, expect, vi } from 'vitest';
import { StorageError, getActionErrorMessage, getActionErrorMessageForCode } from './errors.js';
import {
StorageError,
getActionErrorMessage,
getActionErrorMessageForCode,
getDetailsErrorMessage
} from './errors.js';

const mockMessages = vi.hoisted(() => ({
storage_download_error_not_connected: () => 'No storage connection configured',
Expand All @@ -8,7 +13,10 @@ const mockMessages = vi.hoisted(() => ({
storage_download_error_server_error: () => 'Server error',
storage_upload_error_no_such_bucket: () => 'Bucket not found',
storage_upload_error_invalid_part: () => 'Invalid part',
storage_download_error_unknown: () => 'Unknown error'
storage_download_error_unknown: () => 'Unknown error',
storage_details_error_not_connected: () => 'Details: not connected',
storage_details_error_access_denied: () => 'Details: access denied',
storage_details_error_not_found: () => 'Details: not found'
}));

vi.mock('$lib/paraglide/messages.js', () => mockMessages);
Expand Down Expand Up @@ -65,3 +73,20 @@ describe('getActionErrorMessageForCode', () => {
expect(getActionErrorMessageForCode('unknown')).toBe('Unknown error');
});
});

describe('getDetailsErrorMessage', () => {
it.each([
['not_connected', 'Details: not connected'],
['access_denied', 'Details: access denied'],
['not_found', 'Details: not found'],
['server_error', 'fallback']
])('maps %s', (code, expected) => {
expect(getDetailsErrorMessage(new StorageError(code, 'raw server text'), 'fallback')).toBe(
expected
);
});

it('uses the fallback for errors that are not StorageErrors', () => {
expect(getDetailsErrorMessage(new TypeError('Failed to fetch'), 'fallback')).toBe('fallback');
});
});
15 changes: 15 additions & 0 deletions src/lib/storage/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,18 @@ export function getActionErrorMessage(err: StorageError): string {
export function getActionErrorMessageForCode(code: string): string {
return getActionErrorMessage(new StorageError(code, ''));
}

/** Translated message for a failed details request; `fallback` covers all other failures. */
export function getDetailsErrorMessage(err: unknown, fallback: string): string {
if (!(err instanceof StorageError)) return fallback;
switch (err.code) {
case 'not_connected':
return m.storage_details_error_not_connected();
case 'access_denied':
return m.storage_details_error_access_denied();
case 'not_found':
return m.storage_details_error_not_found();
default:
return fallback;
}
}
24 changes: 14 additions & 10 deletions src/lib/storage/storage-fetch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,6 @@ beforeEach(() => {
});

describe('mapStatusToCode', () => {
it('maps 401 → not_connected', () => {
expect(mapStatusToCode(401)).toBe('not_connected');
});

it('maps 403 → access_denied', () => {
expect(mapStatusToCode(403)).toBe('access_denied');
});
Expand Down Expand Up @@ -150,14 +146,22 @@ describe('createStorageFetch', () => {
});
});

it('throws StorageError on 401 with not_connected code', async () => {
it.each([
[
'a 409 with reason not-connected',
409,
{ message: 'x', reason: 'not-connected' },
'not_connected'
],
['an edit conflict', 409, { message: 'Changed on the server' }, 'conflict'],
['an expired session', 401, { message: 'Authentication required' }, 'unknown']
])('maps %s', async (_label, status, body, code) => {
const storageFetch = createStorageFetch(() => 'conn-1');
vi.spyOn(globalThis, 'fetch').mockResolvedValue(makeResponse({ status: 401 }));
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
makeResponse({ status, body: JSON.stringify(body) })
);

await expect(storageFetch('/api/test')).rejects.toThrow(StorageError);
await expect(storageFetch('/api/test')).rejects.toMatchObject({
code: 'not_connected'
});
await expect(storageFetch('/api/test')).rejects.toMatchObject({ code });
});

it('throws StorageError on 400 with unknown code', async () => {
Expand Down
Loading
Loading