Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add patch endpoint to modify entry #126

Merged
merged 5 commits into from
Mar 20, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 28 additions & 8 deletions src/controllers/rest/impl/FileUploadController.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
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";
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.")
Expand All @@ -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",
)
Expand All @@ -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: {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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")
Expand All @@ -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<unknown> {
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)
Expand Down
44 changes: 44 additions & 0 deletions src/model/dto/EntryModificationDto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Description, Optional, Property } from "@tsed/schema";
import { BeforeDeserialize } from "@tsed/json-mapper";
import { BadRequest } from "@tsed/exceptions";

@BeforeDeserialize((data: Record<keyof EntryModificationDto, unknown>) => {
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");
}
}
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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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));
Expand Down
10 changes: 5 additions & 5 deletions src/model/rest/Stats.ts → src/model/dto/StatsDto.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -14,12 +14,12 @@ export class Stats {
public totalFileSize: number;

@Property()
public entries: FileEntry[];
public entries: FileEntryDto[];

public static async buildStats(entries: FileEntry[]): Promise<Stats> {
public static async buildStats(entries: FileEntryDto[]): Promise<StatsDto> {
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)
Expand Down
16 changes: 8 additions & 8 deletions src/services/AdminService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -20,11 +20,11 @@ export class AdminService {
@Constant(GlobalEnv.BASE_URL)
private readonly baseUrl: string;

public getStatsData(entries: FileEntry[]): Promise<Stats> {
return Stats.buildStats(entries);
public getStatsData(entries: FileEntryDto[]): Promise<StatsDto> {
return StatsDto.buildStats(entries);
}

public async getAllEntries(): Promise<FileEntry[]> {
public async getAllEntries(): Promise<FileEntryDto[]> {
const allEntries = await this.repo.getAllEntries();
return this.buildFileEntryDtos(allEntries);
}
Expand All @@ -35,20 +35,20 @@ export class AdminService {
sortColumn = "id",
sortDir = "ASC",
search?: string,
): Promise<FileEntry[]> {
): Promise<FileEntryDto[]> {
const entries = await this.repo.getAllEntriesOrdered(start, length, sortColumn, sortDir, search);
return this.buildFileEntryDtos(entries);
}

private async buildFileEntryDtos(entries: FileUploadModel[]): Promise<FileEntry[]> {
private async buildFileEntryDtos(entries: FileUploadModel[]): Promise<FileEntryDto[]> {
const ipBlockedPArr = entries.map(entry => Promise.all([entry, this.ipBlackListRepo.isIpBlocked(entry.ip)]));
const ipBlockedArr = await Promise.all(ipBlockedPArr);
return ipBlockedArr.map(([entry, ipBlocked]) => {
const ipBlockedAwareEntry = {
ipBlocked,
entry,
} as IpBlockedAwareFileEntry;
return FileEntry.fromModel(ipBlockedAwareEntry, this.baseUrl);
return FileEntryDto.fromModel(ipBlockedAwareEntry, this.baseUrl);
});
}

Expand Down
28 changes: 22 additions & 6 deletions src/services/EncryptionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,25 @@ export class EncryptionService implements OnInit {
});
}

public async encrypt(filePath: string, password: string): Promise<boolean> {
public async encrypt(file: string | Buffer, password: string): Promise<Buffer | null> {
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<Buffer> {
Expand Down Expand Up @@ -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<void> {
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<boolean> {
return argon2.verify(resource.settings!.password!, password);
}
Expand Down
Loading