d36bb954d1
Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
323 lines
10 KiB
PHP
323 lines
10 KiB
PHP
<?php
|
|
/**
|
|
* TMDO_Audit_Logger — 合規稽核用獨立 audit log 表。
|
|
*
|
|
* 訂閱 v1.2.0 起新增的 `wpdo_after_write` / `wpdo_after_delete` action,
|
|
* 把每次寫入/刪除的 who/when/what/before/after 寫進 `wp_wpdo_uni_audit`,
|
|
* 與 shadow_diffs 分離以利長期保留與合規查詢(GDPR / SOX 等)。
|
|
*
|
|
* 設計決定:
|
|
* - 同步寫入(不走 Action Scheduler)以確保 audit 先於下游動作。
|
|
* 若效能成為瓶頸,未來可改 fire-and-forget(wp_schedule_single_event)。
|
|
* - `value_before` 透過讀取 flat table 取得,若表不存在則留 null。
|
|
* - `source` 欄位以 const 列出可能的呼叫來源(rest / cli / fe-editor / internal);
|
|
* 由呼叫端透過 filter `wpdo_audit_source` 覆寫。
|
|
*
|
|
* @package WP_Data_Optimizer
|
|
* @since 1.3.0
|
|
*/
|
|
|
|
// 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_Audit_Logger {
|
|
|
|
public const TABLE_SUFFIX = 'audit';
|
|
|
|
/** 硬性 row 上限,超過時 maybe_prune 會刪除最舊的資料。 */
|
|
public const MAX_ROWS = 500_000;
|
|
|
|
/** 預設保留天數(可由 `wpdo_audit_retention_days` option 覆寫)。 */
|
|
public const DEFAULT_RETENTION_DAYS = 365;
|
|
|
|
public const OPT_RETENTION_DAYS = 'wpdo_audit_retention_days';
|
|
|
|
public const SOURCE_REST = 'rest';
|
|
public const SOURCE_CLI = 'cli';
|
|
public const SOURCE_FE_EDITOR = 'fe-editor';
|
|
public const SOURCE_INTERNAL = 'internal';
|
|
|
|
public static function table_name(): string {
|
|
global $wpdb;
|
|
return $wpdb->prefix . TMDO_TABLE_PREFIX . self::TABLE_SUFFIX;
|
|
}
|
|
|
|
public static function init(): void {
|
|
add_action( 'wpdo_after_write', array( self::class, 'on_write' ), 10, 7 );
|
|
add_action( 'wpdo_after_delete', array( self::class, 'on_delete' ), 10, 7 );
|
|
}
|
|
|
|
/**
|
|
* 寫入後訂閱 — 記錄 value_before / value_after 至 audit log。
|
|
*
|
|
* @param string $entity_type post|user|term|comment
|
|
* @param int $entity_id
|
|
* @param string $meta_key
|
|
* @param mixed $meta_value 寫入後的新值
|
|
* @param mixed $result perform_upsert 回傳值
|
|
* @param string $op 'add' | 'update'
|
|
* @param mixed $before_value 寫入前的舊值(v1.3.1+ 由 hook_bus 傳入)
|
|
*/
|
|
public static function on_write(
|
|
string $entity_type,
|
|
int $entity_id,
|
|
string $meta_key,
|
|
$meta_value,
|
|
$result,
|
|
string $op,
|
|
$before_value = null
|
|
): void {
|
|
if ( $result === false ) {
|
|
return; // 寫入失敗不記
|
|
}
|
|
|
|
$field_def = TMDO_Entity_Registry::get_field( $entity_type, $meta_key );
|
|
if ( ! $field_def ) {
|
|
return; // 非 UAE 欄位
|
|
}
|
|
|
|
self::write_row(
|
|
array(
|
|
'entity_type' => $entity_type,
|
|
'entity_id' => $entity_id,
|
|
'group_name' => (string) ( $field_def['group'] ?? '' ),
|
|
'meta_key' => $meta_key,
|
|
'action' => 'write',
|
|
'op' => $op, // 'add' | 'update'
|
|
'value_before' => self::stringify( $before_value ),
|
|
'value_after' => self::stringify( $meta_value ),
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 刪除後訂閱。
|
|
*
|
|
* @param string $entity_type
|
|
* @param int $entity_id
|
|
* @param string $meta_key
|
|
* @param mixed $meta_value WP 傳進 delete_metadata 的值(語義:匹配這個值才刪;不等於實際刪除前的 flat 值)
|
|
* @param mixed $result
|
|
* @param bool $delete_all
|
|
* @param mixed $before_value 刪除前 flat table 實際的值(v1.3.1+)
|
|
*/
|
|
public static function on_delete(
|
|
string $entity_type,
|
|
int $entity_id,
|
|
string $meta_key,
|
|
$meta_value,
|
|
$result,
|
|
bool $delete_all,
|
|
$before_value = null
|
|
): void {
|
|
if ( $result === false ) {
|
|
return;
|
|
}
|
|
|
|
$field_def = TMDO_Entity_Registry::get_field( $entity_type, $meta_key );
|
|
if ( ! $field_def ) {
|
|
return;
|
|
}
|
|
|
|
self::write_row(
|
|
array(
|
|
'entity_type' => $entity_type,
|
|
'entity_id' => $entity_id,
|
|
'group_name' => (string) ( $field_def['group'] ?? '' ),
|
|
'meta_key' => $meta_key,
|
|
'action' => 'delete',
|
|
'op' => $delete_all ? 'delete_all' : 'delete',
|
|
'value_before' => self::stringify( $before_value ),
|
|
'value_after' => null,
|
|
)
|
|
);
|
|
}
|
|
|
|
private static function write_row( array $row ): void {
|
|
global $wpdb;
|
|
|
|
$source = apply_filters(
|
|
'wpdo_audit_source',
|
|
defined( 'WP_CLI' ) && WP_CLI
|
|
? self::SOURCE_CLI
|
|
: ( defined( 'REST_REQUEST' ) && REST_REQUEST ? self::SOURCE_REST : self::SOURCE_INTERNAL )
|
|
);
|
|
|
|
// wpdb::insert 以欄位順序對應 format — 我們用 null 讓 wpdb 自己根據值型別推斷,
|
|
// 避免 format array 長度與 row key 數不對稱(v1.5.1 加 op 後更容易出錯)。
|
|
$wpdb->insert(
|
|
self::table_name(),
|
|
array_merge(
|
|
$row,
|
|
array(
|
|
'ts' => current_time( 'mysql', true ),
|
|
'user_id' => get_current_user_id(),
|
|
'source' => (string) $source,
|
|
'trace_id' => TMDO_Logger::trace_id(),
|
|
)
|
|
)
|
|
);
|
|
|
|
// 1% 機率觸發 prune — 類似 shadow_diff_logger::maybe_trim,
|
|
// 避免每次寫都查 count。
|
|
if ( wp_rand( 1, 100 ) === 1 ) {
|
|
self::maybe_prune();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 依 MAX_ROWS 與 retention_days 裁剪 audit 表。
|
|
*
|
|
* 先按時間刪除過期資料,再看是否超過 MAX_ROWS,超過則刪除最舊。
|
|
* 回傳總共刪除的 row 數(供 CLI 呈現)。
|
|
*
|
|
* @since 1.3.2
|
|
*/
|
|
public static function maybe_prune(): int {
|
|
global $wpdb;
|
|
$table = self::table_name();
|
|
$total = 0;
|
|
|
|
$retention_days = (int) get_option( self::OPT_RETENTION_DAYS, self::DEFAULT_RETENTION_DAYS );
|
|
if ( $retention_days > 0 ) {
|
|
$cutoff = gmdate( 'Y-m-d H:i:s', time() - $retention_days * DAY_IN_SECONDS );
|
|
$deleted = (int) $wpdb->query(
|
|
$wpdb->prepare(
|
|
"DELETE FROM `{$table}` WHERE ts < %s",
|
|
$cutoff
|
|
)
|
|
);
|
|
$total += $deleted;
|
|
|
|
if ( $deleted > 0 ) {
|
|
TMDO_Logger::info(
|
|
'audit_prune_expired',
|
|
array(
|
|
'deleted' => $deleted,
|
|
'cutoff' => $cutoff,
|
|
'days' => $retention_days,
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
|
|
if ( $count > self::MAX_ROWS ) {
|
|
$to_delete = $count - self::MAX_ROWS;
|
|
$deleted = (int) $wpdb->query(
|
|
$wpdb->prepare(
|
|
"DELETE FROM `{$table}` ORDER BY id ASC LIMIT %d",
|
|
$to_delete
|
|
)
|
|
);
|
|
$total += $deleted;
|
|
|
|
TMDO_Logger::warning(
|
|
'audit_prune_overflow',
|
|
array(
|
|
'deleted' => $deleted,
|
|
'count_before' => $count,
|
|
'max_rows' => self::MAX_ROWS,
|
|
)
|
|
);
|
|
}
|
|
|
|
return $total;
|
|
}
|
|
|
|
private static function stringify( $value ): ?string {
|
|
if ( $value === null ) {
|
|
return null;
|
|
}
|
|
if ( is_scalar( $value ) ) {
|
|
return (string) $value;
|
|
}
|
|
return (string) wp_json_encode( $value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// Schema
|
|
// ─────────────────────────────────────────────────────────
|
|
|
|
public static function install_table(): void {
|
|
global $wpdb;
|
|
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
|
|
|
$table = self::table_name();
|
|
$charset = $wpdb->get_charset_collate();
|
|
|
|
// `action` 在 MySQL 部分版本為 reserved keyword — 用 backtick 保險。
|
|
// v1.5.1 新增 `op` 欄位:細分 WordPress 內部觸發路徑(add / update / delete),
|
|
// 用來區分「add_metadata 與 update_metadata 被同時觸發」的雙 row 情境。
|
|
$sql = "CREATE TABLE {$table} (
|
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
`ts` DATETIME NOT NULL,
|
|
`user_id` BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
|
`entity_type` VARCHAR(20) NOT NULL,
|
|
`entity_id` BIGINT UNSIGNED NOT NULL,
|
|
`group_name` VARCHAR(64) NOT NULL,
|
|
`meta_key` VARCHAR(255) NOT NULL,
|
|
`action` VARCHAR(10) NOT NULL,
|
|
`op` VARCHAR(10) NOT NULL DEFAULT 'update',
|
|
`value_before` LONGTEXT NULL,
|
|
`value_after` LONGTEXT NULL,
|
|
`source` VARCHAR(20) NULL,
|
|
`trace_id` CHAR(36) NULL,
|
|
PRIMARY KEY (`id`),
|
|
KEY `idx_entity` (`entity_type`, `entity_id`),
|
|
KEY `idx_ts` (`ts`),
|
|
KEY `idx_user` (`user_id`, `ts`),
|
|
KEY `idx_op` (`op`, `ts`)
|
|
) {$charset};";
|
|
|
|
dbDelta( $sql );
|
|
}
|
|
|
|
public static function drop_table(): void {
|
|
global $wpdb;
|
|
$wpdb->query( 'DROP TABLE IF EXISTS ' . self::table_name() );
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// Read API(供 admin / CLI 查詢)
|
|
// ─────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* 取得最近 N 筆 audit 紀錄。
|
|
*
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public static function recent( int $limit = 100, ?string $entity_type = null ): array {
|
|
global $wpdb;
|
|
$table = self::table_name();
|
|
|
|
if ( $entity_type ) {
|
|
$rows = $wpdb->get_results(
|
|
$wpdb->prepare(
|
|
"SELECT * FROM `{$table}` WHERE entity_type = %s ORDER BY id DESC LIMIT %d",
|
|
$entity_type,
|
|
$limit
|
|
),
|
|
ARRAY_A
|
|
);
|
|
} else {
|
|
$rows = $wpdb->get_results(
|
|
$wpdb->prepare(
|
|
"SELECT * FROM `{$table}` ORDER BY id DESC LIMIT %d",
|
|
$limit
|
|
),
|
|
ARRAY_A
|
|
);
|
|
}
|
|
|
|
return is_array( $rows ) ? $rows : array();
|
|
}
|
|
|
|
public static function count(): int {
|
|
global $wpdb;
|
|
return (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::table_name() . '`' );
|
|
}
|
|
}
|