diff options
Diffstat (limited to 'src/file')
| -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 |
8 files changed, 269 insertions, 0 deletions
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(); + } +} |
