feat(thumb): force-proxy toggle + designated thumbnail storage policy

Upstream #2178 items 7+8.

- PolicySetting.ThumbForceProxy: skips the storage backend's native
  thumbnail API even when the file qualifies, routing generation through
  the local proxy pipeline; implies proxy generation.
- PolicySetting.ThumbStoragePolicyID: generated thumbnail entities are
  stored on the designated policy instead of the file's own policy.
  PrepareUpload honors PreferredStoragePolicy for thumbnail entity
  uploads; the manager resolves the designation at generation time and
  falls back to the source policy if the target is unavailable.
- Admin Thumbnails section gains both controls (en+zh); the section now
  renders for local policies too since the designation applies there.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
pull/3589/head
Tomas Dvorak 2 weeks ago
parent ed8f55963d
commit 481e5d8ad2

@ -220,6 +220,7 @@ Order = user-visible value first; each ships with backend + UI + tests.
- [x] Decompression-bomb guards (meta #2 item 10) — `DecompressSize` now bounds cumulative extracted output (its documented "total file size" intent), not just compressed input: `checkExtractGuards` aborts at the limit and at a 100k-entry cap (`maxExtractEntries`, bounds dir-creation bombs), each entry stream wrapped in `cappedFile` so understated size headers cannot overrun; slave path receives the limit via `SlaveExtractArchiveTaskState.ExtractLimit`; all failures carry `queue.CriticalErr` (no retry of the same bomb); resume-safe via cursor-skip size accounting
- [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
- [x] Storage policy overflow chain (upstream #2178 item 4) — `PolicySetting.OverflowPolicyID` links a fallback policy; `overflowChain` walks hops with cycle guard + hop cap, skipping suspended members and resolving load-balance members to weighted children; `PrepareUpload` spills to the first member with headroom for the file size so name/size/extension rules apply to the landing policy; `PreValidateUpload` checks aggregate chain headroom since batches may split across members; admin policy editor gains an Overflow policy select, en+zh locales
- [x] Thumbnail generation controls (upstream #2178 items 7+8) — `PolicySetting.ThumbForceProxy` skips the backend's native thumbnail API even when supported, implying the local proxy pipeline; `PolicySetting.ThumbStoragePolicyID` redirects generated thumb entities to a designated policy (honored via `PreferredStoragePolicy` for thumbnail uploads in `PrepareUpload`); Thumbnails section gains both controls, en+zh locales
## 6. Phase D — desktop, all platforms

@ -977,6 +977,11 @@
"nativeMediaMetaExtUpyun": "The image processing service supports <0>extracting image EXIF</0>. No manual configuration is required, just add the extensions you want to process above.",
"thumbProxy": "Proxy thumbnail generation",
"thumbProxyDes": "Enable this feature to generate thumbnails for files that do not meet the native thumbnail conditions. Cloudreve will try to generate thumbnails and upload them to the storage side. Please configure the thumbnail generator in <0>Media processing</0>.",
"thumbForceProxy": "Force proxy thumbnail generation",
"thumbForceProxyDes": "Always generate thumbnails with the local proxy pipeline, even when the storage backend offers a native thumbnail API. Implies proxy thumbnail generation.",
"thumbStoragePolicy": "Thumbnail storage policy",
"thumbStoragePolicySource": "Same as file",
"thumbStoragePolicyDes": "Generated thumbnails are stored under the selected policy instead of the file's own storage policy.",
"nativeThumbnailMaxSize": "Max size of native thumbnails",
"nativeThumbnailMaxSizeDes": "Enter 0 to disable the size limit, files larger than this size will not use native thumbnails.",
"nativeThumbNailsSupportAllExts": "Enable for all file extension",

@ -977,6 +977,11 @@
"nativeMediaMetaExtUpyun": "图片处理服务支持 <0>提取图片 EXIF</0>。无需手动配置,在上面加上你想要处理的图片的扩展名即可。",
"thumbProxy": "代理生成缩略图",
"thumbProxyDes": "开启后,对于不符合原生缩略图条件的文件,Cloudreve 会尝试为其生成缩略图文件,并上传到存储端。请在 <0>媒体处理</0> 中配置 Cloudreve 缩略图生成器。",
"thumbForceProxy": "强制代理生成缩略图",
"thumbForceProxyDes": "即使存储端支持原生缩略图 API,也始终使用本地代理流水线生成缩略图。启用后等同于开启代理生成缩略图。",
"thumbStoragePolicy": "缩略图存储策略",
"thumbStoragePolicySource": "与文件相同",
"thumbStoragePolicyDes": "生成的缩略图将保存到所选存储策略,而非文件自身的存储策略。",
"nativeThumbnailMaxSize": "使用原生缩略图的最大文件大小",
"nativeThumbnailMaxSizeDes": "填写 0 表示不限制,超出此大小的文件将不会使用原生缩略图。",
"nativeThumbNailsSupportAllExts": "对所有文件扩展名使用",

@ -260,6 +260,8 @@ export interface PolicySetting {
media_meta_exts?: string[];
media_meta_generator_proxy?: boolean;
thumb_generator_proxy?: boolean;
thumb_force_proxy?: boolean;
thumb_storage_policy_id?: number;
native_media_processing?: boolean;
s3_delete_batch_size?: number;
stream_saver?: boolean;

@ -1,11 +1,14 @@
import { Checkbox, FormControl, FormControlLabel, Link, Switch, Typography } from "@mui/material";
import { useCallback, useContext, useMemo } from "react";
import { Checkbox, FormControl, FormControlLabel, Link, SelectChangeEvent, Switch, Typography } from "@mui/material";
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Link as RouterLink } from "react-router-dom";
import { getStoragePolicyList } from "../../../../../api/api";
import { StoragePolicy } from "../../../../../api/dashboard";
import { PolicyType } from "../../../../../api/explorer";
import { useAppDispatch } from "../../../../../redux/hooks";
import SizeInput from "../../../../Common/SizeInput";
import { DenseFilledTextField } from "../../../../Common/StyledComponents";
import { DenseFilledTextField, DenseSelect } from "../../../../Common/StyledComponents";
import { SquareMenuItem } from "../../../../FileManager/ContextMenu/ContextMenu";
import SettingForm from "../../../../Pages/Setting/SettingForm";
import { NoMarginHelperText, SettingSection, SettingSectionContent } from "../../../Settings/Settings";
import { PolicyPropsMap } from "../../StoragePolicySetting";
@ -14,6 +17,16 @@ import { StoragePolicySettingContext } from "../StoragePolicySettingWrapper";
const ThumbnailsSection = () => {
const { t } = useTranslation("dashboard");
const { values, setPolicy } = useContext(StoragePolicySettingContext);
const dispatch = useAppDispatch();
const [thumbPolicyCandidates, setThumbPolicyCandidates] = useState<StoragePolicy[]>([]);
useEffect(() => {
dispatch(getStoragePolicyList({ page: 1, page_size: 1000, order_by: "id", order_direction: "asc" })).then(
(res) => {
setThumbPolicyCandidates(res.policies.filter((p) => p.id !== values.id));
},
);
}, [values.id]);
const policyProps = useMemo(() => {
return PolicyPropsMap[values.type];
@ -66,9 +79,28 @@ const ThumbnailsSection = () => {
[setPolicy],
);
if (values.type === PolicyType.local) {
return null;
}
const onThumbForceProxyChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setPolicy((p: StoragePolicy) => ({
...p,
settings: { ...p.settings, thumb_force_proxy: e.target.checked ? true : undefined },
}));
},
[setPolicy],
);
const onThumbPolicyChange = useCallback(
(e: SelectChangeEvent<unknown>) => {
const id = e.target.value as number;
setPolicy((p: StoragePolicy) => ({
...p,
settings: { ...p.settings, thumb_storage_policy_id: id === 0 ? undefined : id },
}));
},
[setPolicy],
);
const isLocal = values.type === PolicyType.local;
return (
<SettingSection>
@ -76,7 +108,7 @@ const ThumbnailsSection = () => {
{t("settings.thumbnails")}
</Typography>
<SettingSectionContent>
{!noNativeThumbnail && (
{!isLocal && !noNativeThumbnail && (
<>
<SettingForm title={t("policy.nativeThumbNails")} lgWidth={5}>
<FormControl fullWidth>
@ -129,6 +161,7 @@ const ThumbnailsSection = () => {
</SettingForm>
</>
)}
{!isLocal && (
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
@ -146,6 +179,36 @@ const ThumbnailsSection = () => {
</NoMarginHelperText>
</FormControl>
</SettingForm>
)}
{!isLocal && !noNativeThumbnail && (
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch checked={values.settings?.thumb_force_proxy ?? false} onChange={onThumbForceProxyChange} />
}
label={t("policy.thumbForceProxy")}
/>
<NoMarginHelperText>{t("policy.thumbForceProxyDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
)}
<SettingForm title={t("policy.thumbStoragePolicy")} lgWidth={5}>
<FormControl fullWidth>
<DenseSelect
value={values.settings?.thumb_storage_policy_id ?? 0}
onChange={onThumbPolicyChange}
>
<SquareMenuItem value={0}>{t("policy.thumbStoragePolicySource")}</SquareMenuItem>
{thumbPolicyCandidates.map((p) => (
<SquareMenuItem key={p.id} value={p.id}>
{p.name}
</SquareMenuItem>
))}
</DenseSelect>
<NoMarginHelperText>{t("policy.thumbStoragePolicyDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
</SettingSectionContent>
</SettingSection>
);

@ -141,6 +141,14 @@ type (
MediaMetaGeneratorProxy bool `json:"media_meta_generator_proxy,omitempty"`
// ThumbGeneratorProxy whether to use local proxy to generate thumbnail.
ThumbGeneratorProxy bool `json:"thumb_generator_proxy,omitempty"`
// ThumbForceProxy forces thumbnail generation through the local proxy
// pipeline even when the storage backend offers a native thumbnail API.
// Implies ThumbGeneratorProxy behavior.
ThumbForceProxy bool `json:"thumb_force_proxy,omitempty"`
// ThumbStoragePolicyID overrides where generated thumbnail entities are
// stored — they ignore the source file's policy and land here. 0 keeps
// thumbnails on the source policy.
ThumbStoragePolicyID int `json:"thumb_storage_policy_id,omitempty"`
// NativeMediaProcessing whether to use native media processing API from storage provider.
NativeMediaProcessing bool `json:"native_media_processing"`
// S3DeleteBatchSize the number of objects to delete in each batch.

@ -0,0 +1,64 @@
package dbfs
import (
"context"
"testing"
"time"
"github.com/cloudreve/Cloudreve/v4/ent/enttest"
"github.com/cloudreve/Cloudreve/v4/ent/storagepolicy"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/require"
)
func thumbUploadReq(t *testing.T, f *DBFS, u int, name string, size int64, preferredPolicy int) *fs.UploadRequest {
t.Helper()
uri, err := fs.NewUriFromString(fs.NewMyUri(hashid.EncodeUserID(f.hasher, u)) + "/" + name)
require.NoError(t, err)
thumbType := types.EntityTypeThumbnail
return &fs.UploadRequest{
Props: &fs.UploadProps{
Uri: uri,
Size: size,
UploadSessionID: uuid.Must(uuid.NewV4()).String(),
ExpireAt: time.Now().Add(time.Hour),
EntityType: &thumbType,
PreferredStoragePolicy: preferredPolicy,
},
}
}
func TestPrepareUploadThumbPolicyOverride(t *testing.T) {
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
t.Cleanup(func() { require.NoError(t, client.Close()) })
ctx := context.Background()
u, src, f := dedupUploadFixture(t, client, "off", 1<<40)
dst := client.StoragePolicy.Create().SetName("thumbs").SetType("local").
SetStatus(storagepolicy.StatusActive).SetSettings(&types.PolicySetting{}).SaveX(ctx)
// Create the backing file so a thumbnail entity can attach; completing
// releases the upload-session lock the thumb request would hit.
s, err := f.PrepareUpload(ctx, uploadReq(t, f.hasher, u, "img.png", 1024, ""))
require.NoError(t, err)
require.Equal(t, src.ID, s.Policy.ID)
_, err = f.CompleteUpload(ctx, s)
require.NoError(t, err)
// Thumbnail upload with a designated policy resolves to it, not the
// file's own policy.
s, err = f.PrepareUpload(ctx, thumbUploadReq(t, f, u.ID, "img.png", 64, dst.ID))
require.NoError(t, err)
require.Equal(t, dst.ID, s.Policy.ID)
_, err = f.CompleteUpload(ctx, s)
require.NoError(t, err)
// Without a designation the thumb follows the file's policy.
s, err = f.PrepareUpload(ctx, thumbUploadReq(t, f, u.ID, "img.png", 64, 0))
require.NoError(t, err)
require.Equal(t, src.ID, s.Policy.ID)
}

@ -140,10 +140,13 @@ func (f *DBFS) PrepareUpload(ctx context.Context, req *fs.UploadRequest, opts ..
var (
policy *ent.StoragePolicy
)
if req.ImportFrom == nil {
policy, err = f.getPreferredPolicy(ctx, ancestor)
} else {
isThumb := req.Props.EntityType != nil && *req.Props.EntityType == types.EntityTypeThumbnail
if req.ImportFrom != nil || (isThumb && req.Props.PreferredStoragePolicy != 0) {
// Imports and server-designated thumbnail targets name their policy
// explicitly instead of inheriting the ancestor's preference.
policy, err = f.storagePolicyClient.GetPolicyByID(ctx, req.Props.PreferredStoragePolicy)
} else {
policy, err = f.getPreferredPolicy(ctx, ancestor)
}
if err != nil {
return nil, err

@ -33,6 +33,10 @@ func (p dedupSettingProvider) DBFS(context.Context) *setting.DBFS {
return &setting.DBFS{DedupScope: p.scope, MaxPageSize: 200, MaxRecursiveSearchedFolder: 100}
}
func (p dedupSettingProvider) ThumbEntitySuffix(context.Context) string {
return "{blob_path}/{blob_name}._thumb"
}
func (p dedupSettingProvider) AuditLogEnabled(context.Context, int) bool {
return true
}

@ -10,6 +10,7 @@ import (
"time"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/ent/storagepolicy"
"github.com/cloudreve/Cloudreve/v4/ent/task"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/activity"
@ -59,15 +60,18 @@ func (m *manager) Thumbnail(ctx context.Context, uri *fs.URI) (entitysource.Enti
}
// 2. Thumb entity not exist, try native policy generator
_, handler, err := m.getEntityPolicyDriver(ctx, latest, nil)
policy, handler, err := m.getEntityPolicyDriver(ctx, latest, nil)
if err != nil {
return nil, fmt.Errorf("failed to get entity policy driver: %w", err)
}
capabilities := handler.Capabilities()
// thumb_force_proxy skips the native generator even when it supports
// this file — the local pipeline is mandatory.
forceProxy := policy.Settings != nil && policy.Settings.ThumbForceProxy
// Check if file extension and size is supported by native policy generator.
if capabilities.ThumbSupportAllExts || util.IsInExtensionList(capabilities.ThumbSupportedExts, file.DisplayName()) &&
if !forceProxy && (capabilities.ThumbSupportAllExts || util.IsInExtensionList(capabilities.ThumbSupportedExts, file.DisplayName()) &&
(capabilities.ThumbMaxSize == 0 || latest.Size() <= capabilities.ThumbMaxSize) &&
!latest.Encrypted() {
!latest.Encrypted()) {
thumbSource, err := m.GetEntitySource(ctx, 0, fs.WithEntity(latest), fs.WithUseThumb(true))
if err != nil {
return nil, fmt.Errorf("failed to get latest entity source: %w", err)
@ -75,7 +79,7 @@ func (m *manager) Thumbnail(ctx context.Context, uri *fs.URI) (entitysource.Enti
thumbSource.Apply(entitysource.WithDisplayName(file.DisplayName()))
return thumbSource, nil
} else if capabilities.ThumbProxy {
} else if capabilities.ThumbProxy || forceProxy {
if err := m.fs.CheckCapability(ctx, uri,
dbfs.WithRequiredCapabilities(dbfs.NavigatorCapabilityGenerateThumb)); err != nil {
// Current FS does not support generate new thumb.
@ -204,6 +208,18 @@ func (m *manager) generateThumb(ctx context.Context, uri *fs.URI, ext string, es
Seeker: thumbFile,
}
// The source policy may designate a dedicated thumbnail storage
// policy — the entity then ignores the file's own policy.
if srcPolicy, err := m.policyClient.GetPolicyByID(ctx, es.Entity().PolicyID()); err == nil &&
srcPolicy.Settings != nil && srcPolicy.Settings.ThumbStoragePolicyID != 0 {
if dst, err := m.policyClient.GetPolicyByID(ctx, srcPolicy.Settings.ThumbStoragePolicyID); err == nil &&
dst.Status == storagepolicy.StatusActive {
req.Props.PreferredStoragePolicy = dst.ID
} else {
m.l.Warning("designated thumbnail policy %d unavailable, storing with source policy", srcPolicy.Settings.ThumbStoragePolicyID)
}
}
// Generating thumb can be triggered by users with read-only permission. We can bypass update permission check.
ctx = dbfs.WithBypassOwnerCheck(ctx)

Loading…
Cancel
Save