9fa84845be
評估 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
320 lines
9.3 KiB
PHP
320 lines
9.3 KiB
PHP
<?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;
|
||
}
|
||
}
|