import * as plugins from './plugins.js'; import * as interfaces from './interfaces.js'; export const reducePathDescriptorToPath = async (pathDescriptorArg: interfaces.IPathDecriptor): Promise => { let returnPath = `` if (pathDescriptorArg.directory) { if (pathDescriptorArg.path && plugins.path.isAbsolute(pathDescriptorArg.path)) { console.warn('Directory is being ignored when path is absolute.'); returnPath = pathDescriptorArg.path; } else if (pathDescriptorArg.path) { returnPath = plugins.path.join(pathDescriptorArg.directory.getBasePath(), pathDescriptorArg.path); } } else if (pathDescriptorArg.path) { returnPath = pathDescriptorArg.path; } else { throw new Error('You must specify either a path or a directory.'); } if (returnPath.startsWith('/')) { 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, }; }