Skip to content

Commit 2f00a18

Browse files
Merge pull request #140 from ykargeee-bit/feat/Profile
Feat(feature) File Upload
2 parents b537333 + 796123e commit 2f00a18

29 files changed

Lines changed: 5318 additions & 267 deletions

.env.example

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,3 +170,27 @@ OAUTH_GITHUB_CLIENT_SECRET=your_github_client_secret
170170
# Twitter/X OAuth
171171
OAUTH_TWITTER_CLIENT_ID=your_twitter_client_id
172172
OAUTH_TWITTER_CLIENT_SECRET=your_twitter_client_secret
173+
174+
# ── File Upload & Storage ───────────────────────────────────────────────────
175+
# Storage backend: local | s3 | azure_blob
176+
FILE_STORAGE_BACKEND=local
177+
FILE_MAX_SIZE_BYTES=52428800
178+
FILE_STORAGE_LOCAL_PATH=./uploads
179+
FILE_SCAN_ENABLED=true
180+
FILE_ENCRYPTION_ENABLED=false
181+
FILE_CLEANUP_ENABLED=true
182+
FILE_ORPHAN_RETENTION_DAYS=7
183+
FILE_EXPIRED_RETENTION_DAYS=1
184+
FILE_DEFAULT_EXPIRY_HOURS=720
185+
186+
# S3 Configuration (when FILE_STORAGE_BACKEND=s3)
187+
S3_BUCKET=alian-structure-files
188+
S3_REGION=us-east-1
189+
S3_ENDPOINT=
190+
S3_ACCESS_KEY_ID=
191+
S3_SECRET_ACCESS_KEY=
192+
S3_FORCE_PATH_STYLE=false
193+
194+
# Azure Blob Storage Configuration (when FILE_STORAGE_BACKEND=azure_blob)
195+
AZURE_STORAGE_CONNECTION_STRING=
196+
AZURE_STORAGE_CONTAINER=file-uploads

package-lock.json

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

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@
4444
"get-version": "node -p \"require('./package.json').version\""
4545
},
4646
"dependencies": {
47+
"@aws-sdk/client-s3": "^3.1115.0",
48+
"@aws-sdk/s3-request-presigner": "^3.1115.0",
49+
"@azure/storage-blob": "12.32.0",
4750
"@elastic/elasticsearch": "^9.4.2",
4851
"@graphql-typed-document-node/core": "^3.2.0",
4952
"@nestjs/axios": "^4.0.1",
@@ -107,6 +110,7 @@
107110
"rimraf": "^5.0.5",
108111
"rxjs": "^7.8.1",
109112
"serve-favicon": "^2.5.1",
113+
"sharp": "^0.35.3",
110114
"socket.io": "^4.8.3",
111115
"speakeasy": "^2.0.0",
112116
"swagger-ui-express": "^5.0.1",

src/app.module.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,14 @@ import { WebhookSubscription } from "./infrastructure/webhooks/entities/webhook-
104104
import { WebhookEvent } from "./infrastructure/webhooks/entities/webhook-event.entity";
105105
import { WebhookDelivery } from "./infrastructure/webhooks/entities/webhook-delivery.entity";
106106
import { WebhookDeadLetter } from "./infrastructure/webhooks/entities/webhook-dead-letter.entity";
107+
// File upload entities
108+
import { UploadedFile } from "./infrastructure/file-upload/entities/uploaded-file.entity";
109+
import { FileThumbnail } from "./infrastructure/file-upload/entities/file-thumbnail.entity";
110+
import { FileScanResult } from "./infrastructure/file-upload/entities/file-scan-result.entity";
107111
// Modules – webhooks
108112
import { WebhookModule } from "./infrastructure/webhooks/webhook.module";
113+
// Modules – file upload
114+
import { FileUploadModule } from "./infrastructure/file-upload/file-upload.module";
109115

110116
// Guards
111117
import { APP_FILTER } from "@nestjs/core";
@@ -196,6 +202,9 @@ import { GraphqlGatewayModule } from "./graphql/graphql.module";
196202
WebhookEvent,
197203
WebhookDelivery,
198204
WebhookDeadLetter,
205+
UploadedFile,
206+
FileThumbnail,
207+
FileScanResult,
199208
],
200209
synchronize: true,
201210
logging: true,
@@ -229,6 +238,7 @@ import { GraphqlGatewayModule } from "./graphql/graphql.module";
229238
AgentReviewsModule,
230239
GraphqlGatewayModule,
231240
WebhookModule,
241+
FileUploadModule,
232242
CacheModule,
233243
LoggerModule.forRootAsync({
234244
inject: [ConfigService],

src/config/env.validation.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,95 @@ export class EnvironmentVariables {
433433
@Transform(({ value }) => parseInt(value, 10) || 5)
434434
WEBHOOK_CONCURRENCY?: number = 5;
435435

436+
// ── File Upload & Storage ──────────────────────────────────────────
437+
438+
/** Storage backend: local | s3 | azure_blob. Default: local. */
439+
@IsOptional()
440+
@IsString()
441+
FILE_STORAGE_BACKEND?: string = "local";
442+
443+
/** Max upload size in bytes. Default 52428800 (50MB). */
444+
@IsOptional()
445+
@IsNumber()
446+
@Transform(({ value }) => parseInt(value, 10) || 52428800)
447+
FILE_MAX_SIZE_BYTES?: number = 52428800;
448+
449+
/** Local storage base path. Default: ./uploads */
450+
@IsOptional()
451+
@IsString()
452+
FILE_STORAGE_LOCAL_PATH?: string;
453+
454+
/** Enable virus scanning. Default true. */
455+
@IsOptional()
456+
@IsBoolean()
457+
@Transform(({ value }) => value !== "false")
458+
FILE_SCAN_ENABLED?: boolean = true;
459+
460+
/** Enable encryption at rest. Default false. */
461+
@IsOptional()
462+
@IsBoolean()
463+
@Transform(({ value }) => value === "true")
464+
FILE_ENCRYPTION_ENABLED?: boolean = false;
465+
466+
/** Enable scheduled cleanup. Default true. */
467+
@IsOptional()
468+
@IsBoolean()
469+
@Transform(({ value }) => value !== "false")
470+
FILE_CLEANUP_ENABLED?: boolean = true;
471+
472+
/** Days before orphaned files are deleted. Default 7. */
473+
@IsOptional()
474+
@IsNumber()
475+
@Transform(({ value }) => parseInt(value, 10) || 7)
476+
FILE_ORPHAN_RETENTION_DAYS?: number = 7;
477+
478+
/** Days before expired files are deleted. Default 1. */
479+
@IsOptional()
480+
@IsNumber()
481+
@Transform(({ value }) => parseInt(value, 10) || 1)
482+
FILE_EXPIRED_RETENTION_DAYS?: number = 1;
483+
484+
/** Default file expiry in hours. Default 720 (30 days). */
485+
@IsOptional()
486+
@IsNumber()
487+
@Transform(({ value }) => parseInt(value, 10) || 720)
488+
FILE_DEFAULT_EXPIRY_HOURS?: number = 720;
489+
490+
// S3 Configuration
491+
@IsOptional()
492+
@IsString()
493+
S3_BUCKET?: string;
494+
495+
@IsOptional()
496+
@IsString()
497+
S3_REGION?: string;
498+
499+
@IsOptional()
500+
@IsString()
501+
S3_ENDPOINT?: string;
502+
503+
@IsOptional()
504+
@IsString()
505+
S3_ACCESS_KEY_ID?: string;
506+
507+
@IsOptional()
508+
@IsString()
509+
S3_SECRET_ACCESS_KEY?: string;
510+
511+
@IsOptional()
512+
@IsBoolean()
513+
@Transform(({ value }) => value === "true")
514+
S3_FORCE_PATH_STYLE?: boolean = false;
515+
516+
// Azure Blob Storage Configuration
517+
@IsOptional()
518+
@IsString()
519+
AZURE_STORAGE_CONNECTION_STRING?: string;
520+
521+
@IsOptional()
522+
@IsString()
523+
AZURE_STORAGE_CONTAINER?: string;
524+
436525
/** Redis host for webhook Bull queue (falls back to REDIS_HOST). */
437526
@IsOptional()
438527
@IsString()
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import {
2+
IsString,
3+
IsOptional,
4+
IsArray,
5+
IsEnum,
6+
IsNumber,
7+
IsBoolean,
8+
IsObject,
9+
IsInt,
10+
Min,
11+
Max,
12+
MaxLength,
13+
MinLength,
14+
} from "class-validator";
15+
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
16+
import {
17+
FileStorageBackend,
18+
FileCategory,
19+
} from "../entities/uploaded-file.entity";
20+
21+
export class UploadFileDto {
22+
@ApiPropertyOptional({
23+
example: "profile-photo.jpg",
24+
description: "Original file name",
25+
})
26+
@IsOptional()
27+
@IsString()
28+
@MaxLength(255)
29+
originalName?: string;
30+
31+
@ApiPropertyOptional({
32+
enum: FileStorageBackend,
33+
default: FileStorageBackend.LOCAL,
34+
description: "Storage backend to use",
35+
})
36+
@IsOptional()
37+
@IsEnum(FileStorageBackend)
38+
storageBackend?: FileStorageBackend;
39+
40+
@ApiPropertyOptional({
41+
example: ["profile", "avatar"],
42+
description: "Tags to associate with the file",
43+
})
44+
@IsOptional()
45+
@IsArray()
46+
@IsString({ each: true })
47+
tags?: string[];
48+
49+
@ApiPropertyOptional({ example: "User profile photo" })
50+
@IsOptional()
51+
@IsString()
52+
@MaxLength(512)
53+
description?: string;
54+
55+
@ApiPropertyOptional({
56+
description: "Make file publicly accessible",
57+
default: false,
58+
})
59+
@IsOptional()
60+
@IsBoolean()
61+
isPublic?: boolean;
62+
63+
@ApiPropertyOptional({
64+
description: "Expiry time in seconds from now",
65+
example: 3600,
66+
})
67+
@IsOptional()
68+
@IsNumber()
69+
@Min(60)
70+
@Max(31536000)
71+
expiresIn?: number;
72+
}
73+
74+
export class UpdateFileMetadataDto {
75+
@ApiPropertyOptional({
76+
example: ["profile", "updated"],
77+
description: "Tags to associate with the file",
78+
})
79+
@IsOptional()
80+
@IsArray()
81+
@IsString({ each: true })
82+
tags?: string[];
83+
84+
@ApiPropertyOptional({ example: "Updated description" })
85+
@IsOptional()
86+
@IsString()
87+
@MaxLength(512)
88+
description?: string;
89+
90+
@ApiPropertyOptional({ example: { key: "value" } })
91+
@IsOptional()
92+
@IsObject()
93+
metadata?: Record<string, any>;
94+
}
95+
96+
export class GenerateThumbnailDto {
97+
@ApiProperty({ example: 200, description: "Thumbnail width in pixels" })
98+
@IsInt()
99+
@Min(16)
100+
@Max(2048)
101+
width: number;
102+
103+
@ApiProperty({ example: 200, description: "Thumbnail height in pixels" })
104+
@IsInt()
105+
@Min(16)
106+
@Max(2048)
107+
height: number;
108+
109+
@ApiPropertyOptional({
110+
example: "webp",
111+
default: "webp",
112+
description: "Output format",
113+
})
114+
@IsOptional()
115+
@IsString()
116+
format?: string;
117+
118+
@ApiPropertyOptional({
119+
example: "thumb-large",
120+
description: "Variant name for the thumbnail",
121+
})
122+
@IsOptional()
123+
@IsString()
124+
@MaxLength(64)
125+
variant?: string;
126+
}
127+
128+
export class GetDownloadUrlDto {
129+
@ApiPropertyOptional({
130+
example: 3600,
131+
default: 3600,
132+
description: "URL expiry in seconds",
133+
})
134+
@IsOptional()
135+
@IsNumber()
136+
@Min(60)
137+
@Max(86400)
138+
expiresIn?: number;
139+
}
140+
141+
export class FileSearchDto {
142+
@ApiPropertyOptional({ example: "profile" })
143+
@IsOptional()
144+
@IsString()
145+
name?: string;
146+
147+
@ApiPropertyOptional({ enum: FileCategory })
148+
@IsOptional()
149+
@IsEnum(FileCategory)
150+
category?: FileCategory;
151+
152+
@ApiPropertyOptional({ example: ["profile", "avatar"] })
153+
@IsOptional()
154+
@IsArray()
155+
@IsString({ each: true })
156+
tags?: string[];
157+
158+
@ApiPropertyOptional({ example: 1024000, description: "Max size in bytes" })
159+
@IsOptional()
160+
@IsNumber()
161+
@Min(0)
162+
maxSize?: number;
163+
164+
@ApiPropertyOptional({ example: 1024, description: "Min size in bytes" })
165+
@IsOptional()
166+
@IsNumber()
167+
@Min(0)
168+
minSize?: number;
169+
170+
@ApiPropertyOptional({ example: 20, default: 20 })
171+
@IsOptional()
172+
@IsInt()
173+
@Min(1)
174+
@Max(100)
175+
limit?: number;
176+
177+
@ApiPropertyOptional({ example: 0, default: 0 })
178+
@IsOptional()
179+
@IsInt()
180+
@Min(0)
181+
offset?: number;
182+
}
183+
184+
export class FileCleanupDto {
185+
@ApiPropertyOptional({
186+
example: 86400,
187+
description: "Delete files older than this many seconds",
188+
default: 86400,
189+
})
190+
@IsOptional()
191+
@IsNumber()
192+
@Min(60)
193+
olderThanSeconds?: number;
194+
195+
@ApiPropertyOptional({
196+
description: "Only clean up files for this user",
197+
})
198+
@IsOptional()
199+
@IsString()
200+
userId?: string;
201+
202+
@ApiPropertyOptional({
203+
description: "Dry run - return what would be deleted without deleting",
204+
default: false,
205+
})
206+
@IsOptional()
207+
@IsBoolean()
208+
dryRun?: boolean;
209+
}

0 commit comments

Comments
 (0)