chartboard/app/Controllers/Share/PublicController.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;
}
}