Skip to content
This repository has been archived by the owner on Sep 18, 2024. It is now read-only.

feat(files): Add endpoints to retrieve data of files #20

Draft
wants to merge 7 commits into
base: develop
Choose a base branch
from
Draft
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
2 changes: 1 addition & 1 deletion src/exported
5 changes: 4 additions & 1 deletion src/i18n/en-US/validations.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@
"file": {
"invalid": {
"not_provided": "File not provided",
"not_found": "File '{filename}' cannot be found on disk",
"not_found": {
"on_disk": "File '{filename}' cannot be found on disk",
"by_id": "File with ID '{id}' not found"
},
"no_mime_type": "File has no mime type",
"unauthorized_mime_type": "File has an unauthorized mime type, valid mime types are ['{mime_types}']",
"infected": "File is infected with virus"
Expand Down
3 changes: 3 additions & 0 deletions src/modules/files/dto/output.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ export class OutputFileDTO implements OutputFileDto {

@ApiProperty()
description?: string;

@ApiProperty()
owner: { kind: 'user' | 'promotion'; id: number };
}

export class OutputFileVisibilityGroupDTO extends OutputBaseDTO implements OutputFileVisibilityGroupDto {
Expand Down
32 changes: 32 additions & 0 deletions src/modules/files/files.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';

import { ApiNotOkResponses } from '@modules/base/decorators';
import { InputIdParamDTO } from '@modules/base/dto/input.dto';
import { Request } from '@modules/users/entities/user.entity';

import { OutputFileDTO } from './dto/output.dto';
import { FilesService } from './files.service';

@ApiTags('Files')
@Controller('files')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
export class FilesController {
constructor(private readonly filesService: FilesService) {}

@Get(':id/data')
@ApiOperation({ summary: 'Get file data' })
@ApiParam({ name: 'id', description: 'The file ID' })
@ApiOkResponse({ type: OutputFileDTO })
@ApiNotOkResponses({
400: 'Invalid ID',
401: 'Not in file visibility group',
404: 'File not found',
})
async getFile(@Req() req: Request, @Param() params: InputIdParamDTO): Promise<OutputFileDTO> {
const file = await this.filesService.findOne(params.id);
return this.filesService.getAsData(file, req.user.id);
}
}
2 changes: 2 additions & 0 deletions src/modules/files/files.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import { EmailsService } from '@modules/emails/emails.service';
import { UsersDataService } from '@modules/users/services/users-data.service';

import { FileVisibilityGroup } from './entities/file-visibility.entity';
import { FilesController } from './files.controller';
import { FilesService } from './files.service';
import { ImagesService } from './images.service';

@Module({
imports: [MikroOrmModule.forFeature([FileVisibilityGroup])],
providers: [EmailsService, FilesService, ImagesService, UsersDataService],
controllers: [FilesController],
exports: [FilesService],
})
export class FilesModule {}
27 changes: 22 additions & 5 deletions src/modules/files/files.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,17 @@ import { accessSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { join } from 'path';
import { Readable } from 'stream';

import { MikroORM, CreateRequestContext } from '@mikro-orm/core';
import { MikroORM, CreateRequestContext, Loaded } from '@mikro-orm/core';
import { Injectable, StreamableFile } from '@nestjs/common';
import { fromBuffer, MimeType } from 'file-type';

import { i18nBadRequestException, i18nNotFoundException, i18nUnauthorizedException } from '@modules/base/http-errors';
import { User } from '@modules/users/entities/user.entity';
import { UsersDataService } from '@modules/users/services/users-data.service';

import { OutputFileDTO } from './dto/output.dto';
import { FileVisibilityGroup } from './entities/file-visibility.entity';
import { File } from './entities/file.entity';
import { File, FileKind } from './entities/file.entity';

export type WriteFileOptions = {
directory: string;
Expand All @@ -39,6 +40,22 @@ export class FilesService {
return new StreamableFile(this.toReadable(file));
}

async getAsData(file: File<unknown>, user_id: number): Promise<OutputFileDTO> {
if (!(await this.canReadFile(file, user_id)))
throw new i18nUnauthorizedException('validations.user.invalid.not_in_file_visibility_group', {
group_name: file.visibility?.name,
});

return file.toObject() as unknown as OutputFileDTO;
}

async findOne(id: number): Promise<FileKind> {
const file = (await this.orm.em.findOne(File, { id })) as unknown as Loaded<FileKind, string>;
if (!file) throw new i18nNotFoundException('validations.file.invalid.not_found.by_id', { id });

return file;
}

/**
* Determine if the given user can read the given file.
* @param {File<unknown>} file - The file to check the visibility of.
Expand Down Expand Up @@ -135,12 +152,12 @@ export class FilesService {
* @param {File} file The file to delete
* @param {boolean} silent If true, the function will not throw an error if the file doesn't exist
*/
deleteFromDisk(file: File<unknown>, silent: boolean = true) {
deleteFromDisk<T>(file: File<T>, silent: boolean = true) {
try {
accessSync(file.path);
} catch {
if (silent) return;
throw new i18nNotFoundException('validations.file.invalid.not_found', {
throw new i18nNotFoundException('validations.file.invalid.not_found.on_disk', {
filename: file.filename,
});
}
Expand All @@ -157,7 +174,7 @@ export class FilesService {
try {
accessSync(file.path);
} catch {
throw new i18nNotFoundException('validations.file.invalid.not_found', {
throw new i18nNotFoundException('validations.file.invalid.not_found.on_disk', {
filename: file.filename,
});
}
Expand Down
11 changes: 9 additions & 2 deletions src/modules/files/images.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,15 @@ type WriteImageOptions = WriteFileOptions & {
export class ImagesService extends FilesService {
async validateAspectRatio(buffer: Buffer, aspect_ratio: aspect_ratio): Promise<boolean> {
const { width, height } = await sharp(buffer).metadata();
const [aspectWidth, aspectHeight] = aspect_ratio.split(':').map((s) => parseInt(s, 10));
return Math.abs(width / height - aspectWidth / aspectHeight) < Number.EPSILON;

const gcd = (...arr: number[]): number => {
const _gcd = (x: number, y: number) => (!y ? x : gcd(y, x % y));
return [...arr].reduce((a, b) => _gcd(a, b));
};

const gcdResult = gcd(width, height);

return `${width / gcdResult}:${height / gcdResult}` === aspect_ratio;
}

async convertToWebp(buffer: Buffer): Promise<Buffer> {
Expand Down
8 changes: 1 addition & 7 deletions src/modules/promotions/dto/output.dto.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import type { OutputPromotionPictureDto, OutputPromotionDto } from '#types/api';
import type { OutputPromotionDto } from '#types/api';

import { ApiProperty } from '@nestjs/swagger';

import { OutputBaseDTO } from '@modules/base/dto/output.dto';
import { OutputFileDTO } from '@modules/files/dto/output.dto';

export class OutputPromotionDTO extends OutputBaseDTO implements OutputPromotionDto {
@ApiProperty()
Expand All @@ -15,8 +14,3 @@ export class OutputPromotionDTO extends OutputBaseDTO implements OutputPromotion
@ApiProperty({ required: false })
picture?: number;
}

export class OutputPromotionPictureDTO extends OutputFileDTO implements OutputPromotionPictureDto {
@ApiProperty()
picture_promotion_id: number;
}
4 changes: 2 additions & 2 deletions src/modules/promotions/entities/promotion-picture.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ export class PromotionPicture extends File<Promotion> {
@OneToOne(() => Promotion, (promotion) => promotion.picture, {
nullable: true,
owner: true,
serializedName: 'picture_promotion_id',
serializer: (p: Promotion) => p.id,
serializedName: 'owner',
serializer: (p: Promotion) => ({ kind: 'promotion', id: p?.id }),
})
picture_promotion: Promotion;

Expand Down
8 changes: 6 additions & 2 deletions src/modules/promotions/promotions.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { OutputMessageDTO } from '@modules/base/dto/output.dto';
import { i18nBadRequestException } from '@modules/base/http-errors';
import { ApiDownloadFile } from '@modules/files/decorators/download.decorator';
import { ApiUploadFile } from '@modules/files/decorators/upload.decorator';
import { OutputFileDTO } from '@modules/files/dto/output.dto';
import { FilesService } from '@modules/files/files.service';
import { Request } from '@modules/users/entities/user.entity';

Expand Down Expand Up @@ -57,9 +58,12 @@ export class PromotionsController {
@ApiOperation({ summary: 'Update the promotion logo' })
@ApiUploadFile()
@ApiParam({ name: 'number', description: 'The promotion number (eg: 21)' })
@ApiOkResponse({ type: OutputPromotionDTO })
@ApiOkResponse({ type: OutputFileDTO })
@ApiNotOkResponses({ 400: 'Invalid file', 404: 'Promotion not found' })
async editLogo(@UploadedFile() file: Express.Multer.File, @Param() params: InputPromotionNumberParamDTO) {
async editLogo(
@UploadedFile() file: Express.Multer.File,
@Param() params: InputPromotionNumberParamDTO,
): Promise<OutputFileDTO> {
if (!file) throw new i18nBadRequestException('validations.file.invalid.not_provided');

return this.promotionsService.updateLogo(params.number, file);
Expand Down
7 changes: 4 additions & 3 deletions src/modules/promotions/promotions.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import { Cron } from '@nestjs/schedule';
import { env } from '@env';
import { OutputMessageDTO } from '@modules/base/dto/output.dto';
import { i18nNotFoundException } from '@modules/base/http-errors';
import { OutputFileDTO } from '@modules/files/dto/output.dto';
import { ImagesService } from '@modules/files/images.service';

import { OutputPromotionPictureDTO, OutputPromotionDTO } from './dto/output.dto';
import { OutputPromotionDTO } from './dto/output.dto';
import { PromotionPicture } from './entities/promotion-picture.entity';
import { Promotion } from './entities/promotion.entity';
import { OutputBaseUserDTO } from '../users/dto/output.dto';
Expand Down Expand Up @@ -90,7 +91,7 @@ export class PromotionsService {
}

@CreateRequestContext()
async updateLogo(number: number, file: Express.Multer.File): Promise<OutputPromotionPictureDTO> {
async updateLogo(number: number, file: Express.Multer.File): Promise<OutputFileDTO> {
const promotion = await this.orm.em.findOne(Promotion, { number }, { populate: ['picture'] });

if (!promotion) throw new i18nNotFoundException('validations.promotion.invalid.not_found', { number });
Expand Down Expand Up @@ -120,7 +121,7 @@ export class PromotionsService {
});

await this.orm.em.persistAndFlush(promotion);
return promotion.picture.toObject() as unknown as OutputPromotionPictureDTO;
return promotion.picture.toObject() as unknown as OutputFileDTO;
}

@CreateRequestContext()
Expand Down
10 changes: 5 additions & 5 deletions src/modules/users/controllers/users-files.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ import { OutputMessageDTO } from '@modules/base/dto/output.dto';
import { i18nBadRequestException } from '@modules/base/http-errors';
import { ApiDownloadFile } from '@modules/files/decorators/download.decorator';
import { ApiUploadFile } from '@modules/files/decorators/upload.decorator';
import { OutputFileDTO } from '@modules/files/dto/output.dto';
import { FilesService } from '@modules/files/files.service';

import { OutputUserBannerDTO, OutputUserPictureDTO } from '../dto/output.dto';
import { Request } from '../entities/user.entity';
import { UsersFilesService } from '../services/users-files.service';

Expand All @@ -30,14 +30,14 @@ export class UsersFilesController {
@GuardSelfOrPermissions('id', ['CAN_EDIT_USER'])
@ApiOperation({ summary: 'Update user profile picture' })
@ApiParam({ name: 'id', description: 'The user ID' })
@ApiOkResponse({ description: 'The updated user picture', type: OutputUserPictureDTO })
@ApiOkResponse({ description: 'The updated user picture', type: OutputFileDTO })
@ApiNotOkResponses({ 400: 'Invalid user ID or missing uploaded file', 404: 'User not found' })
@ApiUploadFile()
async editPicture(
@Req() req: Request,
@Param() params: InputIdParamDTO,
@UploadedFile() file: Express.Multer.File,
): Promise<OutputUserPictureDTO> {
): Promise<OutputFileDTO> {
if (!file) throw new i18nBadRequestException('validations.file.invalid.not_provided');
return this.usersFilesService.updatePicture(req.user, params.id, file);
}
Expand Down Expand Up @@ -68,13 +68,13 @@ export class UsersFilesController {
@GuardSelfOrPermissions('id', ['CAN_EDIT_USER'])
@ApiOperation({ summary: 'Update user profile banner' })
@ApiParam({ name: 'id', description: 'The user ID' })
@ApiOkResponse({ description: 'The updated user banner', type: OutputUserBannerDTO })
@ApiOkResponse({ description: 'The updated user banner', type: OutputFileDTO })
@ApiNotOkResponses({ 400: 'Invalid user ID or missing uploaded file', 404: 'User not found' })
@ApiUploadFile()
async editBanner(
@Param() params: InputIdParamDTO,
@UploadedFile() file: Express.Multer.File,
): Promise<OutputUserBannerDTO> {
): Promise<OutputFileDTO> {
if (!file) throw new i18nBadRequestException('validations.file.invalid.not_provided');
return this.usersFilesService.updateBanner(params.id, file);
}
Expand Down
21 changes: 2 additions & 19 deletions src/modules/users/dto/output.dto.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,12 @@
import type { email } from '#types';
import type {
OutputUserBannerDto,
OutputUserPictureDto,
OutputUserRoleDto,
OutputUserVisibilityDto,
OutputBaseUserDto,
} from '#types/api';
import type { OutputUserRoleDto, OutputUserVisibilityDto, OutputBaseUserDto } from '#types/api';

import { ApiProperty } from '@nestjs/swagger';

import { OutputUserDto, PERMISSION_NAMES, GENDERS } from '#types/api';
import { USER_GENDER } from '@exported/api/constants/genders';
import { PERMISSIONS_NAMES } from '@exported/api/constants/perms';
import { OutputBaseDTO } from '@modules/base/dto/output.dto';
import { OutputFileDTO } from '@modules/files/dto/output.dto';

export class OutputBaseUserDTO extends OutputBaseDTO implements OutputBaseUserDto {
@ApiProperty()
Expand Down Expand Up @@ -102,7 +95,7 @@ export class OutputUserDTO extends OutputBaseDTO implements OutputUserDto {
verified?: Date;
}

export class OutputUserVisibilityDTO implements OutputUserVisibilityDto {
export class OutputUserVisibilityDTO extends OutputBaseDTO implements OutputUserVisibilityDto {
@ApiProperty({ minimum: 1 })
user_id: number;

Expand Down Expand Up @@ -130,13 +123,3 @@ export class OutputUserVisibilityDTO implements OutputUserVisibilityDto {
@ApiProperty({ type: Boolean, default: false })
parents_phone: boolean;
}

export class OutputUserPictureDTO extends OutputFileDTO implements OutputUserPictureDto {
@ApiProperty({ minimum: 1 })
picture_user_id: number;
}

export class OutputUserBannerDTO extends OutputFileDTO implements OutputUserBannerDto {
@ApiProperty({ minimum: 1 })
banner_user_id: number;
}
4 changes: 2 additions & 2 deletions src/modules/users/entities/user-banner.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ export class UserBanner extends File<User> {
@OneToOne(() => User, (user) => user.banner, {
nullable: true,
owner: true,
serializedName: 'banner_user_id',
serializer: (u: User) => u.id,
serializedName: 'owner',
serializer: (u: User) => ({ kind: 'user', id: u?.id }),
})
banner_user: User;

Expand Down
4 changes: 2 additions & 2 deletions src/modules/users/entities/user-picture.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ export class UserPicture extends File<User> {
@OneToOne(() => User, (user) => user.picture, {
nullable: true,
owner: true,
serializedName: 'picture_user_id',
serializer: (u: User) => u.id,
serializedName: 'owner',
serializer: (u: User) => ({ kind: 'user', id: u?.id }),
})
picture_user: User;

Expand Down
Loading