Compare commits
2 Commits
Author | SHA1 | Date | |
---|---|---|---|
0c631383e1 | |||
d852d8c85b |
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-08-18 - 3.3.10 - fix(helpers)
|
||||||
|
Normalize and robustly parse S3 endpoint configuration; use normalized descriptor in SmartBucket and update dev tooling
|
||||||
|
|
||||||
|
- Add normalizeS3Descriptor to ts/helpers.ts: robust endpoint parsing, coercion of useSsl/port, sanitization, warnings for dropped URL parts, and canonical endpoint URL output.
|
||||||
|
- Update SmartBucket (ts/classes.smartbucket.ts) to use the normalized endpoint, region, credentials and forcePathStyle from normalizeS3Descriptor.
|
||||||
|
- Adjust dev tooling: bump @git.zone/tsbuild -> ^2.6.7, @git.zone/tstest -> ^2.3.4, @push.rocks/qenv -> ^6.1.3 and update test script to run tstest with --verbose --logfile --timeout 60.
|
||||||
|
- Add .claude/settings.local.json containing local assistant/CI permission settings (local config only).
|
||||||
|
|
||||||
## 2025-08-15 - 3.3.9 - fix(docs)
|
## 2025-08-15 - 3.3.9 - fix(docs)
|
||||||
Revise README with detailed usage examples and add local Claude settings
|
Revise README with detailed usage examples and add local Claude settings
|
||||||
|
|
||||||
|
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartbucket",
|
"name": "@push.rocks/smartbucket",
|
||||||
"version": "3.3.9",
|
"version": "3.3.10",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@push.rocks/smartbucket",
|
"name": "@push.rocks/smartbucket",
|
||||||
"version": "3.3.9",
|
"version": "3.3.10",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@push.rocks/smartpath": "^5.0.18",
|
"@push.rocks/smartpath": "^5.0.18",
|
||||||
|
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartbucket",
|
"name": "@push.rocks/smartbucket",
|
||||||
"version": "3.3.9",
|
"version": "3.3.10",
|
||||||
"description": "A TypeScript library providing a cloud-agnostic interface for managing object storage with functionalities like bucket management, file and directory operations, and advanced features such as metadata handling and file locking.",
|
"description": "A TypeScript library providing a cloud-agnostic interface for managing object storage with functionalities like bucket management, file and directory operations, and advanced features such as metadata handling and file locking.",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
"typings": "dist_ts/index.d.ts",
|
"typings": "dist_ts/index.d.ts",
|
||||||
@@ -8,14 +8,14 @@
|
|||||||
"author": "Task Venture Capital GmbH",
|
"author": "Task Venture Capital GmbH",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "(tstest test/)",
|
"test": "(tstest test/ --verbose --logfile --timeout 60)",
|
||||||
"build": "(tsbuild --web --allowimplicitany)"
|
"build": "(tsbuild --web --allowimplicitany)"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^2.6.4",
|
"@git.zone/tsbuild": "^2.6.7",
|
||||||
"@git.zone/tsrun": "^1.2.49",
|
"@git.zone/tsrun": "^1.2.49",
|
||||||
"@git.zone/tstest": "^2.3.2",
|
"@git.zone/tstest": "^2.3.4",
|
||||||
"@push.rocks/qenv": "^6.1.2",
|
"@push.rocks/qenv": "^6.1.3",
|
||||||
"@push.rocks/tapbundle": "^6.0.3"
|
"@push.rocks/tapbundle": "^6.0.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
744
pnpm-lock.yaml
generated
744
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartbucket',
|
name: '@push.rocks/smartbucket',
|
||||||
version: '3.3.9',
|
version: '3.3.10',
|
||||||
description: 'A TypeScript library providing a cloud-agnostic interface for managing object storage with functionalities like bucket management, file and directory operations, and advanced features such as metadata handling and file locking.'
|
description: 'A TypeScript library providing a cloud-agnostic interface for managing object storage with functionalities like bucket management, file and directory operations, and advanced features such as metadata handling and file locking.'
|
||||||
}
|
}
|
||||||
|
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import * as plugins from './plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
import { Bucket } from './classes.bucket.js';
|
import { Bucket } from './classes.bucket.js';
|
||||||
|
import { normalizeS3Descriptor } from './helpers.js';
|
||||||
|
|
||||||
export class SmartBucket {
|
export class SmartBucket {
|
||||||
public config: plugins.tsclass.storage.IS3Descriptor;
|
public config: plugins.tsclass.storage.IS3Descriptor;
|
||||||
@@ -17,18 +18,14 @@ export class SmartBucket {
|
|||||||
constructor(configArg: plugins.tsclass.storage.IS3Descriptor) {
|
constructor(configArg: plugins.tsclass.storage.IS3Descriptor) {
|
||||||
this.config = configArg;
|
this.config = configArg;
|
||||||
|
|
||||||
const protocol = configArg.useSsl === false ? 'http' : 'https';
|
// Use the normalizer to handle various endpoint formats
|
||||||
const port = configArg.port ? `:${configArg.port}` : '';
|
const { normalized } = normalizeS3Descriptor(configArg);
|
||||||
const endpoint = `${protocol}://${configArg.endpoint}${port}`;
|
|
||||||
|
|
||||||
this.s3Client = new plugins.s3.S3Client({
|
this.s3Client = new plugins.s3.S3Client({
|
||||||
endpoint,
|
endpoint: normalized.endpointUrl,
|
||||||
region: configArg.region || 'us-east-1',
|
region: normalized.region,
|
||||||
credentials: {
|
credentials: normalized.credentials,
|
||||||
accessKeyId: configArg.accessKey,
|
forcePathStyle: normalized.forcePathStyle, // Necessary for S3-compatible storage like MinIO or Wasabi
|
||||||
secretAccessKey: configArg.accessSecret,
|
|
||||||
},
|
|
||||||
forcePathStyle: true, // Necessary for S3-compatible storage like MinIO or Wasabi
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
232
ts/helpers.ts
232
ts/helpers.ts
@@ -20,3 +20,235 @@ export const reducePathDescriptorToPath = async (pathDescriptorArg: interfaces.I
|
|||||||
}
|
}
|
||||||
return returnPath;
|
return returnPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// S3 Descriptor Normalization
|
||||||
|
export interface IS3Warning {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface INormalizedS3Config {
|
||||||
|
endpointUrl: string;
|
||||||
|
host: string;
|
||||||
|
protocol: 'http' | 'https';
|
||||||
|
port?: number;
|
||||||
|
region: string;
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: string;
|
||||||
|
secretAccessKey: string;
|
||||||
|
};
|
||||||
|
forcePathStyle: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceBooleanMaybe(value: unknown): { value: boolean | undefined; warning?: IS3Warning } {
|
||||||
|
if (typeof value === 'boolean') return { value };
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const v = value.trim().toLowerCase();
|
||||||
|
if (v === 'true' || v === '1') {
|
||||||
|
return {
|
||||||
|
value: true,
|
||||||
|
warning: {
|
||||||
|
code: 'SBK_S3_COERCED_USESSL',
|
||||||
|
message: `Coerced useSsl='${value}' (string) to boolean true.`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (v === 'false' || v === '0') {
|
||||||
|
return {
|
||||||
|
value: false,
|
||||||
|
warning: {
|
||||||
|
code: 'SBK_S3_COERCED_USESSL',
|
||||||
|
message: `Coerced useSsl='${value}' (string) to boolean false.`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { value: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
function coercePortMaybe(port: unknown): { value: number | undefined; warning?: IS3Warning } {
|
||||||
|
if (port === undefined || port === null || port === '') return { value: undefined };
|
||||||
|
const n = typeof port === 'number' ? port : Number(String(port).trim());
|
||||||
|
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0 || n > 65535) {
|
||||||
|
return {
|
||||||
|
value: undefined,
|
||||||
|
warning: {
|
||||||
|
code: 'SBK_S3_INVALID_PORT',
|
||||||
|
message: `Invalid port '${String(port)}' - expected integer in [1..65535].`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { value: n };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeEndpointString(raw: unknown): { value: string; warnings: IS3Warning[] } {
|
||||||
|
const warnings: IS3Warning[] = [];
|
||||||
|
let s = String(raw ?? '').trim();
|
||||||
|
if (s !== String(raw ?? '')) {
|
||||||
|
warnings.push({
|
||||||
|
code: 'SBK_S3_TRIMMED_ENDPOINT',
|
||||||
|
message: 'Trimmed surrounding whitespace from endpoint.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { value: s, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEndpointHostPort(
|
||||||
|
endpoint: string,
|
||||||
|
provisionalProtocol: 'http' | 'https'
|
||||||
|
): {
|
||||||
|
hadScheme: boolean;
|
||||||
|
host: string;
|
||||||
|
port?: number;
|
||||||
|
extras: {
|
||||||
|
droppedPath?: boolean;
|
||||||
|
droppedQuery?: boolean;
|
||||||
|
droppedCreds?: boolean
|
||||||
|
}
|
||||||
|
} {
|
||||||
|
let url: URL | undefined;
|
||||||
|
const extras: { droppedPath?: boolean; droppedQuery?: boolean; droppedCreds?: boolean } = {};
|
||||||
|
|
||||||
|
// Check if endpoint already has a scheme
|
||||||
|
const hasScheme = /^https?:\/\//i.test(endpoint);
|
||||||
|
|
||||||
|
// Try parsing as full URL first
|
||||||
|
try {
|
||||||
|
if (hasScheme) {
|
||||||
|
url = new URL(endpoint);
|
||||||
|
} else {
|
||||||
|
// Not a full URL; try host[:port] by attaching provisional scheme
|
||||||
|
// Remove anything after first '/' for safety
|
||||||
|
const cleanEndpoint = endpoint.replace(/\/.*/, '');
|
||||||
|
url = new URL(`${provisionalProtocol}://${cleanEndpoint}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`Unable to parse endpoint '${endpoint}'.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for dropped components
|
||||||
|
if (url.username || url.password) extras.droppedCreds = true;
|
||||||
|
if (url.pathname && url.pathname !== '/') extras.droppedPath = true;
|
||||||
|
if (url.search) extras.droppedQuery = true;
|
||||||
|
|
||||||
|
const hadScheme = hasScheme;
|
||||||
|
const host = url.hostname; // hostnames lowercased by URL; IPs preserved
|
||||||
|
const port = url.port ? Number(url.port) : undefined;
|
||||||
|
|
||||||
|
return { hadScheme, host, port, extras };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeS3Descriptor(
|
||||||
|
input: plugins.tsclass.storage.IS3Descriptor,
|
||||||
|
logger?: { warn: (msg: string) => void }
|
||||||
|
): { normalized: INormalizedS3Config; warnings: IS3Warning[] } {
|
||||||
|
const warnings: IS3Warning[] = [];
|
||||||
|
const logWarn = (w: IS3Warning) => {
|
||||||
|
warnings.push(w);
|
||||||
|
if (logger) {
|
||||||
|
logger.warn(`[SmartBucket S3] ${w.code}: ${w.message}`);
|
||||||
|
} else {
|
||||||
|
console.warn(`[SmartBucket S3] ${w.code}: ${w.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Coerce and sanitize inputs
|
||||||
|
const { value: coercedUseSsl, warning: useSslWarn } = coerceBooleanMaybe((input as any).useSsl);
|
||||||
|
if (useSslWarn) logWarn(useSslWarn);
|
||||||
|
|
||||||
|
const { value: coercedPort, warning: portWarn } = coercePortMaybe((input as any).port);
|
||||||
|
if (portWarn) logWarn(portWarn);
|
||||||
|
|
||||||
|
const { value: endpointStr, warnings: endpointSanWarnings } = sanitizeEndpointString((input as any).endpoint);
|
||||||
|
endpointSanWarnings.forEach(logWarn);
|
||||||
|
|
||||||
|
if (!endpointStr) {
|
||||||
|
throw new Error('S3 endpoint is required (got empty string). Provide hostname or URL.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provisional protocol selection for parsing host:port forms
|
||||||
|
const provisionalProtocol: 'http' | 'https' = coercedUseSsl === false ? 'http' : 'https';
|
||||||
|
|
||||||
|
const { hadScheme, host, port: epPort, extras } = parseEndpointHostPort(endpointStr, provisionalProtocol);
|
||||||
|
|
||||||
|
if (extras.droppedCreds) {
|
||||||
|
logWarn({
|
||||||
|
code: 'SBK_S3_DROPPED_CREDENTIALS',
|
||||||
|
message: 'Ignored credentials in endpoint URL.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (extras.droppedPath) {
|
||||||
|
logWarn({
|
||||||
|
code: 'SBK_S3_DROPPED_PATH',
|
||||||
|
message: 'Removed path segment from endpoint URL; S3 endpoint should be host[:port] only.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (extras.droppedQuery) {
|
||||||
|
logWarn({
|
||||||
|
code: 'SBK_S3_DROPPED_QUERY',
|
||||||
|
message: 'Removed query string from endpoint URL; S3 endpoint should be host[:port] only.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final protocol decision
|
||||||
|
let finalProtocol: 'http' | 'https';
|
||||||
|
if (hadScheme) {
|
||||||
|
// Scheme from endpoint wins
|
||||||
|
const schemeFromEndpoint = endpointStr.trim().toLowerCase().startsWith('http://') ? 'http' : 'https';
|
||||||
|
finalProtocol = schemeFromEndpoint;
|
||||||
|
if (typeof coercedUseSsl === 'boolean') {
|
||||||
|
const expected = coercedUseSsl ? 'https' : 'http';
|
||||||
|
if (expected !== finalProtocol) {
|
||||||
|
logWarn({
|
||||||
|
code: 'SBK_S3_SCHEME_CONFLICT',
|
||||||
|
message: `useSsl=${String(coercedUseSsl)} conflicts with endpoint scheme '${finalProtocol}'; using endpoint scheme.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (typeof coercedUseSsl === 'boolean') {
|
||||||
|
finalProtocol = coercedUseSsl ? 'https' : 'http';
|
||||||
|
} else {
|
||||||
|
finalProtocol = 'https';
|
||||||
|
logWarn({
|
||||||
|
code: 'SBK_S3_GUESSED_PROTOCOL',
|
||||||
|
message: "No scheme in endpoint and useSsl not provided; defaulting to 'https'."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final port decision
|
||||||
|
let finalPort: number | undefined = undefined;
|
||||||
|
if (coercedPort !== undefined && epPort !== undefined && coercedPort !== epPort) {
|
||||||
|
logWarn({
|
||||||
|
code: 'SBK_S3_PORT_CONFLICT',
|
||||||
|
message: `Port in config (${coercedPort}) conflicts with endpoint port (${epPort}); using config port.`
|
||||||
|
});
|
||||||
|
finalPort = coercedPort;
|
||||||
|
} else {
|
||||||
|
finalPort = (coercedPort !== undefined) ? coercedPort : epPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build canonical endpoint URL (origin only, no trailing slash)
|
||||||
|
const url = new URL(`${finalProtocol}://${host}`);
|
||||||
|
if (finalPort !== undefined) url.port = String(finalPort);
|
||||||
|
const endpointUrl = url.origin;
|
||||||
|
|
||||||
|
const region = input.region || 'us-east-1';
|
||||||
|
|
||||||
|
return {
|
||||||
|
normalized: {
|
||||||
|
endpointUrl,
|
||||||
|
host,
|
||||||
|
protocol: finalProtocol,
|
||||||
|
port: finalPort,
|
||||||
|
region,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: input.accessKey,
|
||||||
|
secretAccessKey: input.accessSecret,
|
||||||
|
},
|
||||||
|
forcePathStyle: true,
|
||||||
|
},
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
Reference in New Issue
Block a user