import { inject, injectable } from 'inversify'; import * as stream from 'stream'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { Upload } from '@aws-sdk/lib-storage'; import { PutObjectCommand, DeleteObjectCommand, GetObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { CdmLogger } from '@cdm-logger/core'; import { AwsS3Config, AwsS3UploadedFileResponse, AwsS3UploadStream, IAwsS3Service, } from '@container-stack/file-info-core'; import { SERVER_TYPES, IGenerateKeyOptions, IIImageUploadOptions as IImageUploadOptions, IUploadFile as File, ImageFilterName, IFileImageFilter, } from 'common/server'; import { config as envConfig } from '../config'; @injectable() export class AWSS3Service implements IAwsS3Service { private s3: S3Client; private logger: CdmLogger.ILogger; public static readonly directoryName = 'uploads'; private readonly config: AwsS3Config; constructor( @inject(SERVER_TYPES.AwsConfig) config: AwsS3Config, @inject('Logger') logger: CdmLogger.ILogger, ) { this.s3 = new S3Client({ region: config.region || 'ca-central-1', credentials: { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey, }, }); this.config = config; this.logger = logger.child({ className: 'AWSS3Service' }); } get basePath(): string { const { config } = this; const { destinationBucketName, region } = config; return `https://${destinationBucketName}.s3.${region}.amazonaws.com`; } private static generateKey(options: IGenerateKeyOptions): string { const { ref, refType, userId, filename } = options; const uniqueName = `${userId}-${new Date().getTime()}`; const randomizeName = Array.from(uniqueName).reduce( (acc) => `${acc}${uniqueName.charAt(Math.floor(Math.random() * uniqueName.length))}`, '', ); const directoryPath = `${envConfig.APP_NAME}/${AWSS3Service.directoryName}/${refType}/${ref}`; const uniqueFilename = `${randomizeName}-${filename}`.replace(/\s+/g, '-'); return `${directoryPath}/${uniqueFilename}`.toLowerCase(); } async createSignedUrl(options: IGenerateKeyOptions): Promise { try { this.logger.trace('createSignedUrl with params (%j)', options); if (!options.userId || !options.filename) { throw new Error('userId and filename are required'); } const result = await getSignedUrl( // Type assertion needed due to AWS SDK v3 version compatibility issues this.s3 as unknown as Parameters[0], new PutObjectCommand({ Bucket: this.config.destinationBucketName, Key: AWSS3Service.generateKey(options), }), { expiresIn: envConfig.AWS_S3_PRE_SIGNED_URL_TTL, }, ); return result; } catch (error) { this.logger.error('Error creating signed URL: %o', error?.message); throw error; } } /** * Parse S3 URL to extract bucket and key * Supports multiple formats: * - https://bucket.s3.region.amazonaws.com/key * - https://s3.region.amazonaws.com/bucket/key * - s3://bucket/key */ public parseS3Url(url: string): { bucket: string; key: string } | null { try { // Handle s3:// format if (url.startsWith('s3://')) { const path = url.substring(5); // Remove 's3://' const firstSlashIndex = path.indexOf('/'); if (firstSlashIndex === -1) { return null; } return { bucket: path.substring(0, firstSlashIndex), key: path.substring(firstSlashIndex + 1), }; } // Handle https:// format if (url.startsWith('https://')) { const urlObj = new URL(url); const hostname = urlObj.hostname; // bucket.s3.region.amazonaws.com format if (hostname.includes('.s3.') && hostname.includes('.amazonaws.com')) { const bucket = hostname.split('.')[0]; const key = urlObj.pathname.substring(1); // Remove leading / return { bucket, key }; } // s3.region.amazonaws.com/bucket/key format if (hostname.startsWith('s3.') && hostname.includes('.amazonaws.com')) { const pathParts = urlObj.pathname.split('/').filter((p) => p); if (pathParts.length >= 2) { return { bucket: pathParts[0], key: pathParts.slice(1).join('/'), }; } } } return null; } catch (error) { this.logger.error('Error parsing S3 URL:', error); return null; } } /** * Create a pre-signed URL for downloading/reading a file from S3 * @param s3KeyOrUrl - Either an S3 key (for default bucket) or full S3 URL * @param expiresIn - URL expiration time in seconds (default: 300 = 5 minutes) * @param bucketName - Optional bucket name (if not using full URL) * @returns Pre-signed URL for downloading the file */ async createDownloadSignedUrl(s3KeyOrUrl: string, expiresIn: number = 300, bucketName?: string): Promise { try { this.logger.trace('createDownloadSignedUrl with params (%j)', { s3KeyOrUrl, expiresIn, bucketName }); if (!s3KeyOrUrl) { throw new Error('S3 key or URL is required'); } let bucket: string; let key: string; // Try to parse as URL first const parsed = this.parseS3Url(s3KeyOrUrl); if (parsed) { bucket = parsed.bucket; key = parsed.key; this.logger.debug('Parsed S3 URL - bucket: %s, key: %s', bucket, key); } else { // Treat as plain key bucket = bucketName || this.config.destinationBucketName; key = s3KeyOrUrl; this.logger.debug('Using key directly - bucket: %s, key: %s', bucket, key); } if (!bucket || !key) { throw new Error('Invalid S3 URL or key'); } // Security: Validate bucket access (optional but recommended) if (bucket !== this.config.destinationBucketName) { this.logger.warn( 'Accessing different bucket: %s (default: %s)', bucket, this.config.destinationBucketName, ); } const result = await getSignedUrl( this.s3 as unknown as Parameters[0], new GetObjectCommand({ Bucket: bucket, Key: key, }), { expiresIn, }, ); this.logger.debug('Successfully created download signed URL for bucket: %s, key: %s', bucket, key); return result; } catch (error) { this.logger.error('Error creating download signed URL: %o', error?.message); throw error; } } private async createUploadStream(key: string): Promise { const pass = new stream.PassThrough(); return { writeStream: pass, promise: new Upload({ client: this.s3, params: { Bucket: this.config.destinationBucketName, Key: key, Body: pass, }, }).done(), }; } public getKeyFromUrl(url: string): string { try { if (typeof url !== 'string') return url; const [before, after] = url.split(`${this.basePath}/`); return decodeURI(after ?? before); } catch { return url; } } async deleteFile(url: string): Promise { try { this.logger.trace('deleteFile with params (%j)', { url }); if (!url) { throw new Error('URL is required'); } await this.s3.send( new DeleteObjectCommand({ Bucket: this.config.destinationBucketName, Key: this.getKeyFromUrl(url), }), ); this.logger.debug('Successfully deleted file from S3: %s', url); return true; } catch (error) { this.logger.error('Error deleting file from S3: %o', error?.message); return false; } } async uploadFile(file: File, options: IImageUploadOptions): Promise { try { this.logger.trace('uploadFile with params (%j)', { filename: file.filename, options }); if (!file.filename || !file.createReadStream) { throw new Error('File must have filename and createReadStream'); } const { createReadStream, filename, ...rest } = file; const stream = createReadStream(); const key = AWSS3Service.generateKey({ ...options, filename }); const uploadStream = await this.createUploadStream(AWSS3Service.generateKey({ ...options, filename })); stream.pipe(uploadStream.writeStream); const result = await uploadStream.promise; // In newer AWS SDK versions, Location might not be available, so we just return the key // The calling code can construct the full URL if needed using basePath const fileUrl = result.Location ? this.getKeyFromUrl(result.Location) : key; this.logger.debug('Successfully uploaded file to S3: %s', fileUrl); return { ...rest, url: fileUrl, filename, encoding: 'base64' }; } catch (error) { this.logger.error('Error uploading file to S3: %o', error?.message); throw error; } } static getKeyFromEncodedUrl(url: string): string { const { pathname } = url.startsWith('http') ? new URL(url) : { pathname: url }; const encodedPathname = pathname.replace('/', ''); const payload = Buffer.from(encodedPathname, 'base64').toString(); const { key } = JSON.parse(payload); return key; } generateImageHandlerURL(imageKey: string, filters: IFileImageFilter): string { const baseURL = this.config.imageHandlerService?.replace(/^(.+?)\/*?$/, '$1'); // const { pathname } = URL.canParse(imageKey) ? new URL(imageKey) : { pathname: imageKey }; let pathname; try { // Attempt to parse the imageKey as a URL const url = new URL(imageKey); pathname = url.pathname; } catch (error) { // If parsing fails, treat imageKey as a pathname directly pathname = imageKey; } const filtersWithOutValue = [ ImageFilterName.AutoJpg, ImageFilterName.Equalize, ImageFilterName.Grayscale, ImageFilterName.NoUpscale, ImageFilterName.Stretch, ImageFilterName.StripExif, ImageFilterName.StripICC, ImageFilterName.Upscale, ]; const filtersWithValue = [ ImageFilterName.BackgroundColor, ImageFilterName.ColorFill, ImageFilterName.Blur, ImageFilterName.Quality, ImageFilterName.Rotate, ImageFilterName.Convolution, ImageFilterName.RGB, ImageFilterName.ImageFormat, ImageFilterName.Proportion, ImageFilterName.Sharpen, ImageFilterName.Watermark, ]; const filterStrings: string[] = Object.entries(filters).map(([name, value]: [ImageFilterName, string]) => { if (filtersWithOutValue.includes(name)) { return `/filters:${name}()`; } if (filtersWithValue.includes(name)) { return `/filters:${name}(${value})`; } if (name === ImageFilterName.Crop) { return `/${value}`; } if (name === ImageFilterName.Resize) { return `/${name}/${value}`; } return ''; }); const filterString: string = filterStrings.join(''); return `${baseURL}${filterString}${pathname}`; } getProcessedFile(url: string, edits: Record): string { // remove trailing slash const serviceAddress = this.config.imageProcessingService?.replace(/^(.+?)\/*?$/, '$1'); const payload = JSON.stringify({ bucket: this.config.destinationBucketName, key: this.getKeyFromUrl(url), edits, }); const payloadStr = Buffer.from(payload).toString('base64'); return `${serviceAddress}/${payloadStr}`; } getKeyFromResizedUrl(url: string): string { if (!url.startsWith(this.config.imageProcessingService)) { return url; } const encodedUrl = new URL(url).pathname.replace(/^\/+/, ''); const decoded = Buffer.from(encodedUrl, 'base64').toString('ascii'); const { key } = JSON.parse(decoded); return key; } }