470 lines
18 KiB
PHP
470 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Controllers\Share;
|
|
|
|
use App\Controllers\BaseController;
|
|
use App\Libraries\ChartRenderer;
|
|
use App\Libraries\SavedQueryRunner;
|
|
use App\Models\ChartModel;
|
|
use App\Models\DashboardModel;
|
|
use App\Models\DashboardWidgetModel;
|
|
use App\Models\DataSourceModel;
|
|
use App\Models\QueryVariableModel;
|
|
use App\Models\SavedQueryModel;
|
|
use App\Models\SharedLinkModel;
|
|
use CodeIgniter\HTTP\ResponseInterface;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Unauthenticated public share views (dashboard / chart).
|
|
*/
|
|
class PublicController extends BaseController
|
|
{
|
|
private function applyEmbedHeaders(): void
|
|
{
|
|
$this->response->removeHeader('X-Frame-Options');
|
|
$this->response->setHeader('Content-Security-Policy', 'frame-ancestors *');
|
|
}
|
|
|
|
/**
|
|
* @return ResponseInterface|string
|
|
*/
|
|
public function show(string $token)
|
|
{
|
|
$this->applyEmbedHeaders();
|
|
$model = new SharedLinkModel();
|
|
$row = $model->where('token', $token)->first();
|
|
|
|
if (! $row) {
|
|
return $this->response->setStatusCode(404)->setBody(view('share/gone', ['title' => 'Link not found']));
|
|
}
|
|
|
|
if (! (int) ($row['is_active'] ?? 0)) {
|
|
return $this->response->setStatusCode(410)->setBody(view('share/revoked', ['title' => 'Link revoked']));
|
|
}
|
|
|
|
$exp = $row['expires_at'] ?? null;
|
|
if ($exp && strtotime((string) $exp) < time()) {
|
|
return $this->response->setStatusCode(410)->setBody(view('share/expired', ['title' => 'Link expired']));
|
|
}
|
|
|
|
$session = session();
|
|
$unlockKey = 'share_unlocked_' . $token;
|
|
$hash = $row['password_hash'] ?? null;
|
|
if ($hash && ! $session->get($unlockKey)) {
|
|
$embedQ = (string) $this->request->getGet('embed') === '1' ? 'embed=1' : '';
|
|
|
|
return view('share/password', [
|
|
'title' => 'Protected link | Chart-Board',
|
|
'token' => $token,
|
|
'redirect_q' => $embedQ,
|
|
]);
|
|
}
|
|
|
|
$linkId = (int) $row['id'];
|
|
$vcKey = 'share_vc_' . $linkId;
|
|
if (! $session->get($vcKey)) {
|
|
$model->incrementViewCount($linkId);
|
|
$session->set($vcKey, true);
|
|
}
|
|
|
|
$embed = (string) $this->request->getGet('embed') === '1';
|
|
|
|
if ($row['type'] === 'chart') {
|
|
return $this->renderSharedChart($row, $embed);
|
|
}
|
|
|
|
return $this->renderSharedDashboard($row, $embed);
|
|
}
|
|
|
|
public function unlock(string $token)
|
|
{
|
|
$this->applyEmbedHeaders();
|
|
$model = new SharedLinkModel();
|
|
$row = $model->where('token', $token)->first();
|
|
|
|
if (! $row || ! (int) ($row['is_active'] ?? 0)) {
|
|
return redirect()->to('/share/' . $token)->with('error', 'Invalid link.');
|
|
}
|
|
|
|
$exp = $row['expires_at'] ?? null;
|
|
if ($exp && strtotime((string) $exp) < time()) {
|
|
return redirect()->to('/share/' . $token)->with('error', 'This link has expired.');
|
|
}
|
|
|
|
$hash = $row['password_hash'] ?? null;
|
|
if (! $hash) {
|
|
return redirect()->to('/share/' . $token);
|
|
}
|
|
|
|
$pwd = (string) $this->request->getPost('password');
|
|
if ($pwd === '' || ! password_verify($pwd, (string) $hash)) {
|
|
return redirect()->back()->with('error', 'Incorrect password.');
|
|
}
|
|
|
|
session()->set('share_unlocked_' . $token, true);
|
|
|
|
$q = trim((string) $this->request->getPost('redirect_q'));
|
|
$target = '/share/' . $token . ($q !== '' ? '?' . $q : '');
|
|
|
|
return redirect()->to($target);
|
|
}
|
|
|
|
public function chartData(string $token, int $chartId)
|
|
{
|
|
$this->applyEmbedHeaders();
|
|
$model = new SharedLinkModel();
|
|
$link = $model->findActiveByToken($token);
|
|
|
|
if (! $link) {
|
|
return $this->response->setJSON(['success' => false, 'message' => 'Invalid or expired link.'])->setStatusCode(403);
|
|
}
|
|
|
|
$hash = $link['password_hash'] ?? null;
|
|
if ($hash && ! session()->get('share_unlocked_' . $token)) {
|
|
return $this->response->setJSON(['success' => false, 'message' => 'Password required.'])->setStatusCode(403);
|
|
}
|
|
|
|
if (! $this->chartAllowed($link, $chartId)) {
|
|
return $this->response->setJSON(['success' => false, 'message' => 'Chart not in share.'])->setStatusCode(403);
|
|
}
|
|
|
|
$workspaceId = (int) $link['workspace_id'];
|
|
$chartModel = new ChartModel();
|
|
$chart = $chartModel->find($chartId);
|
|
|
|
if (! $chart || (int) $chart['workspace_id'] !== $workspaceId) {
|
|
return $this->response->setJSON(['success' => false, 'message' => 'Chart not found.'])->setStatusCode(404);
|
|
}
|
|
|
|
$savedQueryId = (int) ($chart['saved_query_id'] ?? 0);
|
|
if ($savedQueryId <= 0) {
|
|
return $this->response->setJSON(['success' => false, 'message' => 'Chart has no linked query.'])->setStatusCode(422);
|
|
}
|
|
|
|
$savedQuery = (new SavedQueryModel())->where('workspace_id', $workspaceId)->find($savedQueryId);
|
|
if (! $savedQuery) {
|
|
return $this->response->setJSON(['success' => false, 'message' => 'Query not found.'])->setStatusCode(404);
|
|
}
|
|
|
|
$dataSource = (new DataSourceModel())->where('workspace_id', $workspaceId)->find((int) $savedQuery['data_source_id']);
|
|
if (! $dataSource) {
|
|
return $this->response->setJSON(['success' => false, 'message' => 'Data source not found.'])->setStatusCode(404);
|
|
}
|
|
|
|
$variableValues = json_decode((string) $this->request->getPost('variables_json'), true);
|
|
if (! is_array($variableValues)) {
|
|
$variableValues = [];
|
|
}
|
|
|
|
try {
|
|
$runner = new SavedQueryRunner();
|
|
$result = $runner->run($workspaceId, $dataSource, $savedQuery, $variableValues);
|
|
$renderer = new ChartRenderer();
|
|
$payload = $renderer->buildPayload($chart, $result['rows']);
|
|
|
|
return $this->response->setJSON([
|
|
'success' => true,
|
|
'meta' => [
|
|
'row_count' => count($result['rows']),
|
|
'execution_ms' => $result['execution_ms'],
|
|
'cache_hit' => $result['cache_hit'],
|
|
'chart_type' => (string) $chart['chart_type'],
|
|
],
|
|
'payload' => $payload,
|
|
]);
|
|
} catch (Throwable $e) {
|
|
return $this->response->setJSON([
|
|
'success' => false,
|
|
'message' => $e->getMessage(),
|
|
])->setStatusCode(422);
|
|
}
|
|
}
|
|
|
|
public function dashboardVariables(string $token)
|
|
{
|
|
$this->applyEmbedHeaders();
|
|
$model = new SharedLinkModel();
|
|
$link = $model->findActiveByToken($token);
|
|
if (! $link || ($link['type'] ?? '') !== 'dashboard') {
|
|
return $this->response->setJSON(['success' => false, 'message' => 'Invalid or expired link.'])->setStatusCode(403);
|
|
}
|
|
|
|
$hash = $link['password_hash'] ?? null;
|
|
if ($hash && ! session()->get('share_unlocked_' . $token)) {
|
|
return $this->response->setJSON(['success' => false, 'message' => 'Password required.'])->setStatusCode(403);
|
|
}
|
|
|
|
$dashId = (int) ($link['resource_id'] ?? 0);
|
|
$workspaceId = (int) ($link['workspace_id'] ?? 0);
|
|
if ($dashId <= 0 || $workspaceId <= 0) {
|
|
return $this->response->setJSON(['success' => false, 'message' => 'Invalid dashboard.'])->setStatusCode(422);
|
|
}
|
|
|
|
$widgets = (new DashboardWidgetModel())->forDashboard($dashId);
|
|
$queryIds = [];
|
|
foreach ($widgets as $w) {
|
|
if (($w['widget_type'] ?? '') !== 'chart') {
|
|
continue;
|
|
}
|
|
$qid = (int) ($w['saved_query_id'] ?? 0);
|
|
if ($qid > 0) {
|
|
$queryIds[$qid] = true;
|
|
}
|
|
}
|
|
$queryIds = array_keys($queryIds);
|
|
if ($queryIds === []) {
|
|
return $this->response->setJSON(['success' => true, 'variables' => []]);
|
|
}
|
|
|
|
$rows = (new QueryVariableModel())
|
|
->whereIn('saved_query_id', $queryIds)
|
|
->where('workspace_id', $workspaceId)
|
|
->orderBy('saved_query_id', 'ASC')
|
|
->orderBy('sort_order', 'ASC')
|
|
->findAll();
|
|
|
|
$merged = [];
|
|
foreach ($rows as $row) {
|
|
$name = (string) ($row['name'] ?? '');
|
|
if ($name === '') {
|
|
continue;
|
|
}
|
|
$options = [];
|
|
$optsRaw = $row['options_json'] ?? null;
|
|
if ($optsRaw !== null && $optsRaw !== '') {
|
|
$decoded = json_decode((string) $optsRaw, true);
|
|
if (is_array($decoded)) {
|
|
$options = $decoded;
|
|
}
|
|
}
|
|
if (! isset($merged[$name])) {
|
|
$merged[$name] = [
|
|
'name' => $name,
|
|
'label' => (string) ($row['label'] ?? $name),
|
|
'type' => (string) ($row['type'] ?? 'text'),
|
|
'default_value' => (string) ($row['default_value'] ?? ''),
|
|
'is_required' => ! empty($row['is_required']),
|
|
'options' => $options,
|
|
];
|
|
continue;
|
|
}
|
|
$merged[$name]['is_required'] = $merged[$name]['is_required'] || ! empty($row['is_required']);
|
|
if ($merged[$name]['default_value'] === '' && (string) ($row['default_value'] ?? '') !== '') {
|
|
$merged[$name]['default_value'] = (string) $row['default_value'];
|
|
}
|
|
if (($merged[$name]['label'] ?? '') === '' && (string) ($row['label'] ?? '') !== '') {
|
|
$merged[$name]['label'] = (string) $row['label'];
|
|
}
|
|
if (($merged[$name]['type'] ?? 'text') === 'text' && (string) ($row['type'] ?? 'text') !== 'text') {
|
|
$merged[$name]['type'] = (string) $row['type'];
|
|
}
|
|
if (is_array($options) && $options !== []) {
|
|
$existing = is_array($merged[$name]['options']) ? $merged[$name]['options'] : [];
|
|
foreach ($options as $opt) {
|
|
if (! in_array($opt, $existing, true)) {
|
|
$existing[] = $opt;
|
|
}
|
|
}
|
|
$merged[$name]['options'] = $existing;
|
|
}
|
|
}
|
|
|
|
return $this->response->setJSON([
|
|
'success' => true,
|
|
'variables' => array_values($merged),
|
|
]);
|
|
}
|
|
|
|
public function savedQueryVariables(string $token, int $savedQueryId)
|
|
{
|
|
$this->applyEmbedHeaders();
|
|
$model = new SharedLinkModel();
|
|
$link = $model->findActiveByToken($token);
|
|
if (! $link || ($link['type'] ?? '') !== 'dashboard') {
|
|
return $this->response->setJSON(['success' => false, 'message' => 'Invalid or expired link.'])->setStatusCode(403);
|
|
}
|
|
|
|
$hash = $link['password_hash'] ?? null;
|
|
if ($hash && ! session()->get('share_unlocked_' . $token)) {
|
|
return $this->response->setJSON(['success' => false, 'message' => 'Password required.'])->setStatusCode(403);
|
|
}
|
|
|
|
$dashId = (int) ($link['resource_id'] ?? 0);
|
|
$workspaceId = (int) ($link['workspace_id'] ?? 0);
|
|
if ($dashId <= 0 || $workspaceId <= 0 || $savedQueryId <= 0) {
|
|
return $this->response->setJSON(['success' => false, 'message' => 'Invalid request.'])->setStatusCode(422);
|
|
}
|
|
|
|
$widgets = (new DashboardWidgetModel())->forDashboard($dashId);
|
|
$allowed = false;
|
|
foreach ($widgets as $w) {
|
|
if (($w['widget_type'] ?? '') !== 'chart') {
|
|
continue;
|
|
}
|
|
if ((int) ($w['saved_query_id'] ?? 0) === $savedQueryId) {
|
|
$allowed = true;
|
|
break;
|
|
}
|
|
}
|
|
if (! $allowed) {
|
|
return $this->response->setJSON(['success' => false, 'message' => 'Query not in shared dashboard.'])->setStatusCode(403);
|
|
}
|
|
|
|
$rows = (new QueryVariableModel())
|
|
->where('saved_query_id', $savedQueryId)
|
|
->where('workspace_id', $workspaceId)
|
|
->orderBy('sort_order', 'ASC')
|
|
->findAll();
|
|
|
|
$vars = [];
|
|
foreach ($rows as $row) {
|
|
$optsRaw = $row['options_json'] ?? null;
|
|
$options = [];
|
|
if ($optsRaw !== null && $optsRaw !== '') {
|
|
$decoded = json_decode((string) $optsRaw, true);
|
|
$options = is_array($decoded) ? $decoded : [];
|
|
}
|
|
$vars[] = [
|
|
'name' => (string) ($row['name'] ?? ''),
|
|
'label' => (string) ($row['label'] ?? ''),
|
|
'type' => (string) ($row['type'] ?? 'text'),
|
|
'default_value' => (string) ($row['default_value'] ?? ''),
|
|
'is_required' => ! empty($row['is_required']),
|
|
'options' => $options,
|
|
];
|
|
}
|
|
|
|
return $this->response->setJSON(['success' => true, 'variables' => $vars]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $link
|
|
* @return string
|
|
*/
|
|
private function renderSharedChart(array $link, bool $embed)
|
|
{
|
|
$chartId = (int) $link['resource_id'];
|
|
if (! $this->chartAllowed($link, $chartId)) {
|
|
return $this->response->setStatusCode(404)->setBody(view('share/gone', ['title' => 'Not found']));
|
|
}
|
|
|
|
$chart = (new ChartModel())->find($chartId);
|
|
if (! $chart || (int) $chart['workspace_id'] !== (int) $link['workspace_id']) {
|
|
return $this->response->setStatusCode(404)->setBody(view('share/gone', ['title' => 'Not found']));
|
|
}
|
|
|
|
$layout = $embed ? 'layouts/embed' : 'layouts/embed';
|
|
$token = (string) $link['token'];
|
|
|
|
return view('share/chart_view', [
|
|
'title' => (string) $chart['name'] . ' | Chart-Board',
|
|
'layout' => $layout,
|
|
'embed' => $embed,
|
|
'token' => $token,
|
|
'chartId' => $chartId,
|
|
'chart' => $chart,
|
|
'shareUrl' => rtrim(base_url(), '/') . '/share/' . $token,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $link
|
|
* @return string
|
|
*/
|
|
private function renderSharedDashboard(array $link, bool $embed)
|
|
{
|
|
$dashId = (int) $link['resource_id'];
|
|
$dashboard = (new DashboardModel())->find($dashId);
|
|
if (! $dashboard || (int) $dashboard['workspace_id'] !== (int) $link['workspace_id']) {
|
|
return $this->response->setStatusCode(404)->setBody(view('share/gone', ['title' => 'Not found']));
|
|
}
|
|
|
|
$widgets = (new DashboardWidgetModel())->forDashboard($dashId);
|
|
$token = (string) $link['token'];
|
|
|
|
$dashBoot = [
|
|
'dashboardId' => $dashId,
|
|
'dashboardName' => (string) ($dashboard['name'] ?? 'dashboard'),
|
|
'workspaceTheme'=> (string) ($dashboard['theme'] ?? 'system'),
|
|
'refreshSec' => max(0, (int) ($dashboard['refresh_interval'] ?? 0)),
|
|
'widgets' => $this->widgetsBootPayload($widgets),
|
|
'charts' => [],
|
|
'urls' => [
|
|
'chartData' => rtrim(base_url(), '/') . '/share/' . $token . '/chart/',
|
|
'dashboardVariables' => rtrim(base_url(), '/') . '/share/' . $token . '/dashboard/variables',
|
|
'saveLayout' => '',
|
|
'addWidget' => '',
|
|
'delWidget' => '',
|
|
'savedQueryVars' => rtrim(base_url(), '/') . '/share/' . $token . '/saved-query/',
|
|
],
|
|
'csrf' => [
|
|
'name' => '',
|
|
'hash' => '',
|
|
],
|
|
'publicShare' => true,
|
|
];
|
|
|
|
return view('share/dashboard_view', [
|
|
'title' => (string) $dashboard['name'] . ' | Chart-Board',
|
|
'layout' => $embed ? 'layouts/embed' : 'layouts/embed',
|
|
'embed' => $embed,
|
|
'dashboard' => $dashboard,
|
|
'widgets' => $widgets,
|
|
'dashBoot' => $dashBoot,
|
|
'shareUrl' => rtrim(base_url(), '/') . '/share/' . $token,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $link
|
|
*/
|
|
private function chartAllowed(array $link, int $chartId): bool
|
|
{
|
|
if ($link['type'] === 'chart') {
|
|
return (int) $link['resource_id'] === $chartId;
|
|
}
|
|
|
|
$w = (new DashboardWidgetModel())
|
|
->where('dashboard_id', (int) $link['resource_id'])
|
|
->where('chart_id', $chartId)
|
|
->where('widget_type', 'chart')
|
|
->first();
|
|
|
|
return $w !== null;
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $widgets
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function widgetsBootPayload(array $widgets): array
|
|
{
|
|
$out = [];
|
|
foreach ($widgets as $w) {
|
|
$cfg = null;
|
|
if (! empty($w['widget_config'])) {
|
|
$d = json_decode((string) $w['widget_config'], true);
|
|
$cfg = is_array($d) ? $d : null;
|
|
}
|
|
$out[] = [
|
|
'id' => (int) $w['id'],
|
|
'chart_id' => isset($w['chart_id']) ? (int) $w['chart_id'] : null,
|
|
'widget_type' => (string) ($w['widget_type'] ?? 'chart'),
|
|
'title' => $w['title'] !== null && $w['title'] !== '' ? (string) $w['title'] : null,
|
|
'grid_x' => (int) ($w['grid_x'] ?? 0),
|
|
'grid_y' => (int) ($w['grid_y'] ?? 0),
|
|
'grid_w' => (int) ($w['grid_w'] ?? 4),
|
|
'grid_h' => (int) ($w['grid_h'] ?? 3),
|
|
'content' => $w['content'] !== null ? (string) $w['content'] : null,
|
|
'widget_config' => $cfg,
|
|
'chart_name' => isset($w['chart_name']) ? (string) $w['chart_name'] : null,
|
|
'chart_type' => isset($w['chart_type']) ? (string) $w['chart_type'] : null,
|
|
'saved_query_id' => isset($w['saved_query_id']) ? (int) $w['saved_query_id'] : null,
|
|
];
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
}
|