Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,27 @@ OAUTH_GITHUB_CLIENT_SECRET=your_github_client_secret
# Twitter/X OAuth
OAUTH_TWITTER_CLIENT_ID=your_twitter_client_id
OAUTH_TWITTER_CLIENT_SECRET=your_twitter_client_secret

# ── File Upload & Storage ───────────────────────────────────────────────────
# Storage backend: local | s3 | azure_blob
FILE_STORAGE_BACKEND=local
FILE_MAX_SIZE_BYTES=52428800
FILE_STORAGE_LOCAL_PATH=./uploads
FILE_SCAN_ENABLED=true
FILE_ENCRYPTION_ENABLED=false
FILE_CLEANUP_ENABLED=true
FILE_ORPHAN_RETENTION_DAYS=7
FILE_EXPIRED_RETENTION_DAYS=1
FILE_DEFAULT_EXPIRY_HOURS=720

# S3 Configuration (when FILE_STORAGE_BACKEND=s3)
S3_BUCKET=alian-structure-files
S3_REGION=us-east-1
S3_ENDPOINT=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_FORCE_PATH_STYLE=false

# Azure Blob Storage Configuration (when FILE_STORAGE_BACKEND=azure_blob)
AZURE_STORAGE_CONNECTION_STRING=
AZURE_STORAGE_CONTAINER=file-uploads
1,516 changes: 1,249 additions & 267 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@
"get-version": "node -p \"require('./package.json').version\""
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1115.0",
"@aws-sdk/s3-request-presigner": "^3.1115.0",
"@azure/storage-blob": "12.32.0",
"@elastic/elasticsearch": "^9.4.2",
"@graphql-typed-document-node/core": "^3.2.0",
"@nestjs/axios": "^4.0.1",
Expand Down Expand Up @@ -107,6 +110,7 @@
"rimraf": "^5.0.5",
"rxjs": "^7.8.1",
"serve-favicon": "^2.5.1",
"sharp": "^0.35.3",
"socket.io": "^4.8.3",
"speakeasy": "^2.0.0",
"swagger-ui-express": "^5.0.1",
Expand Down
10 changes: 10 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,14 @@ import { WebhookSubscription } from "./infrastructure/webhooks/entities/webhook-
import { WebhookEvent } from "./infrastructure/webhooks/entities/webhook-event.entity";
import { WebhookDelivery } from "./infrastructure/webhooks/entities/webhook-delivery.entity";
import { WebhookDeadLetter } from "./infrastructure/webhooks/entities/webhook-dead-letter.entity";
// File upload entities
import { UploadedFile } from "./infrastructure/file-upload/entities/uploaded-file.entity";
import { FileThumbnail } from "./infrastructure/file-upload/entities/file-thumbnail.entity";
import { FileScanResult } from "./infrastructure/file-upload/entities/file-scan-result.entity";
// Modules – webhooks
import { WebhookModule } from "./infrastructure/webhooks/webhook.module";
// Modules – file upload
import { FileUploadModule } from "./infrastructure/file-upload/file-upload.module";

// Guards
import { APP_FILTER } from "@nestjs/core";
Expand Down Expand Up @@ -196,6 +202,9 @@ import { GraphqlGatewayModule } from "./graphql/graphql.module";
WebhookEvent,
WebhookDelivery,
WebhookDeadLetter,
UploadedFile,
FileThumbnail,
FileScanResult,
],
synchronize: true,
logging: true,
Expand Down Expand Up @@ -229,6 +238,7 @@ import { GraphqlGatewayModule } from "./graphql/graphql.module";
AgentReviewsModule,
GraphqlGatewayModule,
WebhookModule,
FileUploadModule,
CacheModule,
LoggerModule.forRootAsync({
inject: [ConfigService],
Expand Down
89 changes: 89 additions & 0 deletions src/config/env.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,95 @@ export class EnvironmentVariables {
@Transform(({ value }) => parseInt(value, 10) || 5)
WEBHOOK_CONCURRENCY?: number = 5;

// ── File Upload & Storage ──────────────────────────────────────────

/** Storage backend: local | s3 | azure_blob. Default: local. */
@IsOptional()
@IsString()
FILE_STORAGE_BACKEND?: string = "local";

/** Max upload size in bytes. Default 52428800 (50MB). */
@IsOptional()
@IsNumber()
@Transform(({ value }) => parseInt(value, 10) || 52428800)
FILE_MAX_SIZE_BYTES?: number = 52428800;

/** Local storage base path. Default: ./uploads */
@IsOptional()
@IsString()
FILE_STORAGE_LOCAL_PATH?: string;

/** Enable virus scanning. Default true. */
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value !== "false")
FILE_SCAN_ENABLED?: boolean = true;

/** Enable encryption at rest. Default false. */
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true")
FILE_ENCRYPTION_ENABLED?: boolean = false;

/** Enable scheduled cleanup. Default true. */
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value !== "false")
FILE_CLEANUP_ENABLED?: boolean = true;

/** Days before orphaned files are deleted. Default 7. */
@IsOptional()
@IsNumber()
@Transform(({ value }) => parseInt(value, 10) || 7)
FILE_ORPHAN_RETENTION_DAYS?: number = 7;

/** Days before expired files are deleted. Default 1. */
@IsOptional()
@IsNumber()
@Transform(({ value }) => parseInt(value, 10) || 1)
FILE_EXPIRED_RETENTION_DAYS?: number = 1;

/** Default file expiry in hours. Default 720 (30 days). */
@IsOptional()
@IsNumber()
@Transform(({ value }) => parseInt(value, 10) || 720)
FILE_DEFAULT_EXPIRY_HOURS?: number = 720;

// S3 Configuration
@IsOptional()
@IsString()
S3_BUCKET?: string;

@IsOptional()
@IsString()
S3_REGION?: string;

@IsOptional()
@IsString()
S3_ENDPOINT?: string;

@IsOptional()
@IsString()
S3_ACCESS_KEY_ID?: string;

@IsOptional()
@IsString()
S3_SECRET_ACCESS_KEY?: string;

@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true")
S3_FORCE_PATH_STYLE?: boolean = false;

// Azure Blob Storage Configuration
@IsOptional()
@IsString()
AZURE_STORAGE_CONNECTION_STRING?: string;

@IsOptional()
@IsString()
AZURE_STORAGE_CONTAINER?: string;

/** Redis host for webhook Bull queue (falls back to REDIS_HOST). */
@IsOptional()
@IsString()
Expand Down
209 changes: 209 additions & 0 deletions src/infrastructure/file-upload/dto/file-upload.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import {
IsString,
IsOptional,
IsArray,
IsEnum,
IsNumber,
IsBoolean,
IsObject,
IsInt,
Min,
Max,
MaxLength,
MinLength,
} from "class-validator";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import {
FileStorageBackend,
FileCategory,
} from "../entities/uploaded-file.entity";

export class UploadFileDto {
@ApiPropertyOptional({
example: "profile-photo.jpg",
description: "Original file name",
})
@IsOptional()
@IsString()
@MaxLength(255)
originalName?: string;

@ApiPropertyOptional({
enum: FileStorageBackend,
default: FileStorageBackend.LOCAL,
description: "Storage backend to use",
})
@IsOptional()
@IsEnum(FileStorageBackend)
storageBackend?: FileStorageBackend;

@ApiPropertyOptional({
example: ["profile", "avatar"],
description: "Tags to associate with the file",
})
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];

@ApiPropertyOptional({ example: "User profile photo" })
@IsOptional()
@IsString()
@MaxLength(512)
description?: string;

@ApiPropertyOptional({
description: "Make file publicly accessible",
default: false,
})
@IsOptional()
@IsBoolean()
isPublic?: boolean;

@ApiPropertyOptional({
description: "Expiry time in seconds from now",
example: 3600,
})
@IsOptional()
@IsNumber()
@Min(60)
@Max(31536000)
expiresIn?: number;
}

export class UpdateFileMetadataDto {
@ApiPropertyOptional({
example: ["profile", "updated"],
description: "Tags to associate with the file",
})
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];

@ApiPropertyOptional({ example: "Updated description" })
@IsOptional()
@IsString()
@MaxLength(512)
description?: string;

@ApiPropertyOptional({ example: { key: "value" } })
@IsOptional()
@IsObject()
metadata?: Record<string, any>;
}

export class GenerateThumbnailDto {
@ApiProperty({ example: 200, description: "Thumbnail width in pixels" })
@IsInt()
@Min(16)
@Max(2048)
width: number;

@ApiProperty({ example: 200, description: "Thumbnail height in pixels" })
@IsInt()
@Min(16)
@Max(2048)
height: number;

@ApiPropertyOptional({
example: "webp",
default: "webp",
description: "Output format",
})
@IsOptional()
@IsString()
format?: string;

@ApiPropertyOptional({
example: "thumb-large",
description: "Variant name for the thumbnail",
})
@IsOptional()
@IsString()
@MaxLength(64)
variant?: string;
}

export class GetDownloadUrlDto {
@ApiPropertyOptional({
example: 3600,
default: 3600,
description: "URL expiry in seconds",
})
@IsOptional()
@IsNumber()
@Min(60)
@Max(86400)
expiresIn?: number;
}

export class FileSearchDto {
@ApiPropertyOptional({ example: "profile" })
@IsOptional()
@IsString()
name?: string;

@ApiPropertyOptional({ enum: FileCategory })
@IsOptional()
@IsEnum(FileCategory)
category?: FileCategory;

@ApiPropertyOptional({ example: ["profile", "avatar"] })
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];

@ApiPropertyOptional({ example: 1024000, description: "Max size in bytes" })
@IsOptional()
@IsNumber()
@Min(0)
maxSize?: number;

@ApiPropertyOptional({ example: 1024, description: "Min size in bytes" })
@IsOptional()
@IsNumber()
@Min(0)
minSize?: number;

@ApiPropertyOptional({ example: 20, default: 20 })
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
limit?: number;

@ApiPropertyOptional({ example: 0, default: 0 })
@IsOptional()
@IsInt()
@Min(0)
offset?: number;
}

export class FileCleanupDto {
@ApiPropertyOptional({
example: 86400,
description: "Delete files older than this many seconds",
default: 86400,
})
@IsOptional()
@IsNumber()
@Min(60)
olderThanSeconds?: number;

@ApiPropertyOptional({
description: "Only clean up files for this user",
})
@IsOptional()
@IsString()
userId?: string;

@ApiPropertyOptional({
description: "Dry run - return what would be deleted without deleting",
default: false,
})
@IsOptional()
@IsBoolean()
dryRun?: boolean;
}
Loading
Loading