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.
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
<?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)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user