Handling quota limit error (#437)

This commit is contained in:
Montassar Ghanmy
2024-03-26 09:04:36 +01:00
committed by GitHub
parent 389d42fe4f
commit cb5a2ad3b7
14 changed files with 235 additions and 35 deletions
@@ -60,7 +60,7 @@ import {
RealtimeEntityActionType,
ResourcePath,
} from "../../../core/platform/services/realtime/types";
import config from "config";
export class DocumentsService {
version: "1";
repository: Repository<DriveFile>;
@@ -69,6 +69,12 @@ export class DocumentsService {
driveTdriveTabRepository: Repository<DriveTdriveTabEntity>;
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<this> {
@@ -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));
}
};
@@ -50,7 +50,7 @@ export class DocumentsController {
version: Partial<FileVersion>;
};
}>,
): Promise<DriveFile> => {
): Promise<DriveFile | any> => {
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<FileVersion>;
Querystring: { public_token?: string };
}>,
): Promise<FileVersion> => {
const context = getDriveExecutionContext(request);
const { id } = request.params;
const version = request.body;
): Promise<FileVersion | any> => {
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 (
@@ -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);
@@ -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<File>>(
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);
});
});
@@ -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}`;
+3
View File
@@ -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.",
+3
View File
@@ -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.",
+3
View File
@@ -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.",
+3
View File
@@ -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.",
@@ -102,13 +102,23 @@ export class DriveApiClient {
data: { item: Partial<DriveItem>; version?: Partial<DriveItemVersion> },
) {
if (!data.version) data.version = {} as Partial<DriveItemVersion>;
return await Api.post<
{ item: Partial<DriveItem>; version: Partial<DriveItemVersion> },
DriveItem
>(
`/internal/services/documents/v1/companies/${companyId}/item${appendTdriveToken()}`,
data as { item: Partial<DriveItem>; version: Partial<DriveItemVersion> },
);
return new Promise<DriveItem>((resolve, reject) => {
Api.post<
{ item: Partial<DriveItem>; version: Partial<DriveItemVersion> },
DriveItem
>(
`/internal/services/documents/v1/companies/${companyId}/item${appendTdriveToken()}`,
data as { item: Partial<DriveItem>; version: Partial<DriveItemVersion> },
(res) => {
if ((res as any)?.statusCode || (res as any)?.error) {
reject(res);
}
resolve(res);
},
);
});
}
static async createVersion(companyId: string, id: string, version: Partial<DriveItemVersion>) {
@@ -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<DriveItem>, version: Partial<DriveItemVersion>) => {
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(
<>
<p>{Languages.t('hooks.use-drive-actions.quota_limit_exceeded_title')}</p>
<p>{Languages.t('hooks.use-drive-actions.quota_limit_exceeded_message')}</p>
<p>{Languages.t('hooks.use-drive-actions.quota_limit_exceeded_plans')}</p>
</>,
);
} 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) {
@@ -151,7 +151,7 @@ export default class Api {
requestType?: 'post' | 'get' | 'put' | 'delete';
} = {},
): Promise<Response> {
return new Promise(resolve => {
return new Promise((resolve) => {
Requests.request(
options.requestType ? options.requestType : 'post',
new URL(route, Globals.api_root_url).toString(),
@@ -142,9 +142,9 @@ class UserAPIClientService {
});
}
async getQuota(userId: string): Promise<UserQuota> {
async getQuota(companyId: string, userId: string): Promise<UserQuota> {
return Api.get<UserQuota>(
`/internal/services/users/v1/users/${userId}/quota`,
`/internal/services/users/v1/users/${userId}/quota?companyId=${companyId}`,
undefined,
false
).then(result => {
@@ -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<UserQuota>({
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;
}