diff --git a/ROADMAP.md b/ROADMAP.md
index f9539449..759dc6b3 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -217,6 +217,7 @@ Order = user-visible value first; each ships with backend + UI + tests.
- [x] Share `hide_readme` option (upstream #2729 item 6) — `ShareProps.HideReadMe` (only meaningful with `ShowReadMe`); share navigator filters `README.md`/`README.txt` (case-insensitive) from listings while direct-path resolution stays open for the readme viewer; `detectReadMe` URI fallback now probes unconditionally; owner-only `hide_readme` on share responses; Share dialog nested checkbox
- [x] 2FA recovery codes (upstream #2729 item 3) — `user.two_factor_backup_codes` sensitive JSON of `salt:sha256` digests; `PUT /user/setting/2fa/backup` regenerates 10 one-time codes behind a valid TOTP (rate-limited 5/h); `Verify2FA` falls back to single-use code consumption on TOTP failure; codes invalidated on secret rotation/disable; login phase gains a recovery-code input mode; security settings show remaining count + regenerate dialog
- [x] Public share directory (upstream #2729 items 4+5) — `share.listed_publicly` opt-in column gated by new `GroupPermissionSharePublicList` group bit; rejected on password-protected shares at the service layer and normalized off at creation; `GET /share/listed` anonymous endpoint (rate-limited 60/min/IP, cursor pagination) listing only non-expired passwordless listed shares with case-insensitive name search across anchor + covered files; `listed_publicly` owner-visible in share responses; `/discover` page (anonymous-visible nav item + sign-in link), admin group Share section switch, en+zh locales
+- [x] Storage policy total capacity (upstream #2178 item 1) — `PolicySetting.MaxTotalSize` caps cumulative entity bytes per policy; enforced in `PrepareUpload`, batch upload validation, and `copyFiles` (baseline usage + per-batch accumulation since tx writes are invisible to the usage query); canonical `ErrInsufficientCapacity` preserved so `errors.Is` quota handling still matches; admin policy editor gains a Max total capacity SizeInput, en+zh locales
## 6. Phase D — desktop, all platforms
diff --git a/frontend/public/locales/en-US/dashboard.json b/frontend/public/locales/en-US/dashboard.json
index 5eb9c1bc..ad61bdc1 100644
--- a/frontend/public/locales/en-US/dashboard.json
+++ b/frontend/public/locales/en-US/dashboard.json
@@ -1058,6 +1058,8 @@
"selectAStorageProvider": "Select a storage provider",
"maxSizeOfSingleFile": "Max single file size",
"maxSizeOfSingleFileDes": "Enter 0 to disable the limit.",
+ "maxTotalSize": "Max total capacity",
+ "maxTotalSizeDes": "Maximum total bytes stored under this policy. Uploads and copies that would exceed it are rejected. 0 means unlimited.",
"enterFileExt": "Separated by semi-colon commas, leave blank to allow all file extensions.",
"extList": "File extension restrictions",
"noLimit": "No limit",
diff --git a/frontend/public/locales/zh-CN/dashboard.json b/frontend/public/locales/zh-CN/dashboard.json
index 6a4bc7ec..f0b47b9f 100644
--- a/frontend/public/locales/zh-CN/dashboard.json
+++ b/frontend/public/locales/zh-CN/dashboard.json
@@ -1058,6 +1058,8 @@
"selectAStorageProvider": "选择存储方式",
"maxSizeOfSingleFile": "文件大小限制",
"maxSizeOfSingleFileDes": "单个文件的最大大小,输入限制为 0 时表示不限制单文件大小。",
+ "maxTotalSize": "存储策略总容量",
+ "maxTotalSizeDes": "此存储策略下可存储的文件总大小上限,超出后上传和复制将被拒绝。输入 0 表示不限制。",
"enterFileExt": "留空表示不限制文件扩展名,多个请以半角逗号 , 隔开。",
"extList": "文件扩展名限制",
"noLimit": "无限制",
diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts
index eb311e64..4e5fe409 100644
--- a/frontend/src/api/dashboard.ts
+++ b/frontend/src/api/dashboard.ts
@@ -253,6 +253,7 @@ export interface PolicySetting {
thumb_exts?: string[];
thumb_support_all_exts?: boolean;
thumb_max_size?: number;
+ max_total_size?: number;
relay?: boolean;
pre_allocate?: boolean;
media_meta_exts?: string[];
diff --git a/frontend/src/component/Admin/StoragePolicy/EditStoragePolicy/FormSections/StorageAndUploadSection.tsx b/frontend/src/component/Admin/StoragePolicy/EditStoragePolicy/FormSections/StorageAndUploadSection.tsx
index bcbc0e81..e72526f5 100644
--- a/frontend/src/component/Admin/StoragePolicy/EditStoragePolicy/FormSections/StorageAndUploadSection.tsx
+++ b/frontend/src/component/Admin/StoragePolicy/EditStoragePolicy/FormSections/StorageAndUploadSection.tsx
@@ -110,6 +110,16 @@ const StorageAndUploadSection = () => {
[setPolicy],
);
+ const onMaxTotalSizeChange = useCallback(
+ (e: number) => {
+ setPolicy((p: StoragePolicy) => ({
+ ...p,
+ settings: { ...p.settings, max_total_size: e === 0 ? undefined : e },
+ }));
+ },
+ [setPolicy],
+ );
+
const fileExts = useMemo(() => {
return values.settings?.file_type?.join() ?? "";
}, [values.settings?.file_type]);
@@ -278,6 +288,12 @@ const StorageAndUploadSection = () => {
{t("policy.maxSizeOfSingleFileDes")}
+
+
+
+ {t("policy.maxTotalSizeDes")}
+
+
0 {
+ var used int
+ _, used, err = f.fileClient.CountEntityByStoragePolicyID(ctx, dstPolicy.ID)
+ if err != nil {
+ return nil, nil, nil, fmt.Errorf("copy files: failed to get storage policy usage: %w", err)
+ }
+ dstPolicyUsed = int64(used)
+ }
+
dstAncestors := lo.Map(destination.AncestorsChain(), func(item *File, index int) *ent.File {
return item.Model
})
@@ -991,6 +1008,10 @@ func (f *DBFS) copyFiles(ctx context.Context, targets map[Navigator][]*File, des
if err := f.validateUserCapacityRaw(ctx, sizeTotal, capacity); err != nil {
return fs.ErrInsufficientCapacity
}
+ if err := f.validatePolicyCapacityRaw(sizeTotal, dstPolicy, dstPolicyUsed); err != nil {
+ return err
+ }
+ dstPolicyUsed += sizeTotal
limit -= len(targets)
newDstMap, diff, err := fc.Copy(ctx, &inventory.CopyParameter{
diff --git a/pkg/filemanager/fs/dbfs/upload.go b/pkg/filemanager/fs/dbfs/upload.go
index 22ae21d1..e2081aa2 100644
--- a/pkg/filemanager/fs/dbfs/upload.go
+++ b/pkg/filemanager/fs/dbfs/upload.go
@@ -68,6 +68,9 @@ func (f *DBFS) PreValidateUpload(ctx context.Context, dst *fs.URI, files ...fs.P
if err := f.validateUserCapacity(ctx, total, dstFile.Owner()); err != nil {
return err
}
+ if err := f.validatePolicyCapacity(ctx, total, policy); err != nil {
+ return err
+ }
return nil
}
@@ -162,6 +165,9 @@ func (f *DBFS) PrepareUpload(ctx context.Context, req *fs.UploadRequest, opts ..
if err := validateNewFile(req.Props.Uri.Name(), req.Props.Size, policy); err != nil {
return nil, err
}
+ if err := f.validatePolicyCapacity(ctx, req.Props.Size, policy); err != nil {
+ return nil, err
+ }
owner := ancestor.Owner()
capacity, err := f.Capacity(ctx, owner)
diff --git a/pkg/filemanager/fs/dbfs/validator.go b/pkg/filemanager/fs/dbfs/validator.go
index dbe9582e..ac906351 100644
--- a/pkg/filemanager/fs/dbfs/validator.go
+++ b/pkg/filemanager/fs/dbfs/validator.go
@@ -121,3 +121,30 @@ func (f *DBFS) validateUserCapacityRaw(ctx context.Context, size int64, capacity
}
return nil
}
+
+// validatePolicyCapacity checks that storing `size` more bytes under `policy`
+// stays within the policy's MaxTotalSize cap, fetching current usage first.
+func (f *DBFS) validatePolicyCapacity(ctx context.Context, size int64, policy *ent.StoragePolicy) error {
+ if policy.Settings.MaxTotalSize <= 0 {
+ return nil
+ }
+
+ _, used, err := f.fileClient.CountEntityByStoragePolicyID(ctx, policy.ID)
+ if err != nil {
+ return fmt.Errorf("failed to get storage policy usage: %w", err)
+ }
+ return f.validatePolicyCapacityRaw(size, policy, int64(used))
+}
+
+// validatePolicyCapacityRaw validates the policy capacity against a
+// caller-supplied usage figure — needed when new entities are being written
+// inside a transaction the usage query cannot see yet. The canonical
+// ErrInsufficientCapacity is returned unwrapped so upstream errors.Is
+// checks still match; the policy detail is logged server-side.
+func (f *DBFS) validatePolicyCapacityRaw(size int64, policy *ent.StoragePolicy, used int64) error {
+ if policyCap := policy.Settings.MaxTotalSize; policyCap > 0 && used+size > policyCap {
+ f.l.Warning("storage policy %q is full (%d + %d > %d)", policy.Name, used, size, policyCap)
+ return fs.ErrInsufficientCapacity
+ }
+ return nil
+}
diff --git a/pkg/filemanager/fs/dbfs/validator_test.go b/pkg/filemanager/fs/dbfs/validator_test.go
index 3b381335..fd96076e 100644
--- a/pkg/filemanager/fs/dbfs/validator_test.go
+++ b/pkg/filemanager/fs/dbfs/validator_test.go
@@ -5,6 +5,8 @@ import (
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
+ "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
+ "github.com/cloudreve/Cloudreve/v4/pkg/logging"
"github.com/stretchr/testify/require"
)
@@ -25,3 +27,19 @@ func TestValidateFileNameNativeChars(t *testing.T) {
require.NoError(t, validateFileName("normal file.txt", nil))
}
+
+func TestValidatePolicyCapacityRaw(t *testing.T) {
+ f := &DBFS{l: logging.NewConsoleLogger(logging.LevelError)}
+ capped := &ent.StoragePolicy{Name: "s3", Settings: &types.PolicySetting{MaxTotalSize: 1000}}
+ open := &ent.StoragePolicy{Name: "local", Settings: &types.PolicySetting{}}
+
+ // Zero cap disables the check.
+ require.NoError(t, f.validatePolicyCapacityRaw(1<<62, open, 1<<62))
+ // Within budget.
+ require.NoError(t, f.validatePolicyCapacityRaw(400, capped, 500))
+ // Exactly at the cap is the last allowed byte.
+ require.NoError(t, f.validatePolicyCapacityRaw(500, capped, 500))
+ // Over the cap.
+ require.ErrorIs(t, f.validatePolicyCapacityRaw(501, capped, 500), fs.ErrInsufficientCapacity)
+ require.ErrorIs(t, f.validatePolicyCapacityRaw(1, capped, 1000), fs.ErrInsufficientCapacity)
+}