Files
wordpress-kratos-yang/inc/options-framework/includes/class-options-framework-admin.php
T
laoyang 4beb09307c 3.2.17: 修复「保存设置时静默清空未提交的字段」
现象
在主题设置里填了「附加日期」(例如 10-23),过一阵发现它变回空了,
于是当天的哀悼效果不再出现,而其他设置都好好的,看不出哪里坏了。

根因
inc/options-framework/includes/class-options-framework-admin.php 的
validate_options() 里,对每个选项无条件取 $input[$id] 去 sanitize:

    $clean[$id] = apply_filters('of_sanitize_'.$type, $input[$id], $option);

一旦某次提交里没有这个键(分组没渲染、字段被条件隐藏、表单中途出错只
提交了一半……),$input[$id] 是未定义索引 ⇒ null ⇒ sanitize_text_field()
返回空串 ⇒ 把用户存了很久的配置写成空。而且"清成空串"和"用户主动清空"
在库里完全一样,事后无法区分,只表现为"我明明填过,怎么没了"。

实测证据(线上 PHP 8.5.12,同一套用例)
  修复前:3 通过 / 2 失败,失败项同时伴随
          PHP Warning: Undefined array key "g_rip_extra" ... line 252
  修复后:5 通过 / 0 失败

改法
- 循环外先取一次 get_option('kratos') 作 $saved_options(不在循环里查库)
- 提交了(array_key_exists($id,$input))才 sanitize
- 没提交则沿用 $saved_options 里的旧值;旧值也没有就不写这个键,
  让运行时的默认值继续生效
- checkbox / multicheck 保持原语义:这两类"没提交"确实等于"没勾",
  沿用旧值会导致开关关不掉。用 $is_toggle 单独判定,与"沿用旧值"互斥

为什么这个改动值得单独发一版
它不是界面问题,是**数据被静默改写**。凡是这种"用户没动、值却变了"的
缺陷,都会让人怀疑数据不可靠;而且每次后台保存都可能再触发一次。

验证
- 线上 _test_keep_old_value.php:5/5(修复前 3/5,用例 2 精确复现空串)
- 本地 _static_317.php:8/8,第 4 组自带注入(改成老写法必须变红)
- 全量流水线 verify_all.sh:rc=0
- 线上浏览器 _live315.py:纪念日分支 33/33、非纪念日分支 27/27
  (判据改为「由配置侧独立算 expect_show,再与页面渲染对比」,
   不再无条件断言"必须有灰度",也不再拿页面自身当预期值)
2026-10-23 23:18:51 +08:00

409 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* @package Options_Framework
* @author Devin Price <devin@wptheming.com>
* @license GPL-2.0+
* @link http://wptheming.com
* @copyright 2010-2014 WP Theming
*/
class Options_Framework_Admin {
/**
* Page hook for the options screen
*
* @since 1.7.0
* @type string
*/
protected $options_screen = null;
/**
* Hook in the scripts and styles
*
* @since 1.7.0
*/
public function init() {
// Gets options to load
$options = & Options_Framework::_optionsframework_options();
// Checks if options are available
if ( $options ) {
// Add the options page and menu item.
add_action('admin_menu', array($this, 'add_top_options_page'));
add_action('admin_menu', array($this, 'add_sub_options_page'));
// Add the required scripts and styles
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_styles' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_scripts' ) );
// Settings need to be registered after admin_init
add_action( 'admin_init', array( $this, 'settings_init' ) );
}
}
/**
* Registers the settings
*
* @since 1.7.0
*/
function settings_init() {
// Get the option name
$options_framework = new Options_Framework;
$name = $options_framework->get_option_name();
// Registers the settings fields and callback
register_setting( 'optionsframework', $name, array ( $this, 'validate_options' ) );
// Displays notice after options save
add_action( 'optionsframework_after_validate', array( $this, 'save_options_notice' ) );
add_action( 'optionsframework_after_sendmail', array( $this, 'send_mail_notice' ) );
}
public function add_top_options_page()
{
add_menu_page(
__('主题设置', 'kratos'),
__('主题设置', 'kratos'),
'manage_options',
'kratos_options',
'',
'dashicons-admin-generic',
99
);
}
public static function menu_settings()
{
$menu = array(
'parent_slug' => 'kratos_options',
'page_title' => __('主题设置', 'kratos'),
'menu_title' => __('主题设置', 'kratos'),
'capability' => 'manage_options',
'menu_slug' => 'kratos_options',
);
return $menu;
}
public function add_sub_options_page()
{
$menu = $this->menu_settings();
$this->options_screen = add_submenu_page(
$menu['parent_slug'],
$menu['page_title'],
$menu['menu_title'],
$menu['capability'],
$menu['menu_slug'],
array($this, 'options_page')
);
}
/**
* Loads the required stylesheets
*
* @since 1.7.0
*/
function enqueue_admin_styles( $hook ) {
if ( $this->options_screen != $hook )
return;
wp_enqueue_style( 'optionsframework', get_stylesheet_directory_uri() . '/inc/options-framework/css/optionsframework.css', array(), Options_Framework::VERSION );
wp_enqueue_style( 'wp-color-picker' );
}
/**
* Loads the required javascript
*
* @since 1.7.0
*/
function enqueue_admin_scripts( $hook ) {
if ( $this->options_screen != $hook )
return;
// Enqueue custom option panel JS
wp_enqueue_script(
'options-custom',
get_stylesheet_directory_uri() . '/inc/options-framework/js/options-custom.js',
array( 'jquery','wp-color-picker' ),
Options_Framework::VERSION
);
// Inline scripts from options-interface.php
add_action( 'admin_head', array( $this, 'of_admin_head' ) );
}
function of_admin_head() {
// Hook to add custom scripts
do_action( 'optionsframework_custom_scripts' );
}
/**
* Builds out the options panel.
*
* If we were using the Settings API as it was intended we would use
* do_settings_sections here. But as we don't want the settings wrapped in a table,
* we'll call our own custom optionsframework_fields. See options-interface.php
* for specifics on how each individual field is generated.
*
* Nonces are provided using the settings_fields()
*
* @since 1.7.0
*/
function options_page() { ?>
<div id="optionsframework-wrap" class="wrap">
<?php $menu = $this->menu_settings(); ?>
<h2><?php echo esc_html( $menu['page_title'] ); ?></h2>
<h2 class="nav-tab-wrapper">
<?php echo Options_Framework_Interface::optionsframework_tabs(); ?>
</h2>
<?php settings_errors( 'options-framework' ); ?>
<div id="optionsframework-metabox" class="metabox-holder">
<div id="optionsframework" class="postbox">
<form action="options.php" method="post">
<?php settings_fields( 'optionsframework' ); ?>
<?php Options_Framework_Interface::optionsframework_fields(); /* Settings */ ?>
<div id="optionsframework-submit">
<input type="submit" class="button-primary" name="update" value="<?php esc_attr_e( '保存配置', 'kratos' ); ?>" />
<input type="submit" class="reset-button button-secondary" name="reset" value="<?php esc_attr_e( '恢复默认', 'kratos' ); ?>" onclick="return confirm( '<?php print esc_js( __( '单击「确定」进行恢复,但所有的配置都将丢失!', 'kratos' ) ); ?>' );" />
<div class="clear"></div>
</div>
</form>
</div> <!-- / #container -->
</div>
<?php do_action( 'optionsframework_after' ); ?>
</div> <!-- / .wrap -->
<?php
}
/**
* Validate Options.
*
* This runs after the submit/reset button has been clicked and
* validates the inputs.
*
* @uses $_POST['reset'] to restore default options
*/
function validate_options( $input ) {
/*
* Restore Defaults.
*
* In the event that the user clicked the "Restore Defaults"
* button, the options defined in the theme's options.php
* file will be added to the option for the active theme.
*/
if ( isset( $_POST['reset'] ) ) {
add_settings_error( 'options-framework', 'restore_defaults', __( '恢复完成', 'kratos' ), 'updated fade' );
return $this->get_default_values();
}
/*
* Update Settings
*
* This used to check for $_POST['update'], but has been updated
* to be compatible with the theme customizer introduced in WordPress 3.4
*/
$clean = array();
$options = & Options_Framework::_optionsframework_options();
// 已保存的值,供"字段未提交时沿用旧值"用。放循环外,避免重复查库。
$saved_options = get_option( 'kratos' );
if ( ! is_array( $saved_options ) ) {
$saved_options = array();
}
foreach ( $options as $option ) {
if ( ! isset( $option['id'] ) ) {
continue;
}
if ( ! isset( $option['type'] ) ) {
continue;
}
$id = preg_replace( '/[^a-zA-Z0-9._\-]/', '', strtolower( $option['id'] ) );
// Set checkbox to false if it wasn't sent in the $_POST
if ( 'checkbox' == $option['type'] && ! isset( $input[$id] ) ) {
$input[$id] = false;
}
// Set each item in the multicheck to false if it wasn't sent in the $_POST
if ( 'multicheck' == $option['type'] && ! isset( $input[$id] ) ) {
foreach ( $option['options'] as $key => $value ) {
$input[$id][$key] = false;
}
}
// For a value to be submitted to database it must pass through a sanitization filter
if ( has_filter( 'of_sanitize_' . $option['type'] ) ) {
/*
* ⚠️ 表单里没有这个键时,**必须保留库里已有的值**,不能写空。
*
* 原来这里直接写 `$input[$id]`。如果该字段没被提交(比如某个
* 分组没渲染出来、或者字段被条件隐藏、或者表单中途出错只提交了一半),
* `$input[$id]` 是未定义索引 ⇒ 取到 null ⇒
* `sanitize_text_field(null)` 返回 `''` ⇒ **把用户存了很久的
* 配置静默清成空串**。而"清成空串"和"用户主动清空"在库里长得一模一样,
* 事后完全无法区分,只会表现为"我明明填过,怎么没了"。
*
* 例外:checkbox 和 multicheck 上面已经把缺省项补成了 false,
* 这两类的"没提交"在语义上确实等于"没勾",保持原样。
*/
$is_toggle = in_array( $option['type'], array( 'checkbox', 'multicheck' ), true );
if ( array_key_exists( $id, $input ) ) {
$clean[$id] = apply_filters( 'of_sanitize_' . $option['type'], $input[$id], $option );
} elseif ( $is_toggle ) {
$clean[$id] = apply_filters( 'of_sanitize_' . $option['type'], $input[$id], $option );
} else {
// 没提交 ⇒ 沿用旧值(旧值也没有就不写这个键,让运行时默认值生效)
if ( array_key_exists( $id, $saved_options ) ) {
$clean[$id] = $saved_options[$id];
}
}
}
}
if ( isset( $_POST['sendmail'] ) ) {
/*
* 测试发信要让「这一批新配置」立刻生效,但**绝对不能**
* 在这里调 update_option()。
*
* ── 为什么不调(3.2.11 的血案)──────────────────────────
* 本函数是 register_setting() 注册的 sanitize_callback,
* WP 把挂在 `sanitize_option_kratos` 这个过滤器上
* (wp-includes/option.php:3073)。而 update_option() 内部
* **会再跑一遍**同名过滤器 ⇒ 又调回本函数 ⇒ 本函数再
* update_option() ⇒ 无限递归。
*
* 递归每层都在复制 $clean 和 $_POST 派生的大数组,最终把
* 内存吃到上限,报出来的是
* 「Allowed memory size of 268435456 bytes exhausted
* in class-wp-block-parser.php」。报错落在区块解析器上纯属
* 巧合 —— 那只是递归链最后执行到的一行,看它会完全找错方向。
*
* 正确做法:不写库、不递归,只把新值放进一个一次性覆盖层,
* 让 kratos_option() 在本次请求里优先读它。写完库由 WP 自己
* 在 validate_options 返回后完成。
*/
$GLOBALS['__yang_options_override'] = is_array( $clean ) ? $clean : array();
$this->yang_send_test_mail( $clean );
do_action( 'optionsframework_after_sendmail', $clean );
return $clean;
} else {
do_action( 'optionsframework_after_validate', $clean );
return $clean;
}
}
/**
* 发测试邮件,走主题模板
*
* 原来这里是一句裸 wp_mail(),正文只有一行纯文本 —— 点「测试邮件」
* 收到的样子和真实评论通知(HTML 模板)完全不同,等于没法用它验收版式。
* 主题里早有 yang_mail_send_test() 专门干这件事,但一直没接线,
* 所以优先调它;主题被换掉或不完全时退回原样,不能因此白屏。
*/
private function yang_send_test_mail( $clean )
{
$to = get_bloginfo( 'admin_email' );
if ( function_exists( 'yang_mail_send_test' ) ) {
$r = yang_mail_send_test( $to );
$GLOBALS['__yang_sendmail_result'] = $r;
return;
}
wp_mail( $to, __( '[测试]邮件服务配置成功', 'kratos' ), __( '恭喜您 SMTP 邮件服务配置成功!', 'kratos' ) );
$GLOBALS['__yang_sendmail_result'] = null;
}
/**
* Display message when options have been saved
*/
function save_options_notice() {
add_settings_error( 'options-framework', 'save_options', __( '保存成功', 'kratos' ), 'updated fade' );
}
/**
* 测试邮件的提示
*
* 原来不管发没发出去都显示「发送完成」—— SMTP 密码填错时用户也看到
* "成功",然后去邮箱里干等。现在按真实返回值分三态:成功 / 失败 / 地址不合法。
*/
function send_mail_notice() {
$to = get_bloginfo( 'admin_email' );
$r = isset( $GLOBALS['__yang_sendmail_result'] ) ? $GLOBALS['__yang_sendmail_result'] : null;
if ( is_wp_error( $r ) ) {
add_settings_error(
'options-framework', 'send_mail',
sprintf( __( '测试邮件未发出:%s', 'kratos' ), $r->get_error_message() ),
'error fade'
);
return;
}
if ( $r === false ) {
add_settings_error(
'options-framework', 'send_mail',
__( '测试邮件发送失败,请检查服务器地址、端口、加密方式与密码。', 'kratos' ),
'error fade'
);
return;
}
add_settings_error( 'options-framework', 'send_mail', sprintf( __( '测试邮件已发出,请查收:%s(若没收到请先看垃圾箱)', 'kratos' ), $to ), 'updated fade' );
}
/**
* Get the default values for all the theme options
*
* Get an array of all default values as set in
* options.php. The 'id','std' and 'type' keys need
* to be defined in the configuration array. In the
* event that these keys are not present the option
* will not be included in this function's output.
*
* @return array Re-keyed options configuration array.
*
*/
function get_default_values() {
$output = array();
$config = & Options_Framework::_optionsframework_options();
foreach ( (array) $config as $option ) {
if ( ! isset( $option['id'] ) ) {
continue;
}
if ( ! isset( $option['std'] ) ) {
continue;
}
if ( ! isset( $option['type'] ) ) {
continue;
}
if ( has_filter( 'of_sanitize_' . $option['type'] ) ) {
$output[$option['id']] = apply_filters( 'of_sanitize_' . $option['type'], $option['std'], $option );
}
}
return $output;
}
}