From 5b91049f384c9f7f450c15ae2c49593444d5b8f2 Mon Sep 17 00:00:00 2001 From: VictoriqueMoe Date: Wed, 20 Mar 2024 17:25:38 +0000 Subject: [PATCH 1/5] add patch endpoint to modify entry --- .../rest/impl/FileUploadController.ts | 36 ++++++++++---- src/model/dto/EntryModificationDto.ts | 47 ++++++++++++++++++ .../FileEntry.ts => dto/FileEntryDto.ts} | 8 ++-- .../FileUploadResponseDto.ts} | 12 ++--- src/model/{rest/Stats.ts => dto/StatsDto.ts} | 10 ++-- src/services/AdminService.ts | 16 +++---- src/services/EncryptionService.ts | 28 ++++++++--- src/services/FileService.ts | 48 ++++++++++++++++--- 8 files changed, 159 insertions(+), 46 deletions(-) create mode 100644 src/model/dto/EntryModificationDto.ts rename src/model/{rest/FileEntry.ts => dto/FileEntryDto.ts} (92%) rename src/model/{rest/FileUploadModelResponse.ts => dto/FileUploadResponseDto.ts} (83%) rename src/model/{rest/Stats.ts => dto/StatsDto.ts} (73%) diff --git a/src/controllers/rest/impl/FileUploadController.ts b/src/controllers/rest/impl/FileUploadController.ts index f363dcd..b7a4fb5 100644 --- a/src/controllers/rest/impl/FileUploadController.ts +++ b/src/controllers/rest/impl/FileUploadController.ts @@ -1,7 +1,7 @@ import { Controller, Inject } from "@tsed/di"; -import { Delete, Description, Example, Examples, Get, Name, Put, Returns, Summary } from "@tsed/schema"; +import { Delete, Description, Example, Examples, Get, Name, Patch, Put, Returns, Summary } from "@tsed/schema"; import { StatusCodes } from "http-status-codes"; -import { FileUploadModelResponse } from "../../../model/rest/FileUploadModelResponse.js"; +import { FileUploadResponseDto } from "../../../model/dto/FileUploadResponseDto.js"; import { BadRequest, Forbidden, UnsupportedMediaType } from "@tsed/exceptions"; import { MultipartFile, PathParams, type PlatformMulterFile, QueryParams, Req, Res } from "@tsed/common"; import { BodyParams } from "@tsed/platform-params"; @@ -9,6 +9,8 @@ import { FileService } from "../../../services/FileService.js"; import { FileUtils, NetworkUtils } from "../../../utils/Utils.js"; import { BaseRestController } from "../BaseRestController.js"; import { Logger } from "@tsed/logger"; +import { EntryModificationDto } from "../../../model/dto/EntryModificationDto.js"; +import type { Request, Response } from "express"; @Controller("/") @Description("This is the API documentation for uploading and sharing files.") @@ -23,9 +25,9 @@ export class FileUploadController extends BaseRestController { } @Put() - @Returns(StatusCodes.CREATED, FileUploadModelResponse).Description("If the file was stored successfully") + @Returns(StatusCodes.CREATED, FileUploadResponseDto).Description("If the file was stored successfully") @Returns(StatusCodes.BAD_REQUEST, BadRequest).Description("If the request was malformed") - @Returns(StatusCodes.OK, FileUploadModelResponse).Description("If the file already exists") + @Returns(StatusCodes.OK, FileUploadResponseDto).Description("If the file already exists") @Returns(StatusCodes.UNSUPPORTED_MEDIA_TYPE, UnsupportedMediaType).Description( "If the media type of the file specified was blocked", ) @@ -38,8 +40,8 @@ export class FileUploadController extends BaseRestController { "Upload a file or specify URL to a file. Use the location header in the response or the url prop in the JSON to get the URL of the file", ) public async addEntry( - @Req() req: Req, - @Res() res: Res, + @Req() req: Request, + @Res() res: Response, @QueryParams("expires") @Examples({ empty: { @@ -88,7 +90,7 @@ export class FileUploadController extends BaseRestController { } } const ip = NetworkUtils.getIp(req); - let uploadModelResponse: FileUploadModelResponse; + let uploadModelResponse: FileUploadResponseDto; let alreadyExists: boolean; try { [uploadModelResponse, alreadyExists] = await this.fileUploadService.processUpload( @@ -118,7 +120,7 @@ export class FileUploadController extends BaseRestController { } @Get("/:token") - @Returns(StatusCodes.OK, FileUploadModelResponse) + @Returns(StatusCodes.OK, FileUploadResponseDto) @Returns(StatusCodes.BAD_REQUEST, BadRequest) @Description("Get entry info such as when it will expire and the URL") @Summary("Get entry info via token") @@ -137,6 +139,24 @@ export class FileUploadController extends BaseRestController { return this.fileUploadService.getFileInfo(token, humanReadable); } + @Patch("/:token") + @Returns(StatusCodes.OK, FileUploadResponseDto) + @Returns(StatusCodes.BAD_REQUEST, BadRequest) + @Description("Modify an entry such as password, expiry and other settings") + @Summary("Modify components of an entry") + public modifyEntry( + @PathParams("token") + token: string, + @BodyParams() + body: EntryModificationDto, + ): Promise { + if (!token) { + throw new BadRequest("no token provided"); + } + console.log(body); + return this.fileUploadService.modifyEntry(token, body); + } + @Delete("/:token") @Returns(StatusCodes.OK, Boolean) @Returns(StatusCodes.BAD_REQUEST, BadRequest) diff --git a/src/model/dto/EntryModificationDto.ts b/src/model/dto/EntryModificationDto.ts new file mode 100644 index 0000000..6404e62 --- /dev/null +++ b/src/model/dto/EntryModificationDto.ts @@ -0,0 +1,47 @@ +import { Description, Optional, Property } from "@tsed/schema"; +import { BeforeDeserialize } from "@tsed/json-mapper"; +import { BadRequest } from "@tsed/exceptions"; + +@BeforeDeserialize((data: Record) => { + if (data.customExpiry) { + const checkExpires = /[mhd]/; + if (typeof data.customExpiry !== "string") { + throw new BadRequest("bad expire string format"); + } + data.customExpiry = data.customExpiry.toLowerCase().replace(/ /g, ""); + if (!checkExpires.test(data.customExpiry as string)) { + throw new BadRequest("bad expire string format"); + } + } + if (data.previousPassword && !data.password) { + throw new BadRequest("password must be set if previousPassword is set"); + } + return data; +}) +export class EntryModificationDto { + @Property() + @Optional() + @Description("Set/change the password. If changing a password, then `previousPassword` must be set") + public password?: string; + + @Property() + @Optional() + @Description("The current password for this file. only needs to be set if you are changing a password") + public previousPassword?: string; + + @Property() + @Description( + "a string containing a number and a letter of `m` for mins, `h` for hours, `d` for days. For example: `1h` would be 1 hour and `1d` would be 1 day. " + + "leave this blank if you want the file to exist according to the retention policy. " + + "NOTE: the file expiry will be recalculated from the moment you change this value, not the time it was uploaded (the standard retention rate limit still has effect).", + ) + @Optional() + public customExpiry?: string; + + @Property() + @Optional() + @Description( + "if set to true, then your filename will not appear in the URL. if false, then it will appear in the URL. defaults to false", + ) + public hideFilename?: boolean; +} diff --git a/src/model/rest/FileEntry.ts b/src/model/dto/FileEntryDto.ts similarity index 92% rename from src/model/rest/FileEntry.ts rename to src/model/dto/FileEntryDto.ts index 152330d..d61be41 100644 --- a/src/model/rest/FileEntry.ts +++ b/src/model/dto/FileEntryDto.ts @@ -4,7 +4,7 @@ import { ObjectUtils } from "../../utils/Utils.js"; import type { IpBlockedAwareFileEntry, ProtectionLevel } from "../../utils/typeings.js"; import { FileUploadModel } from "../db/FileUpload.model.js"; -export class FileEntry { +export class FileEntryDto { @Property() public id: number; @@ -44,9 +44,9 @@ export class FileEntry { @Property() public fileProtectionLevel: ProtectionLevel; - public static fromModel({ entry, ipBlocked }: IpBlockedAwareFileEntry, baseUrl: string): FileEntry { - const fileEntryBuilder = Builder(FileEntry) - .url(FileEntry.getUrl(entry, baseUrl)) + public static fromModel({ entry, ipBlocked }: IpBlockedAwareFileEntry, baseUrl: string): FileEntryDto { + const fileEntryBuilder = Builder(FileEntryDto) + .url(FileEntryDto.getUrl(entry, baseUrl)) .fileExtension(entry.fileExtension) .createdAt(entry.createdAt) .id(entry.id) diff --git a/src/model/rest/FileUploadModelResponse.ts b/src/model/dto/FileUploadResponseDto.ts similarity index 83% rename from src/model/rest/FileUploadModelResponse.ts rename to src/model/dto/FileUploadResponseDto.ts index d610a5e..12eb620 100644 --- a/src/model/rest/FileUploadModelResponse.ts +++ b/src/model/dto/FileUploadResponseDto.ts @@ -3,7 +3,7 @@ import { FileUploadModel } from "../db/FileUpload.model.js"; import { Builder } from "builder-pattern"; import { ObjectUtils } from "../../utils/Utils.js"; -export class FileUploadModelResponse { +export class FileUploadResponseDto { @Property() @Description("Used for file info and deleting") public token: string; @@ -21,14 +21,10 @@ export class FileUploadModelResponse { @Nullable(Number, String) public retentionPeriod: string | number | null = null; - public static fromModel( - fileUploadModel: FileUploadModel, - baseUrl: string, - format = false, - ): FileUploadModelResponse { - const builder = Builder(FileUploadModelResponse) + public static fromModel(fileUploadModel: FileUploadModel, baseUrl: string, format = false): FileUploadResponseDto { + const builder = Builder(FileUploadResponseDto) .token(fileUploadModel.token) - .url(FileUploadModelResponse.getUrl(fileUploadModel, baseUrl)); + .url(FileUploadResponseDto.getUrl(fileUploadModel, baseUrl)); const expiresIn = fileUploadModel.expiresIn; if (format && expiresIn !== null) { builder.retentionPeriod(ObjectUtils.timeToHuman(expiresIn)); diff --git a/src/model/rest/Stats.ts b/src/model/dto/StatsDto.ts similarity index 73% rename from src/model/rest/Stats.ts rename to src/model/dto/StatsDto.ts index 508f20d..f10d073 100644 --- a/src/model/rest/Stats.ts +++ b/src/model/dto/StatsDto.ts @@ -1,9 +1,9 @@ import { Property } from "@tsed/schema"; import { Builder } from "builder-pattern"; -import { FileEntry } from "./FileEntry.js"; +import { FileEntryDto } from "./FileEntryDto.js"; import { FileUtils } from "../../utils/Utils.js"; -export class Stats { +export class StatsDto { @Property() public totalFileCount: number; @@ -14,12 +14,12 @@ export class Stats { public totalFileSize: number; @Property() - public entries: FileEntry[]; + public entries: FileEntryDto[]; - public static async buildStats(entries: FileEntry[]): Promise { + public static async buildStats(entries: FileEntryDto[]): Promise { const realFiles = await FileUtils.getFilesCount(); const fileSizes = entries.reduce((acc, currentValue) => acc + currentValue.fileSize, 0); - const statsBuilder = Builder(Stats) + const statsBuilder = Builder(StatsDto) .totalFileCount(entries.length) .realFileCount(realFiles) .totalFileSize(fileSizes) diff --git a/src/services/AdminService.ts b/src/services/AdminService.ts index 4c083e8..45806f6 100644 --- a/src/services/AdminService.ts +++ b/src/services/AdminService.ts @@ -4,10 +4,10 @@ import { IpBlackListRepo } from "../db/repo/IpBlackListRepo.js"; import { IpBlackListModel } from "../model/db/IpBlackList.model.js"; import { FileService } from "./FileService.js"; import GlobalEnv from "../model/constants/GlobalEnv.js"; -import { FileEntry } from "../model/rest/FileEntry.js"; +import { FileEntryDto } from "../model/dto/FileEntryDto.js"; import { FileUploadModel } from "../model/db/FileUpload.model.js"; import { IpBlockedAwareFileEntry } from "../utils/typeings.js"; -import { Stats } from "../model/rest/Stats.js"; +import { StatsDto } from "../model/dto/StatsDto.js"; @Service() export class AdminService { @@ -20,11 +20,11 @@ export class AdminService { @Constant(GlobalEnv.BASE_URL) private readonly baseUrl: string; - public getStatsData(entries: FileEntry[]): Promise { - return Stats.buildStats(entries); + public getStatsData(entries: FileEntryDto[]): Promise { + return StatsDto.buildStats(entries); } - public async getAllEntries(): Promise { + public async getAllEntries(): Promise { const allEntries = await this.repo.getAllEntries(); return this.buildFileEntryDtos(allEntries); } @@ -35,12 +35,12 @@ export class AdminService { sortColumn = "id", sortDir = "ASC", search?: string, - ): Promise { + ): Promise { const entries = await this.repo.getAllEntriesOrdered(start, length, sortColumn, sortDir, search); return this.buildFileEntryDtos(entries); } - private async buildFileEntryDtos(entries: FileUploadModel[]): Promise { + private async buildFileEntryDtos(entries: FileUploadModel[]): Promise { const ipBlockedPArr = entries.map(entry => Promise.all([entry, this.ipBlackListRepo.isIpBlocked(entry.ip)])); const ipBlockedArr = await Promise.all(ipBlockedPArr); return ipBlockedArr.map(([entry, ipBlocked]) => { @@ -48,7 +48,7 @@ export class AdminService { ipBlocked, entry, } as IpBlockedAwareFileEntry; - return FileEntry.fromModel(ipBlockedAwareEntry, this.baseUrl); + return FileEntryDto.fromModel(ipBlockedAwareEntry, this.baseUrl); }); } diff --git a/src/services/EncryptionService.ts b/src/services/EncryptionService.ts index 41e226e..36b03c0 100644 --- a/src/services/EncryptionService.ts +++ b/src/services/EncryptionService.ts @@ -26,18 +26,25 @@ export class EncryptionService implements OnInit { }); } - public async encrypt(filePath: string, password: string): Promise { + public async encrypt(file: string | Buffer, password: string): Promise { if (!this.salt) { - return false; + return null; + } + let buffer: Buffer; + if (typeof file === "string") { + const fileSource = FileUtils.getFilePath(Path.basename(file)); + buffer = await fs.readFile(fileSource); + } else { + buffer = file; } - const fileSource = FileUtils.getFilePath(Path.basename(filePath)); - const buffer = await fs.readFile(fileSource); const iv = await this.randomBytes(16); const key = await this.getKey(password); const cipher = crypto.createCipheriv(this.algorithm, key, iv); const encryptedBuffer = Buffer.concat([iv, cipher.update(buffer), cipher.final()]); - await fs.writeFile(fileSource, encryptedBuffer); - return true; + if (typeof file === "string") { + await fs.writeFile(file, encryptedBuffer); + } + return encryptedBuffer; } public async decrypt(source: FileUploadModel, password?: string): Promise { @@ -68,6 +75,15 @@ export class EncryptionService implements OnInit { return Buffer.concat([decipher.update(encryptedRest), decipher.final()]); } + public async changePassword(oldPassword: string, newPassword: string, entry: FileUploadModel): Promise { + const decryptedBuffer = await this.decrypt(entry, oldPassword); + const newBuffer = await this.encrypt(decryptedBuffer, newPassword); + if (!newBuffer) { + throw new Error("Unable to encrypt file"); + } + await fs.writeFile(FileUtils.getFilePath(entry), newBuffer); + } + private validatePassword(resource: FileUploadModel, password: string): Promise { return argon2.verify(resource.settings!.password!, password); } diff --git a/src/services/FileService.ts b/src/services/FileService.ts index e1ed045..19e9025 100644 --- a/src/services/FileService.ts +++ b/src/services/FileService.ts @@ -8,7 +8,7 @@ import { Builder, type IBuilder } from "builder-pattern"; import path from "node:path"; import fs from "node:fs/promises"; import crypto from "node:crypto"; -import { FileUploadModelResponse } from "../model/rest/FileUploadModelResponse.js"; +import { FileUploadResponseDto } from "../model/dto/FileUploadResponseDto.js"; import GlobalEnv from "../model/constants/GlobalEnv.js"; import { Logger } from "@tsed/logger"; import type { EntrySettings, XOR } from "../utils/typeings.js"; @@ -19,6 +19,7 @@ import argon2 from "argon2"; import { AvManager } from "../manager/AvManager.js"; import { EncryptionService } from "./EncryptionService.js"; import { RecordInfoSocket } from "./socket/RecordInfoSocket.js"; +import { EntryModificationDto } from "../model/dto/EntryModificationDto.js"; @Service() export class FileService { @@ -45,7 +46,7 @@ export class FileService { maskFilename = false, password?: string, secretToken?: string, - ): Promise<[FileUploadModelResponse, boolean]> { + ): Promise<[FileUploadResponseDto, boolean]> { const token = crypto.randomUUID(); const uploadEntry = Builder(FileUploadModel).ip(ip).token(token); const [resourcePath, originalFileName] = await this.determineResourcePathAndFileName(source); @@ -63,7 +64,7 @@ export class FileService { if (existingFileModel.hasExpired) { await this.processDelete([existingFileModel.token], true); } else { - return [FileUploadModelResponse.fromModel(existingFileModel, this.baseUrl, true), true]; + return [FileUploadResponseDto.fromModel(existingFileModel, this.baseUrl, true), true]; } } @@ -84,7 +85,7 @@ export class FileService { if (password) { try { const didEncrypt = await this.encryptionService.encrypt(resourcePath, password); - uploadEntry.encrypted(didEncrypt); + uploadEntry.encrypted(didEncrypt !== null); } catch (e) { await this.deleteUploadedFile(resourcePath); this.logger.error(e.message); @@ -95,7 +96,7 @@ export class FileService { await this.recordInfoSocket.emit(); - return [FileUploadModelResponse.fromModel(savedEntry, this.baseUrl, true), false]; + return [FileUploadResponseDto.fromModel(savedEntry, this.baseUrl, true), false]; } private hashPassword(password: string): Promise { @@ -182,7 +183,40 @@ export class FileService { return Promise.all([this.encryptionService.decrypt(entry, password), entry]); } - public async getFileInfo(token: string, humanReadable: boolean): Promise { + public async modifyEntry(token: string, dto: EntryModificationDto): Promise { + const [entryToModify] = await this.repo.getEntry([token]); + if (!entryToModify) { + throw new BadRequest(`Unknown token ${token}`); + } + const builder = Builder(FileUploadModel, entryToModify); + if (dto.hideFilename) { + builder.settings({ + ...builder.settings(), + hideFilename: dto.hideFilename, + }); + } + if (dto.password) { + builder.settings({ + ...builder.settings(), + password: await this.hashPassword(dto.password), + }); + if (builder.encrypted()) { + if (!dto.previousPassword) { + throw new BadRequest("You must supply 'previousPassword' to change the password"); + } + await this.encryptionService.changePassword(dto.previousPassword, dto.password, entryToModify); + } else { + await this.encryptionService.encrypt(FileUtils.getFilePath(entryToModify), dto.password); + } + } + if (dto.customExpiry) { + this.calculateCustomExpires(builder, dto.customExpiry); + } + const updatedEntry = await this.repo.saveEntry(builder.build()); + return FileUploadResponseDto.fromModel(updatedEntry, this.baseUrl); + } + + public async getFileInfo(token: string, humanReadable: boolean): Promise { const foundEntries = await this.repo.getEntry([token]); if (foundEntries.length !== 1) { throw new BadRequest(`Unknown token ${token}`); @@ -192,7 +226,7 @@ export class FileService { await this.processDelete([entry.token], true); throw new BadRequest(`Unknown token ${token}`); } - return FileUploadModelResponse.fromModel(entry, this.baseUrl, humanReadable); + return FileUploadResponseDto.fromModel(entry, this.baseUrl, humanReadable); } private calculateCustomExpires(entry: IBuilder, expires: string, secretToken?: string): void { From e26ac631b368792072e344db7acf5fc4c94253ba Mon Sep 17 00:00:00 2001 From: VictoriqueMoe Date: Wed, 20 Mar 2024 17:29:50 +0000 Subject: [PATCH 2/5] set encrypted if changed --- src/services/FileService.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/services/FileService.ts b/src/services/FileService.ts index 19e9025..2322353 100644 --- a/src/services/FileService.ts +++ b/src/services/FileService.ts @@ -206,6 +206,7 @@ export class FileService { } await this.encryptionService.changePassword(dto.previousPassword, dto.password, entryToModify); } else { + builder.encrypted(true); await this.encryptionService.encrypt(FileUtils.getFilePath(entryToModify), dto.password); } } From 56e87540cfeec1e5d56ac5d857da1d1a2f9a42e0 Mon Sep 17 00:00:00 2001 From: VictoriqueMoe Date: Wed, 20 Mar 2024 17:47:10 +0000 Subject: [PATCH 3/5] ability to remove password and decrypt file --- src/model/dto/EntryModificationDto.ts | 3 --- src/services/FileService.ts | 20 ++++++++++++++++++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/model/dto/EntryModificationDto.ts b/src/model/dto/EntryModificationDto.ts index 6404e62..118e554 100644 --- a/src/model/dto/EntryModificationDto.ts +++ b/src/model/dto/EntryModificationDto.ts @@ -13,9 +13,6 @@ import { BadRequest } from "@tsed/exceptions"; throw new BadRequest("bad expire string format"); } } - if (data.previousPassword && !data.password) { - throw new BadRequest("password must be set if previousPassword is set"); - } return data; }) export class EntryModificationDto { diff --git a/src/services/FileService.ts b/src/services/FileService.ts index 2322353..60b5e66 100644 --- a/src/services/FileService.ts +++ b/src/services/FileService.ts @@ -206,9 +206,25 @@ export class FileService { } await this.encryptionService.changePassword(dto.previousPassword, dto.password, entryToModify); } else { - builder.encrypted(true); - await this.encryptionService.encrypt(FileUtils.getFilePath(entryToModify), dto.password); + const didEncrypt = await this.encryptionService.encrypt( + FileUtils.getFilePath(entryToModify), + dto.password, + ); + if (didEncrypt) { + builder.encrypted(true); + } + } + } else { + if (builder.encrypted()) { + if (!dto.previousPassword) { + throw new BadRequest("Unable to remove password if previousPassword is not supplied"); + } + const decryptedEntry = await this.encryptionService.decrypt(entryToModify, dto.previousPassword); + await fs.writeFile(decryptedEntry, FileUtils.getFilePath(entryToModify)); } + const newSettings = builder.settings(); + delete newSettings?.password; + builder.settings(newSettings); } if (dto.customExpiry) { this.calculateCustomExpires(builder, dto.customExpiry); From ccbfb17d9a835ddc702b79ba1152064e857ee813 Mon Sep 17 00:00:00 2001 From: VictoriqueMoe Date: Wed, 20 Mar 2024 17:54:42 +0000 Subject: [PATCH 4/5] fix decrypt --- src/services/FileService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/FileService.ts b/src/services/FileService.ts index 60b5e66..e108aeb 100644 --- a/src/services/FileService.ts +++ b/src/services/FileService.ts @@ -220,7 +220,7 @@ export class FileService { throw new BadRequest("Unable to remove password if previousPassword is not supplied"); } const decryptedEntry = await this.encryptionService.decrypt(entryToModify, dto.previousPassword); - await fs.writeFile(decryptedEntry, FileUtils.getFilePath(entryToModify)); + await fs.writeFile(FileUtils.getFilePath(entryToModify), decryptedEntry); } const newSettings = builder.settings(); delete newSettings?.password; From d60fba3ab4957f547b0a5d34278f6da9b20f4cfe Mon Sep 17 00:00:00 2001 From: VictoriqueMoe Date: Wed, 20 Mar 2024 18:20:35 +0000 Subject: [PATCH 5/5] remove log --- src/controllers/rest/impl/FileUploadController.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/controllers/rest/impl/FileUploadController.ts b/src/controllers/rest/impl/FileUploadController.ts index b7a4fb5..df6272f 100644 --- a/src/controllers/rest/impl/FileUploadController.ts +++ b/src/controllers/rest/impl/FileUploadController.ts @@ -153,7 +153,6 @@ export class FileUploadController extends BaseRestController { if (!token) { throw new BadRequest("no token provided"); } - console.log(body); return this.fileUploadService.modifyEntry(token, body); }