Akshar2325 commited on
Commit
eaa626f
·
1 Parent(s): 1d6d4ff

feat(garage-upload): add garage storage integration

Browse files

- implement garage storage service for file uploads and deletions
- provide presigned urls for secure and direct uploads
- allow file deletion by url
- configure environment variables for garage storage credentials
- integrate garage storage module into the main upload module
- define file type enum for organized storage paths

.env.example CHANGED
@@ -91,3 +91,8 @@ SEAWEEDFS_FILER_ENDPOINT="https://your-seaweedfs-instance.com/filer/buckets/your
91
  SEAWEEDFS_PUBLIC_URL="https://your-seaweedfs-instance.com/your-bucket"
92
  SEAWEEDFS_ACCESS_KEY="your-access-key"
93
  SEAWEEDFS_SECRET_KEY="your-secret-key"
 
 
 
 
 
 
91
  SEAWEEDFS_PUBLIC_URL="https://your-seaweedfs-instance.com/your-bucket"
92
  SEAWEEDFS_ACCESS_KEY="your-access-key"
93
  SEAWEEDFS_SECRET_KEY="your-secret-key"
94
+ # Garage Storage Credentials
95
+ GARAGE_ENDPOINT="https://your-garage-instance.com"
96
+ GARAGE_ACCESS_KEY="your-garage-access-key"
97
+ GARAGE_SECRET_KEY="your-garage-secret-key"
98
+ GARAGE_BUCKET="stremflix"
src/shared/modules/upload/garage-storage/enums/file-type.enum.ts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ export enum FileType {
2
+ IMAGE = 'IMAGE',
3
+ DOCUMENT = 'DOCUMENT',
4
+ VIDEO = 'VIDEO',
5
+ }
src/shared/modules/upload/garage-storage/garage-storage.config.ts ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import { registerAs } from '@nestjs/config';
2
+
3
+ export default registerAs('garageStorage', () => ({
4
+ endpoint: process.env.GARAGE_ENDPOINT,
5
+ accessKey: process.env.GARAGE_ACCESS_KEY,
6
+ secretKey: process.env.GARAGE_SECRET_KEY,
7
+ bucket: process.env.GARAGE_BUCKET || 'stremflix',
8
+ }));
src/shared/modules/upload/garage-storage/garage-storage.controller.ts ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ Controller,
3
+ Get,
4
+ Delete,
5
+ Query,
6
+ HttpCode,
7
+ HttpStatus,
8
+ } from '@nestjs/common';
9
+ import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger';
10
+ import { GarageStorageService } from './garage-storage.service';
11
+ import { FileType } from './enums/file-type.enum';
12
+
13
+ @ApiTags('Upload: Garage Storage')
14
+ @Controller('garage')
15
+ export class GarageStorageController {
16
+ constructor(private readonly garageStorageService: GarageStorageService) {}
17
+
18
+ @Get('presigned-upload')
19
+ @ApiOperation({ summary: 'Get presigned upload URL' })
20
+ @ApiQuery({ name: 'fileName', required: true })
21
+ @ApiQuery({ name: 'fileType', enum: FileType, required: false })
22
+ async getPresignedUploadUrl(
23
+ @Query('fileName') fileName: string,
24
+ @Query('fileType') fileType?: FileType,
25
+ ) {
26
+ return this.garageStorageService.getPresignedUploadUrl(fileName, fileType);
27
+ }
28
+
29
+ @Delete('by-url')
30
+ @HttpCode(HttpStatus.OK)
31
+ @ApiOperation({ summary: 'Delete file by URL' })
32
+ async deleteFileByUrl(@Query('url') url: string) {
33
+ const result = await this.garageStorageService.deleteFileByUrl(url);
34
+ return {
35
+ message: 'File deleted successfully',
36
+ ...result,
37
+ };
38
+ }
39
+ }
src/shared/modules/upload/garage-storage/garage-storage.module.ts ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Module } from '@nestjs/common';
2
+ import { ConfigModule } from '@nestjs/config';
3
+ import { GarageStorageController } from './garage-storage.controller';
4
+ import { GarageStorageService } from './garage-storage.service';
5
+ import garageStorageConfig from './garage-storage.config';
6
+
7
+ @Module({
8
+ imports: [ConfigModule.forFeature(garageStorageConfig)],
9
+ controllers: [GarageStorageController],
10
+ providers: [GarageStorageService],
11
+ exports: [GarageStorageService],
12
+ })
13
+ export class GarageStorageModule {}
src/shared/modules/upload/garage-storage/garage-storage.service.ts ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Injectable, Logger, NotFoundException } from '@nestjs/common';
2
+ import { ConfigService } from '@nestjs/config';
3
+ import {
4
+ S3Client,
5
+ PutObjectCommand,
6
+ DeleteObjectCommand,
7
+ } from '@aws-sdk/client-s3';
8
+ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
9
+
10
+ @Injectable()
11
+ export class GarageStorageService {
12
+ private readonly logger = new Logger(GarageStorageService.name);
13
+ private s3Client: S3Client;
14
+ private bucket: string;
15
+ private endpoint: string;
16
+
17
+ constructor(private configService: ConfigService) {
18
+ this.endpoint =
19
+ this.configService.get<string>('garageStorage.endpoint') || '';
20
+ const accessKeyId =
21
+ this.configService.get<string>('garageStorage.accessKey') || '';
22
+ const secretAccessKey =
23
+ this.configService.get<string>('garageStorage.secretKey') || '';
24
+ this.bucket =
25
+ this.configService.get<string>('garageStorage.bucket') || 'stremflix';
26
+
27
+ this.s3Client = new S3Client({
28
+ region: 'garage', // Garage doesn't use regions, but AWS SDK requires it
29
+ endpoint: this.endpoint,
30
+ credentials: {
31
+ accessKeyId,
32
+ secretAccessKey,
33
+ },
34
+ forcePathStyle: true, // Required for Garage
35
+ tls: true,
36
+ // Disable request checksums for Garage compatibility
37
+ requestChecksumCalculation: 'WHEN_REQUIRED',
38
+ });
39
+ }
40
+
41
+ /**
42
+ * Get folder path based on file type
43
+ * @param fileType - The file type (IMAGE, DOCUMENT, VIDEO)
44
+ * @returns Folder path for the file type
45
+ */
46
+ private getFolderPath(fileType: string): string {
47
+ const folderMap: Record<string, string> = {
48
+ IMAGE: 'images',
49
+ DOCUMENT: 'documents',
50
+ VIDEO: 'videos',
51
+ };
52
+ return folderMap[fileType.toUpperCase()] || 'others';
53
+ }
54
+
55
+ /**
56
+ * Generate presigned upload URL using S3
57
+ * @param fileName - The file name
58
+ * @param fileType - The file type (IMAGE, DOCUMENT, VIDEO)
59
+ * @returns Presigned upload URL and file path
60
+ */
61
+ async getPresignedUploadUrl(fileName: string, fileType?: string) {
62
+ try {
63
+ // Determine folder based on file type
64
+ const folder = fileType ? this.getFolderPath(fileType) : 'others';
65
+
66
+ // Create full file path: folder/timestamp-filename
67
+ const timestamp = Date.now();
68
+ const filePath = `${folder}/${timestamp}-${fileName}`;
69
+
70
+ // Create PutObject command
71
+ // Disable checksum for Garage compatibility
72
+ const command = new PutObjectCommand({
73
+ Bucket: this.bucket,
74
+ Key: filePath,
75
+ ChecksumAlgorithm: undefined, // Disable automatic checksums for Garage
76
+ });
77
+
78
+ // Generate presigned URL (valid for 1 hour)
79
+ // Disable checksums in presigned URL for Garage compatibility
80
+ const uploadUrl = await getSignedUrl(this.s3Client, command, {
81
+ expiresIn: 3600,
82
+ unhoistableHeaders: new Set(['x-amz-checksum-crc32']),
83
+ });
84
+
85
+ // Construct the public URL using Garage's public path format
86
+ const fileUrl = `${this.endpoint}/public/${this.bucket}/${filePath}`;
87
+
88
+ this.logger.log(
89
+ `Generated presigned upload URL for: ${filePath}${fileType ? ` (type: ${fileType})` : ''}`,
90
+ );
91
+
92
+ return {
93
+ uploadUrl,
94
+ fileUrl,
95
+ filePath,
96
+ expiresIn: 3600, // 1 hour in seconds
97
+ ...(fileType && { fileType }),
98
+ };
99
+ } catch (error) {
100
+ this.logger.error('Failed to generate presigned upload URL', error);
101
+ throw error;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Delete a file from Garage Storage using S3
107
+ * @param filePath - The file path in the bucket
108
+ * @returns Deletion result
109
+ */
110
+ async deleteFile(filePath: string): Promise<any> {
111
+ try {
112
+ const command = new DeleteObjectCommand({
113
+ Bucket: this.bucket,
114
+ Key: filePath,
115
+ });
116
+
117
+ const result = await this.s3Client.send(command);
118
+
119
+ this.logger.log(`Successfully deleted file: ${filePath}`);
120
+ return {
121
+ success: true,
122
+ deletedPath: filePath,
123
+ result,
124
+ };
125
+ } catch (error) {
126
+ this.logger.error(`Failed to delete file: ${filePath}`, error);
127
+ throw new NotFoundException('File not found or already deleted');
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Delete a file by URL
133
+ * @param url - The Garage Storage file URL
134
+ * @returns Deletion result
135
+ */
136
+ async deleteFileByUrl(url: string): Promise<any> {
137
+ try {
138
+ // Extract file path from URL
139
+ // Example: https://lucifer925-garage-storage.hf.space/public/stremflix/images/1234567890-file.jpg
140
+ // Extract: images/1234567890-file.jpg
141
+ const filePath = this.extractFilePathFromUrl(url);
142
+
143
+ if (!filePath) {
144
+ throw new NotFoundException('Could not extract file path from URL');
145
+ }
146
+
147
+ const result = await this.deleteFile(filePath);
148
+
149
+ this.logger.log(`Successfully deleted file from URL: ${url}`);
150
+
151
+ return {
152
+ ...result,
153
+ deletedUrl: url,
154
+ };
155
+ } catch (error) {
156
+ if (error instanceof NotFoundException) {
157
+ throw error;
158
+ }
159
+ this.logger.error(`Failed to delete file by URL: ${url}`, error);
160
+ throw new NotFoundException('File not found or already deleted');
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Extract file path from Garage Storage URL
166
+ * @param url - The Garage Storage URL
167
+ * @returns File path
168
+ */
169
+ private extractFilePathFromUrl(url: string): string | null {
170
+ try {
171
+ // Match pattern: /public/{bucket}/{path}
172
+ const match = url.match(/\/public\/[^/]+\/(.+)$/);
173
+ return match ? match[1] : null;
174
+ } catch {
175
+ return null;
176
+ }
177
+ }
178
+ }
src/shared/modules/upload/upload.module.ts CHANGED
@@ -7,6 +7,7 @@ import { NewMinioStorageModule } from './new-minio-storage/new-minio-storage.mod
7
  import { OldMinioStorageModule } from './old-minio-storage/old-minio-storage.module';
8
  import { SeaweedfsStorageModule } from './seaweedfs-storage/seaweedfs-storage.module';
9
  import { FilestackModule } from './filestack/filestack.module';
 
10
 
11
  @Module({
12
  imports: [
@@ -16,6 +17,7 @@ import { FilestackModule } from './filestack/filestack.module';
16
  BackblazeStorageModule,
17
  NewMinioStorageModule,
18
  OldMinioStorageModule,
 
19
  SeaweedfsStorageModule,
20
  FilestackModule,
21
  ],
 
7
  import { OldMinioStorageModule } from './old-minio-storage/old-minio-storage.module';
8
  import { SeaweedfsStorageModule } from './seaweedfs-storage/seaweedfs-storage.module';
9
  import { FilestackModule } from './filestack/filestack.module';
10
+ import { GarageStorageModule } from './garage-storage/garage-storage.module';
11
 
12
  @Module({
13
  imports: [
 
17
  BackblazeStorageModule,
18
  NewMinioStorageModule,
19
  OldMinioStorageModule,
20
+ GarageStorageModule,
21
  SeaweedfsStorageModule,
22
  FilestackModule,
23
  ],