change ratelimiter
This commit is contained in:
parent
246ca93307
commit
077b79b8f7
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
namespace Config;
|
namespace Config;
|
||||||
|
|
||||||
|
use App\Libraries\RateLimitIdentifier;
|
||||||
|
use App\Services\RateLimitService;
|
||||||
use CodeIgniter\Config\BaseService;
|
use CodeIgniter\Config\BaseService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -45,9 +47,30 @@ class Services extends BaseService
|
||||||
return new \App\Services\AccessService();
|
return new \App\Services\AccessService();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сервис для идентификации клиентов в rate limiting
|
||||||
|
*
|
||||||
|
* Использует комбинацию cookie token + IP + User Agent
|
||||||
|
* для уникальной идентификации браузера клиента.
|
||||||
|
*
|
||||||
|
* @param bool $getShared
|
||||||
|
* @return \App\Libraries\RateLimitIdentifier
|
||||||
|
*/
|
||||||
|
public static function rateLimitIdentifier(bool $getShared = true)
|
||||||
|
{
|
||||||
|
if ($getShared) {
|
||||||
|
return static::getSharedInstance('rateLimitIdentifier');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RateLimitIdentifier();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сервис для rate limiting
|
* Сервис для rate limiting
|
||||||
*
|
*
|
||||||
|
* Обеспечивает защиту от брутфорса и ограничение частоты запросов.
|
||||||
|
* При недоступности Redis автоматически использует файловый кэш.
|
||||||
|
*
|
||||||
* @param bool $getShared
|
* @param bool $getShared
|
||||||
* @return \App\Services\RateLimitService|null
|
* @return \App\Services\RateLimitService|null
|
||||||
*/
|
*/
|
||||||
|
|
@ -58,7 +81,7 @@ class Services extends BaseService
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return \App\Services\RateLimitService::getInstance();
|
return RateLimitService::getInstance();
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
log_message('warning', 'RateLimitService unavailable: ' . $e->getMessage());
|
log_message('warning', 'RateLimitService unavailable: ' . $e->getMessage());
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -259,6 +259,7 @@ abstract class BaseController extends Controller
|
||||||
$tableData['actions'] = $config['actions'] ?? false;
|
$tableData['actions'] = $config['actions'] ?? false;
|
||||||
$tableData['actionsConfig'] = $config['actionsConfig'] ?? [];
|
$tableData['actionsConfig'] = $config['actionsConfig'] ?? [];
|
||||||
$tableData['columns'] = $config['columns'] ?? [];
|
$tableData['columns'] = $config['columns'] ?? [];
|
||||||
|
$tableData['onRowClick'] = $config['onRowClick'] ?? null;
|
||||||
|
|
||||||
// Параметры для пустого состояния
|
// Параметры для пустого состояния
|
||||||
$tableData['emptyMessage'] = $config['emptyMessage'] ?? 'Нет данных';
|
$tableData['emptyMessage'] = $config['emptyMessage'] ?? 'Нет данных';
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,15 @@
|
||||||
$routes->group('clients', ['filter' => 'org', 'namespace' => 'App\Modules\Clients\Controllers'], static function ($routes) {
|
$routes->group('clients', ['filter' => 'org', 'namespace' => 'App\Modules\Clients\Controllers'], static function ($routes) {
|
||||||
$routes->get('/', 'Clients::index');
|
$routes->get('/', 'Clients::index');
|
||||||
$routes->get('table', 'Clients::table'); // AJAX endpoint для таблицы
|
$routes->get('table', 'Clients::table'); // AJAX endpoint для таблицы
|
||||||
|
$routes->get('view/(:num)', 'Clients::view/$1'); // API: данные клиента
|
||||||
$routes->get('new', 'Clients::new');
|
$routes->get('new', 'Clients::new');
|
||||||
$routes->post('create', 'Clients::create');
|
$routes->post('create', 'Clients::create');
|
||||||
$routes->get('edit/(:num)', 'Clients::edit/$1');
|
$routes->get('edit/(:num)', 'Clients::edit/$1');
|
||||||
$routes->post('update/(:num)', 'Clients::update/$1');
|
$routes->post('update/(:num)', 'Clients::update/$1');
|
||||||
$routes->get('delete/(:num)', 'Clients::delete/$1');
|
$routes->get('delete/(:num)', 'Clients::delete/$1');
|
||||||
|
|
||||||
|
// Экспорт и импорт
|
||||||
|
$routes->get('export', 'Clients::export');
|
||||||
|
$routes->get('import', 'Clients::importPage');
|
||||||
|
$routes->post('import', 'Clients::import');
|
||||||
});
|
});
|
||||||
|
|
@ -70,6 +70,7 @@ class Clients extends BaseController
|
||||||
'type' => 'delete',
|
'type' => 'delete',
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
|
'onRowClick' => 'viewClient', // Функция для открытия карточки клиента
|
||||||
'emptyMessage' => 'Клиентов пока нет',
|
'emptyMessage' => 'Клиентов пока нет',
|
||||||
'emptyIcon' => 'fa-solid fa-users',
|
'emptyIcon' => 'fa-solid fa-users',
|
||||||
'emptyActionUrl' => base_url('/clients/new'),
|
'emptyActionUrl' => base_url('/clients/new'),
|
||||||
|
|
@ -237,4 +238,245 @@ class Clients extends BaseController
|
||||||
session()->setFlashdata('error', $message);
|
session()->setFlashdata('error', $message);
|
||||||
return redirect()->to('/');
|
return redirect()->to('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// API: Просмотр, Экспорт, Импорт
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API: Получение данных клиента для модального окна
|
||||||
|
*/
|
||||||
|
public function view($id)
|
||||||
|
{
|
||||||
|
if (!$this->access->canView('clients')) {
|
||||||
|
return $this->response->setJSON([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Доступ запрещён'
|
||||||
|
])->setStatusCode(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$client = $this->clientModel->forCurrentOrg()->find($id);
|
||||||
|
|
||||||
|
if (!$client) {
|
||||||
|
return $this->response->setJSON([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Клиент не найден'
|
||||||
|
])->setStatusCode(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Формируем данные для ответа
|
||||||
|
$data = [
|
||||||
|
'id' => $client['id'],
|
||||||
|
'name' => $client['name'],
|
||||||
|
'email' => $client['email'] ?? '',
|
||||||
|
'phone' => $client['phone'] ?? '',
|
||||||
|
'notes' => $client['notes'] ?? '',
|
||||||
|
'status' => $client['status'] ?? 'active',
|
||||||
|
'created_at' => $client['created_at'] ? date('d.m.Y H:i', strtotime($client['created_at'])) : '',
|
||||||
|
'updated_at' => $client['updated_at'] ? date('d.m.Y H:i', strtotime($client['updated_at'])) : '',
|
||||||
|
];
|
||||||
|
|
||||||
|
return $this->response->setJSON([
|
||||||
|
'success' => true,
|
||||||
|
'data' => $data
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Экспорт клиентов
|
||||||
|
*/
|
||||||
|
public function export()
|
||||||
|
{
|
||||||
|
if (!$this->access->canView('clients')) {
|
||||||
|
return $this->forbiddenResponse('Доступ запрещён');
|
||||||
|
}
|
||||||
|
|
||||||
|
$format = $this->request->getGet('format') ?? 'csv';
|
||||||
|
|
||||||
|
// Получаем всех клиентов организации
|
||||||
|
$clients = $this->clientModel->forCurrentOrg()->findAll();
|
||||||
|
|
||||||
|
// Устанавливаем заголовки для скачивания
|
||||||
|
if ($format === 'xlsx') {
|
||||||
|
$filename = 'clients_' . date('Y-m-d') . '.xlsx';
|
||||||
|
$this->exportToXlsx($clients, $filename);
|
||||||
|
} else {
|
||||||
|
$filename = 'clients_' . date('Y-m-d') . '.csv';
|
||||||
|
$this->exportToCsv($clients, $filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Экспорт в CSV
|
||||||
|
*/
|
||||||
|
protected function exportToCsv(array $clients, string $filename)
|
||||||
|
{
|
||||||
|
header('Content-Type: text/csv; charset=utf-8');
|
||||||
|
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||||
|
|
||||||
|
$output = fopen('php://output', 'w');
|
||||||
|
|
||||||
|
// Заголовок CSV
|
||||||
|
fputcsv($output, ['ID', 'Имя', 'Email', 'Телефон', 'Статус', 'Создан', 'Обновлён'], ';');
|
||||||
|
|
||||||
|
// Данные
|
||||||
|
foreach ($clients as $client) {
|
||||||
|
fputcsv($output, [
|
||||||
|
$client['id'],
|
||||||
|
$client['name'],
|
||||||
|
$client['email'] ?? '',
|
||||||
|
$client['phone'] ?? '',
|
||||||
|
$client['status'] ?? 'active',
|
||||||
|
$client['created_at'] ?? '',
|
||||||
|
$client['updated_at'] ?? '',
|
||||||
|
], ';');
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($output);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Экспорт в XLSX (упрощённый через HTML table)
|
||||||
|
*/
|
||||||
|
protected function exportToXlsx(array $clients, string $filename)
|
||||||
|
{
|
||||||
|
// Для упрощения используем HTML table с правильными заголовками Excel
|
||||||
|
// В продакшене рекомендуется использовать PhpSpreadsheet
|
||||||
|
|
||||||
|
header('Content-Type: application/vnd.ms-excel');
|
||||||
|
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||||
|
header('Cache-Control: max-age=0');
|
||||||
|
|
||||||
|
echo '<table border="1">';
|
||||||
|
echo '<tr><th>ID</th><th>Имя</th><th>Email</th><th>Телефон</th><th>Статус</th><th>Создан</th><th>Обновлён</th></tr>';
|
||||||
|
|
||||||
|
foreach ($clients as $client) {
|
||||||
|
echo '<tr>';
|
||||||
|
echo '<td>' . $client['id'] . '</td>';
|
||||||
|
echo '<td>' . htmlspecialchars($client['name']) . '</td>';
|
||||||
|
echo '<td>' . htmlspecialchars($client['email'] ?? '') . '</td>';
|
||||||
|
echo '<td>' . htmlspecialchars($client['phone'] ?? '') . '</td>';
|
||||||
|
echo '<td>' . ($client['status'] ?? 'active') . '</td>';
|
||||||
|
echo '<td>' . ($client['created_at'] ?? '') . '</td>';
|
||||||
|
echo '<td>' . ($client['updated_at'] ?? '') . '</td>';
|
||||||
|
echo '</tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
echo '</table>';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Страница импорта клиентов (форма)
|
||||||
|
*/
|
||||||
|
public function importPage()
|
||||||
|
{
|
||||||
|
if (!$this->access->canCreate('clients')) {
|
||||||
|
return $this->forbiddenResponse('Доступ запрещён');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->renderTwig('@Clients/import');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Импорт клиентов из файла
|
||||||
|
*/
|
||||||
|
public function import()
|
||||||
|
{
|
||||||
|
if (!$this->access->canCreate('clients')) {
|
||||||
|
return $this->forbiddenResponse('Доступ запрещён');
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $this->request->getFile('file');
|
||||||
|
|
||||||
|
if (!$file->isValid()) {
|
||||||
|
return $this->response->setJSON([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Файл не загружен'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$extension = strtolower($file->getClientExtension());
|
||||||
|
|
||||||
|
if (!in_array($extension, ['csv', 'xlsx', 'xls'])) {
|
||||||
|
return $this->response->setJSON([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Неподдерживаемый формат файла. Используйте CSV или XLSX.'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$organizationId = session()->get('active_org_id');
|
||||||
|
$imported = 0;
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
// Парсим CSV файл
|
||||||
|
if ($extension === 'csv') {
|
||||||
|
$handle = fopen($file->getTempName(), 'r');
|
||||||
|
|
||||||
|
// Пропускаем заголовок
|
||||||
|
fgetcsv($handle, 0, ';');
|
||||||
|
|
||||||
|
$row = 1;
|
||||||
|
while (($data = fgetcsv($handle, 0, ';')) !== false) {
|
||||||
|
$row++;
|
||||||
|
|
||||||
|
$name = trim($data[1] ?? '');
|
||||||
|
$email = trim($data[2] ?? '');
|
||||||
|
|
||||||
|
// Валидация
|
||||||
|
if (empty($name)) {
|
||||||
|
$errors[] = "Строка $row: Имя обязательно";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($email) && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$errors[] = "Строка $row: Некорректный email";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка на дубликат email
|
||||||
|
if (!empty($email)) {
|
||||||
|
$exists = $this->clientModel->where('organization_id', $organizationId)
|
||||||
|
->where('email', $email)
|
||||||
|
->countAllResults();
|
||||||
|
if ($exists > 0) {
|
||||||
|
$errors[] = "Строка $row: Клиент с email $email уже существует";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вставка
|
||||||
|
$this->clientModel->insert([
|
||||||
|
'organization_id' => $organizationId,
|
||||||
|
'name' => $name,
|
||||||
|
'email' => $email ?: null,
|
||||||
|
'phone' => trim($data[3] ?? '') ?: null,
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$imported++;
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($handle);
|
||||||
|
} else {
|
||||||
|
// Для XLSX в продакшене использовать PhpSpreadsheet
|
||||||
|
return $this->response->setJSON([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Импорт XLSX файлов временно недоступен. Пожалуйста, используйте CSV формат.'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = "Импортировано клиентов: $imported";
|
||||||
|
if (!empty($errors)) {
|
||||||
|
$message .= '. Ошибок: ' . count($errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->response->setJSON([
|
||||||
|
'success' => true,
|
||||||
|
'message' => $message,
|
||||||
|
'imported' => $imported,
|
||||||
|
'errors' => $errors
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,226 @@
|
||||||
|
{#
|
||||||
|
# Модальное окно просмотра клиента
|
||||||
|
#}
|
||||||
|
|
||||||
|
{# Скрытый модальный контейнер - будет показан при клике на строку таблицы #}
|
||||||
|
<div class="modal fade" id="viewClientModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
{# Header #}
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div id="clientAvatar" class="bg-primary text-white rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 48px; height: 48px;">
|
||||||
|
<i class="fa-solid fa-user fs-5"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 class="modal-title mb-0" id="clientName">—</h5>
|
||||||
|
<span id="clientStatus" class="badge bg-success">Активен</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Body #}
|
||||||
|
<div class="modal-body">
|
||||||
|
{# Навигация по вкладкам #}
|
||||||
|
<ul class="nav nav-tabs mb-3" id="clientTabs" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" id="general-tab" data-bs-toggle="tab" data-bs-target="#general" type="button" role="tab">
|
||||||
|
<i class="fa-solid fa-user me-2"></i>Основное
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="contact-tab" data-bs-toggle="tab" data-bs-target="#contact" type="button" role="tab">
|
||||||
|
<i class="fa-solid fa-address-book me-2"></i>Контакты
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="notes-tab" data-bs-toggle="tab" data-bs-target="#notes" type="button" role="tab">
|
||||||
|
<i class="fa-solid fa-note-sticky me-2"></i>Заметки
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{# Контент вкладок #}
|
||||||
|
<div class="tab-content" id="clientTabsContent">
|
||||||
|
{# Вкладка: Основное #}
|
||||||
|
<div class="tab-pane fade show active" id="general" role="tabpanel">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="text-muted small mb-1">Email</label>
|
||||||
|
<div id="clientEmail" class="fw-medium">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="text-muted small mb-1">Телефон</label>
|
||||||
|
<div id="clientPhone" class="fw-medium">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="text-muted small mb-1">Дата создания</label>
|
||||||
|
<div id="clientCreated" class="fw-medium">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="text-muted small mb-1">Последнее обновление</label>
|
||||||
|
<div id="clientUpdated" class="fw-medium">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Вкладка: Контакты #}
|
||||||
|
<div class="tab-pane fade" id="contact" role="tabpanel">
|
||||||
|
<div class="text-center py-4 text-muted">
|
||||||
|
<i class="fa-solid fa-envelope fa-3x mb-3"></i>
|
||||||
|
<p class="mb-0">Email: <a id="contactEmail" href="#">—</a></p>
|
||||||
|
<p>Телефон: <a id="contactPhone" href="#">—</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Вкладка: Заметки #}
|
||||||
|
<div class="tab-pane fade" id="notes" role="tabpanel">
|
||||||
|
<div id="clientNotes" class="p-3 bg-light rounded">
|
||||||
|
—
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Footer #}
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||||
|
<i class="fa-solid fa-times me-2"></i>Закрыть
|
||||||
|
</button>
|
||||||
|
<a id="clientEditLink" href="#" class="btn btn-primary">
|
||||||
|
<i class="fa-solid fa-pen me-2"></i>Редактировать
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let viewClientModal = null;
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Инициализируем модальное окно
|
||||||
|
viewClientModal = new bootstrap.Modal(document.getElementById('viewClientModal'));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Открыть карточку клиента
|
||||||
|
*/
|
||||||
|
function viewClient(clientId) {
|
||||||
|
// Показываем модалку с лоадером
|
||||||
|
showClientLoader();
|
||||||
|
viewClientModal.show();
|
||||||
|
|
||||||
|
// Загружаем данные
|
||||||
|
fetch('/clients/view/' + clientId)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
renderClientData(data.data);
|
||||||
|
} else {
|
||||||
|
showClientError(data.error || 'Ошибка загрузки');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
showClientError('Ошибка соединения');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Показать лоадер в модальном окне
|
||||||
|
*/
|
||||||
|
function showClientLoader() {
|
||||||
|
document.getElementById('clientName').textContent = 'Загрузка...';
|
||||||
|
document.getElementById('clientStatus').className = 'badge bg-secondary';
|
||||||
|
document.getElementById('clientStatus').textContent = '—';
|
||||||
|
|
||||||
|
// Очищаем поля
|
||||||
|
document.getElementById('clientEmail').textContent = '—';
|
||||||
|
document.getElementById('clientPhone').textContent = '—';
|
||||||
|
document.getElementById('clientCreated').textContent = '—';
|
||||||
|
document.getElementById('clientUpdated').textContent = '—';
|
||||||
|
document.getElementById('clientNotes').innerHTML = '—';
|
||||||
|
document.getElementById('clientNotes').classList.remove('bg-light');
|
||||||
|
|
||||||
|
document.getElementById('contactEmail').textContent = '—';
|
||||||
|
document.getElementById('contactEmail').href = '#';
|
||||||
|
document.getElementById('contactPhone').textContent = '—';
|
||||||
|
document.getElementById('contactPhone').href = '#';
|
||||||
|
|
||||||
|
document.getElementById('clientEditLink').href = '#';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Показать ошибку загрузки
|
||||||
|
*/
|
||||||
|
function showClientError(message) {
|
||||||
|
document.getElementById('clientName').textContent = 'Ошибка';
|
||||||
|
document.getElementById('clientNotes').innerHTML = '<span class="text-danger">' + message + '</span>';
|
||||||
|
document.getElementById('clientNotes').classList.add('bg-light');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отобразить данные клиента
|
||||||
|
*/
|
||||||
|
function renderClientData(client) {
|
||||||
|
// Имя и аватар
|
||||||
|
const initials = client.name ? client.name.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase() : '?';
|
||||||
|
const avatarEl = document.getElementById('clientAvatar');
|
||||||
|
avatarEl.innerHTML = initials;
|
||||||
|
|
||||||
|
document.getElementById('clientName').textContent = client.name || '—';
|
||||||
|
|
||||||
|
// Статус
|
||||||
|
const statusEl = document.getElementById('clientStatus');
|
||||||
|
if (client.status === 'active') {
|
||||||
|
statusEl.className = 'badge bg-success';
|
||||||
|
statusEl.textContent = 'Активен';
|
||||||
|
} else if (client.status === 'blocked') {
|
||||||
|
statusEl.className = 'badge bg-danger';
|
||||||
|
statusEl.textContent = 'Заблокирован';
|
||||||
|
} else {
|
||||||
|
statusEl.className = 'badge bg-secondary';
|
||||||
|
statusEl.textContent = client.status || '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Основная информация
|
||||||
|
document.getElementById('clientEmail').textContent = client.email || '—';
|
||||||
|
document.getElementById('clientPhone').textContent = client.phone || '—';
|
||||||
|
document.getElementById('clientCreated').textContent = client.created_at || '—';
|
||||||
|
document.getElementById('clientUpdated').textContent = client.updated_at || '—';
|
||||||
|
|
||||||
|
// Заметки
|
||||||
|
const notesEl = document.getElementById('clientNotes');
|
||||||
|
if (client.notes && client.notes.trim()) {
|
||||||
|
notesEl.textContent = client.notes;
|
||||||
|
notesEl.classList.add('bg-light');
|
||||||
|
} else {
|
||||||
|
notesEl.innerHTML = '<em class="text-muted">Заметок нет</em>';
|
||||||
|
notesEl.classList.add('bg-light');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Контакты для клика
|
||||||
|
const contactEmail = document.getElementById('contactEmail');
|
||||||
|
if (client.email) {
|
||||||
|
contactEmail.textContent = client.email;
|
||||||
|
contactEmail.href = 'mailto:' + client.email;
|
||||||
|
} else {
|
||||||
|
contactEmail.textContent = '—';
|
||||||
|
contactEmail.href = '#';
|
||||||
|
}
|
||||||
|
|
||||||
|
const contactPhone = document.getElementById('contactPhone');
|
||||||
|
if (client.phone) {
|
||||||
|
contactPhone.textContent = client.phone;
|
||||||
|
contactPhone.href = 'tel:' + client.phone;
|
||||||
|
} else {
|
||||||
|
contactPhone.textContent = '—';
|
||||||
|
contactPhone.href = '#';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ссылка на редактирование
|
||||||
|
document.getElementById('clientEditLink').href = '/clients/edit/' + client.id;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
{% extends 'layouts/base.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Импорт клиентов - {{ parent() }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container" style="max-width: 700px;">
|
||||||
|
<div class="card shadow">
|
||||||
|
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0"><i class="fa-solid fa-file-import me-2"></i>Импорт клиентов</h5>
|
||||||
|
<a href="{{ base_url('/clients') }}" class="btn btn-light btn-sm">
|
||||||
|
<i class="fa-solid fa-arrow-left"></i> Назад
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<form id="importForm" action="{{ base_url('/clients/import') }}" method="POST" enctype="multipart/form-data">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
|
||||||
|
{# Инструкция #}
|
||||||
|
<div class="alert alert-info mb-4">
|
||||||
|
<h6 class="alert-heading"><i class="fa-solid fa-info-circle me-2"></i>Инструкция по импорту</h6>
|
||||||
|
<ul class="mb-0">
|
||||||
|
<li>Загрузите файл в формате <strong>CSV</strong> (разделитель — точка с запятой)</li>
|
||||||
|
<li>Файл должен содержать заголовки в первой строке</li>
|
||||||
|
<li>Обязательные поля: <strong>Имя</strong></li>
|
||||||
|
<li>Опционально: Email, Телефон</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Ссылка на шаблон #}
|
||||||
|
<div class="mb-4">
|
||||||
|
<a href="{{ base_url('/clients/export?format=csv') }}" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="fa-solid fa-download me-2"></i>Скачать шаблон
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Загрузка файла #}
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="file" class="form-label fw-medium">Выберите файл</label>
|
||||||
|
<input type="file"
|
||||||
|
class="form-control"
|
||||||
|
id="file"
|
||||||
|
name="file"
|
||||||
|
accept=".csv"
|
||||||
|
required>
|
||||||
|
<div class="form-text">Поддерживается только формат CSV</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Кнопка отправки #}
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<a href="{{ base_url('/clients') }}" class="btn btn-secondary me-3">Отмена</a>
|
||||||
|
<button type="submit" class="btn btn-primary" id="submitBtn">
|
||||||
|
<i class="fa-solid fa-upload me-2"></i>Импортировать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{# Результат импорта (скрыт по умолчанию) #}
|
||||||
|
<div id="importResult" class="mt-4" style="display: none;">
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<h6><i class="fa-solid fa-check-circle me-2"></i>Импорт завершён</h6>
|
||||||
|
<p class="mb-0" id="importMessage"></p>
|
||||||
|
</div>
|
||||||
|
<div id="importErrors" class="alert alert-warning" style="display: none;">
|
||||||
|
<h6><i class="fa-solid fa-exclamation-triangle me-2"></i>Ошибки</h6>
|
||||||
|
<ul class="mb-0 small" id="errorsList"></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const form = document.getElementById('importForm');
|
||||||
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
|
const resultDiv = document.getElementById('importResult');
|
||||||
|
const messageEl = document.getElementById('importMessage');
|
||||||
|
const errorsDiv = document.getElementById('importErrors');
|
||||||
|
const errorsList = document.getElementById('errorsList');
|
||||||
|
|
||||||
|
form.addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const formData = new FormData(form);
|
||||||
|
|
||||||
|
// Блокируем кнопку
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Загрузка...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(form.action, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Показываем результат
|
||||||
|
resultDiv.style.display = 'block';
|
||||||
|
messageEl.textContent = data.message;
|
||||||
|
|
||||||
|
if (data.errors && data.errors.length > 0) {
|
||||||
|
errorsDiv.style.display = 'block';
|
||||||
|
errorsList.innerHTML = data.errors.map(err => '<li>' + err + '</li>').join('');
|
||||||
|
} else {
|
||||||
|
errorsDiv.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// Очищаем форму
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
resultDiv.style.display = 'block';
|
||||||
|
messageEl.innerHTML = '<span class="text-danger">Произошла ошибка при загрузке файла</span>';
|
||||||
|
errorsDiv.style.display = 'none';
|
||||||
|
} finally {
|
||||||
|
// Разблокируем кнопку
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = '<i class="fa-solid fa-upload me-2"></i>Импортировать';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -6,9 +6,26 @@
|
||||||
<h1 class="h3 mb-0">{{ title }}</h1>
|
<h1 class="h3 mb-0">{{ title }}</h1>
|
||||||
<p class="text-muted mb-0">Управление клиентами вашей организации</p>
|
<p class="text-muted mb-0">Управление клиентами вашей организации</p>
|
||||||
</div>
|
</div>
|
||||||
<a href="{{ base_url('/clients/new') }}" class="btn btn-primary">
|
<div class="d-flex gap-2">
|
||||||
<i class="fa-solid fa-plus me-2"></i>Добавить клиента
|
{# Кнопки экспорта и импорта #}
|
||||||
</a>
|
<div class="btn-group">
|
||||||
|
<a href="{{ base_url('/clients/export?format=csv') }}" class="btn btn-outline-success" title="Экспорт CSV">
|
||||||
|
<i class="fa-solid fa-file-csv me-1"></i>CSV
|
||||||
|
</a>
|
||||||
|
<a href="{{ base_url('/clients/export?format=xlsx') }}" class="btn btn-outline-success" title="Экспорт Excel">
|
||||||
|
<i class="fa-solid fa-file-excel me-1"></i>Excel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<a href="{{ base_url('/clients/import') }}" class="btn btn-outline-primary" title="Импорт клиентов">
|
||||||
|
<i class="fa-solid fa-file-import me-1"></i>Импорт
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% if can_create %}
|
||||||
|
<a href="{{ base_url('/clients/new') }}" class="btn btn-primary">
|
||||||
|
<i class="fa-solid fa-plus me-2"></i>Добавить клиента
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
|
|
@ -26,6 +43,9 @@
|
||||||
{{ csrf_field()|raw }}
|
{{ csrf_field()|raw }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Модальное окно просмотра клиента #}
|
||||||
|
{% include '@Clients/_client_modal.twig' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block stylesheets %}
|
{% block stylesheets %}
|
||||||
|
|
@ -38,6 +58,7 @@
|
||||||
<script src="/assets/js/modules/DataTable.js"></script>
|
<script src="/assets/js/modules/DataTable.js"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Инициализация DataTable
|
||||||
document.querySelectorAll('.data-table').forEach(function(container) {
|
document.querySelectorAll('.data-table').forEach(function(container) {
|
||||||
const id = container.id;
|
const id = container.id;
|
||||||
const url = container.dataset.url;
|
const url = container.dataset.url;
|
||||||
|
|
|
||||||
|
|
@ -2,38 +2,59 @@
|
||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Libraries\RateLimitIdentifier;
|
||||||
|
use CodeIgniter\Cache\CacheFactory;
|
||||||
|
use CodeIgniter\Cache\Interfaces\CacheInterface;
|
||||||
use CodeIgniter\Config\Services as BaseServices;
|
use CodeIgniter\Config\Services as BaseServices;
|
||||||
use Redis;
|
use Redis;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RateLimitService - Сервис для ограничения частоты запросов и защиты от брутфорса
|
* RateLimitService — сервис для ограничения частоты запросов и защиты от брутфорса
|
||||||
*
|
*
|
||||||
* Использует Redis для хранения счётчиков попыток и блокировок.
|
* Использует Redis для хранения счётчиков попыток и блокировок.
|
||||||
* Все ограничения применяются по IP-адресу клиента.
|
* При недоступности Redis автоматически переключается на файловый кэш.
|
||||||
|
* Идентификация клиентов осуществляется через RateLimitIdentifier
|
||||||
|
* (cookie token + IP + User Agent).
|
||||||
*
|
*
|
||||||
* @property \Redis $redis
|
* @property \Redis $redis
|
||||||
|
* @property \CodeIgniter\Cache\Interfaces\CacheInterface $cache
|
||||||
*/
|
*/
|
||||||
class RateLimitService
|
class RateLimitService
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @var \Redis
|
* @var \Redis|null
|
||||||
*/
|
*/
|
||||||
private $redis;
|
private ?Redis $redis = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var \CodeIgniter\Cache\Interfaces\CacheInterface
|
||||||
|
*/
|
||||||
|
private CacheInterface $cache;
|
||||||
|
|
||||||
|
private RateLimitIdentifier $identifier;
|
||||||
private string $prefix;
|
private string $prefix;
|
||||||
private array $config;
|
private array $config;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Конструктор сервиса
|
* Конструктор сервиса
|
||||||
*
|
*
|
||||||
* @param \Redis $redis Экземпляр Redis-подключения
|
* @param \Redis|null $redis Экземпляр Redis-подключения (null если недоступен)
|
||||||
* @param string $prefix Префикс для всех ключей в Redis
|
* @param \CodeIgniter\Cache\Interfaces\CacheInterface $cache Фоллбэк кэш
|
||||||
|
* @param \App\Libraries\RateLimitIdentifier $identifier Генератор идентификаторов
|
||||||
|
* @param string $prefix Префикс для всех ключей в хранилище
|
||||||
* @param array $config Конфигурация ограничений
|
* @param array $config Конфигурация ограничений
|
||||||
*/
|
*/
|
||||||
public function __construct(\Redis $redis, string $prefix = 'rl:', array $config = [])
|
public function __construct(
|
||||||
{
|
?Redis $redis,
|
||||||
|
CacheInterface $cache,
|
||||||
|
RateLimitIdentifier $identifier,
|
||||||
|
string $prefix = 'rl:',
|
||||||
|
array $config = []
|
||||||
|
) {
|
||||||
$this->redis = $redis;
|
$this->redis = $redis;
|
||||||
|
$this->cache = $cache;
|
||||||
|
$this->identifier = $identifier;
|
||||||
$this->prefix = $prefix;
|
$this->prefix = $prefix;
|
||||||
|
|
||||||
$this->config = array_merge([
|
$this->config = array_merge([
|
||||||
|
|
@ -68,10 +89,12 @@ class RateLimitService
|
||||||
{
|
{
|
||||||
$config = self::getConfig();
|
$config = self::getConfig();
|
||||||
$redis = self::getRedisConnection();
|
$redis = self::getRedisConnection();
|
||||||
|
$cache = self::getCache();
|
||||||
|
$identifier = self::getIdentifier();
|
||||||
|
|
||||||
$prefix = $config['prefix'] ?? 'rl:';
|
$prefix = $config['prefix'] ?? 'rl:';
|
||||||
|
|
||||||
return new self($redis, $prefix, $config);
|
return new self($redis, $cache, $identifier, $prefix, $config);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -112,12 +135,10 @@ class RateLimitService
|
||||||
/**
|
/**
|
||||||
* Подключение к Redis
|
* Подключение к Redis
|
||||||
*
|
*
|
||||||
* @return \Redis
|
* @return \Redis|null
|
||||||
* @throws RuntimeException
|
|
||||||
*/
|
*/
|
||||||
private static function getRedisConnection(): \Redis
|
private static function getRedisConnection(): ?Redis
|
||||||
{
|
{
|
||||||
/** @var \Redis $redis */
|
|
||||||
$redis = new \Redis();
|
$redis = new \Redis();
|
||||||
|
|
||||||
$host = env('redis.host', '127.0.0.1');
|
$host = env('redis.host', '127.0.0.1');
|
||||||
|
|
@ -127,50 +148,76 @@ class RateLimitService
|
||||||
$timeout = (float) env('redis.timeout', 2.0);
|
$timeout = (float) env('redis.timeout', 2.0);
|
||||||
$readTimeout = (float) env('redis.read_timeout', 60.0);
|
$readTimeout = (float) env('redis.read_timeout', 60.0);
|
||||||
|
|
||||||
if (!$redis->connect($host, $port, $timeout)) {
|
try {
|
||||||
throw new RuntimeException("Не удалось подключиться к Redis ({$host}:{$port})");
|
if (!$redis->connect($host, $port, $timeout)) {
|
||||||
}
|
log_message('warning', "RateLimitService: Не удалось подключиться к Redis ({$host}:{$port})");
|
||||||
|
return null;
|
||||||
if (!empty($password)) {
|
|
||||||
if (!$redis->auth($password)) {
|
|
||||||
throw new RuntimeException('Ошибка аутентификации в Redis');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!empty($password)) {
|
||||||
|
if (!$redis->auth($password)) {
|
||||||
|
log_message('warning', 'RateLimitService: Ошибка аутентификации в Redis');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$redis->select($database);
|
||||||
|
$redis->setOption(\Redis::OPT_READ_TIMEOUT, $readTimeout);
|
||||||
|
|
||||||
|
return $redis;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
log_message('warning', 'RateLimitService: Исключение при подключении к Redis - ' . $e->getMessage());
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$redis->select($database);
|
|
||||||
$redis->setOption(\Redis::OPT_READ_TIMEOUT, $readTimeout);
|
|
||||||
|
|
||||||
return $redis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Получение IP-адреса клиента
|
* Получение кэш-сервиса
|
||||||
*
|
*
|
||||||
* @return string
|
* @return \CodeIgniter\Cache\Interfaces\CacheInterface
|
||||||
*/
|
*/
|
||||||
private function getClientIp(): string
|
private static function getCache(): CacheInterface
|
||||||
{
|
{
|
||||||
$ip = service('request')->getIPAddress();
|
$cache = cache();
|
||||||
|
|
||||||
// Если это CLI-запрос или IP не определён - используем fallback
|
if (!$cache instanceof CacheInterface) {
|
||||||
if (empty($ip) || $ip === '0.0.0.0') {
|
throw new RuntimeException('RateLimitService: Кэш-сервис не инициализирован. Проверьте конфигурацию app/Config/Cache.php');
|
||||||
return '127.0.0.1';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $ip;
|
return $cache;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Генерация ключа для Redis
|
* Получение идентификатора для rate limiting
|
||||||
*
|
*
|
||||||
* @param string $type Тип ограничения (login, register, reset)
|
* @return \App\Libraries\RateLimitIdentifier
|
||||||
* @param string $suffix Дополнительный суффикс
|
*/
|
||||||
|
private static function getIdentifier(): RateLimitIdentifier
|
||||||
|
{
|
||||||
|
return new RateLimitIdentifier();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверка подключения к Redis
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isRedisAvailable(): bool
|
||||||
|
{
|
||||||
|
return $this->redis !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Генерация ключа для хранения
|
||||||
|
*
|
||||||
|
* @param string $action Действие (login, register, reset)
|
||||||
|
* @param string $suffix Дополнительный суффикс (attempts, block)
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
private function getKey(string $type, string $suffix = ''): string
|
private function getKey(string $action, string $suffix = ''): string
|
||||||
{
|
{
|
||||||
$ip = $this->getClientIp();
|
$identifier = $this->identifier->getIdentifier($action);
|
||||||
$key = "{$this->prefix}{$type}:{$ip}";
|
$key = "{$this->prefix}{$identifier}";
|
||||||
|
|
||||||
if (!empty($suffix)) {
|
if (!empty($suffix)) {
|
||||||
$key .= ":{$suffix}";
|
$key .= ":{$suffix}";
|
||||||
|
|
@ -179,6 +226,138 @@ class RateLimitService
|
||||||
return $key;
|
return $key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение значения из хранилища (Redis или Cache)
|
||||||
|
*
|
||||||
|
* @param string $key
|
||||||
|
* @return string|false
|
||||||
|
*/
|
||||||
|
private function get(string $key): string|false
|
||||||
|
{
|
||||||
|
if ($this->redis !== null) {
|
||||||
|
try {
|
||||||
|
return $this->redis->get($key) ?: false;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
log_message('warning', 'RateLimitService Redis error (get): ' . $e->getMessage());
|
||||||
|
$this->redis = null; // Mark as unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->cache->get($key) ?: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Установка значения с TTL (Redis) или без (Cache)
|
||||||
|
*
|
||||||
|
* @param string $key
|
||||||
|
* @param string $value
|
||||||
|
* @param int $ttl TTL в секундах
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private function set(string $key, string $value, int $ttl): bool
|
||||||
|
{
|
||||||
|
if ($this->redis !== null) {
|
||||||
|
try {
|
||||||
|
if ($ttl > 0) {
|
||||||
|
return $this->redis->setex($key, $ttl, $value);
|
||||||
|
}
|
||||||
|
return $this->redis->set($key, $value);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
log_message('warning', 'RateLimitService Redis error (set): ' . $e->getMessage());
|
||||||
|
$this->redis = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для файлового кэша TTL обрабатывается иначе
|
||||||
|
return $this->cache->save($key, $value, $ttl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Инкремент значения
|
||||||
|
*
|
||||||
|
* @param string $key
|
||||||
|
* @return int|false
|
||||||
|
*/
|
||||||
|
private function incr(string $key): int|false
|
||||||
|
{
|
||||||
|
if ($this->redis !== null) {
|
||||||
|
try {
|
||||||
|
return $this->redis->incr($key);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
log_message('warning', 'RateLimitService Redis error (incr): ' . $e->getMessage());
|
||||||
|
$this->redis = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для файлового кэша эмулируем инкремент
|
||||||
|
$current = (int) $this->cache->get($key);
|
||||||
|
$newValue = $current + 1;
|
||||||
|
$this->cache->save($key, (string) $newValue, 3600);
|
||||||
|
return $newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаление ключа
|
||||||
|
*
|
||||||
|
* @param string $key
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private function delete(string $key): bool
|
||||||
|
{
|
||||||
|
if ($this->redis !== null) {
|
||||||
|
try {
|
||||||
|
return (bool) $this->redis->del($key);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
log_message('warning', 'RateLimitService Redis error (del): ' . $e->getMessage());
|
||||||
|
$this->redis = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->cache->delete($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверка существования ключа
|
||||||
|
*
|
||||||
|
* @param string $key
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private function exists(string $key): bool
|
||||||
|
{
|
||||||
|
if ($this->redis !== null) {
|
||||||
|
try {
|
||||||
|
return (bool) $this->redis->exists($key);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
log_message('warning', 'RateLimitService Redis error (exists): ' . $e->getMessage());
|
||||||
|
$this->redis = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->cache->get($key) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение TTL ключа
|
||||||
|
*
|
||||||
|
* @param string $key
|
||||||
|
* @return int
|
||||||
|
*/
|
||||||
|
private function ttl(string $key): int
|
||||||
|
{
|
||||||
|
if ($this->redis !== null) {
|
||||||
|
try {
|
||||||
|
$ttl = $this->redis->ttl($key);
|
||||||
|
return $ttl !== false ? (int) $ttl : -1;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
log_message('warning', 'RateLimitService Redis error (ttl): ' . $e->getMessage());
|
||||||
|
$this->redis = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для файлового кэша TTL не доступен напрямую
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Проверка на блокировку
|
* Проверка на блокировку
|
||||||
*
|
*
|
||||||
|
|
@ -188,7 +367,7 @@ class RateLimitService
|
||||||
public function isBlocked(string $type): bool
|
public function isBlocked(string $type): bool
|
||||||
{
|
{
|
||||||
$blockKey = $this->getKey($type, 'block');
|
$blockKey = $this->getKey($type, 'block');
|
||||||
return (bool) $this->redis->exists($blockKey);
|
return $this->exists($blockKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -200,7 +379,7 @@ class RateLimitService
|
||||||
public function getBlockTimeLeft(string $type): int
|
public function getBlockTimeLeft(string $type): int
|
||||||
{
|
{
|
||||||
$blockKey = $this->getKey($type, 'block');
|
$blockKey = $this->getKey($type, 'block');
|
||||||
$ttl = $this->redis->ttl($blockKey);
|
$ttl = $this->ttl($blockKey);
|
||||||
|
|
||||||
return max(0, $ttl);
|
return max(0, $ttl);
|
||||||
}
|
}
|
||||||
|
|
@ -230,26 +409,9 @@ class RateLimitService
|
||||||
$maxAttempts = $this->config["auth_{$type}_attempts"] ?? 5;
|
$maxAttempts = $this->config["auth_{$type}_attempts"] ?? 5;
|
||||||
|
|
||||||
// Получаем текущее количество попыток
|
// Получаем текущее количество попыток
|
||||||
$currentAttempts = (int) $this->redis->get($attemptsKey);
|
$currentAttempts = (int) $this->get($attemptsKey);
|
||||||
$remaining = max(0, $maxAttempts - $currentAttempts);
|
$remaining = max(0, $maxAttempts - $currentAttempts);
|
||||||
|
|
||||||
// Проверяем, не превышен ли лимит
|
|
||||||
if ($currentAttempts >= $maxAttempts) {
|
|
||||||
// Устанавливаем блокировку
|
|
||||||
$blockTtl = $this->config["auth_{$type}_block"] ?? $window;
|
|
||||||
$blockKey = $this->getKey($type, 'block');
|
|
||||||
$this->redis->setex($blockKey, $blockTtl, '1');
|
|
||||||
|
|
||||||
return [
|
|
||||||
'allowed' => false,
|
|
||||||
'attempts' => $currentAttempts,
|
|
||||||
'limit' => $maxAttempts,
|
|
||||||
'remaining' => 0,
|
|
||||||
'blocked' => true,
|
|
||||||
'block_ttl' => $blockTtl,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'allowed' => true,
|
'allowed' => true,
|
||||||
'attempts' => $currentAttempts,
|
'attempts' => $currentAttempts,
|
||||||
|
|
@ -273,11 +435,11 @@ class RateLimitService
|
||||||
$maxAttempts = $this->config["auth_{$type}_attempts"] ?? 5;
|
$maxAttempts = $this->config["auth_{$type}_attempts"] ?? 5;
|
||||||
|
|
||||||
// Инкрементируем счётчик
|
// Инкрементируем счётчик
|
||||||
$attempts = $this->redis->incr($attemptsKey);
|
$attempts = $this->incr($attemptsKey);
|
||||||
|
|
||||||
// Устанавливаем TTL только при первой попытке
|
// Устанавливаем TTL только при первой попытке
|
||||||
if ($attempts === 1) {
|
if ($attempts === 1) {
|
||||||
$this->redis->expire($attemptsKey, $window);
|
$this->set($attemptsKey, (string) $attempts, $window);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем, не превышен ли лимит
|
// Проверяем, не превышен ли лимит
|
||||||
|
|
@ -285,7 +447,7 @@ class RateLimitService
|
||||||
// Устанавливаем блокировку
|
// Устанавливаем блокировку
|
||||||
$blockTtl = $this->config["auth_{$type}_block"] ?? $window;
|
$blockTtl = $this->config["auth_{$type}_block"] ?? $window;
|
||||||
$blockKey = $this->getKey($type, 'block');
|
$blockKey = $this->getKey($type, 'block');
|
||||||
$this->redis->setex($blockKey, $blockTtl, '1');
|
$this->set($blockKey, '1', $blockTtl);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'allowed' => false,
|
'allowed' => false,
|
||||||
|
|
@ -316,7 +478,7 @@ class RateLimitService
|
||||||
public function resetAttempts(string $type): void
|
public function resetAttempts(string $type): void
|
||||||
{
|
{
|
||||||
$attemptsKey = $this->getKey($type, 'attempts');
|
$attemptsKey = $this->getKey($type, 'attempts');
|
||||||
$this->redis->del($attemptsKey);
|
$this->delete($attemptsKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -351,12 +513,12 @@ class RateLimitService
|
||||||
$maxAttempts = $this->config["api_{$type}_attempts"] ?? 60;
|
$maxAttempts = $this->config["api_{$type}_attempts"] ?? 60;
|
||||||
$window = $this->config["api_{$type}_window"] ?? 60;
|
$window = $this->config["api_{$type}_window"] ?? 60;
|
||||||
|
|
||||||
$current = (int) $this->redis->get($key);
|
$current = (int) $this->get($key);
|
||||||
$ttl = $this->redis->ttl($key);
|
$ttl = $this->ttl($key);
|
||||||
|
|
||||||
// Если ключ не существует или истёк - создаём новый
|
// Если ключ не существует или истёк - создаём новый
|
||||||
if ($ttl < 0) {
|
if ($ttl < 0) {
|
||||||
$this->redis->setex($key, $window, 1);
|
$this->set($key, '1', $window);
|
||||||
return [
|
return [
|
||||||
'allowed' => true,
|
'allowed' => true,
|
||||||
'remaining' => $maxAttempts - 1,
|
'remaining' => $maxAttempts - 1,
|
||||||
|
|
@ -372,7 +534,7 @@ class RateLimitService
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->redis->incr($key);
|
$this->incr($key);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'allowed' => true,
|
'allowed' => true,
|
||||||
|
|
@ -392,16 +554,16 @@ class RateLimitService
|
||||||
$attemptsKey = $this->getKey($type, 'attempts');
|
$attemptsKey = $this->getKey($type, 'attempts');
|
||||||
$blockKey = $this->getKey($type, 'block');
|
$blockKey = $this->getKey($type, 'block');
|
||||||
|
|
||||||
$attempts = (int) $this->redis->get($attemptsKey);
|
$attempts = (int) $this->get($attemptsKey);
|
||||||
$attemptsTtl = $this->redis->ttl($attemptsKey);
|
$attemptsTtl = $this->ttl($attemptsKey);
|
||||||
$isBlocked = $this->redis->exists($blockKey);
|
$isBlocked = $this->exists($blockKey);
|
||||||
$blockTtl = $isBlocked ? $this->redis->ttl($blockKey) : 0;
|
$blockTtl = $isBlocked ? $this->ttl($blockKey) : 0;
|
||||||
|
|
||||||
$maxAttempts = $this->config["auth_{$type}_attempts"] ?? 5;
|
$maxAttempts = $this->config["auth_{$type}_attempts"] ?? 5;
|
||||||
$window = $this->config["auth_{$type}_window"] ?? 900;
|
$window = $this->config["auth_{$type}_window"] ?? 900;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'ip' => $this->getClientIp(),
|
'identifier' => $this->identifier->getIdentifier($type),
|
||||||
'type' => $type,
|
'type' => $type,
|
||||||
'attempts' => $attempts,
|
'attempts' => $attempts,
|
||||||
'attempts_ttl' => max(0, $attemptsTtl),
|
'attempts_ttl' => max(0, $attemptsTtl),
|
||||||
|
|
@ -409,9 +571,30 @@ class RateLimitService
|
||||||
'window' => $window,
|
'window' => $window,
|
||||||
'is_blocked' => $isBlocked,
|
'is_blocked' => $isBlocked,
|
||||||
'block_ttl' => max(0, $blockTtl),
|
'block_ttl' => max(0, $blockTtl),
|
||||||
|
'redis_available' => $this->isRedisAvailable(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обеспечение установки токена (вызывать в контроллере)
|
||||||
|
*
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
public function ensureToken(): ?string
|
||||||
|
{
|
||||||
|
return $this->identifier->ensureToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение JS скрипта для установки токена
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getJsScript(): string
|
||||||
|
{
|
||||||
|
return $this->identifier->getJsScript();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Проверка подключения к Redis
|
* Проверка подключения к Redis
|
||||||
*
|
*
|
||||||
|
|
@ -419,6 +602,10 @@ class RateLimitService
|
||||||
*/
|
*/
|
||||||
public function isConnected(): bool
|
public function isConnected(): bool
|
||||||
{
|
{
|
||||||
|
if ($this->redis === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return $this->redis->ping() === true || $this->redis->ping() === '+PONG';
|
return $this->redis->ping() === true || $this->redis->ping() === '+PONG';
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
{% if items is defined and items|length > 0 %}
|
{% if items is defined and items|length > 0 %}
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<tr>
|
<tr{% if onRowClick is defined and onRowClick %} onclick="{{ onRowClick }}({{ item.id }})" style="cursor: pointer;"{% endif %}>
|
||||||
{# Рендерим каждую колонку #}
|
{# Рендерим каждую колонку #}
|
||||||
{% for key, column in columns %}
|
{% for key, column in columns %}
|
||||||
<td>
|
<td>
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
|
|
||||||
{# Колонка действий #}
|
{# Колонка действий #}
|
||||||
{% if actionsConfig is defined and actionsConfig|length > 0 %}
|
{% if actionsConfig is defined and actionsConfig|length > 0 %}
|
||||||
<td class="actions-cell text-end">
|
<td class="actions-cell text-end"{% if onRowClick is defined and onRowClick %} onclick="event.stopPropagation();"{% endif %}>
|
||||||
{# Фильтруем действия на основе прав доступа #}
|
{# Фильтруем действия на основе прав доступа #}
|
||||||
{% set visibleActions = [] %}
|
{% set visibleActions = [] %}
|
||||||
{% for action in actionsConfig %}
|
{% for action in actionsConfig %}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
]
|
]
|
||||||
- can_edit: Разрешено ли редактирование (для фильтрации действий)
|
- can_edit: Разрешено ли редактирование (для фильтрации действий)
|
||||||
- can_delete: Разрешено ли удаление (для фильтрации действий)
|
- can_delete: Разрешено ли удаление (для фильтрации действий)
|
||||||
|
- onRowClick: JavaScript функция для обработки клика по строке (опционально)
|
||||||
- emptyMessage: Сообщение при отсутствии данных
|
- emptyMessage: Сообщение при отсутствии данных
|
||||||
- emptyActionUrl: URL для кнопки действия
|
- emptyActionUrl: URL для кнопки действия
|
||||||
- emptyActionLabel: Текст кнопки
|
- emptyActionLabel: Текст кнопки
|
||||||
|
|
@ -41,7 +42,7 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
{% if items is defined and items|length > 0 %}
|
{% if items is defined and items|length > 0 %}
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<tr>
|
<tr{% if onRowClick is defined and onRowClick %} onclick="{{ onRowClick }}({{ item.id }})" style="cursor: pointer;"{% endif %}>
|
||||||
{# Рендерим каждую колонку #}
|
{# Рендерим каждую колонку #}
|
||||||
{% for key, column in columns %}
|
{% for key, column in columns %}
|
||||||
<td>
|
<td>
|
||||||
|
|
@ -51,7 +52,7 @@
|
||||||
|
|
||||||
{# Колонка действий #}
|
{# Колонка действий #}
|
||||||
{% if actionsConfig is defined and actionsConfig|length > 0 %}
|
{% if actionsConfig is defined and actionsConfig|length > 0 %}
|
||||||
<td class="actions-cell text-end">
|
<td class="actions-cell text-end"{% if onRowClick is defined and onRowClick %} onclick="event.stopPropagation();"{% endif %}>
|
||||||
{# Фильтруем действия на основе прав доступа #}
|
{# Фильтруем действия на основе прав доступа #}
|
||||||
{% set visibleActions = [] %}
|
{% set visibleActions = [] %}
|
||||||
{% for action in actionsConfig %}
|
{% for action in actionsConfig %}
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,8 @@
|
||||||
<!-- Scripts -->
|
<!-- Scripts -->
|
||||||
<script src="{{ base_url('assets/js/bootstrap.bundle.min.js') }}"></script>
|
<script src="{{ base_url('assets/js/bootstrap.bundle.min.js') }}"></script>
|
||||||
<script src="{{ base_url('assets/js/base.js') }}"></script>
|
<script src="{{ base_url('assets/js/base.js') }}"></script>
|
||||||
|
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -14,6 +14,9 @@
|
||||||
|
|
||||||
|
|
||||||
<script src="{{ base_url('assets/js/bootstrap.bundle.min.js') }}"></script>
|
<script src="{{ base_url('assets/js/bootstrap.bundle.min.js') }}"></script>
|
||||||
|
<script src="{{ base_url('assets/js/base.js') }}"></script>
|
||||||
|
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -1,65 +1,96 @@
|
||||||
// Скрипт тоггла сайдбара с улучшенной обработкой
|
// Скрипт тоггла сайдбара с улучшенной обработкой
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
const sidebarToggle = document.getElementById('sidebarToggle');
|
const sidebarToggle = document.getElementById('sidebarToggle');
|
||||||
const sidebarWrapper = document.getElementById('sidebar-wrapper');
|
const sidebarWrapper = document.getElementById('sidebar-wrapper');
|
||||||
const body = document.body;
|
const body = document.body;
|
||||||
|
|
||||||
if (sidebarToggle) {
|
// Базовая функция для получения base URL (объявляем ДО использования)
|
||||||
// Обработчик клика
|
const baseUrl = '{{ base_url("/") }}'.replace(/\/+$/, '');
|
||||||
sidebarToggle.addEventListener('click', function(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
// Переключаем класс
|
if (sidebarToggle) {
|
||||||
body.classList.toggle('sb-sidenav-toggled');
|
// Обработчик клика
|
||||||
|
sidebarToggle.addEventListener('click', function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
// Сохраняем состояние в localStorage
|
// Переключаем класс
|
||||||
const isToggled = body.classList.contains('sb-sidenav-toggled');
|
body.classList.toggle('sb-sidenav-toggled');
|
||||||
localStorage.setItem('sidebar-toggled', isToggled);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Закрытие сайдбара при клике вне его (для мобильных)
|
// Сохраняем состояние в localStorage
|
||||||
document.addEventListener('click', function(event) {
|
const isToggled = body.classList.contains('sb-sidenav-toggled');
|
||||||
if (window.innerWidth <= 768 &&
|
localStorage.setItem('sidebar-toggled', isToggled);
|
||||||
body.classList.contains('sb-sidenav-toggled') &&
|
});
|
||||||
!sidebarWrapper.contains(event.target) &&
|
|
||||||
event.target !== sidebarToggle &&
|
|
||||||
!sidebarToggle.contains(event.target)) {
|
|
||||||
body.classList.remove('sb-sidenav-toggled');
|
|
||||||
localStorage.setItem('sidebar-toggled', false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Восстановление состояния из localStorage
|
// Закрытие сайдбара при клике вне его (для мобильных)
|
||||||
const sidebarToggled = localStorage.getItem('sidebar-toggled');
|
document.addEventListener('click', function (event) {
|
||||||
if (sidebarToggled === 'true' && window.innerWidth > 768) {
|
if (window.innerWidth <= 768 &&
|
||||||
body.classList.add('sb-sidenav-toggled');
|
body.classList.contains('sb-sidenav-toggled') &&
|
||||||
}
|
!sidebarWrapper.contains(event.target) &&
|
||||||
}
|
event.target !== sidebarToggle &&
|
||||||
|
!sidebarToggle.contains(event.target)) {
|
||||||
// Адаптивное поведение при изменении размера окна
|
|
||||||
window.addEventListener('resize', function() {
|
|
||||||
if (window.innerWidth > 768) {
|
|
||||||
// На десктопе всегда показываем сайдбар
|
|
||||||
body.classList.remove('sb-sidenav-toggled');
|
body.classList.remove('sb-sidenav-toggled');
|
||||||
|
localStorage.setItem('sidebar-toggled', false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Подсветка активного пункта меню при загрузке
|
// Восстановление состояния из localStorage
|
||||||
highlightActiveMenuItem();
|
const sidebarToggled = localStorage.getItem('sidebar-toggled');
|
||||||
|
if (sidebarToggled === 'true' && window.innerWidth > 768) {
|
||||||
|
body.classList.add('sb-sidenav-toggled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function highlightActiveMenuItem() {
|
// Адаптивное поведение при изменении размера окна
|
||||||
const currentPath = window.location.pathname;
|
window.addEventListener('resize', function () {
|
||||||
const menuItems = document.querySelectorAll('.sidebar-link');
|
if (window.innerWidth > 768) {
|
||||||
|
// На десктопе всегда показываем сайдбар
|
||||||
|
body.classList.remove('sb-sidenav-toggled');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
menuItems.forEach(item => {
|
// Подсветка активного пункта меню при загрузке
|
||||||
const href = item.getAttribute('href');
|
highlightActiveMenuItem();
|
||||||
if (href && href !== '#' && currentPath.includes(href.replace(baseUrl, ''))) {
|
|
||||||
item.classList.add('active');
|
function highlightActiveMenuItem() {
|
||||||
}
|
const currentPath = window.location.pathname;
|
||||||
|
const menuItems = document.querySelectorAll('.sidebar-link');
|
||||||
|
|
||||||
|
menuItems.forEach(item => {
|
||||||
|
const href = item.getAttribute('href');
|
||||||
|
if (href && href !== '#' && currentPath.includes(href.replace(baseUrl, ''))) {
|
||||||
|
item.classList.add('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
(function () {
|
||||||
|
const cookieName = 'rl_token';
|
||||||
|
|
||||||
|
// Проверяем, есть ли кука
|
||||||
|
const hasCookie = document.cookie.split('; ').find(function (row) {
|
||||||
|
return row.indexOf(cookieName + '=') === 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasCookie) {
|
||||||
|
// Генерируем UUID v4 на клиенте
|
||||||
|
function generateUUID() {
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||||
|
const r = Math.random() * 16 | 0;
|
||||||
|
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||||
|
return v.toString(16);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Базовая функция для получения base URL
|
const token = generateUUID();
|
||||||
const baseUrl = '{{ base_url("/") }}'.replace(/\/+$/, '');
|
const date = new Date();
|
||||||
});
|
date.setTime(date.getTime() + (365 * 24 * 60 * 60 * 1000));
|
||||||
|
|
||||||
|
const cookieString = cookieName + '=' + token +
|
||||||
|
'; expires=' + date.toUTCString() +
|
||||||
|
'; path=/' +
|
||||||
|
'; SameSite=Lax' +
|
||||||
|
(location.protocol === 'https:' ? '; Secure' : '');
|
||||||
|
|
||||||
|
document.cookie = cookieString;
|
||||||
|
}
|
||||||
|
})();
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue