Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
name: Deploy to Staging
name: Deploy to Development

on:
push:
branches:
- staging
- main

permissions:
id-token: write
Expand All @@ -30,15 +30,10 @@ jobs:
cd backend
npm test

- name: Run CDK tests
run: |
cd backend
npm run test:cdk

deploy:
needs: test
runs-on: ubuntu-latest
environment: staging
environment: development

steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -70,7 +65,7 @@ jobs:
cd backend
npm run cdk deploy -- \
--require-approval never \
--context environment=staging
--context environment=development
env:
CDK_DEFAULT_ACCOUNT: ${{ secrets.AWS_ACCOUNT_ID }}
CDK_DEFAULT_REGION: us-east-1
5 changes: 0 additions & 5 deletions .github/workflows/deploy-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,6 @@ jobs:
cd backend
npm test

- name: Run CDK tests
run: |
cd backend
npm run test:cdk

deploy:
needs: test
runs-on: ubuntu-latest
Expand Down
1,452 changes: 1,237 additions & 215 deletions backend/package-lock.json

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@
"@aws-sdk/client-bedrock": "^3.782.0",
"@aws-sdk/client-bedrock-runtime": "^3.782.0",
"@aws-sdk/client-dynamodb": "^3.758.0",
"@aws-sdk/client-s3": "^3.782.0",
"@aws-sdk/client-secrets-manager": "^3.758.0",
"@aws-sdk/s3-request-presigner": "^3.782.0",
"@aws-sdk/client-textract": "^3.782.0",
"@aws-sdk/util-dynamodb": "^3.758.0",
"@nestjs/common": "^10.0.0",
Expand Down Expand Up @@ -68,7 +70,7 @@
"@nestjs/testing": "^10.0.0",
"@types/config": "^3.3.4",
"@types/cors": "^2.8.15",
"@types/express": "^4.17.20",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.4",
"@types/jwk-to-pem": "^2.0.2",
Expand Down
41 changes: 41 additions & 0 deletions backend/src/common/middleware/file-validation.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Injectable, NestMiddleware, BadRequestException } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';

@Injectable()
export class FileValidationMiddleware implements NestMiddleware {
// Allowed MIME types
private readonly allowedMimeTypes = ['application/pdf', 'image/jpeg', 'image/png'];

// Size limits in bytes with proper type definition
private readonly sizeLimits: Record<string, number> = {
'application/pdf': 10 * 1024 * 1024, // 10MB
'image/jpeg': 5 * 1024 * 1024, // 5MB
'image/png': 5 * 1024 * 1024, // 5MB
};

use(req: Request, res: Response, next: NextFunction) {
if (!req.file) {
return next();
}

const file = req.file as Express.Multer.File;

// Validate MIME type
if (!this.allowedMimeTypes.includes(file.mimetype)) {
throw new BadRequestException(
`Invalid file format. Allowed formats are: PDF, JPEG, and PNG.`,
);
}

// Validate file size
const sizeLimit = this.sizeLimits[file.mimetype];
if (file.size > sizeLimit) {
const limitInMB = sizeLimit / (1024 * 1024);
throw new BadRequestException(
`File size exceeds the limit. Maximum allowed size for ${file.mimetype} is ${limitInMB}MB.`,
);
}

next();
}
}
142 changes: 142 additions & 0 deletions backend/src/common/services/s3.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { Injectable, Logger, InternalServerErrorException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
S3Client,
PutObjectCommand,
DeleteObjectCommand,
GetObjectCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { v4 as uuidv4 } from 'uuid';

@Injectable()
export class S3Service {
private readonly s3Client: S3Client;
private readonly bucketName: string;
private readonly logger = new Logger(S3Service.name);

constructor(private configService: ConfigService) {
const region = this.configService.get<string>('AWS_REGION', 'us-east-1');

Check failure on line 19 in backend/src/common/services/s3.service.ts

View workflow job for this annotation

GitHub Actions / test

src/app.module.spec.ts > AppModule > should compile the module

TypeError: Cannot read properties of undefined (reading 'get') ❯ new S3Service src/common/services/s3.service.ts:19:39 ❯ TestingInjector.instantiateClass node_modules/@nestjs/core/injector/injector.js:373:19 ❯ callback node_modules/@nestjs/core/injector/injector.js:65:45 ❯ TestingInjector.resolveConstructorParams node_modules/@nestjs/core/injector/injector.js:145:24 ❯ TestingInjector.loadInstance node_modules/@nestjs/core/injector/injector.js:70:13 ❯ TestingInjector.loadProvider node_modules/@nestjs/core/injector/injector.js:98:9 ❯ node_modules/@nestjs/core/injector/instance-loader.js:56:13 ❯ TestingInstanceLoader.createInstancesOfProviders node_modules/@nestjs/core/injector/instance-loader.js:55:9
const accessKeyId = this.configService.get<string>('AWS_ACCESS_KEY_ID');
const secretAccessKey = this.configService.get<string>('AWS_SECRET_ACCESS_KEY');

// Prepare client configuration
const clientConfig: any = { region };

// Only add credentials if both values are present
if (accessKeyId && secretAccessKey) {
clientConfig.credentials = { accessKeyId, secretAccessKey };
}

this.s3Client = new S3Client(clientConfig);

const bucketName = this.configService.get<string>('S3_BUCKET_NAME');

if (!bucketName) {
this.logger.error('S3_BUCKET_NAME environment variable is not set');
throw new InternalServerErrorException('S3 bucket configuration is missing');
}

this.bucketName = bucketName;
}

/**
* Uploads a file to S3 with server-side encryption
*/
async uploadFile(
file: Express.Multer.File,
userId: string,
customFileName?: string,
): Promise<{
fileName: string;
filePath: string;
fileUrl: string;
mimeType: string;
size: number;
}> {
try {
// Generate a unique file name if not provided
const fileName = customFileName || this.generateFileName(file.originalname);

// Create path: userId/reports/fileName
const filePath = `${userId}/reports/${fileName}`;

const command = new PutObjectCommand({
Bucket: this.bucketName,
Key: filePath,
Body: file.buffer,
ContentType: file.mimetype,
// Enable server-side encryption
ServerSideEncryption: 'AES256',
});

await this.s3Client.send(command);

// Generate a pre-signed URL for temporary access
const fileUrl = await this.getSignedUrl(filePath);

return {
fileName,
filePath: `s3://${this.bucketName}/${filePath}`,
fileUrl,
mimeType: file.mimetype,
size: file.size,
};
} catch (error: unknown) {
// Properly handle unknown error type
this.logger.error(
`Failed to upload file to S3: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
throw new InternalServerErrorException('Failed to upload file to storage');
}
}

/**
* Generates a pre-signed URL for temporary file access
*/
async getSignedUrl(filePath: string, expiresIn = 3600): Promise<string> {
try {
const command = new GetObjectCommand({
Bucket: this.bucketName,
Key: filePath,
});

return await getSignedUrl(this.s3Client, command, { expiresIn });
} catch (error: unknown) {
// Properly handle unknown error type
this.logger.error(
`Failed to generate signed URL: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
throw new InternalServerErrorException('Failed to generate file access URL');
}
}

/**
* Deletes a file from S3
*/
async deleteFile(filePath: string): Promise<void> {
try {
const command = new DeleteObjectCommand({
Bucket: this.bucketName,
Key: filePath,
});

await this.s3Client.send(command);
} catch (error: unknown) {
// Properly handle unknown error type
this.logger.error(
`Failed to delete file from S3: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
throw new InternalServerErrorException('Failed to delete file from storage');
}
}

/**
* Generates a unique file name
*/
private generateFileName(originalName: string): string {
const fileExtension = originalName.split('.').pop();
const randomId = uuidv4();
return `${randomId}-report.${fileExtension}`;
}
}
18 changes: 18 additions & 0 deletions backend/src/iac/backend-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as servicediscovery from 'aws-cdk-lib/aws-servicediscovery';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import { Bucket, BucketEncryption } from 'aws-cdk-lib/aws-s3';

import { Construct } from 'constructs';
import { AttributeType, BillingMode, Table } from 'aws-cdk-lib/aws-dynamodb';
Expand Down Expand Up @@ -535,5 +536,22 @@ export class BackendStack extends cdk.Stack {
value: nlb.loadBalancerDnsName,
description: 'Network Load Balancer DNS Name',
});

// Add S3 bucket for medical reports
const reportsBucket = new Bucket(this, 'MedicalReportsBucket', {
bucketName: `${appName}-reports-bucket-${props.environment}`,
encryption: BucketEncryption.S3_MANAGED, // Server-side encryption
enforceSSL: true, // Enforce TLS for data in transit
versioned: true, // Enable versioning for file history
blockPublicAccess: {
blockPublicAcls: true,
blockPublicPolicy: true,
ignorePublicAcls: true,
restrictPublicBuckets: true,
},
});

// Add S3 bucket name to environment variables
container.addEnvironment('S3_BUCKET_NAME', reportsBucket.bucketName);
}
}
45 changes: 45 additions & 0 deletions backend/src/reports/dto/create-report.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, IsUrl, IsOptional } from 'class-validator';

export class CreateReportDto {
@ApiProperty({
description: 'The URL of the file stored in S3',
example: 'https://my-bucket.s3.amazonaws.com/reports/file-123456.pdf',
})
@IsNotEmpty()
@IsString()
@IsUrl()
fileUrl: string;

@ApiProperty({
description: 'Original filename of the uploaded file',
example: 'medical-report-2023.pdf',
})
@IsNotEmpty()
@IsString()
fileName: string;

@ApiProperty({
description: 'MIME type of the file',
example: 'application/pdf',
})
@IsNotEmpty()
@IsString()
fileType: string;

@ApiProperty({
description: 'Size of the file in bytes',
example: 1024567,
})
@IsNotEmpty()
fileSize: number;

@ApiProperty({
description: 'Optional description of the report',
example: 'Annual checkup results',
required: false,
})
@IsOptional()
@IsString()
description?: string;
}
32 changes: 32 additions & 0 deletions backend/src/reports/dto/file-upload.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { ApiProperty } from '@nestjs/swagger';

export class FileUploadDto {
@ApiProperty({ type: 'string', format: 'binary', description: 'Medical report file' })
file: Express.Multer.File;
}

export class FileUploadResponseDto {
@ApiProperty({ example: 'abc123-report.pdf', description: 'Generated file name' })
fileName: string;

@ApiProperty({
example: 's3://bucket/user123/reports/abc123-report.pdf',
description: 'File storage path',
})
filePath: string;

@ApiProperty({
example: 'https://bucket.s3.amazonaws.com/user123/reports/abc123-report.pdf',
description: 'File URL',
})
fileUrl: string;

@ApiProperty({ example: 'application/pdf', description: 'File MIME type' })
mimeType: string;

@ApiProperty({ example: 1024000, description: 'File size in bytes' })
size: number;

@ApiProperty({ example: '2023-10-25T15:30:45Z', description: 'Upload timestamp' })
uploadedAt: string;
}
Loading
Loading