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.
249 lines
7.5 KiB
PHP
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)
|
|
);
|
|
}
|
|
}
|