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 { StorageService } from "apps/backend/src/app/storage/storage.service";
import { DbModule } from 'apps/backend/src/app/db/db.module';
@Module({
imports: [DbModule],
providers: [StorageService],
})
export class StorageModule {}

View File

@ -9,45 +9,25 @@ import {
NotFoundException,
} from "@nestjs/common";
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()
export class StorageService {
/**
* Saves the given file with a generated file name that includes a prefix,
* the checksum of the file, and the file type extension.
*
* @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
}
//TODO make file size configurable by admin
private maxFileSize = 256; // MiB unit
constructor(private readonly dbService: DbService) {}
/**
* 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.
* @return {string} - The hexadecimal representation of the checksum.
*/
private getChecksum(file: Buffer): string {
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.
* @param {string} fileName - The name of the file.
* @param {Buffer} file - The file content in Buffer format.
* @throws {InternalServerErrorException} If the file save fails.
* @return {Promise<void>} A Promise that resolves when the file is successfully saved.
*/
private async saveFile(fileName: string, file: Buffer): Promise<void> {
try {
@ -59,38 +39,81 @@ export class StorageService {
}
/**
* Checks the conditions for a given file.
*
* @param file - The file to check.
* @returns The checksum of the file.
* @throws BadRequestException - If the file type is invalid or the file size exceeds the limit.
* Checks if the current MIME type and file size meet the specified conditions.
* @param {Array<string>} machineIds - The IDs of the associated machines.
* @param {Buffer} file - The file to check.
* @return {Promise<boolean>} - A Promise that resolves to true if the conditions are met, false otherwise.
*/
private async checkConditions(file: Buffer): Promise<string> {
const checksum = this.getChecksum(file);
const fileType = await FileType.fileTypeFromBuffer(file);
//TODO make file type configurable by admin
if (!fileType || !fileType.mime.startsWith("image/")) {
throw new BadRequestException(
"Invalid file type. Only images are allowed.",
);
private async checkConditions(machineIds: Array<string>,file: Buffer): Promise<boolean> {
/**
* Checks if the current MIME type is allowed based on the given set of allowed MIME types.
* @param {Set<string>} allowedMime - The set of allowed MIME types.
* @param {string} currentMime - The current MIME type to check.
* @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
const fileSize = file.byteLength;
//TODO make file size configurable by admin
if (fileSize > 2 * 1024 * 1024) {
if (fileSize > this.maxFileSize * (1024 * 1024)) {
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.
*
* @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 could not be found.
* @throws {NotFoundException} If the file is not found.
* @returns {Promise<Buffer>} A Promise that resolves to the file contents as a Buffer.
*/
private async getFile(fileName: string): Promise<Buffer> {
try {
@ -99,4 +122,61 @@ export class StorageService {
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
}