diff options
| author | Leonardo Bishop <me@leonardobishop.com> | 2024-03-15 20:23:41 +0000 |
|---|---|---|
| committer | Leonardo Bishop <me@leonardobishop.com> | 2024-03-15 20:23:41 +0000 |
| commit | aeb8b66375335e8c9d6cb9cb0d8d7da3d8b79628 (patch) | |
| tree | 0ddaa24fc536ac03493d6357e481df31dad77b16 /src | |
Initial commit
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.module.ts | 43 | ||||
| -rw-r--r-- | src/config/configuration.ts | 10 | ||||
| -rw-r--r-- | src/file/controller/file.controller.ts | 75 | ||||
| -rw-r--r-- | src/file/dto/create-file.dto.ts | 12 | ||||
| -rw-r--r-- | src/file/dto/retrieve-file.dto.ts | 6 | ||||
| -rw-r--r-- | src/file/dto/session-key.dto.ts | 6 | ||||
| -rw-r--r-- | src/file/entity/file.entity.ts | 47 | ||||
| -rw-r--r-- | src/file/entity/purpose.entity.ts | 10 | ||||
| -rw-r--r-- | src/file/file.module.ts | 17 | ||||
| -rw-r--r-- | src/file/service/file.service.ts | 96 | ||||
| -rw-r--r-- | src/main.ts | 10 | ||||
| -rw-r--r-- | src/seeder/seeding.service.ts | 29 | ||||
| -rw-r--r-- | src/session/controller/session.controller.ts | 20 | ||||
| -rw-r--r-- | src/session/entity/session.entity.ts | 30 | ||||
| -rw-r--r-- | src/session/service/session.service.ts | 70 | ||||
| -rw-r--r-- | src/session/session.module.ts | 13 | ||||
| -rw-r--r-- | src/storage/entity/stored-file.entity.ts | 18 | ||||
| -rw-r--r-- | src/storage/service/storage-cron.service.ts | 34 | ||||
| -rw-r--r-- | src/storage/service/storage.service.ts | 21 | ||||
| -rw-r--r-- | src/storage/storage.module.ts | 13 |
20 files changed, 580 insertions, 0 deletions
diff --git a/src/app.module.ts b/src/app.module.ts new file mode 100644 index 0000000..b79f2c7 --- /dev/null +++ b/src/app.module.ts @@ -0,0 +1,43 @@ +import { Module } from '@nestjs/common'; +import { SessionModule } from './session/session.module'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { FileModule } from './file/file.module'; +import { SeedingService } from './seeder/seeding.service'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import configuration from './config/configuration'; +import { ScheduleModule } from '@nestjs/schedule'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + load: [configuration], + }), + TypeOrmModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => { + return { + type: 'mysql', + host: configService.get<string>('database.host'), + port: configService.get<number>('database.port'), + username: configService.get<string>('database.username'), + password: configService.get<string>('database.password'), + database: configService.get<string>('database.database'), + autoLoadEntities: true, + synchronize: true, + }; + }, + }), + ScheduleModule.forRoot(), + SessionModule, + FileModule, + ], + providers: [SeedingService], +}) +export class AppModule { + constructor(private readonly seedingService: SeedingService) {} + + async onApplicationBootstrap(): Promise<void> { + await this.seedingService.seed(); + } +} diff --git a/src/config/configuration.ts b/src/config/configuration.ts new file mode 100644 index 0000000..f409205 --- /dev/null +++ b/src/config/configuration.ts @@ -0,0 +1,10 @@ +export default () => ({ + port: parseInt(process.env.PORT, 10) || 3000, + database: { + host: process.env.DATABASE_HOST, + port: parseInt(process.env.DATABASE_PORT, 10) || 3306, + username: process.env.DATABASE_USERNAME, + password: process.env.DATABASE_PASSWORD, + database: process.env.DATABASE_DATABASE, + }, +}); diff --git a/src/file/controller/file.controller.ts b/src/file/controller/file.controller.ts new file mode 100644 index 0000000..d7632ed --- /dev/null +++ b/src/file/controller/file.controller.ts @@ -0,0 +1,75 @@ +import { Body, Controller, ForbiddenException, Get, GoneException, Param, Post } from '@nestjs/common'; +import { SessionService } from '../../session/service/session.service'; +import { SessionTokenDto } from '../dto/session-key.dto'; +import { CreateFileDto } from '../dto/create-file.dto'; +import { FileService } from '../service/file.service'; + +@Controller('file') +export class FileController { + constructor( + private readonly sessionService: SessionService, + private readonly fileService: FileService, + ) {} + + @Post('retrieve-keys') + async use(@Body() sessionTokenDto: SessionTokenDto) { + const sessionMarkedAsUsed = await this.sessionService.markSessionAsUsed(sessionTokenDto.token); + + if (!sessionMarkedAsUsed) { + throw new ForbiddenException(); + } + + const files = await this.fileService.findManyValid(sessionTokenDto.token); + + return { + files: files.map((file) => ({ + key: file.key, + purpose: file.purpose?.name, + validUntil: file.validUntil, + })), + }; + } + + @Post('create') + async create(@Body() createFileDto: CreateFileDto) { + const session = await this.sessionService.findValid(createFileDto.token); + + if (!session) { + throw new ForbiddenException(); + } + + const data = JSON.stringify(createFileDto.data); + const b64EncodedData = Buffer.from(data).toString('base64'); + const validTo = new Date(Date.now() + 1000 * 60 * 30); + + const file = await this.fileService.createFile(b64EncodedData, createFileDto.purpose, validTo, session); + return { + creationDate: file.creationDate, + validUntil: file.validUntil, + purpose: file.purpose, + session: { + validUntil: file.session.validUntil, + }, + }; + } + + @Get('retrieve-file/:key') + async retrieve(@Param('key') key: string) { + const fileMarkedAsUsed = await this.fileService.markFileAsUsed(key); + + if (!fileMarkedAsUsed) { + throw new ForbiddenException(); + } + + const fileData = await this.fileService.fetchFileContents(key); + if (!fileData) { + throw new GoneException('This file has been deleted'); + } + + const json = JSON.parse(Buffer.from(fileData, 'base64').toString('utf8')); + + return { + data: json, + }; + } +} diff --git a/src/file/dto/create-file.dto.ts b/src/file/dto/create-file.dto.ts new file mode 100644 index 0000000..0fbe4b6 --- /dev/null +++ b/src/file/dto/create-file.dto.ts @@ -0,0 +1,12 @@ +import { IsNotEmpty } from 'class-validator'; + +export class CreateFileDto { + @IsNotEmpty() + token: string; + + @IsNotEmpty() + purpose: string; + + @IsNotEmpty() + data: any; +} diff --git a/src/file/dto/retrieve-file.dto.ts b/src/file/dto/retrieve-file.dto.ts new file mode 100644 index 0000000..4623e28 --- /dev/null +++ b/src/file/dto/retrieve-file.dto.ts @@ -0,0 +1,6 @@ +import { IsNotEmpty } from 'class-validator'; + +export class RetrieveFileDto { + @IsNotEmpty() + key: string; +} diff --git a/src/file/dto/session-key.dto.ts b/src/file/dto/session-key.dto.ts new file mode 100644 index 0000000..fd227d7 --- /dev/null +++ b/src/file/dto/session-key.dto.ts @@ -0,0 +1,6 @@ +import { IsNotEmpty } from 'class-validator'; + +export class SessionTokenDto { + @IsNotEmpty() + token: string; +} diff --git a/src/file/entity/file.entity.ts b/src/file/entity/file.entity.ts new file mode 100644 index 0000000..49c0ecc --- /dev/null +++ b/src/file/entity/file.entity.ts @@ -0,0 +1,47 @@ +import { nanoid } from 'nanoid'; +import { Session } from '../../session/entity/session.entity'; +import { + Entity, + Column, + BeforeInsert, + CreateDateColumn, + PrimaryGeneratedColumn, + ManyToOne, + Index, + OneToOne, +} from 'typeorm'; +import { FilePurpose } from './purpose.entity'; +import { StoredFile } from 'src/storage/entity/stored-file.entity'; + +@Entity() +export class FileMetadata { + @PrimaryGeneratedColumn() + id: number; + + @Index('filekey-idx') + @Column({ unique: true, nullable: false }) + key: string; + + @ManyToOne(() => FilePurpose, { nullable: false }) + purpose: FilePurpose; + + @CreateDateColumn() + creationDate: Date; + + @Column() + validUntil: Date; + + @Column({ default: false }) + used: boolean; + + @ManyToOne(() => Session, (session) => session.files, { nullable: false }) + session: Session; + + @OneToOne(() => StoredFile, (file) => file.fileMetadata, { nullable: true }) + contents: StoredFile; + + @BeforeInsert() + private beforeInsert() { + this.key = nanoid(); + } +} diff --git a/src/file/entity/purpose.entity.ts b/src/file/entity/purpose.entity.ts new file mode 100644 index 0000000..27c3ad6 --- /dev/null +++ b/src/file/entity/purpose.entity.ts @@ -0,0 +1,10 @@ +import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity() +export class FilePurpose { + @PrimaryGeneratedColumn() + id: number; + + @Column({ nullable: false }) + name: string; +} diff --git a/src/file/file.module.ts b/src/file/file.module.ts new file mode 100644 index 0000000..6f5329f --- /dev/null +++ b/src/file/file.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { FileController } from './controller/file.controller'; +import { SessionModule } from '../session/session.module'; +import { FileMetadata } from './entity/file.entity'; +import { FilePurpose } from './entity/purpose.entity'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { FileService } from './service/file.service'; +import { Session } from 'src/session/entity/session.entity'; +import { StorageModule } from 'src/storage/storage.module'; +import { StoredFile } from 'src/storage/entity/stored-file.entity'; + +@Module({ + imports: [SessionModule, StorageModule, TypeOrmModule.forFeature([FileMetadata, FilePurpose, Session, StoredFile])], + controllers: [FileController], + providers: [FileService], +}) +export class FileModule {} diff --git a/src/file/service/file.service.ts b/src/file/service/file.service.ts new file mode 100644 index 0000000..24cb55d --- /dev/null +++ b/src/file/service/file.service.ts @@ -0,0 +1,96 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { FileMetadata } from '../entity/file.entity'; +import { FilePurpose } from '../entity/purpose.entity'; +import { DataSource, Repository } from 'typeorm'; +import { Session } from '../../session/entity/session.entity'; +import { StorageService } from 'src/storage/service/storage.service'; + +@Injectable() +export class FileService { + constructor( + @InjectRepository(FileMetadata) + private fileMetadataRepository: Repository<FileMetadata>, + + @InjectRepository(FilePurpose) + private filePurposeRepository: Repository<FilePurpose>, + + private readonly storageService: StorageService, + private readonly dataSource: DataSource, + ) {} + + async createFile( + b64Contents: string, + purpose: string, + validTo: Date, + session: Session, + ): Promise<FileMetadata | null> { + const filePurpose = await this.filePurposeRepository.findOneBy({ name: purpose }); + if (!filePurpose) { + throw new NotFoundException(`Unknown file purpose: ${filePurpose}`); + } + + const file = new FileMetadata(); + file.purpose = filePurpose; + file.session = session; + file.validUntil = validTo; + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + try { + await this.fileMetadataRepository.save(file); + await this.storageService.createFile(b64Contents, file); + + await queryRunner.commitTransaction(); + } catch (err) { + await queryRunner.rollbackTransaction(); + + throw err; + } finally { + queryRunner.release(); + } + + return file; + } + + async markFileAsUsed(fileKey: string): Promise<boolean> { + const update = await this.fileMetadataRepository + .createQueryBuilder() + .update({ + used: true, + }) + .where('validUntil > :date', { + date: new Date(Date.now()), + }) + .andWhere({ + key: fileKey, + used: false, + }) + .execute(); + + return update.affected > 0; + } + + async fetchFileContents(fileKey: string): Promise<string | null> { + const file = await this.fileMetadataRepository.findOne({ + relations: { + contents: true, + }, + where: { + key: fileKey, + }, + }); + + return file?.contents?.b64Contents; + } + + async findManyValid(sessionToken: string): Promise<FileMetadata[]> { + return this.fileMetadataRepository + .createQueryBuilder('file') + .innerJoinAndSelect('file.session', 'session', 'session.token = :token', { token: sessionToken }) + .leftJoinAndSelect('file.purpose', 'purpose') + .where('file.used = :used', { used: false }) + .getMany(); + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..a01b32f --- /dev/null +++ b/src/main.ts @@ -0,0 +1,10 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { ValidationPipe } from '@nestjs/common'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + app.useGlobalPipes(new ValidationPipe()); + await app.listen(3000); +} +bootstrap(); diff --git a/src/seeder/seeding.service.ts b/src/seeder/seeding.service.ts new file mode 100644 index 0000000..f145eb5 --- /dev/null +++ b/src/seeder/seeding.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; +import { FilePurpose } from 'src/file/entity/purpose.entity'; +import { EntityManager } from 'typeorm'; + +@Injectable() +export class SeedingService { + constructor(private readonly entityManager: EntityManager) {} + + async seed(): Promise<void> { + await this.entityManager.save(FilePurpose, [ + { + id: 1, + name: 'QUESTS_FILE', + }, + { + id: 2, + name: 'CATEGORIES_FILE', + }, + { + id: 3, + name: 'ITEMS_FILE', + }, + { + id: 4, + name: 'MAIN_CONFIGURATION_FILE', + }, + ]); + } +} diff --git a/src/session/controller/session.controller.ts b/src/session/controller/session.controller.ts new file mode 100644 index 0000000..23508bb --- /dev/null +++ b/src/session/controller/session.controller.ts @@ -0,0 +1,20 @@ +import { Controller, Post } from '@nestjs/common'; +import { SessionService } from '../service/session.service'; + +@Controller('session') +export class SessionController { + constructor(private readonly sessionService: SessionService) {} + + @Post('create') + async create() { + const validTo = new Date(Date.now() + 1000 * 60 * 15); + + const session = await this.sessionService.createSession(validTo); + + return { + token: session.token, + creationDate: session.creationDate, + validUntil: session.validUntil, + }; + } +} diff --git a/src/session/entity/session.entity.ts b/src/session/entity/session.entity.ts new file mode 100644 index 0000000..a4f13ae --- /dev/null +++ b/src/session/entity/session.entity.ts @@ -0,0 +1,30 @@ +import { FileMetadata } from '../../file/entity/file.entity'; +import { Entity, Column, BeforeInsert, CreateDateColumn, PrimaryGeneratedColumn, Index, OneToMany } from 'typeorm'; +import { nanoid } from 'nanoid'; + +@Entity() +export class Session { + @PrimaryGeneratedColumn() + id: number; + + @Index('sessiontoken-idx') + @Column({ unique: true }) + token: string; + + @CreateDateColumn() + creationDate: Date; + + @Column() + validUntil: Date; + + @Column({ default: false }) + used: boolean; + + @OneToMany(() => FileMetadata, (file) => file.session) + files: FileMetadata[]; + + @BeforeInsert() + private beforeInsert() { + this.token = nanoid(); + } +} diff --git a/src/session/service/session.service.ts b/src/session/service/session.service.ts new file mode 100644 index 0000000..0712fb0 --- /dev/null +++ b/src/session/service/session.service.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Session } from '../entity/session.entity'; +import { Repository } from 'typeorm'; + +@Injectable() +export class SessionService { + constructor( + @InjectRepository(Session) + private sessionRepository: Repository<Session>, + ) {} + + async findOne(key: string): Promise<Session | null> { + return await this.sessionRepository.findOneBy({ token: key }); + } + + async findValid(token: string): Promise<Session | null> { + return await this.sessionRepository + .createQueryBuilder('session') + .where('session.validUntil > :date', { + date: new Date(Date.now()), + }) + .andWhere({ + token: token, + used: false, + }) + .getOne(); + } + + async createSession(validTo: Date): Promise<Session | null> { + const session = new Session(); + session.validUntil = validTo; + + await this.sessionRepository.save(session); + return session; + } + + async isValidSession(token: string) { + return ( + (await this.sessionRepository + .createQueryBuilder('session') + .where('session.validUntil > :date', { + date: new Date(Date.now()), + }) + .andWhere({ + token: token, + used: false, + }) + .getCount()) > 0 + ); + } + + async markSessionAsUsed(token: string): Promise<boolean> { + const update = await this.sessionRepository + .createQueryBuilder() + .update({ + used: true, + }) + .where('validUntil > :date', { + date: new Date(Date.now()), + }) + .andWhere({ + token: token, + used: false, + }) + .execute(); + + return update.affected > 0; + } +} diff --git a/src/session/session.module.ts b/src/session/session.module.ts new file mode 100644 index 0000000..1f25786 --- /dev/null +++ b/src/session/session.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { SessionController } from './controller/session.controller'; +import { SessionService } from './service/session.service'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Session } from './entity/session.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Session])], + controllers: [SessionController], + providers: [SessionService], + exports: [SessionService], +}) +export class SessionModule {} diff --git a/src/storage/entity/stored-file.entity.ts b/src/storage/entity/stored-file.entity.ts new file mode 100644 index 0000000..e4b4931 --- /dev/null +++ b/src/storage/entity/stored-file.entity.ts @@ -0,0 +1,18 @@ +import { FileMetadata } from 'src/file/entity/file.entity'; +import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, OneToOne, JoinColumn } from 'typeorm'; + +@Entity() +export class StoredFile { + @PrimaryGeneratedColumn() + id: number; + + @CreateDateColumn() + creationDate: Date; + + @Column({ nullable: false }) + b64Contents: string; + + @OneToOne(() => FileMetadata, (metadata) => metadata.id, { nullable: false }) + @JoinColumn() + fileMetadata: FileMetadata; +} diff --git a/src/storage/service/storage-cron.service.ts b/src/storage/service/storage-cron.service.ts new file mode 100644 index 0000000..ede6ce9 --- /dev/null +++ b/src/storage/service/storage-cron.service.ts @@ -0,0 +1,34 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { StoredFile } from '../entity/stored-file.entity'; +import { LessThan, Repository } from 'typeorm'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Cron } from '@nestjs/schedule'; + +@Injectable() +export class StorageCronService { + private readonly logger = new Logger(StorageCronService.name); + + constructor( + @InjectRepository(StoredFile) + private storedFileRepository: Repository<StoredFile>, + ) {} + + @Cron('0 30 * * * *') + async deleteExpired() { + const filesToDelete = await this.storedFileRepository.find({ + relations: { + fileMetadata: true, + }, + where: { + fileMetadata: { + validUntil: LessThan(new Date(Date.now())), + }, + }, + }); + + if (filesToDelete.length > 0) { + await this.storedFileRepository.remove(filesToDelete); + this.logger.log(`Deleted ${filesToDelete.length} expired files`); + } + } +} diff --git a/src/storage/service/storage.service.ts b/src/storage/service/storage.service.ts new file mode 100644 index 0000000..6bc6b11 --- /dev/null +++ b/src/storage/service/storage.service.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { FileMetadata } from 'src/file/entity/file.entity'; +import { StoredFile } from '../entity/stored-file.entity'; +import { Repository } from 'typeorm'; +import { InjectRepository } from '@nestjs/typeorm'; + +@Injectable() +export class StorageService { + constructor( + @InjectRepository(StoredFile) + private storedFileRepository: Repository<StoredFile>, + ) {} + + async createFile(b64Contents: string, fileMetadata: FileMetadata): Promise<StoredFile> { + const newFile = new StoredFile(); + newFile.b64Contents = b64Contents; + newFile.fileMetadata = fileMetadata; + + return this.storedFileRepository.save(newFile); + } +} diff --git a/src/storage/storage.module.ts b/src/storage/storage.module.ts new file mode 100644 index 0000000..689aa28 --- /dev/null +++ b/src/storage/storage.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { StorageService } from './service/storage.service'; +import { StoredFile } from './entity/stored-file.entity'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { StorageCronService } from './service/storage-cron.service'; +import { FileMetadata } from 'src/file/entity/file.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([StoredFile]), FileMetadata], + providers: [StorageService, StorageCronService], + exports: [StorageService], +}) +export class StorageModule {} |
