- Бэкапы: 2026-01-23-backup_all.sql.gz, 2026-01-23-dbname.sql.gz - CSV экспорт: 2026-01-23-tablename.csv Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
415 lines
16 KiB
PHP
415 lines
16 KiB
PHP
<?php
|
||
use Psr\Http\Message\ResponseInterface as Response;
|
||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||
use Slim\Factory\AppFactory;
|
||
use DI\Container;
|
||
|
||
require __DIR__ . '/../vendor/autoload.php';
|
||
|
||
session_start();
|
||
|
||
// DI‑контейнер
|
||
$container = new Container();
|
||
AppFactory::setContainer($container);
|
||
$app = AppFactory::create();
|
||
|
||
// В dev включаем подробные ошибки (можно выключить в проде)
|
||
$app->addErrorMiddleware(true, true, true);
|
||
|
||
$container->set('db', function () {
|
||
return \App\Db::connectFromSession();
|
||
});
|
||
|
||
// --- ROUTES ---
|
||
|
||
// Статическая страница с фронтендом
|
||
$app->get('/', function (Request $request, Response $response) {
|
||
// Очень простой view встроен прямо здесь
|
||
$html = file_get_contents(__DIR__ . '/index.html'); // вынесем в отдельный файл ниже
|
||
$response->getBody()->write($html);
|
||
return $response;
|
||
});
|
||
|
||
// Логин: сохраняет логин/пароль MariaDB в сессии после проверки
|
||
$app->post('/api/login', function (Request $request, Response $response) {
|
||
$data = json_decode((string)$request->getBody(), true);
|
||
$user = $data['user'] ?? '';
|
||
$pass = $data['pass'] ?? '';
|
||
|
||
try {
|
||
\App\Db::testConnection($user, $pass);
|
||
$_SESSION['db_user'] = $user;
|
||
$_SESSION['db_pass'] = $pass;
|
||
|
||
$payload = ['ok' => true];
|
||
$status = 200;
|
||
} catch (\RuntimeException $e) {
|
||
$payload = ['ok' => false, 'error' => $e->getMessage()];
|
||
$status = 401;
|
||
}
|
||
|
||
$response->getBody()->write(json_encode($payload));
|
||
return $response->withHeader('Content-Type', 'application/json')
|
||
->withStatus($status);
|
||
});
|
||
|
||
// Middleware на /api/*: проверяем, что аутентифицированы
|
||
$app->add(function (Request $request, $handler) {
|
||
$path = $request->getUri()->getPath();
|
||
if (str_starts_with($path, '/api/') && $path !== '/api/login') {
|
||
if (empty($_SESSION['db_user']) || empty($_SESSION['db_pass'])) {
|
||
$res = new \Slim\Psr7\Response();
|
||
$res->getBody()->write(json_encode(['error' => 'Not authenticated']));
|
||
return $res->withStatus(401)->withHeader('Content-Type', 'application/json');
|
||
}
|
||
}
|
||
return $handler->handle($request);
|
||
});
|
||
|
||
// API: дерево схем/таблиц
|
||
$app->get('/api/tree', function (Request $request, Response $response) use ($container) {
|
||
$pdo = $container->get('db');
|
||
$meta = new \App\MetaService($pdo);
|
||
$tree = $meta->getSchemaTree();
|
||
|
||
$response->getBody()->write(json_encode($tree));
|
||
return $response->withHeader('Content-Type', 'application/json');
|
||
});
|
||
|
||
// API: метаданные таблицы
|
||
$app->get('/api/table/meta', function (Request $request, Response $response) use ($container) {
|
||
$params = $request->getQueryParams();
|
||
$schema = $params['schema'] ?? '';
|
||
$table = $params['table'] ?? '';
|
||
|
||
$pdo = $container->get('db');
|
||
$meta = new \App\MetaService($pdo);
|
||
$data = $meta->getTableMeta($schema, $table);
|
||
|
||
$response->getBody()->write(json_encode($data));
|
||
return $response->withHeader('Content-Type', 'application/json');
|
||
});
|
||
|
||
// API: данные таблицы
|
||
$app->post('/api/table/data', function (Request $request, Response $response) use ($container) {
|
||
$payload = json_decode((string)$request->getBody(), true);
|
||
|
||
$schema = $payload['schema'] ?? '';
|
||
$table = $payload['table'] ?? '';
|
||
$page = (int)($payload['page'] ?? 1);
|
||
// Tabulator отправляет 'size', для обратной совместимости также проверяем 'pageSize'
|
||
$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');
|
||
$ds = new \App\DataService($pdo);
|
||
$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');
|
||
});
|
||
|
||
// API: insert / update / delete
|
||
$app->post('/api/table/insert', function (Request $request, Response $response) use ($container) {
|
||
try {
|
||
$payload = json_decode((string)$request->getBody(), true);
|
||
$pdo = $container->get('db');
|
||
$meta = new \App\MetaService($pdo);
|
||
$ds = new \App\DataService($pdo);
|
||
|
||
$schema = $payload['schema'];
|
||
$table = $payload['table'];
|
||
$row = $payload['row'];
|
||
$metaArr = $meta->getTableMeta($schema, $table);
|
||
|
||
$result = $ds->insertRow($schema, $table, $row, $metaArr['columns']);
|
||
$response->getBody()->write(json_encode($result));
|
||
return $response->withHeader('Content-Type', 'application/json');
|
||
} catch (\Exception $e) {
|
||
$error = ['error' => true, 'message' => $e->getMessage()];
|
||
$response->getBody()->write(json_encode($error));
|
||
return $response->withHeader('Content-Type', 'application/json')->withStatus(400);
|
||
}
|
||
});
|
||
|
||
$app->post('/api/table/update', function (Request $request, Response $response) use ($container) {
|
||
try {
|
||
$payload = json_decode((string)$request->getBody(), true);
|
||
$pdo = $container->get('db');
|
||
$meta = new \App\MetaService($pdo);
|
||
$ds = new \App\DataService($pdo);
|
||
|
||
$schema = $payload['schema'];
|
||
$table = $payload['table'];
|
||
$row = $payload['row'];
|
||
$metaArr = $meta->getTableMeta($schema, $table);
|
||
|
||
$result = $ds->updateRow($schema, $table, $row, $metaArr['columns'], $metaArr['primaryKey']);
|
||
$response->getBody()->write(json_encode($result));
|
||
return $response->withHeader('Content-Type', 'application/json');
|
||
} catch (\Exception $e) {
|
||
$error = ['error' => true, 'message' => $e->getMessage()];
|
||
$response->getBody()->write(json_encode($error));
|
||
return $response->withHeader('Content-Type', 'application/json')->withStatus(400);
|
||
}
|
||
});
|
||
|
||
$app->post('/api/table/delete', function (Request $request, Response $response) use ($container) {
|
||
try {
|
||
$payload = json_decode((string)$request->getBody(), true);
|
||
$pdo = $container->get('db');
|
||
$meta = new \App\MetaService($pdo);
|
||
$ds = new \App\DataService($pdo);
|
||
|
||
$schema = $payload['schema'];
|
||
$table = $payload['table'];
|
||
$row = $payload['row'];
|
||
$metaArr = $meta->getTableMeta($schema, $table);
|
||
|
||
$result = $ds->deleteRow($schema, $table, $row, $metaArr['primaryKey']);
|
||
$response->getBody()->write(json_encode($result));
|
||
return $response->withHeader('Content-Type', 'application/json');
|
||
} catch (\Exception $e) {
|
||
$error = ['error' => true, 'message' => $e->getMessage()];
|
||
$response->getBody()->write(json_encode($error));
|
||
return $response->withHeader('Content-Type', 'application/json')->withStatus(400);
|
||
}
|
||
});
|
||
|
||
// API: импорт CSV (массовая вставка)
|
||
$app->post('/api/table/import-csv', function (Request $request, Response $response) use ($container) {
|
||
$body = (string)$request->getBody();
|
||
$payload = json_decode($body, true);
|
||
|
||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||
error_log("CSV Import: Invalid JSON - " . json_last_error_msg());
|
||
$response->getBody()->write(json_encode(['error' => 'Invalid JSON: ' . json_last_error_msg()]));
|
||
return $response->withHeader('Content-Type', 'application/json')->withStatus(400);
|
||
}
|
||
|
||
$pdo = $container->get('db');
|
||
$meta = new \App\MetaService($pdo);
|
||
$ds = new \App\DataService($pdo);
|
||
|
||
$schema = $payload['schema'] ?? '';
|
||
$table = $payload['table'] ?? '';
|
||
$rows = $payload['rows'] ?? [];
|
||
|
||
if (empty($schema) || empty($table)) {
|
||
error_log("CSV Import: Missing schema or table");
|
||
$response->getBody()->write(json_encode(['error' => 'Schema and table are required']));
|
||
return $response->withHeader('Content-Type', 'application/json')->withStatus(400);
|
||
}
|
||
|
||
try {
|
||
$metaArr = $meta->getTableMeta($schema, $table);
|
||
$result = $ds->insertMultipleRows($schema, $table, $rows, $metaArr['columns']);
|
||
|
||
// ✅ Очищаем невалидные UTF-8 символы в сообщениях об ошибках
|
||
if (!empty($result['errorMessages'])) {
|
||
$result['errorMessages'] = array_map(function($msg) {
|
||
// Конвертируем в UTF-8 и удаляем невалидные символы
|
||
return mb_convert_encoding($msg, 'UTF-8', 'UTF-8');
|
||
}, $result['errorMessages']);
|
||
}
|
||
|
||
// ✅ Проверяем, что json_encode работает
|
||
$json = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_PARTIAL_OUTPUT_ON_ERROR);
|
||
|
||
if ($json === false) {
|
||
error_log("CSV Import: JSON encoding failed - " . json_last_error_msg());
|
||
|
||
// Возвращаем упрощённую версию без детальных ошибок
|
||
$json = json_encode([
|
||
'inserted' => $result['inserted'] ?? 0,
|
||
'errors' => $result['errors'] ?? 0,
|
||
'errorMessages' => ['Ошибка JSON сериализации. Проверьте логи сервера.']
|
||
], JSON_UNESCAPED_UNICODE);
|
||
}
|
||
|
||
$response->getBody()->write($json);
|
||
return $response->withHeader('Content-Type', 'application/json');
|
||
} catch (\Exception $e) {
|
||
error_log("CSV Import: Critical error - " . $e->getMessage());
|
||
error_log("Stack trace: " . $e->getTraceAsString());
|
||
|
||
$response->getBody()->write(json_encode([
|
||
'error' => mb_convert_encoding($e->getMessage(), 'UTF-8', 'UTF-8'),
|
||
'inserted' => 0,
|
||
'errors' => 1
|
||
], JSON_UNESCAPED_UNICODE));
|
||
return $response->withHeader('Content-Type', 'application/json')->withStatus(500);
|
||
}
|
||
});
|
||
|
||
|
||
|
||
// API: экспорт CSV
|
||
$app->post('/api/table/export-csv', function (Request $request, Response $response) use ($container) {
|
||
$payload = json_decode((string)$request->getBody(), true);
|
||
$pdo = $container->get('db');
|
||
$ds = new \App\DataService($pdo);
|
||
|
||
$schema = $payload['schema'] ?? '';
|
||
$table = $payload['table'] ?? '';
|
||
$filters = $payload['filters'] ?? [];
|
||
$sort = $payload['sort'] ?? null;
|
||
$columns = $payload['columns'] ?? [];
|
||
|
||
$result = $ds->exportCSV($schema, $table, $columns, $filters, $sort);
|
||
|
||
$response->getBody()->write(json_encode($result));
|
||
return $response->withHeader('Content-Type', 'application/json');
|
||
});
|
||
|
||
// API: получить доступные значения для Foreign Key
|
||
$app->get('/api/fk-values', function (Request $request, Response $response) use ($container) {
|
||
$params = $request->getQueryParams();
|
||
$schema = $params['schema'] ?? '';
|
||
$table = $params['table'] ?? '';
|
||
$column = $params['column'] ?? '';
|
||
$search = $params['search'] ?? ''; // ✅ Добавлен параметр поиска
|
||
|
||
$pdo = $container->get('db');
|
||
|
||
// ✅ Сначала проверяем количество записей
|
||
$countSql = "SELECT COUNT(DISTINCT `{$column}`) as cnt
|
||
FROM `{$schema}`.`{$table}`
|
||
WHERE `{$column}` IS NOT NULL";
|
||
|
||
$countStmt = $pdo->prepare($countSql);
|
||
$countStmt->execute();
|
||
$totalCount = (int)$countStmt->fetchColumn();
|
||
|
||
// ✅ Если есть поиск - фильтруем
|
||
$whereSql = "`{$column}` IS NOT NULL";
|
||
$params = [];
|
||
|
||
if (!empty($search)) {
|
||
$whereSql .= " AND `{$column}` LIKE :search";
|
||
$params[':search'] = '%' . $search . '%';
|
||
}
|
||
|
||
// ✅ Определяем лимит (для больших таблиц - без лимита при поиске)
|
||
$limit = '';
|
||
if (empty($search)) {
|
||
// Без поиска - лимит 1000 значений
|
||
$limit = 'LIMIT 1000';
|
||
}
|
||
|
||
$sql = "SELECT DISTINCT `{$column}`
|
||
FROM `{$schema}`.`{$table}`
|
||
WHERE {$whereSql}
|
||
ORDER BY `{$column}`
|
||
{$limit}";
|
||
|
||
$stmt = $pdo->prepare($sql);
|
||
foreach ($params as $key => $value) {
|
||
$stmt->bindValue($key, $value);
|
||
}
|
||
$stmt->execute();
|
||
$values = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||
|
||
$response->getBody()->write(json_encode([
|
||
'values' => $values,
|
||
'total' => $totalCount,
|
||
'loaded' => count($values)
|
||
]));
|
||
return $response->withHeader('Content-Type', 'application/json');
|
||
});
|
||
|
||
|
||
// API: массовое удаление строк (batch delete)
|
||
$app->post('/api/table/delete-batch', function (Request $request, Response $response) use ($container) {
|
||
try {
|
||
$payload = json_decode((string)$request->getBody(), true);
|
||
$pdo = $container->get('db');
|
||
$meta = new \App\MetaService($pdo);
|
||
$ds = new \App\DataService($pdo);
|
||
|
||
$schema = $payload['schema'];
|
||
$table = $payload['table'];
|
||
$rows = $payload['rows'] ?? []; // Массив строк для удаления
|
||
$metaArr = $meta->getTableMeta($schema, $table);
|
||
|
||
$result = $ds->deleteMultipleRows($schema, $table, $rows, $metaArr['primaryKey']);
|
||
$response->getBody()->write(json_encode($result));
|
||
return $response->withHeader('Content-Type', 'application/json');
|
||
} catch (\Exception $e) {
|
||
$error = ['error' => true, 'message' => $e->getMessage()];
|
||
$response->getBody()->write(json_encode($error));
|
||
return $response->withHeader('Content-Type', 'application/json')->withStatus(400);
|
||
}
|
||
});
|
||
|
||
|
||
// === BACKUP API ===
|
||
|
||
// Скачать дамп всех баз данных
|
||
$app->get('/api/backup/all', function (Request $request, Response $response) use ($container) {
|
||
try {
|
||
$pdo = $container->get('db');
|
||
$backup = new \App\BackupService($_SESSION['db_user'], $_SESSION['db_pass']);
|
||
$tempFile = $backup->dumpAllDatabases($pdo);
|
||
$filename = date('Y-m-d') . '-backup_all.sql.gz';
|
||
|
||
$stream = fopen($tempFile, 'rb');
|
||
$filesize = filesize($tempFile);
|
||
|
||
// Удаляем временный файл после отправки
|
||
register_shutdown_function(function() use ($tempFile) {
|
||
if (file_exists($tempFile)) unlink($tempFile);
|
||
});
|
||
|
||
return $response
|
||
->withHeader('Content-Type', 'application/gzip')
|
||
->withHeader('Content-Disposition', 'attachment; filename="' . $filename . '"')
|
||
->withHeader('Content-Length', $filesize)
|
||
->withBody(new \Slim\Psr7\Stream($stream));
|
||
} catch (\Exception $e) {
|
||
$response->getBody()->write(json_encode(['error' => $e->getMessage()]));
|
||
return $response->withHeader('Content-Type', 'application/json')->withStatus(500);
|
||
}
|
||
});
|
||
|
||
// Скачать дамп конкретной базы
|
||
$app->get('/api/backup/database/{name}', function (Request $request, Response $response, array $args) {
|
||
try {
|
||
$database = $args['name'];
|
||
$backup = new \App\BackupService($_SESSION['db_user'], $_SESSION['db_pass']);
|
||
$tempFile = $backup->dumpDatabase($database);
|
||
$filename = date('Y-m-d') . '-' . $database . '.sql.gz';
|
||
|
||
$stream = fopen($tempFile, 'rb');
|
||
$filesize = filesize($tempFile);
|
||
|
||
register_shutdown_function(function() use ($tempFile) {
|
||
if (file_exists($tempFile)) unlink($tempFile);
|
||
});
|
||
|
||
return $response
|
||
->withHeader('Content-Type', 'application/gzip')
|
||
->withHeader('Content-Disposition', 'attachment; filename="' . $filename . '"')
|
||
->withHeader('Content-Length', $filesize)
|
||
->withBody(new \Slim\Psr7\Stream($stream));
|
||
} catch (\Exception $e) {
|
||
$response->getBody()->write(json_encode(['error' => $e->getMessage()]));
|
||
return $response->withHeader('Content-Type', 'application/json')->withStatus(500);
|
||
}
|
||
});
|
||
|
||
$app->run();
|