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 $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 $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 $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> $widgets * @return list> */ 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; } }