import crypto from 'crypto'; import type { S3ImageStorageConfig } from './imageStorageConfig.js'; const awsDate = (date: Date) => date.toISOString().replace(/[:-]|\.\d{3}/g, ''); const shortDate = (date: Date) => date.toISOString().slice(0, 10).replace(/-/g, ''); const hmac = (key: crypto.BinaryLike, value: string) => crypto.createHmac('sha256', key).update(value).digest(); const sha256Hex = (value: crypto.BinaryLike) => crypto.createHash('sha256').update(value).digest('hex'); const encodeObjectKey = (key: string) => key.split('/').map(encodeURIComponent).join('/'); const encodeQueryValue = (value: string) => encodeURIComponent(value).replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`); const getSigningKey = (secretAccessKey: string, date: string, region: string) => { const dateKey = hmac(`AWS4${secretAccessKey}`, date); const regionKey = hmac(dateKey, region); const serviceKey = hmac(regionKey, 's3'); return hmac(serviceKey, 'aws4_request'); }; const buildObjectUrl = (config: S3ImageStorageConfig, objectKey: string) => { const endpoint = config.endpoint.replace(/\/+$/, ''); return new URL(`${endpoint}/${encodeURIComponent(config.bucket)}/${encodeObjectKey(objectKey)}`); }; const signS3Request = ({ config, method, objectKey, contentHash, contentType, now = new Date(), }: { config: S3ImageStorageConfig; method: 'DELETE' | 'PUT'; objectKey: string; contentHash: string; contentType?: string; now?: Date; }) => { const url = buildObjectUrl(config, objectKey); const amzDate = awsDate(now); const date = shortDate(now); const credentialScope = `${date}/${config.region}/s3/aws4_request`; const headers: Record = { host: url.host, 'x-amz-content-sha256': contentHash, 'x-amz-date': amzDate, }; if (contentType) { headers['content-type'] = contentType; } const signedHeaders = Object.keys(headers).sort().join(';'); const canonicalHeaders = Object.keys(headers) .sort() .map((key) => `${key}:${headers[key]}\n`) .join(''); const canonicalRequest = [method, url.pathname, url.search.slice(1), canonicalHeaders, signedHeaders, contentHash].join('\n'); const stringToSign = ['AWS4-HMAC-SHA256', amzDate, credentialScope, sha256Hex(canonicalRequest)].join('\n'); const signature = crypto.createHmac('sha256', getSigningKey(config.secretAccessKey, date, config.region)).update(stringToSign).digest('hex'); return { url, headers: { ...headers, Authorization: `AWS4-HMAC-SHA256 Credential=${config.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`, }, }; }; export const getPublicObjectUrl = (config: S3ImageStorageConfig, objectKey: string) => { if (config.publicBaseUrl) { return `${config.publicBaseUrl.replace(/\/+$/, '')}/${encodeObjectKey(objectKey)}`; } return buildObjectUrl(config, objectKey).toString(); }; export const getSignedS3ObjectUrl = ({ config, objectKey, expiresInSeconds = 900, now = new Date(), }: { config: S3ImageStorageConfig; objectKey: string; expiresInSeconds?: number; now?: Date; }) => { const url = buildObjectUrl(config, objectKey); const amzDate = awsDate(now); const date = shortDate(now); const credentialScope = `${date}/${config.region}/s3/aws4_request`; const credential = `${config.accessKeyId}/${credentialScope}`; const queryParams: Record = { 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256', 'X-Amz-Credential': credential, 'X-Amz-Date': amzDate, 'X-Amz-Expires': String(Math.min(Math.max(expiresInSeconds, 1), 604800)), 'X-Amz-SignedHeaders': 'host', }; const canonicalQuery = Object.keys(queryParams) .sort() .map((key) => `${encodeQueryValue(key)}=${encodeQueryValue(queryParams[key])}`) .join('&'); const canonicalHeaders = `host:${url.host}\n`; const canonicalRequest = ['GET', url.pathname, canonicalQuery, canonicalHeaders, 'host', 'UNSIGNED-PAYLOAD'].join('\n'); const stringToSign = ['AWS4-HMAC-SHA256', amzDate, credentialScope, sha256Hex(canonicalRequest)].join('\n'); const signature = crypto.createHmac('sha256', getSigningKey(config.secretAccessKey, date, config.region)).update(stringToSign).digest('hex'); url.search = `${canonicalQuery}&X-Amz-Signature=${signature}`; return url.toString(); }; export const putS3Object = async ({ config, objectKey, content, contentType, }: { config: S3ImageStorageConfig; objectKey: string; content: Buffer; contentType: string; }) => { const contentHash = sha256Hex(content); const signed = signS3Request({ config, method: 'PUT', objectKey, contentHash, contentType }); const response = await fetch(signed.url, { method: 'PUT', headers: signed.headers, body: content, }); if (!response.ok) { const errorText = await response.text().catch(() => ''); throw new Error(`Wasabi upload failed with ${response.status}${errorText ? `: ${errorText.slice(0, 300)}` : ''}`); } return getPublicObjectUrl(config, objectKey); }; export const deleteS3Object = async ({ config, objectKey, }: { config: S3ImageStorageConfig; objectKey: string; }) => { const signed = signS3Request({ config, method: 'DELETE', objectKey, contentHash: sha256Hex('') }); const response = await fetch(signed.url, { method: 'DELETE', headers: signed.headers, }); if (!response.ok && response.status !== 404) { const errorText = await response.text().catch(() => ''); throw new Error(`Wasabi delete failed with ${response.status}${errorText ? `: ${errorText.slice(0, 300)}` : ''}`); } };