feat(storage policy): set deny/allow list for file extension and custom regexp (#2695)

This commit is contained in:
Aaron Liu 2025-07-25 11:32:02 +08:00
parent b8dd0fcd9d
commit a20621868f
18 changed files with 194 additions and 26 deletions

View File

@ -588,7 +588,10 @@
"setConcurrentTooltip": "Set the max number of tasks that can be uploaded simultaneously.",
"setConcurrent": "Set concurrent task limit",
"sizeExceedLimitError": "File size exceeds storage policy limits. (Maximum: {{max}})",
"suffixNotAllowedError": "The storage policy does not support uploading files with this extension. (Supported:{{supported}})",
"suffixNotAllowedError": "The storage policy does not support uploading files with this extension.",
"regexpNotAllowedError": "The storage policy does not support uploading files with this name.",
"suffixAllowed": " (Supported:{{supported}})",
"suffixDenied": " (Denied:{{denied}})",
"createUploadSessionError": "Unable to create upload session",
"deleteUploadSessionError": "Unable to delete upload session",
"requestError": "Request failed: {{msg}} ({{url}}).",

View File

@ -79,7 +79,7 @@
"40049": "File size exceed limit.",
"40050": "File type not allowed.",
"40051": "Insufficient storage quota.",
"40052": "Invalid object name, please remove special characters.",
"40052": "This file name or extension is not allowed.",
"40053": "Cannot perform such action on root folder",
"40054": "File with the same name is already being uploaded under this folder, please cleanup upload sessions.",
"40055": "File metadata mismatch.",
@ -102,4 +102,4 @@
"50010": "Desired node is unavailable.",
"50011": "Failed to query file metadata."
}
}
}

View File

@ -865,7 +865,12 @@
"maxSizeOfSingleFile": "Max single file size",
"maxSizeOfSingleFileDes": "Enter 0 to disable the limit.",
"enterFileExt": "Separated by semi-colon commas, leave blank to allow all file extensions.",
"extList": "Allowed file extensions",
"extList": "File extension restrictions",
"noLimit": "No limit",
"whitelist": "Allow",
"blacklist": "Deny",
"fileNameRegex": "File name regex rules",
"fileNameRegexDes": "Regular expression to match file names, leave blank for no restriction.",
"chunkSizeDes": "Specify the chunk size for chunked uploads. A value of 0 means no chunked uploads are used, but the maximum upload size may be limited by the web server.",
"chunkSizeDesSuffix": "{{prefix}} With chunked upload, the files uploaded by users will be sliced into chunks and uploaded to the storage side one by one. After the upload is interrupted, users can choose to continue uploading from the last uploaded chunk.",
"chunkSize": "Chunk size",

View File

@ -592,7 +592,10 @@
"setConcurrentTooltip": "同時に実行するタスク数を設定する",
"setConcurrent": "並列処理数を設定する",
"sizeExceedLimitError": "ファイルサイズがストレージポリシーの制限を超えています(最大:{{max}}",
"suffixNotAllowedError": "ストレージポリシーはこの拡張子のファイルをサポートしていません(現在サポート:{{supported}}",
"suffixNotAllowedError": "ストレージポリシーはこの拡張子のファイルをサポートしていません",
"regexpNotAllowedError": "ストレージポリシーはこのファイル名をサポートしていません",
"suffixAllowed": "(サポートされている拡張子:{{supported}}",
"suffixDenied": "(禁止されている拡張子:{{denied}}",
"createUploadSessionError": "アップロードセッションを作成できません",
"deleteUploadSessionError": "アップロードセッションを削除できません",
"requestError": "リクエストに失敗しました: {{msg}} ({{url}})",

View File

@ -79,7 +79,7 @@
"40049": "ファイルサイズが制限を超えています",
"40050": "ファイルの種類が許可されていません",
"40051": "容量不足です",
"40052": "オブジェクト名が不正です。特殊文字を削除してください",
"40052": "このファイル名または拡張子は許可されていません",
"40053": "ルートディレクトリではこの操作はサポートされていません",
"40054": "現在、同じ名前のファイルがアップロードされています。アップロードセッションをクリアしてください",
"40055": "ファイル情報が一致しません",
@ -102,4 +102,4 @@
"50010": "対象ノードが利用できません",
"50011": "ファイルメタデータの取得に失敗しました"
}
}
}

View File

@ -866,7 +866,12 @@
"maxSizeOfSingleFile": "ファイルサイズ制限",
"maxSizeOfSingleFileDes": "単一ファイルの最大サイズ。0を入力すると、単一ファイルサイズに制限はありません。",
"enterFileExt": "空欄はファイル拡張子の制限なしを示します。複数指定する場合は半角カンマ「,」で区切ってください。",
"extList": "許可されるファイル拡張子",
"extList": "ファイル拡張子制限",
"noLimit": "無制限",
"whitelist": "許可リスト",
"blacklist": "ブロックリスト",
"fileNameRegex": "ファイル名正規表現ルール",
"fileNameRegexDes": "ファイル名にマッチする正規表現。空白の場合は制限なし。",
"chunkSizeDes": "チャンクアップロード時のチャンクサイズを指定してください。0 を入力するとチャンクアップロードを使用せず、最大アップロードサイズはWebサーバーによって制限される可能性があります。",
"chunkSizeDesSuffix": "{{prefix}}チャンクアップロードを使用すると、ユーザーがアップロードするファイルはチャンクに分割され、個別にストレージにアップロードされます。アップロードが中断された場合、ユーザーは前回アップロードしたチャンクの続きからアップロードを再開できます。",
"chunkSize": "アップロードチャンクサイズ",

View File

@ -592,7 +592,10 @@
"setConcurrentTooltip": "设定同时进行的任务数量",
"setConcurrent": "设置并行数量",
"sizeExceedLimitError": "文件大小超出存储策略限制(最大:{{max}}",
"suffixNotAllowedError": "存储策略不支持上传此扩展名的文件(当前支持:{{supported}}",
"suffixNotAllowedError": "存储策略不支持上传此扩展名的文件",
"regexpNotAllowedError": "存储策略不支持上传此名称的文件",
"suffixAllowed": "(支持的扩展名:{{supported}}",
"suffixDenied": "(禁止的扩展名:{{denied}}",
"createUploadSessionError": "无法创建上传会话",
"deleteUploadSessionError": "无法删除上传会话",
"requestError": "请求失败: {{msg}} ({{url}})",

View File

@ -79,7 +79,7 @@
"40049": "文件大小超出限制",
"40050": "文件类型不允许",
"40051": "容量空间不足",
"40052": "对象名非法,请移除特殊字符",
"40052": "此文件名或扩展名不被允许",
"40053": "不支持对根目录执行此操作",
"40054": "话当前目录下已经有同名文件正在上传中,请尝试清空上传会话",
"40055": "文件信息不一致",
@ -102,4 +102,4 @@
"50010": "目标节点不可用",
"50011": "文件元信息查询失败"
}
}
}

View File

@ -865,7 +865,12 @@
"maxSizeOfSingleFile": "文件大小限制",
"maxSizeOfSingleFileDes": "单个文件的最大大小,输入限制为 0 时表示不限制单文件大小。",
"enterFileExt": "留空表示不限制文件扩展名,多个请以半角逗号 , 隔开。",
"extList": "允许的文件扩展名",
"extList": "文件扩展名限制",
"noLimit": "无限制",
"whitelist": "允许",
"blacklist": "拒绝",
"fileNameRegex": "文件名正则规则",
"fileNameRegexDes": "用于匹配文件名的正则表达式,留空表示无限制。",
"chunkSizeDes": "请指定分片上传时的分片大小,填写为 0 表示不使用分片上传,但最大上传大小可能受限于 Web 服务器。",
"chunkSizeDesSuffix": "{{prefix}}通过分片上传,用户上传的文件将会被切分成分片逐个上传到存储端,当上传中断后,用户可以选择从上次上传的分片后继续开始上传。",
"chunkSize": "上传分片大小",

View File

@ -588,7 +588,10 @@
"setConcurrentTooltip": "設定同時進行的任務數量",
"setConcurrent": "設定並行數量",
"sizeExceedLimitError": "檔案大小超出儲存策略限制(最大:{{max}}",
"suffixNotAllowedError": "儲存策略不支援上傳此副檔名的檔案(當前支援:{{supported}}",
"suffixNotAllowedError": "儲存策略不支援上傳此副檔名的檔案",
"regexpNotAllowedError": "儲存策略不支援上傳此名稱的檔案",
"suffixAllowed": "(支持的副檔名:{{supported}}",
"suffixDenied": "(禁止的副檔名:{{denied}}",
"createUploadSessionError": "無法建立上傳會話",
"deleteUploadSessionError": "無法刪除上傳會話",
"requestError": "請求失敗: {{msg}} ({{url}})",

View File

@ -79,7 +79,7 @@
"40049": "檔案大小超出限制",
"40050": "檔案型別不允許",
"40051": "容量空間不足",
"40052": "物件名非法,請移除特殊字元",
"40052": "此檔案名或副檔名不被允許",
"40053": "不支援對根目錄執行此操作",
"40054": "話當前目錄下已經有同名檔案正在上傳中,請嘗試清空上傳會話",
"40055": "檔案資訊不一致",
@ -102,4 +102,4 @@
"50010": "目標節點不可用",
"50011": "檔案元資訊查詢失敗"
}
}
}

View File

@ -862,7 +862,12 @@
"maxSizeOfSingleFile": "檔案大小限制",
"maxSizeOfSingleFileDes": "單個檔案的最大大小,輸入限制為 0 時表示不限制單檔案大小。",
"enterFileExt": "留空表示不限制副檔名,多個請以半形逗號 , 隔開。",
"extList": "允許的副檔名",
"extList": "副檔名限制",
"noLimit": "無限制",
"whitelist": "允許",
"blacklist": "拒絕",
"fileNameRegex": "檔名正規表達式規則",
"fileNameRegexDes": "用於匹配檔名的正規表達式,留空表示無限制。",
"chunkSizeDes": "請指定分片上傳時的分片大小,填寫為 0 表示不使用分片上傳,但最大上傳大小可能受限於 Web 伺服器。",
"chunkSizeDesSuffix": "{{prefix}}通過分片上傳,使用者上傳的檔案將會被切分成分片逐個上傳到儲存端,當上傳中斷後,使用者可以選擇從上次上傳的分片後繼續開始上傳。",
"chunkSize": "上傳分片大小",

View File

@ -206,6 +206,9 @@ export enum NodeStatus {
export interface PolicySetting {
token?: string;
file_type?: string[];
is_file_type_deny_list?: boolean;
file_regexp?: string;
is_name_regexp_deny_list?: boolean;
od_redirect?: string;
custom_proxy?: boolean;
proxy_server?: string;

View File

@ -108,6 +108,9 @@ export interface StoragePolicy {
id: string;
name: string;
allowed_suffix?: string[];
denied_suffix?: string[];
allowed_name_regexp?: string;
denied_name_regexp?: string;
max_size: number;
type: PolicyType;
relay?: boolean;

View File

@ -1,9 +1,9 @@
import { FormControl, FormControlLabel, Link, Switch, Typography } from "@mui/material";
import { FormControl, FormControlLabel, InputAdornment, Link, MenuItem, Switch, Typography } from "@mui/material";
import { useCallback, useContext, useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { StoragePolicy } from "../../../../../api/dashboard";
import { PolicyType } from "../../../../../api/explorer";
import SizeInput from "../../../../Common/SizeInput";
import SizeInput, { StyleOutlinedSelect } from "../../../../Common/SizeInput";
import { DenseFilledTextField } from "../../../../Common/StyledComponents";
import SettingForm from "../../../../Pages/Setting/SettingForm";
import MagicVarDialog from "../../../Common/MagicVarDialog";
@ -77,6 +77,49 @@ const StorageAndUploadSection = () => {
[setPolicy],
);
const onFileExtsModeChange = useCallback(
(mode: boolean | undefined) => {
setPolicy((p: StoragePolicy) => ({
...p,
settings: {
...(p.settings ?? {}),
is_file_type_deny_list: mode,
},
}));
},
[setPolicy],
);
const fileRegexp = useMemo(() => {
return values.settings?.file_regexp ?? "";
}, [values.settings?.file_regexp]);
const onFileRegexpChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setPolicy((p: StoragePolicy) => ({
...p,
settings: {
...(p.settings ?? {}),
file_regexp: e.target.value === "" ? undefined : e.target.value,
},
}));
},
[setPolicy],
);
const onFileRegexpModeChange = useCallback(
(mode: boolean | undefined) => {
setPolicy((p: StoragePolicy) => ({
...p,
settings: {
...(p.settings ?? {}),
is_name_regexp_deny_list: mode,
},
}));
},
[setPolicy],
);
const onChunkSizeChange = useCallback(
(size: number) => {
setPolicy((p: StoragePolicy) => ({
@ -167,10 +210,66 @@ const StorageAndUploadSection = () => {
</SettingForm>
<SettingForm title={t("policy.extList")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField multiline maxRows={4} value={fileExts} onChange={onFileExtsChange} />
<DenseFilledTextField
placeholder={t("policy.noLimit")}
multiline
maxRows={4}
value={fileExts}
onChange={onFileExtsChange}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<StyleOutlinedSelect
size="small"
variant="filled"
value={values.settings?.is_file_type_deny_list === true ? "blacklist" : "whitelist"}
onChange={(e) => onFileExtsModeChange(e.target.value === "blacklist" ? true : undefined)}
sx={{ minWidth: 80, mr: 1 }}
>
<MenuItem dense value="whitelist">
{t("policy.whitelist")}
</MenuItem>
<MenuItem dense value="blacklist">
{t("policy.blacklist")}
</MenuItem>
</StyleOutlinedSelect>
</InputAdornment>
),
}}
/>
<NoMarginHelperText>{t("policy.enterFileExt")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("policy.fileNameRegex")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
placeholder={t("policy.noLimit")}
value={fileRegexp}
onChange={onFileRegexpChange}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<StyleOutlinedSelect
size="small"
variant="filled"
value={values.settings?.is_name_regexp_deny_list === true ? "blacklist" : "whitelist"}
onChange={(e) => onFileRegexpModeChange(e.target.value === "blacklist" ? true : undefined)}
sx={{ minWidth: 80, mr: 1 }}
>
<MenuItem dense value="whitelist">
{t("policy.whitelist")}
</MenuItem>
<MenuItem dense value="blacklist">
{t("policy.blacklist")}
</MenuItem>
</StyleOutlinedSelect>
</InputAdornment>
),
}}
/>
<NoMarginHelperText>{t("policy.fileNameRegexDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
{values.type !== PolicyType.upyun && (
<SettingForm title={t("policy.chunkSize")} lgWidth={5}>
<FormControl fullWidth>

View File

@ -54,7 +54,7 @@ export const StyledSelect = styled(Select)(() => ({
backgroundColor: "initial",
}));
const StyleOutlinedSelect = styled(Select)(({ theme }) => ({
export const StyleOutlinedSelect = styled(Select)(({ theme }) => ({
"& .MuiFilledInput-input": {
paddingTop: "5px",
"&:focus": {

View File

@ -67,12 +67,12 @@ export class UploaderError implements Error {
// 文件未通过存储策略验证
export class FileValidateError extends UploaderError {
// 未通过验证的文件属性
public field: "size" | "suffix";
public field: "size" | "suffix" | "suffix_denied" | "regexp";
// 对应的存储策略
public policy: StoragePolicy;
constructor(message: string, field: "size" | "suffix", policy: StoragePolicy) {
constructor(message: string, field: "size" | "suffix" | "suffix_denied" | "regexp", policy: StoragePolicy) {
super(UploaderErrorName.InvalidFile, message);
this.field = field;
this.policy = policy;
@ -85,9 +85,25 @@ export class FileValidateError extends UploaderError {
});
}
return i18next.t(`uploader.suffixNotAllowedError`, {
supported: this.policy.allowed_suffix ? this.policy.allowed_suffix.join(",") : "*",
});
if (this.field == "suffix_denied") {
return (
i18next.t("uploader.suffixNotAllowedError") +
i18next.t(`uploader.suffixDenied`, {
denied: this.policy.denied_suffix ? this.policy.denied_suffix.join(",") : "*",
})
);
}
if (this.field == "regexp") {
return i18next.t("uploader.regexpNotAllowedError");
}
return (
i18next.t(`uploader.suffixNotAllowedError`) +
i18next.t(`uploader.suffixAllowed`, {
supported: this.policy.allowed_suffix ? this.policy.allowed_suffix.join(",") : "*",
})
);
}
}

View File

@ -8,11 +8,26 @@ interface Validator {
// validators
const checkers: Array<Validator> = [
function checkExt(file: File, policy: StoragePolicy) {
const ext = file?.name.split(".").pop();
if (policy.allowed_suffix != undefined && policy.allowed_suffix.length > 0) {
const ext = file?.name.split(".").pop();
if (ext === null || !ext || !policy.allowed_suffix.includes(ext))
throw new FileValidateError("File suffix not allowed in policy.", "suffix", policy);
}
if (policy.denied_suffix != undefined && policy.denied_suffix.length > 0) {
if (ext && policy.denied_suffix.includes(ext))
throw new FileValidateError("File suffix not allowed in policy.", "suffix_denied", policy);
}
},
function checkRegexp(file: File, policy: StoragePolicy) {
if (policy.allowed_name_regexp != undefined && policy.allowed_name_regexp.length > 0) {
if (!new RegExp(policy.allowed_name_regexp).test(file.name))
throw new FileValidateError("File name must match the allowed regexp.", "regexp", policy);
}
if (policy.denied_name_regexp != undefined && policy.denied_name_regexp.length > 0) {
if (new RegExp(policy.denied_name_regexp).test(file.name))
throw new FileValidateError("File name must not match the regexp.", "regexp", policy);
}
},
function checkSize(file: File, policy: StoragePolicy) {