Files
FlockPal/backend/src/storage/s3Client.ts
T
2026-05-02 10:24:58 -04:00

162 lines
5.5 KiB
TypeScript

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<string, string> = {
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<string, string> = {
'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)}` : ''}`);
}
};