From 384432d31e7bee6bf35d8af6b7165410303ffda4 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Sun, 6 Jul 2025 15:54:49 +0000 Subject: feat: Add per user storage quota --- apps/web/components/admin/UpdateUserDialog.tsx | 26 ++++ apps/web/components/admin/UserList.tsx | 7 + apps/workers/workerUtils.ts | 1 - apps/workers/workers/assetPreprocessingWorker.ts | 18 +++ apps/workers/workers/crawlerWorker.ts | 165 ++++++++++++++++------- apps/workers/workers/videoWorker.ts | 74 ++++++---- 6 files changed, 216 insertions(+), 75 deletions(-) (limited to 'apps') diff --git a/apps/web/components/admin/UpdateUserDialog.tsx b/apps/web/components/admin/UpdateUserDialog.tsx index 82a239ca..7093ccda 100644 --- a/apps/web/components/admin/UpdateUserDialog.tsx +++ b/apps/web/components/admin/UpdateUserDialog.tsx @@ -41,12 +41,14 @@ interface UpdateUserDialogProps { userId: string; currentRole: "user" | "admin"; currentQuota: number | null; + currentStorageQuota: number | null; children?: React.ReactNode; } export default function UpdateUserDialog({ userId, currentRole, currentQuota, + currentStorageQuota, children, }: UpdateUserDialogProps) { const apiUtils = api.useUtils(); @@ -55,6 +57,7 @@ export default function UpdateUserDialog({ userId, role: currentRole, bookmarkQuota: currentQuota, + storageQuota: currentStorageQuota, }; const form = useForm({ resolver: zodResolver(updateUserSchema), @@ -147,6 +150,29 @@ export default function UpdateUserDialog({ )} /> + ( + + Storage Quota (bytes) + + { + const value = e.target.value; + field.onChange(value === "" ? null : parseInt(value)); + }} + /> + + + + )} + /> {t("common.email")} {t("admin.users_list.num_bookmarks")} {t("common.quota")} + Storage Quota {t("admin.users_list.asset_sizes")} {t("common.role")} {t("admin.users_list.local_user")} @@ -88,6 +89,11 @@ export default function UsersSection() { {u.bookmarkQuota ?? t("admin.users_list.unlimited")} + + {u.storageQuota + ? toHumanReadableSize(u.storageQuota) + : t("admin.users_list.unlimited")} + {toHumanReadableSize(userStats[u.id].assetSizes)} @@ -140,6 +146,7 @@ export default function UsersSection() { userId={u.id} currentRole={u.role!} currentQuota={u.bookmarkQuota} + currentStorageQuota={u.storageQuota} > { - await updateAsset( - oldFullPageArchiveAssetId, - { - id: fullPageArchiveAssetId, - bookmarkId, - userId, - assetType: AssetTypes.LINK_FULL_PAGE_ARCHIVE, - contentType, - size, - fileName: null, - }, - txn, - ); - }); - if (oldFullPageArchiveAssetId) { - silentDeleteAsset(userId, oldFullPageArchiveAssetId); + if (archiveResult) { + const { + assetId: fullPageArchiveAssetId, + size, + contentType, + } = archiveResult; + + await db.transaction(async (txn) => { + await updateAsset( + oldFullPageArchiveAssetId, + { + id: fullPageArchiveAssetId, + bookmarkId, + userId, + assetType: AssetTypes.LINK_FULL_PAGE_ARCHIVE, + contentType, + size, + fileName: null, + }, + txn, + ); + }); + if (oldFullPageArchiveAssetId) { + silentDeleteAsset(userId, oldFullPageArchiveAssetId); + } } } }; diff --git a/apps/workers/workers/videoWorker.ts b/apps/workers/workers/videoWorker.ts index ca591e6f..d25c1948 100644 --- a/apps/workers/workers/videoWorker.ts +++ b/apps/workers/workers/videoWorker.ts @@ -8,7 +8,6 @@ import { db } from "@karakeep/db"; import { AssetTypes } from "@karakeep/db/schema"; import { ASSET_TYPES, - getAssetSize, newAssetId, saveAssetFromFile, silentDeleteAsset, @@ -20,6 +19,10 @@ import { ZVideoRequest, zvideoRequestSchema, } from "@karakeep/shared/queues"; +import { + checkStorageQuota, + StorageQuotaError, +} from "@karakeep/trpc/lib/storageQuota"; import { withTimeout } from "../utils"; import { getBookmarkDetails, updateAsset } from "../workerUtils"; @@ -140,32 +143,51 @@ async function runWorker(job: DequeuedJob) { logger.info( `[VideoCrawler][${jobId}] Finished downloading a file from "${url}" to "${assetPath}"`, ); - await saveAssetFromFile({ - userId, - assetId: videoAssetId, - assetPath, - metadata: { contentType: ASSET_TYPES.VIDEO_MP4 }, - }); - - await db.transaction(async (txn) => { - await updateAsset( - oldVideoAssetId, - { - id: videoAssetId, - bookmarkId, - userId, - assetType: AssetTypes.LINK_VIDEO, - contentType: ASSET_TYPES.VIDEO_MP4, - size: await getAssetSize({ userId, assetId: videoAssetId }), - }, - txn, - ); - }); - await silentDeleteAsset(userId, oldVideoAssetId); - logger.info( - `[VideoCrawler][${jobId}] Finished downloading video from "${url}" and adding it to the database`, - ); + // Get file size and check quota before saving + const stats = await fs.promises.stat(assetPath); + const fileSize = stats.size; + + try { + const quotaApproved = await checkStorageQuota(db, userId, fileSize); + + await saveAssetFromFile({ + userId, + assetId: videoAssetId, + assetPath, + metadata: { contentType: ASSET_TYPES.VIDEO_MP4 }, + quotaApproved, + }); + + await db.transaction(async (txn) => { + await updateAsset( + oldVideoAssetId, + { + id: videoAssetId, + bookmarkId, + userId, + assetType: AssetTypes.LINK_VIDEO, + contentType: ASSET_TYPES.VIDEO_MP4, + size: fileSize, + }, + txn, + ); + }); + await silentDeleteAsset(userId, oldVideoAssetId); + + logger.info( + `[VideoCrawler][${jobId}] Finished downloading video from "${url}" and adding it to the database`, + ); + } catch (error) { + if (error instanceof StorageQuotaError) { + logger.warn( + `[VideoCrawler][${jobId}] Skipping video storage due to quota exceeded: ${error.message}`, + ); + await deleteLeftOverAssetFile(jobId, videoAssetId); + return; + } + throw error; + } } /** -- cgit v1.2.3-70-g09d2