Skip to content

Commit 5ba5d7a

Browse files
authored
Fix S3 URL parsing for nested paths in AWS buckets (#1819)
* Add s3UrlFormat option, reformat regex * Update parseS3Url params * Update pmtiles_adapter.js * Update pmtiles_adapter.js * bump version
1 parent 4aa5f48 commit 5ba5d7a

File tree

9 files changed

+140
-46
lines changed

9 files changed

+140
-46
lines changed

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
# tileserver-gl changelog
22

3-
## 5.5.0-pre.2
3+
## 5.5.0-pre.3
44
* Add S3 support for PMTiles with multiple AWS credential profiles (https://github.com/maptiler/tileserver-gl/pull/1779) by @acalcutt
55
* Create .aws directory passthrough folder in Dockerfile (https://github.com/maptiler/tileserver-gl/pull/1784) by @acalcutt
66
* Update eslint to v9 (https://github.com/maptiler/tileserver-gl/pull/1473) by @acalcutt
77
* Fix Renderer Crashes from Failed Fetches (https://github.com/maptiler/tileserver-gl/pull/1798) by @acalcutt
8+
* Add Visual Regression Tests for Static Image Overlays (https://github.com/maptiler/tileserver-gl/pull/1792) by @acalcutt
9+
* Fix S3 URL parsing for nested paths in AWS buckets (https://github.com/maptiler/tileserver-gl/pull/1819) by @acalcutt
810

911
## 5.4.0
1012
* Fix the issue where the tile URL cannot be correctly parsed with the HTTPS protocol when using an nginx proxy service (https://github.com/maptiler/tileserver-gl/pull/1578) by @dakanggo

docs/config.rst

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,23 @@ Here are the available options for each data source:
311311
If not specified, uses ``AWS_REGION`` environment variable or defaults to ``us-east-1``.
312312
Optional, only applicable to PMTiles sources using S3 URLs.
313313

314+
``s3UrlFormat`` (string)
315+
Specifies how to interpret the S3 URL format.
316+
317+
Allowed values:
318+
319+
* ``aws`` - Interpret as AWS S3 (``s3://bucket/path/file.pmtiles``)
320+
* ``custom`` - Interpret as custom S3 endpoint (``s3://endpoint/bucket/path/file.pmtiles``)
321+
* Not specified (default) - Auto-detect based on URL pattern
322+
323+
Can be specified in the URL using ``?s3UrlFormat=aws`` or in the configuration.
324+
If both are specified, the configuration value takes precedence.
325+
326+
Optional, only applicable to PMTiles sources using S3 URLs.
327+
328+
.. note::
329+
By default, URLs with dots in the first segment (e.g., ``s3://storage.example.com/bucket/file.pmtiles``) are treated as custom endpoints, while URLs without dots are treated as AWS S3. Use ``s3UrlFormat: "aws"`` if your AWS bucket name contains dots.
330+
314331
.. note::
315332
These configuration options will be overridden by metadata in the MBTiles or PMTiles file. if corresponding properties exist in the file's metadata, you do not need to specify them in the data configuration.
316333

@@ -445,6 +462,17 @@ Precedence order (highest to lowest): Configuration property ``s3Region``, URL p
445462

446463
Precedence order (highest to lowest): Configuration property ``requestPayer``, URL parameter ``?requestPayer=true``, Default: ``false``.
447464

465+
*S3UrlFormat* - Specifies how to interpret S3 URLs::
466+
467+
# URL parameter
468+
"pmtiles": "s3://my.bucket.name/tiles.pmtiles?s3UrlFormat=aws"
469+
470+
# Configuration property
471+
"pmtiles": "s3://my.bucket.name/tiles.pmtiles",
472+
"s3UrlFormat": "aws"
473+
474+
Precedence order (highest to lowest): Configuration property ``s3UrlFormat``, URL parameter ``?s3UrlFormat=...``, Auto-detection.
475+
448476
**Complete Configuration Examples:**
449477

450478
Using URL parameters::
@@ -453,6 +481,9 @@ Using URL parameters::
453481
"us-west-tiles": {
454482
"pmtiles": "s3://prod-bucket/tiles.pmtiles?profile=production&region=us-west-2"
455483
},
484+
"dotted-bucket-name": {
485+
"pmtiles": "s3://my.bucket.name/tiles.pmtiles?s3UrlFormat=aws&region=us-east-1"
486+
},
456487
"eu-requester-pays": {
457488
"pmtiles": "s3://bucket/tiles.pmtiles?profile=prod&region=eu-central-1&requestPayer=true"
458489
}
@@ -466,6 +497,11 @@ Using configuration properties (recommended)::
466497
"s3Profile": "production",
467498
"s3Region": "us-west-2"
468499
},
500+
"dotted-bucket-name": {
501+
"pmtiles": "s3://my.bucket.name/tiles.pmtiles",
502+
"s3UrlFormat": "aws",
503+
"s3Region": "us-east-1"
504+
},
469505
"eu-requester-pays": {
470506
"pmtiles": "s3://bucket/tiles.pmtiles",
471507
"s3Profile": "production",
@@ -476,13 +512,17 @@ Using configuration properties (recommended)::
476512

477513
**Using S3 in Style JSON Sources:**
478514

479-
When referencing S3 sources from within a style JSON file, use the ``pmtiles://`` prefix with S3 URLs. You can only specify profile, region, and requestPayer using URL query parameters (configuration properties are not available in style JSON)::
515+
When referencing S3 sources from within a style JSON file, use the ``pmtiles://`` prefix with S3 URLs. You can specify profile, region, requestPayer, and s3UrlFormat using URL query parameters (configuration properties are not available in style JSON)::
480516

481517
"sources": {
482518
"aws-tiles": {
483519
"url": "pmtiles://s3://my-bucket/tiles.pmtiles?profile=production",
484520
"type": "vector"
485521
},
522+
"dotted-bucket": {
523+
"url": "pmtiles://s3://my.bucket.name/tiles.pmtiles?s3UrlFormat=aws",
524+
"type": "vector"
525+
},
486526
"spaces-tiles": {
487527
"url": "pmtiles://s3://example-storage.com/my-bucket/tiles.pmtiles?region=nyc3",
488528
"type": "vector"

docs/usage.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ The `--file` option supports multiple source types:
6464
# Requester-pays bucket
6565
tileserver-gl --file "s3://bucket/tiles.pmtiles?requestPayer=true"
6666

67+
# Bucket name with dots (force AWS S3 interpretation)
68+
tileserver-gl --file "s3://my.bucket.name/tiles.pmtiles?s3UrlFormat=aws"
69+
6770
# All options combined
6871
tileserver-gl --file "s3://bucket/tiles.pmtiles?profile=prod&region=us-west-2&requestPayer=true"
6972

@@ -82,6 +85,8 @@ You can also use `pmtiles://` or `mbtiles://` prefixes to explicitly specify the
8285
.. note::
8386
For S3 sources, AWS credentials must be configured via environment variables, AWS credentials file (`~/.aws/credentials` on Linux/macOS or `C:\Users\USERNAME\.aws\credentials` on Windows), or IAM roles.
8487

88+
The `s3UrlFormat` parameter can be set to `aws` or `custom` to override auto-detection when needed (e.g., for AWS bucket names containing dots).
89+
8590
**When using Docker**, the host credentials file can be mounted to the container's user home directory:
8691

8792
::

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "tileserver-gl",
3-
"version": "5.5.0-pre.2",
3+
"version": "5.5.0-pre.3",
44
"description": "Map tile server for JSON GL styles - vector and server side generated raster tiles",
55
"main": "src/main.js",
66
"bin": "src/main.js",

src/pmtiles_adapter.js

Lines changed: 76 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -17,31 +17,45 @@ class S3Source {
1717
* @param {string} [s3Profile] - Optional AWS credential profile name from config.
1818
* @param {boolean} [configRequestPayer] - Optional flag from config for requester pays buckets.
1919
* @param {string} [configRegion] - Optional AWS region from config.
20+
* @param {string} [s3UrlFormat] - Optional S3 URL format from config: 'aws' or 'custom'.
2021
* @param {boolean} [verbose] - Whether to show verbose logging.
2122
*/
2223
constructor(
2324
s3Url,
2425
s3Profile,
2526
configRequestPayer,
2627
configRegion,
28+
s3UrlFormat,
2729
verbose = false,
2830
) {
29-
const parsed = this.parseS3Url(s3Url);
31+
const parsed = this.parseS3Url(s3Url, s3UrlFormat);
3032
this.bucket = parsed.bucket;
3133
this.key = parsed.key;
3234
this.endpoint = parsed.endpoint;
3335
this.url = s3Url;
3436
this.verbose = verbose;
3537

36-
// Determine the final profile: Config takes precedence over URL
38+
// Apply configuration precedence: Config > URL > Default
39+
// Using || for strings (empty string = not set)
40+
// Using ?? for booleans (false is valid value)
3741
const profile = s3Profile || parsed.profile;
38-
39-
// Determine requestPayer: Config takes precedence over URL
4042
this.requestPayer = configRequestPayer ?? parsed.requestPayer;
41-
42-
// Determine region: Config takes precedence over URL
4343
this.region = configRegion || parsed.region;
4444

45+
// Log precedence decisions for debugging
46+
if (verbose >= 3) {
47+
console.log(`S3 config precedence for ${s3Url}:`);
48+
console.log(
49+
` Profile: ${s3Profile ? 'config' : parsed.profile ? 'url' : 'default'} = ${profile || 'none'}`,
50+
);
51+
console.log(
52+
` Region: ${configRegion ? 'config' : parsed.region !== (process.env.AWS_REGION || 'us-east-1') ? 'url' : 'env/default'} = ${this.region}`,
53+
);
54+
console.log(
55+
` RequestPayer: ${configRequestPayer !== undefined ? 'config' : parsed.requestPayer ? 'url' : 'default'} = ${this.requestPayer}`,
56+
);
57+
}
58+
4559
// Create S3 client
4660
this.s3Client = this.createS3Client(
4761
parsed.endpoint,
@@ -54,60 +68,78 @@ class S3Source {
5468
/**
5569
* Parses various S3 URL formats into bucket, key, endpoint, region, and profile.
5670
* @param {string} url - The S3 URL to parse.
71+
* @param {string} [s3UrlFormat] - Optional format override: 'aws' or 'custom'.
5772
* @returns {object} - An object containing bucket, key, endpoint, region, and profile.
5873
* @throws {Error} - Throws an error if the URL format is invalid.
5974
*/
60-
parseS3Url(url) {
61-
// Initialize with defaults/environment
75+
parseS3Url(url, s3UrlFormat) {
76+
// Validate s3UrlFormat if provided
77+
if (s3UrlFormat && s3UrlFormat !== 'aws' && s3UrlFormat !== 'custom') {
78+
console.warn(
79+
`Invalid s3UrlFormat: "${s3UrlFormat}". Must be "aws" or "custom". Using auto-detection.`,
80+
);
81+
s3UrlFormat = undefined;
82+
}
83+
6284
let region = process.env.AWS_REGION || 'us-east-1';
6385
let profile = null;
6486
let requestPayer = false;
6587

66-
const queryString = url.split('?')[1];
88+
// Parse URL parameters
89+
const [cleanUrl, queryString] = url.split('?');
6790
if (queryString) {
6891
const params = new URLSearchParams(queryString);
69-
// Update profile if provided in url parameters
92+
// URL parameters override defaults
7093
profile = params.get('profile') ?? profile;
71-
// Update region if provided in url parameters
7294
region = params.get('region') ?? region;
73-
// Update requestPayer if provided in url parameters
95+
s3UrlFormat = s3UrlFormat ?? params.get('s3UrlFormat'); // Config overrides URL
96+
7497
const payerVal = params.get('requestPayer');
7598
requestPayer = payerVal === 'true' || payerVal === '1';
7699
}
77100

78-
// Clean URL for format detection (remove trailing slashes)
79-
const baseUrl = url.split('?')[0];
80-
const cleanUrl = baseUrl.replace(/\/+$/, '');
101+
// Helper to build result object
102+
const buildResult = (endpoint, bucket, key) => ({
103+
endpoint: endpoint ? `https://${endpoint}` : null,
104+
bucket,
105+
key,
106+
region,
107+
profile,
108+
requestPayer,
109+
});
81110

82-
// Format 1: s3://endpoint/bucket/key (S3-compatible storage)
83-
// Example: s3://storage.example.com/mybucket/path/to/tiles.pmtile
84-
const endpointMatch = cleanUrl.match(/^s3:\/\/([^/]+)\/([^/]+)\/(.+)$/);
85-
if (endpointMatch) {
86-
return {
87-
endpoint: `https://${endpointMatch[1]}`,
88-
bucket: endpointMatch[2],
89-
key: endpointMatch[3],
90-
region,
91-
profile,
92-
requestPayer,
93-
};
94-
}
111+
// Define patterns based on format
112+
const patterns = {
113+
customWithDot: /^s3:\/\/([^/]*\.[^/]+)\/([^/]+)\/(.+)$/, // Auto-detect: requires dot
114+
customForced: /^s3:\/\/([^/]+)\/([^/]+)\/(.+)$/, // Explicit: no dot required
115+
aws: /^s3:\/\/([^/]+)\/(.+)$/,
116+
};
95117

96-
// Format 2: s3://bucket/key (AWS S3 default)
97-
// Example: s3://my-bucket/path/to/tiles.pmtiles
98-
const awsMatch = cleanUrl.match(/^s3:\/\/([^/]+)\/(.+)$/);
99-
if (awsMatch) {
100-
return {
101-
endpoint: null, // Use default AWS endpoint
102-
bucket: awsMatch[1],
103-
key: awsMatch[2],
104-
region,
105-
profile,
106-
requestPayer,
107-
};
118+
// Match based on s3UrlFormat or auto-detect
119+
let match;
120+
121+
if (s3UrlFormat === 'custom') {
122+
match = cleanUrl.match(patterns.customForced);
123+
if (match) return buildResult(match[1], match[2], match[3]);
124+
} else if (s3UrlFormat === 'aws') {
125+
match = cleanUrl.match(patterns.aws);
126+
if (match) return buildResult(null, match[1], match[2]);
127+
} else {
128+
// Auto-detection: try custom (with dot) first, then AWS
129+
match = cleanUrl.match(patterns.customWithDot);
130+
if (match) return buildResult(match[1], match[2], match[3]);
131+
132+
match = cleanUrl.match(patterns.aws);
133+
if (match) return buildResult(null, match[1], match[2]);
108134
}
109135

110-
throw new Error(`Invalid S3 URL format: ${url}`);
136+
throw new Error(
137+
`Invalid S3 URL format: ${url}\n` +
138+
`Expected formats:\n` +
139+
` AWS S3: s3://bucket-name/path/to/file.pmtiles\n` +
140+
` Custom endpoint: s3://endpoint.com/bucket/path/to/file.pmtiles\n` +
141+
`Use s3UrlFormat parameter to override auto-detection if needed.`,
142+
);
111143
}
112144

113145
/**
@@ -282,6 +314,7 @@ async function readFileBytes(fd, buffer, offset) {
282314
* @param {string} [s3Profile] - Optional AWS credential profile name.
283315
* @param {boolean} [requestPayer] - Optional flag for requester pays buckets.
284316
* @param {string} [s3Region] - Optional AWS region.
317+
* @param {string} [s3UrlFormat] - Optional S3 URL format: 'aws' or 'custom'.
285318
* @param {boolean} [verbose] - Whether to show verbose logging.
286319
* @returns {PMTiles} - A PMTiles instance.
287320
*/
@@ -290,6 +323,7 @@ export function openPMtiles(
290323
s3Profile,
291324
requestPayer,
292325
s3Region,
326+
s3UrlFormat,
293327
verbose = 0,
294328
) {
295329
let pmtiles = undefined;
@@ -303,6 +337,7 @@ export function openPMtiles(
303337
s3Profile,
304338
requestPayer,
305339
s3Region,
340+
s3UrlFormat,
306341
verbose,
307342
);
308343
pmtiles = new PMTiles(source);

src/serve_data.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,7 @@ export const serve_data = {
389389
params.s3Profile,
390390
params.requestPayer,
391391
params.s3Region,
392+
params.s3UrlFormat,
392393
verbose,
393394
);
394395
sourceType = 'pmtiles';

src/serve_rendered.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1594,6 +1594,7 @@ export const serve_rendered = {
15941594
let s3Profile;
15951595
let requestPayer;
15961596
let s3Region;
1597+
let s3UrlFormat;
15971598
const dataInfo = dataResolver(dataId);
15981599
if (dataInfo.inputFile) {
15991600
inputFile = dataInfo.inputFile;
@@ -1602,6 +1603,7 @@ export const serve_rendered = {
16021603
s3Profile = dataInfo.s3Profile;
16031604
requestPayer = dataInfo.requestPayer;
16041605
s3Region = dataInfo.s3Region;
1606+
s3UrlFormat = dataInfo.s3UrlFormat;
16051607
} else {
16061608
console.error(`ERROR: data "${inputFile}" not found!`);
16071609
process.exit(1);
@@ -1622,6 +1624,7 @@ export const serve_rendered = {
16221624
s3Profile,
16231625
requestPayer,
16241626
s3Region,
1627+
s3UrlFormat,
16251628
verbose,
16261629
);
16271630
// eslint-disable-next-line security/detect-object-injection -- name is from style sources object keys

src/server.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ async function start(opts) {
290290
let resolvedS3Profile;
291291
let resolvedRequestPayer;
292292
let resolvedS3Region;
293+
let resolvedS3UrlFormat;
293294

294295
for (const id of Object.keys(data)) {
295296
// eslint-disable-next-line security/detect-object-injection -- id is from Object.keys of data config
@@ -327,6 +328,11 @@ async function start(opts) {
327328
resolvedS3Profile = sourceData.s3Profile;
328329
}
329330

331+
// Get s3UrlFormat if present
332+
if (Object.hasOwn(sourceData, 's3UrlFormat')) {
333+
resolvedS3UrlFormat = sourceData.s3UrlFormat;
334+
}
335+
330336
// Get requestPayer if present
331337
if (Object.hasOwn(sourceData, 'requestPayer')) {
332338
resolvedRequestPayer = !!sourceData.requestPayer;
@@ -354,6 +360,7 @@ async function start(opts) {
354360
s3Profile: undefined,
355361
requestPayer: false,
356362
s3Region: undefined,
363+
s3UrlFormat: undefined,
357364
};
358365
}
359366

@@ -385,6 +392,7 @@ async function start(opts) {
385392
s3Profile: resolvedS3Profile,
386393
requestPayer: resolvedRequestPayer,
387394
s3Region: resolvedS3Region,
395+
s3UrlFormat: resolvedS3UrlFormat,
388396
};
389397
},
390398
),

0 commit comments

Comments
 (0)