diff --git a/server/errors.ts b/server/errors.ts index d1735581e..f9f9b1194 100644 --- a/server/errors.ts +++ b/server/errors.ts @@ -28,6 +28,14 @@ export function AuthorizationError( }); } +export function RateLimitExceededError( + message = "Rate limit exceeded for this operation" +) { + return httpErrors(429, message, { + id: "rate_limit_exceeded", + }); +} + export function InviteRequiredError( message = "You need an invite to join this team" ) { diff --git a/server/models/FileOperation.ts b/server/models/FileOperation.ts index 672c13621..a06e420e8 100644 --- a/server/models/FileOperation.ts +++ b/server/models/FileOperation.ts @@ -1,3 +1,5 @@ +import { subHours } from "date-fns"; +import { Op, WhereOptions } from "sequelize"; import { ForeignKey, DefaultScope, @@ -6,7 +8,9 @@ import { BelongsTo, Table, DataType, + AfterValidate, } from "sequelize-typescript"; +import { RateLimitExceededError } from "@server/errors"; import { deleteFromS3, getFileByKey } from "@server/utils/s3"; import Collection from "./Collection"; import Team from "./Team"; @@ -89,6 +93,21 @@ class FileOperation extends IdModel { await deleteFromS3(model.key); } + @AfterValidate + static async checkRateLimit(model: FileOperation) { + const count = await this.countExportsAfterDateTime( + model.teamId, + subHours(new Date(), 12), + { + type: model.type, + } + ); + + if (count >= 12) { + throw RateLimitExceededError(); + } + } + // associations @BelongsTo(() => User, "userId") @@ -111,6 +130,30 @@ class FileOperation extends IdModel { @ForeignKey(() => Collection) @Column(DataType.UUID) collectionId: string; + + /** + * Count the number of export file operations for a given team after a point + * in time. + * + * @param teamId The team id + * @param startDate The start time + * @returns The number of file operations + */ + static async countExportsAfterDateTime( + teamId: string, + startDate: Date, + where: WhereOptions = {} + ): Promise { + return this.count({ + where: { + teamId, + createdAt: { + [Op.gt]: startDate, + }, + ...where, + }, + }); + } } export default FileOperation;