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
16 changes: 9 additions & 7 deletions docs/technical_settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand Down
90 changes: 90 additions & 0 deletions src/settings/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
Expand Down Expand Up @@ -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#" },
Expand Down Expand Up @@ -524,13 +549,15 @@ test("providerOptions: production excludes the custom provider", () => {
assert.deepStrictEqual(providerOptions(false), {
r2: "Cloudflare R2",
s3: "Amazon S3",
minio: "MinIO",
});
});

test("providerOptions: local development includes the custom provider", () => {
assert.deepStrictEqual(providerOptions(true), {
r2: "Cloudflare R2",
s3: "Amazon S3",
minio: "MinIO",
custom: "Custom",
});
});
Expand Down Expand Up @@ -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" },
Expand Down
18 changes: 12 additions & 6 deletions src/settings/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`;
Expand All @@ -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) !== "";
}

Expand Down Expand Up @@ -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<string, string> {
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";
Expand Down
75 changes: 52 additions & 23 deletions src/settings/tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/storage/storage.itest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
50 changes: 50 additions & 0 deletions src/storage/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions src/storage/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ function conditionHeaders(condition: PutCondition | undefined): Record<string, s

// missingFieldFor returns the name of the first field testConnection needs but doesn't have, or
// "" when everything required is present; R2 derives endpoint and region from the account ID and
// Amazon S3 derives its endpoint from the region, so only custom needs both explicitly.
// Amazon S3 from the region, so only MinIO and a custom provider need both explicitly.
function missingFieldFor(settings: GeodeSettings, secretAccessKey: string): string {
if (bucketFor(settings) === "") {
return "bucket";
Expand All @@ -300,7 +300,7 @@ function missingFieldFor(settings: GeodeSettings, secretAccessKey: string): stri
return "";
}

if (settings.provider === "custom") {
if (settings.provider === "custom" || settings.provider === "minio") {
if (normalizeEndpoint(settings.endpoint) === "") {
return "endpoint";
}
Expand Down
Loading