From 4e29b352d6d1d83916f111206e4ec1cb27a71841 Mon Sep 17 00:00:00 2001 From: kwame-Owusu Date: Tue, 11 Aug 2026 13:02:47 +0100 Subject: [PATCH] feat: add MinIO as provider in settings and make it obvious --- docs/technical_settings.md | 16 ++++--- src/settings/settings.test.ts | 90 +++++++++++++++++++++++++++++++++++ src/settings/settings.ts | 18 ++++--- src/settings/tab.ts | 75 ++++++++++++++++++++--------- src/storage/storage.itest.ts | 2 +- src/storage/storage.test.ts | 50 +++++++++++++++++++ src/storage/storage.ts | 4 +- src/sync/sync.itest.ts | 2 +- 8 files changed, 217 insertions(+), 40 deletions(-) diff --git a/docs/technical_settings.md b/docs/technical_settings.md index 5e9738f..5baee0e 100644 --- a/docs/technical_settings.md +++ b/docs/technical_settings.md @@ -22,10 +22,10 @@ Settings persist to `data.json` in the plugin's own folder. | Field | Meaning | | ------------- | -------------------------------------------------------------- | -| `provider` | `r2`, `s3`, or `custom` | +| `provider` | `r2`, `s3`, `custom`, or `minio` | | `accountId` | Cloudflare account, which R2 derives endpoint and region from | -| `endpoint` | The S3 compatible endpoint, for a custom provider | -| `region` | The region, for Amazon S3 and a custom provider | +| `endpoint` | The S3 compatible endpoint, for a MinIO or custom provider | +| `region` | The region, for Amazon S3, a MinIO server, or a custom provider | | `bucket` | The bucket name | | `prefix` | The folder inside the bucket the vault lives under | | `accessKeyId` | The access key | @@ -39,17 +39,19 @@ to vault scoped localStorage instead, because settings travel to every device th ### Providers A provider is only a way of arriving at an endpoint and a signing region. Everything past that point -is the same S3 API for all three. +is the same S3 API for all four. | Provider | Endpoint | Signing region | | ------------------ | --------------------------------- | --------------------- | | `r2` Cloudflare R2 | Derived from `accountId` | Always `auto` | | `s3` Amazon S3 | Derived from `region` | The `region` as typed | +| `minio` MinIO | Typed in full | The `region` as typed | | `custom` | Typed in full | The `region` as typed | -Custom only appears in development builds, where esbuild defines `NODE_ENV`. It exists for the local -MinIO setup contributors run, and a production user has no reason to reach for a raw endpoint field -when R2 and Amazon S3 both derive theirs. +MinIO is a real provider for the self-hosted audience the project serves: pick it, type your server's +endpoint, and the region it signs with (usually `us-east-1`). Custom only appears in development +builds, where esbuild defines `NODE_ENV`. It exists as an escape hatch for any other S3 compatible +endpoint, while the named providers cover the setups worth naming. Amazon S3 puts the region straight into the endpoint host, so the region is the endpoint. A value carrying URL authority delimiters, `x@attacker.example:443#`, would otherwise send signed requests diff --git a/src/settings/settings.test.ts b/src/settings/settings.test.ts index fa3278f..ffa6ac9 100644 --- a/src/settings/settings.test.ts +++ b/src/settings/settings.test.ts @@ -222,6 +222,11 @@ const normalizeCases: { name: string; input: unknown; want: GeodeSettings }[] = input: { provider: "custom", endpoint: "https://s3.example.com" }, want: { ...DEFAULT_SETTINGS, provider: "custom", endpoint: "https://s3.example.com" }, }, + { + name: "provider minio preserved", + input: { provider: "minio", endpoint: "http://localhost:9000" }, + want: { ...DEFAULT_SETTINGS, provider: "minio", endpoint: "http://localhost:9000" }, + }, { name: "secretId missing defaults to empty string", input: {}, @@ -311,6 +316,26 @@ const endpointCases: { name: string; input: GeodeSettings; want: string }[] = [ input: { ...DEFAULT_SETTINGS, provider: "custom", endpoint: " https://s3.example.com " }, want: "https://s3.example.com", }, + { + name: "minio", + input: { ...DEFAULT_SETTINGS, provider: "minio", endpoint: "http://localhost:9000" }, + want: "http://localhost:9000", + }, + { + name: "minio with no scheme is prefixed with https", + input: { ...DEFAULT_SETTINGS, provider: "minio", endpoint: "minio.example.com" }, + want: "https://minio.example.com", + }, + { + name: "minio with a trailing slash is stripped", + input: { ...DEFAULT_SETTINGS, provider: "minio", endpoint: "https://minio.example.com/" }, + want: "https://minio.example.com", + }, + { + name: "minio with surrounding whitespace is trimmed", + input: { ...DEFAULT_SETTINGS, provider: "minio", endpoint: " https://minio.example.com " }, + want: "https://minio.example.com", + }, { name: "amazon s3 with a region carrying URL authority delimiters yields no endpoint", input: { ...DEFAULT_SETTINGS, provider: "s3", region: "x@attacker.example:443#" }, @@ -524,6 +549,7 @@ test("providerOptions: production excludes the custom provider", () => { assert.deepStrictEqual(providerOptions(false), { r2: "Cloudflare R2", s3: "Amazon S3", + minio: "MinIO", }); }); @@ -531,6 +557,7 @@ test("providerOptions: local development includes the custom provider", () => { assert.deepStrictEqual(providerOptions(true), { r2: "Cloudflare R2", s3: "Amazon S3", + minio: "MinIO", custom: "Custom", }); }); @@ -711,6 +738,69 @@ const hasConnectionConfigCases: { name: string; input: GeodeSettings; want: bool }, want: false, }, + { + name: "minio missing endpoint is incomplete", + input: { + ...DEFAULT_SETTINGS, + provider: "minio", + region: "us-east-1", + bucket: "b", + accessKeyId: "a", + secretId: "s", + }, + want: false, + }, + { + name: "minio missing region is incomplete", + input: { + ...DEFAULT_SETTINGS, + provider: "minio", + endpoint: "http://localhost:9000", + bucket: "b", + accessKeyId: "a", + secretId: "s", + }, + want: false, + }, + { + name: "minio with all fields is complete", + input: { + ...DEFAULT_SETTINGS, + provider: "minio", + endpoint: "http://localhost:9000", + region: "us-east-1", + bucket: "b", + accessKeyId: "a", + secretId: "s", + }, + want: true, + }, + { + name: "minio with a whitespace only endpoint is incomplete", + input: { + ...DEFAULT_SETTINGS, + provider: "minio", + endpoint: " ", + region: "us-east-1", + bucket: "b", + accessKeyId: "a", + secretId: "s", + }, + want: false, + }, + { + name: "minio with a whitespace only region is incomplete", + input: { + ...DEFAULT_SETTINGS, + provider: "minio", + endpoint: "http://localhost:9000", + region: " ", + bucket: "b", + accessKeyId: "a", + secretId: "s", + }, + want: false, + }, { name: "a whitespace only bucket is incomplete", input: { ...DEFAULT_SETTINGS, accountId: "a", bucket: " ", accessKeyId: "a", secretId: "s" }, diff --git a/src/settings/settings.ts b/src/settings/settings.ts index fbabece..167a494 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -15,7 +15,7 @@ export const DEFAULT_SETTINGS: GeodeSettings = { export type ConnectionStatus = "unknown" | "checking" | "ok" | "error"; // Provider identifies a supported S3 compatible storage configuration. -export type Provider = "r2" | "s3" | "custom"; +export type Provider = "r2" | "s3" | "custom" | "minio"; // GeodeSettings is the persisted shape of a Geode plugin's user configuration; see // docs/technical_settings.md for why each field is normalized where it is used rather than saved. @@ -109,7 +109,7 @@ export function normalizeEndpoint(endpoint: string): string { // endpointFor returns the storage endpoint URL for settings, or "" when none can be derived; an // Amazon S3 region lands in the URL authority, so an unrecognised one yields no endpoint rather -// than a host we never meant to sign a request against. +// than a host we never meant to sign against; MinIO and custom take their endpoint as typed. export function endpointFor(settings: GeodeSettings): string { if (settings.provider === "r2") { return `https://${accountIdFor(settings)}.r2.cloudflarestorage.com`; @@ -136,6 +136,7 @@ export function hasConnectionConfig(settings: GeodeSettings): boolean { if (settings.provider === "s3") { return isAwsRegion(regionFor(settings)); } + // MinIO and a custom provider both take their endpoint as typed, with a region for signing. return normalizeEndpoint(settings.endpoint) !== "" && regionFor(settings) !== ""; } @@ -221,22 +222,27 @@ export function prefixError(raw: string): string { // providerOptions returns user-facing providers, including Custom only for local development. export function providerOptions(localDev: boolean): Record { if (localDev) { - return { r2: "Cloudflare R2", s3: "Amazon S3", custom: "Custom" }; + return { + r2: "Cloudflare R2", + s3: "Amazon S3", + minio: "MinIO", + custom: "Custom", + }; } - return { r2: "Cloudflare R2", s3: "Amazon S3" }; + return { r2: "Cloudflare R2", s3: "Amazon S3", minio: "MinIO" }; } // providerOr returns a known provider, defaulting unknown values to "r2". export function providerOr(v: unknown): Provider { - if (v === "s3" || v === "custom") { + if (v === "s3" || v === "custom" || v === "minio") { return v; } return "r2"; } // regionFor returns the signing region for settings, trimmed at the point of use; R2 always signs -// with "auto", so only Amazon S3 and a custom provider need one specified. +// with "auto", so Amazon S3, MinIO, and a custom provider need one specified. export function regionFor(settings: GeodeSettings): string { if (settings.provider === "r2") { return "auto"; diff --git a/src/settings/tab.ts b/src/settings/tab.ts index 35a042d..8dd9130 100644 --- a/src/settings/tab.ts +++ b/src/settings/tab.ts @@ -159,6 +159,42 @@ function renderActions(tab: GeodeSettingTab, containerEl: HTMLElement): void { tab.refreshActionsUI(); } +// renderEndpointAndRegion draws the endpoint and region fields an S3 compatible provider whose +// endpoint is typed rather than derived needs, with copy tailored to that provider. +function renderEndpointAndRegion( + tab: GeodeSettingTab, + containerEl: HTMLElement, + endpointDesc: string, + endpointPlaceholder: string, + regionDesc: string, +): void { + new Setting(containerEl) + .setName("Endpoint") + .setDesc(endpointDesc) + .addText((text) => + text + .setPlaceholder(endpointPlaceholder) + .setValue(tab.draft.endpoint) + .onChange((value) => { + tab.draft.endpoint = value; + onFieldChanged(tab); + }), + ); + + new Setting(containerEl) + .setName("Region") + .setDesc(regionDesc) + .addText((text) => + text + .setPlaceholder("us-east-1") + .setValue(tab.draft.region) + .onChange((value) => { + tab.draft.region = value; + onFieldChanged(tab); + }), + ); +} + // renderHeader draws the plugin title, subtitle, and external link buttons. The title is a plain // div rather than an h1: Obsidian's settings pane suppresses nested h1 elements. function renderHeader(containerEl: HTMLElement): void { @@ -223,31 +259,24 @@ function renderProviderFields(tab: GeodeSettingTab, containerEl: HTMLElement): v return; } - new Setting(containerEl) - .setName("Endpoint") - .setDesc("The S3 compatible endpoint URL for your storage.") - .addText((text) => - text - .setPlaceholder("https://s3.example.com") - .setValue(tab.draft.endpoint) - .onChange((value) => { - tab.draft.endpoint = value; - onFieldChanged(tab); - }), + if (tab.draft.provider === "minio") { + renderEndpointAndRegion( + tab, + containerEl, + "Your MinIO server's S3 endpoint URL.", + "https://minio.example.com", + "The region your MinIO bucket lives in.", ); + return; + } - new Setting(containerEl) - .setName("Region") - .setDesc("The region your bucket lives in.") - .addText((text) => - text - .setPlaceholder("us-east-1") - .setValue(tab.draft.region) - .onChange((value) => { - tab.draft.region = value; - onFieldChanged(tab); - }), - ); + renderEndpointAndRegion( + tab, + containerEl, + "The S3 compatible endpoint URL for your storage.", + "https://s3.example.com", + "The region your bucket lives in.", + ); } // renderSecretRow draws the secret access key control; SecretComponent can't force a new entry diff --git a/src/storage/storage.itest.ts b/src/storage/storage.itest.ts index 8f858f3..59fee12 100644 --- a/src/storage/storage.itest.ts +++ b/src/storage/storage.itest.ts @@ -9,7 +9,7 @@ const SECRET_ACCESS_KEY = "geodedev"; const liveSettings: GeodeSettings = { ...DEFAULT_SETTINGS, - provider: "custom", + provider: "minio", endpoint: "http://localhost:4568", region: "us-east-1", bucket: "geode-test", diff --git a/src/storage/storage.test.ts b/src/storage/storage.test.ts index de9a208..a750134 100644 --- a/src/storage/storage.test.ts +++ b/src/storage/storage.test.ts @@ -168,6 +168,56 @@ const missingFieldCases: { secretAccessKey: "shh", want: "Fill in region first", }, + { + name: "missing endpoint for minio", + settings: { + ...DEFAULT_SETTINGS, + provider: "minio", + region: "us-east-1", + bucket: "my-vault", + accessKeyId: "AKIA123", + }, + secretAccessKey: "shh", + want: "Fill in endpoint first", + }, + { + name: "missing region for minio", + settings: { + ...DEFAULT_SETTINGS, + provider: "minio", + endpoint: "http://localhost:9000", + bucket: "my-vault", + accessKeyId: "AKIA123", + }, + secretAccessKey: "shh", + want: "Fill in region first", + }, + { + name: "whitespace only endpoint for minio", + settings: { + ...DEFAULT_SETTINGS, + provider: "minio", + endpoint: " ", + region: "us-east-1", + bucket: "my-vault", + accessKeyId: "AKIA123", + }, + secretAccessKey: "shh", + want: "Fill in endpoint first", + }, + { + name: "whitespace only region for minio", + settings: { + ...DEFAULT_SETTINGS, + provider: "minio", + endpoint: "http://localhost:9000", + region: " ", + bucket: "my-vault", + accessKeyId: "AKIA123", + }, + secretAccessKey: "shh", + want: "Fill in region first", + }, ]; for (const { name, settings, secretAccessKey, want } of missingFieldCases) { diff --git a/src/storage/storage.ts b/src/storage/storage.ts index d123c59..d6892d5 100644 --- a/src/storage/storage.ts +++ b/src/storage/storage.ts @@ -281,7 +281,7 @@ function conditionHeaders(condition: PutCondition | undefined): Record