chartboard/app/Controllers/DataSource/DataSourceController.php
2026-03-30 09:51:33 +05:30

725 lines
24 KiB
PHP

<?php
namespace App\Controllers\DataSource;
use App\Controllers\BaseController;
use App\Libraries\AuditLogger;
use App\Libraries\ConnectionFactory;
use App\Models\DataSourceModel;
use PDO;
use PDOException;
use Throwable;
class DataSourceController extends BaseController
{
public function index()
{
$workspaceId = (int) $this->session->get('active_workspace_id');
$dataSources = (new DataSourceModel())->forWorkspace($workspaceId);
return view('datasource/index', [
'title' => 'Data Sources | Chart-Board',
'dataSources' => $dataSources,
]);
}
public function create()
{
return view('datasource/create', [
'title' => 'Create Data Source | Chart-Board',
]);
}
public function show(int $id)
{
$workspaceId = (int) $this->session->get('active_workspace_id');
$model = new DataSourceModel();
$dataSource = $model->where('workspace_id', $workspaceId)->find($id);
if (! $dataSource) {
return redirect()->to('/datasource')->with('error', 'Data source not found.');
}
$proof = $this->buildConnectionProof($dataSource);
return view('datasource/show', [
'title' => 'Data Source Details | Chart-Board',
'dataSource' => $dataSource,
'proof' => $proof,
]);
}
public function proof(int $id)
{
$workspaceId = (int) $this->session->get('active_workspace_id');
$model = new DataSourceModel();
$dataSource = $model->where('workspace_id', $workspaceId)->find($id);
if (! $dataSource) {
return $this->response->setJSON([
'success' => false,
'message' => 'Data source not found.',
])->setStatusCode(404);
}
$proof = $this->buildConnectionProof($dataSource);
$html = view('datasource/_proof_content', [
'dataSource' => $dataSource,
'proof' => $proof,
]);
return $this->response->setJSON([
'success' => true,
'message' => 'Proof refreshed.',
'proof' => $proof,
'html' => $html,
]);
}
public function store()
{
$rules = $this->baseRules();
if (! $this->validate($rules)) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
$workspaceId = (int) $this->session->get('active_workspace_id');
$payload = $this->buildPayload($workspaceId);
$payload['created_by'] = (int) $this->session->get('user_id');
$payload['status'] = 'untested';
$payload['error_message'] = null;
$payload['last_tested_at'] = null;
$dsModel = new DataSourceModel();
$newId = (int) $dsModel->insert($payload, true);
if ($newId > 0) {
AuditLogger::log(
'datasource.created',
'data_source',
$newId,
null,
$this->snapshotDataSource($payload),
$workspaceId,
(int) $this->session->get('user_id')
);
}
return redirect()->to('/datasource')->with('success', 'Data source created.');
}
public function edit(int $id)
{
$workspaceId = (int) $this->session->get('active_workspace_id');
$dataSource = (new DataSourceModel())
->where('workspace_id', $workspaceId)
->find($id);
if (! $dataSource) {
return redirect()->to('/datasource')->with('error', 'Data source not found.');
}
return view('datasource/edit', [
'title' => 'Edit Data Source | Chart-Board',
'dataSource' => $dataSource,
]);
}
public function update(int $id)
{
$workspaceId = (int) $this->session->get('active_workspace_id');
$model = new DataSourceModel();
$dataSource = $model->where('workspace_id', $workspaceId)->find($id);
if (! $dataSource) {
return redirect()->to('/datasource')->with('error', 'Data source not found.');
}
$rules = $this->baseRules(false);
if (! $this->validate($rules)) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
$payload = $this->buildPayload($workspaceId);
// Keep previously encrypted secrets when user leaves masked value unchanged.
if (trim((string) $this->request->getPost('password')) === '') {
unset($payload['password']);
}
if (trim((string) $this->request->getPost('api_auth_value')) === '') {
unset($payload['api_auth_value']);
}
$payload['status'] = 'untested';
$payload['error_message'] = null;
$payload['last_tested_at'] = null;
$oldSnap = $this->snapshotDataSource($dataSource);
$model->update($id, $payload);
$newRow = $model->find($id) ?? [];
AuditLogger::log(
'datasource.updated',
'data_source',
$id,
$oldSnap,
$this->snapshotDataSource($newRow),
$workspaceId,
(int) $this->session->get('user_id')
);
return redirect()->to('/datasource')->with('success', 'Data source updated.');
}
public function delete(int $id)
{
$workspaceId = (int) $this->session->get('active_workspace_id');
$model = new DataSourceModel();
$dataSource = $model->where('workspace_id', $workspaceId)->find($id);
if (! $dataSource) {
return redirect()->to('/datasource')->with('error', 'Data source not found.');
}
AuditLogger::log(
'datasource.deleted',
'data_source',
$id,
$this->snapshotDataSource($dataSource),
null,
$workspaceId,
(int) $this->session->get('user_id')
);
$model->delete($id);
return redirect()->to('/datasource')->with('success', 'Data source deleted.');
}
public function test()
{
$workspaceId = (int) $this->session->get('active_workspace_id');
$id = (int) $this->request->getPost('id');
$model = new DataSourceModel();
$dataSource = $id > 0
? $model->where('workspace_id', $workspaceId)->find($id)
: null;
$payload = $dataSource ?: $this->buildPayload($workspaceId);
$result = ['success' => false, 'message' => 'Connection test failed.'];
try {
$connector = (new ConnectionFactory())->make((string) $payload['type']);
$result = $connector->test($payload);
} catch (Throwable $e) {
$result = ['success' => false, 'message' => $e->getMessage()];
}
if ($dataSource) {
$model->update($id, [
'status' => $result['success'] ? 'connected' : 'failed',
'error_message' => $result['success'] ? null : $result['message'],
'last_tested_at' => date('Y-m-d H:i:s'),
]);
}
if ($this->request->isAJAX()) {
return $this->response->setJSON($result);
}
return redirect()->back()->with(
$result['success'] ? 'success' : 'error',
$result['message']
);
}
public function schema(int $id)
{
$workspaceId = (int) $this->session->get('active_workspace_id');
$model = new DataSourceModel();
$dataSource = $model->where('workspace_id', $workspaceId)->find($id);
if (! $dataSource) {
return $this->response->setJSON([
'success' => false,
'message' => 'Data source not found.',
])->setStatusCode(404);
}
$cacheKey = 'ds_schema_' . $id . '_' . md5((string) ($dataSource['updated_at'] ?? ''));
$cached = cache()->get($cacheKey);
if (is_array($cached)) {
return $this->response->setJSON([
'success' => true,
'message' => 'Schema loaded from cache.',
'data' => $cached,
]);
}
try {
$schemaData = $this->loadSchema((string) $dataSource['type'], $dataSource);
cache()->save($cacheKey, $schemaData, 300);
return $this->response->setJSON([
'success' => true,
'message' => 'Schema loaded successfully.',
'data' => $schemaData,
]);
} catch (Throwable $e) {
return $this->response->setJSON([
'success' => false,
'message' => $e->getMessage(),
])->setStatusCode(422);
}
}
private function baseRules(bool $requirePassword = true): array
{
$passwordRule = $requirePassword ? 'permit_empty|max_length[1000]' : 'permit_empty|max_length[1000]';
return [
'name' => 'required|min_length[3]|max_length[150]',
'type' => 'required|in_list[mysql,postgresql,mongodb,rest_api,csv]',
'host' => 'permit_empty|max_length[255]',
'port' => 'permit_empty|integer|greater_than_equal_to[1]|less_than_equal_to[65535]',
'database_name' => 'permit_empty|max_length[150]',
'username' => 'permit_empty|max_length[150]',
'password' => $passwordRule,
'connection_uri' => 'permit_empty|max_length[5000]',
'api_base_url' => 'permit_empty|max_length[500]|valid_url_strict',
'api_method' => 'permit_empty|in_list[GET,POST]',
'api_auth_type' => 'permit_empty|in_list[none,bearer,basic,api_key]',
'api_auth_value' => 'permit_empty|max_length[5000]',
'csv_file_path' => 'permit_empty|max_length[500]',
'csv_delimiter' => 'permit_empty|max_length[1]',
];
}
private function buildPayload(int $workspaceId): array
{
$headersRaw = trim((string) $this->request->getPost('api_headers'));
$data = [
'workspace_id' => $workspaceId,
'name' => strip_tags((string) $this->request->getPost('name')),
'type' => (string) $this->request->getPost('type'),
'host' => trim((string) $this->request->getPost('host')) ?: null,
'port' => $this->request->getPost('port') !== null && $this->request->getPost('port') !== '' ? (int) $this->request->getPost('port') : null,
'database_name' => trim((string) $this->request->getPost('database_name')) ?: null,
'username' => trim((string) $this->request->getPost('username')) ?: null,
'password' => (string) $this->request->getPost('password'),
'connection_uri' => trim((string) $this->request->getPost('connection_uri')) ?: null,
'ssl_enabled' => $this->request->getPost('ssl_enabled') ? 1 : 0,
'ssl_ca' => trim((string) $this->request->getPost('ssl_ca')) ?: null,
'api_base_url' => trim((string) $this->request->getPost('api_base_url')) ?: null,
'api_method' => (string) ($this->request->getPost('api_method') ?: 'GET'),
'api_auth_type' => (string) ($this->request->getPost('api_auth_type') ?: 'none'),
'api_auth_value' => (string) $this->request->getPost('api_auth_value'),
'api_headers' => $headersRaw !== '' ? json_encode($this->sanitizeHeadersJson($headersRaw)) : null,
'csv_file_path' => trim((string) $this->request->getPost('csv_file_path')) ?: null,
'csv_delimiter' => trim((string) $this->request->getPost('csv_delimiter')) ?: ',',
];
return $this->normalizeForType($data);
}
private function normalizeForType(array $data): array
{
$type = (string) ($data['type'] ?? '');
if (in_array($type, ['mysql', 'postgresql'], true)) {
$data['api_base_url'] = null;
$data['api_auth_type'] = 'none';
$data['api_auth_value'] = null;
$data['api_headers'] = null;
$data['csv_file_path'] = null;
$data['csv_delimiter'] = ',';
return $data;
}
if ($type === 'mongodb') {
$data['database_name'] = null;
$data['api_base_url'] = null;
$data['api_auth_type'] = 'none';
$data['api_auth_value'] = null;
$data['api_headers'] = null;
$data['csv_file_path'] = null;
$data['csv_delimiter'] = ',';
return $data;
}
if ($type === 'rest_api') {
$data['host'] = null;
$data['port'] = null;
$data['database_name'] = null;
$data['username'] = null;
$data['password'] = null;
$data['connection_uri'] = null;
$data['ssl_enabled'] = 0;
$data['ssl_ca'] = null;
$data['csv_file_path'] = null;
$data['csv_delimiter'] = ',';
return $data;
}
if ($type === 'csv') {
$data['host'] = null;
$data['port'] = null;
$data['database_name'] = null;
$data['username'] = null;
$data['password'] = null;
$data['connection_uri'] = null;
$data['ssl_enabled'] = 0;
$data['ssl_ca'] = null;
$data['api_base_url'] = null;
$data['api_auth_type'] = 'none';
$data['api_auth_value'] = null;
$data['api_headers'] = null;
}
return $data;
}
private function sanitizeHeadersJson(string $json): array
{
$decoded = json_decode($json, true);
if (! is_array($decoded)) {
return [];
}
$clean = [];
foreach ($decoded as $key => $value) {
$cleanKey = strip_tags((string) $key);
if ($cleanKey === '') {
continue;
}
$clean[$cleanKey] = strip_tags((string) $value);
}
return $clean;
}
private function buildConnectionProof(array $dataSource): array
{
$proof = [
'success' => false,
'message' => 'Connection proof not available.',
'meta' => [],
'data' => [],
];
try {
$connector = (new ConnectionFactory())->make((string) $dataSource['type']);
$test = $connector->test($dataSource);
$proof['success'] = (bool) ($test['success'] ?? false);
$proof['message'] = (string) ($test['message'] ?? $proof['message']);
} catch (Throwable $e) {
$proof['message'] = $e->getMessage();
return $proof;
}
if (! $proof['success']) {
return $proof;
}
$type = (string) $dataSource['type'];
if (in_array($type, ['mysql', 'postgresql'], true)) {
try {
$schema = $this->loadSchema($type, $dataSource);
$tables = $schema['tables'] ?? [];
$proof['meta']['table_count'] = count($tables);
$proof['data']['tables'] = $tables;
} catch (Throwable $e) {
$proof['message'] = 'Connected, but unable to load schema: ' . $e->getMessage();
}
return $proof;
}
if ($type === 'rest_api') {
try {
$preview = $this->fetchApiPreview($dataSource);
$proof['meta']['http_code'] = $preview['http_code'];
$proof['data']['api_preview'] = $preview['data'];
$proof['data']['api_raw'] = $preview['raw'];
} catch (Throwable $e) {
$proof['message'] = 'Connected, but unable to fetch API preview: ' . $e->getMessage();
}
return $proof;
}
if ($type === 'csv') {
try {
$csv = $this->loadCsvPreview($dataSource);
$proof['meta']['row_count_preview'] = count($csv['rows']);
$proof['data']['csv_headers'] = $csv['headers'];
$proof['data']['csv_rows'] = $csv['rows'];
} catch (Throwable $e) {
$proof['message'] = 'Connected, but unable to read CSV preview: ' . $e->getMessage();
}
}
return $proof;
}
private function loadSchema(string $type, array $config): array
{
if ($type === 'mysql') {
return $this->loadMySqlSchema($config);
}
if ($type === 'postgresql') {
return $this->loadPostgreSqlSchema($config);
}
throw new \RuntimeException('Schema browsing is currently supported only for MySQL and PostgreSQL sources.');
}
private function loadMySqlSchema(array $config): array
{
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',
$config['host'] ?? '',
(int) ($config['port'] ?? 3306),
$config['database_name'] ?? ''
);
try {
$pdo = new PDO(
$dsn,
(string) ($config['username'] ?? ''),
(string) ($config['password'] ?? ''),
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_TIMEOUT => 10,
]
);
} catch (PDOException $e) {
throw new \RuntimeException('MySQL schema connection failed: ' . $e->getMessage());
}
$tablesStmt = $pdo->query('SHOW TABLES');
$tableRows = $tablesStmt ? $tablesStmt->fetchAll(PDO::FETCH_NUM) : [];
$tables = [];
foreach ($tableRows as $tableRow) {
$tableName = (string) ($tableRow[0] ?? '');
if ($tableName === '') {
continue;
}
$columnsStmt = $pdo->prepare('SHOW COLUMNS FROM `' . str_replace('`', '``', $tableName) . '`');
$columnsStmt->execute();
$columnsRows = $columnsStmt->fetchAll(PDO::FETCH_ASSOC);
$columns = [];
foreach ($columnsRows as $column) {
$columns[] = [
'name' => (string) ($column['Field'] ?? ''),
'type' => (string) ($column['Type'] ?? ''),
'nullable' => (string) ($column['Null'] ?? '') === 'YES',
];
}
$tables[] = [
'name' => $tableName,
'columns' => $columns,
];
}
return ['tables' => $tables];
}
private function loadPostgreSqlSchema(array $config): array
{
$dsn = sprintf(
'pgsql:host=%s;port=%d;dbname=%s',
$config['host'] ?? '',
(int) ($config['port'] ?? 5432),
$config['database_name'] ?? ''
);
try {
$pdo = new PDO(
$dsn,
(string) ($config['username'] ?? ''),
(string) ($config['password'] ?? ''),
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_TIMEOUT => 10,
]
);
} catch (PDOException $e) {
throw new \RuntimeException('PostgreSQL schema connection failed: ' . $e->getMessage());
}
$tableStmt = $pdo->query(
"SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
ORDER BY table_name"
);
$tableRows = $tableStmt ? $tableStmt->fetchAll(PDO::FETCH_ASSOC) : [];
$tables = [];
$columnStmt = $pdo->prepare(
"SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = :table
ORDER BY ordinal_position"
);
foreach ($tableRows as $row) {
$tableName = (string) ($row['table_name'] ?? '');
if ($tableName === '') {
continue;
}
$columnStmt->execute(['table' => $tableName]);
$columnsRows = $columnStmt->fetchAll(PDO::FETCH_ASSOC);
$columns = [];
foreach ($columnsRows as $column) {
$columns[] = [
'name' => (string) ($column['column_name'] ?? ''),
'type' => (string) ($column['data_type'] ?? ''),
'nullable' => (string) ($column['is_nullable'] ?? '') === 'YES',
];
}
$tables[] = [
'name' => $tableName,
'columns' => $columns,
];
}
return ['tables' => $tables];
}
private function fetchApiPreview(array $config): array
{
$url = (string) ($config['api_base_url'] ?? '');
if ($url === '' || ! filter_var($url, FILTER_VALIDATE_URL)) {
throw new \RuntimeException('A valid API base URL is required.');
}
$headers = ['Accept: application/json'];
$authType = (string) ($config['api_auth_type'] ?? 'none');
$authValue = (string) ($config['api_auth_value'] ?? '');
if ($authType === 'bearer' && $authValue !== '') {
$headers[] = 'Authorization: Bearer ' . $authValue;
} elseif ($authType === 'api_key' && $authValue !== '') {
$headers[] = 'X-API-KEY: ' . $authValue;
} elseif ($authType === 'basic' && $authValue !== '') {
$headers[] = 'Authorization: Basic ' . base64_encode($authValue);
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => (string) ($config['api_method'] ?? 'GET'),
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => $headers,
]);
$raw = (string) curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($error !== '') {
throw new \RuntimeException($error);
}
$decoded = json_decode($raw, true);
$preview = is_array($decoded) ? $decoded : null;
if (is_array($preview)) {
// Keep preview concise for UI readability.
$preview = $this->limitDepth($preview, 2, 20);
}
return [
'http_code' => $httpCode,
'data' => $preview,
'raw' => mb_substr($raw, 0, 2000),
];
}
private function loadCsvPreview(array $config): array
{
$path = (string) ($config['csv_file_path'] ?? '');
if ($path === '' || ! is_file($path)) {
throw new \RuntimeException('CSV file not found.');
}
$delimiter = (string) ($config['csv_delimiter'] ?? ',');
$handle = fopen($path, 'r');
if ($handle === false) {
throw new \RuntimeException('Unable to open CSV file.');
}
$headers = fgetcsv($handle, 0, $delimiter);
if ($headers === false) {
fclose($handle);
throw new \RuntimeException('CSV appears empty.');
}
$rows = [];
$limit = 20;
while (($row = fgetcsv($handle, 0, $delimiter)) !== false && count($rows) < $limit) {
$rowAssoc = [];
foreach ($headers as $i => $header) {
$rowAssoc[(string) $header] = (string) ($row[$i] ?? '');
}
$rows[] = $rowAssoc;
}
fclose($handle);
return [
'headers' => array_map(static fn($h) => (string) $h, $headers),
'rows' => $rows,
];
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function snapshotDataSource(array $row): array
{
$keys = ['id', 'name', 'type', 'host', 'port', 'database_name', 'username', 'api_base_url', 'status'];
$out = [];
foreach ($keys as $k) {
if (array_key_exists($k, $row)) {
$out[$k] = $row[$k];
}
}
return $out;
}
private function limitDepth(array $data, int $depth, int $limit): array
{
if ($depth <= 0) {
return [];
}
$count = 0;
$result = [];
foreach ($data as $key => $value) {
if ($count >= $limit) {
break;
}
$count++;
if (is_array($value)) {
$result[$key] = $this->limitDepth($value, $depth - 1, $limit);
continue;
}
$result[$key] = $value;
}
return $result;
}
}