diff --git a/public/index.php b/public/index.php index e2c4478..4256b0d 100644 --- a/public/index.php +++ b/public/index.php @@ -13,8 +13,9 @@ $container = new Container(); AppFactory::setContainer($container); $app = AppFactory::create(); -// В dev включаем подробные ошибки (можно выключить в проде) -$app->addErrorMiddleware(true, true, true); +// Подробные ошибки только если явно включены +$displayErrors = filter_var(getenv('PHP_ERROR_DISPLAY') ?: 'false', FILTER_VALIDATE_BOOLEAN); +$app->addErrorMiddleware($displayErrors, true, true); $container->set('db', function () { return \App\Db::connectFromSession(); @@ -38,6 +39,7 @@ $app->post('/api/login', function (Request $request, Response $response) { try { \App\Db::testConnection($user, $pass); + session_regenerate_id(true); $_SESSION['db_user'] = $user; $_SESSION['db_pass'] = $pass; @@ -53,10 +55,36 @@ $app->post('/api/login', function (Request $request, Response $response) { ->withStatus($status); }); +// Текущий статус сессии +$app->get('/api/session', function (Request $request, Response $response) { + $authenticated = !empty($_SESSION['db_user']) && !empty($_SESSION['db_pass']); + $payload = [ + 'authenticated' => $authenticated, + 'user' => $authenticated ? $_SESSION['db_user'] : null, + ]; + + $response->getBody()->write(json_encode($payload)); + return $response->withHeader('Content-Type', 'application/json'); +}); + +// Выход из системы: гарантированно очищаем серверную сессию +$app->post('/api/logout', function (Request $request, Response $response) { + $_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']); + } + session_destroy(); + + $response->getBody()->write(json_encode(['ok' => true])); + return $response->withHeader('Content-Type', 'application/json'); +}); + // Middleware на /api/*: проверяем, что аутентифицированы $app->add(function (Request $request, $handler) { $path = $request->getUri()->getPath(); - if (str_starts_with($path, '/api/') && $path !== '/api/login') { + $publicApi = ['/api/login', '/api/session', '/api/logout']; + if (str_starts_with($path, '/api/') && !in_array($path, $publicApi, true)) { if (empty($_SESSION['db_user']) || empty($_SESSION['db_pass'])) { $res = new \Slim\Psr7\Response(); $res->getBody()->write(json_encode(['error' => 'Not authenticated'])); @@ -101,23 +129,13 @@ $app->post('/api/table/data', function (Request $request, Response $response) us $pageSize = (int)($payload['size'] ?? $payload['pageSize'] ?? 50); $filters = $payload['filters'] ?? []; $sort = $payload['sort'] ?? null; - $columns = $payload['columns'] ?? []; - - // ✅ Логирование для отладки - error_log("Data request: page=$page, pageSize=$pageSize, filters=" . json_encode($filters) . ", sort=" . json_encode($sort)); - $pdo = $container->get('db'); + $meta = new \App\MetaService($pdo); $ds = new \App\DataService($pdo); + $metaArr = $meta->getTableMeta($schema, $table); + $columns = $metaArr['columns']; $resData = $ds->fetchData($schema, $table, $columns, $page, $pageSize, $filters, $sort); - // ✅ Логирование ответа - error_log("Data response: " . json_encode([ - 'data_count' => count($resData['data']), - 'last_page' => $resData['last_page'], - 'current_page' => $resData['current_page'], - 'total' => $resData['total'] - ])); - $response->getBody()->write(json_encode($resData)); return $response->withHeader('Content-Type', 'application/json'); }); @@ -267,7 +285,14 @@ $app->post('/api/table/export-csv', function (Request $request, Response $respon $table = $payload['table'] ?? ''; $filters = $payload['filters'] ?? []; $sort = $payload['sort'] ?? null; - $columns = $payload['columns'] ?? []; + $meta = new \App\MetaService($pdo); + $metaArr = $meta->getTableMeta($schema, $table); + $requestedColumns = $payload['columns'] ?? []; + $requestedNames = array_values(array_filter(array_map(fn($c) => $c['COLUMN_NAME'] ?? '', $requestedColumns))); + $columns = array_values(array_filter( + $metaArr['columns'], + fn($c) => in_array($c['COLUMN_NAME'], $requestedNames, true) + )); $result = $ds->exportCSV($schema, $table, $columns, $filters, $sort); @@ -284,6 +309,11 @@ $app->get('/api/fk-values', function (Request $request, Response $response) use $search = $params['search'] ?? ''; // ✅ Добавлен параметр поиска $pdo = $container->get('db'); + $isSafeIdentifier = fn(string $v): bool => preg_match('/^[A-Za-z0-9_]+$/', $v) === 1; + if (!$isSafeIdentifier($schema) || !$isSafeIdentifier($table) || !$isSafeIdentifier($column)) { + $response->getBody()->write(json_encode(['error' => 'Invalid schema/table/column name'])); + return $response->withHeader('Content-Type', 'application/json')->withStatus(400); + } // ✅ Сначала проверяем количество записей $countSql = "SELECT COUNT(DISTINCT `{$column}`) as cnt diff --git a/public/js/user.js b/public/js/user.js index 04bde32..2b16215 100644 --- a/public/js/user.js +++ b/public/js/user.js @@ -1,7 +1,7 @@ // ===== USER.JS - Авторизация и сессия ===== const STORAGE_KEYS = { - credentials: 'turborfq_credentials', + username: 'turborfq_username', columns: 'turborfq_columns', lastTable: 'turborfq_lastTable', tableState: 'turborfq_tableState' @@ -111,22 +111,22 @@ function showLogin() { document.getElementById('loginScreen').style.display = 'flex'; document.getElementById('header').style.display = 'none'; document.getElementById('appContent').style.display = 'none'; - document.getElementById('loginUser').value = ''; + document.getElementById('loginUser').value = localStorage.getItem(STORAGE_KEYS.username) || ''; document.getElementById('loginPass').value = ''; document.getElementById('loginStatus').textContent = ''; document.getElementById('loginStatus').className = ''; } // Выполнить авторизацию -async function doLogin(user, pass, saveCredentials = true) { +async function doLogin(user, pass, saveUsername = true) { const statusEl = document.getElementById('loginStatus'); try { const res = await api('/api/login', 'POST', { user, pass }); if (res.ok) { - if (saveCredentials) { - localStorage.setItem(STORAGE_KEYS.credentials, JSON.stringify({ user, pass })); + if (saveUsername) { + localStorage.setItem(STORAGE_KEYS.username, user); } showApp(user); await loadTree(); @@ -145,8 +145,12 @@ async function doLogin(user, pass, saveCredentials = true) { } // Выйти из системы -function logout() { - localStorage.removeItem(STORAGE_KEYS.credentials); +async function logout() { + try { + await api('/api/logout', 'POST', {}); + } catch (e) { + console.error('Logout error:', e); + } showLogin(); document.getElementById('tree').innerHTML = ''; if (table) { @@ -168,21 +172,17 @@ function resetSettings() { // Автологин при загрузке async function tryAutoLogin() { - const saved = localStorage.getItem(STORAGE_KEYS.credentials); - if (saved) { - try { - const { user, pass } = JSON.parse(saved); - document.getElementById('loginStatus').textContent = 'Автоматический вход...'; - const success = await doLogin(user, pass, false); - if (!success) { - localStorage.removeItem(STORAGE_KEYS.credentials); - showLogin(); - } - } catch (e) { - localStorage.removeItem(STORAGE_KEYS.credentials); - showLogin(); + try { + const session = await api('/api/session'); + if (session.authenticated) { + showApp(session.user || 'MariaDB User'); + await loadTree(); + return; } + } catch (e) { + console.error('Session restore error:', e); } + showLogin(); } // Инициализация обработчиков авторизации diff --git a/src/DataService.php b/src/DataService.php index 21650c9..c30043b 100644 --- a/src/DataService.php +++ b/src/DataService.php @@ -16,9 +16,16 @@ class DataService array $filters, ?array $sort ): array { + $schema = $this->validateIdentifier($schema, 'schema'); + $table = $this->validateIdentifier($table, 'table'); + $page = max(1, $page); + $pageSize = max(1, min(5000, $pageSize)); $offset = ($page - 1) * $pageSize; - $colNames = array_map(fn($c) => $c['COLUMN_NAME'], $columns); + $colNames = array_map(fn($c) => $this->validateIdentifier((string)$c['COLUMN_NAME'], 'column'), $columns); + if (empty($colNames)) { + throw new \RuntimeException('No columns available for table'); + } $quotedColumns = array_map(fn($name) => "`$name`", $colNames); $selectList = implode(', ', $quotedColumns); @@ -87,12 +94,14 @@ class DataService public function insertRow(string $schema, string $table, array $row, array $columns): array { + $schema = $this->validateIdentifier($schema, 'schema'); + $table = $this->validateIdentifier($table, 'table'); $insertCols = []; $placeholders = []; $params = []; foreach ($columns as $c) { - $name = $c['COLUMN_NAME']; + $name = $this->validateIdentifier((string)$c['COLUMN_NAME'], 'column'); $extra = $c['EXTRA'] ?? ''; if (str_contains($extra, 'auto_increment')) { @@ -202,6 +211,8 @@ private function formatPDOError(\PDOException $e, string $schema, string $table, public function updateRow(string $schema, string $table, array $row, array $columns, array $pk): array { + $schema = $this->validateIdentifier($schema, 'schema'); + $table = $this->validateIdentifier($table, 'table'); error_log("=== UPDATE ROW ==="); error_log("Schema: $schema, Table: $table"); @@ -213,7 +224,7 @@ public function updateRow(string $schema, string $table, array $row, array $colu $params = []; foreach ($columns as $c) { - $name = $c['COLUMN_NAME']; + $name = $this->validateIdentifier((string)$c['COLUMN_NAME'], 'column'); if (in_array($name, $pk, true)) { continue; } @@ -229,6 +240,7 @@ public function updateRow(string $schema, string $table, array $row, array $colu $whereParts = []; foreach ($pk as $name) { + $name = $this->validateIdentifier((string)$name, 'column'); if (!array_key_exists($name, $row)) { throw new \RuntimeException("Missing PK value: $name"); } @@ -263,6 +275,8 @@ public function updateRow(string $schema, string $table, array $row, array $colu public function deleteRow(string $schema, string $table, array $row, array $pk): array { + $schema = $this->validateIdentifier($schema, 'schema'); + $table = $this->validateIdentifier($table, 'table'); if (empty($pk)) { throw new \RuntimeException('No primary key — delete disabled'); } @@ -271,6 +285,7 @@ public function updateRow(string $schema, string $table, array $row, array $colu $params = []; foreach ($pk as $name) { + $name = $this->validateIdentifier((string)$name, 'column'); if (!array_key_exists($name, $row)) { throw new \RuntimeException("Missing PK value: $name"); } @@ -291,6 +306,8 @@ public function updateRow(string $schema, string $table, array $row, array $colu public function insertMultipleRows(string $schema, string $table, array $rows, array $columns): array { + $schema = $this->validateIdentifier($schema, 'schema'); + $table = $this->validateIdentifier($table, 'table'); error_log("=== ИМПОРТ CSV: Schema=$schema, Table=$table, Строк=" . count($rows) . " ==="); if (empty($rows)) { @@ -335,7 +352,7 @@ public function insertMultipleRows(string $schema, string $table, array $rows, a } foreach ($columns as $c) { - $name = $c['COLUMN_NAME']; + $name = $this->validateIdentifier((string)$c['COLUMN_NAME'], 'column'); $extra = $c['EXTRA'] ?? ''; $dataType = strtolower($c['DATA_TYPE'] ?? ''); @@ -659,6 +676,8 @@ private function formatRowData(array $rowData): string */ public function getForeignKeyInfo(string $schema, string $table): array { + $schema = $this->validateIdentifier($schema, 'schema'); + $table = $this->validateIdentifier($table, 'table'); $sql = " SELECT COLUMN_NAME, @@ -729,7 +748,12 @@ private function formatRowData(array $rowData): string array $filters, ?array $sort ): array { - $colNames = array_map(fn($c) => $c['COLUMN_NAME'], $columns); + $schema = $this->validateIdentifier($schema, 'schema'); + $table = $this->validateIdentifier($table, 'table'); + $colNames = array_map(fn($c) => $this->validateIdentifier((string)$c['COLUMN_NAME'], 'column'), $columns); + if (empty($colNames)) { + throw new \RuntimeException('No columns available for export'); + } $quotedColumns = array_map(fn($name) => "`$name`", $colNames); $selectList = implode(', ', $quotedColumns); @@ -787,6 +811,8 @@ private function formatRowData(array $rowData): string */ public function deleteMultipleRows(string $schema, string $table, array $rows, array $pk): array { + $schema = $this->validateIdentifier($schema, 'schema'); + $table = $this->validateIdentifier($table, 'table'); if (empty($rows)) { return ['deleted' => 0, 'errors' => 0, 'message' => 'No rows provided']; } @@ -806,7 +832,7 @@ private function formatRowData(array $rowData): string try { // Если PK состоит из одного поля - используем WHERE IN (самый быстрый способ) if (count($pk) === 1) { - $pkField = $pk[0]; + $pkField = $this->validateIdentifier((string)$pk[0], 'column'); // Группируем по батчам (по 500 строк) для избежания превышения лимитов MySQL $batchSize = 500; @@ -848,6 +874,7 @@ private function formatRowData(array $rowData): string $params = []; foreach ($pk as $name) { + $name = $this->validateIdentifier((string)$name, 'column'); if (!array_key_exists($name, $row)) { throw new \RuntimeException("Missing PK value: $name"); } @@ -911,4 +938,12 @@ private function formatRowData(array $rowData): string return $csv; } + + private function validateIdentifier(string $identifier, string $kind = 'identifier'): string + { + if (!preg_match('/^[A-Za-z0-9_]+$/', $identifier)) { + throw new \RuntimeException("Invalid {$kind} name"); + } + return $identifier; + } }