From 4990e0d59c69c6713fdb8b01277ef2afbb12e34b Mon Sep 17 00:00:00 2001 From: Johannes Fleck Date: Thu, 22 Oct 2026 14:40:41 +0200 Subject: [PATCH 1/3] fix(storage): apply superforms fixes to the storage connection forms The storage connect and connection edit forms came in with the S3 file browser merge and missed the fixes from #346. - Show network and server failures in the form instead of the error page (onError, caught save/session/update errors) - Translate schema validation messages; an untouched host now reports "Host is required" instead of zod's default - Treat a cleared port field (null) as the default port - Return translated errors from the use/delete actions and show them - Replace the hand-rolled unsaved-changes guard on the edit page with superforms' taintedMessage - Always test the connection when editing, with the stored credentials if none are entered - Share the connection test error mapping and detect network errors by error code (AggregateError for localhost has an empty message) - Log through locals.logger in the edit action --- e2e/storage/connection.spec.ts | 12 + e2e/storage/connections-management.spec.ts | 52 ++ messages/de.json | 8 + messages/en.json | 8 + .../storage/landing/StorageConnectForm.svelte | 8 +- .../landing/StorageConnectForm.svelte.spec.ts | 14 +- .../storage/connection-test-error.test.ts | 66 +++ .../server/storage/connection-test-error.ts | 39 ++ src/lib/storage/schemas.spec.ts | 31 ++ src/lib/storage/schemas.ts | 50 +- .../settings/connections/+page.server.ts | 30 +- .../(app)/settings/connections/+page.svelte | 5 + .../connections/[id]/edit/+page.server.ts | 157 +++--- .../connections/[id]/edit/+page.svelte | 456 ++++++++---------- src/routes/(app)/storage/+page.server.ts | 116 ++--- src/routes/(app)/storage/page.server.test.ts | 50 +- 16 files changed, 682 insertions(+), 420 deletions(-) create mode 100644 src/lib/server/storage/connection-test-error.test.ts create mode 100644 src/lib/server/storage/connection-test-error.ts diff --git a/e2e/storage/connection.spec.ts b/e2e/storage/connection.spec.ts index 2ce6c8f7..d0130613 100644 --- a/e2e/storage/connection.spec.ts +++ b/e2e/storage/connection.spec.ts @@ -46,6 +46,18 @@ test.describe('Storage S3 — Connection', () => { await expect(page.getByText('Access denied — check your credentials.')).toBeVisible(); }); + test('a cleared port field means the default port, not a validation error', async ({ page }) => { + await openConnectForm(page); + const port = page.getByLabel('Port'); + await port.fill('9000'); + await port.fill(''); + await page.getByRole('button', { name: 'Connect', exact: true }).click(); + + // The empty host still fails, so validation has run. + await expect(page.getByText('Host is required')).toBeVisible(); + await expect(page.getByText(/Port must be|Too small/)).toHaveCount(0); + }); + test('disconnects from Garage S3', async ({ page }) => { const credentials = requireGarageCredentials(); diff --git a/e2e/storage/connections-management.spec.ts b/e2e/storage/connections-management.spec.ts index d39d5aa9..18853bab 100644 --- a/e2e/storage/connections-management.spec.ts +++ b/e2e/storage/connections-management.spec.ts @@ -3,6 +3,8 @@ import { hasGarageCredentials, requireGarageCredentials } from '../support/garag import { connectToStorage, openConnectForm } from './helpers.js'; import { waitForHydration } from '../support/helpers.js'; +const PORT_1_ERROR = 'Could not reach the storage backend — check the host and port.'; + /** Open the Options context menu for the first connection row and click Edit. */ async function openFirstConnectionEditPage(page: Page) { await page.locator('table tbody tr').first().waitFor(); @@ -149,6 +151,22 @@ test.describe('Storage — Connections management', () => { await expect(page.getByText('Access denied — check your credentials.')).toBeVisible(); }); + test('edit page tests the connection when only the endpoint changes', async ({ page }) => { + const credentials = requireGarageCredentials(); + await connectToStorage(page, credentials); + await page.goto('/settings/connections'); + await waitForHydration(page); + + await openFirstConnectionEditPage(page); + + // Credentials stay blank (keep the stored ones); nothing listens on port 1. + await page.getByLabel('Port').fill('1'); + await page.getByRole('button', { name: 'Save changes' }).click(); + + await expect(page.getByText(PORT_1_ERROR)).toBeVisible(); + await expect(page).toHaveURL(/\/edit$/); + }); + test('delete button on management page removes the connection after confirmation', async ({ page }) => { @@ -231,6 +249,40 @@ test.describe('Storage — Connections management', () => { await expect(page.getByRole('heading', { name: 'Edit connection' })).toBeVisible(); }); + test('edit page without changes leaves without asking', async ({ page }) => { + const credentials = requireGarageCredentials(); + await connectToStorage(page, credentials); + await page.goto('/settings/connections'); + await waitForHydration(page); + + await openFirstConnectionEditPage(page); + await page.getByRole('link', { name: '← Manage connections' }).click(); + + await expect(page).toHaveURL('/settings/connections'); + await expect(page.getByRole('dialog')).toHaveCount(0); + }); + + test('unsaved-changes modal: Escape stays and the guard asks again', async ({ page }) => { + const credentials = requireGarageCredentials(); + await connectToStorage(page, credentials); + await page.goto('/settings/connections'); + await waitForHydration(page); + + await openFirstConnectionEditPage(page); + + await page.getByLabel('Connection name').fill('Changed name'); + const backLink = page.getByRole('link', { name: '← Manage connections' }); + await backLink.click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog')).toBeHidden(); + await expect(page).toHaveURL(/\/edit$/); + + await backLink.click(); + await expect(page.getByRole('heading', { name: 'Unsaved changes' })).toBeVisible(); + }); + test('unsaved-changes modal: Leave navigates away', async ({ page }) => { const credentials = requireGarageCredentials(); await connectToStorage(page, credentials); diff --git a/messages/de.json b/messages/de.json index b7ce2634..d15bc568 100644 --- a/messages/de.json +++ b/messages/de.json @@ -166,6 +166,13 @@ "storage_connect_error_server_error": "Das Speicher-Backend hat einen Fehler gemeldet — Endpunkt und Zugangsdaten prüfen.", "storage_connect_error_network": "Speicher-Backend nicht erreichbar — Host und Port prüfen.", "storage_connect_error_hdfs": "HDFS-Verbindungen werden noch nicht unterstützt.", + "storage_connect_save_failed": "Die Verbindung konnte nicht gespeichert werden. Bitte erneut versuchen.", + "storage_connect_host_required": "Host ist erforderlich", + "storage_connect_host_invalid": "Host muss ein gültiger Hostname oder eine gültige IP-Adresse sein", + "storage_connect_port_invalid": "Port muss eine ganze Zahl zwischen 1 und 65535 sein", + "storage_connect_region_required": "Region ist erforderlich", + "storage_connect_access_key_required": "Zugriffsschlüssel ist erforderlich, wenn ein geheimer Schlüssel angegeben ist", + "storage_connect_secret_key_required": "Geheimer Schlüssel ist erforderlich, wenn ein Zugriffsschlüssel angegeben ist", "storage_connect_saved": "Gespeicherte Verbindungen", "storage_connect_no_saved": "Noch keine gespeicherten Verbindungen", "storage_connect_forget": "Löschen", @@ -202,6 +209,7 @@ "storage_connections_delete_confirm": "\"{label}\" aus den gespeicherten Verbindungen entfernen? Dies kann nicht rückgängig gemacht werden.", "storage_connections_delete_cancel": "Abbrechen", "storage_connections_delete_confirm_button": "Löschen", + "storage_connections_delete_failed": "Die Verbindung konnte nicht gelöscht werden. Bitte erneut versuchen.", "storage_connection_edit_title": "Verbindung bearbeiten", "storage_connection_edit_name": "Verbindungsname", "storage_connection_edit_name_placeholder": "z. B. Produktions-Garage", diff --git a/messages/en.json b/messages/en.json index d64250a1..7b677e13 100644 --- a/messages/en.json +++ b/messages/en.json @@ -166,6 +166,13 @@ "storage_connect_error_server_error": "The storage backend returned an error — check the endpoint and credentials.", "storage_connect_error_network": "Could not reach the storage backend — check the host and port.", "storage_connect_error_hdfs": "HDFS connections are not yet supported.", + "storage_connect_save_failed": "Could not save the connection. Please try again.", + "storage_connect_host_required": "Host is required", + "storage_connect_host_invalid": "Host must be a valid hostname or IP address", + "storage_connect_port_invalid": "Port must be a whole number between 1 and 65535", + "storage_connect_region_required": "Region is required", + "storage_connect_access_key_required": "Access key is required when a secret key is provided", + "storage_connect_secret_key_required": "Secret key is required when an access key is provided", "storage_connect_saved": "Saved connections", "storage_connect_no_saved": "No saved connections yet", "storage_connect_forget": "Delete", @@ -202,6 +209,7 @@ "storage_connections_delete_confirm": "Remove “{label}” from saved connections? This cannot be undone.", "storage_connections_delete_cancel": "Cancel", "storage_connections_delete_confirm_button": "Delete", + "storage_connections_delete_failed": "Could not delete the connection. Please try again.", "storage_connection_edit_title": "Edit connection", "storage_connection_edit_name": "Connection name", "storage_connection_edit_name_placeholder": "e.g. Production Garage", diff --git a/src/lib/components/storage/landing/StorageConnectForm.svelte b/src/lib/components/storage/landing/StorageConnectForm.svelte index c15ff6b3..1510eda5 100644 --- a/src/lib/components/storage/landing/StorageConnectForm.svelte +++ b/src/lib/components/storage/landing/StorageConnectForm.svelte @@ -27,9 +27,9 @@ { dataType: 'json', validators: zod(StorageConnectionSchema), - onResult: () => { - // Server saved the connection; the redirect to /storage triggers - // the layout load to auto-connect via activeStorageConnectionId. + // Network failures or unexpected server errors: show them in the form instead of the error page. + onError() { + $message = { type: 'error', message: m.storage_connect_save_failed() }; } } ); @@ -57,7 +57,7 @@ {#if connectError} {/if} diff --git a/src/lib/components/storage/landing/StorageConnectForm.svelte.spec.ts b/src/lib/components/storage/landing/StorageConnectForm.svelte.spec.ts index 95909388..4b998b9c 100644 --- a/src/lib/components/storage/landing/StorageConnectForm.svelte.spec.ts +++ b/src/lib/components/storage/landing/StorageConnectForm.svelte.spec.ts @@ -47,7 +47,13 @@ vi.mock('$lib/paraglide/messages.js', () => ({ `Forget connection to ${endpoint}`, storage_connect_forget_confirm: ({ endpoint }: { endpoint: string }) => `Remove ${endpoint} from saved connections?`, - storage_connect_error_unreachable: () => 'Could not connect', + storage_connect_save_failed: () => 'Could not save the connection', + storage_connect_host_required: () => 'Host is required', + storage_connect_host_invalid: () => 'Host is invalid', + storage_connect_port_invalid: () => 'Port is invalid', + storage_connect_region_required: () => 'Region is required', + storage_connect_access_key_required: () => 'Access key is required', + storage_connect_secret_key_required: () => 'Secret key is required', storage_connect_manage: () => 'Manage connections', storage_connect_edit: () => 'Edit', storage_more_options: () => 'More options', @@ -412,4 +418,10 @@ describe('StorageConnectForm', () => { await expect.element(page.getByText('Secret key required')).toBeInTheDocument(); await expect.element(page.getByText('Validation failed')).toBeInTheDocument(); }); + + it('should show the error returned by the use action', async () => { + renderForm({}, [], 'Connection not found.'); + + await expect.element(page.getByRole('alert')).toHaveTextContent('Connection not found.'); + }); }); diff --git a/src/lib/server/storage/connection-test-error.test.ts b/src/lib/server/storage/connection-test-error.test.ts new file mode 100644 index 00000000..6ab24ad2 --- /dev/null +++ b/src/lib/server/storage/connection-test-error.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; +import { error } from '@sveltejs/kit'; +import { connectionTestErrorMessage } from './connection-test-error.js'; + +function httpError(status: number): unknown { + try { + error(status, 'test'); + } catch (err) { + return err; + } +} + +describe('connectionTestErrorMessage', () => { + it('maps HTTP errors by status', () => { + expect(connectionTestErrorMessage(httpError(403))).toBe( + 'Access denied — check your credentials.' + ); + expect(connectionTestErrorMessage(httpError(404))).toBe( + 'Could not find the storage backend — check the endpoint.' + ); + expect(connectionTestErrorMessage(httpError(502))).toBe( + 'The storage backend returned an error — check the endpoint and credentials.' + ); + expect(connectionTestErrorMessage(httpError(500))).toBe( + 'Could not connect — check the endpoint and credentials.' + ); + }); + + it('maps network errors', () => { + expect(connectionTestErrorMessage(new Error('connect ECONNREFUSED 127.0.0.1:9000'))).toBe( + 'Could not reach the storage backend — check the host and port.' + ); + }); + + it('maps network errors by code when the message is empty', () => { + // Connecting to localhost tries IPv6 and IPv4; Node reports both in an AggregateError. + const refused = (address: string) => + Object.assign(new Error(`connect ECONNREFUSED ${address}`), { code: 'ECONNREFUSED' }); + const aggregate = Object.assign( + new AggregateError([refused('::1:1'), refused('127.0.0.1:1')], ''), + { code: 'ECONNREFUSED' } + ); + expect(connectionTestErrorMessage(aggregate)).toBe( + 'Could not reach the storage backend — check the host and port.' + ); + + const inner = new AggregateError([refused('::1:1')], ''); + expect(connectionTestErrorMessage(inner)).toBe( + 'Could not reach the storage backend — check the host and port.' + ); + + const notFound = Object.assign(new Error(''), { code: 'ENOTFOUND' }); + expect(connectionTestErrorMessage(notFound)).toBe( + 'Could not reach the storage backend — check the host and port.' + ); + }); + + it('falls back to the generic message', () => { + expect(connectionTestErrorMessage(new Error('boom'))).toBe( + 'Could not connect — check the endpoint and credentials.' + ); + expect(connectionTestErrorMessage('not an error')).toBe( + 'Could not connect — check the endpoint and credentials.' + ); + }); +}); diff --git a/src/lib/server/storage/connection-test-error.ts b/src/lib/server/storage/connection-test-error.ts new file mode 100644 index 00000000..ce7a3b0c --- /dev/null +++ b/src/lib/server/storage/connection-test-error.ts @@ -0,0 +1,39 @@ +import { isHttpError } from '@sveltejs/kit'; +import * as m from '$lib/paraglide/messages.js'; + +const NETWORK_ERROR_CODES = new Set([ + 'ECONNREFUSED', + 'ECONNRESET', + 'ENOTFOUND', + 'EAI_AGAIN', + 'ETIMEDOUT', + 'EHOSTUNREACH', + 'ENETUNREACH' +]); + +/** + * Whether a connection failure comes from the network rather than from S3. Checks the Node error + * code: connecting to `localhost` tries IPv6 and IPv4 and fails with an AggregateError whose + * message is empty, but whose code (and each inner error's code) is set. + */ +function isNetworkError(err: Error): boolean { + const errors = [err, ...(err instanceof AggregateError ? err.errors : [])]; + if (errors.some((e) => NETWORK_ERROR_CODES.has((e as { code?: unknown })?.code as string))) { + return true; + } + return /econnrefused|enotfound|eai_again|etimedout|network/.test(err.message.toLowerCase()); +} + +/** User-facing message for a failed connection test (listing the buckets of a new connection). */ +export function connectionTestErrorMessage(err: unknown): string { + if (isHttpError(err)) { + if (err.status === 403) return m.storage_connect_error_access_denied(); + if (err.status === 404) return m.storage_connect_error_not_found(); + if (err.status === 502) return m.storage_connect_error_server_error(); + return m.storage_connect_error(); + } + if (err instanceof Error && isNetworkError(err)) { + return m.storage_connect_error_network(); + } + return m.storage_connect_error(); +} diff --git a/src/lib/storage/schemas.spec.ts b/src/lib/storage/schemas.spec.ts index 5cd09265..5a493b34 100644 --- a/src/lib/storage/schemas.spec.ts +++ b/src/lib/storage/schemas.spec.ts @@ -87,6 +87,11 @@ describe('StorageConnectionSchema', () => { it('rejects port out of range', () => { const result = StorageConnectionSchema.safeParse({ host: 's3.example.com', port: 99999 }); expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe( + 'Port must be a whole number between 1 and 65535' + ); + } }); it('accepts empty string port as undefined', () => { @@ -97,6 +102,32 @@ describe('StorageConnectionSchema', () => { } }); + it('rejects a non-numeric port with the port message', () => { + const result = StorageConnectionSchema.safeParse({ host: 's3.example.com', port: 'abc' }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe( + 'Port must be a whole number between 1 and 65535' + ); + } + }); + + it('reports a missing host as required', () => { + const result = StorageConnectionSchema.safeParse({}); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe('Host is required'); + } + }); + + it('accepts a cleared number input (null) as undefined', () => { + const result = StorageConnectionSchema.safeParse({ host: 's3.example.com', port: null }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.port).toBeUndefined(); + } + }); + it('accepts tls verification', () => { const result = StorageConnectionSchema.safeParse({ host: 's3.example.com', diff --git a/src/lib/storage/schemas.ts b/src/lib/storage/schemas.ts index c57df1cd..3ecce3f8 100644 --- a/src/lib/storage/schemas.ts +++ b/src/lib/storage/schemas.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import * as m from '$lib/paraglide/messages.js'; import { MAX_TRANSFER_ITEMS } from './transfer-types.js'; function isValidHostname(host: string): boolean { @@ -45,33 +46,46 @@ export function hasEmptyPathSegment(value: string): boolean { /** S3 limits object keys to 1024 bytes of UTF-8. */ const MAX_OBJECT_KEY_BYTES = 1024; +const portError = { error: () => m.storage_connect_port_invalid() }; + const baseStorageConnectionObject = z.object({ id: z.string().uuid().optional(), name: z.string().optional(), type: z.enum(['s3', 'hdfs']).default('s3'), + // An untouched host field is undefined, so the type check needs the "required" message too. host: z - .string() + .string({ error: () => m.storage_connect_host_required() }) .trim() - .min(1, 'Host is required') - .refine((value) => { - if (!value.includes('://')) return true; - return z.url().safeParse(value).success; - }, 'Host must be a valid hostname or IP address') + .min(1, { error: () => m.storage_connect_host_required() }) + .refine( + (value) => { + if (!value.includes('://')) return true; + return z.url().safeParse(value).success; + }, + { error: () => m.storage_connect_host_invalid() } + ) .transform((v) => { if (v.includes('://')) return new URL(v).hostname; return isIpv6(v) ? `[${v}]` : v; }) - .refine(isValidHost, 'Host must be a valid hostname or IP address'), - port: z.coerce - .number() - .int() - .min(1) - .max(65535) - .optional() - .or(z.literal('').transform(() => undefined)), + .refine(isValidHost, { error: () => m.storage_connect_host_invalid() }), + // A cleared number input is bound as `null` and an empty form field arrives as `''`; both mean + // "default port". Without the explicit branches, coercion would turn them into 0. + port: z + .union( + [ + z.coerce.number(portError).int(portError).min(1, portError).max(65535, portError), + z.literal('').transform(() => undefined), + z.null().transform(() => undefined) + ], + portError + ) + .optional(), tls: z.object({ verification: z.enum(['Full', 'None']) }).optional(), accessStyle: z.enum(['Path', 'VirtualHosted']).default('VirtualHosted'), - region: z.object({ name: z.string().min(1) }).default({ name: 'us-east-1' }), + region: z + .object({ name: z.string().min(1, { error: () => m.storage_connect_region_required() }) }) + .default({ name: 'us-east-1' }), credentials: z .object({ accessKey: z.string(), @@ -87,14 +101,14 @@ export const StorageConnectionSchema = baseStorageConnectionObject.superRefine(( ctx.addIssue({ code: 'custom', path: ['credentials', 'secretKey'], - message: 'Secret key is required when access key is provided' + message: m.storage_connect_secret_key_required() }); } if (!hasKey && hasSecret) { ctx.addIssue({ code: 'custom', path: ['credentials', 'accessKey'], - message: 'Access key is required when secret key is provided' + message: m.storage_connect_access_key_required() }); } }); @@ -109,7 +123,7 @@ export const EditStorageConnectionSchema = baseStorageConnectionObject.superRefi ctx.addIssue({ code: 'custom', path: ['credentials', 'accessKey'], - message: 'Access key is required when secret key is provided' + message: m.storage_connect_access_key_required() }); } }); diff --git a/src/routes/(app)/settings/connections/+page.server.ts b/src/routes/(app)/settings/connections/+page.server.ts index 638de09f..868c1596 100644 --- a/src/routes/(app)/settings/connections/+page.server.ts +++ b/src/routes/(app)/settings/connections/+page.server.ts @@ -4,6 +4,7 @@ import { superValidate } from 'sveltekit-superforms'; import { zod4 as zod } from 'sveltekit-superforms/adapters'; import { desc, eq } from 'drizzle-orm'; import { ConnectionIdSchema, StoredStorageConnectionSchema } from '$lib/storage/schemas.js'; +import * as m from '$lib/paraglide/messages.js'; import { deleteConnection } from '$lib/server/storage/connections-db.js'; import { auth } from '$lib/server/auth.js'; import { db } from '$lib/server/db.js'; @@ -54,23 +55,32 @@ export const actions: Actions = { const form = await superValidate(request, zod(ConnectionIdSchema)); if (!form.valid) { - return fail(400, { error: 'Invalid connection ID' }); + log.debug({ errors: form.errors }, 'invalid connection id for delete'); + return fail(400, { error: m.storage_connection_edit_not_found() }); } const { connectionId } = form.data; const userId = locals.user!.id; const activeId = locals.session?.activeStorageConnectionId ?? null; - if (connectionId === activeId) { - await auth.api.updateSession({ - headers: request.headers, - body: { activeStorageConnectionId: null } - }); - log.info({ connectionId }, 'active storage connection deleted, session cleared'); - } + try { + if (connectionId === activeId) { + await auth.api.updateSession({ + headers: request.headers, + body: { activeStorageConnectionId: null } + }); + log.info( + { connection_id: connectionId }, + 'active storage connection deleted, session cleared' + ); + } - await deleteConnection(userId, connectionId); - log.info({ connectionId }, 'storage connection deleted from management page'); + await deleteConnection(userId, connectionId); + log.info({ connection_id: connectionId }, 'storage connection deleted from management page'); + } catch (err) { + log.error({ err, connection_id: connectionId }, 'failed to delete storage connection'); + return fail(500, { error: m.storage_connections_delete_failed() }); + } throw redirect(303, '/settings/connections'); } diff --git a/src/routes/(app)/settings/connections/+page.svelte b/src/routes/(app)/settings/connections/+page.svelte index 2845e9d9..533aec76 100644 --- a/src/routes/(app)/settings/connections/+page.svelte +++ b/src/routes/(app)/settings/connections/+page.svelte @@ -13,6 +13,7 @@ import type { SavedConnection } from '$lib/storage/connection-id-header.js'; const connections = $derived(page.data.connections ?? []); + const deleteError = $derived((page.form as { error?: string } | null)?.error ?? null); // Context menu state let menuConn: ConnectionListItem | null = $state(null); @@ -90,6 +91,10 @@

{m.storage_connections_title()}

{m.storage_connections_subtitle()}

+ {#if deleteError} + + {/if} + {#if connections.length === 0}

{m.storage_connections_empty()}

{:else} diff --git a/src/routes/(app)/settings/connections/[id]/edit/+page.server.ts b/src/routes/(app)/settings/connections/[id]/edit/+page.server.ts index ab79b71a..49af8b8e 100644 --- a/src/routes/(app)/settings/connections/[id]/edit/+page.server.ts +++ b/src/routes/(app)/settings/connections/[id]/edit/+page.server.ts @@ -1,4 +1,4 @@ -import { error, fail, redirect, isHttpError } from '@sveltejs/kit'; +import { error, fail, redirect } from '@sveltejs/kit'; import { superValidate, message, setError } from 'sveltekit-superforms'; import type { Infer, InferIn } from 'sveltekit-superforms'; import { zod4 as zod } from 'sveltekit-superforms/adapters'; @@ -11,20 +11,18 @@ import { } from '$lib/storage/schemas.js'; import type { z } from 'zod'; import { getConnectionProvider } from '$lib/server/storage/utils.js'; +import { connectionTestErrorMessage } from '$lib/server/storage/connection-test-error.js'; import type { S3ConnectionConfig } from '$lib/server/storage/types.js'; import { db } from '$lib/server/db.js'; import { userStorageConnections } from '$lib/server/schema.js'; import { decrypt, encrypt, fingerprint } from '$lib/server/storage/encryption.js'; import { isConnectionNameConflict } from '$lib/server/storage/connections-db.js'; import { storageEncryptionKey } from '$lib/server/storage/encryption-key.js'; -import { logger } from '$lib/server/logging'; import * as m from '$lib/paraglide/messages.js'; type EditData = Infer; type EditInput = InferIn; -const log = logger.child({ module: 'connection-edit' }); - export const load: PageServerLoad = async ({ locals, params }) => { const userId = locals.user!.id; @@ -80,6 +78,7 @@ export const load: PageServerLoad = async ({ locals, params }) => { export const actions: Actions = { update: async ({ request, locals, params }) => { + const log = locals.logger; const form = await superValidate( request, zod(EditStorageConnectionSchema) @@ -102,8 +101,52 @@ export const actions: Actions = { ); } - const resolvedCredentials = - credentials.accessKey && credentials.secretKey ? credentials : undefined; + let existing: z.infer; + try { + const rows = await db + .select({ encryptedPayload: userStorageConnections.encryptedPayload }) + .from(userStorageConnections) + .where( + and( + eq(userStorageConnections.id, connectionId), + eq(userStorageConnections.userId, userId) + ) + ) + .limit(1); + if (rows.length === 0) { + log.debug({ connection_id: connectionId }, 'storage connection to update not found'); + return message( + form, + { type: 'error', message: m.storage_connection_edit_not_found() }, + { status: 404 } + ); + } + existing = StoredStorageConnectionSchema.parse( + JSON.parse(decrypt(rows[0].encryptedPayload, storageEncryptionKey())) + ); + } catch (err) { + log.error( + { err, connection_id: connectionId }, + 'failed to load storage connection to update' + ); + return message( + form, + { type: 'error', message: m.storage_connect_save_failed() }, + { status: 500 } + ); + } + + // Blank credentials keep the stored ones; a new access key needs its secret key. + const newCredentials = credentials.accessKey && credentials.secretKey ? credentials : undefined; + if ( + !newCredentials && + credentials.accessKey && + credentials.accessKey !== existing.credentials?.accessKey + ) { + log.debug({ connection_id: connectionId }, 'access key changed without a secret key'); + return setError(form, 'credentials.secretKey', m.storage_connection_edit_secret_required()); + } + const resolvedCredentials = newCredentials ?? existing.credentials; const config: S3ConnectionConfig = { type: 's3', @@ -112,86 +155,29 @@ export const actions: Actions = { tls, accessStyle, region, - credentials: resolvedCredentials + credentials: resolvedCredentials?.accessKey ? resolvedCredentials : undefined }; - if (resolvedCredentials) { - try { - await getConnectionProvider(config).listContainers(); - log.info({ storage_type: type }, 'storage connection edit verified'); - } catch (err) { - log.warn({ err }, 'storage connection edit test failed'); - - let msg: string; - if (isHttpError(err)) { - if (err.status === 403) { - msg = m.storage_connect_error_access_denied(); - } else if (err.status === 404) { - msg = m.storage_connect_error_not_found(); - } else if (err.status === 502) { - msg = m.storage_connect_error_server_error(); - } else { - msg = m.storage_connect_error(); - } - } else if (err instanceof Error) { - const e = (err.message ?? '').toLowerCase(); - if (/econnrefused|enotfound|eai_again|etimedout|network/.test(e)) { - msg = m.storage_connect_error_network(); - } else { - msg = m.storage_connect_error(); - } - } else { - msg = m.storage_connect_error(); - } - - return message(form, { type: 'error', message: msg }, { status: 400 }); - } - } - - // If no new credentials were provided, preserve the existing encrypted ones. - let payload: z.infer; - if (resolvedCredentials) { - payload = { host, port, tls, accessStyle, region, credentials: resolvedCredentials }; - } else { - try { - const rows = await db - .select({ encryptedPayload: userStorageConnections.encryptedPayload }) - .from(userStorageConnections) - .where( - and( - eq(userStorageConnections.id, connectionId), - eq(userStorageConnections.userId, userId) - ) - ) - .limit(1); - if (rows.length === 0) { - return message( - form, - { type: 'error', message: m.storage_connect_error_not_found() }, - { status: 404 } - ); - } - const existing = StoredStorageConnectionSchema.parse( - JSON.parse(decrypt(rows[0].encryptedPayload, storageEncryptionKey())) - ); - if (credentials.accessKey && credentials.accessKey !== existing.credentials?.accessKey) { - log.debug({ connection_id: connectionId }, 'access key changed without a secret key'); - return setError( - form, - 'credentials.secretKey', - m.storage_connection_edit_secret_required() - ); - } - payload = { host, port, tls, accessStyle, region, credentials: existing.credentials }; - } catch { - return message( - form, - { type: 'error', message: m.storage_connect_error() }, - { status: 500 } - ); - } + try { + await getConnectionProvider(config).listContainers(); + log.info({ connection_id: connectionId }, 'storage connection edit verified'); + } catch (err) { + log.warn({ err, connection_id: connectionId }, 'storage connection edit test failed'); + return message( + form, + { type: 'error', message: connectionTestErrorMessage(err) }, + { status: 400 } + ); } + const payload: z.infer = { + host, + port, + tls, + accessStyle, + region, + credentials: resolvedCredentials + }; const encryptedPayload = encrypt(JSON.stringify(payload), storageEncryptionKey()); const hash = fingerprint( { @@ -223,7 +209,12 @@ export const actions: Actions = { log.debug({ connection_id: connectionId }, 'storage connection name already in use'); return setError(form, 'name', m.storage_connection_edit_name_taken()); } - throw err; + log.error({ err, connection_id: connectionId }, 'failed to update storage connection'); + return message( + form, + { type: 'error', message: m.storage_connect_save_failed() }, + { status: 500 } + ); } log.info({ connection_id: connectionId }, 'storage connection updated'); diff --git a/src/routes/(app)/settings/connections/[id]/edit/+page.svelte b/src/routes/(app)/settings/connections/[id]/edit/+page.svelte index dc82a773..e590335e 100644 --- a/src/routes/(app)/settings/connections/[id]/edit/+page.svelte +++ b/src/routes/(app)/settings/connections/[id]/edit/+page.svelte @@ -1,94 +1,62 @@ - -