Files
wordpress-theme-aksrt/src/AKSRT/Extensions/APIAudit.php
T
kate522 aaf458af1c refactor(autoload): migrate theme classes to Composer PSR-4
Move 288 types from global AKSRT_* + inc/classes manual requires into
namespace AKSRT\{Domain} under src/AKSRT/, wire composer.json psr-4,
and slim bootstrap to helpers/modules/hooks only. Split multi-class
widget/gateway files, update admin contract audit paths, and document
the layout in ADR/README.
2026-07-17 13:29:22 +08:00

249 lines
7.5 KiB
PHP

<?php
/**
* @package AKSRT
*/
namespace AKSRT\Extensions;
defined('ABSPATH') || exit;
class APIAudit {
private static $instance = null;
private static $table_name = '';
private function __construct() {}
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
global $wpdb;
self::$table_name = $wpdb->prefix . 'aksrt_api_audit';
}
return self::$instance;
}
public function init(): void {
add_action('rest_api_init', [$this, 'register_hooks']);
add_action('aksrt_db_migrate', [$this, 'maybe_create_table']);
}
/**
* Check if audit logging is enabled
*/
private function is_enabled(): bool {
if (!class_exists('\AKSRT\Settings\Settings')) return false;
return (bool) \AKSRT\Settings\Settings::get_value('api_audit_enabled', false);
}
/**
* Register REST pre-dispatch hook to log requests
*/
public function register_hooks(): void {
if (!$this->is_enabled()) return;
// Log after response to capture status code
add_filter('rest_post_dispatch', [$this, 'log_request'], 10, 3);
}
/**
* Log a REST API request
*/
public function log_request($result, $server, $request) {
$route = $request->get_route();
$method = $request->get_method();
// Only log write operations (POST, PUT, DELETE, PATCH)
if (!in_array($method, ['POST', 'PUT', 'DELETE', 'PATCH'], true)) {
return $result;
}
// Skip non-aksrt routes
if (strpos($route, '/aksrt/') === false) {
return $result;
}
// Skip health checks and non-sensitive routes
$skip_patterns = ['/health', '/settings/export', '/settings/import'];
foreach ($skip_patterns as $pattern) {
if (strpos($route, $pattern) !== false) {
return $result;
}
}
$user_id = get_current_user_id();
$ip = $this->get_client_ip();
$status = $result instanceof \WP_REST_Response ? $result->get_status() : 200;
$this->insert_log([
'user_id' => $user_id,
'method' => $method,
'route' => $route,
'ip' => $ip,
'status' => $status,
'user_agent' => substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255),
]);
return $result;
}
/**
* Manually log an event
*/
public static function log(string $action, array $context = [], int $user_id = 0): void {
$instance = self::get_instance();
if (!$instance->is_enabled()) return;
$instance->insert_log([
'user_id' => $user_id ?: get_current_user_id(),
'method' => 'ACTION',
'route' => $action,
'ip' => $instance->get_client_ip(),
'status' => 200,
'user_agent' => substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255),
'context' => wp_json_encode($context, JSON_UNESCAPED_UNICODE),
]);
}
/**
* Insert a log entry
*/
private function insert_log(array $data): void {
global $wpdb;
$wpdb->insert(self::$table_name, [
'user_id' => $data['user_id'] ?? 0,
'method' => $data['method'] ?? '',
'route' => $data['route'] ?? '',
'ip' => $data['ip'] ?? '',
'status' => $data['status'] ?? 0,
'user_agent' => $data['user_agent'] ?? '',
'context' => $data['context'] ?? '',
'created_at' => current_time('mysql', true),
], ['%d', '%s', '%s', '%s', '%d', '%s', '%s', '%s']);
}
/**
* Get client IP
*/
private function get_client_ip(): string {
$ip = '';
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$parts = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
$ip = trim($parts[0]);
} elseif (!empty($_SERVER['HTTP_X_REAL_IP'])) {
$ip = $_SERVER['HTTP_X_REAL_IP'];
} elseif (!empty($_SERVER['REMOTE_ADDR'])) {
$ip = $_SERVER['REMOTE_ADDR'];
}
return sanitize_text_field($ip);
}
/**
* Create audit table if not exists
*/
public function maybe_create_table(): void {
global $wpdb;
$table = self::$table_name;
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE IF NOT EXISTS {$table} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
method VARCHAR(10) NOT NULL DEFAULT '',
route VARCHAR(255) NOT NULL DEFAULT '',
ip VARCHAR(45) NOT NULL DEFAULT '',
status SMALLINT UNSIGNED NOT NULL DEFAULT 0,
user_agent VARCHAR(255) NOT NULL DEFAULT '',
context TEXT,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_user_id (user_id),
KEY idx_route (route),
KEY idx_created_at (created_at)
) {$charset};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta($sql);
}
/**
* Query audit logs with pagination
*/
public function query_logs(array $args = []): array {
global $wpdb;
$defaults = [
'per_page' => 20,
'page' => 1,
'user_id' => 0,
'route' => '',
'method' => '',
'from' => '',
'to' => '',
];
$args = wp_parse_args($args, $defaults);
$where = ['1=1'];
$values = [];
if ($args['user_id'] > 0) {
$where[] = 'user_id = %d';
$values[] = $args['user_id'];
}
if (!empty($args['route'])) {
$where[] = 'route LIKE %s';
$values[] = '%' . $wpdb->esc_like($args['route']) . '%';
}
if (!empty($args['method'])) {
$where[] = 'method = %s';
$values[] = $args['method'];
}
if (!empty($args['from'])) {
$where[] = 'created_at >= %s';
$values[] = $args['from'];
}
if (!empty($args['to'])) {
$where[] = 'created_at <= %s';
$values[] = $args['to'];
}
$where_sql = implode(' AND ', $where);
$offset = ($args['page'] - 1) * $args['per_page'];
$count_sql = "SELECT COUNT(*) FROM " . self::$table_name . " WHERE {$where_sql}";
$total = (int) ($values
? $wpdb->get_var($wpdb->prepare($count_sql, $values))
: $wpdb->get_var($count_sql));
$values[] = $args['per_page'];
$values[] = $offset;
$logs = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM " . self::$table_name . " WHERE {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d",
$values
),
ARRAY_A
);
return [
'total' => $total,
'per_page'=> $args['per_page'],
'page' => $args['page'],
'logs' => $logs ?: [],
];
}
/**
* Purge old logs
*/
public function purge_old(int $days = 90): int {
global $wpdb;
$cutoff = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS));
return (int) $wpdb->query(
$wpdb->prepare("DELETE FROM " . self::$table_name . " WHERE created_at < %s", $cutoff)
);
}
}