Skip to content

Commit b382aa2

Browse files
authored
Enable AWS S3 image storage : Issue #208 (#320)
1 parent f1be51f commit b382aa2

File tree

12 files changed

+3113
-164
lines changed

12 files changed

+3113
-164
lines changed

.env

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ POSTGRES_DB=vrt_db_dev
1414

1515
# static
1616
STATIC_SERVICE=hdd # hdd | s3 - hdd as default if not provided
17-
18-
# AWS_ACCESS_KEY_ID=
19-
# AWS_SECRET_ACCESS_KEY=
20-
# AWS_REGION=
21-
# AWS_S3_BUCKET_NAME=
17+
# Enter below values if STATIC_SERVICE=s3
18+
AWS_ACCESS_KEY_ID=
19+
AWS_SECRET_ACCESS_KEY=
20+
AWS_REGION=
21+
AWS_S3_BUCKET_NAME=
2222

2323
# optional
2424
#HTTPS_KEY_PATH='./secrets/ssl.key'

package-lock.json

Lines changed: 2999 additions & 124 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
"node": ">=18.12.0"
2727
},
2828
"dependencies": {
29+
"@aws-sdk/client-s3": "^3.717.0",
30+
"@aws-sdk/s3-request-presigner": "^3.717.0",
2931
"@nestjs/cache-manager": "^2.1.0",
3032
"@nestjs/common": "^10.2.5",
3133
"@nestjs/config": "^3.1.1",

src/compare/libs/odiff/odiff.service.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ export class OdiffService implements ImageComparator {
2626

2727
constructor(private staticService: StaticService) {
2828
if (!isHddStaticServiceConfigured()) {
29-
throw new Error('OdiffService can only be used with HddService');
29+
return undefined;
30+
// If we throw an exception, the application does not start.
31+
// throw new Error('OdiffService can only be used with HddService');
3032
}
3133
this.hddService = this.staticService as unknown as HddService;
3234
}

src/main.ts

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import { readFileSync, existsSync } from 'fs';
88
import { HttpsOptions } from '@nestjs/common/interfaces/external/https-options.interface';
99
import { NestExpressApplication } from '@nestjs/platform-express';
1010
import { HDD_IMAGE_PATH } from './static/hdd/constants';
11-
import { isHddStaticServiceConfigured } from './static/utils';
1211

1312
function getHttpsOptions(): HttpsOptions | null {
1413
const keyPath = './secrets/ssl.key';
@@ -35,16 +34,13 @@ async function bootstrap() {
3534
app.use(bodyParser.json({ limit: process.env.BODY_PARSER_JSON_LIMIT }));
3635
}
3736

38-
// serve images only if hdd configuration
39-
if (isHddStaticServiceConfigured()) {
40-
app.useStaticAssets(join(process.cwd(), HDD_IMAGE_PATH), {
41-
maxAge: 31536000,
42-
// allow cors
43-
setHeaders: (res) => {
44-
res.set('Access-Control-Allow-Origin', '*');
45-
},
46-
});
47-
}
37+
app.useStaticAssets(join(process.cwd(), HDD_IMAGE_PATH), {
38+
maxAge: 31536000,
39+
// allow cors
40+
setHeaders: (res) => {
41+
res.set('Access-Control-Allow-Origin', '*');
42+
},
43+
});
4844

4945
await app.listen(process.env.APP_PORT || 3000);
5046
}

src/static/aws/s3.service.ts

Lines changed: 59 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,75 @@
1-
import { PNGWithMetadata } from 'pngjs';
1+
import { PNG, PNGWithMetadata } from 'pngjs';
22
import { Logger } from '@nestjs/common';
33
import { Static } from '../static.interface';
4-
// import { S3Client } from '@aws-sdk/client-s3';
4+
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
5+
import { Readable } from 'stream';
6+
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
57

68
export class AWSS3Service implements Static {
79
private readonly logger: Logger = new Logger(AWSS3Service.name);
8-
// private readonly AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID;
9-
// private readonly AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY;
10-
// private readonly AWS_REGION = process.env.AWS_REGION;
11-
// private readonly AWS_S3_BUCKET_NAME = process.env.AWS_S3_BUCKET_NAME;
10+
private readonly AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID;
11+
private readonly AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY;
12+
private readonly AWS_REGION = process.env.AWS_REGION;
13+
private readonly AWS_S3_BUCKET_NAME = process.env.AWS_S3_BUCKET_NAME;
1214

13-
// private s3Client: S3Client;
15+
private s3Client: S3Client;
1416

1517
constructor() {
16-
// this.s3Client = new S3Client({
17-
// credentials: {
18-
// accessKeyId: this.AWS_ACCESS_KEY_ID,
19-
// secretAccessKey: this.AWS_SECRET_ACCESS_KEY,
20-
// },
21-
// region: this.AWS_REGION,
22-
// });
18+
this.s3Client = new S3Client({
19+
credentials: {
20+
accessKeyId: this.AWS_ACCESS_KEY_ID,
21+
secretAccessKey: this.AWS_SECRET_ACCESS_KEY,
22+
},
23+
region: this.AWS_REGION,
24+
});
2325
this.logger.log('AWS S3 service is being used for file storage.');
2426
}
25-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
26-
saveImage(type: 'screenshot' | 'diff' | 'baseline', imageBuffer: Buffer): Promise<string> {
27-
throw new Error('Method not implemented.');
27+
28+
async saveImage(type: 'screenshot' | 'diff' | 'baseline', imageBuffer: Buffer): Promise<string> {
29+
const imageName = `${Date.now()}.${type}.png`;
30+
try {
31+
await this.s3Client.send(
32+
new PutObjectCommand({
33+
Bucket: this.AWS_S3_BUCKET_NAME,
34+
Key: imageName,
35+
ContentType: 'image/png',
36+
Body: imageBuffer,
37+
})
38+
);
39+
return imageName;
40+
} catch (ex) {
41+
throw new Error('Could not save file at AWS S3 : ' + ex);
42+
}
43+
}
44+
45+
async getImage(fileName: string): Promise<PNGWithMetadata> {
46+
if (!fileName) return null;
47+
try {
48+
const command = new GetObjectCommand({ Bucket: this.AWS_S3_BUCKET_NAME, Key: fileName });
49+
const s3Response = await this.s3Client.send(command);
50+
const stream = s3Response.Body as Readable;
51+
return PNG.sync.read(Buffer.concat(await stream.toArray()));
52+
} catch (ex) {
53+
this.logger.error(`Error from read : Cannot get image: ${fileName}. ${ex}`);
54+
}
2855
}
2956

30-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
31-
getImage(fileName: string): Promise<PNGWithMetadata> {
32-
throw new Error('Method not implemented.');
57+
async getImageUrl(imageName: string): Promise<string> {
58+
const command = new GetObjectCommand({
59+
Bucket: `${this.AWS_S3_BUCKET_NAME}`,
60+
Key: imageName,
61+
});
62+
return getSignedUrl(this.s3Client, command, { expiresIn: 3600 });
3363
}
3464

35-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
36-
deleteImage(imageName: string): Promise<boolean> {
37-
throw new Error('Method not implemented.');
65+
async deleteImage(imageName: string): Promise<boolean> {
66+
if (!imageName) return false;
67+
try {
68+
await this.s3Client.send(new DeleteObjectCommand({ Bucket: this.AWS_S3_BUCKET_NAME, Key: imageName }));
69+
return true;
70+
} catch (error) {
71+
this.logger.log(`Failed to delete file at AWS S3 for image ${imageName}:`, error);
72+
return false;
73+
}
3874
}
3975
}

src/static/hdd/hdd.service.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ export class HddService implements Static {
2121
return path.resolve(HDD_IMAGE_PATH, imageName);
2222
}
2323

24+
getImageUrl(imageName: string): Promise<string> {
25+
return Promise.resolve('/' + imageName);
26+
}
27+
2428
async saveImage(type: 'screenshot' | 'diff' | 'baseline', imageBuffer: Buffer): Promise<string> {
2529
try {
2630
new PNG().parse(imageBuffer);

src/static/static.controller.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Controller, Get, Logger, Param, Res } from '@nestjs/common';
2+
import { Response } from 'express';
3+
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
4+
import { StaticService } from './static.service';
5+
6+
@ApiTags('images')
7+
@Controller('images')
8+
export class StaticController {
9+
private readonly logger: Logger = new Logger(StaticController.name);
10+
constructor(private staticService: StaticService) {}
11+
12+
@Get('/:fileName')
13+
@ApiOkResponse()
14+
async getUrlAndRedirect(@Param('fileName') fileName: string, @Res() res: Response) {
15+
try {
16+
const url = await this.staticService.getImageUrl(fileName);
17+
res.redirect(url);
18+
} catch (error) {
19+
this.logger.error('Error fetching file from S3:' + fileName, error);
20+
res.status(500).send('Error occurred while getting the file.');
21+
}
22+
}
23+
}

src/static/static.interface.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ export interface Static {
44
saveImage(type: 'screenshot' | 'diff' | 'baseline', imageBuffer: Buffer): Promise<string>;
55
getImage(fileName: string): Promise<PNGWithMetadata>;
66
deleteImage(imageName: string): Promise<boolean>;
7+
getImageUrl(imageName: string): Promise<string>;
78
}

src/static/static.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import { Module } from '@nestjs/common';
22
import { ConfigModule } from '@nestjs/config';
33
import { StaticService } from './static.service';
44
import { StaticFactoryService } from './static.factory';
5+
import { StaticController } from './static.controller';
56

67
@Module({
78
imports: [ConfigModule],
89
providers: [StaticService, StaticFactoryService],
910
exports: [StaticService],
11+
controllers: [StaticController],
1012
})
1113
export class StaticModule {}

0 commit comments

Comments
 (0)