Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Storage Microservice #29

Open
wants to merge 18 commits into
base: development
Choose a base branch
from
Open
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
28 changes: 28 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.next
**/.cache
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/charts
**/docker-compose*
**/compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
**/build
**/dist
LICENSE
README.md
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,5 @@ lerna-debug.log*
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json

*.gz
30 changes: 30 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# syntax=docker/dockerfile:1
ARG NODE_VERSION=20.9.0

FROM node:${NODE_VERSION}-alpine as base

WORKDIR /usr/src/app

COPY package.json yarn.lock ./
RUN yarn install --production

FROM base as build

COPY . .

RUN yarn install

RUN yarn run build

FROM base as final

ENV NODE_ENV production

USER node

COPY --from=build /usr/src/app/dist ./dist
COPY --from=base /usr/src/app/node_modules ./node_modules

EXPOSE 3000

CMD ["yarn", "start"]
118 changes: 62 additions & 56 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,80 +1,86 @@
# Image Storage Microservice
# File Upload Microservice

This microservice implements a basic CRUD for uploading, storing, and retrieving images using NestJS. Images are externally stored in Firebase Storage, and the endpoints support file upload, retrieval, and validation.
Este es un microservicio para la **subida de archivos**, específicamente diseñado para manejar la **subida de fotos**. Este servicio se ha construido utilizando **NestJS**, siguiendo patrones de diseño como **Clean Architecture** y **CQRS** (Command Query Responsibility Segregation) para garantizar una alta escalabilidad, fácil mantenimiento y una separación clara entre la lógica de comandos y consultas.

## Project Description
## Características clave

This project provides a scalable and efficient image storage microservice, featuring:
- **Arquitectura limpia (Clean Architecture)**: Facilita la separación de las responsabilidades dentro de la aplicación, lo que mejora la mantenibilidad y la escalabilidad a medida que el proyecto crece.
- **Patrón CQRS**: Separación de las operaciones de lectura y escritura, lo que optimiza el rendimiento y la claridad en la lógica de negocio.
- **Almacenamiento externo con Firebase**: Utilizamos Firebase como proveedor externo para almacenar los archivos subidos, aprovechando su capacidad de almacenamiento escalable y seguro.
- **Caching con Redis**: Implementamos Redis para caching, lo que mejora el rendimiento general al reducir la latencia en operaciones repetitivas y mejora la eficiencia del sistema.
- **Base de datos con PostgreSQL**: PostgreSQL es la base de datos elegida para este proyecto, donde almacenamos metadatos relacionados con los archivos y otros datos persistentes.

- **Image upload** (with type and size validation).
- **Storage in Firebase Storage**.
- **Image retrieval** via public URLs.
- **Security with JWT authentication** to protect access to the endpoints (Mocked data, not real authentication process required).
## Requisitos previos

Antes de ejecutar este proyecto, asegúrate de tener instalados los siguientes componentes:

### Additional Features (Optional)
1. **Docker** y **Docker Compose**: El proyecto utiliza contenedores Docker para gestionar los servicios de base de datos y caché.
2. **Node.js**: Para ejecutar el servidor NestJS.

- Thumbnail generation (for image resizing).
- Audit logs to track who uploaded which file and when.
- **Redis integration** to cache URLs and improve performance.
## Configuración y ejecución del proyecto

---
### 1. Levantar los servicios de base de datos y caché

## Prerequisites
El primer paso es levantar los contenedores para **PostgreSQL** (la base de datos) y **Redis** (el servicio de caché) utilizando Docker. Desde la carpeta raíz del proyecto, ejecuta el siguiente comando:

Before starting, make sure you have the following installed in your local environment:
```bash
docker-compose up
```

- [Node.js](https://nodejs.org/) (version 20 or higher).
- [NestJS](https://nestjs.com/) (already included in the template).
- [Firebase CLI](https://firebase.google.com/docs/cli) (if using Firebase Storage).
- Redis (optional for URL caching).
Este comando lanzará los servicios necesarios, incluyendo la base de datos y Redis.

---
### 2. Iniciar la aplicación NestJS

## Required Stack
Una vez que los servicios están corriendo, puedes iniciar la aplicación NestJS con el siguiente comando:

You should use the following tech stack during this project:
```bash
nest start file-gateway
```

- TypeORM.
- PostgreSQL.
- Redis (optional).

---
Este comando iniciará el microservicio de subida de archivos, permitiéndote interactuar con los endpoints disponibles.

## Project workflow
## Funcionalidades del servicio

- You should create a forked repository and make a PR when you complete the project.
- A guideline of the tasks required can be found in the issues of this repo and in the following project:
[Cute Digital Media Project](https://github.com/orgs/Cute-Digital-Media/projects/4/views/1)
- Each issue contains the description needed to handle the task.
### 1. **Subida de fotos con generación de miniaturas**

---
Este microservicio incluye un endpoint que permite la **subida de fotos**. Al subir una imagen, el sistema generará automáticamente una **miniatura (thumbnail)** de la imagen y proporcionará una URL para acceder tanto a la imagen original como a la miniatura.

## Installation and Setup
### 2. **Autenticación de usuarios (mock)**

1. **Clone the Repository**
Actualmente, el sistema de autenticación de usuarios está **mockeado**. Esto significa que no se está utilizando un sistema de autenticación real, sino que se simula el proceso de autenticación devolviendo únicamente el ID del usuario. Este ID es suficiente para manejar archivos privados en este contexto.

Clone the GitHub repository to your local machine:
### 3. **Audit logs**

```bash
git clone <REPOSITORY_URL>
cd <PROJECT_NAME>

2. **Install dependencies**
```bash
yarn install

3. **.env variables**
- You should update this point to include env names needed.
El sistema cuenta con un **sistema de auditoría** que registra cada acción realizada por los usuarios, como la subida, modificación o eliminación de archivos. Estos **audit logs** se almacenan en la base de datos y permiten tener un seguimiento completo de quién hizo qué y cuándo.

4. **Start the Service**
```bash
yarn start:dev

6. **Final considerations and recomendations**
- Keep it simple.
- Be organized in your code.
- Don't forget to provide the necessary environment variable names needed to run and test the project.
- Good luck :).
Para evitar sobrecargar el flujo principal de las peticiones, los logs se procesan mediante **eventos de CQRS**, lo que asegura que el registro de auditoría no afecte el rendimiento general del sistema.


## Estructura del proyecto

El proyecto sigue el patrón de **Clean Architecture**, dividiendo las capas de la aplicación en:

- **Controladores**: Manejan las peticiones HTTP.
- **Servicios**: Implementan la lógica de negocio.
- **Repositorios**: Gestionan la persistencia y recuperación de datos desde la base de datos.
- **Módulos**: Agrupan diferentes partes de la aplicación y organizan la lógica en unidades reutilizables.

Además, el patrón **CQRS** divide la aplicación en:

- **Comandos**: Para operaciones que modifican el estado del sistema (como la subida de un archivo).
- **Consultas**: Para operaciones de lectura de datos (como obtener un archivo o su miniatura).

## Tecnologías utilizadas

- **NestJS**: Framework principal para la construcción del microservicio.
- **Firebase**: Para el almacenamiento externo de archivos.
- **PostgreSQL**: Base de datos relacional utilizada para almacenar los metadatos de los archivos.
- **Redis**: Sistema de caché para mejorar el rendimiento.
- **Docker**: Para orquestar los servicios de Redis y PostgreSQL.
- **CQRS**: Patrones de segregación de comandos y consultas para mejorar el rendimiento y la escalabilidad.

## Consideraciones futuras

En futuras versiones del microservicio seria interesante:

- Mejorar la generación de miniaturas, incluyendo diferentes tamaños y formatos.
- Extender las capacidades de almacenamiento a otros proveedores como Amazon S3.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { AuditLogCreatedEventHandler } from "./create/audit-log.create.event";

export const AuditLogEvents = [
AuditLogCreatedEventHandler
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Inject } from "@nestjs/common";
import { EventsHandler, IEventHandler } from "@nestjs/cqrs";
import { IAuditLogRepository } from "apps/file-gateway/src/application/interfaces/reposoitories/iaudit-log.repository";
import { AuditLogEntity } from "apps/file-gateway/src/domain/entities/aduit-log.entity";

export class AuditLogCreatedEvent
{
constructor(
public readonly userId : string ,
public readonly message: string | object,
) {}
}

@EventsHandler(AuditLogCreatedEvent)
export class AuditLogCreatedEventHandler implements IEventHandler<AuditLogCreatedEvent>
{
constructor(
@Inject("IAuditLogRepository")
private readonly auditLogRepository: IAuditLogRepository
) {}

async handle(event: AuditLogCreatedEvent) {
let message = typeof(event.message) == "string"? event.message : JSON.stringify(event.message);
await this.auditLogRepository.saveNew(new AuditLogEntity({userId: event.userId, message}))
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { CommandHandler, ICommandHandler } from "@nestjs/cqrs";
import { CreateFileDto } from "./file.create.dto.command";
import { Inject } from "@nestjs/common";
import { ILoggerService } from "apps/file-gateway/src/application/services/ilogger.service";
import { FileEntity } from "apps/file-gateway/src/domain/entities/file.entity";
import { IFileRepository } from "apps/file-gateway/src/application/interfaces/reposoitories/ifile-repository";
import { InjectMapper } from "@automapper/nestjs";
import { Mapper } from "@automapper/core";
import { Result } from "libs/common/application/base";
import { FilePersistence } from "apps/file-gateway/src/infrastructure/persistence/file.persistence";
import { IFileStorageService } from "apps/file-gateway/src/application/services/ifile-storage.service";
import { ValidMimeTypes } from "apps/file-gateway/src/domain/constants/valid-mime-types.constant";
import { AppError } from "libs/common/application/errors/app.errors";
import { EnvVarsAccessor } from "libs/common/configs/env-vars-accessor";
import { StringExtension } from "apps/file-gateway/src/infrastructure/utils/string-extensions";
import * as sharp from 'sharp';

export class CreateFileCommand
{
constructor(
public fileName: string,
public size: number,
public type: string,
public data: Buffer,
public dto: CreateFileDto,
public userId: string
) {}
}

@CommandHandler(CreateFileCommand)
export class CreateFileCommandHandler implements ICommandHandler<CreateFileCommand, Result<FileEntity>>
{
constructor(
@Inject("IFileRepository")
private readonly fileRepository: IFileRepository,
@Inject("ILoggerService")
private readonly logger : ILoggerService,
@Inject("IFileStorageService")
private readonly storageFileService: IFileStorageService,
@InjectMapper() private readonly mapper: Mapper
) {}
async execute(command: CreateFileCommand): Promise<Result<FileEntity>> {
const { size, type, userId, fileName} = command;
await this.logger.auditAsync(userId,`Create a new file with extension ${command.type}.`)

const { isPrivate } = command.dto;

const generatedFileName = StringExtension.generateFileName(fileName);
this.logger.info(`New file name generated: ${generatedFileName}.`)

if(!ValidMimeTypes.includes(type))
{
this.logger.error(`Invalid mime type ${type}.`)
return Result.Fail(new AppError.ValidationError(`Invalid mime type ${type}.`))
}
await this.logger.auditAsync(userId,`Uploading file to storage with name: ${generatedFileName}.`)
const uploadResult = await this.storageFileService.uploadFileAsync(generatedFileName,command.data,isPrivate,type);
if(uploadResult.isFailure)
{
this.logger.error(`An error ocurred uploading the file.`)
return Result.Fail(uploadResult.unwrapError())
}
this.logger.info(`Creating thumbnail.`)
const thumbnailBuffer = await sharp(command.data)
.resize(200)
.toBuffer();
const thumbnailFileName = generatedFileName + "_thumbnail"
const thumbnailUploadResult = await this.storageFileService.uploadFileAsync(thumbnailFileName,thumbnailBuffer,isPrivate,type);
if(thumbnailUploadResult.isFailure)
{
this.logger.error(`An error ocurred uploading the thumb nail file.`)
return Result.Fail(thumbnailUploadResult.unwrapError())
}

const fileUrl = EnvVarsAccessor.MS_HOST + ":" + EnvVarsAccessor.MS_PORT + "/api/fileGW/file/" + generatedFileName;
const file = new FileEntity({
fileName: generatedFileName,
originalFileName: fileName,
type,
size,
url: fileUrl,
userId,
isPrivate,
thumbnailFileName
});

const saveResult = await this.fileRepository.saveNew(file)
const mapped = this.mapper.map(saveResult,FilePersistence, FileEntity);
return Result.Ok(mapped);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class CreateFileDto {
public isPrivate: boolean;
}


Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { CommandHandler, ICommandHandler } from "@nestjs/cqrs";
import { Inject } from "@nestjs/common";
import { ILoggerService } from "apps/file-gateway/src/application/services/ilogger.service";
import { IFileRepository } from "apps/file-gateway/src/application/interfaces/reposoitories/ifile-repository";
import { InjectMapper } from "@automapper/nestjs";
import { Mapper } from "@automapper/core";
import { Result } from "libs/common/application/base";
import { IFileStorageService } from "apps/file-gateway/src/application/services/ifile-storage.service";
import { AppError } from "libs/common/application/errors/app.errors";

export class DeleteFileCommand
{
constructor(
public fileName: string,
public userId: string
) {}
}

@CommandHandler(DeleteFileCommand)
export class DeleteFileCommandHandler implements ICommandHandler<DeleteFileCommand, Result<void>>
{
constructor(
@Inject("IFileRepository")
private readonly fileRepository: IFileRepository,
@Inject("ILoggerService")
private readonly logger : ILoggerService,
@Inject("IFileStorageService")
private readonly fileStorageService: IFileStorageService
) {}
async execute(command: DeleteFileCommand): Promise<Result<void>> {
const { fileName, userId } = command;
this.logger.info(`User with id ${userId} is trying to delete file with name ${command.fileName}.`)

const file = await this.fileRepository.findOneByFilter({
where: {
fileName
}
})
if(!file)
{
this.logger.error(`File with name ${fileName} not found.`)
return Result.Fail(new AppError.NotFoundError(`File not found.`))
}
const isPrivate = file.props.isPrivate;

const ans = await this.fileStorageService.deleteFileAsync(fileName,isPrivate);
if(ans.isFailure)
{
this.logger.error(`Error deleting the file.`)
return ans;
}
return Result.Ok(ans.unwrap())
}
}
Loading