diff --git a/assets/admin/src/constants/sections/extensions.js b/assets/admin/src/constants/sections/extensions.js index 830c0695..5e8b49d1 100644 --- a/assets/admin/src/constants/sections/extensions.js +++ b/assets/admin/src/constants/sections/extensions.js @@ -6,6 +6,16 @@ export const extensionsSection = { icon: renderFaIcon('fa fas fa-puzzle-piece'), route: '/extensions', children: [ + schemaPage({ + key: 'extensions-system', + name: 'ExtensionsSystem', + label: '系统工具', + route: '/extensions/system', + title: '系统工具', + desc: 'WordPress 优化、上传优化与编辑器显示的系统级开关。', + canonicalDomain: 'extensions', + section: 'system', + }), schemaPage({ key: 'extensions-ip', name: 'ExtensionsIp', diff --git a/assets/admin/src/constants/sections/index.js b/assets/admin/src/constants/sections/index.js index 147b197e..dbb6ef40 100644 --- a/assets/admin/src/constants/sections/index.js +++ b/assets/admin/src/constants/sections/index.js @@ -16,7 +16,7 @@ const SECTION_DEFAULT_CHILDREN = { permissions: 'permissions-comment', users: 'users-auth', payment: 'payment-knowledge', - extensions: 'extensions-ip', + extensions: 'extensions-system', tools: 'tools-import-export', 'theme-info': 'theme-info-dashboard', docs: 'docs-dashboard', diff --git a/assets/admin/src/schema/sections/extensions/index.js b/assets/admin/src/schema/sections/extensions/index.js index 7b909c7b..cbccbf62 100644 --- a/assets/admin/src/schema/sections/extensions/index.js +++ b/assets/admin/src/schema/sections/extensions/index.js @@ -3,12 +3,14 @@ * Each key corresponds to a sub-page under the "扩展&增强" section. */ +import { systemSchema } from './system.js' import { ipSchema } from './ip.js' import { auditSchema } from './audit.js' import { pushSchema } from './push.js' import { securitySchema } from './security.js' export const extensionsSchemas = { + system: systemSchema, ip: ipSchema, audit: auditSchema, push: pushSchema, diff --git a/assets/admin/src/schema/sections/extensions/system.js b/assets/admin/src/schema/sections/extensions/system.js new file mode 100644 index 00000000..4f249a2e --- /dev/null +++ b/assets/admin/src/schema/sections/extensions/system.js @@ -0,0 +1,91 @@ +export const systemSchema = { + key: 'extensions.system', + title: '系统工具', + icon: 'fa fas fa-gavel', + description: 'WordPress 优化、上传优化与编辑器显示的系统级开关。', + defaults: { + hide_admin_bar: true, + remove_emoji: true, + remove_open_sans: true, + remove_more_wp_head: true, + remove_dashboard_widgets: true, + display_wp_update: true, + newfilename: false, + newfilename_type: 'time', + split_upload_s: true, + split_minimum_size: 20, + disabled_large_thumbnail: true, + admin_user_del_fields: true, + admin_frontend_set: true, + close_gutenberg: false, + disabled_autoembed: true, + mce_float_toolbar: ['pc_s', 'm_s'], + time_ago_s: true, + time_format: 'n月j日 H:i', + }, + groups: [ + { + key: 'optimize', + title: 'WordPress 优化', + icon: 'fa fas fa-bolt', + description: '精简WordPress自带的冗余功能,提升前台与后台的加载速度。', + fields: [ + { key: 'hide_admin_bar', label: '前台关闭顶部admin_bar', type: 'switch', default: true, help: '开启后前台不显示WordPress顶部黑条' }, + { key: 'remove_emoji', label: '删除WordPress自带Emoji表情', type: 'switch', default: true }, + { key: 'remove_open_sans', label: '禁用Google字体', type: 'switch', default: true }, + { key: 'remove_more_wp_head', label: '清理多余的头部Meta标签', type: 'switch', default: true }, + { key: 'remove_dashboard_widgets', label: 'WordPress 后台加速', type: 'switch', default: true, help: '移除仪表盘的站点健康和WordPress活动等不必要功能,并禁止检测浏览器和PHP版本(这两个非常耗时),优化后台加载速度' }, + { key: 'display_wp_update', label: '禁止WordPress检测更新', type: 'switch', default: true, help: '开启后将禁用WordPress核心、插件和主题的自动更新及更新检测' }, + ], + }, + { + key: 'upload', + title: '上传优化', + icon: 'fa fas fa-cloud-upload-alt', + description: '上传文件的命名与缩略图裁剪优化,减少硬盘占用并提高上传效率。', + fields: [ + { key: 'split_upload_s', label: '大文件分片上传', type: 'switch', default: true, help: '分片上传大文件,优化大文件上传效率(支持断点续传)' }, + { key: 'split_minimum_size', label: '分片上传最小文件', type: 'number', default: 20, min: 4, max: 100, unit: 'M', dependsOn: { key: 'split_upload_s', value: true }, help: '大于此大小的文件才会分片上传,请根据服务器性能合理设置;较大文件直接上传会比较耗时且无法突破系统上传大小限制,分片上传可以有效解决此问题;前端上传大小限制在「功能权限 → 上传权限」中设置。注意:能否顺利上传大文件还取决于服务器性能、带宽、CDN等因素,如果网站使用了OSS/COS等第三方云储存,则不建议在网站上传大文件' }, + { key: 'newfilename', label: '上传文件自动重命名', type: 'switch', default: false, help: '上传文件时系统会查询重名文件,开启此功能后可减少重名文件,从而提高上传效率' }, + { + key: 'newfilename_type', + label: '重命名格式', + type: 'radio', + buttonStyle: false, + default: 'time', + dependsOn: { key: 'newfilename', value: true }, + options: [ + { label: '随机字串符', value: 'random' }, + { label: '时间+原文件名', value: 'time' }, + ], + }, + { key: 'disabled_large_thumbnail', label: '禁止WP大尺寸缩略图裁剪', type: 'switch', default: true, help: '开启后将禁用WordPress自动裁剪medium_large、1536x1536、2048x2048的缩略图,可减少服务器硬盘压力,同时提高上传效率;如果您使用的插件必须要这些尺寸的缩略图,或使用了云储存的图片处理功能,则请关闭' }, + ], + }, + { + key: 'editor', + title: '编辑器与显示', + icon: 'fa fas fa-edit', + description: '编辑器模式、用户资料页与前台时间显示格式的定制。', + fields: [ + { key: 'admin_user_del_fields', label: '关闭后台用户编辑多余的选项', type: 'switch', default: true, help: '开启后在后台编辑用户资料时将不显示无用的多余选项' }, + { key: 'admin_frontend_set', label: '前端页面编辑', type: 'switch', default: true, help: '管理员可在前台快速修改页面、文章参数' }, + { key: 'close_gutenberg', label: '禁用古腾堡编辑器', type: 'switch', default: false, help: '后台文章编辑器将退回使用经典编辑器;禁用后主题的编辑器增强功能将不能使用,请酌情禁用!' }, + { key: 'disabled_autoembed', label: '禁用Auto Embed', type: 'switch', default: true, help: 'Auto Embed是WordPress自动检测并嵌入链接的一种功能,主要适配海外媒体网站,国内几乎用不上,建议禁用,可大幅度提高页面渲染速度' }, + { + key: 'mce_float_toolbar', + label: '经典编辑器增强浮动工具栏', + type: 'checkbox', + default: ['pc_s', 'm_s'], + options: [ + { label: 'PC端开启', value: 'pc_s' }, + { label: '移动端开启', value: 'm_s' }, + ], + help: '开启后前端编辑器会显示浮动的工具栏,更加方便书写', + }, + { key: 'time_ago_s', label: '倒计时显示', type: 'switch', default: true, help: '时间格式化为:X分钟前,X小时前,X天前....' }, + { key: 'time_format', label: '自定义时间格式', type: 'input', default: 'n月j日 H:i', dependsOn: { key: 'time_ago_s', operator: 'falsy' }, help: '关闭倒计时显示后生效,时间格式接受标准PHP时间格式,请注意控制长度!' }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/permissions/index.js b/assets/admin/src/schema/sections/permissions/index.js index 3523db93..5acbdb70 100644 --- a/assets/admin/src/schema/sections/permissions/index.js +++ b/assets/admin/src/schema/sections/permissions/index.js @@ -238,8 +238,6 @@ export const permissionsSchemas = { { type: 'admin', pattern: 'allow', val: 'svg=image/svg+xml' }, { type: 'all', pattern: 'allow', val: '' }, ], - newfilename: false, - newfilename_type: 'time', }, groups: [ { @@ -314,26 +312,6 @@ export const permissionsSchemas = { }, ], }, - { - 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' }, - ], - }, - ], - }, ], }, diff --git a/assets/js/front-submit-editor.js b/assets/js/front-submit-editor.js index d4da6bc3..dda5c5ad 100644 --- a/assets/js/front-submit-editor.js +++ b/assets/js/front-submit-editor.js @@ -121,27 +121,100 @@ return true; } + var SPLIT_CHUNK_BYTES = 2 * 1048576; + + function splitUploadConfig() { + var split = cfg.splitUpload || {}; + var minSize = parseInt(split.minSize, 10); + if (!isFinite(minSize) || minSize < 4) minSize = 4; + return { enabled: !!split.enabled, minSize: minSize }; + } + + function requestJson(url, options) { + var init = options || {}; + init.credentials = 'same-origin'; + init.headers = { 'X-WP-Nonce': cfg.restNonce || '' }; + return fetch(url, init).then(function (response) { + return response.json().catch(function () { return {}; }).then(function (payload) { + if (!response.ok) { + throw new Error((payload && (payload.message || (payload.data && payload.data.message))) || i18n.error || '文件上传失败'); + } + return payload; + }); + }); + } + + // 分片上传:状态(断点续传)→ 逐片上传 → 合并走标准落库管线,响应与整传一致。 + function uploadInChunks(file, type) { + var uploadKey = type === 'file' ? 'resource' : type; + var base = String(cfg.restUrl || '').replace(/\/$/, ''); + if (!base) return Promise.reject(new Error(i18n.uploadUnavailable || '当前账号不能上传此类文件')); + var postId = currentPostId(); + + function chunkUrl(action) { + var target = base + '/front-submit/' + action + '/' + uploadKey; + if (postId) target += (target.indexOf('?') === -1 ? '?' : '&') + 'post_id=' + encodeURIComponent(String(postId)); + return target; + } + + var total = Math.ceil(file.size / SPLIT_CHUNK_BYTES); + return requestJson(chunkUrl('upload-status')).catch(function () { return { uploaded: [] }; }).then(function (status) { + var uploaded = {}; + (Array.isArray(status.uploaded) ? status.uploaded : []).forEach(function (index) { uploaded[index] = true; }); + var chain = Promise.resolve(); + for (var index = 0; index < total; index++) { + (function (i) { + chain = chain.then(function () { + if (uploaded[i]) return; + var body = new FormData(); + body.append('file', file.slice(i * SPLIT_CHUNK_BYTES, Math.min(file.size, (i + 1) * SPLIT_CHUNK_BYTES))); + body.append('chunk', String(i)); + body.append('chunks', String(total)); + body.append('name', file.name); + body.append('size', String(file.size)); + return requestJson(chunkUrl('upload-chunk'), { method: 'POST', body: body }); + }); + })(index); + } + return chain; + }).then(function () { + var body = new FormData(); + body.append('chunks', String(total)); + body.append('name', file.name); + body.append('size', String(file.size)); + return requestJson(chunkUrl('upload-merge'), { method: 'POST', body: body }); + }).then(function (payload) { + if (!payload || !payload.success || !payload.url) { + throw new Error((payload && (payload.message || (payload.data && payload.data.message))) || i18n.error || '文件上传失败'); + } + return payload; + }); + } + function uploadMedia(file, type) { var uploadKey = type === 'file' ? 'resource' : type; var upload = (cfg.uploads || {})[uploadKey] || null; if (!upload || !upload.url) return Promise.reject(new Error(i18n.uploadUnavailable || '当前账号不能上传此类文件')); if (!acceptsFile(file, type)) return Promise.reject(new Error(i18n.invalidFile || '请选择有效文件')); if (upload.maxSize && file.size > upload.maxSize) return Promise.reject(new Error(i18n.fileTooLarge || '文件大小超过限制')); + var split = splitUploadConfig(); + if (split.enabled && typeof file.slice === 'function' && file.size > split.minSize * 1048576) { + if (Math.ceil(file.size / SPLIT_CHUNK_BYTES) > 1) { + return uploadInChunks(file, type); + } + } var body = new FormData(); body.append('file', file); var uploadUrl = String(upload.url); if (currentPostId()) { uploadUrl += (uploadUrl.indexOf('?') === -1 ? '?' : '&') + 'post_id=' + encodeURIComponent(String(currentPostId())); } - return fetch(uploadUrl, { method: 'POST', credentials: 'same-origin', headers: { 'X-WP-Nonce': cfg.restNonce || '' }, body: body }) - .then(function (response) { - return response.json().catch(function () { return {}; }).then(function (payload) { - if (!response.ok || !payload || !payload.success || !payload.url) { - throw new Error((payload && (payload.message || (payload.data && payload.data.message))) || i18n.error || '文件上传失败'); - } - return payload; - }); - }); + return requestJson(uploadUrl, { method: 'POST', body: body }).then(function (payload) { + if (!payload || !payload.success || !payload.url) { + throw new Error((payload && (payload.message || (payload.data && payload.data.message))) || i18n.error || '文件上传失败'); + } + return payload; + }); } function imageBatchLimit() { @@ -578,6 +651,120 @@ }); } + function toggleBlockFormat(editor, format) { + editor.formatter.toggle(format); + editor.nodeChanged(); + } + + function removeEditorBlock(editor, node) { + if (!node || node === editor.getBody()) return; + editor.dom.remove(node); + editor.nodeChanged(); + } + + function replaceButtonIconWithSvg(button, pathData) { + var node = button && button.$el && button.$el[0]; + var icon = node ? node.querySelector('.mce-i-c-svg') : null; + if (!icon) return; + var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 1024 1024'); + svg.setAttribute('style', 'width:21px;height:21px;fill:currentColor;'); + var path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', pathData); + svg.appendChild(path); + icon.parentNode.replaceChild(svg, icon); + } + + // 经典编辑器增强浮动工具栏(后台「扩展&增强 → 系统工具」mce_float_toolbar): + // 复刻参考主题 editextend.js 的 wptoolbar 上下文工具条。 + function registerFloatToolbar(editor) { + editor.addButton('aksrt_h2', { + title: 'Heading 2', + icon: 'c-svg heading2', + stateSelector: 'h2', + onclick: function () { toggleBlockFormat(editor, 'h2'); }, + onPostRender: function () { + replaceButtonIconWithSvg(this, 'M143.616 219.648v228.864h278.016V219.648h89.856V768H421.632v-242.688H143.616V768H53.76V219.648h89.856z m660.48-10.752c52.992 0 96.768 15.36 131.328 46.08 33.792 30.72 50.688 69.888 50.688 119.04 0 47.616-18.432 90.624-53.76 129.792-16.554667 17.706667-43.093333 39.082667-78.933333 64.426667l-22.613334 15.701333-12.117333 8.192c-52.309333 34.389333-85.248 64.810667-99.413333 91.178667l-2.730667 5.589333h270.336V768h-382.464c0-56.064 17.664-104.448 54.528-145.92 8.746667-10.069333 21.76-22.186667 38.912-36.352l15.786667-12.586667c5.589333-4.352 11.52-8.874667 17.834666-13.568l19.84-14.506666 21.888-15.488 11.690667-8.106667c35.328-24.576 59.904-45.312 75.264-61.44 23.808-26.88 36.096-56.064 36.096-86.784 0-29.952-8.448-52.224-23.808-66.816-16.128-14.592-39.936-21.504-71.424-21.504-33.792 0-59.136 11.52-76.032 34.56-15.36 19.541333-24.362667 48.64-27.050667 86.058667l-0.597333 11.477333h-89.856c0.768-61.44 18.432-110.592 53.76-148.224 36.096-39.936 83.712-59.904 142.848-59.904z'); + }, + }); + + editor.addButton('aksrt_h3', { + title: 'Heading 3', + icon: 'c-svg heading3', + stateSelector: 'h3', + onclick: function () { toggleBlockFormat(editor, 'h3'); }, + onPostRender: function () { + replaceButtonIconWithSvg(this, 'M801.024 208.896c55.296 0 100.608 13.056 134.4 39.936 33.024 26.88 49.92 63.744 49.92 111.36 0 59.904-30.72 99.84-91.392 119.808 32.256 9.984 57.6 24.576 74.496 44.544 18.432 20.736 27.648 47.616 27.648 79.872 0 50.688-17.664 92.16-52.992 124.416-36.864 33.024-85.248 49.92-145.152 49.92-56.832 0-102.912-14.592-137.472-43.776-38.4-32.256-59.904-79.872-64.512-141.312h91.392c1.536 35.328 12.288 62.976 33.792 82.176 19.2 17.664 44.544 26.88 76.032 26.88 34.56 0 62.208-9.984 82.176-29.184 17.664-17.664 26.88-39.168 26.88-65.28 0-31.488-9.984-54.528-28.416-69.12-18.432-15.36-45.312-22.272-80.64-22.272h-38.4V449.28h38.4c32.256 0 56.832-6.912 73.728-20.736 16.128-13.824 24.576-34.56 24.576-61.44 0-26.88-7.68-46.848-22.272-60.672-16.128-13.824-39.936-20.736-71.424-20.736-32.256 0-56.832 7.68-74.496 23.808-18.432 16.128-29.184 40.704-32.256 73.728h-88.32c4.608-55.296 24.576-98.304 61.44-129.024 34.56-30.72 79.104-45.312 132.864-45.312z m-657.408 10.752v228.864h278.016V219.648h89.856V768H421.632v-242.688H143.616V768H53.76V219.648h89.856z'); + }, + }); + + editor.addButton('aksrt_remove', { + tooltip: i18n.removeBlock || '删除', + icon: 'dashicon dashicons-no-alt', + onclick: function () { + removeEditorBlock(editor, editor.selection.getNode()); + }, + }); + + editor.on('wptoolbar', function (event) { + if (event.toolbar) return; + var probe = document.createElement('div'); + probe.innerHTML = editor.getContent(); + if (probe.querySelectorAll('p,h1,h2,h3,h4,h5,h6,ul,ol,div,blockquote,pre,img,iframe,table,hr').length <= 4) return; + + var bars = floatToolbarButtons(editor, event); + if (bars.length && editor.wp && typeof editor.wp._createToolbar === 'function') { + event.toolbar = editor.wp._createToolbar(bars); + } + }); + } + + // 复刻参考主题 get_all_toolbar:按光标所在块与是否选中文字给出上下文按钮。 + function floatToolbarButtons(editor, event) { + var element = event.element; + if (!element) return []; + var nodeName = element.nodeName; + var innerText = element.innerText ? String(element.innerText).trim() : ''; + var hasSelection = !!editor.selection.getContent(); + var parent = element.parentElement; + var inRoot = !!(parent && parent.id === 'tinymce'); + var bars = []; + + if (['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'UL', 'LI', 'STRONG', 'SPAN', 'CODE'].indexOf(nodeName) !== -1) { + if (inRoot || (nodeName === 'SPAN' && parent && parent.parentElement && parent.parentElement.id === 'tinymce')) { + bars.push('aksrt_h2', 'aksrt_h3'); + } + + if (hasSelection) { + bars = ['H1', 'H2', 'H3', 'H4', 'H5'].indexOf(nodeName) === -1 ? ['bold', 'link', 'wp_code'] : []; + bars.push('forecolor'); + if (nodeName === 'P' && editorCfg.canHide) bars.push('aksrt_hide'); + bars.push('aksrt_remove'); + return bars; + } + + if (innerText) { + bars.push('bullist', 'numlist', 'aligncenter'); + if (nodeName === 'P' && inRoot) { + bars.push('aksrt_quote'); + if (editorCfg.canHide) bars.push('aksrt_hide'); + } + bars.push('aksrt_remove'); + } else { + bars.push('bullist', 'aksrt_quote'); + if (editorCfg.canHide) bars.push('aksrt_hide'); + if (editorCfg.canUploadImage) bars.push('aksrt_image'); + bars.push('aksrt_video', 'aksrt_precode'); + } + } + + if (!bars.length && (nodeName === 'DIV' || nodeName === 'PRE') && !(element.classList && element.classList.contains('file-download-name'))) { + bars.push('aksrt_remove'); + } + + return bars; + } + function registerEditorTools() { if (!window.tinymce || !window.tinymce.PluginManager || window.aksrtSubmitEditorToolsRegistered) { return false; @@ -711,6 +898,10 @@ menu: menu, }); } + + if (editorCfg.floatToolbar) { + registerFloatToolbar(editor); + } }); return true; } diff --git a/assets/js/page-admin-set.js b/assets/js/page-admin-set.js new file mode 100644 index 00000000..05d8c41f --- /dev/null +++ b/assets/js/page-admin-set.js @@ -0,0 +1,63 @@ +/** + * AKSRT Page Admin Set — 前端页面编辑弹窗提交(后台「扩展&增强 → 系统工具」)。 + * + * 弹窗内容由 PageAdminSet::ajax_modal() 经 aksrt-modal-runtime 注入, + * 这里只负责表单提交:序列化 → admin-ajax → 成功后刷新页面。 + * + * @package AKSRT + */ +(function (window, document) { + 'use strict'; + + function notify(message, type) { + if (typeof window.notyf === 'function') { + window.notyf(message, type || 'error', 4, 'aksrt-page-admin-set-toast'); + } + } + + document.addEventListener('submit', function (event) { + var form = event.target.closest('form.aksrt-admin-set-form'); + if (!form) return; + event.preventDefault(); + + var submitButton = form.querySelector('.aksrt-admin-set-submit'); + if (submitButton && submitButton.disabled) return; + if (submitButton) submitButton.disabled = true; + + var action = (form.querySelector('input[name="action"]') || {}).value || ''; + var ajaxUrl = (window.aksrtData && window.aksrtData.ajaxUrl) || (window._win && window._win.ajax_url) || ''; + if (!ajaxUrl) { + notify('请求地址缺失', 'error'); + if (submitButton) submitButton.disabled = false; + return; + } + + var url = ajaxUrl + (ajaxUrl.indexOf('?') === -1 ? '?' : '&') + 'action=' + encodeURIComponent(action); + fetch(url, { + method: 'POST', + credentials: 'same-origin', + body: new FormData(form), + }) + .then(function (response) { + return response.json().catch(function () { return {}; }).then(function (payload) { + if (!response.ok || !payload || !payload.success) { + throw new Error((payload && payload.data && payload.data.msg) || '保存失败,请重试'); + } + return payload; + }); + }) + .then(function (payload) { + var data = payload.data || {}; + notify(data.msg || '保存成功', 'success'); + if (data.reload !== false) { + window.setTimeout(function () { window.location.reload(); }, 600); + } else if (submitButton) { + submitButton.disabled = false; + } + }) + .catch(function (error) { + notify(error.message || '保存失败,请重试', 'error'); + if (submitButton) submitButton.disabled = false; + }); + }); +})(window, document); diff --git a/functions.php b/functions.php index 6bf908db..5274b852 100644 --- a/functions.php +++ b/functions.php @@ -170,6 +170,12 @@ if (class_exists('\AKSRT\Settings\MailNotifications')) { if (class_exists('\AKSRT\Extensions\SecurityHardening')) { \AKSRT\Extensions\SecurityHardening::get_instance()->init(); } +if (class_exists('\AKSRT\Extensions\SystemTools')) { + \AKSRT\Extensions\SystemTools::get_instance()->init(); +} +if (class_exists('\AKSRT\Frontend\PageAdminSet')) { + \AKSRT\Frontend\PageAdminSet::get_instance()->init(); +} // 短代码统一注册入口(registry) if (class_exists('\AKSRT\Core\Shortcodes')) { diff --git a/inc/core-hooks.php b/inc/core-hooks.php index fc9f1dc8..a6ca4d6c 100644 --- a/inc/core-hooks.php +++ b/inc/core-hooks.php @@ -62,11 +62,10 @@ add_action('save_post', static function ($post_id, $post = null): void { // ============================================================ -// 前端隐藏 WordPress Admin Bar +// 前端隐藏 WordPress Admin Bar:由 SystemTools 按「扩展&增强 → 系统工具」 +// 的 hide_admin_bar 设置门控(默认开启),不再无条件注入。 // ============================================================ -add_filter('show_admin_bar', '__return_false'); - // ============================================================ // SMTP 邮件配置注入 // ============================================================ diff --git a/inc/helpers/aksrt-float-buttons.php b/inc/helpers/aksrt-float-buttons.php index 2de2b4a3..a02115c4 100644 --- a/inc/helpers/aksrt-float-buttons.php +++ b/inc/helpers/aksrt-float-buttons.php @@ -155,9 +155,6 @@ if (!function_exists('aksrt_float_right')) : } $settings = \AKSRT\Settings\Settings::get_floating_buttons_settings(); $items = is_array($settings['items'] ?? null) ? $settings['items'] : []; - if (!$items) { - return; - } $is_mobile = wp_is_mobile(); $btn = ''; @@ -224,6 +221,12 @@ if (!function_exists('aksrt_float_right')) : return; } + // 扩展点(对齐参考主题 zib_float_right filter):模块可注入自定义悬浮按钮。 + $btn = (string) apply_filters('aksrt_float_right_buttons', $btn, $is_mobile); + if ($btn === '') { + return; + } + $filter = (array) ($settings['filter_blur'] ?? []); $class = ($settings['style'] ?? 'round') . ' position-' . ($settings['position'] ?? 'bottom'); $class .= (($is_mobile && in_array('m_s', $filter, true)) || (!$is_mobile && in_array('pc_s', $filter, true))) ? ' filter' : ''; diff --git a/inc/helpers/template-tags/search-meta.php b/inc/helpers/template-tags/search-meta.php index a24d71eb..f2a69db7 100644 --- a/inc/helpers/template-tags/search-meta.php +++ b/inc/helpers/template-tags/search-meta.php @@ -480,6 +480,14 @@ if (!function_exists('aksrt_time_ago')) : $ctime = (int) strtotime(current_time('mysql')); $t = $ctime - $time; + // 关闭倒计时显示时按「系统工具」自定义时间格式输出(legacy zib_get_time_ago parity)。 + $system = class_exists('\AKSRT\Settings\ExtensionRuntime') + ? \AKSRT\Settings\ExtensionRuntime::get_system_tools_settings() + : []; + if (empty($system['time_ago_s']) && ($system['time_format'] ?? '') !== '') { + return date($system['time_format'], $time); + } + if ($t < 0) { return date('Y-m-d H:i', $time); } diff --git a/src/Admin/Schema/SchemaExtensions.php b/src/Admin/Schema/SchemaExtensions.php index 7f6dc87d..766607e2 100644 --- a/src/Admin/Schema/SchemaExtensions.php +++ b/src/Admin/Schema/SchemaExtensions.php @@ -14,10 +14,11 @@ class SchemaExtensions { 'domain' => 'extensions', 'title' => '扩展&增强', 'sections' => [ - 'ip' => SchemaExtensionsIP::build(), - 'audit' => SchemaExtensionsAudit::build(), - 'push' => SchemaExtensionsPush::build(), - 'security' => SchemaExtensionsSecurity::build(), + 'system' => SchemaExtensionsSystem::build(), + 'ip' => SchemaExtensionsIP::build(), + 'audit' => SchemaExtensionsAudit::build(), + 'push' => SchemaExtensionsPush::build(), + 'security' => SchemaExtensionsSecurity::build(), ], ]; } diff --git a/src/Admin/Schema/SchemaExtensionsSystem.php b/src/Admin/Schema/SchemaExtensionsSystem.php new file mode 100644 index 00000000..39f84eff --- /dev/null +++ b/src/Admin/Schema/SchemaExtensionsSystem.php @@ -0,0 +1,52 @@ + 'system', + 'title' => '系统工具', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'optimize', 'title' => 'WordPress 优化', 'fields' => [ + ['key' => 'hide_admin_bar', 'label' => '前台关闭顶部admin_bar', 'type' => 'switch', 'default' => true, 'help' => '开启后前台不显示WordPress顶部黑条'], + ['key' => 'remove_emoji', 'label' => '删除WordPress自带Emoji表情', 'type' => 'switch', 'default' => true], + ['key' => 'remove_open_sans', 'label' => '禁用Google字体', 'type' => 'switch', 'default' => true], + ['key' => 'remove_more_wp_head', 'label' => '清理多余的头部Meta标签', 'type' => 'switch', 'default' => true], + ['key' => 'remove_dashboard_widgets', 'label' => 'WordPress 后台加速', 'type' => 'switch', 'default' => true, 'help' => '移除仪表盘的站点健康和WordPress活动等不必要功能,并禁止检测浏览器和PHP版本(这两个非常耗时),优化后台加载速度'], + ['key' => 'display_wp_update', 'label' => '禁止WordPress检测更新', 'type' => 'switch', 'default' => true, 'help' => '开启后将禁用WordPress核心、插件和主题的自动更新及更新检测'], + ]], + ['key' => 'upload', 'title' => '上传优化', 'fields' => [ + ['key' => 'split_upload_s', 'label' => '大文件分片上传', 'type' => 'switch', 'default' => true, 'help' => '分片上传大文件,优化大文件上传效率(支持断点续传)'], + ['key' => 'split_minimum_size', 'label' => '分片上传最小文件', 'type' => 'number', 'default' => 20, 'min' => 4, 'max' => 100, 'unit' => 'M', 'dependsOn' => ['key' => 'split_upload_s', 'value' => true], 'help' => '大于此大小的文件才会分片上传,请根据服务器性能合理设置;较大文件直接上传会比较耗时且无法突破系统上传大小限制,分片上传可以有效解决此问题;前端上传大小限制在「功能权限 → 上传权限」中设置。注意:能否顺利上传大文件还取决于服务器性能、带宽、CDN等因素,如果网站使用了OSS/COS等第三方云储存,则不建议在网站上传大文件'], + ['key' => 'newfilename', 'label' => '上传文件自动重命名', 'type' => 'switch', 'default' => false, 'help' => '上传文件时系统会查询重名文件,开启此功能后可减少重名文件,从而提高上传效率'], + ['key' => 'newfilename_type', 'label' => '重命名格式', 'type' => 'radio', 'default' => 'time', 'dependsOn' => ['key' => 'newfilename', 'value' => true], 'options' => [ + ['label' => '随机字串符', 'value' => 'random'], + ['label' => '时间+原文件名', 'value' => 'time'], + ]], + ['key' => 'disabled_large_thumbnail', 'label' => '禁止WP大尺寸缩略图裁剪', 'type' => 'switch', 'default' => true, 'help' => '开启后将禁用WordPress自动裁剪medium_large、1536x1536、2048x2048的缩略图,可减少服务器硬盘压力,同时提高上传效率;如果您使用的插件必须要这些尺寸的缩略图,或使用了云储存的图片处理功能,则请关闭'], + ]], + ['key' => 'editor', 'title' => '编辑器与显示', 'fields' => [ + ['key' => 'admin_user_del_fields', 'label' => '关闭后台用户编辑多余的选项', 'type' => 'switch', 'default' => true, 'help' => '开启后在后台编辑用户资料时将不显示无用的多余选项'], + ['key' => 'admin_frontend_set', 'label' => '前端页面编辑', 'type' => 'switch', 'default' => true, 'help' => '管理员可在前台快速修改页面、文章参数'], + ['key' => 'close_gutenberg', 'label' => '禁用古腾堡编辑器', 'type' => 'switch', 'default' => false, 'help' => '后台文章编辑器将退回使用经典编辑器;禁用后主题的编辑器增强功能将不能使用,请酌情禁用!'], + ['key' => 'disabled_autoembed', 'label' => '禁用Auto Embed', 'type' => 'switch', 'default' => true, 'help' => 'Auto Embed是WordPress自动检测并嵌入链接的一种功能,主要适配海外媒体网站,国内几乎用不上,建议禁用,可大幅度提高页面渲染速度'], + ['key' => 'mce_float_toolbar', 'label' => '经典编辑器增强浮动工具栏', 'type' => 'checkbox', 'default' => ['pc_s', 'm_s'], 'sanitize_type' => 'array', 'options' => [ + ['label' => 'PC端开启', 'value' => 'pc_s'], + ['label' => '移动端开启', 'value' => 'm_s'], + ], 'help' => '开启后前端编辑器会显示浮动的工具栏,更加方便书写'], + ['key' => 'time_ago_s', 'label' => '倒计时显示', 'type' => 'switch', 'default' => true, 'help' => '时间格式化为:X分钟前,X小时前,X天前....'], + ['key' => 'time_format', 'label' => '自定义时间格式', 'type' => 'input', 'default' => 'n月j日 H:i', 'dependsOn' => ['key' => 'time_ago_s', 'operator' => 'falsy'], 'help' => '关闭倒计时显示后生效,时间格式接受标准PHP时间格式,请注意控制长度!'], + ]], + ], + ]; + } +} diff --git a/src/Admin/Schema/SchemaPermissions.php b/src/Admin/Schema/SchemaPermissions.php index b83eb549..a324f288 100644 --- a/src/Admin/Schema/SchemaPermissions.php +++ b/src/Admin/Schema/SchemaPermissions.php @@ -131,13 +131,6 @@ class SchemaPermissions { ['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'], - ]], - ]], ], ], 'moderation' => [ diff --git a/src/Core/FrontSubmit.php b/src/Core/FrontSubmit.php index 3e61f73d..3530d1c3 100644 --- a/src/Core/FrontSubmit.php +++ b/src/Core/FrontSubmit.php @@ -17,6 +17,7 @@ class FrontSubmit { use FrontSubmitPay; use FrontSubmitFeatured; use FrontSubmitMedia; + use FrontSubmitChunk; private static $instance = null; @@ -93,6 +94,8 @@ class FrontSubmit { ], ], ]); + + $this->register_chunk_routes(); } public function can_upload_attachment(\WP_REST_Request $request): bool { @@ -128,15 +131,26 @@ class FrontSubmit { } public function rest_upload_attachment(\WP_REST_Request $request) { - $kind = (string) $request->get_param('kind'); - $user_id = get_current_user_id(); - $files = $request->get_file_params(); - $file = $files['file'] ?? null; + $kind = (string) $request->get_param('kind'); + $files = $request->get_file_params(); + $file = $files['file'] ?? null; if (!is_array($file)) { return new \WP_Error('aksrt_upload_missing_file', __('请选择要上传的文件。', 'aksrt'), ['status' => 400]); } + return $this->persist_uploaded_file($kind, $file, false); + } + + /** + * 校验并把一个上传文件落库为附件;整传与分片合并共用同一条管线。 + * + * @param string $kind image|video|resource + * @param array $file $_FILES 风格数组(name/tmp_name/size/error) + * @param bool $sideload true 时文件不是 HTTP 直传(分片合并产物),用 sideload 动作保存 + * @return \WP_REST_Response|\WP_Error + */ + public function persist_uploaded_file(string $kind, array $file, bool $sideload = false) { $max_size = self::upload_limit_bytes($kind); $upload_error = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE); if ($upload_error === UPLOAD_ERR_INI_SIZE || $upload_error === UPLOAD_ERR_FORM_SIZE) { @@ -156,7 +170,7 @@ class FrontSubmit { return new \WP_Error('aksrt_upload_failed', __('服务器无法保存上传文件。', 'aksrt'), ['status' => 500]); } - $size = (int) ($file['size'] ?? 0); + $size = (int) ($file['size'] ?? 0); if ($size <= 0 || $size > $max_size) { return new \WP_Error( @@ -197,15 +211,18 @@ class FrontSubmit { require_once ABSPATH . 'wp-admin/includes/image.php'; } + // 合并产物不是 HTTP 直传文件,走 sideload 动作(copy 而非 move_uploaded_file)。 + $overrides = $sideload ? ['test_form' => false, 'action' => 'wp_handle_sideload'] : ['test_form' => false]; + $_FILES['aksrt_front_submit_file'] = $file; - $attachment_id = media_handle_upload('aksrt_front_submit_file', 0, [], ['test_form' => false]); + $attachment_id = media_handle_upload('aksrt_front_submit_file', 0, [], $overrides); unset($_FILES['aksrt_front_submit_file']); if (is_wp_error($attachment_id)) { return new \WP_Error('aksrt_upload_failed', $attachment_id->get_error_message(), ['status' => 400]); } $attachment_id = (int) $attachment_id; - wp_update_post(['ID' => $attachment_id, 'post_author' => $user_id]); + wp_update_post(['ID' => $attachment_id, 'post_author' => get_current_user_id()]); update_post_meta($attachment_id, '_aksrt_front_submit_media', $kind); $attachment = $this->prepare_attachment_response($attachment_id); diff --git a/src/Core/FrontSubmitChunk.php b/src/Core/FrontSubmitChunk.php new file mode 100644 index 00000000..1a24342f --- /dev/null +++ b/src/Core/FrontSubmitChunk.php @@ -0,0 +1,278 @@ + static function ($value): bool { + return in_array((string) $value, ['image', 'video', 'resource'], true); + }, + ]; + + register_rest_route('aksrt/v1', '/front-submit/upload-chunk/(?Pimage|video|resource)', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_upload_chunk'], + 'permission_callback' => [$this, 'can_upload_attachment'], + 'args' => [ + 'kind' => $kind_arg, + 'chunk' => ['default' => 0, 'sanitize_callback' => 'absint'], + 'chunks' => ['default' => 0, 'sanitize_callback' => 'absint'], + 'name' => ['default' => '', 'sanitize_callback' => 'sanitize_file_name'], + 'size' => ['default' => 0, 'sanitize_callback' => 'absint'], + ], + ]); + + register_rest_route('aksrt/v1', '/front-submit/upload-merge/(?Pimage|video|resource)', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_upload_chunk_merge'], + 'permission_callback' => [$this, 'can_upload_attachment'], + 'args' => [ + 'kind' => $kind_arg, + 'chunks' => ['default' => 0, 'sanitize_callback' => 'absint'], + 'name' => ['default' => '', 'sanitize_callback' => 'sanitize_file_name'], + 'size' => ['default' => 0, 'sanitize_callback' => 'absint'], + ], + ]); + + register_rest_route('aksrt/v1', '/front-submit/upload-status/(?Pimage|video|resource)', [ + 'methods' => \WP_REST_Server::READABLE, + 'callback' => [$this, 'rest_upload_chunk_status'], + 'permission_callback' => [$this, 'can_upload_attachment'], + 'args' => [ + 'kind' => $kind_arg, + 'chunks' => ['default' => 0, 'sanitize_callback' => 'absint'], + 'name' => ['default' => '', 'sanitize_callback' => 'sanitize_file_name'], + ], + ]); + } + + /** + * 上传单个分片;index 为 0 基,同时累加校验总大小不超上限。 + */ + public function rest_upload_chunk(\WP_REST_Request $request) { + $file = $request->get_file_params()['file'] ?? null; + if (!is_array($file) || empty($file['tmp_name']) || (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { + return new \WP_Error('aksrt_chunk_missing_file', __('上传信息错误,请重新选择文件', 'aksrt'), ['status' => 400]); + } + + $params = $this->chunk_params($request, true); + if (is_wp_error($params)) { + return $params; + } + + $max_size = self::upload_limit_bytes($params['kind']); + if ($params['size'] <= 0 || $params['size'] > $max_size) { + return new \WP_Error('aksrt_upload_too_large', sprintf(__('文件大小不能超过 %s。', 'aksrt'), size_format($max_size)), ['status' => 413]); + } + + $chunk_bytes = (int) @filesize((string) $file['tmp_name']); + if ($chunk_bytes <= 0 || $chunk_bytes > self::CHUNK_SIZE_BYTES) { + return new \WP_Error('aksrt_chunk_invalid_size', __('分片大小异常,请重新上传', 'aksrt'), ['status' => 400]); + } + + $temp_dir = $this->chunk_temp_dir(); + if ($params['chunk'] === 0) { + $this->chunk_temp_cleanup($temp_dir); + } + + $existing = $this->chunk_part_paths($temp_dir, $params['key']); + $uploaded = 0; + foreach ($existing as $path) { + $uploaded += (int) @filesize($path); + } + if ($uploaded + $chunk_bytes > $max_size) { + return new \WP_Error('aksrt_upload_too_large', sprintf(__('文件大小不能超过 %s。', 'aksrt'), size_format($max_size)), ['status' => 413]); + } + + $target = $temp_dir . '/' . $this->chunk_part_name($params['key'], $params['chunk']); + $moved = @move_uploaded_file((string) $file['tmp_name'], $target); + if (!$moved) { + $moved = @copy((string) $file['tmp_name'], $target); + @unlink((string) $file['tmp_name']); + } + if (!$moved) { + return new \WP_Error('aksrt_chunk_save_failed', __('分片保存失败,请重新上传', 'aksrt'), ['status' => 500]); + } + + return new \WP_REST_Response([ + 'success' => true, + 'chunk' => $params['chunk'], + 'chunks' => $params['chunks'], + 'uploaded_size' => $uploaded + $chunk_bytes, + ], 200); + } + + /** + * 断点续传:返回已落盘的分片序号,前端按序跳过。 + */ + public function rest_upload_chunk_status(\WP_REST_Request $request) { + $params = $this->chunk_params($request); + if (is_wp_error($params)) { + return $params; + } + + $parts = $this->chunk_part_paths($this->chunk_temp_dir(), $params['key']); + $uploaded = []; + foreach ($parts as $path) { + $uploaded[] = $this->chunk_index_from_name((string) $path); + } + + return new \WP_REST_Response([ + 'success' => true, + 'uploaded' => $uploaded, + 'uploaded_count' => count($uploaded), + ], 200); + } + + /** + * 合并分片并按标准管线落库;成功或失败后都清理分片。 + */ + public function rest_upload_chunk_merge(\WP_REST_Request $request) { + $params = $this->chunk_params($request); + if (is_wp_error($params)) { + return $params; + } + + $max_size = self::upload_limit_bytes($params['kind']); + $temp_dir = $this->chunk_temp_dir(); + $merge_path = $temp_dir . '/' . $this->chunk_part_name($params['key'], 'merge'); + + $fp = @fopen($merge_path, 'wb'); + if (!$fp) { + return new \WP_Error('aksrt_chunk_merge_failed', __('分片合并失败,请重新上传', 'aksrt'), ['status' => 500]); + } + + $missing = 0; + for ($i = 0; $i < $params['chunks']; $i++) { + $part = $temp_dir . '/' . $this->chunk_part_name($params['key'], $i); + if (!is_file($part)) { + $missing++; + break; + } + fwrite($fp, (string) file_get_contents($part)); + } + fclose($fp); + + if ($missing) { + @unlink($merge_path); + return new \WP_Error('aksrt_chunk_missing', __('文件缺少分片,正在重新上传缺失分片', 'aksrt'), ['status' => 409]); + } + + $merged_size = (int) @filesize($merge_path); + if ($merged_size <= 0 || ($params['size'] > 0 && $merged_size !== $params['size'])) { + @unlink($merge_path); + return new \WP_Error('aksrt_chunk_size_mismatch', __('合并后的文件大小校验失败,请重新上传', 'aksrt'), ['status' => 400]); + } + if ($merged_size > $max_size) { + @unlink($merge_path); + return new \WP_Error('aksrt_upload_too_large', sprintf(__('文件大小不能超过 %s。', 'aksrt'), size_format($max_size)), ['status' => 413]); + } + + $name = sanitize_file_name($params['name']); + $type = (string) (wp_check_filetype_and_ext($merge_path, $name, get_allowed_mime_types())['type'] ?? ''); + + $result = $this->persist_uploaded_file($params['kind'], [ + 'tmp_name' => $merge_path, + 'name' => $name, + 'size' => $merged_size, + 'type' => $type, + 'error' => UPLOAD_ERR_OK, + ], true); + + // 无论落库成败都清理本次分片(persist 成功时已 copy 走合并文件,失败时也要清场)。 + foreach ($this->chunk_part_paths($temp_dir, $params['key']) as $path) { + @unlink($path); + } + @unlink($merge_path); + + return $result; + } + + /** 校验并归一分片参数(name/chunks/chunk/size/key)。 */ + private function chunk_params(\WP_REST_Request $request, bool $with_index = false) { + $name = sanitize_file_name((string) $request->get_param('name')); + $chunks = (int) $request->get_param('chunks'); + $chunk = (int) $request->get_param('chunk'); + + if ($name === '' || $chunks < 1 || $chunks > 20480) { + return new \WP_Error('aksrt_chunk_invalid_params', __('分片参数异常', 'aksrt'), ['status' => 400]); + } + if ($with_index && ($chunk < 0 || $chunk >= $chunks)) { + return new \WP_Error('aksrt_chunk_invalid_params', __('分片参数异常', 'aksrt'), ['status' => 400]); + } + + return [ + 'kind' => (string) $request->get_param('kind'), + 'name' => $name, + 'chunks' => $chunks, + 'chunk' => $chunk, + 'size' => (int) $request->get_param('size'), + // 分片按用户隔离,避免同名文件互相覆盖、也防止探测他人分片。 + 'key' => md5(get_current_blog_id() . '|' . get_current_user_id() . '|' . $name), + ]; + } + + /** 分片临时目录(参考主题为 wp-content/zib-temp,带 index.php 保护和 48h 清理)。 */ + private function chunk_temp_dir(): string { + $temp_dir = (string) apply_filters('aksrt_temp_dir', WP_CONTENT_DIR . '/aksrt-temp'); + if (!@is_dir($temp_dir)) { + wp_mkdir_p($temp_dir); + } + $index_file = $temp_dir . '/index.php'; + if (!file_exists($index_file)) { + @file_put_contents($index_file, "chunk_part_name($key, '*')) as $path) { + $path = (string) $path; + if (substr($path, -10) === 'merge.part') { + continue; + } + $paths[] = $path; + } + sort($paths, SORT_NATURAL); + return $paths; + } + + private function chunk_part_name(string $key, $index): string { + return sprintf('%d-%s-%s.part', get_current_blog_id(), $key, (string) $index); + } + + private function chunk_index_from_name(string $path): int { + $base = basename($path, '.part'); + $dash = strrpos($base, '-'); + return $dash === false ? -1 : (int) substr($base, $dash + 1); + } +} diff --git a/src/Core/ScriptsPart1c.php b/src/Core/ScriptsPart1c.php index df87b467..7267db95 100644 --- a/src/Core/ScriptsPart1c.php +++ b/src/Core/ScriptsPart1c.php @@ -213,8 +213,6 @@ trait ScriptsPart1c { 'upload_ext' => implode('|', array_keys(get_allowed_mime_types())), 'user_upload_nonce' => wp_create_nonce('user_upload'), 'post_action_nonce' => wp_create_nonce('post_action'), - 'is_split_upload' => false, - 'split_minimum_size' => 10, 'translate_config' => [], 'i18n' => [ 'load_more' => __('加载更多', 'aksrt'), diff --git a/src/Extensions/SystemTools.php b/src/Extensions/SystemTools.php new file mode 100644 index 00000000..77ed7bbe --- /dev/null +++ b/src/Extensions/SystemTools.php @@ -0,0 +1,205 @@ +remove_emoji(); + } + + if (!empty($settings['remove_open_sans'])) { + add_action('init', [$this, 'remove_open_sans']); + } + + if (!empty($settings['remove_more_wp_head'])) { + add_action('after_setup_theme', [$this, 'remove_more_wp_head']); + } + + if (!empty($settings['remove_dashboard_widgets'])) { + add_action('do_meta_boxes', [$this, 'remove_dashboard_widgets'], 10, 3); + add_filter('site_status_tests', [$this, 'filter_site_status_tests']); + add_filter('pre_http_request', [$this, 'block_site_health_requests'], 10, 3); + } + + if (!empty($settings['display_wp_update'])) { + $this->disable_wp_update(); + } + + if (!empty($settings['disabled_large_thumbnail'])) { + add_action('after_setup_theme', [$this, 'disable_large_thumbnail']); + } + + if (!empty($settings['close_gutenberg'])) { + add_filter('use_block_editor_for_post', '__return_false'); + } + + if (!empty($settings['disabled_autoembed'])) { + $this->disable_autoembed(); + } + + if (!empty($settings['admin_user_del_fields'])) { + add_action('show_user_profile', [$this, 'hide_user_profile_fields']); + add_action('edit_user_profile', [$this, 'hide_user_profile_fields']); + } + } + + /** 删除WordPress自带Emoji表情(对齐参考主题 remove_emoji 套件)。 */ + private function remove_emoji(): void { + remove_action('admin_print_scripts', 'print_emoji_detection_script'); + remove_action('admin_print_styles', 'print_emoji_styles'); + remove_action('wp_head', 'print_emoji_detection_script', 7); + remove_action('wp_print_styles', 'print_emoji_styles'); + remove_filter('the_content_feed', 'wp_staticize_emoji'); + remove_filter('comment_text_rss', 'wp_staticize_emoji'); + remove_filter('wp_mail', 'wp_staticize_emoji_for_email'); + } + + /** 禁用Google字体(Open Sans)。 */ + public function remove_open_sans(): void { + wp_deregister_style('open-sans'); + wp_register_style('open-sans', false); + wp_enqueue_style('open-sans', ''); + } + + /** 清理wp_head带入的多余Meta标签。 */ + public function remove_more_wp_head(): void { + remove_action('wp_head', 'feed_links_extra', 3); + remove_action('wp_head', 'feed_links', 2); + remove_action('wp_head', 'rsd_link'); + remove_action('wp_head', 'wlwmanifest_link'); + remove_action('wp_head', 'index_rel_link'); + remove_action('wp_head', 'parent_post_rel_link', 10, 0); + remove_action('wp_head', 'start_post_rel_link', 10, 0); + remove_action('wp_head', 'adjacent_posts_rel_link', 10, 0); + remove_action('wp_head', 'wp_generator'); + remove_action('wp_head', 'adjacent_posts_rel_link_wp_head', 10, 0); + remove_action('wp_head', 'wp_shortlink_wp_head', 10, 0); + remove_action('wp_head', 'rest_output_link_wp_head', 10, 0); + remove_action('wp_head', 'wp_oembed_add_discovery_links', 10, 1); + remove_action('wp_head', 'rel_canonical', 10, 0); + } + + /** 移除仪表盘的站点健康、PHP/浏览器版本检查小组件。 */ + public function remove_dashboard_widgets($screen_id, $context): void { + if ('dashboard' !== $screen_id || 'normal' !== $context) { + return; + } + + global $wp_meta_boxes; + + unset($wp_meta_boxes['dashboard']['normal']['high']['dashboard_php_nag']); + unset($wp_meta_boxes['dashboard']['normal']['core']['dashboard_site_health']); + unset($wp_meta_boxes['dashboard']['side']['core']['dashboard_primary']); + } + + /** 站点健康检查中移除页面缓存与PHP版本检测(非常耗时)。 */ + public function filter_site_status_tests(array $tests): array { + if (isset($tests['async']['page_cache'])) { + unset($tests['async']['page_cache']); + } + if (isset($tests['direct']['php_version'])) { + unset($tests['direct']['php_version']); + } + return $tests; + } + + /** 禁止后台联网检测PHP版本、浏览器与贡献者信息,提高后台加载速度。 */ + public function block_site_health_requests($pre, $parsed_args, $url) { + $blocked = [ + 'api.wordpress.org/core/serve-happy/', + 'api.wordpress.org/core/browse-happy/', + 'api.wordpress.org/core/credits/', + ]; + + foreach ($blocked as $arg) { + if (strpos((string) $url, $arg) !== false) { + return [ + 'headers' => [], + 'body' => '', + 'response' => [ + 'code' => false, + 'message' => false, + ], + 'cookies' => [], + 'http_response' => null, + ]; + } + } + + return $pre; + } + + /** 禁止WordPress检测核心/插件/主题更新,并屏蔽更新通知。 */ + private function disable_wp_update(): void { + remove_action('admin_init', '_maybe_update_core'); + remove_action('admin_init', '_maybe_update_plugins'); + remove_action('admin_init', '_maybe_update_themes'); + add_action('admin_menu', [$this, 'hide_update_notices']); + + add_filter('automatic_updater_disabled', '__return_true'); + remove_action('init', 'wp_schedule_update_checks'); + remove_action('wp_version_check', 'wp_version_check'); + remove_action('wp_update_plugins', 'wp_update_plugins'); + remove_action('wp_update_themes', 'wp_update_themes'); + add_filter('auto_update_core', '__return_false'); + add_filter('allow_major_auto_core_updates', '__return_false'); + add_filter('allow_dev_auto_core_updates', '__return_false'); + add_filter('allow_minor_auto_core_updates', '__return_false'); + } + + /** 禁用更新通知。 */ + public function hide_update_notices(): void { + remove_action('admin_notices', 'update_nag', 3); + remove_action('network_admin_notices', 'update_nag', 3); + } + + /** 禁止WP大尺寸缩略图裁剪(medium_large、1536x1536、2048x2048)。 */ + public function disable_large_thumbnail(): void { + remove_image_size('1536x1536'); + remove_image_size('2048x2048'); + add_filter('intermediate_image_sizes', static function ($sizes) { + return array_merge(array_diff((array) $sizes, ['medium_large', '1536x1536', '2048x2048'])); + }, 20); + } + + /** 禁用Auto Embed自动嵌入功能,提高页面渲染速度。 */ + private function disable_autoembed(): void { + remove_filter('the_content', [$GLOBALS['wp_embed'], 'autoembed'], 8); + remove_filter('widget_text_content', [$GLOBALS['wp_embed'], 'autoembed'], 8); + remove_filter('widget_block_content', [$GLOBALS['wp_embed'], 'autoembed'], 8); + remove_action('edit_form_advanced', [$GLOBALS['wp_embed'], 'maybe_run_ajax_cache']); + remove_action('edit_page_form', [$GLOBALS['wp_embed'], 'maybe_run_ajax_cache']); + } + + /** 关闭后台用户编辑页面中的多余选项。 */ + public function hide_user_profile_fields(): void { + echo ''; + } +} diff --git a/src/Frontend/PageAdminSet.php b/src/Frontend/PageAdminSet.php new file mode 100644 index 00000000..1785937f --- /dev/null +++ b/src/Frontend/PageAdminSet.php @@ -0,0 +1,305 @@ + 'aksrt_frontend_set_modal', + '_wpnonce' => wp_create_nonce('aksrt_frontend_set'), + ], + admin_url('admin-ajax.php') + ); + + $button = ''; + + return $button . (string) $buttons; + } + + /** 条件加载弹窗提交脚本。 */ + public function enqueue_script(): void { + if (!self::enabled()) { + return; + } + $src = get_template_directory() . '/assets/js/page-admin-set.js'; + wp_enqueue_script( + 'aksrt-page-admin-set', + get_template_directory_uri() . '/assets/js/page-admin-set.js', + [], + file_exists($src) ? (string) filemtime($src) : null, + true + ); + } + + /** GET 弹窗内容(注入 aksrt-modal-runtime)。 */ + public function ajax_modal(): void { + check_ajax_referer('aksrt_frontend_set', '_wpnonce'); + if (!self::enabled()) { + wp_send_json_error(['msg' => __('权限不足', 'aksrt')]); + } + + $object_id = (int) get_queried_object_id(); + if (is_category() || is_tag() || is_tax()) { + $html = $this->term_modal_html($object_id, (string) get_queried_object()->taxonomy); + } else { + $html = $this->post_modal_html($object_id, get_post_type($object_id) ?: 'post'); + } + + wp_send_json_success(['html' => $html]); + } + + /** POST 保存:仅接受白名单字段。 */ + public function ajax_save(): void { + check_ajax_referer('aksrt_frontend_set', '_wpnonce'); + if (!self::enabled()) { + wp_send_json_error(['msg' => __('权限不足', 'aksrt')]); + } + + $object_id = isset($_POST['id']) ? absint($_POST['id']) : 0; + if (!$object_id) { + wp_send_json_error(['msg' => __('页面数据出错', 'aksrt')]); + } + + $type = isset($_POST['type']) ? sanitize_key((string) $_POST['type']) : ''; + if ($type === 'tax') { + $taxonomy = isset($_POST['subtype']) ? sanitize_key((string) $_POST['subtype']) : ''; + $this->save_term($object_id, $taxonomy); + } else { + $this->save_post($object_id); + } + + wp_send_json_success(['msg' => __('保存成功,正在刷新页面', 'aksrt'), 'reload' => true]); + } + + private function save_post(int $post_id): void { + $post = get_post($post_id); + if (!$post) { + wp_send_json_error(['msg' => __('未找到可用数据', 'aksrt')]); + } + if (!current_user_can('edit_post', $post_id)) { + wp_send_json_error(['msg' => __('权限不足', 'aksrt')]); + } + + $postarr = ['ID' => $post_id]; + if (isset($_POST['post_title'])) { + $title = sanitize_text_field(wp_unslash((string) $_POST['post_title'])); + if ($title !== '') { + $postarr['post_title'] = $title; + } + } + if (isset($_POST['comments_open'])) { + $postarr['comment_status'] = !empty($_POST['comments_open']) ? 'open' : 'closed'; + } + if (count($postarr) > 1) { + $result = wp_update_post($postarr, true); + if (is_wp_error($result)) { + wp_send_json_error(['msg' => $result->get_error_message()]); + } + } + + if (isset($_POST['post_format']) && post_type_supports($post->post_type, 'post-formats')) { + $format = sanitize_key((string) wp_unslash($_POST['post_format'])); + set_post_format($post_id, $format === 'standard' ? '' : $format); + } + + if ($post->post_type === 'post' && class_exists('\AKSRT\Core\PostMetaBox')) { + $layout = isset($_POST['show_layout']) ? sanitize_key((string) wp_unslash($_POST['show_layout'])) : ''; + if ($layout !== '' && !in_array($layout, ['false', 'no_sidebar', 'sidebar_left', 'sidebar_right'], true)) { + $layout = 'false'; + } + + // 保留白名单之外的既有配置键(封面、视频等),仅覆盖弹窗提交的字段。 + // 复选框未勾选时浏览器不会提交键名,必须无条件写入两个开关。 + $meta = get_post_meta($post_id, \AKSRT\Core\PostMetaBox::META_KEY, true); + $meta = is_array($meta) ? $meta : []; + if ($layout !== '') { + $meta['show_layout'] = $layout; + } + if (isset($_POST['subtitle'])) { + $meta['subtitle'] = sanitize_text_field(wp_unslash((string) $_POST['subtitle'])); + } + $meta['no_article-navs'] = !empty($_POST['no_article-navs']); + $meta['article_maxheight_xz'] = !empty($_POST['article_maxheight_xz']); + update_post_meta($post_id, \AKSRT\Core\PostMetaBox::META_KEY, $meta); + + if (isset($_POST['views'])) { + update_post_meta($post_id, \AKSRT\Core\DomainModel::POST_VIEW_COUNT_KEY, max(0, (int) $_POST['views'])); + } + if (isset($_POST['like'])) { + update_post_meta($post_id, '_aksrt_like_count', max(0, (int) $_POST['like'])); + } + } + } + + private function save_term(int $term_id, string $taxonomy = ''): void { + $term = $taxonomy !== '' ? get_term($term_id, $taxonomy) : get_term($term_id); + if (!$term || is_wp_error($term)) { + wp_send_json_error(['msg' => __('未找到可用数据', 'aksrt')]); + } + if (!current_user_can('manage_categories') && !current_user_can('manage_terms')) { + wp_send_json_error(['msg' => __('权限不足', 'aksrt')]); + } + + $data = []; + if (isset($_POST['term_name'])) { + $name = sanitize_text_field(wp_unslash((string) $_POST['term_name'])); + if ($name !== '') { + $data['name'] = $name; + } + } + if (isset($_POST['term_description'])) { + $data['description'] = sanitize_textarea_field(wp_unslash((string) $_POST['term_description'])); + } + if (!$data) { + return; + } + + $result = wp_update_term($term_id, (string) $term->taxonomy, $data); + if (is_wp_error($result)) { + wp_send_json_error(['msg' => $result->get_error_message()]); + } + } + + private function post_modal_html(int $post_id, string $post_type): string { + $meta = class_exists('\AKSRT\Core\PostMetaBox') + ? \AKSRT\Core\PostMetaBox::get_meta($post_id) + : []; + $views = (int) ($meta['views'] ?? 0); + $like = (int) ($meta['like'] ?? 0); + $is_single = is_single(); + + $html = '
'; + $html .= '

' . esc_html(get_the_title($post_id)) . '

'; + + $html .= $this->text_field('post_title', __('标题', 'aksrt'), (string) get_the_title($post_id)); + + if ($is_single) { + $html .= $this->text_field('subtitle', __('副标题', 'aksrt'), (string) ($meta['subtitle'] ?? '')); + $layout = (string) ($meta['show_layout'] ?? 'false'); + $html .= ''; + $html .= $this->number_field('like', __('点赞数', 'aksrt'), $like); + $html .= $this->number_field('views', __('阅读数', 'aksrt'), $views); + $html .= $this->checkbox_field('no_article-navs', __('不显示目录树', 'aksrt'), !empty($meta['no_article-navs'])); + $html .= $this->checkbox_field('article_maxheight_xz', __('限制文章最大高度', 'aksrt'), !empty($meta['article_maxheight_xz'])); + } + + $html .= ''; + + $html .= $this->hidden_fields('post', $post_id, $post_type); + $html .= '
' + . '
'; + $html .= '
'; + + return $this->wrap_form($html); + } + + private function term_modal_html(int $term_id, string $taxonomy): string { + $term = get_term($term_id, $taxonomy); + if (!$term || is_wp_error($term)) { + return '

' . esc_html__('未找到可用数据', 'aksrt') . '

'; + } + + $html = '
'; + $html .= '

' . esc_html($term->name) . '

'; + $html .= $this->text_field('term_name', __('名称', 'aksrt'), (string) $term->name); + $html .= $this->text_field('term_description', __('描述', 'aksrt'), (string) $term->description); + $html .= $this->hidden_fields('tax', $term_id, $taxonomy); + $html .= '
' + . '
'; + $html .= '
'; + + return $this->wrap_form($html); + } + + private function wrap_form(string $inner): string { + return '
' + . '' + . wp_nonce_field('aksrt_frontend_set', '_wpnonce', true, false) + . $inner + . '
'; + } + + private function hidden_fields(string $type, int $id, string $subtype): string { + return '' + . '' + . ''; + } + + private function text_field(string $name, string $label, string $value): string { + return ''; + } + + private function number_field(string $name, string $label, int $value): string { + return ''; + } + + private function checkbox_field(string $name, string $label, bool $checked): string { + return '
'; + } +} diff --git a/src/Settings/ExtensionRuntime.php b/src/Settings/ExtensionRuntime.php index 80f47ed8..f52387e5 100644 --- a/src/Settings/ExtensionRuntime.php +++ b/src/Settings/ExtensionRuntime.php @@ -189,6 +189,55 @@ class ExtensionRuntime { ]; } + /** 获取系统工具设置(WordPress 优化、上传优化、编辑器与显示)。 */ + public static function get_system_tools_settings(): array { + $settings = Settings::get_all(); + + $switch = static function (string $key, bool $default) use ($settings): bool { + return !isset($settings[$key]) ? $default : !empty($settings[$key]); + }; + + $rename_type = sanitize_key((string) ($settings['newfilename_type'] ?? 'time')); + if (!in_array($rename_type, ['random', 'time'], true)) { + $rename_type = 'time'; + } + + // Only fill defaults when the key was never saved — empty array means "none". + $toolbar_scenes = ['pc_s', 'm_s']; + if (!array_key_exists('mce_float_toolbar', $settings)) { + $float_toolbar = $toolbar_scenes; + } else { + $float_toolbar = []; + foreach ((array) ($settings['mce_float_toolbar'] ?? []) as $scene) { + $scene = sanitize_key((string) $scene); + if (in_array($scene, $toolbar_scenes, true) && !in_array($scene, $float_toolbar, true)) { + $float_toolbar[] = $scene; + } + } + } + + return [ + 'hide_admin_bar' => $switch('hide_admin_bar', true), + 'remove_emoji' => $switch('remove_emoji', true), + 'remove_open_sans' => $switch('remove_open_sans', true), + 'remove_more_wp_head' => $switch('remove_more_wp_head', true), + 'remove_dashboard_widgets' => $switch('remove_dashboard_widgets', true), + 'display_wp_update' => $switch('display_wp_update', true), + 'split_upload_s' => $switch('split_upload_s', true), + 'split_minimum_size' => max(4, min(100, (int) ($settings['split_minimum_size'] ?? 20))), + 'newfilename' => $switch('newfilename', false), + 'newfilename_type' => $rename_type, + 'disabled_large_thumbnail' => $switch('disabled_large_thumbnail', true), + 'admin_user_del_fields' => $switch('admin_user_del_fields', true), + 'admin_frontend_set' => $switch('admin_frontend_set', true), + 'close_gutenberg' => $switch('close_gutenberg', false), + 'disabled_autoembed' => $switch('disabled_autoembed', true), + 'mce_float_toolbar' => $float_toolbar, + 'time_ago_s' => $switch('time_ago_s', true), + 'time_format' => (string) ($settings['time_format'] ?? 'n月j日 H:i'), + ]; + } + /** 获取短信接口设置。 */ public static function get_sms_settings(): array { $settings = Settings::get_all(); diff --git a/templates/pages/page-submit.php b/templates/pages/page-submit.php index 2ba9436a..0571bdc4 100644 --- a/templates/pages/page-submit.php +++ b/templates/pages/page-submit.php @@ -80,6 +80,11 @@ if (file_exists($js)) { $image_upload_multiple = class_exists('\AKSRT\Attachment\UploadLimits') ? \AKSRT\Attachment\UploadLimits::image_batch_limit() : 6; + $system_tools = class_exists('\AKSRT\Settings\ExtensionRuntime') + ? \AKSRT\Settings\ExtensionRuntime::get_system_tools_settings() + : []; + $float_scenes = is_array($system_tools['mce_float_toolbar'] ?? null) ? $system_tools['mce_float_toolbar'] : ['pc_s', 'm_s']; + $float_toolbar = wp_is_mobile() ? in_array('m_s', $float_scenes, true) : in_array('pc_s', $float_scenes, true); wp_localize_script('aksrt-front-submit-editor', 'aksrtSubmitData', [ 'ajaxUrl' => admin_url('admin-ajax.php'), @@ -95,6 +100,10 @@ if (file_exists($js)) { 'file' => ['url' => esc_url_raw(rest_url('aksrt/v1/front-submit/media/file'))], ], + 'splitUpload' => [ + 'enabled' => !empty($system_tools['split_upload_s']), + 'minSize' => (int) ($system_tools['split_minimum_size'] ?? 20), + ], 'imageUploadMultiple' => $image_upload_multiple, 'ai' => [ @@ -109,6 +118,7 @@ if (file_exists($js)) { 'canUploadFile' => !empty($ctx['can_upload_file']), 'canHide' => !empty($ctx['can_hide']), 'canPay' => !empty($ctx['can_pay']), + 'floatToolbar' => $float_toolbar, ], 'uploads' => [ 'image' => !empty($ctx['can_upload_img']) ? [ diff --git a/tests/widget-system/site-security-harness.php b/tests/widget-system/site-security-harness.php index 18a927d1..1b566c20 100644 --- a/tests/widget-system/site-security-harness.php +++ b/tests/widget-system/site-security-harness.php @@ -58,6 +58,7 @@ namespace { require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensionsAudit.php'; require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensionsPush.php'; require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensionsSecurity.php'; + require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensionsSystem.php'; require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensions.php'; } diff --git a/tests/widget-system/split-upload-harness.php b/tests/widget-system/split-upload-harness.php new file mode 100644 index 00000000..4407ac3b --- /dev/null +++ b/tests/widget-system/split-upload-harness.php @@ -0,0 +1,167 @@ + 'video/mp4']; } + function wp_check_filetype_and_ext(string $file, string $name, array $mimes = []): array { + $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); + return ['ext' => $ext, 'type' => $mimes[$ext] ?? '']; + } + + class WP_Error { + private array $errors; + public function __construct($code = '', $message = '') { $this->errors = [$code => $message]; } + public function get_error_message(): string { return (string) reset($this->errors); } + public function get_error_code() { return (string) array_key_first($this->errors); } + } + + class WP_REST_Server { + const READABLE = 'GET'; + } + + class WP_REST_Response { + private array $data; + private int $status; + public function __construct($data = [], int $status = 200) { $this->data = $data; $this->status = $status; } + public function get_data(): array { return $this->data; } + public function get_status(): int { return $this->status; } + } + + class WP_REST_Request { + } + + class FakeRestRequest extends \WP_REST_Request { + private array $params; + private array $files; + public function __construct(array $params = [], array $files = []) { + $this->params = $params; + $this->files = $files; + } + public function get_param(string $key) { return $this->params[$key] ?? null; } + public function get_file_params(): array { return $this->files; } + } + + function split_upload_check(bool $condition, string $message): void { + if (!$condition) throw new \RuntimeException($message); + } + + require dirname(__DIR__, 2) . '/src/Core/FrontSubmitChunk.php'; +} + +namespace AKSRT\Core { + final class FakeFrontSubmit { + use \AKSRT\Core\FrontSubmitChunk; + + public static int $limit_bytes = 10 * 1048576; + public static array $persisted = []; + + public static function upload_limit_bytes(string $kind): int { + return self::$limit_bytes; + } + + /** Stand-in for FrontSubmit::persist_uploaded_file(): records and echoes merged content. */ + public function persist_uploaded_file(string $kind, array $file, bool $sideload = false) { + self::$persisted[] = ['kind' => $kind, 'file' => $file, 'sideload' => $sideload]; + return 'persisted:' . (string) file_get_contents((string) $file['tmp_name']); + } + } +} + +namespace { + use AKSRT\Core\FakeFrontSubmit; + + $submit = new FakeFrontSubmit(); + $temp_dir = WP_CONTENT_DIR . '/aksrt-temp'; + + // 1) Chunk parameter validation. + $err = $submit->rest_upload_chunk(new FakeRestRequest(['name' => '', 'chunks' => 3, 'chunk' => 0, 'size' => 10])); + split_upload_check($err instanceof \WP_Error, 'empty name should be rejected'); + $err = $submit->rest_upload_chunk(new FakeRestRequest(['name' => 'a.mp4', 'chunks' => 0, 'chunk' => 0, 'size' => 10])); + split_upload_check($err instanceof \WP_Error, 'zero chunk count should be rejected'); + $err = $submit->rest_upload_chunk(new FakeRestRequest(['name' => 'a.mp4', 'chunks' => 3, 'chunk' => 5, 'size' => 10])); + split_upload_check($err instanceof \WP_Error, 'out-of-range chunk index should be rejected'); + + // 2) Chunk upload lands per-index .part files, oversized total is refused. + $make_file = static function (string $content): string { + $path = tempnam(sys_get_temp_dir(), 'chunk'); + file_put_contents($path, $content); + return $path; + }; + $params = static fn (int $chunk, int $chunks, int $size): array => [ + 'kind' => 'video', 'chunk' => $chunk, 'chunks' => $chunks, 'name' => 'movie.mp4', 'size' => $size, + ]; + + $response = $submit->rest_upload_chunk(new FakeRestRequest($params(0, 3, 6), ['file' => ['tmp_name' => $make_file('AAA'), 'error' => 0]])); + split_upload_check($response instanceof \WP_REST_Response, 'first chunk should upload'); + split_upload_check($response->get_data()['uploaded_size'] === 3, 'uploaded_size should count the stored chunk'); + $submit->rest_upload_chunk(new FakeRestRequest($params(2, 3, 6), ['file' => ['tmp_name' => $make_file('CCC'), 'error' => 0]])); + + $parts = glob($temp_dir . '/*.part'); + split_upload_check(count($parts) === 2, 'two part files should exist'); + foreach ($parts as $part) { + split_upload_check(strpos(basename($part), '1-') === 0, 'part name should be blog-id prefixed'); + } + + // 3) Status reports the uploaded indexes (resume contract). + $response = $submit->rest_upload_chunk_status(new FakeRestRequest(['kind' => 'video', 'name' => 'movie.mp4', 'chunks' => 3])); + split_upload_check($response instanceof \WP_REST_Response, 'status should respond'); + split_upload_check($response->get_data()['uploaded'] === [0, 2], 'status should list uploaded chunk indexes'); + + // 4) Merge refuses when a chunk is missing. + $err = $submit->rest_upload_chunk_merge(new FakeRestRequest(['kind' => 'video', 'name' => 'movie.mp4', 'chunks' => 3, 'size' => 9])); + split_upload_check($err instanceof \WP_Error && $err->get_error_code() === 'aksrt_chunk_missing', 'merge should refuse with missing chunks'); + + // 5) Merge with a size mismatch is refused. + $submit->rest_upload_chunk(new FakeRestRequest($params(1, 3, 6), ['file' => ['tmp_name' => $make_file('BBB'), 'error' => 0]])); + $err = $submit->rest_upload_chunk_merge(new FakeRestRequest(['kind' => 'video', 'name' => 'movie.mp4', 'chunks' => 3, 'size' => 999])); + split_upload_check($err instanceof \WP_Error && $err->get_error_code() === 'aksrt_chunk_size_mismatch', 'size mismatch should refuse the merge'); + + // 6) Happy-path merge concatenates in order, flags sideload, and cleans up. + $response = $submit->rest_upload_chunk_merge(new FakeRestRequest(['kind' => 'video', 'name' => 'movie.mp4', 'chunks' => 3, 'size' => 9])); + split_upload_check(is_string($response) && $response === 'persisted:AAABBBCCC', 'merge should concatenate chunks in order'); + split_upload_check(count(FakeFrontSubmit::$persisted) === 1, 'merge should persist exactly once'); + $call = FakeFrontSubmit::$persisted[0]; + split_upload_check($call['kind'] === 'video' && $call['sideload'] === true, 'merge should persist as a sideloaded video'); + split_upload_check($call['file']['name'] === 'movie.mp4' && $call['file']['size'] === 9, 'merge should rebuild the $_FILES-style payload'); + split_upload_check(glob($temp_dir . '/*.part') === [], 'merge should clean up all parts'); + + // 7) Per-user keying: a different file name never sees another name's parts. + $response = $submit->rest_upload_chunk_status(new FakeRestRequest(['kind' => 'video', 'name' => 'other.mp4', 'chunks' => 3])); + split_upload_check($response instanceof \WP_REST_Response && $response->get_data()['uploaded'] === [], 'a different name should start with no uploaded chunks'); + + // 8) Chunk size cap refuses oversized chunk blobs. + $big = $make_file(str_repeat('x', 3 * 1048576)); + $err = $submit->rest_upload_chunk(new FakeRestRequest($params(0, 1, 3 * 1048576), ['file' => ['tmp_name' => $big, 'error' => 0]])); + split_upload_check($err instanceof \WP_Error && $err->get_error_code() === 'aksrt_chunk_invalid_size', 'oversized chunk blobs should be refused'); + + // 9) Kind size limit caps cumulative uploads. + FakeFrontSubmit::$limit_bytes = 4; + $err = $submit->rest_upload_chunk(new FakeRestRequest($params(0, 2, 9), ['file' => ['tmp_name' => $make_file('AAAAA'), 'error' => 0]])); + split_upload_check($err instanceof \WP_Error && $err->get_error_code() === 'aksrt_upload_too_large', 'cumulative size over the kind limit should be refused'); + + // Cleanup. + FakeFrontSubmit::$persisted = []; + foreach ((array) glob(WP_CONTENT_DIR . '/*.part') as $leftover) { + @unlink((string) $leftover); + } + @unlink(WP_CONTENT_DIR . '/aksrt-temp/index.php'); + @rmdir(WP_CONTENT_DIR . '/aksrt-temp'); + @rmdir(WP_CONTENT_DIR); + + echo "Split upload harness passed\n"; +} diff --git a/tests/widget-system/system-tools-contract.test.cjs b/tests/widget-system/system-tools-contract.test.cjs new file mode 100644 index 00000000..649579e7 --- /dev/null +++ b/tests/widget-system/system-tools-contract.test.cjs @@ -0,0 +1,229 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const root = path.resolve(__dirname, '..', '..'); +const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); + +const SYSTEM_FIELDS = [ + 'hide_admin_bar', 'remove_emoji', 'remove_open_sans', 'remove_more_wp_head', + 'remove_dashboard_widgets', 'display_wp_update', + 'split_upload_s', 'split_minimum_size', 'newfilename', 'newfilename_type', + 'disabled_large_thumbnail', + 'admin_user_del_fields', 'admin_frontend_set', 'close_gutenberg', 'disabled_autoembed', + 'mce_float_toolbar', 'time_ago_s', 'time_format', +]; + +test('system tools schema registers the 系统工具 page under 扩展&增强', () => { + const schema = read('src/Admin/Schema/SchemaExtensionsSystem.php'); + const registry = read('src/Admin/Schema/SchemaExtensions.php'); + const clientMirror = read('assets/admin/src/schema/sections/extensions/system.js'); + const clientIndex = read('assets/admin/src/schema/sections/extensions/index.js'); + const nav = read('assets/admin/src/constants/sections/extensions.js'); + const sectionDefaults = read('assets/admin/src/constants/sections/index.js'); + + assert.match(registry, /'system'\s+=> SchemaExtensionsSystem::build\(\)/); + assert.match(schema, /'key'\s+=> 'system'/); + assert.match(schema, /'title'\s+=> '系统工具'/); + for (const key of SYSTEM_FIELDS) { + assert.match(schema, new RegExp(`'${key}'`), `missing schema field ${key}`); + assert.match(clientMirror, new RegExp(`${key}`), `missing client mirror field ${key}`); + } + // Conditional fields mirror the reference theme's dependency rules. + assert.match(schema, /'newfilename_type'[^[]*'dependsOn'\s+=> \['key' => 'newfilename', 'value' => true\]/s); + assert.match(schema, /'split_minimum_size'[^[]*'dependsOn'\s+=> \['key' => 'split_upload_s', 'value' => true\]/s); + assert.match(schema, /'time_format'[^[]*'dependsOn'\s+=> \['key' => 'time_ago_s', 'operator' => 'falsy'\]/s); + assert.match(clientMirror, /split_minimum_size[\s\S]*dependsOn:\s*\{\s*key:\s*'split_upload_s',\s*value:\s*true\s*\}/); + assert.match(clientMirror, /time_format[\s\S]*dependsOn:\s*\{\s*key:\s*'time_ago_s',\s*operator:\s*'falsy'\s*\}/); + assert.match(clientIndex, /import \{ systemSchema \} from '\.\/system\.js'/); + assert.match(clientIndex, /system: systemSchema/); + assert.match(nav, /key: 'extensions-system'/); + assert.match(nav, /route: '\/extensions\/system'/); + assert.match(nav, /section: 'system'/); + // 系统工具 is the first child of 扩展&增强 (reference theme order) and the default landing page. + assert.ok(nav.indexOf('extensions-system') < nav.indexOf('extensions-ip'), '系统工具 should precede IP归属地'); + assert.match(sectionDefaults, /extensions:\s*'extensions-system'/); +}); + +test('system tools settings are consumed at runtime and initialized on boot', () => { + const runtime = read('src/Settings/ExtensionRuntime.php'); + const bootstrap = read('functions.php'); + + assert.match(runtime, /public static function get_system_tools_settings\(\): array/); + assert.match(runtime, /'newfilename_type'\s+=> \$rename_type/); + assert.match(runtime, /'split_minimum_size'\s+=> max\(4, min\(100, \(int\) \(\$settings\['split_minimum_size'\] \?\? 20\)\)\)/); + assert.match(bootstrap, /\\AKSRT\\Extensions\\SystemTools::get_instance\(\)->init\(\)/); + assert.match(bootstrap, /\\AKSRT\\Frontend\\PageAdminSet::get_instance\(\)->init\(\)/); +}); + +test('system tools switches wire the reference theme hook suite', () => { + const tools = read('src/Extensions/SystemTools.php'); + + assert.match(tools, /!empty\(\$settings\['hide_admin_bar'\]\) \|\| !is_super_admin\(\)/); + assert.match(tools, /remove_action\('wp_head', 'print_emoji_detection_script', 7\)/); + assert.match(tools, /remove_filter\('wp_mail', 'wp_staticize_emoji_for_email'\)/); + assert.match(tools, /add_action\('init', \[\$this, 'remove_open_sans'\]\)/); + assert.match(tools, /wp_deregister_style\('open-sans'\)/); + assert.match(tools, /add_action\('after_setup_theme', \[\$this, 'remove_more_wp_head'\]\)/); + assert.match(tools, /remove_action\('wp_head', 'wp_generator'\)/); + assert.match(tools, /add_action\('do_meta_boxes', \[\$this, 'remove_dashboard_widgets'\], 10, 3\)/); + assert.match(tools, /unset\(\$wp_meta_boxes\['dashboard'\]\['normal'\]\['high'\]\['dashboard_php_nag'\]\)/); + assert.match(tools, /add_filter\('site_status_tests', \[\$this, 'filter_site_status_tests'\]\)/); + assert.match(tools, /add_filter\('pre_http_request', \[\$this, 'block_site_health_requests'\], 10, 3\)/); + assert.match(tools, /'api\.wordpress\.org\/core\/serve-happy\/'/); + assert.match(tools, /remove_action\('admin_init', '_maybe_update_core'\)/); + assert.match(tools, /add_filter\('automatic_updater_disabled', '__return_true'\)/); + assert.match(tools, /remove_action\('wp_version_check', 'wp_version_check'\)/); + assert.match(tools, /add_action\('admin_menu', \[\$this, 'hide_update_notices'\]\)/); + assert.match(tools, /remove_action\('admin_notices', 'update_nag', 3\)/); + assert.match(tools, /remove_image_size\('1536x1536'\)/); + assert.match(tools, /add_filter\('intermediate_image_sizes', static function \(\$sizes\)/); + assert.match(tools, /add_filter\('use_block_editor_for_post', '__return_false'\)/); + assert.match(tools, /remove_filter\('the_content', \[\$GLOBALS\['wp_embed'\], 'autoembed'\], 8\)/); + assert.match(tools, /add_action\('show_user_profile', \[\$this, 'hide_user_profile_fields'\]\)/); + assert.match(tools, /add_action\('edit_user_profile', \[\$this, 'hide_user_profile_fields'\]\)/); +}); + +test('upload rename settings are canonical to 系统工具 and consumed by the upload pipeline', () => { + const permissionsSchema = read('src/Admin/Schema/SchemaPermissions.php'); + const permissionsMirror = read('assets/admin/src/schema/sections/permissions/index.js'); + const systemSchema = read('src/Admin/Schema/SchemaExtensionsSystem.php'); + const uploadLimits = read('src/Attachment/UploadLimits.php'); + + assert.doesNotMatch(permissionsSchema, /newfilename/); + assert.doesNotMatch(permissionsMirror, /newfilename/); + assert.match(systemSchema, /'newfilename'/); + assert.match(uploadLimits, /add_filter\('wp_handle_upload_prefilter', \[\$this, 'rename_upload_file'\]/); + assert.match(uploadLimits, /\$settings\['newfilename_type'\] \?\? 'time'\) === 'random'/); +}); + +test('split upload ships a full chunk protocol: routes, merge pipeline, and client uploader', () => { + const chunk = read('src/Core/FrontSubmitChunk.php'); + const frontSubmit = read('src/Core/FrontSubmit.php'); + const editorJs = read('assets/js/front-submit-editor.js'); + const submitPage = read('templates/pages/page-submit.php'); + const schema = read('src/Admin/Schema/SchemaExtensionsSystem.php'); + + // The submit class consumes the chunk trait and registers its routes. + assert.match(frontSubmit, /use FrontSubmitChunk;/); + assert.match(frontSubmit, /\$this->register_chunk_routes\(\)/); + assert.match(frontSubmit, /public function persist_uploaded_file\(string \$kind, array \$file, bool \$sideload = false\)/); + + // Backend mirrors the reference theme's user_split_upload protocol. + assert.match(chunk, /\/front-submit\/upload-chunk\/\(\?Pimage\|video\|resource\)/); + assert.match(chunk, /\/front-submit\/upload-merge\(\?Pimage\|video\|resource\)|\/front-submit\/upload-merge\/\(\?Pimage\|video\|resource\)/); + assert.match(chunk, /\/front-submit\/upload-status\/\(\?Pimage\|video\|resource\)/); + assert.match(chunk, /permission_callback' => \[\$this, 'can_upload_attachment'\]/); + assert.match(chunk, /const CHUNK_SIZE_BYTES = 2 \* 1048576;/); + assert.match(chunk, /md5\(get_current_blog_id\(\) \. '\|' \. get_current_user_id\(\) \. '\|' \. \$name\)/); + assert.match(chunk, /\$this->persist_uploaded_file\(\$params\['kind'\], \[/); + assert.match(chunk, /apply_filters\('aksrt_temp_dir', WP_CONTENT_DIR \. '\/aksrt-temp'\)/); + assert.match(chunk, /\$this->chunk_temp_cleanup\(\$temp_dir\)/); + + // Whole-file upload and chunk merge share one persistence pipeline; merged + // files are not HTTP uploads, so the pipeline must use the sideload action. + assert.match(frontSubmit, /return \$this->persist_uploaded_file\(\$kind, \$file, false\);/); + assert.match(frontSubmit, /'action' => 'wp_handle_sideload'/); + assert.match(frontSubmit, /media_handle_upload\('aksrt_front_submit_file', 0, \[\], \$overrides\)/); + + // Client uploader picks the chunked path past the threshold and resumes from status. + assert.match(editorJs, /function uploadInChunks\(file, type\)/); + assert.match(editorJs, /splitUploadConfig\(\)/); + assert.match(editorJs, /file\.size > split\.minSize \* 1048576/); + assert.match(editorJs, /chunkUrl\('upload-status'\)/); + assert.match(editorJs, /chunkUrl\('upload-chunk'\)/); + assert.match(editorJs, /chunkUrl\('upload-merge'\)/); + assert.match(editorJs, /'\/front-submit\/' \+ action \+ '\/' \+ uploadKey/); + + // Config flows from the settings page into aksrtSubmitData. + assert.match(submitPage, /'splitUpload' => \[/); + assert.match(submitPage, /'enabled' => !empty\(\$system_tools\['split_upload_s'\]\)/); + assert.match(submitPage, /'minSize' => \(int\) \(\$system_tools\['split_minimum_size'\] \?\? 20\)/); + assert.match(schema, /'split_upload_s'/); + assert.match(schema, /'split_minimum_size'/); + + // The legacy dead config keys stay out of the shared script payload. + assert.doesNotMatch(read('src/Core/ScriptsPart1c.php'), /is_split_upload|split_minimum_size/); +}); + +test('frontend page edit ships gate, float button, modal, and whitelisted save', () => { + const pageAdminSet = read('src/Frontend/PageAdminSet.php'); + const pageAdminJs = read('assets/js/page-admin-set.js'); + const floatButtons = read('inc/helpers/aksrt-float-buttons.php'); + const bootstrap = read('functions.php'); + + assert.match(bootstrap, /\\AKSRT\\Frontend\\PageAdminSet::get_instance\(\)->init\(\)/); + assert.match(pageAdminSet, /public static function enabled\(\): bool/); + assert.match(pageAdminSet, /empty\(\$settings\['admin_frontend_set'\]/); + assert.match(pageAdminSet, /current_user_can\('manage_options'\)/); + assert.match(pageAdminSet, /is_page_template\('templates\/pages\/page-submit\.php'\)/); + assert.match(pageAdminSet, /is_page_template\('templates\/pages\/page-posts-navs\.php'\)/); + + // Float bar gains the same extension point the reference theme hooks into. + assert.match(floatButtons, /apply_filters\('aksrt_float_right_buttons', \$btn, \$is_mobile\)/); + assert.match(pageAdminSet, /add_filter\('aksrt_float_right_buttons', \[\$this, 'float_button'\], 10, 2\)/); + assert.match(pageAdminSet, /data-aksrt-modal="remote"/); + assert.match(pageAdminSet, /check_ajax_referer\('aksrt_frontend_set', '_wpnonce'\)/); + + // Modal + save endpoints; save is whitelisted and preserves other meta keys. + assert.match(pageAdminSet, /add_action\('wp_ajax_aksrt_frontend_set_modal', \[\$this, 'ajax_modal'\]\)/); + assert.match(pageAdminSet, /add_action\('wp_ajax_aksrt_frontend_set_save', \[\$this, 'ajax_save'\]\)/); + assert.match(pageAdminSet, /\\AKSRT\\Core\\PostMetaBox::META_KEY/); + assert.match(pageAdminSet, /\\AKSRT\\Core\\DomainModel::POST_VIEW_COUNT_KEY/); + assert.match(pageAdminSet, /update_post_meta\(\$post_id, '_aksrt_like_count', max\(0, \(int\) \$_POST\['like'\]\)\)/); + assert.match(pageAdminSet, /wp_update_term\(\$term_id, \(string\) \$term->taxonomy, \$data\)/); + assert.match(pageAdminSet, /set_post_format\(\$post_id, \$format === 'standard' \? '' : \$format\)/); + + // Client submits through the shared modal runtime contract; the ajax action + // comes from the form's hidden field that the modal endpoint embeds. + assert.match(pageAdminJs, /form\.aksrt-admin-set-form/); + assert.match(pageAdminJs, /input\[name="action"\]/); + assert.match(pageAdminJs, /window\.location\.reload/); + assert.match(pageAdminSet, /'html' => \$html/); +}); + +test('float toolbar config reaches the tinymce plugin with reference-context buttons', () => { + const editorJs = read('assets/js/front-submit-editor.js'); + const submitPage = read('templates/pages/page-submit.php'); + const editorTemplate = read('templates/parts/submit/editor-main.php'); + const schema = read('src/Admin/Schema/SchemaExtensionsSystem.php'); + + assert.match(submitPage, /\$float_toolbar = wp_is_mobile\(\) \? in_array\('m_s', \$float_scenes, true\) : in_array\('pc_s', \$float_scenes, true\)/); + assert.match(submitPage, /'floatToolbar'\s+=> \$float_toolbar/); + assert.match(editorJs, /if \(editorCfg\.floatToolbar\) \{\s*\n\s*registerFloatToolbar\(editor\);/); + assert.match(editorJs, /editor\.on\('wptoolbar', function \(event\)/); + assert.match(editorJs, /editor\.wp\._createToolbar\(bars\)/); + assert.match(editorJs, /editor\.addButton\('aksrt_h2'/); + assert.match(editorJs, /editor\.addButton\('aksrt_h3'/); + assert.match(editorJs, /editor\.addButton\('aksrt_remove'/); + assert.match(editorJs, /function floatToolbarButtons\(editor, event\)/); + assert.match(schema, /'mce_float_toolbar', 'label' => '经典编辑器增强浮动工具栏', 'type' => 'checkbox'/); + // The editor template still wires the plugin into wp_editor. + assert.match(editorTemplate, /aksrt_editor_tools/); +}); + +test('countdown display switch gates aksrt_time_ago with the custom format fallback', () => { + const helper = read('inc/helpers/template-tags/search-meta.php'); + const coreHooks = read('inc/core-hooks.php'); + + assert.match(helper, /empty\(\$system\['time_ago_s'\]\) && \(\$system\['time_format'\] \?\? ''\) !== ''/); + assert.match(helper, /return date\(\$system\['time_format'\], \$time\)/); + // Admin bar gating moved out of the unconditional core hook into SystemTools. + assert.doesNotMatch(coreHooks, /add_filter\('show_admin_bar', '__return_false'\)/); +}); + +test('system tools runtime contract harness passes', () => { + const harness = path.join(root, 'tests', 'widget-system', 'system-tools-harness.php'); + const output = execFileSync('php', [harness], { encoding: 'utf8' }); + assert.match(output, /System tools harness passed/); +}); + +test('split upload runtime contract harness passes', () => { + const harness = path.join(root, 'tests', 'widget-system', 'split-upload-harness.php'); + const output = execFileSync('php', [harness], { encoding: 'utf8' }); + assert.match(output, /Split upload harness passed/); +}); diff --git a/tests/widget-system/system-tools-harness.php b/tests/widget-system/system-tools-harness.php new file mode 100644 index 00000000..0118c8f8 --- /dev/null +++ b/tests/widget-system/system-tools-harness.php @@ -0,0 +1,268 @@ + true, + 'remove_emoji' => true, + 'remove_open_sans' => true, + 'remove_more_wp_head' => true, + 'remove_dashboard_widgets' => true, + 'display_wp_update' => true, + 'split_upload_s' => true, + 'split_minimum_size' => 20, + 'newfilename' => false, + 'newfilename_type' => 'time', + 'disabled_large_thumbnail' => true, + 'admin_user_del_fields' => true, + 'admin_frontend_set' => true, + 'close_gutenberg' => false, + 'disabled_autoembed' => true, + 'mce_float_toolbar' => ['pc_s', 'm_s'], + 'time_ago_s' => true, + 'time_format' => 'n月j日 H:i', + ]; + foreach ($expected as $key => $value) { + system_tools_check(array_key_exists($key, $defaults), "system tools setting missing: {$key}"); + system_tools_check($defaults[$key] === $value, "system tools default wrong for {$key}"); + } + + // 2) Stored values sanitize: rename type whitelist, switch coercion, passthrough format. + $GLOBALS['aksrt_system_settings'] = [ + 'newfilename_type' => 'bogus', + 'newfilename' => 1, + 'time_ago_s' => 0, + 'time_format' => 'Y/m/d H:i', + 'close_gutenberg' => 'yes', + 'split_minimum_size' => 500, + 'mce_float_toolbar' => ['m_s', 'bogus', 'm_s'], + ]; + $clean = ExtensionRuntime::get_system_tools_settings(); + system_tools_check($clean['newfilename_type'] === 'time', 'bogus rename type should fall back to time'); + system_tools_check($clean['newfilename'] === true, 'truthy rename switch should coerce to true'); + system_tools_check($clean['time_ago_s'] === false, 'falsy time_ago_s should coerce to false'); + system_tools_check($clean['time_format'] === 'Y/m/d H:i', 'time format should pass through'); + system_tools_check($clean['close_gutenberg'] === true, 'truthy gutenberg switch should coerce to true'); + system_tools_check($clean['split_minimum_size'] === 100, 'split minimum size should clamp to the 4..100 range'); + system_tools_check($clean['mce_float_toolbar'] === ['m_s'], 'float toolbar scenes should whitelist and dedupe'); + + // 2b) Unsaved float toolbar key keeps the both-scenes default; explicit empty stays empty. + $GLOBALS['aksrt_system_settings'] = ['hide_admin_bar' => 0]; + $clean = ExtensionRuntime::get_system_tools_settings(); + system_tools_check($clean['mce_float_toolbar'] === ['pc_s', 'm_s'], 'float toolbar should default to pc+mobile'); + system_tools_check($clean['hide_admin_bar'] === false, 'stored falsy value should not fall back to default'); + $GLOBALS['aksrt_system_settings'] = ['mce_float_toolbar' => []]; + $clean = ExtensionRuntime::get_system_tools_settings(); + system_tools_check($clean['mce_float_toolbar'] === [], 'explicitly empty float toolbar should stay empty'); + + // 3) Hook wiring with every switch enabled. + $GLOBALS['aksrt_system_settings'] = array_fill_keys(array_keys($expected), true); + $GLOBALS['aksrt_system_settings']['time_format'] = 'n月j日 H:i'; + $GLOBALS['aksrt_system_is_super_admin'] = false; + system_tools_reset(); + SystemTools::get_instance()->init(); + + system_tools_check(system_tools_added('filter', 'show_admin_bar'), 'hide_admin_bar should gate show_admin_bar'); + system_tools_check(system_tools_added('action', 'init'), 'remove_open_sans should hook init'); + system_tools_check(system_tools_added('action', 'after_setup_theme'), 'head cleanup and thumbnail pruning should hook after_setup_theme'); + system_tools_check(system_tools_added('action', 'do_meta_boxes'), 'dashboard widget removal should hook do_meta_boxes'); + system_tools_check(system_tools_added('filter', 'site_status_tests'), 'site status tests should be filtered'); + system_tools_check(system_tools_added('filter', 'pre_http_request'), 'site health http requests should be blocked'); + system_tools_check(system_tools_added('action', 'admin_menu'), 'update notices should hook admin_menu'); + system_tools_check(system_tools_added('filter', 'automatic_updater_disabled'), 'automatic updates should be disabled'); + system_tools_check(system_tools_added('filter', 'use_block_editor_for_post'), 'close_gutenberg should gate the block editor'); + system_tools_check(system_tools_added('action', 'show_user_profile'), 'user profile cleanup should hook show_user_profile'); + system_tools_check(system_tools_added('action', 'edit_user_profile'), 'user profile cleanup should hook edit_user_profile'); + system_tools_check(system_tools_removed('action', 'admin_print_scripts', 'print_emoji_detection_script'), 'emoji scripts should be removed'); + system_tools_check(system_tools_removed('filter', 'the_content', 'autoembed'), 'auto embed should be removed from the_content'); + system_tools_check(system_tools_removed('action', 'admin_init', '_maybe_update_core'), 'core update checks should be removed'); + system_tools_check(system_tools_removed('action', 'init', 'wp_schedule_update_checks'), 'update cron schedule should be removed'); + + // 4) hide_admin_bar off keeps the admin bar for super admins. + $GLOBALS['aksrt_system_settings']['hide_admin_bar'] = false; + $GLOBALS['aksrt_system_is_super_admin'] = true; + system_tools_reset(); + SystemTools::get_instance()->init(); + system_tools_check(!system_tools_added('filter', 'show_admin_bar'), 'super admin should keep the admin bar when hide_admin_bar is off'); + + // 5) Dashboard widget removal edits the dashboard meta boxes only in the normal context. + $tools = SystemTools::get_instance(); + $GLOBALS['wp_meta_boxes'] = [ + 'dashboard' => [ + 'normal' => [ + 'high' => ['dashboard_php_nag' => ['id' => 'dashboard_php_nag']], + 'core' => ['dashboard_site_health' => ['id' => 'dashboard_site_health']], + ], + 'side' => [ + 'core' => ['dashboard_primary' => ['id' => 'dashboard_primary']], + ], + ], + ]; + $tools->remove_dashboard_widgets('dashboard', 'normal'); + system_tools_check(!isset($GLOBALS['wp_meta_boxes']['dashboard']['normal']['high']['dashboard_php_nag']), 'php nag widget should be removed'); + system_tools_check(!isset($GLOBALS['wp_meta_boxes']['dashboard']['normal']['core']['dashboard_site_health']), 'site health widget should be removed'); + system_tools_check(!isset($GLOBALS['wp_meta_boxes']['dashboard']['side']['core']['dashboard_primary']), 'primary widget should be removed'); + $GLOBALS['wp_meta_boxes']['dashboard']['side']['core']['dashboard_primary'] = ['id' => 'dashboard_primary']; + $tools->remove_dashboard_widgets('dashboard', 'side'); + system_tools_check(isset($GLOBALS['wp_meta_boxes']['dashboard']['side']['core']['dashboard_primary']), 'non-normal context should be ignored'); + + // 6) Site status tests drop page cache and php version probes. + $tests = $tools->filter_site_status_tests([ + 'async' => ['page_cache' => [], 'dilation' => []], + 'direct' => ['php_version' => [], 'updates' => []], + ]); + system_tools_check(!isset($tests['async']['page_cache']) && isset($tests['async']['dilation']), 'page_cache test should be dropped'); + system_tools_check(!isset($tests['direct']['php_version']) && isset($tests['direct']['updates']), 'php_version test should be dropped'); + + // 7) Health endpoint blocking is scoped to version/browser/credits checks. + $blocked = $tools->block_site_health_requests(null, [], 'https://api.wordpress.org/core/serve-happy/1.0/'); + system_tools_check(is_array($blocked) && $blocked['body'] === '', 'serve-happy requests should be blocked'); + $blocked = $tools->block_site_health_requests(null, [], 'https://api.wordpress.org/core/browse-happy/1.0/'); + system_tools_check(is_array($blocked) && $blocked['body'] === '', 'browse-happy requests should be blocked'); + $passed = $tools->block_site_health_requests('passthrough', [], 'https://api.wordpress.org/plugins/update-check/1.0/'); + system_tools_check($passed === 'passthrough', 'update checks should pass through the blocker'); + + // 8) Large thumbnail pruning removes the reference theme's size list. + $GLOBALS['aksrt_system_added'] = []; + $tools->disable_large_thumbnail(); + $sizes_filter = null; + foreach ($GLOBALS['aksrt_system_added'] as $row) { + if ($row[0] === 'filter' && $row[1] === 'intermediate_image_sizes') { + $sizes_filter = $row[2]; + } + } + system_tools_check(is_callable($sizes_filter), 'intermediate_image_sizes filter should be registered'); + system_tools_check($sizes_filter(['medium_large', '1536x1536', '2048x2048', 'thumbnail']) === ['thumbnail'], 'medium_large/1536x1536/2048x2048 should be pruned'); + + // 9) Schema registry exposes the 系统工具 page with all fields. + $sections = SchemaExtensions::build()['sections']; + system_tools_check(isset($sections['system']) && $sections['system']['title'] === '系统工具', 'system section should register under extensions'); + $keys = []; + foreach ($sections['system']['groups'] as $group) { + foreach ($group['fields'] as $field) { + $keys[] = $field['key']; + } + } + system_tools_check(count($keys) === count(array_unique($keys)), 'system schema field keys must be unique'); + foreach (array_keys($expected) as $key) { + system_tools_check(in_array($key, $keys, true), "system schema missing field {$key}"); + } + $client_defaults = array_keys($expected); + system_tools_check(count(SchemaExtensionsSystem::build()['groups']) === 3, 'system schema should keep three groups'); + + // 10) aksrt_time_ago honors the 倒计时显示 switch and custom format. + $past = date('Y-m-d H:i:s', strtotime('-3 days')); + $GLOBALS['aksrt_system_settings'] = []; + system_tools_check(strpos(aksrt_time_ago($past), '天前') !== false, 'relative time should be used by default'); + $GLOBALS['aksrt_system_settings'] = ['time_ago_s' => 0, 'time_format' => 'Y年n月j日']; + $stamp = strtotime('-3 days'); + system_tools_check(aksrt_time_ago(date('Y-m-d H:i:s', $stamp)) === date('Y年n月j日', $stamp), 'custom time format should apply when countdown display is off'); + $GLOBALS['aksrt_system_settings'] = ['time_ago_s' => 0, 'time_format' => '']; + system_tools_check(strpos(aksrt_time_ago($past), '天前') !== false, 'empty custom format should fall back to relative time'); + $GLOBALS['aksrt_system_settings'] = ['time_ago_s' => 1]; + system_tools_check(strpos(aksrt_time_ago($past), '天前') !== false, 'countdown display should stay relative when enabled'); + + echo "System tools harness passed\n"; +}