fix(helpers): Normalize and robustly parse S3 endpoint configuration; use normalized descriptor in SmartBucket and update dev tooling
This commit is contained in:
232
ts/helpers.ts
232
ts/helpers.ts
@@ -19,4 +19,236 @@ export const reducePathDescriptorToPath = async (pathDescriptorArg: interfaces.I
|
||||
returnPath = returnPath.substring(1);
|
||||
}
|
||||
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