diff --git a/assets/admin/src/api/modules/permissions.js b/assets/admin/src/api/modules/permissions.js index 1b6e47ed..1c46d27e 100644 --- a/assets/admin/src/api/modules/permissions.js +++ b/assets/admin/src/api/modules/permissions.js @@ -3,14 +3,6 @@ export const permissionsApiMethods = { * 功能&权限 (Permissions) * ================================================================ */ - getPermissionsAttachmentSettings() { return this.getSchemaPage('permissions', 'attachment') }, - savePermissionsAttachmentSettings(data) { return this.saveSchemaPage('permissions', 'attachment', data) }, - - getPermissionsLoginSettings() { return this.getSchemaPage('permissions', 'login') }, - savePermissionsLoginSettings(data) { return this.saveSchemaPage('permissions', 'login', data) }, - - getPermissionsModerationSettings() { return this.getSchemaPage('permissions', 'moderation') }, - getCapabilities() { return this.request('/permissions/capabilities') }, saveCapabilities(user_caps) { return this.request('/permissions/capabilities', { method: 'POST', body: JSON.stringify({ user_caps }) }) }, } diff --git a/assets/admin/src/components/form/AksField.vue b/assets/admin/src/components/form/AksField.vue index 03d805cc..da9ecc4e 100644 --- a/assets/admin/src/components/form/AksField.vue +++ b/assets/admin/src/components/form/AksField.vue @@ -60,17 +60,19 @@ /> - +
+ + {{ schema.unit }} +
@@ -1210,6 +1212,19 @@ function confirmRepeaterModal() { max-width: 360px; } +.aksrt-field__number { + display: flex; + align-items: center; + gap: 8px; + max-width: 360px; +} + +.aksrt-field__unit { + color: var(--aksrt-color-muted); + font-size: 13px; + white-space: nowrap; +} + .aksrt-field__between-sep { color: var(--aksrt-color-border-strong); font-size: 14px; diff --git a/assets/admin/src/constants/sections/permissions.js b/assets/admin/src/constants/sections/permissions.js index 40ade320..839cc175 100644 --- a/assets/admin/src/constants/sections/permissions.js +++ b/assets/admin/src/constants/sections/permissions.js @@ -37,14 +37,13 @@ export const permissionsSection = { canonicalDomain: 'permissions', section: 'moderation', }), - customPage({ + schemaPage({ key: 'permissions-attachment', name: 'PermissionsAttachment', label: '附件配置', route: '/permissions/attachment', - component: 'AttachmentPermissions', title: '附件配置', - desc: '管理文件大小、类型、安全限制和审核策略。', + desc: '头像图片、上传大小、上传格式与上传行为配置。', canonicalDomain: 'permissions', section: 'attachment', }), diff --git a/assets/admin/src/pages/permissions/AttachmentPermissions.vue b/assets/admin/src/pages/permissions/AttachmentPermissions.vue deleted file mode 100644 index 0c9ab642..00000000 --- a/assets/admin/src/pages/permissions/AttachmentPermissions.vue +++ /dev/null @@ -1,174 +0,0 @@ - - - - - diff --git a/assets/admin/src/router/component-loaders/core.js b/assets/admin/src/router/component-loaders/core.js index 94e637a9..85885f65 100644 --- a/assets/admin/src/router/component-loaders/core.js +++ b/assets/admin/src/router/component-loaders/core.js @@ -7,7 +7,6 @@ export const corePageLoaders = { PostPageSettings: () => import('@/pages/post-list/PostPageSettings.vue'), CommentSettings: () => import('@/pages/post-list/CommentSettings.vue'), PermissionsCapabilities: () => import('@/pages/permissions/PermissionsCapabilities.vue'), - AttachmentPermissions: () => import('@/pages/permissions/AttachmentPermissions.vue'), CmsCredits: () => import('@/pages/CmsCredits.vue'), ThemeInfoPage: () => import('@/pages/theme/ThemeInfoPage.vue'), ThemeAuthorizationPage: () => import('@/pages/theme/ThemeAuthorizationPage.vue'), diff --git a/assets/admin/src/schema/sections/permissions/index.js b/assets/admin/src/schema/sections/permissions/index.js index e5708af4..ca99ceaf 100644 --- a/assets/admin/src/schema/sections/permissions/index.js +++ b/assets/admin/src/schema/sections/permissions/index.js @@ -3,6 +3,30 @@ * Section keys must stay aligned with the router and PHP schema provider. */ +/** + * User type options shared by the upload limit repeaters. + * Mirrors AKSRT\Attachment\UploadLimits::user_type_options() (levels 2-10, VIP 1-2). + */ +const uploadUserTypeOptions = [ + { label: '最小默认值(所有人)', value: 'default' }, + { label: '已登录用户', value: 'logged' }, + ...[2, 3, 4, 5, 6, 7, 8, 9, 10].map((level) => ({ label: `等级达到 ${level} 级`, value: `level_${level}` })), + { label: 'VIP1 会员', value: 'vip_1' }, + { label: 'VIP2 会员', value: 'vip_2' }, + { label: '已认证用户', value: 'auth' }, + { label: '超级管理员', value: 'admin' }, +] + +const uploadMimeUserTypeOptions = [ + { label: '所有用户', value: 'all' }, + ...uploadUserTypeOptions, +] + +const uploadSizeFields = [ + { key: 'type', label: '用户类型', type: 'select', default: 'logged', options: uploadUserTypeOptions }, + { key: 'val', label: '可上传', type: 'number', default: 30, min: 1, max: 2048 }, +] + export const permissionsSchemas = { /** * Login / captcha / access guard settings @@ -188,47 +212,140 @@ export const permissionsSchemas = { }, /** - * Attachment permissions + * Attachment / upload governance */ attachment: { - key: 'permissions.attachment', - title: '附件权限', - icon: 'fa fas fa-paperclip', - description: '控制文件上传和附件下载权限。', - defaults: { - avatar_max_size: 2, - submit_image_max_size: 5, - comment_image_max_size: 2, - resource_max_size: 50, - }, - groups: [ - { - key: 'profile', - title: '用户资料卡背景图', - icon: 'fa fas fa-user-circle', - fields: [ - { key: 'avatar_max_size', label: '头像/用户资料卡背景图大小上限 (MB)', type: 'number', min: 1, max: 10 }, - ], - }, - { - key: 'content', - title: '内容上传', - icon: 'fa fas fa-image', - fields: [ - { key: 'submit_image_max_size', label: '投稿图片大小上限 (MB)', type: 'number', min: 1, max: 20 }, - { key: 'comment_image_max_size', label: '评论图片大小上限 (MB)', type: 'number', min: 1, max: 10 }, - ], - }, - { - key: 'resource', - title: '资源附件', - icon: 'fa fas fa-file-archive', - fields: [ - { key: 'resource_max_size', label: '资源附件大小上限 (MB)', type: 'number', min: 1, max: 200 }, - ], - }, + key: 'permissions.attachment', + title: '附件配置', + icon: 'fa fas fa-paperclip', + description: '统一管理头像、上传大小、上传格式与上传行为;准入权限请在“基本权限”中配置。', + defaults: { + avatar_max_size: 2, + upload_img_size: [ + { type: 'default', val: 5 }, + { type: 'admin', val: 20 }, ], + image_upload_multiple: 6, + upload_video_size: [ + { type: 'default', val: 30 }, + { type: 'admin', val: 2048 }, + ], + upload_file_size: [ + { type: 'default', val: 30 }, + { type: 'admin', val: 2048 }, + ], + upload_file_mimes: [ + { type: 'admin', pattern: 'allow', val: 'svg=image/svg+xml' }, + { type: 'all', pattern: 'allow', val: '' }, + ], + newfilename: false, + newfilename_type: 'time', + close_attachment_page: true, }, + groups: [ + { + key: 'profile', + title: '用户头像与资料卡图片', + icon: 'fa fas fa-id-card', + fields: [ + { key: 'avatar_max_size', label: '头像/用户资料卡背景图大小上限 (MB)', type: 'number', min: 1, max: 10 }, + ], + }, + { + key: 'size-limits', + title: '上传大小限制', + icon: 'fa fas fa-weight-hanging', + description: '设置上传大小,请考虑服务器负荷、硬盘,以及服务器最大能支持的范围。实际上传大小取「主题限制」与「PHP 环境限制」的较小值,超过 PHP 环境限制时请调整 php.ini 的 upload_max_filesize 与 post_max_size。', + fields: [ + { + key: 'upload_img_size', + label: '前端图片上传限制', + type: 'repeater', + min: 1, + addText: '添加细分用户权限', + help: '投稿、评论等前端场景允许上传的最大图片大小(单位 MB)。同一个用户满足多个条件则取最大值,「最小默认值」为所有人兜底。', + fields: uploadSizeFields.map((field) => ({ ...field, default: field.key === 'val' ? 5 : field.default })), + }, + { key: 'image_upload_multiple', label: '图片批量上传最大数量', type: 'number', min: 0, max: 100, unit: '张', help: '前端投稿、评论上传图片允许的单次批量上传数量(为 0 则不限制,为 1 则不能批量上传)。' }, + { + key: 'upload_video_size', + label: '前端视频上传限制', + type: 'repeater', + min: 1, + addText: '添加细分用户权限', + help: '前端允许上传的最大视频大小(单位 MB)。同一个用户满足多个条件则取最大值,必须包含最低默认值。', + fields: uploadSizeFields, + }, + { + key: 'upload_file_size', + label: '前端文件上传大小限制', + type: 'repeater', + min: 1, + addText: '添加细分用户权限', + help: '前端允许上传的其他文件(不含视频和图片,建议大于视频图片)最大大小(单位 MB)。同一个用户满足多个条件则取最大值,必须包含最低默认值。', + fields: uploadSizeFields, + }, + ], + }, + { + key: 'mime-limits', + title: '上传格式限制', + icon: 'fa fas fa-file-code', + fields: [ + { + key: 'mime_limit_notice', + label: '安全提示', + type: 'notice', + alertType: 'warning', + alertContent: '请添加文件的扩展名和 MIME 类型,用英文逗号分割。格式示例:mp3=audio/mpeg,jpg=image/jpeg。禁止的优先级大于允许。郑重提示:请勿允许上传 php、sh、exe 等可执行文件,会极大增加服务器被入侵攻击的风险。', + }, + { + key: 'upload_file_mimes', + label: '前端上传文件格式限制', + type: 'repeater', + min: 1, + addText: '添加细分用户权限', + help: '根据不同的用户类型,添加额外允许或禁止上传的文件格式(禁止的优先级大于允许)。', + fields: [ + { key: 'type', label: '用户类型', type: 'select', default: 'all', options: uploadMimeUserTypeOptions }, + { + key: 'pattern', + label: '规则', + type: 'select', + default: 'allow', + options: [ + { label: '允许', value: 'allow' }, + { label: '禁止', value: 'prohibit' }, + ], + }, + { key: 'val', label: '格式', type: 'textarea', default: '', rows: 1, placeholder: '如:svg=image/svg+xml,mp3=audio/mpeg' }, + ], + }, + ], + }, + { + key: 'behavior', + title: '上传行为', + icon: 'fa fas fa-cogs', + fields: [ + { key: 'newfilename', label: '上传文件自动重命名', type: 'switch', help: '开启后上传文件按规则重命名,减少重名文件查询,提高上传效率。' }, + { + key: 'newfilename_type', + label: '重命名格式', + type: 'radio', + buttonStyle: false, + default: 'time', + dependsOn: { key: 'newfilename', value: true }, + options: [ + { label: '时间 + 原文件名', value: 'time' }, + { label: '随机字符串', value: 'random' }, + ], + }, + { key: 'close_attachment_page', label: '非管理员屏蔽附件页面', type: 'switch', help: '开启后非管理员访问附件(图片、文件等)页面将直接显示 404,可有效提高文件的安全性。' }, + ], + }, + ], +}, /** * Download link configuration diff --git a/assets/js/front-submit-editor.js b/assets/js/front-submit-editor.js index 059f2ae7..d4da6bc3 100644 --- a/assets/js/front-submit-editor.js +++ b/assets/js/front-submit-editor.js @@ -144,6 +144,18 @@ }); } + function imageBatchLimit() { + // 0 = 不限制(沿用默认 100 的选取上限),1 = 不允许批量上传。 + var configured = Number(cfg.imageUploadMultiple); + if (!isFinite(configured) || configured <= 0) { + return { multiple: true, maxItems: 100 }; + } + if (configured === 1) { + return { multiple: false, maxItems: 1 }; + } + return { multiple: true, maxItems: Math.min(configured, 100) }; + } + function mediaDefinition(type) { if (type === 'video') return { title: i18n.insertVideo || '插入视频', mineTab: i18n.mineVideo || '我的视频', uploadTab: i18n.uploadVideo || '上传视频', @@ -156,9 +168,10 @@ accept: '*/*', multiple: false, maxItems: 1, canUpload: !!editorCfg.canUploadFile && !!((cfg.uploads || {}).resource), canEmbed: false, select: i18n.selectFile || '选择或拖入附件', hint: i18n.fileUploadHint || '上传完成后自动插入正文', externalHint: i18n.externalFileHint || '请填写附件地址:', }; + var batch = imageBatchLimit(); return { title: i18n.insertImage || '插入图片', mineTab: i18n.mineImage || '我的图片', uploadTab: i18n.uploadImage || '上传图片', externalTab: i18n.externalImage || '外链图片', - accept: 'image/gif,image/jpeg,image/png,image/webp', multiple: true, maxItems: 100, canUpload: !!editorCfg.canUploadImage && !!((cfg.uploads || {}).image), canEmbed: false, + accept: 'image/gif,image/jpeg,image/png,image/webp', multiple: batch.multiple, maxItems: batch.maxItems, canUpload: !!editorCfg.canUploadImage && !!((cfg.uploads || {}).image), canEmbed: false, select: i18n.selectImages || '选择或拖入图片', hint: i18n.imageUploadHint || '可批量选择,上传完成后自动插入正文', externalHint: i18n.externalImageHint || '每行填写一个图片地址:', }; } @@ -575,13 +588,14 @@ bindCharacterCount(editor); if (editorCfg.canUploadImage) { + var imageBatch = imageBatchLimit(); editor.addButton('aksrt_image', { text: '', icon: 'image', tooltip: i18n.insertImage || '插入图片', onclick: function () { window.AksrtSubmitMedia.open({ - type: 'image', multiple: true, maxItems: 100, + type: 'image', multiple: imageBatch.multiple, maxItems: imageBatch.maxItems, onSelect: function (items) { editor.insertContent(items.map(function (item) { return mediaMarkup('image', item); }).join('') + '

'); editor.nodeChanged(); diff --git a/functions.php b/functions.php index 97280115..5731058a 100644 --- a/functions.php +++ b/functions.php @@ -301,6 +301,9 @@ if (class_exists('\AKSRT\Core\CategoryRewrite')) { if (class_exists('\AKSRT\Core\FrontSubmit')) { \AKSRT\Core\FrontSubmit::get_instance()->init(); } +if (class_exists('\AKSRT\Attachment\UploadLimits')) { + \AKSRT\Attachment\UploadLimits::get_instance()->init(); +} if (class_exists('\AKSRT\Payment\Order')) { \AKSRT\Payment\Order::get_instance()->init(); } diff --git a/src/Admin/Schema/SchemaPermissions.php b/src/Admin/Schema/SchemaPermissions.php index a3ffe96e..c6d2a183 100644 --- a/src/Admin/Schema/SchemaPermissions.php +++ b/src/Admin/Schema/SchemaPermissions.php @@ -9,6 +9,34 @@ defined('ABSPATH') || exit; class SchemaPermissions { + /** + * User type options for upload size limit rows. + * + * @return array + */ + private static function upload_user_type_options(): array { + $options = []; + foreach (\AKSRT\Attachment\UploadLimits::user_type_options() as $value => $label) { + $options[] = ['label' => $label, 'value' => $value]; + } + return $options; + } + + /** + * User type options for mime limit rows (adds the "all users" type). + * + * @return array + */ + private static function upload_mime_user_type_options(): array { + $options = [ + ['label' => __('所有用户', 'aksrt'), 'value' => 'all'], + ]; + foreach (\AKSRT\Attachment\UploadLimits::user_type_options() as $value => $label) { + $options[] = ['label' => $label, 'value' => $value]; + } + return $options; + } + public static function build(): array { $provider_meta = function_exists('aksrt_captcha_provider_metadata') ? aksrt_captcha_provider_metadata() : []; $provider_options = [['label' => '关闭', 'value' => '']]; @@ -60,19 +88,58 @@ class SchemaPermissions { 'attachment' => [ 'key' => 'attachment', 'title' => '附件配置', - 'version' => '1.1.0', + 'version' => '1.2.0', 'option_key' => 'aksrt_settings', 'groups' => [ - ['key' => 'profile', 'title' => '用户资料卡背景图', 'fields' => [ + ['key' => 'profile', 'title' => '用户头像与资料卡图片', 'fields' => [ ['key' => 'avatar_max_size', 'label' => '头像/用户资料卡背景图大小上限 (MB)', 'type' => 'number', 'default' => 2, 'min' => 1, 'max' => 10], ]], - ['key' => 'content', 'title' => '内容上传限制', 'fields' => [ - ['key' => 'submit_image_max_size', 'label' => '投稿图片大小上限 (MB)', 'type' => 'number', 'default' => 5, 'min' => 1, 'max' => 20], - ['key' => 'submit_video_max_size', 'label' => '投稿视频大小上限 (MB)', 'type' => 'number', 'default' => 30, 'min' => 1, 'max' => 200], - ['key' => 'comment_image_max_size', 'label' => '评论图片大小上限 (MB)', 'type' => 'number', 'default' => 2, 'min' => 1, 'max' => 10], + ['key' => 'size-limits', 'title' => '上传大小限制', 'fields' => [ + ['key' => 'size_limit_notice', 'label' => '服务器环境提示', 'type' => 'notice', 'alertType' => 'warning', 'alertContent' => '设置上传大小,请考虑服务器负荷、硬盘,以及服务器最大能支持的范围。实际上传大小取「主题限制」与「PHP 环境限制」的较小值,超过 PHP 环境限制时请调整 php.ini 的 upload_max_filesize 与 post_max_size。'], + ['key' => 'upload_img_size', 'label' => '前端图片上传限制', 'type' => 'repeater', 'default' => [ + ['type' => 'default', 'val' => 5], + ['type' => 'admin', 'val' => 20], + ], 'min' => 1, 'button_title' => '添加细分用户权限', 'help' => '投稿、评论等前端场景允许上传的最大图片大小(单位 MB)。同一个用户满足多个条件则取最大值,「最小默认值」为所有人兜底。', 'fields' => [ + ['key' => 'type', 'label' => '用户类型', 'type' => 'select', 'default' => 'logged', 'options' => self::upload_user_type_options()], + ['key' => 'val', 'label' => '可上传', 'type' => 'number', 'default' => 5, 'min' => 1, 'max' => 2048, 'unit' => 'M'], + ]], + ['key' => 'image_upload_multiple', 'label' => '图片批量上传最大数量', 'type' => 'number', 'default' => 6, 'min' => 0, 'max' => 100, 'unit' => '张', 'help' => '前端投稿、评论上传图片允许的单次批量上传数量(为 0 则不限制,为 1 则不能批量上传)。'], + ['key' => 'upload_video_size', 'label' => '前端视频上传限制', 'type' => 'repeater', 'default' => [ + ['type' => 'default', 'val' => 30], + ['type' => 'admin', 'val' => 2048], + ], 'min' => 1, 'button_title' => '添加细分用户权限', 'help' => '前端允许上传的最大视频大小(单位 MB)。同一个用户满足多个条件则取最大值,必须包含最低默认值。', 'fields' => [ + ['key' => 'type', 'label' => '用户类型', 'type' => 'select', 'default' => 'logged', 'options' => self::upload_user_type_options()], + ['key' => 'val', 'label' => '可上传', 'type' => 'number', 'default' => 30, 'min' => 1, 'max' => 2048, 'unit' => 'M'], + ]], + ['key' => 'upload_file_size', 'label' => '前端文件上传大小限制', 'type' => 'repeater', 'default' => [ + ['type' => 'default', 'val' => 30], + ['type' => 'admin', 'val' => 2048], + ], 'min' => 1, 'button_title' => '添加细分用户权限', 'help' => '前端允许上传的其他文件(不含视频和图片,建议大于视频图片)最大大小(单位 MB)。同一个用户满足多个条件则取最大值,必须包含最低默认值。', 'fields' => [ + ['key' => 'type', 'label' => '用户类型', 'type' => 'select', 'default' => 'logged', 'options' => self::upload_user_type_options()], + ['key' => 'val', 'label' => '可上传', 'type' => 'number', 'default' => 30, 'min' => 1, 'max' => 2048, 'unit' => 'M'], + ]], ]], - ['key' => 'resource', 'title' => '资源附件限制', 'fields' => [ - ['key' => 'resource_max_size', 'label' => '资源附件大小上限 (MB)', 'type' => 'number', 'default' => 50, 'min' => 1, 'max' => 200], + ['key' => 'mime-limits', 'title' => '上传格式限制', 'fields' => [ + ['key' => 'mime_limit_notice', 'label' => '安全提示', 'type' => 'notice', 'alertType' => 'warning', 'alertContent' => '请添加文件的扩展名和 MIME 类型,用英文逗号分割。格式示例:mp3=audio/mpeg,jpg=image/jpeg。禁止的优先级大于允许。郑重提示:请勿允许上传 php、sh、exe 等可执行文件,会极大增加服务器被入侵攻击的风险。'], + ['key' => 'upload_file_mimes', 'label' => '前端上传文件格式限制', 'type' => 'repeater', 'default' => [ + ['type' => 'admin', 'pattern' => 'allow', 'val' => 'svg=image/svg+xml'], + ['type' => 'all', 'pattern' => 'allow', 'val' => ''], + ], 'min' => 1, 'button_title' => '添加细分用户权限', 'help' => '根据不同的用户类型,添加额外允许或禁止上传的文件格式(禁止的优先级大于允许)。', 'fields' => [ + ['key' => 'type', 'label' => '用户类型', 'type' => 'select', 'default' => 'all', 'options' => self::upload_mime_user_type_options()], + ['key' => 'pattern', 'label' => '规则', 'type' => 'select', 'default' => 'allow', 'options' => [ + ['label' => '允许', 'value' => 'allow'], + ['label' => '禁止', 'value' => 'prohibit'], + ]], + ['key' => 'val', 'label' => '格式', 'type' => 'textarea', 'default' => '', 'rows' => 1, 'placeholder' => '如:svg=image/svg+xml,mp3=audio/mpeg'], + ]], + ]], + ['key' => 'behavior', 'title' => '上传行为', 'fields' => [ + ['key' => 'newfilename', 'label' => '上传文件自动重命名', 'type' => 'switch', 'default' => false, 'help' => '开启后上传文件按规则重命名,减少重名文件查询,提高上传效率。'], + ['key' => 'newfilename_type', 'label' => '重命名格式', 'type' => 'radio', 'default' => 'time', 'dependsOn' => ['key' => 'newfilename', 'value' => true], 'options' => [ + ['label' => '时间 + 原文件名', 'value' => 'time'], + ['label' => '随机字符串', 'value' => 'random'], + ]], + ['key' => 'close_attachment_page', 'label' => '非管理员屏蔽附件页面', 'type' => 'switch', 'default' => true, 'help' => '开启后非管理员访问附件(图片、文件等)页面将直接显示 404,可有效提高文件的安全性。'], ]], ], ], diff --git a/src/Admin/UserController.php b/src/Admin/UserController.php index a1817459..9954d296 100644 --- a/src/Admin/UserController.php +++ b/src/Admin/UserController.php @@ -546,11 +546,12 @@ class UserController extends ControllerBase { return $this->error(__('图片文件上传失败,请重新选择', 'aksrt'), 400); } - $settings = class_exists('\\AKSRT\\Settings\\Settings') ? \AKSRT\Settings\Settings::get_all() : []; - $max_size_mb = max(1, min(10, (int) ($settings['comment_image_max_size'] ?? 2))); + $max_size = class_exists('\AKSRT\Attachment\UploadLimits') + ? \AKSRT\Attachment\UploadLimits::limit_bytes('image', $user_id) + : (int) wp_max_upload_size(); $actual_size = !empty($file['tmp_name']) && is_readable($file['tmp_name']) ? (int) filesize($file['tmp_name']) : 0; - if ($actual_size <= 0 || $actual_size > $max_size_mb * MB_IN_BYTES) { - return $this->error(sprintf(__('图片大小必须在 1 字节至 %dMB 之间', 'aksrt'), $max_size_mb), 413); + if ($actual_size <= 0 || $actual_size > $max_size) { + return $this->error(sprintf(__('图片大小不能超过 %s。', 'aksrt'), size_format($max_size)), 413); } $mimes = [ diff --git a/src/Attachment/UploadLimits.php b/src/Attachment/UploadLimits.php new file mode 100644 index 00000000..e23d3b20 --- /dev/null +++ b/src/Attachment/UploadLimits.php @@ -0,0 +1,325 @@ + 'upload_img_size', + 'video' => 'upload_video_size', + 'file' => 'upload_file_size', + ]; + + /** Fallback limits (MB) used before the admin saves any size row. */ + const KIND_FALLBACK_MB = [ + 'image' => 5.0, + 'video' => 30.0, + 'file' => 30.0, + ]; + + /** Hard caps mirroring the capability matrix (level 2..10, vip 1..2). */ + const LEVEL_MAX = 10; + const VIP_MAX = 2; + + public static function get_instance(): self { + if (null === self::$instance) { + self::$instance = new self(); + } + return self::$instance; + } + + public function init(): void { + add_filter('upload_mimes', [$this, 'filter_upload_mimes']); + add_filter('getimagesize_mimes_to_exts', [$this, 'filter_image_mimes_to_exts']); + add_filter('wp_handle_upload_prefilter', [$this, 'rename_upload_file'], 99); + add_action('template_redirect', [$this, 'close_attachment_page']); + } + + /** + * User type options shared by the admin schema and the SPA schema. + * + * @return array + */ + public static function user_type_options(): array { + $options = [ + 'default' => __('最小默认值(所有人)', 'aksrt'), + 'logged' => __('已登录用户', 'aksrt'), + ]; + for ($i = 2; $i <= self::LEVEL_MAX; $i++) { + $options['level_' . $i] = sprintf(__('等级达到 %d 级', 'aksrt'), $i); + } + for ($i = 1; $i <= self::VIP_MAX; $i++) { + $options['vip_' . $i] = sprintf(__('VIP%d 会员', 'aksrt'), $i); + } + $options['auth'] = __('已认证用户', 'aksrt'); + $options['admin'] = __('超级管理员', 'aksrt'); + return $options; + } + + /** + * Whether a user matches a config row type. + */ + public static function user_matches_type(string $type, int $user_id = 0): bool { + if ($user_id <= 0) { + $user_id = get_current_user_id(); + } + if ($type === 'default' || $type === 'all') { + return true; + } + if ($type === 'logged') { + return $user_id > 0; + } + if ($type === 'admin') { + return $user_id > 0 && is_super_admin($user_id); + } + if ($type === 'auth') { + return $user_id > 0 + && class_exists('\AKSRT\User\AuthApply') + && \AKSRT\User\AuthApply::is_verified($user_id); + } + if (str_starts_with($type, 'level_')) { + $need = (int) substr($type, 6); + return $user_id > 0 + && $need > 0 + && class_exists('\AKSRT\User\Level') + && \AKSRT\User\Level::get_level($user_id) >= $need; + } + if (str_starts_with($type, 'vip_')) { + $need = (int) substr($type, 4); + return $user_id > 0 + && $need > 0 + && function_exists('aksrt_get_user_vip_level') + && aksrt_get_user_vip_level($user_id) >= $need; + } + return false; + } + + /** + * Size rows (user type + MB) for one upload kind. + * + * @return array + */ + private static function size_rows(string $option_key): array { + if ($option_key === '' || !class_exists('\AKSRT\Settings\Settings')) { + return []; + } + $settings = \AKSRT\Settings\Settings::get_all(); + $rows = is_array($settings[$option_key] ?? null) ? $settings[$option_key] : []; + $clean = []; + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + $type = sanitize_key((string) ($row['type'] ?? '')); + $val = (float) ($row['val'] ?? 0); + if ($type === '' || $val <= 0) { + continue; + } + $clean[] = ['type' => $type, 'val' => $val]; + } + return $clean; + } + + /** + * Max upload size in MB for a user. + * + * A user takes the maximum value among all rows whose type matches them; + * the "default" row is the floor for everyone (reference theme rule: + * 同一个用户满足多个条件则取最大值,必须包含最低默认值). + */ + public static function limit_mb(string $kind, int $user_id = 0): float { + $option_key = self::KIND_OPTIONS[$kind] ?? ''; + if ($option_key === '') { + return 0.0; + } + $rows = self::size_rows($option_key); + if (!$rows) { + return self::KIND_FALLBACK_MB[$kind] ?? 0.0; + } + $default = 0.0; + $matched = 0.0; + foreach ($rows as $row) { + if ($row['type'] === 'default' || $row['type'] === 'all') { + $default = max($default, $row['val']); + continue; + } + if (self::user_matches_type($row['type'], $user_id)) { + $matched = max($matched, $row['val']); + } + } + $limit = max($default, $matched); + // No default row and nothing matched: keep the built-in floor instead + // of silently blocking the upload with a zero limit. + return $limit > 0 ? $limit : (self::KIND_FALLBACK_MB[$kind] ?? 0.0); + } + + /** + * Effective byte limit for a user. + * + * The configured limit is clamped to the PHP upload cap so the front end + * never promises more than the server can accept in a single request. + */ + public static function limit_bytes(string $kind, int $user_id = 0): int { + $mb = self::limit_mb($kind, $user_id); + if ($mb <= 0) { + return 0; + } + return (int) min($mb * MB_IN_BYTES, (int) wp_max_upload_size()); + } + + /** + /** + * Max images allowed in one front-end batch selection (0 = unlimited). + */ + public static function image_batch_limit(): int { + if (!class_exists('\AKSRT\Settings\Settings')) { + return 6; + } + $settings = \AKSRT\Settings\Settings::get_all(); + return max(0, (int) ($settings['image_upload_multiple'] ?? 6)); + } + + /** + * Allowed / prohibited mime maps for a user. + * + * Row format: type + pattern(allow|prohibit) + "ext=mime,ext=mime". + * Prohibit wins over allow for the same extension. + * + * @return array{allow:array, prohibit:array, allow_image_mime_to_ext:array} + */ + public static function mimes_limit(int $user_id = 0): array { + $result = [ + 'allow' => [], + 'prohibit' => [], + 'allow_image_mime_to_ext' => [], + ]; + if ($user_id <= 0) { + $user_id = get_current_user_id(); + } + if (!class_exists('\AKSRT\Settings\Settings')) { + return $result; + } + $settings = \AKSRT\Settings\Settings::get_all(); + $rows = is_array($settings['upload_file_mimes'] ?? null) ? $settings['upload_file_mimes'] : []; + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + $type = sanitize_key((string) ($row['type'] ?? '')); + $pattern = ($row['pattern'] ?? 'allow') === 'prohibit' ? 'prohibit' : 'allow'; + $val = (string) ($row['val'] ?? ''); + if ($type === '' || trim($val) === '' || !self::user_matches_type($type, $user_id)) { + continue; + } + foreach (explode(',', $val) as $pair) { + $kv = array_map('trim', explode('=', (string) $pair)); + if (count($kv) !== 2 || $kv[0] === '' || $kv[1] === '') { + continue; + } + $ext = strtolower($kv[0]); + $mime = strtolower($kv[1]); + $result[$pattern][$ext] = $mime; + if ($pattern === 'allow' && strpos($mime, 'image/') === 0) { + $result['allow_image_mime_to_ext'][$mime] = $ext; + } + } + } + return $result; + } + + /** + * Apply configured allow / prohibit mimes to the system mime list. + * + * @param array $mimes + * @return array + */ + public function filter_upload_mimes($mimes) { + if (!is_array($mimes)) { + return $mimes; + } + $limit = self::mimes_limit(); + if (empty($limit['allow']) && empty($limit['prohibit'])) { + return $mimes; + } + $mimes = array_merge($mimes, $limit['allow']); + $mimes = array_diff($mimes, $limit['prohibit']); + return $mimes; + } + + /** + * Register extra image mime -> extension mappings (e.g. svg). + * + * @param array $mimes_to_exts + * @return array + */ + public function filter_image_mimes_to_exts($mimes_to_exts) { + if (!is_array($mimes_to_exts)) { + return $mimes_to_exts; + } + $limit = self::mimes_limit(); + if (empty($limit['allow_image_mime_to_ext'])) { + return $mimes_to_exts; + } + return array_merge($mimes_to_exts, $limit['allow_image_mime_to_ext']); + } + + /** + * Optionally rename uploaded files to reduce name collisions. + * + * @param array $file + * @return array + */ + public function rename_upload_file(array $file): array { + if (!class_exists('\AKSRT\Settings\Settings')) { + return $file; + } + $settings = \AKSRT\Settings\Settings::get_all(); + if (empty($settings['newfilename'])) { + return $file; + } + $name = (string) ($file['name'] ?? ''); + if ($name === '') { + return $file; + } + $info = pathinfo($name); + $ext = empty($info['extension']) ? '' : '.' . $info['extension']; + if (($settings['newfilename_type'] ?? 'time') === 'random') { + $file['name'] = substr(md5($name), 0, 10) . current_time('YmdHis') . $ext; + } else { + $file['name'] = current_time('YmdHis') . mt_rand(10, 99) . mt_rand(0, 9) . '-' . $name; + } + return $file; + } + + /** + * Hide attachment pages from non-admins (returns 404 instead). + */ + public function close_attachment_page(): void { + if (!class_exists('\AKSRT\Settings\Settings')) { + return; + } + $settings = \AKSRT\Settings\Settings::get_all(); + if (empty($settings['close_attachment_page'])) { + return; + } + if (!is_attachment() || is_super_admin()) { + return; + } + global $wp_query; + $wp_query->is_404 = true; + } +} diff --git a/src/Core/FrontSubmit.php b/src/Core/FrontSubmit.php index d71025a2..e0e11ab8 100644 --- a/src/Core/FrontSubmit.php +++ b/src/Core/FrontSubmit.php @@ -118,18 +118,13 @@ class FrontSubmit { } public static function upload_limit_bytes(string $kind): int { - $settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : []; - $configured_mb = match ($kind) { - 'image' => max(1, min(20, (int) ($settings['submit_image_max_size'] ?? 5))), - 'video' => max(1, min(200, (int) ($settings['submit_video_max_size'] ?? 30))), - 'resource' => max(1, min(200, (int) ($settings['resource_max_size'] ?? 50))), - default => 0, - }; - if ($configured_mb === 0) { - return 0; + // Kind names follow the REST route; the config rows use the reference + // theme's naming (image / video / file). + $config_kind = $kind === 'resource' ? 'file' : $kind; + if (class_exists('\AKSRT\Attachment\UploadLimits')) { + return \AKSRT\Attachment\UploadLimits::limit_bytes($config_kind); } - - return min($configured_mb * MB_IN_BYTES, (int) wp_max_upload_size()); + return (int) wp_max_upload_size(); } public function rest_upload_attachment(\WP_REST_Request $request) { diff --git a/src/Core/ScriptsPart1c.php b/src/Core/ScriptsPart1c.php index c76682a5..9d50fe7d 100644 --- a/src/Core/ScriptsPart1c.php +++ b/src/Core/ScriptsPart1c.php @@ -479,9 +479,9 @@ trait ScriptsPart1c { 'commentIasAuto' => class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_comment_display_settings()['paging_ajax_ias'] : true, 'commentUploadUrl' => esc_url_raw(rest_url('aksrt/v1/comment/upload-image')), 'commentUploadMaxSize' => (function () { - $settings = class_exists('\\AKSRT\\Settings\\Settings') ? \AKSRT\Settings\Settings::get_all() : []; - $max_size_mb = max(1, min(10, (int) ($settings['comment_image_max_size'] ?? 2))); - return $max_size_mb * MB_IN_BYTES; + return class_exists('\AKSRT\Attachment\UploadLimits') + ? \AKSRT\Attachment\UploadLimits::limit_bytes('image') + : (int) wp_max_upload_size(); })(), 'commentIasMax' => class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_comment_display_settings()['paging_ajax_ias_max'] : 3, 'restUrl' => esc_url_raw(rest_url('aksrt/v1')), diff --git a/templates/pages/page-submit.php b/templates/pages/page-submit.php index 4a9b6c80..b7a14cd1 100644 --- a/templates/pages/page-submit.php +++ b/templates/pages/page-submit.php @@ -81,9 +81,12 @@ if (file_exists($js)) { (string) filemtime($js), true ); - $submit_image_max_size = \AKSRT\Core\FrontSubmit::upload_limit_bytes('image'); - $submit_video_max_size = \AKSRT\Core\FrontSubmit::upload_limit_bytes('video'); - $resource_max_size = \AKSRT\Core\FrontSubmit::upload_limit_bytes('resource'); + $upload_image_max_bytes = \AKSRT\Core\FrontSubmit::upload_limit_bytes('image'); + $upload_video_max_bytes = \AKSRT\Core\FrontSubmit::upload_limit_bytes('video'); + $upload_resource_max_bytes = \AKSRT\Core\FrontSubmit::upload_limit_bytes('resource'); + $image_upload_multiple = class_exists('\AKSRT\Attachment\UploadLimits') + ? \AKSRT\Attachment\UploadLimits::image_batch_limit() + : 6; wp_localize_script('aksrt-front-submit-editor', 'aksrtSubmitData', [ 'ajaxUrl' => admin_url('admin-ajax.php'), @@ -101,6 +104,7 @@ if (file_exists($js)) { 'leaveWarning' => !empty($ctx['settings']['leave_warning']), + 'imageUploadMultiple' => $image_upload_multiple, 'ai' => [ 'available' => !empty($ctx['ai_available']), 'summary' => !empty($ctx['ai_available']), @@ -117,15 +121,15 @@ if (file_exists($js)) { 'uploads' => [ 'image' => !empty($ctx['can_upload_img']) ? [ 'url' => esc_url_raw(rest_url('aksrt/v1/front-submit/upload/image')), - 'maxSize' => $submit_image_max_size, + 'maxSize' => $upload_image_max_bytes, ] : null, 'video' => !empty($ctx['can_upload_video']) ? [ 'url' => esc_url_raw(rest_url('aksrt/v1/front-submit/upload/video')), - 'maxSize' => $submit_video_max_size, + 'maxSize' => $upload_video_max_bytes, ] : null, 'resource' => !empty($ctx['can_upload_file']) ? [ 'url' => esc_url_raw(rest_url('aksrt/v1/front-submit/upload/resource')), - 'maxSize' => $resource_max_size, + 'maxSize' => $upload_resource_max_bytes, ] : null, ], 'i18n' => [ diff --git a/tests/widget-system/captcha-security-harness.php b/tests/widget-system/captcha-security-harness.php index b491889a..8b67db1d 100644 --- a/tests/widget-system/captcha-security-harness.php +++ b/tests/widget-system/captcha-security-harness.php @@ -86,6 +86,7 @@ namespace AKSRT\Settings { namespace { require dirname(__DIR__, 2) . '/inc/modules/captcha.php'; + require dirname(__DIR__, 2) . '/src/Attachment/UploadLimits.php'; require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaPermissions.php'; require dirname(__DIR__, 2) . '/src/Admin/SchemaProvider.php'; diff --git a/tests/widget-system/front-submit-editor-contract.test.cjs b/tests/widget-system/front-submit-editor-contract.test.cjs index 4749b2a0..d15d2fd3 100644 --- a/tests/widget-system/front-submit-editor-contract.test.cjs +++ b/tests/widget-system/front-submit-editor-contract.test.cjs @@ -19,19 +19,35 @@ test('front submit enqueues the WordPress editor before wp_head is rendered', () test('front submit uploads use the effective WordPress server limit', () => { const controller = read('src/Core/FrontSubmit.php'); const page = read('templates/pages/page-submit.php'); + const limits = read('src/Attachment/UploadLimits.php'); assert.match(controller, /function upload_limit_bytes\(string \$kind\): int/); - assert.match(controller, /min\(\$configured_mb \* MB_IN_BYTES, \(int\) wp_max_upload_size\(\)\)/); + assert.match(controller, /UploadLimits::limit_bytes\(\$config_kind\)/); + assert.match(controller, /\$config_kind = \$kind === 'resource' \? 'file' : \$kind;/); assert.match(controller, /UPLOAD_ERR_INI_SIZE/); assert.match(controller, /UPLOAD_ERR_FORM_SIZE/); assert.match(controller, /UPLOAD_ERR_PARTIAL/); assert.match(controller, /\['status' => 413\]/); + // The effective clamp (configured limit vs PHP upload cap) lives in the limiter. + assert.match(limits, /min\(\$mb \* MB_IN_BYTES, \(int\) wp_max_upload_size\(\)\)/); assert.match(page, /FrontSubmit::upload_limit_bytes\('image'\)/); assert.match(page, /FrontSubmit::upload_limit_bytes\('video'\)/); assert.match(page, /FrontSubmit::upload_limit_bytes\('resource'\)/); assert.doesNotMatch(page, /\$attachment_settings\s*=/); }); +test('front submit image batch selection follows the admin upload config', () => { + const page = read('templates/pages/page-submit.php'); + const editor = read('assets/js/front-submit-editor.js'); + + assert.match(page, /UploadLimits::image_batch_limit\(\)/); + assert.match(page, /'imageUploadMultiple' => \$image_upload_multiple,/); + assert.match(editor, /function imageBatchLimit\(\)/); + assert.match(editor, /Number\(cfg\.imageUploadMultiple\)/); + assert.match(editor, /multiple: batch\.multiple, maxItems: batch\.maxItems/); + assert.match(editor, /type: 'image', multiple: imageBatch\.multiple, maxItems: imageBatch\.maxItems,/); +}); + test('published post content reaches the front submit editor unchanged', () => { const context = read('templates/parts/submit/context.php'); const editor = read('templates/parts/submit/editor-main.php');