diff --git a/tdrive/backend/node/src/services/documents/services/index.ts b/tdrive/backend/node/src/services/documents/services/index.ts index eb1a8247..92b3b6d7 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 e025afeb..917a7c80 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 ade72f20..39c76454 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 00000000..a3f14329 --- /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 019fb95c..884f7dc4 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 2fbdd9ea..849462f0 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 6fc3cabf..bd7dbcfe 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 65c2aa21..b16033f6 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 d6d27b16..874fee0c 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 ba0bc37e..0be3717b 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 c7c6a58f..65d17401 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 67974984..c11b0699 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 1933f567..12a51bfd 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 bd6b3163..6ed4779a 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; }