Skip to content

Commit 1fda046

Browse files
committed
Refactor storage services: remove deprecated FileStorageService and ImageStorageService, introduce new S3StorageProvider, FileMetadataService, and BucketConfigService for improved file handling and metadata management. Add exception handling for storage operations and integrate AWS SDK for S3 request presigning. Update pnpm-lock.yaml to include new dependencies.
1 parent 3a63d2f commit 1fda046

15 files changed

+605
-279
lines changed

apps/api/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
},
2828
"dependencies": {
2929
"@aws-sdk/client-s3": "^3.940.0",
30+
"@aws-sdk/s3-request-presigner": "^3.940.0",
3031
"@itgorillaz/configify": "^4.0.1",
3132
"@keyv/redis": "^5.1.4",
3233
"@keyv/valkey": "^1.0.11",
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { HttpException, HttpStatus } from '@nestjs/common';
2+
3+
export class StorageException extends HttpException {
4+
constructor(message: string, statusCode: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR) {
5+
super(message, statusCode);
6+
}
7+
}
8+
9+
export class FileNotFoundException extends StorageException {
10+
constructor(key: string) {
11+
super(`File not found: ${key}`, HttpStatus.NOT_FOUND);
12+
}
13+
}
14+
15+
export class StorageOperationException extends StorageException {
16+
constructor(operation: string, cause?: Error) {
17+
super(
18+
`Storage operation failed: ${operation}${cause ? ` - ${cause.message}` : ''}`,
19+
HttpStatus.INTERNAL_SERVER_ERROR,
20+
);
21+
}
22+
}
23+

apps/api/src/storage/file-storage.service.ts

Lines changed: 0 additions & 15 deletions
This file was deleted.

apps/api/src/storage/image-storage.service.ts

Lines changed: 0 additions & 36 deletions
This file was deleted.
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { Injectable } from '@nestjs/common';
2+
import {
3+
S3Client,
4+
PutObjectCommand,
5+
GetObjectCommand,
6+
DeleteObjectCommand,
7+
HeadObjectCommand,
8+
} from '@aws-sdk/client-s3';
9+
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
10+
import { Readable } from 'stream';
11+
import { StorageConfig } from 'src/common/config/storage.config';
12+
13+
export interface UploadParams {
14+
key: string;
15+
body: Buffer | Readable;
16+
contentType?: string;
17+
metadata?: Record<string, string>;
18+
}
19+
20+
export interface UploadResult {
21+
key: string;
22+
url?: string;
23+
etag?: string;
24+
}
25+
26+
export interface UrlOptions {
27+
expiresIn?: number; // seconds
28+
}
29+
30+
@Injectable()
31+
export class S3StorageProvider {
32+
private readonly client: S3Client;
33+
private readonly bucket: string;
34+
35+
constructor(private readonly storageConfig: StorageConfig) {
36+
this.bucket = 'public'; // Could be configurable
37+
this.client = new S3Client({
38+
endpoint: this.storageConfig.url,
39+
region: this.storageConfig.region,
40+
credentials: {
41+
accessKeyId: this.storageConfig.accessKey,
42+
secretAccessKey: this.storageConfig.secretKey,
43+
},
44+
forcePathStyle: true, // Required for LocalStack and S3-compatible services
45+
});
46+
}
47+
48+
async upload(params: UploadParams): Promise<UploadResult> {
49+
const command = new PutObjectCommand({
50+
Bucket: this.bucket,
51+
Key: params.key,
52+
Body: params.body,
53+
ContentType: params.contentType,
54+
Metadata: params.metadata,
55+
});
56+
57+
const result = await this.client.send(command);
58+
59+
// Generate URL for the uploaded file
60+
const url = await this.getUrl(params.key);
61+
62+
return {
63+
key: params.key,
64+
url,
65+
etag: result.ETag,
66+
};
67+
}
68+
69+
async download(key: string): Promise<Buffer> {
70+
const command = new GetObjectCommand({
71+
Bucket: this.bucket,
72+
Key: key,
73+
});
74+
75+
const response = await this.client.send(command);
76+
77+
if (!response.Body) {
78+
throw new Error(`File not found: ${key}`);
79+
}
80+
81+
// Convert stream to buffer
82+
const chunks: Uint8Array[] = [];
83+
const stream = response.Body as Readable;
84+
85+
return new Promise((resolve, reject) => {
86+
stream.on('data', (chunk) => chunks.push(chunk));
87+
stream.on('end', () => resolve(Buffer.concat(chunks)));
88+
stream.on('error', reject);
89+
});
90+
}
91+
92+
async delete(key: string): Promise<void> {
93+
const command = new DeleteObjectCommand({
94+
Bucket: this.bucket,
95+
Key: key,
96+
});
97+
98+
await this.client.send(command);
99+
}
100+
101+
async exists(key: string): Promise<boolean> {
102+
try {
103+
const command = new HeadObjectCommand({
104+
Bucket: this.bucket,
105+
Key: key,
106+
});
107+
108+
await this.client.send(command);
109+
return true;
110+
} catch (error: any) {
111+
if (error.name === 'NotFound' || error.$metadata?.httpStatusCode === 404) {
112+
return false;
113+
}
114+
throw error;
115+
}
116+
}
117+
118+
async getUrl(key: string, options?: UrlOptions): Promise<string> {
119+
const command = new GetObjectCommand({
120+
Bucket: this.bucket,
121+
Key: key,
122+
});
123+
124+
// Generate presigned URL if expiration is specified, otherwise public URL
125+
if (options?.expiresIn) {
126+
return getSignedUrl(this.client, command, {
127+
expiresIn: options.expiresIn,
128+
});
129+
}
130+
131+
// For public buckets, return direct URL
132+
// Format: http://endpoint/bucket/key
133+
const endpoint = this.storageConfig.url.replace(/\/$/, '');
134+
return `${endpoint}/${this.bucket}/${key}`;
135+
}
136+
137+
async stream(key: string): Promise<Readable> {
138+
const command = new GetObjectCommand({
139+
Bucket: this.bucket,
140+
Key: key,
141+
});
142+
143+
const response = await this.client.send(command);
144+
145+
if (!response.Body) {
146+
throw new Error(`File not found: ${key}`);
147+
}
148+
149+
return response.Body as Readable;
150+
}
151+
}
152+

apps/api/src/storage/repositories/files-read.repository.ts

Lines changed: 0 additions & 31 deletions
This file was deleted.

apps/api/src/storage/repositories/files-write.repository.ts

Lines changed: 0 additions & 26 deletions
This file was deleted.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
2+
import {
3+
S3Client,
4+
CreateBucketCommand,
5+
HeadBucketCommand,
6+
PutBucketPolicyCommand,
7+
} from '@aws-sdk/client-s3';
8+
import { StorageConfig } from 'src/common/config/storage.config';
9+
10+
@Injectable()
11+
export class BucketConfigService implements OnModuleInit {
12+
private readonly logger = new Logger(BucketConfigService.name);
13+
private readonly client: S3Client;
14+
private readonly bucket = 'public';
15+
16+
constructor(private readonly storageConfig: StorageConfig) {
17+
this.client = new S3Client({
18+
endpoint: this.storageConfig.url,
19+
region: this.storageConfig.region,
20+
credentials: {
21+
accessKeyId: this.storageConfig.accessKey,
22+
secretAccessKey: this.storageConfig.secretKey,
23+
},
24+
forcePathStyle: true,
25+
});
26+
}
27+
28+
async onModuleInit() {
29+
await this.ensureBucketExists();
30+
await this.ensureBucketPolicy();
31+
}
32+
33+
private async ensureBucketExists() {
34+
try {
35+
// Check if bucket exists
36+
await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }));
37+
this.logger.log(`Bucket "${this.bucket}" already exists`);
38+
} catch (error: any) {
39+
// Bucket doesn't exist, create it
40+
if (
41+
error.name === 'NotFound' ||
42+
error.$metadata?.httpStatusCode === 404
43+
) {
44+
try {
45+
await this.client.send(
46+
new CreateBucketCommand({ Bucket: this.bucket }),
47+
);
48+
this.logger.log(`Created bucket "${this.bucket}"`);
49+
} catch (createError) {
50+
this.logger.error(
51+
`Failed to create bucket "${this.bucket}":`,
52+
createError,
53+
);
54+
throw createError;
55+
}
56+
} else {
57+
this.logger.error(`Failed to check bucket "${this.bucket}":`, error);
58+
throw error;
59+
}
60+
}
61+
}
62+
63+
private async ensureBucketPolicy() {
64+
try {
65+
const policy = {
66+
Version: '2012-10-17',
67+
Statement: [
68+
{
69+
Sid: 'PublicReadGetObject',
70+
Effect: 'Allow',
71+
Principal: '*',
72+
Action: ['s3:GetObject'],
73+
Resource: [`arn:aws:s3:::${this.bucket}/*`],
74+
},
75+
],
76+
};
77+
78+
await this.client.send(
79+
new PutBucketPolicyCommand({
80+
Bucket: this.bucket,
81+
Policy: JSON.stringify(policy),
82+
}),
83+
);
84+
85+
this.logger.log(
86+
`Bucket policy set for "${this.bucket}" (public read access)`,
87+
);
88+
} catch (error) {
89+
this.logger.warn(
90+
`Failed to set bucket policy for "${this.bucket}":`,
91+
error,
92+
);
93+
// Don't throw - policy setting is not critical for basic functionality
94+
}
95+
}
96+
}

0 commit comments

Comments
 (0)