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
12 changes: 6 additions & 6 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,30 +41,30 @@ The name of the S3 bucket to use
- Type: `String`
- Default: `"Minio"`

The AWS region where the bucket is located
The region used to sign requests. Generic S3: set the region of your bucket. Cloudflare R2: leave unset - R2 always uses "auto" (the defaults "Minio", "us-east-1" and an empty value are mapped to it, any other value is ignored with a warning).

## access-key-id

- Type: `String`
- Default: `"bluemap"`

The AWS access key ID for authentication
The AWS access key ID for authentication. Leave empty together with secret-access-key to use the default AWS credential chain (environment, profile, ECS task role, EC2 instance profile)

## secret-access-key

- Type: `String`
- Default: `"bluemap-secret"`
- Sensitive: excluded from BlueMap debug dumps (`@DebugDump(exclude = true)`)

The AWS secret access key for authentication
The AWS secret access key for authentication. Leave empty together with access-key-id to use the default AWS credential chain

## endpoint-url

- Type: `String`
- Default: `"http://localhost:9000"`
- Sensitive: excluded from BlueMap debug dumps (`@DebugDump(exclude = true)`)

Optional: The endpoint URL for S3-compatible services (leave empty for AWS S3)
The endpoint URL for S3-compatible services (leave empty for AWS S3). Cloudflare R2: leave unset and set account-id instead, or set the full R2 endpoint (e.g. https://<account-id>.eu.r2.cloudflarestorage.com for the EU jurisdiction); an endpoint ending in .r2.cloudflarestorage.com selects the R2 profile automatically.

## compression

Expand Down Expand Up @@ -99,15 +99,15 @@ Controls when the AWS SDK attaches/validates request and response checksums (aws
- Type: `String`
- Default: `""`

Optional: explicitly picks which provider profile resolves this config, one of: "r2", "generic". Leave empty to auto-detect: setting account-id below picks "r2" automatically, otherwise "generic" (uses endpoint-url/force-path-style as-is). Only needed if you want to be explicit rather than rely on auto-detection.
Optional: explicitly picks which provider profile resolves this config, one of: "r2", "generic". Leave empty to auto-detect: setting account-id below, or an endpoint-url ending in .r2.cloudflarestorage.com, picks "r2"; otherwise "generic" (uses endpoint-url/region/force-path-style as-is). Only needed if you want to be explicit rather than rely on auto-detection.

## account-id

- Type: `String`
- Default: `""`
- Sensitive: excluded from BlueMap debug dumps (`@DebugDump(exclude = true)`)

Optional: Cloudflare account ID. If set and endpoint-url is left empty, the endpoint is derived automatically as https://<account-id>.r2.cloudflarestorage.com and path-style access is enabled (required by R2). Leave empty for AWS S3 or any other S3-compatible provider - use endpoint-url directly for those instead.
Optional: Cloudflare account ID. If set and endpoint-url is empty or left at its default, the R2 profile is used: the endpoint becomes https://<account-id>.r2.cloudflarestorage.com, path-style access is enabled and the region is "auto". Leave empty for AWS S3 or any other S3-compatible provider.

## list-cache-ttl-seconds

Expand Down
8 changes: 6 additions & 2 deletions docs/reference/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,13 @@ Resolve with: [Put your map on Cloudflare R2](../tutorial/put-your-map-on-cloudf

The `provider` key holds a value that is not a profile id. See [Provider detection](provider-detection.md).

## S3 bucket name is required / AWS region is required / AWS access key ID is required / AWS secret access key is required
## S3 bucket name is required / AWS region is required

A required key is empty. See the [Configuration reference](configuration.md).
A required key is empty. `AWS region is required` does not apply to the `r2` profile, which maps a blank region to `auto`. See the [Configuration reference](configuration.md).

## Set both access-key-id and secret-access-key, or leave both empty to use the default AWS credential chain

Only one of `access-key-id` and `secret-access-key` is set. Set both, or leave both empty to use the default AWS credential chain. See the [Configuration reference](configuration.md).

## Failed to build S3 FileSystem

Expand Down
3 changes: 2 additions & 1 deletion docs/reference/provider-detection.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ The addon resolves every storage configuration through one of two provider profi

- `provider: "r2"` without `account-id` and without an R2 `endpoint-url` fails at startup with `Provider 'r2' needs either 'account-id' or an 'endpoint-url' ending in .r2.cloudflarestorage.com`.
- The `r2` profile with a blank or placeholder `access-key-id` or `secret-access-key` fails at startup with `Cloudflare R2 needs a real '<key>' (create an R2 API token); it is missing or still the built-in placeholder`, where `<key>` is the offending key.
- `bucket-name`, `region`, `access-key-id` and `secret-access-key` must be non-empty for every profile.
- `bucket-name` must be non-empty for every profile. `region` must be non-empty for every profile except `r2`, which maps a blank region to `auto`.
- `access-key-id` and `secret-access-key` must be both set or both empty; the message is `Set both access-key-id and secret-access-key, or leave both empty to use the default AWS credential chain`. Both empty is valid for the `generic` profile, where the AWS default credential chain applies, but not for `r2`, which has no default credential chain.

## Startup log line

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
*/
package dev.themeinerlp.bluemap.s3.storage;

import java.util.List;

/**
* Fallback profile: passes the configured endpoint-url/force-path-style/region through
* unchanged. Always applies, and {@link ProviderProfiles} tries it last so any provider-specific
Expand All @@ -38,6 +40,6 @@ public boolean appliesTo(S3Configuration cfg) {

@Override
public ResolvedEndpoint resolve(S3Configuration cfg) {
return new ResolvedEndpoint(cfg.getEndpointUrl(), cfg.forcePathStyle(), cfg.getRegion());
return new ResolvedEndpoint(ID, cfg.getEndpointUrl(), cfg.forcePathStyle(), cfg.getRegion(), List.of());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
*/
package dev.themeinerlp.bluemap.s3.storage;

import java.util.List;

/**
* Resolves a provider's own shorthand config (e.g. Cloudflare R2's account-id) into the plain
* connection settings {@link S3FileSystemFactory} actually needs. Keeps provider-specific
Expand All @@ -39,5 +41,10 @@ interface ProviderProfile {
/** Only called once this profile has been selected, either explicitly or via {@link #appliesTo}. */
ResolvedEndpoint resolve(S3Configuration cfg);

record ResolvedEndpoint(String endpointUrl, boolean forcePathStyle, String region) {}
/**
* @param profileId id of the profile that produced this result
* @param warnings non-fatal notes the caller should log (never contains secrets)
*/
record ResolvedEndpoint(
String profileId, String endpointUrl, boolean forcePathStyle, String region, List<String> warnings) {}
}
106 changes: 96 additions & 10 deletions src/main/java/dev/themeinerlp/bluemap/s3/storage/R2Profile.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,24 @@
*/
package dev.themeinerlp.bluemap.s3.storage;

import java.net.URI;
import java.util.List;
import java.util.Locale;

/**
* Cloudflare R2: derives the endpoint from account-id so users don't have to look up/type the
* full https://&lt;account-id&gt;.r2.cloudflarestorage.com URL themselves. Applies only when
* account-id is set and no explicit endpoint-url was given, so setting endpoint-url always
* still wins (e.g. for R2's jurisdictional/EU-restricted endpoints).
* Cloudflare R2. Applies when account-id is set and endpoint-url is blank or still the built-in
* default (the endpoint is then derived as https://&lt;account-id&gt;.r2.cloudflarestorage.com),
* or when endpoint-url itself points at an R2 host (e.g. the EU jurisdiction endpoint), in which
* case that endpoint is used as-is and account-id is not needed.
*
* <p>R2 only supports path-style access, so this always forces it on regardless of
* force-path-style.
* <p>R2 only supports path-style access and signs with region "auto", so both are forced
* regardless of force-path-style/region (a differing region is reported as a warning).
*/
final class R2Profile implements ProviderProfile {

static final String ID = "r2";
static final String HOST_SUFFIX = ".r2.cloudflarestorage.com";
static final String REGION = "auto";

@Override
public String id() {
Expand All @@ -37,13 +43,93 @@ public String id() {

@Override
public boolean appliesTo(S3Configuration cfg) {
boolean noExplicitEndpoint = cfg.getEndpointUrl() == null || cfg.getEndpointUrl().isBlank();
return noExplicitEndpoint && cfg.getAccountId() != null && !cfg.getAccountId().isBlank();
if (isR2Endpoint(cfg.getEndpointUrl())) {
return true;
}
return hasAccountId(cfg) && endpointUnset(cfg.getEndpointUrl());
}

@Override
public ResolvedEndpoint resolve(S3Configuration cfg) {
String endpoint = "https://" + cfg.getAccountId() + ".r2.cloudflarestorage.com";
return new ResolvedEndpoint(endpoint, true, cfg.getRegion());
String endpoint;
if (isR2Endpoint(cfg.getEndpointUrl())) {
endpoint = cfg.getEndpointUrl().trim();
} else if (hasAccountId(cfg)) {
endpoint = "https://" + cfg.getAccountId().trim() + HOST_SUFFIX;
} else {
throw new IllegalArgumentException("Provider 'r2' needs either 'account-id' or an 'endpoint-url' "
+ "ending in " + HOST_SUFFIX);
}
requireCredential("access-key-id", cfg.getAccessKeyId(), S3StorageConfiguration.DEFAULT_ACCESS_KEY_ID);
requireCredential(
"secret-access-key", cfg.getSecretAccessKey(), S3StorageConfiguration.DEFAULT_SECRET_ACCESS_KEY);

List<String> warnings = List.of();
String region = cfg.getRegion();
if (!isAutoAlias(region)) {
warnings = List.of("Cloudflare R2 only accepts region 'auto' (or the aliases 'us-east-1'/empty); "
+ "ignoring configured region '" + region + "' and using 'auto'");
}
return new ResolvedEndpoint(ID, endpoint, true, REGION, warnings);
}

/** Replaces the account-id label of an R2 host with {@code ***} so logs don't leak it. */
static String maskEndpoint(String endpoint) {
if (endpoint == null || endpoint.isBlank()) {
return "";
}
String host = hostOf(endpoint);
if (host == null || !host.toLowerCase(Locale.ROOT).endsWith(HOST_SUFFIX)) {
return endpoint;
}
int dot = host.indexOf('.');
if (dot <= 0) {
return endpoint;
}
int at = endpoint.indexOf(host);
return endpoint.substring(0, at) + "***" + endpoint.substring(at + dot);
}

static boolean isR2Endpoint(String endpoint) {
String host = hostOf(endpoint);
return host != null && host.toLowerCase(Locale.ROOT).endsWith(HOST_SUFFIX);
}

private static String hostOf(String endpoint) {
if (endpoint == null || endpoint.isBlank()) {
return null;
}
try {
return URI.create(endpoint.trim()).getHost();
} catch (IllegalArgumentException e) {
return null;
}
}

private static boolean hasAccountId(S3Configuration cfg) {
return cfg.getAccountId() != null && !cfg.getAccountId().isBlank();
}

private static boolean endpointUnset(String endpoint) {
return endpoint == null
|| endpoint.isBlank()
|| endpoint.trim().equals(S3StorageConfiguration.DEFAULT_ENDPOINT_URL);
}

private static boolean isAutoAlias(String region) {
if (region == null || region.isBlank()) {
return true;
}
String r = region.trim();
return r.equalsIgnoreCase(S3StorageConfiguration.DEFAULT_REGION)
|| r.equalsIgnoreCase(REGION)
|| r.equalsIgnoreCase("us-east-1");
}

private static void requireCredential(String key, String value, String placeholder) {
if (value == null || value.isBlank() || value.equals(placeholder)) {
throw new IllegalArgumentException("Cloudflare R2 needs a real '" + key
+ "' (create an R2 API token); it is missing or still the built-in placeholder");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
*/
package dev.themeinerlp.bluemap.s3.storage;

public sealed interface S3Configuration permits S3StorageConfiguration {
public interface S3Configuration {

String getBucketName();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
*/
package dev.themeinerlp.bluemap.s3.storage;

import de.bluecolored.bluemap.core.logger.Logger;
import java.net.URI;
import java.nio.file.FileSystem;
import java.util.Objects;
Expand All @@ -40,6 +41,13 @@ public static S3Fs build(S3Configuration cfg) {
}

final ProviderProfile.ResolvedEndpoint resolved = ProviderProfiles.resolve(cfg);
resolved.warnings().forEach(Logger.global::logWarning);
Logger.global.logInfo("S3 storage: profile=" + resolved.profileId()
+ ", endpoint=" + R2Profile.maskEndpoint(resolved.endpointUrl())
+ ", bucket=" + cfg.getBucketName()
+ ", region=" + resolved.region()
+ ", path-style=" + resolved.forcePathStyle()
+ ", checksum-validation=" + cfg.getChecksumValidation());
final boolean thirdParty = resolved.endpointUrl() != null && !resolved.endpointUrl().isBlank();
try {
final URI uri;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,23 +29,41 @@

@ConfigSerializable
public final class S3StorageConfiguration extends StorageConfig implements S3Configuration {

/** Built-in endpoint-url default (a local MinIO); R2 detection treats it as "not set". */
static final String DEFAULT_ENDPOINT_URL = "http://localhost:9000";
/** Built-in region default; R2 detection treats it as "not set". */
static final String DEFAULT_REGION = "Minio";
/** Built-in access-key-id placeholder; R2 refuses to start with it. */
static final String DEFAULT_ACCESS_KEY_ID = "bluemap";
/** Built-in secret-access-key placeholder; R2 refuses to start with it. */
static final String DEFAULT_SECRET_ACCESS_KEY = "bluemap-secret";

@Comment("The name of the S3 bucket to use")
private String bucketName = "bluemap-storage";

@Comment("The AWS region where the bucket is located")
private String region = "Minio";
@Comment("""
The region used to sign requests. Generic S3: set the region of your bucket.
Cloudflare R2: leave unset - R2 always uses "auto" (the defaults "Minio",
"us-east-1" and an empty value are mapped to it, any other value is ignored with a warning).
""")
private String region = DEFAULT_REGION;

@Comment("The AWS access key ID for authentication. Leave empty together with secret-access-key to use the default AWS credential chain (environment, profile, ECS task role, EC2 instance profile)")
private String accessKeyId = "bluemap";
private String accessKeyId = DEFAULT_ACCESS_KEY_ID;

@Comment("The AWS secret access key for authentication. Leave empty together with access-key-id to use the default AWS credential chain")
@DebugDump(exclude = true)
private String secretAccessKey = "bluemap-secret";
private String secretAccessKey = DEFAULT_SECRET_ACCESS_KEY;

@Comment("Optional: The endpoint URL for S3-compatible services (leave empty for AWS S3)")
@Comment("""
The endpoint URL for S3-compatible services (leave empty for AWS S3).
Cloudflare R2: leave unset and set account-id instead, or set the full R2 endpoint
(e.g. https://<account-id>.eu.r2.cloudflarestorage.com for the EU jurisdiction);
an endpoint ending in .r2.cloudflarestorage.com selects the R2 profile automatically.
""")
@DebugDump(exclude = true)
private String endpointUrl = "http://localhost:9000";
private String endpointUrl = DEFAULT_ENDPOINT_URL;

@Comment("The compression type to use for storing data (default: gzip)")
private String compression = "gzip"; // Default compression type
Expand All @@ -68,17 +86,18 @@ public final class S3StorageConfiguration extends StorageConfig implements S3Con

@Comment("""
Optional: explicitly picks which provider profile resolves this config, one of:
"r2", "generic". Leave empty to auto-detect: setting account-id below picks "r2"
automatically, otherwise "generic" (uses endpoint-url/force-path-style as-is).
"r2", "generic". Leave empty to auto-detect: setting account-id below, or an endpoint-url
ending in .r2.cloudflarestorage.com, picks "r2"; otherwise "generic"
(uses endpoint-url/region/force-path-style as-is).
Only needed if you want to be explicit rather than rely on auto-detection.
""")
private String provider = "";

@Comment("""
Optional: Cloudflare account ID. If set and endpoint-url is left empty, the
endpoint is derived automatically as https://<account-id>.r2.cloudflarestorage.com
and path-style access is enabled (required by R2). Leave empty for AWS S3 or any
other S3-compatible provider - use endpoint-url directly for those instead.
Optional: Cloudflare account ID. If set and endpoint-url is empty or left at its
default, the R2 profile is used: the endpoint becomes
https://<account-id>.r2.cloudflarestorage.com, path-style access is enabled and the
region is "auto". Leave empty for AWS S3 or any other S3-compatible provider.
""")
@DebugDump(exclude = true)
private String accountId = "";
Expand All @@ -99,7 +118,17 @@ public Storage createStorage() throws ConfigurationException {
throw new ConfigurationException("S3 bucket name is required");
}

if (region == null || region.isEmpty()) {
// Resolve the provider profile up front so R2 misconfiguration is reported as a clean
// ConfigurationException instead of a stack trace from S3FileSystemFactory.
final ProviderProfile.ResolvedEndpoint resolved;
try {
resolved = ProviderProfiles.resolve(this);
} catch (IllegalArgumentException e) {
throw new ConfigurationException(e.getMessage(), e);
}

// R2 maps an empty region to "auto", so only the other profiles need one.
if (!R2Profile.ID.equals(resolved.profileId()) && (region == null || region.isEmpty())) {
throw new ConfigurationException("AWS region is required");
}

Expand Down
Loading
Loading