feat(attachment): 按参考主题重构后台上传配置套件

- 新增 AKSRT\Attachment\UploadLimits:按用户类型解析图片/视频/文件
  上传上限(取最大值+默认兜底,钳制到 PHP 上限)、允许/禁止格式过滤、
  上传自动重命名、非管理员附件页 404;不含论坛角色
- 后台「附件配置」重做:大小限制/格式限制/上传行为三组配置,
  PHP Schema 与 SPA Schema 逐字段镜像(v1.2.0)
- 定制页 AttachmentPermissions.vue 改为标准 schemaPage,清理失效的
  API 包装、组件加载器与旧配置键(submit_image_max_size 等)
- 投稿/评论上传、批量上传数量全部切换到新链路;AksField 数字组件
  支持单位展示
- 更新 captcha harness 与 front-submit 契约测试;421 项测试通过
This commit is contained in:
2026-09-24 11:04:49 +08:00
parent 505d9156fe
commit d4bb43a275
16 changed files with 643 additions and 269 deletions
+75 -8
View File
@@ -9,6 +9,34 @@ defined('ABSPATH') || exit;
class SchemaPermissions {
/**
* User type options for upload size limit rows.
*
* @return array<int, array{label:string, value:string}>
*/
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<int, array{label:string, value:string}>
*/
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,可有效提高文件的安全性。'],
]],
],
],
+5 -4
View File
@@ -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 = [
+325
View File
@@ -0,0 +1,325 @@
<?php
/**
* Upload limits, mime restrictions, auto-rename, and attachment page rules.
*
* Mirrors the reference theme's upload permission suite (zib-attachment.php):
* per-user-type size limits for image / video / file uploads, allow & prohibit
* mime lists, upload file renaming, and hiding attachment pages from guests.
*
* @package AKSRT
*/
namespace AKSRT\Attachment;
defined('ABSPATH') || exit;
class UploadLimits {
private static $instance = null;
/** Setting keys holding the per-user-type size rows for each upload kind. */
const KIND_OPTIONS = [
'image' => '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<string, string>
*/
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<int, array{type:string, val:float}>
*/
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<string,string>, prohibit:array<string,string>, allow_image_mime_to_ext:array<string,string>}
*/
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<string, string> $mimes
* @return array<string, string>
*/
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<string, string> $mimes_to_exts
* @return array<string, string>
*/
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<string, mixed> $file
* @return array<string, mixed>
*/
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;
}
}
+6 -11
View File
@@ -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) {
+3 -3
View File
@@ -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')),