11import { Injectable , Logger } from '@nestjs/common' ;
22import path from 'path' ;
3- import { writeFileSync , readFileSync , unlink , mkdirSync , existsSync } from 'fs' ;
3+ import {
4+ writeFileSync ,
5+ readFileSync ,
6+ unlink ,
7+ mkdirSync ,
8+ existsSync ,
9+ createWriteStream ,
10+ unlinkSync ,
11+ statSync ,
12+ readdir ,
13+ } from 'fs' ;
414import { PNG , PNGWithMetadata } from 'pngjs' ;
515
16+ import { S3Client , PutObjectCommand , DeleteObjectCommand , GetObjectCommand } from '@aws-sdk/client-s3' ;
17+ import { Readable } from 'stream' ;
18+
619export const IMAGE_PATH = 'imageUploads/' ;
720
821@Injectable ( )
922export class StaticService {
1023 private readonly logger : Logger = new Logger ( StaticService . name ) ;
1124
25+ private readonly USE_AWS_S3_BUCKET = process . env . USE_AWS_S3_BUCKET ?. trim ( ) . toLowerCase ( ) ;
26+ private readonly AWS_ACCESS_KEY_ID = process . env . AWS_ACCESS_KEY_ID ;
27+ private readonly AWS_SECRET_ACCESS_KEY = process . env . AWS_SECRET_ACCESS_KEY ;
28+ private readonly AWS_REGION = process . env . AWS_REGION ;
29+ private readonly AWS_S3_BUCKET_NAME = process . env . AWS_S3_BUCKET_NAME ;
30+ private readonly MAX_DISK_USAGE : number = parseInt ( process . env . MAX_TEMP_STORAGE_FOR_S3_DOWNLOAD ) * 1 * 1024 * 1024 ;
31+ private s3Client : S3Client ;
32+
33+ private readonly DELETE_INTERVAL = 60 * 60 * 1000 ; // Check for deletions every hour
34+ private deletionQueue : { filePath : string ; size : number } [ ] = [ ] ;
35+
36+ constructor ( ) {
37+ setInterval ( ( ) => this . cleanupQueuedFiles ( ) , this . DELETE_INTERVAL ) ;
38+ this . cleanUpAllFiles ( ) ;
39+ }
40+
41+ getEnvBoolean ( value : string , defaultValue : boolean = false ) : boolean {
42+ if ( value === 'true' || value === '1' ) return true ;
43+ if ( value === 'false' || value === '0' ) return false ;
44+ return defaultValue ;
45+ }
46+
47+ isAWSDefined ( ) : boolean {
48+ const areAWSVariablesValid =
49+ this . getEnvBoolean ( this . USE_AWS_S3_BUCKET ) &&
50+ this . AWS_ACCESS_KEY_ID ?. trim ( ) . length > 1 &&
51+ this . AWS_SECRET_ACCESS_KEY ?. trim ( ) . length > 1 &&
52+ this . AWS_S3_BUCKET_NAME ?. trim ( ) . length > 1 ;
53+
54+ if ( areAWSVariablesValid && ! this . s3Client ) {
55+ this . s3Client = new S3Client ( {
56+ credentials : {
57+ accessKeyId : this . AWS_ACCESS_KEY_ID ,
58+ secretAccessKey : this . AWS_SECRET_ACCESS_KEY ,
59+ } ,
60+ region : this . AWS_REGION ,
61+ } ) ;
62+ this . logger . log ( 'Using AWS S3 bucket.' ) ;
63+ }
64+ return areAWSVariablesValid ;
65+ }
66+
1267 generateNewImage ( type : 'screenshot' | 'diff' | 'baseline' ) : { imageName : string ; imagePath : string } {
1368 const imageName = `${ Date . now ( ) } .${ type } .png` ;
1469 return {
@@ -22,37 +77,148 @@ export class StaticService {
2277 return path . resolve ( IMAGE_PATH , imageName ) ;
2378 }
2479
25- saveImage ( type : 'screenshot' | 'diff' | 'baseline' , imageBuffer : Buffer ) : string {
80+ async saveImage ( type : 'screenshot' | 'diff' | 'baseline' , imageBuffer : Buffer ) : Promise < string > {
81+ const { imageName, imagePath } = this . generateNewImage ( type ) ;
2682 try {
27- new PNG ( ) . parse ( imageBuffer ) ;
83+ if ( this . isAWSDefined ( ) ) {
84+ await this . s3Client . send (
85+ new PutObjectCommand ( {
86+ Bucket : this . AWS_S3_BUCKET_NAME ,
87+ Key : imageName ,
88+ ContentType : 'image/png' ,
89+ Body : imageBuffer ,
90+ } )
91+ ) ;
92+ return imageName ;
93+ } else {
94+ new PNG ( ) . parse ( imageBuffer ) ;
95+ writeFileSync ( imagePath , imageBuffer ) ;
96+ return imageName ;
97+ }
2898 } catch ( ex ) {
29- throw new Error ( 'Cannot parse image as PNG file' ) ;
99+ throw new Error ( 'Cannot parse image as PNG file: ' + ex ) ;
30100 }
101+ }
31102
32- const { imageName, imagePath } = this . generateNewImage ( type ) ;
33- writeFileSync ( imagePath , imageBuffer ) ;
34- return imageName ;
103+ scheduleFileDeletion ( fileName : string ) : void {
104+ const filePath = this . getImagePath ( fileName ) ;
105+ const fileSize = statSync ( filePath ) . size ;
106+ this . deletionQueue . push ( { filePath, size : fileSize } ) ;
107+ this . logger . log ( `Scheduled deletion for file: ${ filePath } with size: ${ fileSize } bytes` ) ;
108+ }
109+
110+ checkDiskUsageAndClean ( ) : void {
111+ const totalDiskUsage = this . getTotalDiskUsage ( ) ;
112+ if ( totalDiskUsage > this . MAX_DISK_USAGE ) {
113+ this . logger . log ( `Disk usage exceeded. Triggering cleanup.` ) ;
114+ this . cleanupQueuedFiles ( ) ;
115+ }
116+ }
117+
118+ private getTotalDiskUsage ( ) : number {
119+ return this . deletionQueue . reduce ( ( total , file ) => total + file . size , 0 ) ;
35120 }
36121
37- getImage ( imageName : string ) : PNGWithMetadata {
38- if ( ! imageName ) return ;
122+ private cleanUpAllFiles ( ) {
123+ readdir ( path . resolve ( IMAGE_PATH ) , ( err , files ) => {
124+ if ( err ) throw err ;
125+ for ( const file of files ) {
126+ unlink ( path . join ( path . resolve ( IMAGE_PATH ) , file ) , ( err ) => {
127+ if ( err ) throw err ;
128+ } ) ;
129+ }
130+ } ) ;
131+ }
132+
133+ private cleanupQueuedFiles ( ) : void {
134+ const totalDiskUsage = this . getTotalDiskUsage ( ) ;
135+ this . logger . log ( `Cleaning up files. Total disk usage: ${ totalDiskUsage } bytes` ) ;
136+
137+ // Delete files until the total disk usage is within the limit
138+ let currentUsage = totalDiskUsage ;
139+
140+ if ( currentUsage > this . MAX_DISK_USAGE ) {
141+ // Sort files by the earliest (oldest) first for deletion
142+ this . deletionQueue . sort ( ( a , b ) => statSync ( a . filePath ) . birthtimeMs - statSync ( b . filePath ) . birthtimeMs ) ;
143+
144+ // Reduce the size by half
145+ while ( currentUsage > this . MAX_DISK_USAGE / 2 && this . deletionQueue . length > 0 ) {
146+ const fileToDelete = this . deletionQueue . shift ( ) ;
147+ if ( fileToDelete ) {
148+ try {
149+ unlinkSync ( fileToDelete . filePath ) ;
150+ currentUsage -= fileToDelete . size ;
151+ this . logger . log ( `Deleted file: ${ fileToDelete . filePath } ` ) ;
152+ } catch ( error ) {
153+ this . logger . log ( `Failed to delete file: ${ fileToDelete . filePath } . Error: ${ error . message } ` ) ;
154+ }
155+ }
156+ }
157+ }
158+ }
159+
160+ doesFileExist ( fileName : string ) {
161+ return existsSync ( this . getImagePath ( fileName ) ) ;
162+ }
163+
164+ async getImage ( fileName : string ) : Promise < PNGWithMetadata > {
165+ if ( ! fileName ) return null ;
39166 try {
40- return PNG . sync . read ( readFileSync ( this . getImagePath ( imageName ) ) ) ;
167+ if ( ! this . doesFileExist ( fileName ) && this . isAWSDefined ( ) ) {
168+ const localFileStream = await this . saveFileToServerFromS3 ( fileName ) ;
169+ await new Promise < void > ( ( resolve , reject ) => {
170+ localFileStream . on ( 'finish' , ( ) => {
171+ this . scheduleFileDeletion ( fileName ) ;
172+ this . checkDiskUsageAndClean ( ) ;
173+ resolve ( ) ;
174+ } ) ;
175+ localFileStream . on ( 'error' , ( error ) => {
176+ this . logger . error ( 'Error writing file:' , error ) ;
177+ reject ( error ) ;
178+ } ) ;
179+ } ) ;
180+ }
181+ return PNG . sync . read ( readFileSync ( this . getImagePath ( fileName ) ) ) ;
41182 } catch ( ex ) {
42- this . logger . error ( `Cannot get image: ${ imageName } . ${ ex } ` ) ;
183+ this . logger . error ( `Error from read : Cannot get image: ${ fileName } . ${ ex } ` ) ;
184+ }
185+ }
186+
187+ async saveFileToServerFromS3 ( fileName : string ) {
188+ if ( this . isAWSDefined ( ) ) {
189+ const command = new GetObjectCommand ( { Bucket : this . AWS_S3_BUCKET_NAME , Key : fileName } ) ;
190+ const s3Response = await this . s3Client . send ( command ) ;
191+ const fileStream = s3Response . Body as Readable ;
192+ const localFileStream = createWriteStream ( this . getImagePath ( fileName ) ) ;
193+ fileStream . pipe ( localFileStream ) ;
194+ this . logger . log ( `File saved locally at ${ this . getImagePath ( fileName ) } ` ) ;
195+ return localFileStream ;
196+ } else {
197+ throw Error ( 'Error connecting to AWS' ) ;
43198 }
44199 }
45200
46201 async deleteImage ( imageName : string ) : Promise < boolean > {
47- if ( ! imageName ) return ;
48- return new Promise ( ( resolvePromise ) => {
49- unlink ( this . getImagePath ( imageName ) , ( err ) => {
50- if ( err ) {
51- this . logger . error ( err ) ;
52- }
53- resolvePromise ( true ) ;
202+ if ( ! imageName ) return false ;
203+ if ( this . isAWSDefined ( ) ) {
204+ try {
205+ await this . s3Client . send ( new DeleteObjectCommand ( { Bucket : this . AWS_S3_BUCKET_NAME , Key : imageName } ) ) ;
206+ return true ;
207+ } catch ( error ) {
208+ this . logger . log ( `Failed to delete image ${ imageName } :` , error ) ;
209+ return false ; // Return `false` if an error occurs.
210+ }
211+ } else {
212+ return new Promise ( ( resolvePromise ) => {
213+ unlink ( this . getImagePath ( imageName ) , ( err ) => {
214+ if ( err ) {
215+ this . logger . error ( err ) ;
216+ resolvePromise ( false ) ;
217+ }
218+ resolvePromise ( true ) ;
219+ } ) ;
54220 } ) ;
55- } ) ;
221+ }
56222 }
57223
58224 private ensureDirectoryExistence ( dir : string ) {
0 commit comments