Files
2meet-data-optimizer/modules/options/class-tmdo-options-manager.php
wpdev 9fa84845be fix: NinjaFirewall 相容性 — 保護 WAF 設定選項不被重導向
評估 NinjaFirewall (WP Edition) 4.9 的相容性,結論是兩者可共存且
不需要開發 AddOn(該外掛全 codebase 零個 apply_filters('nfw_*') /
do_action('nfw_*'),官方相容手段全在部署層)。但核心有一個隱患必須補。

TMDO_Options_Manager::register_settings_group() 以 pre_update_option_{key}
回傳 $old_value,讓選項不再落地 wp_options、改存專屬設定表。而
NinjaFirewall 的 Full WAF 走 auto_prepend_file,在 WordPress 載入前就以
原生 mysqli 直查 wp_options 取 nfw_options / nfw_rules。一旦這些鍵被
重導向,WAF 會讀不到設定而靜默停止防護 —— 不報錯、不寫 log。

nfw_rules 約 77KB 且 autoload=auto,正是 autoload 瘦身最誘人的目標,
因此這條路徑並非理論風險。

Added
- PROTECTED_OPTIONS 常數與註冊守衛(nfw_options / nfw_rules / nfw_checked),
  命中時發出 _doing_it_wrong()。守衛置於方法開頭,全部鍵都被擋時提前返回,
  不再建立空的設定表。
- tests/unit/OptionsManagerProtectedTest.php(4 tests / 10 assertions),
  鎖住「受保護鍵絕不會被掛上 pre_option_* / pre_update_option_* 攔截」。
- docs/WAF-COMPATIBILITY.md:模式差異、symlink 多租戶部署、WP SaaS 開站
  流程與驗證清單、三條開發約束、實測風險矩陣。

Changed
- Migration Wizard 輪詢 500ms → 2s,與四個 stress-test 面板一致。
  原本 2 req/s 打同一 REST endpoint,易觸發 WAF rate-limit 與 bot 偵測。

autoload 最佳化不受影響:optimize_autoload() 只改 autoload 欄位、不刪列,
而 Full WAF 的 SELECT * 不看 autoload。

驗證:591 tests / 1166 assertions 通過,PHPCS 零違規,版本一致性 1.0.2。
dev30 於 Full WAF 與 WP WAF 兩種模式下實測,firewall log 中 TMDO 相關
攔截 0 筆。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdjXAU473eekjPBB8vPVRS
2026-08-15 20:38:09 +08:00

320 lines
9.3 KiB
PHP
Raw Permalink 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
/**
* TMDO_Options_Manager - wp_options 反 EAV 模組
*
* 功能:
* - autoload 最佳化(掃描並 defer 過大的 autoload 項目)
* - 選項重導向(將高頻選項搬至專屬設定表)
* - 快取攔截
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
defined( 'ABSPATH' ) || exit;
final class TMDO_Options_Manager {
/** 管理中的選項設定 */
private static array $redirected_options = array();
/** 已知可安全 defer autoload 的選項名稱清單 */
private const KNOWN_DEFERRABLE_OPTIONS = array(
// 大型設定類(不需頁面載入即時讀取)
'wpseo_titles',
'wpseo_social',
'wpseo_internallinks',
'wpseo_xml',
'wpseo_flush_rewrite',
// WooCommerce 管理端設定
'woocommerce_tax_classes',
'woocommerce_shipping_debug_mode',
'woocommerce_schema_version',
// 其他常見巨型選項
'rewrite_rules',
'_transient_doing_cron',
'cron',
// 備份/匯出類
'updraft_backup_history',
'wpb_backup_options',
);
/**
* 禁止重導向的選項名稱清單
*
* 這些選項會被「WordPress 載入之前」執行的元件以原生 SQL 直查 wp_options
* NinjaFirewall Full WAF 走 auto_prepend_file,以 mysqli 讀取 nfw_options
* 與 nfw_rules)。一旦被 register_settings_group() 重導向至專屬設定表,
* 該元件會讀不到設定而靜默停止運作 —— 不報錯、不寫 log。
*
* 註:autoload 最佳化不受此限,因為那些元件的 SELECT 不看 autoload 欄位。
*/
private const PROTECTED_OPTIONS = array(
'nfw_options',
'nfw_rules',
'nfw_checked',
);
/** autoload 合計閾值(MB),超過則警告 */
private const AUTOLOAD_WARNING_THRESHOLD_MB = 1;
/** 單一選項大小閾值(bytes) */
private const SINGLE_OPTION_SIZE_THRESHOLD = 51200; // 50KB
public static function init(): void {
// 提供第三方登錄 API
/**
* 讓第三方外掛註冊要管理的選項
* add_action('wpdo_register_option_groups', function() {...});
*/
do_action( 'wpdo_register_option_groups' );
}
// ─────────────────────────────────────────────────────────
// Autoload 分析與最佳化
// ─────────────────────────────────────────────────────────
/**
* 分析當前 autoload 狀況
*/
public static function analyze_autoload(): array {
global $wpdb;
// 總體 autoload 大小
$total = $wpdb->get_row(
"
SELECT
COUNT(*) as cnt,
SUM(LENGTH(option_value)) as total_bytes
FROM {$wpdb->options}
WHERE autoload = 'yes'
",
ARRAY_A
);
// 最大的 50 個 autoload 選項
$largest = $wpdb->get_results(
"
SELECT
option_name,
LENGTH(option_value) AS size_bytes,
autoload
FROM {$wpdb->options}
WHERE autoload = 'yes'
ORDER BY size_bytes DESC
LIMIT 50
",
ARRAY_A
);
// 可 defer 的候選項
$deferrable_candidates = array();
foreach ( $largest as $row ) {
if ( (int) $row['size_bytes'] > self::SINGLE_OPTION_SIZE_THRESHOLD ||
in_array( $row['option_name'], self::KNOWN_DEFERRABLE_OPTIONS, true )
) {
$deferrable_candidates[] = $row;
}
}
return array(
'total_count' => (int) $total['cnt'],
'total_bytes' => (int) $total['total_bytes'],
'total_mb' => round( $total['total_bytes'] / 1024 / 1024, 2 ),
'warning' => (int) $total['total_bytes'] > self::AUTOLOAD_WARNING_THRESHOLD_MB * 1024 * 1024,
'largest' => $largest,
'deferrable' => $deferrable_candidates,
'estimated_save_mb' => round(
array_sum( array_column( $deferrable_candidates, 'size_bytes' ) ) / 1024 / 1024,
2
),
);
}
/**
* 執行 autoload 最佳化
*
* @param array $options_to_defer 要設為 autoload=no 的選項名稱陣列
* 若為空,使用內建安全清單
* @param bool $dry_run
*/
public static function optimize_autoload( array $options_to_defer = array(), bool $dry_run = false ): array {
global $wpdb;
if ( empty( $options_to_defer ) ) {
$options_to_defer = self::KNOWN_DEFERRABLE_OPTIONS;
}
$result = array(
'dry_run' => $dry_run,
'processed' => 0,
'saved_bytes' => 0,
'details' => array(),
);
foreach ( $options_to_defer as $option_name ) {
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT option_id, LENGTH(option_value) AS size_bytes, autoload
FROM {$wpdb->options}
WHERE option_name = %s",
$option_name
),
ARRAY_A
);
if ( ! $row ) {
continue;
}
if ( $row['autoload'] === 'no' ) {
continue;
}
if ( ! $dry_run ) {
$wpdb->update(
$wpdb->options,
array( 'autoload' => 'no' ),
array( 'option_name' => $option_name ),
array( '%s' ),
array( '%s' )
);
// 清除該選項的快取(下次讀取時會重建)
wp_cache_delete( $option_name, 'options' );
wp_cache_delete( 'alloptions', 'options' );
}
++$result['processed'];
$result['saved_bytes'] += (int) $row['size_bytes'];
$result['details'][] = array(
'option_name' => $option_name,
'saved_bytes' => (int) $row['size_bytes'],
);
}
$result['saved_mb'] = round( $result['saved_bytes'] / 1024 / 1024, 2 );
return $result;
}
// ─────────────────────────────────────────────────────────
// 選項群組重導向
// ─────────────────────────────────────────────────────────
/**
* 將一組選項重導向至專屬設定表
*
* @param string $group_name 設定群組名
* @param array $option_keys 要管理的 option_name 清單
*/
public static function register_settings_group( string $group_name, array $option_keys ): void {
global $wpdb;
// 受保護的選項不得重導向,否則會讓 WordPress 載入前讀取它的元件靜默失效
foreach ( array_intersect( $option_keys, self::PROTECTED_OPTIONS ) as $protected_key ) {
_doing_it_wrong(
__METHOD__,
esc_html( "選項 {$protected_key} 由 WordPress 載入前的元件以原生 SQL 直讀 wp_options,重導向會使其靜默失效,已略過。" ),
'1.0.2'
);
}
$option_keys = array_diff( $option_keys, self::PROTECTED_OPTIONS );
// 全部都被擋下就不必建表
if ( empty( $option_keys ) ) {
return;
}
$table = $wpdb->prefix . TMDO_TABLE_PREFIX . 'settings_' . sanitize_key( $group_name );
// 建立設定專屬表
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
id INT NOT NULL AUTO_INCREMENT,
setting_key VARCHAR(191) NOT NULL,
setting_value LONGTEXT,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_key (setting_key)
) {$charset};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
// 註冊為管理中選項
foreach ( $option_keys as $key ) {
self::$redirected_options[ $key ] = array(
'group' => $group_name,
'table' => $table,
);
// 攔截讀取
add_filter(
"pre_option_{$key}",
function ( $value ) use ( $key, $table ) {
return self::read_setting( $table, $key, $value );
},
10,
1
);
// 攔截寫入:寫入 UAE 表,並回傳 $old_value 使 WP 跳過寫 wp_options
add_filter(
"pre_update_option_{$key}",
function ( $value, $old_value ) use ( $key, $table ) {
self::write_setting( $table, $key, $value );
// 回傳 $old_value 會讓 update_option() 判定「值未變動」進而跳過 wp_options 寫入
return $old_value;
},
10,
2
);
}
}
private static function read_setting( string $table, string $key, $default ) {
global $wpdb;
$value = $wpdb->get_var(
$wpdb->prepare(
"SELECT setting_value FROM `{$table}` WHERE setting_key = %s",
$key
)
);
if ( $value === null ) {
return $default;
}
// v2.13.3: object-injection-safe unserialize (fixes L-DESER-1).
$decoded = TMDO_Safe_Unserialize::run( $value );
return $decoded;
}
private static function write_setting( string $table, string $key, $value ): void {
global $wpdb;
$wpdb->replace(
$table,
array(
'setting_key' => $key,
'setting_value' => maybe_serialize( $value ),
),
array( '%s', '%s' )
);
}
public static function get_redirected_options(): array {
return self::$redirected_options;
}
}