diff --git a/tdrive/backend/node/src/services/documents/services/index.ts b/tdrive/backend/node/src/services/documents/services/index.ts index eb1a82472..92b3b6d7e 100644 --- a/tdrive/backend/node/src/services/documents/services/index.ts +++ b/tdrive/backend/node/src/services/documents/services/index.ts @@ -60,7 +60,7 @@ import { RealtimeEntityActionType, ResourcePath, } from "../../../core/platform/services/realtime/types"; - +import config from "config"; export class DocumentsService { version: "1"; repository: Repository; @@ -69,6 +69,12 @@ export class DocumentsService { driveTdriveTabRepository: Repository; ROOT: RootType = "root"; TRASH: TrashType = "trash"; + quotaEnabled: boolean = config.has("drive.featureUserQuota") + ? config.get("drive.featureUserQuota") + : false; + defaultQuota: number = config.has("drive.defaultUserQuota") + ? config.get("drive.defaultUserQuota") + : 0; logger: TdriveLogger = getLogger("Documents Service"); async init(): Promise { @@ -328,6 +334,19 @@ export class DocumentsService { } if (fileToProcess) { + if (this.quotaEnabled) { + const userQuota = await this.userQuota(context); + const leftQuota = this.defaultQuota - userQuota; + + if (fileToProcess.upload_data.size > leftQuota) { + // clean up everything + await globalResolver.services.files.delete(fileToProcess.id, context); + throw new CrudException( + `Not enough space: ${fileToProcess.upload_data.size}, ${leftQuota}.`, + 403, + ); + } + } driveItem.size = fileToProcess.upload_data.size; driveItem.is_directory = false; driveItem.extension = fileToProcess.metadata.name.split(".").pop(); @@ -774,6 +793,17 @@ export class DocumentsService { const driveItemVersion = getDefaultDriveItemVersion(version, context); const metadata = await getFileMetadata(driveItemVersion.file_metadata.external_id, context); + if (this.quotaEnabled) { + const userQuota = await this.userQuota(context); + const leftQuota = this.defaultQuota - userQuota; + + if (metadata.size > leftQuota) { + // clean up everything + await globalResolver.services.files.delete(metadata.external_id, context); + throw new CrudException(`Not enough space: ${metadata.size}, ${leftQuota}.`, 403); + } + } + driveItemVersion.file_size = metadata.size; driveItemVersion.file_metadata.size = metadata.size; driveItemVersion.file_metadata.name = metadata.name; @@ -820,8 +850,12 @@ export class DocumentsService { return driveItemVersion; } catch (error) { - this.logger.error({ error: `${error}` }, "Failed to create Drive item version"); - throw new CrudException("Failed to create Drive item version", 500); + logger.error({ error: `${error}` }, "Failed to create Drive item version"); + // if error code is 403, it means the user exceeded the quota limit + if (error.code === 403) { + CrudException.throwMe(error, new CrudException("Quota limit exceeded", 403)); + } + CrudException.throwMe(error, new CrudException("Failed to create Drive item version", 500)); } }; diff --git a/tdrive/backend/node/src/services/documents/web/controllers/documents.ts b/tdrive/backend/node/src/services/documents/web/controllers/documents.ts index e025afeb0..917a7c800 100644 --- a/tdrive/backend/node/src/services/documents/web/controllers/documents.ts +++ b/tdrive/backend/node/src/services/documents/web/controllers/documents.ts @@ -50,7 +50,7 @@ export class DocumentsController { version: Partial; }; }>, - ): Promise => { + ): Promise => { try { const context = getDriveExecutionContext(request); @@ -73,6 +73,7 @@ export class DocumentsController { const { item, version } = request.body; + // return await globalResolver.services.documents.documents.create( createdFile, item, @@ -305,14 +306,19 @@ export class DocumentsController { Body: Partial; Querystring: { public_token?: string }; }>, - ): Promise => { - const context = getDriveExecutionContext(request); - const { id } = request.params; - const version = request.body; + ): Promise => { + try { + const context = getDriveExecutionContext(request); + const { id } = request.params; + const version = request.body; - if (!id) throw new CrudException("Missing id", 400); + if (!id) throw new CrudException("Missing id", 400); - return await globalResolver.services.documents.documents.createVersion(id, version, context); + return await globalResolver.services.documents.documents.createVersion(id, version, context); + } catch (error) { + logger.error({ error: `${error}` }, "Failed to create Drive item version"); + CrudException.throwMe(error, new CrudException("Failed to create Drive item version", 500)); + } }; downloadGetToken = async ( diff --git a/tdrive/backend/node/test/e2e/common/user-api.ts b/tdrive/backend/node/test/e2e/common/user-api.ts index ade72f201..39c764543 100644 --- a/tdrive/backend/node/test/e2e/common/user-api.ts +++ b/tdrive/backend/node/test/e2e/common/user-api.ts @@ -233,8 +233,14 @@ export default class UserApi { }; async createDocumentFromFilename( - file_name: "sample.png", - parent_id = "root" + file_name: + | "sample.png" + | "sample.doc" + | "sample.pdf" + | "sample.zip" + | "sample.mp4" + | "sample.gif", + parent_id = "root", ) { const file = await this.uploadFile(file_name); diff --git a/tdrive/backend/node/test/e2e/documents/documents-quota.spec.ts b/tdrive/backend/node/test/e2e/documents/documents-quota.spec.ts new file mode 100644 index 000000000..a3f143294 --- /dev/null +++ b/tdrive/backend/node/test/e2e/documents/documents-quota.spec.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it } from "@jest/globals"; +import { init, TestPlatform } from "../setup"; +import UserApi from "../common/user-api"; +import config from "config"; +import { e2e_createDocumentFile, e2e_createVersion } from "./utils"; +import { deserialize } from "class-transformer"; +import { ResourceUpdateResponse } from "../../../src/utils/types"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +jest.mock("config"); + +describe("the Drive feature", () => { + const filesUrl = "/internal/services/files/v1"; + let platform: TestPlatform; + let currentUser: UserApi; + let configHasSpy: jest.SpyInstance; + let configGetSpy: jest.SpyInstance; + + beforeEach(async () => { + configHasSpy = jest.spyOn(config, "has"); + configGetSpy = jest.spyOn(config, "get"); + + configHasSpy.mockImplementation((setting: string) => { + const value = jest.requireActual("config").has(setting); + return value; + }); + configGetSpy.mockImplementation((setting: string) => { + if (setting === "drive.featureUserQuota") { + return true; + } + if (setting === "drive.defaultUserQuota") { + return 2000000; + } + return jest.requireActual("config").get(setting); + }); + platform = await init({ + services: [ + "webserver", + "database", + "applications", + "search", + "storage", + "message-queue", + "user", + "search", + "files", + "websocket", + "messages", + "auth", + "realtime", + "channels", + "counter", + "statistics", + "platform-services", + "documents", + ], + }); + currentUser = await UserApi.getInstance(platform); + }); + + afterEach(async () => { + await platform?.tearDown(); + platform = null; + configGetSpy.mockRestore(); + }); + + it("did create the drive item with size under quota", async () => { + const result = await currentUser.uploadFileAndCreateDocument("sample.doc"); + expect(result).toBeDefined(); + }); + + it("did not upload the drive item with size above quota", async () => { + const item = await currentUser.uploadFile("sample.mp4"); + expect(item).toBeDefined(); + const result: any = await currentUser.createDocumentFromFile(item); + expect(result).toBeDefined(); + expect(result.statusCode).toBe(403); + expect(result.error).toBe("Forbidden"); + expect(result.message).toContain("Not enough space"); + const fileDownloadResponse = await platform.app.inject({ + method: "GET", + url: `${filesUrl}/companies/${platform.workspace.company_id}/files/${item.id}/download`, + }); + // make sure the file was removed + expect(fileDownloadResponse).toBeTruthy(); + expect(fileDownloadResponse.statusCode).toBe(404); + }); + + it("did create a version for a drive item", async () => { + const item = await currentUser.createDefaultDocument(); + const fileUploadResponse = await e2e_createDocumentFile( + platform, + "../common/assets/sample.mp4", + ); + const fileUploadResult: any = deserialize>( + ResourceUpdateResponse, + fileUploadResponse.body, + ); + const file_metadata = { external_id: fileUploadResult.resource.id }; + + const result: any = await e2e_createVersion(platform, item.id, { + filename: "file2", + file_metadata, + }); + expect(result).toBeDefined(); + expect(result.statusCode).toBe(403); + const fileDownloadResponse = await platform.app.inject({ + method: "GET", + url: `${filesUrl}/companies/${platform.workspace.company_id}/files/${item.id}/download`, + }); + // make sure the file was removed + expect(fileDownloadResponse).toBeTruthy(); + expect(fileDownloadResponse.statusCode).toBe(404); + }); +}); diff --git a/tdrive/backend/node/test/e2e/documents/utils.ts b/tdrive/backend/node/test/e2e/documents/utils.ts index 019fb95ca..884f7dc4e 100644 --- a/tdrive/backend/node/test/e2e/documents/utils.ts +++ b/tdrive/backend/node/test/e2e/documents/utils.ts @@ -54,8 +54,9 @@ export const e2e_createVersion = async ( }); }; -export const e2e_createDocumentFile = async (platform: TestPlatform) => { - const filePath = `${__dirname}/assets/test.txt`; +export const e2e_createDocumentFile = async (platform: TestPlatform, documentPath?: string) => { + const subFilePath = documentPath ?? "assets/test.txt"; + const filePath = `${__dirname}/${subFilePath}`; const token = await platform.auth.getJWTToken(); const form = formAutoContent({ file: fs.createReadStream(filePath) }); form.headers["authorization"] = `Bearer ${token}`; diff --git a/tdrive/frontend/public/locales/en.json b/tdrive/frontend/public/locales/en.json index 2fbdd9ea2..849462f0f 100644 --- a/tdrive/frontend/public/locales/en.json +++ b/tdrive/frontend/public/locales/en.json @@ -235,6 +235,9 @@ "compenents.VersionModalContent_donwload":"Download", "hooks.use-drive-actions.unable_load_file":"Unable to load your files.", "hooks.use-drive-actions.unable_create_file":"Unable to create a new file.", + "hooks.use-drive-actions.quota_limit_exceeded_title":"You're out of storage", + "hooks.use-drive-actions.quota_limit_exceeded_message":"You have reached the limit of your storage. Please, delete some files to vacate more space and have access to all features of Twake Drive.", + "hooks.use-drive-actions.quota_limit_exceeded_plans": "Our solution is now free for all users, but additional plans are coming soon.", "hooks.use-drive-actions.unable_download_file":"Unable to download this files.", "hooks.use-drive-actions.unable_remove_file":"Unable to remove this file.", "hooks.use-drive-actions.unable_restore_file":"Unable to restore this item.", diff --git a/tdrive/frontend/public/locales/fr.json b/tdrive/frontend/public/locales/fr.json index 6fc3cabfd..bd7dbcfe7 100644 --- a/tdrive/frontend/public/locales/fr.json +++ b/tdrive/frontend/public/locales/fr.json @@ -218,6 +218,9 @@ "compenents.VersionModalContent_donwload": "Télécharger", "hooks.use-drive-actions.unable_load_file": "Impossible de charger cet élément", "hooks.use-drive-actions.unable_create_file": "Impossible de créer ce fichier", + "hooks.use-drive-actions.quota_limit_exceeded_title": "Vous avez épuisé votre espace de stockage", + "hooks.use-drive-actions.quota_limit_exceeded_message": "Vous avez atteint la limite de votre espace de stockage. Veuillez supprimer certains fichiers pour libérer plus d'espace et avoir accès à toutes les fonctionnalités de Twake Drive.", + "hooks.use-drive-actions.quota_limit_exceeded_plans": "Notre solution est désormais gratuite pour tous les utilisateurs, mais des plans supplémentaires seront bientôt disponibles.", "hooks.use-drive-actions.unable_download_file": "Impossible de télécharger ce fichier", "hooks.use-drive-actions.unable_remove_file": "Impossible de supprimer ce fichier", "hooks.use-drive-actions.unable_restore_file": "Impossible de restaurer cet élément.", diff --git a/tdrive/frontend/public/locales/ru.json b/tdrive/frontend/public/locales/ru.json index 65c2aa21a..b16033f64 100644 --- a/tdrive/frontend/public/locales/ru.json +++ b/tdrive/frontend/public/locales/ru.json @@ -218,6 +218,9 @@ "compenents.VersionModalContent_donwload":"Download", "hooks.use-drive-actions.unable_load_file":"Unable to load your files.", "hooks.use-drive-actions.unable_create_file":"Unable to create a new file.", + "hooks.use-drive-actions.quota_limit_exceeded_title": "У вас закончилось место для хранения", + "hooks.use-drive-actions.quota_limit_exceeded_message": "Вы достигли предела вашего места для хранения. Пожалуйста, удалите некоторые файлы, чтобы освободить больше места и получить доступ ко всем функциям Twake Drive.", + "hooks.use-drive-actions.quota_limit_exceeded_plans": "Наше решение теперь бесплатно для всех пользователей, но скоро будут доступны дополнительные планы.", "hooks.use-drive-actions.unable_download_file":"Unable to download this files.", "hooks.use-drive-actions.unable_remove_file":"Unable to remove this file.", "hooks.use-drive-actions.unable_restore_file":"Unable to restore this item.", diff --git a/tdrive/frontend/public/locales/vn.json b/tdrive/frontend/public/locales/vn.json index d6d27b165..874fee0c2 100644 --- a/tdrive/frontend/public/locales/vn.json +++ b/tdrive/frontend/public/locales/vn.json @@ -234,6 +234,9 @@ "compenents.VersionModalContent_donwload": "Tải xuống", "hooks.use-drive-actions.unable_load_file": "Không thể tải tệp của bạn.", "hooks.use-drive-actions.unable_create_file": "Không thể tạo tệp mới.", + "hooks.use-drive-actions.quota_limit_exceeded_title": "Bạn đã hết dung lượng lưu trữ", + "hooks.use-drive-actions.quota_limit_exceeded_message": "Bạn đã đạt đến giới hạn dung lượng lưu trữ của mình. Vui lòng xóa một số tệp để giải phóng thêm không gian và truy cập vào tất cả các tính năng của Twake Drive.", + "hooks.use-drive-actions.quota_limit_exceeded_plans": "Giải pháp của chúng tôi hiện đã miễn phí cho tất cả người dùng, nhưng các gói bổ sung sẽ sớm có mặt.", "hooks.use-drive-actions.unable_download_file": "Không thể tải xuống các tệp này.", "hooks.use-drive-actions.unable_remove_file": "Không thể xóa tệp này.", "hooks.use-drive-actions.unable_restore_file": "Không thể khôi phục mục này.", diff --git a/tdrive/frontend/src/app/features/drive/api-client/api-client.ts b/tdrive/frontend/src/app/features/drive/api-client/api-client.ts index ba0bc37e7..0be3717ba 100644 --- a/tdrive/frontend/src/app/features/drive/api-client/api-client.ts +++ b/tdrive/frontend/src/app/features/drive/api-client/api-client.ts @@ -102,13 +102,23 @@ export class DriveApiClient { data: { item: Partial; version?: Partial }, ) { if (!data.version) data.version = {} as Partial; - return await Api.post< - { item: Partial; version: Partial }, - DriveItem - >( - `/internal/services/documents/v1/companies/${companyId}/item${appendTdriveToken()}`, - data as { item: Partial; version: Partial }, - ); + + return new Promise((resolve, reject) => { + Api.post< + { item: Partial; version: Partial }, + DriveItem + >( + `/internal/services/documents/v1/companies/${companyId}/item${appendTdriveToken()}`, + data as { item: Partial; version: Partial }, + (res) => { + if ((res as any)?.statusCode || (res as any)?.error) { + reject(res); + } + + resolve(res); + }, + ); + }); } static async createVersion(companyId: string, id: string, version: Partial) { diff --git a/tdrive/frontend/src/app/features/drive/hooks/use-drive-actions.tsx b/tdrive/frontend/src/app/features/drive/hooks/use-drive-actions.tsx index c7c6a58f9..65d174013 100644 --- a/tdrive/frontend/src/app/features/drive/hooks/use-drive-actions.tsx +++ b/tdrive/frontend/src/app/features/drive/hooks/use-drive-actions.tsx @@ -7,7 +7,7 @@ import { DriveItemAtom, DriveItemChildrenAtom } from '../state/store'; import { BrowseFilter, DriveItem, DriveItemVersion } from '../types'; import { SharedWithMeFilterState } from '../state/shared-with-me-filter'; import Languages from 'features/global/services/languages-service'; -import { useUserQuota } from "features/users/hooks/use-user-quota"; +import { useUserQuota } from 'features/users/hooks/use-user-quota'; /** * Returns the children of a drive item @@ -48,17 +48,30 @@ export const useDriveActions = () => { const create = useCallback( async (item: Partial, version: Partial) => { - if (!item || !version) throw new Error("All "); - let driveFile = null; + if (!item || !version) throw new Error('All '); if (!item.company_id) item.company_id = companyId; + try { - driveFile = await DriveApiClient.create(companyId, { item, version }); - await refresh(driveFile.parent_id!); + const driveFile = await DriveApiClient.create(companyId, { item, version }); + + await refresh(driveFile.parent_id); await getQuota(); - } catch (e) { - ToasterService.error(Languages.t('hooks.use-drive-actions.unable_create_file')); + + return driveFile; + } catch (e: any) { + if (e.statusCode === 403) { + ToasterService.info( + <> +

{Languages.t('hooks.use-drive-actions.quota_limit_exceeded_title')}

+

{Languages.t('hooks.use-drive-actions.quota_limit_exceeded_message')}

+

{Languages.t('hooks.use-drive-actions.quota_limit_exceeded_plans')}

+ , + ); + } else { + ToasterService.error(Languages.t('hooks.use-drive-actions.unable_create_file')); + } + return null; } - return driveFile; }, [refresh], ); @@ -132,8 +145,8 @@ export const useDriveActions = () => { const updateBody = { company_id: companyId, user_id: userId, - level: level - } + level: level, + }; await DriveApiClient.updateLevel(companyId, id, updateBody); await refresh(id || ''); } catch (e) { diff --git a/tdrive/frontend/src/app/features/global/framework/api-service.ts b/tdrive/frontend/src/app/features/global/framework/api-service.ts index 679749840..c11b06992 100755 --- a/tdrive/frontend/src/app/features/global/framework/api-service.ts +++ b/tdrive/frontend/src/app/features/global/framework/api-service.ts @@ -151,7 +151,7 @@ export default class Api { requestType?: 'post' | 'get' | 'put' | 'delete'; } = {}, ): Promise { - return new Promise(resolve => { + return new Promise((resolve) => { Requests.request( options.requestType ? options.requestType : 'post', new URL(route, Globals.api_root_url).toString(), diff --git a/tdrive/frontend/src/app/features/users/api/user-api-client.ts b/tdrive/frontend/src/app/features/users/api/user-api-client.ts index 1933f5675..12a51bfd0 100644 --- a/tdrive/frontend/src/app/features/users/api/user-api-client.ts +++ b/tdrive/frontend/src/app/features/users/api/user-api-client.ts @@ -142,9 +142,9 @@ class UserAPIClientService { }); } - async getQuota(userId: string): Promise { + async getQuota(companyId: string, userId: string): Promise { return Api.get( - `/internal/services/users/v1/users/${userId}/quota`, + `/internal/services/users/v1/users/${userId}/quota?companyId=${companyId}`, undefined, false ).then(result => { diff --git a/tdrive/frontend/src/app/features/users/hooks/use-user-quota.ts b/tdrive/frontend/src/app/features/users/hooks/use-user-quota.ts index bd6b3163e..6ed4779a3 100644 --- a/tdrive/frontend/src/app/features/users/hooks/use-user-quota.ts +++ b/tdrive/frontend/src/app/features/users/hooks/use-user-quota.ts @@ -4,6 +4,7 @@ import UserAPIClient from '@features/users/api/user-api-client'; import { useCurrentUser } from "features/users/hooks/use-current-user"; import { atom, useRecoilState } from "recoil"; import useRouteState from "app/features/router/hooks/use-route-state"; +import useRouterCompany from "app/features/router/hooks/use-router-company"; export const QuotaState = atom({ key: 'QuotaState', @@ -22,13 +23,14 @@ export const useUserQuota = () => { } const { appName } = useRouteState(); const isPublic = appName === 'drive'; + const companyId = useRouterCompany(); const { user } = isPublic ? { user: null } : useCurrentUser(); const [quota, setQuota] = useRecoilState(QuotaState); const getQuota = useCallback(async () => { let data: UserQuota = nullQuota; if (user && user?.id) { - data = await UserAPIClient.getQuota(user.id); + data = await UserAPIClient.getQuota(companyId, user.id); } else { data = nullQuota; }