Refactor StorageService to include MIME type and size checks

Reorganized StorageService to incorporate MIME type validation and adjustable file size limits, enhancing file validation. Introduced new methods for checksum calculation and file information generation, and updated the StorageModule to import the DbModule for database interactions.
This commit is contained in:
Mathis H (Avnyr) 2024-09-24 12:29:11 +02:00
parent d42aaeda05
commit f4393301a2
Signed by: Mathis
GPG Key ID: DD9E0666A747D126
3 changed files with 152 additions and 53 deletions

View File

@ -1,7 +1,9 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { StorageService } from "apps/backend/src/app/storage/storage.service"; import { StorageService } from "apps/backend/src/app/storage/storage.service";
import { DbModule } from 'apps/backend/src/app/db/db.module';
@Module({ @Module({
imports: [DbModule],
providers: [StorageService], providers: [StorageService],
}) })
export class StorageModule {} export class StorageModule {}

View File

@ -9,45 +9,25 @@ import {
NotFoundException, NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
import FileType from "file-type"; import FileType from "file-type";
import { DbService } from 'apps/backend/src/app/db/db.service';
import { IFileInformation } from 'apps/backend/src/app/storage/storage.types';
import { FilesTypeForMachine, FilesTypesTable } from 'apps/backend/src/app/db/schema';
import { eq } from 'drizzle-orm';
@Injectable() @Injectable()
export class StorageService { export class StorageService {
/** //TODO make file size configurable by admin
* Saves the given file with a generated file name that includes a prefix, private maxFileSize = 256; // MiB unit
* the checksum of the file, and the file type extension. constructor(private readonly dbService: DbService) {}
*
* @param {string} prefix - The prefix to include in the generated file name.
* @param {Buffer} file - The file to save.
* @returns {Promise<string>} A promise that resolves with the generated file name after the file is saved.
*/
private async save(prefix: string, file: Buffer) {
const checksum = await this.checkConditions(file);
const fileType = await FileType.fileTypeFromBuffer(file);
const fileName = `${prefix.toLowerCase()}-${checksum}.${fileType.ext.toLowerCase()}`;
await this.saveFile(fileName, file);
return fileName;
//SEC TODO Catch case
}
/** /**
* Calculates the checksum of a given file. * Save a file to the specified directory.
* *
* @param {Buffer} file - The file for which the checksum is to be calculated. * @param {string} fileName - The name of the file.
* @return {string} - The hexadecimal representation of the checksum. * @param {Buffer} file - The file content in Buffer format.
*/ * @throws {InternalServerErrorException} If the file save fails.
private getChecksum(file: Buffer): string { * @return {Promise<void>} A Promise that resolves when the file is successfully saved.
return crypto.createHash("sha256").update(file).digest("hex").toLowerCase();
}
/**
* Saves a file to the specified location with the provided name.
*
* @param {string} fileName - The name of the file to be saved.
* @param {Buffer} file - The file content to be saved.
*
* @return {Promise<void>} - A promise that resolves when the file is successfully saved.
*
* @throws {InternalServerErrorException} - If there is an error while saving the file.
*/ */
private async saveFile(fileName: string, file: Buffer): Promise<void> { private async saveFile(fileName: string, file: Buffer): Promise<void> {
try { try {
@ -59,38 +39,81 @@ export class StorageService {
} }
/** /**
* Checks the conditions for a given file. * Checks if the current MIME type and file size meet the specified conditions.
* * @param {Array<string>} machineIds - The IDs of the associated machines.
* @param file - The file to check. * @param {Buffer} file - The file to check.
* @returns The checksum of the file. * @return {Promise<boolean>} - A Promise that resolves to true if the conditions are met, false otherwise.
* @throws BadRequestException - If the file type is invalid or the file size exceeds the limit.
*/ */
private async checkConditions(file: Buffer): Promise<string> { private async checkConditions(machineIds: Array<string>,file: Buffer): Promise<boolean> {
const checksum = this.getChecksum(file);
const fileType = await FileType.fileTypeFromBuffer(file); /**
//TODO make file type configurable by admin * Checks if the current MIME type is allowed based on the given set of allowed MIME types.
if (!fileType || !fileType.mime.startsWith("image/")) { * @param {Set<string>} allowedMime - The set of allowed MIME types.
throw new BadRequestException( * @param {string} currentMime - The current MIME type to check.
"Invalid file type. Only images are allowed.", * @return {boolean} - True if the current MIME type is allowed, false otherwise.
); */
function checkMime(allowedMime: Set<string>, currentMime: string): boolean {
for (const mime of allowedMime) if (mime === currentMime) return true;
return false;
} }
const fileType = await FileType.fileTypeFromBuffer(file);
// Array of MIMEs with possible duplicate field
const _mimes: Array<string> = []
// Fetching MIMEs for the associated machines
for (const machineId of machineIds) {
console.debug(`Fetching mimeTypes for machine : ${machineId}`)
// Get mimes associated to a machine
const allowedMimeId = this.dbService.use()
.select()
.from(FilesTypeForMachine)
.where(eq(FilesTypeForMachine.machineId, machineId)).as("allowedMimeId");
const _allowedMime = await this.dbService.use()
.select({
slug: FilesTypesTable.mime,
name: FilesTypesTable.typeName
})
.from(FilesTypesTable)
.leftJoin(allowedMimeId, eq(FilesTypesTable.id, allowedMimeId.fileTypeId))
console.debug(`Total : ${_allowedMime.length}`)
// Append each mime of a machine
for (const allowedMimeElement of _allowedMime) {
_mimes.push(allowedMimeElement.slug)
}
}
//Store the MIMEs without duplicate
const mimeSet = new Set(_mimes)
console.debug(`Indexed ${mimeSet.size} unique mimeTypes`)
//check file size is less than 2mb //check file size is less than 2mb
const fileSize = file.byteLength; const fileSize = file.byteLength;
//TODO make file size configurable by admin if (fileSize > this.maxFileSize * (1024 * 1024)) {
if (fileSize > 2 * 1024 * 1024) {
throw new BadRequestException( throw new BadRequestException(
"File size exceeds the limit. Maximum file size allowed is 2MB.", "File size to high.",
{
cause: "File size",
description: `File size exceeds the limit. Maximum file size allowed is ${this.maxFileSize}MiB.`
}
); );
} }
return checksum;
if (!checkMime(mimeSet, fileType.mime)) throw new BadRequestException(
{
cause: "MIME type",
description: `Invalid MIME type. Allowed MIME types are: ${[...mimeSet].join(", ")}.`
}
)
return true
} }
/** /**
* Retrieves the contents of a file as a Buffer. * Retrieves the contents of a file as a Buffer.
*
* @param {string} fileName - The name of the file to retrieve. * @param {string} fileName - The name of the file to retrieve.
* @return {Promise<Buffer>} A Promise that resolves with the contents of the file as a Buffer. * @throws {NotFoundException} If the file is not found.
* @throws {NotFoundException} If the file could not be found. * @returns {Promise<Buffer>} A Promise that resolves to the file contents as a Buffer.
*/ */
private async getFile(fileName: string): Promise<Buffer> { private async getFile(fileName: string): Promise<Buffer> {
try { try {
@ -99,4 +122,61 @@ export class StorageService {
throw new NotFoundException("File not found"); throw new NotFoundException("File not found");
} }
} }
/**
* Calculates the SHA256 checksum for a given file.
*
* @param {Buffer} file - The file to calculate the checksum for.
* @return {Promise<string>} A Promise resolving to the calculated checksum as a lowercase hexadecimal string.
* @throws {InternalServerErrorException} If an error occurs during the checksum calculation.
*/
public async getChecksum(file: Buffer): Promise<string> {
return new Promise((resolve) => {
try {
const checksum = crypto.createHash("sha256").update(file).digest("hex").toLowerCase();
resolve(checksum)
} catch (err) {
throw new InternalServerErrorException(err)
}
})
}
/**
* Generate file information based on the provided file and display name.
*
* @param {Buffer} file - The file in Buffer format.
* @param {string} fileDisplayName - The display name of the file.
* @param {boolean} [isDocumentation] - Optional flag to indicate if the file is a documentation file.
* @returns {Promise<IFileInformation>} - A Promise that resolves to the generated file information.
*/
public async generateInformation(file: Buffer, fileDisplayName: string, isDocumentation?: boolean): Promise<IFileInformation> {
const fileType = await FileType.fileTypeFromBuffer(file);
const checksum = await this.getChecksum(file)
const fileName = `${isDocumentation ? "doc" : "file"}-${checksum}.${fileType.ext.toLowerCase()}`;
return {
fileName: fileName,
fileDisplayName: fileDisplayName,
fileSize: file.byteLength,
fileChecksum: checksum,
fileType: fileType,
isDocumentation: isDocumentation || false
}
}
public async new(fileDisplayName: string, file: Buffer, isDocumentation?: boolean) {
try {
const info = await this.generateInformation(file, fileDisplayName, isDocumentation);
console.log(`Trying to append a new file : "${info.fileDisplayName}"...\n > Checksum SHA-256 : ${info.fileChecksum}\n > Size : ${info.fileSize / (1024*1024)}Mio\n > File format : ${info.fileType.mime}\n`)
const condition = await this.checkConditions([], file)
if (!condition) {
console.warn(`File "${info.fileDisplayName}" did not pass the files requirement.\n${info.fileChecksum}`)
}
//TODO Append in DB and save to storage
} catch (err) {
throw new BadRequestException(err)
}
}
} }

View File

@ -0,0 +1,17 @@
import FileType from 'file-type';
export interface IFileInformation {
fileDisplayName: string;
fileName: string;
fileChecksum: string;
isDocumentation: boolean;
fileType: FileType.FileTypeResult;
fileSize: number;
}
export interface IFileWithInformation<AdditionalData> {
buffer: Buffer;
info: IFileInformation;
additionalData?: AdditionalData
}