diff --git a/src/lib/utils/s3-region.ts b/src/lib/utils/s3-region.ts new file mode 100644 index 0000000..e8db02a --- /dev/null +++ b/src/lib/utils/s3-region.ts @@ -0,0 +1,42 @@ +/** + * S3 region helpers for the backup destination form. Dependency-free so they can + * be unit-tested without the Svelte component. + * + * An AWS bucket lives in one region; the generic s3.amazonaws.com endpoint doesn't + * know which and returns 301. Selecting a region builds the regional endpoint AND + * passes AWS_DEFAULT_REGION, either of which lets restic reach the bucket. + */ + +/** Sentinel value the region picker uses for "not an AWS region" (MinIO/Wasabi/etc). */ +export const CUSTOM_REGION = '__custom__'; + +/** AWS regions offered by the S3 region picker. Selecting one auto-fills the regional + * endpoint; "Custom" leaves the endpoint editable for MinIO / Wasabi / other providers. */ +export const AWS_REGIONS = [ + 'us-east-1', 'us-east-2', 'us-west-1', 'us-west-2', + 'eu-north-1', 'eu-west-1', 'eu-west-2', 'eu-west-3', 'eu-central-1', 'eu-central-2', 'eu-south-1', 'eu-south-2', + 'ca-central-1', 'sa-east-1', + 'ap-south-1', 'ap-southeast-1', 'ap-southeast-2', 'ap-southeast-3', 'ap-northeast-1', 'ap-northeast-2', 'ap-northeast-3', 'ap-east-1', + 'me-south-1', 'me-central-1', 'af-south-1' +]; + +/** The regional S3 endpoint host for an AWS region, e.g. s3.eu-north-1.amazonaws.com. */ +export function regionalEndpoint(region: string): string { + return `s3.${region}.amazonaws.com`; +} + +/** + * Recover the AWS region from an endpoint host so the picker can show it on edit. + * Matches s3..amazonaws.com and s3-.amazonaws.com (older form). + * Returns '' for the generic endpoint or any non-AWS host (MinIO, Wasabi, ...). + */ +export function extractS3Region(host: string): string { + const m = host.match(/^s3[.-]([a-z0-9-]+)\.amazonaws\.com$/i); + // s3.amazonaws.com itself has no region segment (the capture would be undefined). + return m && m[1] ? m[1] : ''; +} + +/** True when a picker value is a real region (not the custom sentinel or empty). */ +export function isConcreteRegion(region: string | undefined | null): boolean { + return !!region && region !== CUSTOM_REGION; +} diff --git a/src/routes/api/backup/destinations/+server.ts b/src/routes/api/backup/destinations/+server.ts index e3381b5..b3a0e37 100644 --- a/src/routes/api/backup/destinations/+server.ts +++ b/src/routes/api/backup/destinations/+server.ts @@ -1,6 +1,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { authorize } from '$lib/server/authorize'; +import { requireBackups } from '$lib/server/backups/route-guards'; import { auditBackupDestination } from '$lib/server/audit'; import { getBackupDestinations, @@ -11,7 +12,7 @@ import { } from '$lib/server/db'; import { initRepository, testRepository } from '$lib/server/backups'; import { registerSchedule } from '$lib/server/scheduler'; -import { validateRepositoryForSave, validateFlags, validatePolicySchedules } from '$lib/server/backups/helpers'; +import { validateRepositoryForSave, validateAndSerializeFlags, validatePolicySchedules } from '$lib/server/backups/helpers'; /** * Prepare destination for API response — strip password, parse env vars. @@ -37,9 +38,8 @@ function prepareDestination(dest: any, opts: { includeEnvVars: boolean }): any { export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); - if (auth.authEnabled && !await auth.can('backups', 'view')) { - return json({ error: 'Permission denied' }, { status: 403 }); - } + const denied = await requireBackups(auth, 'view'); + if (denied) return denied; const destinations = await getBackupDestinations(); // LIST: strip envVars (cloud creds). Modal re-fetches single destination to edit. @@ -49,9 +49,8 @@ export const GET: RequestHandler = async ({ cookies }) => { export const POST: RequestHandler = async (event) => { const { request, cookies } = event; const auth = await authorize(cookies); - if (auth.authEnabled && !await auth.can('backups', 'manage')) { - return json({ error: 'Permission denied' }, { status: 403 }); - } + const denied = await requireBackups(auth, 'manage'); + if (denied) return denied; const body = await request.json(); @@ -63,8 +62,16 @@ export const POST: RequestHandler = async (event) => { // BEFORE persisting, so nothing is saved on bad input. const repoError = validateRepositoryForSave(body.repository); if (repoError) return json({ error: repoError }, { status: 400 }); - const flagError = validateFlags(body.flags); - if (flagError) return json({ error: flagError }, { status: 400 }); + // Flags: prefer the split shape (backupFlags/restoreFlags); fall back to a legacy `flags` + // string (treated as backup flags). Validate+serialize to the JSON stored in `flags`. + let flagsColumn: string | null = null; + try { + flagsColumn = (body.backupFlags !== undefined || body.restoreFlags !== undefined) + ? validateAndSerializeFlags(body.backupFlags, body.restoreFlags) + : validateAndSerializeFlags(body.flags, ''); + } catch (e) { + return json({ error: e instanceof Error ? e.message : 'Invalid restic flags' }, { status: 400 }); + } // Validate any cron schedules in the supplied policies (audit #7) const policyCronError = validatePolicySchedules(body.policies); @@ -85,7 +92,7 @@ export const POST: RequestHandler = async (event) => { repository: body.repository, password: body.password, envVars: body.envVars ? JSON.stringify(body.envVars) : null, - flags: body.flags ?? null, + flags: flagsColumn, hostPath: body.hostPath ?? null, policies: body.policies ?? defaultPolicies }); diff --git a/src/routes/api/backup/destinations/[id]/+server.ts b/src/routes/api/backup/destinations/[id]/+server.ts index 3ed9d84..977b5f2 100644 --- a/src/routes/api/backup/destinations/[id]/+server.ts +++ b/src/routes/api/backup/destinations/[id]/+server.ts @@ -1,6 +1,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { authorize } from '$lib/server/authorize'; +import { requireBackups } from '$lib/server/backups/route-guards'; import { auditBackupDestination } from '$lib/server/audit'; import { getBackupDestination, @@ -10,7 +11,7 @@ import { getBackupConfigs } from '$lib/server/db'; import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler'; -import { validatePolicySchedules, validateRepositoryForSave, validateFlags } from '$lib/server/backups/helpers'; +import { validatePolicySchedules, validateRepositoryForSave, validateAndSerializeFlags, parseBackupFlags } from '$lib/server/backups/helpers'; import { destinationHasRunningBackup } from '$lib/server/backups'; /** @@ -28,14 +29,18 @@ function prepareDestination(dest: any, includeSecrets: boolean): any { } else { delete result.envVars; } + // Split the stored `flags` JSON into separate fields the edit form binds to (legacy + // bare strings surface as backupFlags), so the UI never parses the string-vs-JSON column. + const { backup, restore } = parseBackupFlags(dest.flags); + result.backupFlags = backup; + result.restoreFlags = restore; return result; } export const GET: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); - if (auth.authEnabled && !await auth.can('backups', 'view')) { - return json({ error: 'Permission denied' }, { status: 403 }); - } + const denied = await requireBackups(auth, 'view'); + if (denied) return denied; const id = parseInt(params.id); if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 }); @@ -52,9 +57,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => { export const PUT: RequestHandler = async (event) => { const { params, request, cookies } = event; const auth = await authorize(cookies); - if (auth.authEnabled && !await auth.can('backups', 'manage')) { - return json({ error: 'Permission denied' }, { status: 403 }); - } + const denied = await requireBackups(auth, 'manage'); + if (denied) return denied; const id = parseInt(params.id); if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 }); @@ -82,8 +86,16 @@ export const PUT: RequestHandler = async (event) => { const repoError = validateRepositoryForSave(body.repository); if (repoError) return json({ error: repoError }, { status: 400 }); } - const flagError = validateFlags(body.flags); - if (flagError) return json({ error: flagError }, { status: 400 }); + // Flags: prefer the split shape; fall back to a legacy `flags` string. undefined for ALL + // three means "don't touch flags". Validate+serialize to the JSON stored in `flags`. + let flagsColumn: string | null | undefined = undefined; + if (body.backupFlags !== undefined || body.restoreFlags !== undefined) { + try { flagsColumn = validateAndSerializeFlags(body.backupFlags, body.restoreFlags); } + catch (e) { return json({ error: e instanceof Error ? e.message : 'Invalid restic flags' }, { status: 400 }); } + } else if (body.flags !== undefined) { + try { flagsColumn = validateAndSerializeFlags(body.flags, ''); } + catch (e) { return json({ error: e instanceof Error ? e.message : 'Invalid restic flags' }, { status: 400 }); } + } // A local-path repo is allowed on any env; a wrong-host mount fails loud via // the helper's localRepoGuard at backup/restore time. @@ -94,7 +106,7 @@ export const PUT: RequestHandler = async (event) => { repository: body.repository, password: body.password, envVars: body.envVars !== undefined ? JSON.stringify(body.envVars) : undefined, - flags: body.flags, + flags: flagsColumn, hostPath: body.hostPath, policies: body.policies }); @@ -131,9 +143,8 @@ export const PUT: RequestHandler = async (event) => { export const DELETE: RequestHandler = async (event) => { const { params, cookies } = event; const auth = await authorize(cookies); - if (auth.authEnabled && !await auth.can('backups', 'manage')) { - return json({ error: 'Permission denied' }, { status: 403 }); - } + const denied = await requireBackups(auth, 'manage'); + if (denied) return denied; const id = parseInt(params.id); if (isNaN(id)) return json({ error: 'Invalid ID' }, { status: 400 }); diff --git a/src/routes/settings/backups/DestinationModal.svelte b/src/routes/settings/backups/DestinationModal.svelte index f227fe9..8dac1c7 100644 --- a/src/routes/settings/backups/DestinationModal.svelte +++ b/src/routes/settings/backups/DestinationModal.svelte @@ -6,13 +6,16 @@ import { TogglePill } from '$lib/components/ui/toggle-pill'; import * as Tooltip from '$lib/components/ui/tooltip'; import { Input } from '$lib/components/ui/input'; - import { Plus, Check, RefreshCw, Wifi, Database, HardDrive, Dices, Copy, BarChart3, Loader2, Clock, PackageCheck, FolderCheck, Unlock, CircleHelp, AlertTriangle } from 'lucide-svelte'; + import { Textarea } from '$lib/components/ui/textarea'; + import { Plus, Check, RefreshCw, Wifi, Database, HardDrive, Dices, Copy, BarChart3, Loader2, Clock, PackageCheck, FolderCheck, Unlock, CircleHelp, AlertTriangle, Upload } from 'lucide-svelte'; + import { AWS_REGIONS, regionalEndpoint, extractS3Region } from '$lib/utils/s3-region'; import cronstrue from 'cronstrue'; import { formatBytes } from '$lib/utils/format'; import CronEditor from '$lib/components/cron-editor.svelte'; import { copyToClipboard } from '$lib/utils/clipboard'; import * as Popover from '$lib/components/ui/popover'; import { AmazonS3Icon, BackblazeIcon, AzureBlobIcon, GoogleCloudIcon, RestServerIcon } from '$lib/components/cloud-icons'; + import { FieldLabel } from '$lib/components/ui/field-label'; import { toast } from 'svelte-sonner'; import { focusFirstInput } from '$lib/utils'; @@ -23,6 +26,8 @@ hostPath?: string | null; envVars?: Record; flags?: string; + backupFlags?: string; // split from the GET endpoint (legacy strings surface here) + restoreFlags?: string; policies?: string | null; lastTestStatus?: string | null; createdAt: string; @@ -70,6 +75,9 @@ placeholder: string; secret?: boolean; envKey?: string; // if set, stored as env var instead of part of repo URL + optional?: boolean; // no red asterisk; not required for Test/Create + multiline?: boolean; // render a textarea (e.g. a pasted JSON credential) + hint?: string; // small helper text under the field } interface BackendType { @@ -85,7 +93,7 @@ { value: 'local', label: 'Local path', icon: HardDrive, fields: [ - { key: 'path', label: 'Path', placeholder: '/mnt/backups/dockhand' } + { key: 'path', label: 'Path', placeholder: '/mnt/backups/dockhand', optional: true } ], buildRepo: (f) => f.path || '', parseRepo: (repo) => ({ path: repo }) @@ -93,9 +101,10 @@ { value: 's3', label: 'Amazon S3', icon: AmazonS3Icon, fields: [ + { key: 'region', label: 'AWS region', placeholder: '', optional: true }, { key: 'endpoint', label: 'Endpoint', placeholder: 's3.amazonaws.com (or http://minio:9000)' }, { key: 'bucket', label: 'Bucket', placeholder: 'my-backup-bucket' }, - { key: 'path', label: 'Path (optional)', placeholder: 'dockhand' }, + { key: 'path', label: 'Path', placeholder: 'dockhand', optional: true }, { key: 'accessKey', label: 'Access key ID', placeholder: '', envKey: 'AWS_ACCESS_KEY_ID' }, { key: 'secretKey', label: 'Secret access key', placeholder: '', secret: true, envKey: 'AWS_SECRET_ACCESS_KEY' } ], @@ -113,9 +122,12 @@ const bucket = parts[0] || ''; const path = parts.slice(1).join('/'); const endpoint = after.startsWith('http') ? `${url.protocol}//${url.host}` : url.host; - return { endpoint, bucket, path }; + // Recover the region from an AWS regional endpoint so the picker shows it + // on edit. Non-AWS hosts leave it blank (treated as custom). + const region = extractS3Region(url.host); + return { endpoint, bucket, path, region }; } catch { - return { endpoint: '', bucket: '', path: '' }; + return { endpoint: '', bucket: '', path: '', region: '' }; } } }, @@ -123,7 +135,7 @@ value: 'b2', label: 'Backblaze B2', icon: BackblazeIcon, fields: [ { key: 'bucket', label: 'Bucket', placeholder: 'my-backup-bucket' }, - { key: 'path', label: 'Path (optional)', placeholder: 'dockhand' }, + { key: 'path', label: 'Path', placeholder: 'dockhand', optional: true }, { key: 'accountId', label: 'Key ID', placeholder: '', envKey: 'B2_ACCOUNT_ID' }, { key: 'accountKey', label: 'Application key', placeholder: '', secret: true, envKey: 'B2_ACCOUNT_KEY' } ], @@ -138,11 +150,13 @@ value: 'azure', label: 'Azure Blob', icon: AzureBlobIcon, fields: [ { key: 'container', label: 'Container', placeholder: 'my-backup-container' }, - { key: 'path', label: 'Path (optional)', placeholder: 'dockhand' }, + { key: 'path', label: 'Path', placeholder: 'dockhand', optional: true }, { key: 'accountName', label: 'Account name', placeholder: '', envKey: 'AZURE_ACCOUNT_NAME' }, { key: 'accountKey', label: 'Account key', placeholder: '', secret: true, envKey: 'AZURE_ACCOUNT_KEY' } ], - buildRepo: (f) => `azure:${f.container}:${f.path || ''}`.replace(/:$/, ''), + // Keep the trailing colon: restic's azure parser needs `azure:container:` + // (it splits on ':' and rejects a repo with no second colon). B2/GS differ. + buildRepo: (f) => `azure:${f.container}:${f.path || ''}`, parseRepo: (repo) => { const after = repo.replace(/^azure:/, ''); const [container, ...rest] = after.split(':'); @@ -153,8 +167,9 @@ value: 'gs', label: 'Google Cloud', icon: GoogleCloudIcon, fields: [ { key: 'bucket', label: 'Bucket', placeholder: 'my-backup-bucket' }, - { key: 'path', label: 'Path (optional)', placeholder: '/dockhand' }, - { key: 'accessToken', label: 'Access token', placeholder: '', secret: true, envKey: 'GOOGLE_ACCESS_TOKEN' } + { key: 'path', label: 'Path', placeholder: '/dockhand', optional: true }, + { key: 'projectId', label: 'Project ID', placeholder: 'my-gcp-project', envKey: 'GOOGLE_PROJECT_ID' }, + { key: 'saJson', label: 'Service account JSON', placeholder: '{ "type": "service_account", ... }', secret: true, multiline: true, envKey: 'GOOGLE_APPLICATION_CREDENTIALS_JSON', hint: 'Paste or upload a service-account key JSON. It auto-refreshes, so scheduled backups keep working.' } ], buildRepo: (f) => `gs:${f.bucket}:${f.path || '/'}`, parseRepo: (repo) => { @@ -176,8 +191,25 @@ let formName = $state(''); let formBackendType = $state('local'); let formFields = $state>({}); + // Hidden file-input refs per multiline field, for the "Upload file" button. + let fileInputs = $state>({}); + + // Read a selected file into the field (e.g. a service-account JSON). Kept lenient: + // we accept any text; restic validates the JSON when the destination is tested. + async function uploadJsonToField(key: string, e: Event) { + const input = e.target as HTMLInputElement; + const file = input.files?.[0]; + if (!file) return; + try { + formFields[key] = await file.text(); + } catch { + toast.error('Could not read the selected file'); + } + input.value = ''; // let the same file be re-picked + } let formPassword = $state(''); - let formFlags = $state(''); + let formBackupFlags = $state(''); + let formRestoreFlags = $state(''); let formError = $state(''); let formSaving = $state(false); @@ -198,11 +230,48 @@ // can save + init in one click. Reset whenever the repo fields change (a stale // verdict must not drive the button after the user edits the target). let needsInit = $state(false); + // Inline verdict shown next to the Test button after a test. `needs_init` is + // NOT an error (connectivity works, the repo just isn't initialised yet), so it + // reads green, not red. Cleared when the repo fields change (stale verdict). + let testStatus = $state(null); + let testStatusMsg = $state(''); const selectedBackend = $derived(backendTypes.find(b => b.value === formBackendType) ?? backendTypes[0]); const repoFields = $derived(selectedBackend.fields.filter(f => !f.envKey)); const credentialFields = $derived(selectedBackend.fields.filter(f => f.envKey)); + // A field is mandatory unless it opts out (optional) or is a toggle (skipHostKey). + function fieldRequired(f: FormField): boolean { + return !f.optional && f.key !== 'skipHostKey'; + } + // All mandatory fields present? Drives the disabled state of Test/Create. On edit + // the credentials + password are already stored (secrets show blank = "keep + // current"), so credential fields aren't required there - only repo fields are. + const requiredFieldsFilled = $derived( + selectedBackend.fields + .filter(f => fieldRequired(f) && !(isEditing && f.envKey)) + .every(f => (formFields[f.key] ?? '').trim().length > 0) + ); + const formValid = $derived( + formName.trim().length > 0 + && requiredFieldsFilled + && (isEditing || formPassword.trim().length > 0) + ); + + // Collect the destination's env vars from the fields that declare an envKey, plus + // the S3 region (AWS_DEFAULT_REGION) when set. Passing the region lets restic reach + // a bucket even against the generic s3.amazonaws.com endpoint, and is required by + // some S3 providers regardless of endpoint. + function collectEnvVars(): Record { + const envVars: Record = {}; + for (const field of selectedBackend.fields) { + if (field.envKey && formFields[field.key]?.trim()) envVars[field.envKey] = formFields[field.key]; + } + const region = formFields.region?.trim(); + if (region) envVars['AWS_DEFAULT_REGION'] = region; + return envVars; + } + function detectBackendType(repo: string): string { for (const prefix of ['s3:', 'b2:', 'azure:', 'gs:', 'rest:']) { if (repo.startsWith(prefix)) return prefix.slice(0, -1); @@ -212,7 +281,7 @@ function resetForm() { formName = ''; formBackendType = 'local'; formFields = {}; formPassword = ''; - formFlags = ''; formError = ''; + formBackupFlags = ''; formRestoreFlags = ''; formError = ''; formSaving = false; policyPruneEnabled = true; policyPruneSchedule = '0 0 1 * *'; policyPruneMaxUnused = '10'; policyCheckEnabled = true; policyCheckSchedule = '0 0 1 * *'; @@ -234,10 +303,19 @@ const field = backend?.fields.find(f => f.envKey === key); if (field) fields[field.key] = value as string; } + // The S3 region is stored as AWS_DEFAULT_REGION (no envKey field), so + // map it back explicitly. It wins over any region recovered from the + // endpoint by parseRepo — the saved env var is authoritative. + if (destination.envVars['AWS_DEFAULT_REGION']) { + fields.region = destination.envVars['AWS_DEFAULT_REGION']; + } } formFields = fields; formPassword = ''; - formFlags = destination.flags || ''; + // The GET endpoint splits the stored flags JSON into backupFlags/restoreFlags + // (legacy bare strings surface as backupFlags), so the form binds them directly. + formBackupFlags = destination.backupFlags ?? ''; + formRestoreFlags = destination.restoreFlags ?? ''; formError = ''; // Load policies const pol = destination.policies ? (() => { try { return JSON.parse(destination.policies); } catch { return {}; } })() : {}; @@ -296,13 +374,12 @@ // Use saved credentials from DB payload = { destinationId: destination!.id }; } else { + // Test is disabled until formValid (all mandatory fields incl. credentials), + // so empty creds can't reach restic here - which matters because without + // them restic falls back to ambient auth (Azure ManagedIdentity, AWS + // instance role) that hangs on a retry loop instead of failing fast. const repository = selectedBackend.buildRepo(formFields); - if (!repository.trim()) { toast.error('Fill in repository fields before testing'); testing = false; return; } - if (!formPassword.trim()) { toast.error('Enter encryption password before testing'); testing = false; return; } - const envVars: Record = {}; - for (const field of selectedBackend.fields) { - if (field.envKey && formFields[field.key]?.trim()) envVars[field.envKey] = formFields[field.key]; - } + const envVars = collectEnvVars(); payload = { repository, password: formPassword, envVars: Object.keys(envVars).length > 0 ? envVars : undefined }; } const res = await fetch('/api/backup/destinations/test', { @@ -311,16 +388,25 @@ body: JSON.stringify(payload) }); const data = await res.json(); - if (data.success && data.needsInit) { + // needsInit means restic REACHED the repo but it isn't initialised yet — + // connectivity + credentials are fine, so this is NOT an error. The backend + // returns success:false here, so check needsInit on its own. + if (data.needsInit) { // Only the create flow gets the "Save and init" button; editing an // existing destination already has its own "Init repo" action. needsInit = !isEditing; - toast.info(data.message || 'Connection works, but repository needs initialization'); + testStatus = 'needs_init'; + testStatusMsg = 'Connectivity OK, repository needs init'; + } else if (res.ok && data.success) { + needsInit = false; + testStatus = 'ok'; + testStatusMsg = 'Connection successful'; } else { needsInit = false; - toast[res.ok && data.success ? 'success' : 'error'](data.success ? 'Connection test successful' : (data.error || 'Connection test failed')); + testStatus = 'error'; + testStatusMsg = data.error || 'Connection test failed'; } - } catch { toast.error('Connection test failed'); } finally { testing = false; } + } catch { testStatus = 'error'; testStatusMsg = 'Connection test failed'; } finally { testing = false; } } async function initRepo() { @@ -358,6 +444,7 @@ $effect(() => { currentRepo; formFields; formPassword; // track needsInit = false; + testStatus = null; }); async function save() { @@ -379,12 +466,7 @@ formSaving = true; formError = ''; try { - const envVars: Record = {}; - for (const field of selectedBackend.fields) { - if (field.envKey && formFields[field.key]?.trim()) { - envVars[field.envKey] = formFields[field.key]; - } - } + const envVars = collectEnvVars(); const policies = { pruneEnabled: policyPruneEnabled, pruneSchedule: policyPruneSchedule, @@ -399,7 +481,8 @@ const body: Record = { name: formName.trim(), repository, envVars: Object.keys(envVars).length > 0 ? envVars : undefined, - flags: formFlags.trim() || undefined, + backupFlags: formBackupFlags.trim(), + restoreFlags: formRestoreFlags.trim(), policies: JSON.stringify(policies) }; if (formPassword || !isEditing) body.password = formPassword || undefined; @@ -435,7 +518,7 @@
- +
@@ -462,15 +545,49 @@
{#each repoFields as field} -
- - { formFields[field.key] = (e.target as HTMLInputElement).value; }} - placeholder={field.placeholder} - /> -
+ {#if field.key === 'region'} +
+ +
+ + { formFields.region = (e.target as HTMLInputElement).value; }} + placeholder="e.g. eu-north-1" + class="flex-1" + /> + + { + if (!v) return; + formFields.region = v; + formFields.endpoint = regionalEndpoint(v); + }}> + AWS regions + + {#each AWS_REGIONS as r} + {r} + {/each} + + +
+

+ Passed to restic as AWS_DEFAULT_REGION. +

+
+ {:else} +
+ + { formFields[field.key] = (e.target as HTMLInputElement).value; }} + placeholder={field.placeholder} + /> +
+ {/if} {/each} @@ -484,20 +601,45 @@ {:else}
- - { formFields[field.key] = (e.target as HTMLInputElement).value; }} - placeholder={isEditing && field.secret ? '(leave blank to keep current)' : field.placeholder} - /> + {#if field.multiline} +
+ + +
+ uploadJsonToField(field.key, e)} + /> +