Телевізор нікуди не залогиниш — зранку на стіні висітиме форма входу замість карти мережі. Тому /tv/<токен> розгалужується до перевірки сесії, а доступ дає токен у посиланні. Публічний зріз навмисно вузький: розкладка, алерти, хости й метрики ЛИШЕ тих хостів, які показані на цьому дашборді. Ширший доступ був би простішим у коді й перетворив би забутий лінк на ключ до кабінету. Плитки ті самі, що в кабінеті: джерело даних підмінюється контекстом. Дорогою знайдено дві помилки. Вбудовані icmp-тригери мали умову у формі, якої движок не розуміє, і мовчали, засипаючи журнал помилками — виправлено міграцією 0026 і в редакторі. База стенда виявилась у SQL_ASCII: тепер міграція відмовляється накочуватись на не-UTF8, а compose задає кодування явно. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
154 lines
5 KiB
Go
154 lines
5 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
)
|
||
|
||
// SetDashboardPublicToken видає або відкликає посилання для телевізора.
|
||
//
|
||
// Порожній токен відкликає: старе посилання одразу перестає працювати.
|
||
// Це головна операція безпеки для цієї можливості — лінк висить у
|
||
// відкритому браузері в кімнаті, куди заходять різні люди, і мусить
|
||
// вимикатись одним рухом.
|
||
func (s *Store) SetDashboardPublicToken(ctx context.Context, tenantID, id, token string) error {
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
tag, err := tx.Exec(ctx, `
|
||
UPDATE core.dashboards
|
||
SET public_token = NULLIF($3,''), updated_at = now()
|
||
WHERE id = $1 AND tenant_id = $2
|
||
`, id, tenantID, token)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if tag.RowsAffected() == 0 {
|
||
return ErrNotFound
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// DashboardByPublicToken віддає дашборд разом із тенантом, якому він
|
||
// належить.
|
||
//
|
||
// Без tenantID на вході — його ще нема звідки взяти: телевізор не
|
||
// проходить вхід, і токен у посиланні є єдиним, що ідентифікує і
|
||
// дашборд, і кабінет. Саме тому пошук іде по унікальному індексу
|
||
// public_token, а знайдений tenant_id далі обмежує все, що віддається.
|
||
func (s *Store) DashboardByPublicToken(ctx context.Context, token string) (string, *Dashboard, error) {
|
||
if token == "" {
|
||
return "", nil, ErrNotFound
|
||
}
|
||
|
||
var (
|
||
tenantID string
|
||
d Dashboard
|
||
)
|
||
err := s.pool.QueryRow(ctx, `
|
||
SELECT tenant_id::text, id::text, name, slug, kind::text, refresh_sec, is_default
|
||
FROM core.dashboards WHERE public_token = $1
|
||
`, token).Scan(&tenantID, &d.ID, &d.Name, &d.Slug, &d.Kind, &d.RefreshSec, &d.IsDefault)
|
||
if err != nil {
|
||
if isNoRows(err) {
|
||
return "", nil, ErrNotFound
|
||
}
|
||
return "", nil, err
|
||
}
|
||
|
||
err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT id::text, kind::text, COALESCE(title,''),
|
||
grid_x, grid_y, grid_w, grid_h,
|
||
COALESCE(map_id::text,''), queries::text, options::text
|
||
FROM core.dashboard_widgets
|
||
WHERE dashboard_id = $1
|
||
ORDER BY grid_y, grid_x
|
||
`, d.ID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
|
||
d.Widgets = []Widget{}
|
||
for rows.Next() {
|
||
var w Widget
|
||
var queries, options string
|
||
if err := rows.Scan(&w.ID, &w.Kind, &w.Title, &w.GridX, &w.GridY,
|
||
&w.GridW, &w.GridH, &w.MapID, &queries, &options); err != nil {
|
||
return err
|
||
}
|
||
w.Queries = json.RawMessage(queries)
|
||
w.Options = json.RawMessage(options)
|
||
d.Widgets = append(d.Widgets, w)
|
||
}
|
||
d.WidgetCount = len(d.Widgets)
|
||
return rows.Err()
|
||
})
|
||
if err != nil {
|
||
return "", nil, err
|
||
}
|
||
return tenantID, &d, nil
|
||
}
|
||
|
||
// DashboardWidgetDevices — хости, згадані плитками дашборда.
|
||
//
|
||
// Потрібні, щоб публічний доступ не перетворився на читання всього
|
||
// інвентарю: телевізор бачить рівно ті хости, які сам показує, і жодним
|
||
// підбором ідентифікаторів більше не дістане.
|
||
func (s *Store) DashboardWidgetDevices(ctx context.Context, tenantID, dashboardID string) (map[string]bool, error) {
|
||
out := map[string]bool{}
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT queries::text FROM core.dashboard_widgets WHERE dashboard_id = $1
|
||
`, dashboardID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
|
||
for rows.Next() {
|
||
var raw string
|
||
if err := rows.Scan(&raw); err != nil {
|
||
return err
|
||
}
|
||
for _, id := range deviceIDsFromQueries(raw) {
|
||
out[id] = true
|
||
}
|
||
}
|
||
return rows.Err()
|
||
})
|
||
return out, err
|
||
}
|
||
|
||
// deviceIDsFromQueries дістає device_id з опису плитки.
|
||
//
|
||
// Опис зберігається як довільний jsonb — його форму знає фронтенд, а не
|
||
// сервер. Тому дістаємо м'яко: і об'єкт, і масив об'єктів, а все, чого
|
||
// не впізнали, просто не дає ідентифікаторів.
|
||
func deviceIDsFromQueries(raw string) []string {
|
||
if raw == "" || raw == "null" {
|
||
return nil
|
||
}
|
||
type q struct {
|
||
DeviceID string `json:"device_id"`
|
||
}
|
||
|
||
var one q
|
||
if err := json.Unmarshal([]byte(raw), &one); err == nil && one.DeviceID != "" {
|
||
return []string{one.DeviceID}
|
||
}
|
||
|
||
var many []q
|
||
if err := json.Unmarshal([]byte(raw), &many); err == nil {
|
||
out := make([]string, 0, len(many))
|
||
for _, x := range many {
|
||
if x.DeviceID != "" {
|
||
out = append(out, x.DeviceID)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
return nil
|
||
}
|