diff --git a/src/common/utils/api-id.config.ts b/src/common/utils/api-id.config.ts index ee6c9b45..222011d4 100644 --- a/src/common/utils/api-id.config.ts +++ b/src/common/utils/api-id.config.ts @@ -37,6 +37,7 @@ export const APIID = { FIELDVALUES_CREATE: 'api.fieldValues.create', FIELDVALUES_SEARCH: 'api.fieldValues.search', FIELDVALUES_DELETE: 'api.fieldValues.delete', + FIELDVALUES_DOWNLOAD: 'api.fieldValues.download', FIELD_OPTIONS_DELETE: 'api.fields.options.delete', FIELD_DELETE: 'api.fields.delete', LOGIN: 'api.login', diff --git a/src/fields/fields.controller.ts b/src/fields/fields.controller.ts index 7f6b2120..afb97a1a 100644 --- a/src/fields/fields.controller.ts +++ b/src/fields/fields.controller.ts @@ -6,6 +6,8 @@ import { ApiBasicAuth, ApiHeader, ApiQuery, + ApiOperation, + ApiParam, } from '@nestjs/swagger'; import { Controller, @@ -769,4 +771,89 @@ export class FieldsController { ); } } + + @Get('download-file/:fieldId') + @UseGuards(JwtAuthGuard) + @UseFilters(new AllExceptionsFilter(APIID.FIELDVALUES_DOWNLOAD)) + @ApiOperation({ + summary: 'Download a file from a field', + description: 'Downloads a file associated with a specific field. Only the file owner or admin users can download files.' + }) + @ApiParam({ + name: 'fieldId', + type: String, + description: 'The ID of the field containing the file to download' + }) + @ApiHeader({ + name: 'Authorization', + description: 'Bearer token for authentication' + }) + @ApiForbiddenResponse({ + description: 'User is not authorized to download this file' + }) + async downloadFile( + @Param('fieldId') fieldId: string, + @Req() request: RequestWithUser, + @Res() response: Response + ) { + try { + // Extract userId and role from bearer token + const userId = request.user?.userId || request.user?.sub; + const userRole = request.user?.role || request.user?.realm_access?.roles?.[0]; + + if (!userId) { + return APIResponse.error( + response, + APIID.FIELDVALUES_DOWNLOAD, + 'User ID is required from authentication token.', + 'USER_ID_REQUIRED', + HttpStatus.BAD_REQUEST + ); + } + + const downloadResult = await this.fileUploadService.downloadFile( + fieldId, + userId, + userRole + ); + + // Set response headers for file download + response.setHeader('Content-Type', downloadResult.contentType); + response.setHeader('Content-Disposition', `attachment; filename="${downloadResult.originalName}"`); + response.setHeader('Content-Length', downloadResult.size.toString()); + response.setHeader('X-Field-Id', fieldId); + response.setHeader('X-File-Name', downloadResult.originalName); + response.setHeader('X-File-Size', downloadResult.size.toString()); + response.setHeader('X-Status', 'downloaded'); + + // Send the file buffer and end the response + response.send(downloadResult.buffer); + + } catch (error) { + // Only log unexpected errors, not validation errors + if (!(error instanceof FileValidationException)) { + console.log('Error in FieldsController downloadFile:', error); + } + + if (error instanceof FileValidationException) { + // Extract the error message directly from the exception + const errorMsg = error.message; + return APIResponse.error( + response, + APIID.FIELDVALUES_DOWNLOAD, + errorMsg, + 'File Download Error', + HttpStatus.BAD_REQUEST + ); + } + + return APIResponse.error( + response, + APIID.FIELDVALUES_DOWNLOAD, + 'Failed to download file: ' + error.message, + API_RESPONSES.SERVER_ERROR, + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + } } diff --git a/src/fields/fields.service.ts b/src/fields/fields.service.ts index 8df11e67..c4b8ac3f 100644 --- a/src/fields/fields.service.ts +++ b/src/fields/fields.service.ts @@ -15,6 +15,7 @@ import APIResponse from 'src/utils/response'; import { log } from 'util'; import { ErrorResponseTypeOrm } from 'src/error-response-typeorm'; import { FieldValueConverter } from 'src/utils/field-value-converter'; +import { FieldValue, Field } from 'src/storage/interfaces/field-operations.interface'; @Injectable() export class FieldsService { @@ -442,14 +443,82 @@ export class FieldsService { return fieldResponse; } - async getField(fieldId: string): Promise { - return this.fieldsRepository.findOne({ where: { fieldId } }); + async getField(fieldId: string): Promise { + const field = await this.fieldsRepository.findOne({ where: { fieldId } }); + + if (!field) { + return null; + } + + // Convert Fields entity to Field interface + return { + fieldId: field.fieldId, + name: field.name, + label: field.label, + type: field.type, + context: field.context, + state: field.state, + contextType: field.contextType, + fieldParams: field.fieldParams, + required: field.required, + metadata: field.metadata + }; } - async getFieldValue(fieldId: string, itemId: string): Promise { - return this.fieldsValuesRepository.findOne({ + async getFieldValue(fieldId: string, itemId: string): Promise { + const fieldValue = await this.fieldsValuesRepository.findOne({ where: { fieldId: fieldId, itemId: itemId } }); + + if (!fieldValue) { + return null; + } + + // Convert FieldValues entity to FieldValue interface + return { + fieldValuesId: fieldValue.fieldValuesId, + value: fieldValue.value, + itemId: fieldValue.itemId, + fieldId: fieldValue.fieldId, + fileValue: fieldValue.fileValue, + textValue: fieldValue.textValue, + numberValue: fieldValue.numberValue, + calendarValue: fieldValue.calendarValue, + dropdownValue: fieldValue.dropdownValue, + radioValue: fieldValue.radioValue, + checkboxValue: fieldValue.checkboxValue, + textareaValue: fieldValue.textareaValue, + createdAt: fieldValue.createdAt, + updatedAt: fieldValue.updatedAt, + createdBy: fieldValue.createdBy, + updatedBy: fieldValue.updatedBy + }; + } + + async getFieldValuesByFieldId(fieldId: string): Promise { + const fieldValues = await this.fieldsValuesRepository.find({ + where: { fieldId: fieldId } + }); + + // Convert FieldValues entities to FieldValue interface + return fieldValues.map(fv => ({ + fieldValuesId: fv.fieldValuesId, + value: fv.value, + itemId: fv.itemId, + fieldId: fv.fieldId, + fileValue: fv.fileValue, + textValue: fv.textValue, + numberValue: fv.numberValue, + calendarValue: fv.calendarValue, + dropdownValue: fv.dropdownValue, + radioValue: fv.radioValue, + checkboxValue: fv.checkboxValue, + textareaValue: fv.textareaValue, + createdAt: fv.createdAt, + updatedAt: fv.updatedAt, + createdBy: fv.createdBy, + updatedBy: fv.updatedBy + })); } async updateFieldValue(data: { diff --git a/src/storage/file-upload.service.ts b/src/storage/file-upload.service.ts index 36069b7f..4b766229 100644 --- a/src/storage/file-upload.service.ts +++ b/src/storage/file-upload.service.ts @@ -9,6 +9,10 @@ import { HeadObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { PutObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { FileTypeMapper } from '../utils/file-type-mapper'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { UserRoleMapping } from '../rbac/assign-role/entities/assign-role.entity'; +import { Role } from '../rbac/role/entities/role.entity'; /** * File Upload Service @@ -37,9 +41,41 @@ export class FileUploadService { constructor( private readonly storageConfig: StorageConfigService, @Inject('FIELD_OPERATIONS') - private readonly fieldOperations: IFieldOperations + private readonly fieldOperations: IFieldOperations, + @InjectRepository(UserRoleMapping) + private userRoleMappingRepository: Repository, + @InjectRepository(Role) + private roleRepository: Repository ) {} + /** + * Get user role from database + * @param userId - The user ID to get role for + * @returns User role or null if not found + */ + private async getUserRole(userId: string): Promise { + try { + // Get user role mapping + const userRoleMapping = await this.userRoleMappingRepository.findOne({ + where: { userId: userId } + }); + + if (!userRoleMapping) { + return null; + } + + // Get role details + const role = await this.roleRepository.findOne({ + where: { roleId: userRoleMapping.roleId }, + select: ['title', 'code'] + }); + + return role ? role.title : null; + } catch (error) { + return null; + } + } + /** * Upload a file directly to storage (local or S3) * @@ -426,6 +462,12 @@ export class FileUploadService { throw new FileValidationException('Field is not a file type'); } + // Get user role from database (preferred) or fallback to JWT token + let actualUserRole = userRole; + if (!actualUserRole) { + actualUserRole = await this.getUserRole(userId); + } + // Get the field value to find the file path // itemId represents the user who owns the file, so we look for fieldValue where itemId = userId const fieldValue = await this.fieldOperations.getFieldValue(fieldId, userId); @@ -447,11 +489,6 @@ export class FileUploadService { const url = new URL(fieldValue.fileValue); // Remove the leading slash and get the path s3Key = url.pathname.substring(1); - - // Log for debugging - console.log('Original fileValue:', fieldValue.fileValue); - console.log('Extracted S3 key:', s3Key); - } catch (error) { throw new FileValidationException('Invalid file URL format'); } @@ -460,7 +497,8 @@ export class FileUploadService { // Authorization check const fileOwnerId = fieldValue.itemId; // itemId is the userId who owns the file const isOwner = fileOwnerId === userId; - const isAdmin = userRole === 'Admin' || userRole === 'admin'; + const isAdmin = actualUserRole === 'Admin' || actualUserRole === 'admin' || + actualUserRole === 'ADMIN' || actualUserRole?.toLowerCase() === 'admin'; if (!isOwner && !isAdmin) { throw new FileValidationException('You are not authorized to delete this file. Only the file owner or admin can delete it.'); @@ -494,4 +532,123 @@ export class FileUploadService { throw new FileValidationException('Failed to delete file: ' + error.message); } } + + /** + * Download a file with comprehensive authorization and validation. + * + * - Validates field and file record. + * - Only allows download by file owner or admin. + * - Verifies file exists in storage before download. + * - Returns file buffer and metadata for download. + * + * Authorization Rules: + * - Users can only download files where fieldValue.itemId === userId (file owner) + * - Admin users can download any file regardless of ownership + * - All other users are denied access + * + * @param fieldId - The field ID containing the file + * @param userId - The user ID requesting download + * @param userRole - Optional user role for admin authorization + * @returns File download result with buffer and metadata + * @throws FileValidationException if validation or authorization fails + */ + async downloadFile( + fieldId: string, + userId: string, + userRole?: string + ): Promise<{ buffer: Buffer; contentType: string; originalName: string; size: number }> { + try { + // Get field configuration + const field = await this.fieldOperations.getField(fieldId); + if (!field) { + throw new FileValidationException('Field not found'); + } + + if (field.type !== 'file') { + throw new FileValidationException('Field type is not valid'); + } + + // Get user role from database (preferred) or fallback to JWT token + let actualUserRole = userRole; + if (!actualUserRole) { + actualUserRole = await this.getUserRole(userId); + } + + // Check if user is admin - improved role detection + const isAdmin = actualUserRole === 'Admin' || actualUserRole === 'admin' || + actualUserRole === 'ADMIN' || actualUserRole?.toLowerCase() === 'admin'; + + // Get the field value to find the file path + let fieldValue; + + if (isAdmin) { + // For admin users, get all field values for this fieldId and use the first one + const fieldValues = await this.fieldOperations.getFieldValuesByFieldId(fieldId); + + if (fieldValues.length === 0) { + throw new FileValidationException('Field values does not found for this field'); + } + // Use the first field value found (admin can access any file for this field) + fieldValue = fieldValues[0]; + } else { + // For non-admin users, look for their own file + fieldValue = await this.fieldOperations.getFieldValue(fieldId, userId); + if (!fieldValue) { + throw new FileValidationException('Field values does not found for this field and user'); + } + } + + // Check if file path exists + if (!fieldValue.fileValue) { + throw new FileValidationException('File path not found in database'); + } + + // Authorization check for non-admin users + if (!isAdmin) { + const fileOwnerId = fieldValue.itemId; // itemId is the userId who owns the file + const isOwner = fileOwnerId === userId; + + if (!isOwner) { + throw new FileValidationException('You are not authorized to download this file. Only the file owner or admin can download it.'); + } + } + + // Determine file path based on storage type + let filePath = fieldValue.fileValue; + + // For S3, fileValue contains the S3 key + // For local storage, fileValue contains the file path, fallback to value if not set + if (!filePath && fieldValue.value) { + filePath = fieldValue.value; + } + + if (!filePath) { + throw new FileValidationException('File path not found in database'); + } + + // Extract S3 key from filePath if it's a full URL + let s3Key = filePath; + if (filePath.startsWith('http')) { + try { + const url = new URL(filePath); + // Remove the leading slash and get the path + s3Key = url.pathname.substring(1); + } catch (error) { + throw new FileValidationException('Invalid file URL format'); + } + } + + // Download file from storage + const storageProvider = this.storageConfig.getProvider(); + const downloadResult = await storageProvider.download(s3Key); + + return downloadResult; + + } catch (error) { + if (error instanceof FileValidationException) { + throw error; + } + throw new FileValidationException('Failed to download file: ' + error.message); + } + } } \ No newline at end of file diff --git a/src/storage/interfaces/field-operations.interface.ts b/src/storage/interfaces/field-operations.interface.ts index 58e998c6..206950cd 100644 --- a/src/storage/interfaces/field-operations.interface.ts +++ b/src/storage/interfaces/field-operations.interface.ts @@ -77,6 +77,13 @@ export interface IFieldOperations { */ getFieldValue(fieldId: string, itemId: string): Promise; + /** + * Retrieves field values by field ID only (for admin access). + * @param fieldId - The field ID + * @returns Promise resolving to array of field values or empty array if not found + */ + getFieldValuesByFieldId(fieldId: string): Promise; + /** * Deletes a field value from the database. * @param fieldId - The field ID diff --git a/src/storage/interfaces/storage.provider.ts b/src/storage/interfaces/storage.provider.ts index 5d0285ea..3ea0a0a2 100644 --- a/src/storage/interfaces/storage.provider.ts +++ b/src/storage/interfaces/storage.provider.ts @@ -4,6 +4,7 @@ * Defines the contract for storage providers (local and S3): * - File upload operations * - File deletion operations + * - File download operations * - URL generation * - Presigned URL generation for direct uploads * @@ -25,6 +26,13 @@ export interface StorageProvider { */ delete(filePath: string): Promise; + /** + * Downloads a file from storage. + * @param filePath - The file path/key to download + * @returns Promise resolving to file buffer and metadata + */ + download(filePath: string): Promise<{ buffer: Buffer; contentType: string; originalName: string; size: number }>; + /** * Returns the public URL for a file. * @param filePath - The file path/key diff --git a/src/storage/providers/local-storage.provider.ts b/src/storage/providers/local-storage.provider.ts index ec5aa81d..5e6136e1 100644 --- a/src/storage/providers/local-storage.provider.ts +++ b/src/storage/providers/local-storage.provider.ts @@ -105,4 +105,64 @@ export class LocalStorageProvider implements StorageProvider { key: filePath, }; } + + /** + * Downloads a file from local storage. + * @param filePath - The local file path to download + * @returns Promise resolving to file buffer and metadata + */ + async download(filePath: string): Promise<{ buffer: Buffer; contentType: string; originalName: string; size: number }> { + try { + // Check if file exists + if (!fs.existsSync(filePath)) { + throw new Error('File not found in local storage'); + } + + // Read file buffer + const buffer = await fs.promises.readFile(filePath); + + // Get file stats for size + const stats = await fs.promises.stat(filePath); + + // Determine content type based on file extension + const ext = path.extname(filePath).toLowerCase(); + let contentType = 'application/octet-stream'; + + // Simple content type mapping + const contentTypeMap: { [key: string]: string } = { + '.pdf': 'application/pdf', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.txt': 'text/plain', + '.doc': 'application/msword', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.xls': 'application/vnd.ms-excel', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.csv': 'text/csv', + '.zip': 'application/zip', + '.rar': 'application/x-rar-compressed' + }; + + if (contentTypeMap[ext]) { + contentType = contentTypeMap[ext]; + } + + // Extract original filename from path + const originalName = path.basename(filePath); + + return { + buffer, + contentType, + originalName, + size: stats.size + }; + } catch (error) { + if (error.code === 'ENOENT') { + throw new Error('File not found in local storage'); + } + throw new Error(`Failed to download file: ${error.message}`); + } + } } \ No newline at end of file diff --git a/src/storage/providers/s3-storage.provider.ts b/src/storage/providers/s3-storage.provider.ts index 7cfd44cf..310e22e8 100644 --- a/src/storage/providers/s3-storage.provider.ts +++ b/src/storage/providers/s3-storage.provider.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { StorageProvider } from '../interfaces/storage.provider'; -import { S3Client, PutObjectCommand, DeleteObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'; +import { S3Client, PutObjectCommand, DeleteObjectCommand, HeadObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { ConfigService } from '@nestjs/config'; import { v4 as uuidv4 } from 'uuid'; @@ -314,4 +314,60 @@ export class S3StorageProvider implements StorageProvider { return { valid: false, deleted: false, reason: error.message }; } } + + /** + * Downloads a file from S3. + * @param filePath - The S3 key of the file to download + * @returns Promise resolving to file buffer and metadata + */ + async download(filePath: string): Promise<{ buffer: Buffer; contentType: string; originalName: string; size: number }> { + try { + // First, get file metadata to check if it exists and get content type + const headCommand = new HeadObjectCommand({ + Bucket: this.bucket, + Key: filePath, + }); + + const headResult = await this.s3Client.send(headCommand); + + // Get the file content + const getCommand = new GetObjectCommand({ + Bucket: this.bucket, + Key: filePath, + }); + + const getResult = await this.s3Client.send(getCommand); + + if (!getResult.Body) { + throw new Error('File body is empty'); + } + + // Convert stream to buffer + const chunks: Uint8Array[] = []; + const stream = getResult.Body as any; + + for await (const chunk of stream) { + chunks.push(chunk); + } + + const buffer = Buffer.concat(chunks); + + // Extract original filename from metadata or use the key + const originalName = headResult.Metadata?.originalFileName || + path.basename(filePath) || + 'downloaded-file'; + + return { + buffer, + contentType: headResult.ContentType || 'application/octet-stream', + originalName, + size: buffer.length + }; + } catch (error) { + if (error.name === 'NoSuchKey') { + throw new Error('File not found in S3'); + } + throw new Error(`Failed to download file: ${error.message}`); + } + } } \ No newline at end of file diff --git a/src/storage/storage.module.ts b/src/storage/storage.module.ts index 4aa24baf..711dcca5 100644 --- a/src/storage/storage.module.ts +++ b/src/storage/storage.module.ts @@ -6,11 +6,15 @@ import { LocalStorageProvider } from './providers/local-storage.provider'; import { S3StorageProvider } from './providers/s3-storage.provider'; import { FileUploadService } from './file-upload.service'; import { FieldOperationsModule } from '../fields/field-operations.module'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { UserRoleMapping } from '../rbac/assign-role/entities/assign-role.entity'; +import { Role } from '../rbac/role/entities/role.entity'; @Module({ imports: [ ConfigModule, - FieldOperationsModule + FieldOperationsModule, + TypeOrmModule.forFeature([UserRoleMapping, Role]) ], providers: [ StorageConfigService,