aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorLeonardo Bishop <me@leonardobishop.com>2024-03-15 20:23:41 +0000
committerLeonardo Bishop <me@leonardobishop.com>2024-03-15 20:23:41 +0000
commitaeb8b66375335e8c9d6cb9cb0d8d7da3d8b79628 (patch)
tree0ddaa24fc536ac03493d6357e481df31dad77b16 /src
Initial commit
Diffstat (limited to 'src')
-rw-r--r--src/app.module.ts43
-rw-r--r--src/config/configuration.ts10
-rw-r--r--src/file/controller/file.controller.ts75
-rw-r--r--src/file/dto/create-file.dto.ts12
-rw-r--r--src/file/dto/retrieve-file.dto.ts6
-rw-r--r--src/file/dto/session-key.dto.ts6
-rw-r--r--src/file/entity/file.entity.ts47
-rw-r--r--src/file/entity/purpose.entity.ts10
-rw-r--r--src/file/file.module.ts17
-rw-r--r--src/file/service/file.service.ts96
-rw-r--r--src/main.ts10
-rw-r--r--src/seeder/seeding.service.ts29
-rw-r--r--src/session/controller/session.controller.ts20
-rw-r--r--src/session/entity/session.entity.ts30
-rw-r--r--src/session/service/session.service.ts70
-rw-r--r--src/session/session.module.ts13
-rw-r--r--src/storage/entity/stored-file.entity.ts18
-rw-r--r--src/storage/service/storage-cron.service.ts34
-rw-r--r--src/storage/service/storage.service.ts21
-rw-r--r--src/storage/storage.module.ts13
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 {}