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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user