aboutsummaryrefslogtreecommitdiffstats
path: root/src/file
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/file
Initial commit
Diffstat (limited to 'src/file')
-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
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();
+ }
+}