mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 15:51:27 +00:00
feat(api): phase 5 — alerts, agent depth, subdomains, auth, boards
P5b: notifier transport registry (webhook/slack/discord/telegram/ gotify/ntfy/smtp via net/smtp), per-monitor+domain alert rules (failsBeforeDown, latencyWarn, certDays, mute). P5c: per-container cpu/mem, optional SMART/ZFS/GPU exec-or-skip collectors, crt.sh subdomain discovery with daily sweep. P5d: opt-in auth (bcrypt, sqlite sessions, 7d rotating cookie, login rate limit, public-route bypass) and multiple boards (boards table, sections.board_id, /api/boards CRUD, slug routing). Authored By: TDvorak <[email protected]> Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
8973be3b41
commit
f3143af351
33 files changed
+2772
-126
No files matched your search
@@ -0,0 +1,46 @@
|
||||
package api
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// alertRules is the per-target rules blob stored in monitors.alerts /
|
||||
// domains.alerts. Zero values keep pre-rules behavior: first failure alerts,
|
||||
// no latency rule, cert threshold = alertDaysBefore, unmuted.
|
||||
//
|
||||
// consecutiveFailures — monitor must fail N checks in a row before it flips down
|
||||
// latencyWarnMs — notify once when an up check's ping crosses above this
|
||||
// certDays — domain TLS-expiry threshold (overrides alertDaysBefore)
|
||||
// mute — suppress notifications; incidents/status still record
|
||||
type alertRules struct {
|
||||
ConsecutiveFailures int `json:"consecutiveFailures"`
|
||||
LatencyWarnMs int `json:"latencyWarnMs"`
|
||||
CertDays int `json:"certDays"`
|
||||
Mute bool `json:"mute"`
|
||||
}
|
||||
|
||||
func parseAlertRules(raw json.RawMessage) alertRules {
|
||||
var r alertRules
|
||||
_ = json.Unmarshal(raw, &r)
|
||||
return r
|
||||
}
|
||||
|
||||
// consecutiveDowns counts failed heartbeats, newest-first, stopping at the
|
||||
// first up. The just-written heartbeat is included.
|
||||
func (s *Server) consecutiveDowns(monitorID string, limit int) int {
|
||||
var n int
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM (
|
||||
SELECT status FROM heartbeats WHERE monitor_id = ?
|
||||
ORDER BY checked_at DESC, rowid DESC LIMIT ?)
|
||||
WHERE status = 'down'`, monitorID, limit).Scan(&n)
|
||||
return n
|
||||
}
|
||||
|
||||
// prevPing returns the ping of the heartbeat before the latest, or -1.
|
||||
func (s *Server) prevPing(monitorID string) int {
|
||||
var p int
|
||||
if err := s.db.QueryRow(`SELECT ping_ms FROM heartbeats
|
||||
WHERE monitor_id = ? ORDER BY checked_at DESC, rowid DESC
|
||||
LIMIT 1 OFFSET 1`, monitorID).Scan(&p); err != nil {
|
||||
return -1
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tdvorak/dash/internal/domain"
|
||||
)
|
||||
|
||||
// ---------- transports ----------
|
||||
|
||||
func TestNotifyTransports(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
// One receiver records body + headers + query per request.
|
||||
type hit struct {
|
||||
body map[string]any
|
||||
raw string
|
||||
title string
|
||||
path string
|
||||
}
|
||||
got := make(chan hit, 8)
|
||||
hook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var m map[string]any
|
||||
buf, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(buf, &m)
|
||||
got <- hit{body: m, raw: string(buf), title: r.Header.Get("Title"), path: r.URL.RequestURI()}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer hook.Close()
|
||||
|
||||
put := func(kv string) {
|
||||
if res, _ := do(t, "PUT", srv.URL+"/api/settings", kv); res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("put %s: %d", kv, res.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// slack + discord + gotify + ntfy all configured; test fires each.
|
||||
put(`{"notify_slack":{"url":"` + hook.URL + `/slack"}}`)
|
||||
put(`{"notify_discord":{"url":"` + hook.URL + `/discord"}}`)
|
||||
put(`{"notify_gotify":{"server":"` + hook.URL + `","token":"gtok"}}`)
|
||||
put(`{"notify_ntfy":{"server":"` + hook.URL + `","topic":"alerts"}}`)
|
||||
|
||||
res, out := do(t, "POST", srv.URL+"/api/notify/test", "")
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("notify test: %d", res.StatusCode)
|
||||
}
|
||||
results := out["results"].(map[string]any)
|
||||
for _, name := range []string{"slack", "discord", "gotify", "ntfy"} {
|
||||
if results[name] != "ok" {
|
||||
t.Fatalf("%s: %v", name, results[name])
|
||||
}
|
||||
}
|
||||
|
||||
seen := map[string]hit{}
|
||||
for i := 0; i < 4; i++ {
|
||||
select {
|
||||
case h := <-got:
|
||||
seen[h.path] = h
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("timed out waiting for transports")
|
||||
}
|
||||
}
|
||||
if seen["/slack"].body["text"] == "" {
|
||||
t.Fatalf("slack payload missing text: %v", seen["/slack"].body)
|
||||
}
|
||||
if seen["/discord"].body["content"] == "" {
|
||||
t.Fatalf("discord payload missing content: %v", seen["/discord"].body)
|
||||
}
|
||||
if !strings.HasPrefix(seen["/message?token=gtok"].path, "/message?token=") {
|
||||
t.Fatalf("gotify hit missing: %v", seen)
|
||||
}
|
||||
if h := seen["/alerts"]; h.raw != "Dash test notification" || h.title != "dash" {
|
||||
t.Fatalf("ntfy payload wrong: %q title=%q", h.raw, h.title)
|
||||
}
|
||||
|
||||
// Legacy string webhook still works alongside presets.
|
||||
put(`{"notify_webhook":"` + hook.URL + `/legacy"}`)
|
||||
res, _ = do(t, "POST", srv.URL+"/api/notify/test", `{"transport":"webhook"}`)
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("webhook test: %d", res.StatusCode)
|
||||
}
|
||||
select {
|
||||
case h := <-got:
|
||||
if h.path != "/legacy" || h.body["event"] != "test" {
|
||||
t.Fatalf("legacy webhook payload: %v", h.body)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("legacy webhook never fired")
|
||||
}
|
||||
|
||||
// Named unconfigured transport -> 400.
|
||||
res, _ = do(t, "POST", srv.URL+"/api/notify/test", `{"transport":"smtp"}`)
|
||||
if res.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for unconfigured smtp, got %d", res.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeSMTP is the smallest lawful SMTP server: EHLO/MAIL/RCPT/DATA/QUIT.
|
||||
func fakeSMTP(t *testing.T) (addr string, gotMsg <-chan string) {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
msgs := make(chan string, 1)
|
||||
go func() {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
r := bufio.NewReader(conn)
|
||||
fmt.Fprint(conn, "220 fake\r\n")
|
||||
var data strings.Builder
|
||||
inData := false
|
||||
for {
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
if inData {
|
||||
if line == "." {
|
||||
inData = false
|
||||
fmt.Fprint(conn, "250 queued\r\n")
|
||||
continue
|
||||
}
|
||||
data.WriteString(line + "\n")
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(line, "EHLO"):
|
||||
fmt.Fprint(conn, "250-fake\r\n250 OK\r\n")
|
||||
case strings.HasPrefix(line, "MAIL"), strings.HasPrefix(line, "RCPT"):
|
||||
fmt.Fprint(conn, "250 OK\r\n")
|
||||
case strings.HasPrefix(line, "DATA"):
|
||||
fmt.Fprint(conn, "354 go\r\n")
|
||||
inData = true
|
||||
case strings.HasPrefix(line, "QUIT"):
|
||||
fmt.Fprint(conn, "221 bye\r\n")
|
||||
msgs <- data.String()
|
||||
return
|
||||
default:
|
||||
fmt.Fprint(conn, "250 OK\r\n")
|
||||
}
|
||||
}
|
||||
}()
|
||||
return ln.Addr().String(), msgs
|
||||
}
|
||||
|
||||
func TestNotifySMTP(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
addr, gotMsg := fakeSMTP(t)
|
||||
host, port, _ := net.SplitHostPort(addr)
|
||||
res, _ := do(t, "PUT", srv.URL+"/api/settings",
|
||||
`{"notify_smtp":{"host":"`+host+`","port":"`+port+`","from":"dash@local","to":"me@local"}}`)
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("put smtp: %d", res.StatusCode)
|
||||
}
|
||||
res, out := do(t, "POST", srv.URL+"/api/notify/test", `{"transport":"smtp"}`)
|
||||
if res.StatusCode != http.StatusOK || out["results"].(map[string]any)["smtp"] != "ok" {
|
||||
t.Fatalf("smtp test: %d %v", res.StatusCode, out)
|
||||
}
|
||||
select {
|
||||
case msg := <-gotMsg:
|
||||
if !strings.Contains(msg, "Subject: [Dash] Dash test notification") {
|
||||
t.Fatalf("smtp message missing subject: %q", msg)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("smtp server never received the message")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- alert rules ----------
|
||||
|
||||
func TestAlertRulesConsecutiveAndMute(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
got := make(chan map[string]any, 8)
|
||||
hook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var m map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&m)
|
||||
got <- m
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer hook.Close()
|
||||
do(t, "PUT", srv.URL+"/api/settings", `{"notify_webhook":"`+hook.URL+`"}`)
|
||||
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }))
|
||||
defer up.Close()
|
||||
down := `{"url":"http://127.0.0.1:1","timeoutS":1}`
|
||||
|
||||
// Monitor requiring 2 consecutive failures before going down.
|
||||
res, m := do(t, "POST", srv.URL+"/api/monitors",
|
||||
`{"name":"Flap","type":"http","url":"`+up.URL+`","timeoutS":1,"alerts":{"consecutiveFailures":2}}`)
|
||||
if res.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: %v", m)
|
||||
}
|
||||
id := m["id"].(string)
|
||||
do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // -> up
|
||||
|
||||
do(t, "PATCH", srv.URL+"/api/monitors/"+id, down)
|
||||
do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // 1st failure: hold
|
||||
_, mv := do(t, "GET", srv.URL+"/api/monitors/"+id, "")
|
||||
if mv["status"] != "up" {
|
||||
t.Fatalf("expected held status up after 1 failure, got %v", mv["status"])
|
||||
}
|
||||
do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // 2nd failure: down
|
||||
_, mv = do(t, "GET", srv.URL+"/api/monitors/"+id, "")
|
||||
if mv["status"] != "down" {
|
||||
t.Fatalf("expected down after 2 failures, got %v", mv["status"])
|
||||
}
|
||||
select {
|
||||
case body := <-got:
|
||||
if body["event"] != "monitor.down" {
|
||||
t.Fatalf("expected monitor.down, got %v", body["event"])
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("monitor.down never fired after threshold")
|
||||
}
|
||||
|
||||
// Muted monitor: status still flips, no notification.
|
||||
res, m2 := do(t, "POST", srv.URL+"/api/monitors",
|
||||
`{"name":"Quiet","type":"http","url":"`+up.URL+`","timeoutS":1,"alerts":{"mute":true}}`)
|
||||
if res.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create muted: %v", m2)
|
||||
}
|
||||
id2 := m2["id"].(string)
|
||||
do(t, "POST", srv.URL+"/api/monitors/"+id2+"/check", "")
|
||||
do(t, "PATCH", srv.URL+"/api/monitors/"+id2, down)
|
||||
do(t, "POST", srv.URL+"/api/monitors/"+id2+"/check", "")
|
||||
_, mv = do(t, "GET", srv.URL+"/api/monitors/"+id2, "")
|
||||
if mv["status"] != "down" {
|
||||
t.Fatalf("muted monitor should still go down, got %v", mv["status"])
|
||||
}
|
||||
select {
|
||||
case body := <-got:
|
||||
if body["name"] == "Quiet" {
|
||||
t.Fatal("muted monitor still notified")
|
||||
}
|
||||
case <-time.After(1500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertRulesBadJSON(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
defer srv.Close()
|
||||
res, _ := do(t, "POST", srv.URL+"/api/monitors",
|
||||
`{"name":"X","type":"http","url":"http://127.0.0.1:1","alerts":"nope"}`)
|
||||
if res.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for invalid alerts, got %d", res.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- subdomains ----------
|
||||
|
||||
func TestSubdomainDiscovery(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
// Stub the network seams: CT returns candidates, resolver answers a few.
|
||||
oldCT, oldRes := domain.CTNames, domain.ResolveIPs
|
||||
domain.CTNames = func(_ context.Context, d string) ([]string, error) {
|
||||
return []string{"api." + d + "\nwww." + d, "*." + d, "other.com"}, nil
|
||||
}
|
||||
domain.ResolveIPs = func(_ context.Context, name string) ([]string, error) {
|
||||
if strings.HasPrefix(name, "api.") {
|
||||
return []string{"10.0.0.1"}, nil
|
||||
}
|
||||
return []string{"93.184.216.34"}, nil
|
||||
}
|
||||
t.Cleanup(func() { domain.CTNames, domain.ResolveIPs = oldCT, oldRes })
|
||||
|
||||
res, d := do(t, "POST", srv.URL+"/api/domains", `{"name":"example.com"}`)
|
||||
if res.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create domain: %v", d)
|
||||
}
|
||||
id := d["id"].(string)
|
||||
|
||||
// First refresh ran discovery inline (sub_checked_at was NULL).
|
||||
res, subs := do(t, "GET", srv.URL+"/api/domains/"+id+"/subdomains", "")
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("list subdomains: %d", res.StatusCode)
|
||||
}
|
||||
list, _ := subs["subdomains"].([]any) // response is a bare array — check below
|
||||
_ = list
|
||||
// do() decodes objects only; re-fetch raw for the array.
|
||||
raw, err := http.Get(srv.URL + "/api/domains/" + id + "/subdomains")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var arr []map[string]any
|
||||
_ = json.NewDecoder(raw.Body).Decode(&arr)
|
||||
raw.Body.Close()
|
||||
names := map[string][]string{}
|
||||
for _, s := range arr {
|
||||
ips := []string{}
|
||||
for _, ip := range s["ips"].([]any) {
|
||||
ips = append(ips, ip.(string))
|
||||
}
|
||||
names[s["name"].(string)] = ips
|
||||
}
|
||||
if len(names) != 3 || len(names["api.example.com"]) != 1 || names["api.example.com"][0] != "10.0.0.1" {
|
||||
t.Fatalf("unexpected subdomains: %v", names)
|
||||
}
|
||||
if _, bad := names["other.com"]; bad {
|
||||
t.Fatal("foreign name leaked into subdomains")
|
||||
}
|
||||
}
|
||||
@@ -60,8 +60,20 @@ func (s *Server) routes() *gin.Engine {
|
||||
r.Use(accessLog(s.log), gin.Recovery())
|
||||
|
||||
v1 := r.Group("/api")
|
||||
v1.Use(s.requireAuth())
|
||||
v1.GET("/healthz", s.healthz)
|
||||
|
||||
v1.GET("/auth/session", s.authSession)
|
||||
v1.POST("/auth/setup", s.authSetup)
|
||||
v1.POST("/auth/login", s.authLogin)
|
||||
v1.POST("/auth/logout", s.authLogout)
|
||||
v1.POST("/auth/disable", s.authDisable)
|
||||
|
||||
v1.GET("/boards", s.listBoards)
|
||||
v1.POST("/boards", s.createBoard)
|
||||
v1.PATCH("/boards/:id", s.updateBoard)
|
||||
v1.DELETE("/boards/:id", s.deleteBoard)
|
||||
|
||||
v1.GET("/sections", s.listSections)
|
||||
v1.POST("/sections", s.createSection)
|
||||
v1.PATCH("/sections/:id", s.updateSection)
|
||||
@@ -91,6 +103,8 @@ func (s *Server) routes() *gin.Engine {
|
||||
v1.DELETE("/domains/:id", s.deleteDomain)
|
||||
v1.POST("/domains/:id/refresh", s.refreshDomainH)
|
||||
v1.GET("/domains/:id/checks", s.domainChecks)
|
||||
v1.GET("/domains/:id/subdomains", s.listSubdomains)
|
||||
v1.POST("/domains/:id/subdomains/refresh", s.refreshSubdomains)
|
||||
v1.POST("/notify/test", s.notifyTest)
|
||||
|
||||
v1.GET("/systems", s.listSystemsH)
|
||||
@@ -167,6 +181,7 @@ type Section struct {
|
||||
Name string `json:"name"`
|
||||
Position float64 `json:"position"`
|
||||
Collapsed bool `json:"collapsed"`
|
||||
BoardID string `json:"boardId"`
|
||||
Items []Item `json:"items"`
|
||||
}
|
||||
|
||||
|
||||
@@ -786,3 +786,73 @@ func TestValidURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoards(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
// Default board exists and owns new sections.
|
||||
var boards []Board
|
||||
resp, err := http.Get(srv.URL + "/api/boards")
|
||||
if err != nil || json.NewDecoder(resp.Body).Decode(&boards) != nil {
|
||||
t.Fatalf("list boards: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if len(boards) != 1 || boards[0].ID != "b_home" {
|
||||
t.Fatalf("expected seeded home board, got %+v", boards)
|
||||
}
|
||||
_, sec := do(t, "POST", srv.URL+"/api/sections", `{"name":"A"}`)
|
||||
if sec["boardId"] != "b_home" {
|
||||
t.Fatalf("section should default to home board: %v", sec["boardId"])
|
||||
}
|
||||
|
||||
// Create a second board; slug is derived and unique.
|
||||
_, b2 := do(t, "POST", srv.URL+"/api/boards", `{"name":"Lab Net"}`)
|
||||
if b2["slug"] != "lab-net" {
|
||||
t.Fatalf("bad slug: %v", b2["slug"])
|
||||
}
|
||||
_, b3 := do(t, "POST", srv.URL+"/api/boards", `{"name":"Lab Net"}`)
|
||||
if b3["slug"] == b2["slug"] {
|
||||
t.Fatalf("slug collision: %v", b3["slug"])
|
||||
}
|
||||
|
||||
// Sections scope to their board.
|
||||
_, s2 := do(t, "POST", srv.URL+"/api/sections", `{"name":"B","boardId":"`+b2["id"].(string)+`"}`)
|
||||
if s2["boardId"] != b2["id"] {
|
||||
t.Fatalf("section not scoped to board: %v", s2["boardId"])
|
||||
}
|
||||
var home, lab []Section
|
||||
r1, _ := http.Get(srv.URL + "/api/sections?board=b_home")
|
||||
_ = json.NewDecoder(r1.Body).Decode(&home)
|
||||
r1.Body.Close()
|
||||
r2, _ := http.Get(srv.URL + "/api/sections?board=" + b2["id"].(string))
|
||||
_ = json.NewDecoder(r2.Body).Decode(&lab)
|
||||
r2.Body.Close()
|
||||
if len(home) != 1 || home[0].Name != "A" || len(lab) != 1 || lab[0].Name != "B" {
|
||||
t.Fatalf("board filter broken: home=%v lab=%v", home, lab)
|
||||
}
|
||||
|
||||
// Rename keeps the slug; deleting cascades sections.
|
||||
_, rb := do(t, "PATCH", srv.URL+"/api/boards/"+b3["id"].(string), `{"name":"Renamed"}`)
|
||||
if rb["name"] != "Renamed" || rb["slug"] == "renamed" {
|
||||
t.Fatalf("rename should not change slug: %+v", rb)
|
||||
}
|
||||
res, _ := do(t, "DELETE", srv.URL+"/api/boards/"+b2["id"].(string), "")
|
||||
if res.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("delete board: %d", res.StatusCode)
|
||||
}
|
||||
var labAfter []Section
|
||||
r3, _ := http.Get(srv.URL + "/api/sections?board=" + b2["id"].(string))
|
||||
_ = json.NewDecoder(r3.Body).Decode(&labAfter)
|
||||
r3.Body.Close()
|
||||
if len(labAfter) != 0 {
|
||||
t.Fatalf("sections should cascade with board: %v", labAfter)
|
||||
}
|
||||
|
||||
// Down to two boards — delete both and the last delete must refuse.
|
||||
do(t, "DELETE", srv.URL+"/api/boards/"+b3["id"].(string), "")
|
||||
res, _ = do(t, "DELETE", srv.URL+"/api/boards/b_home", "")
|
||||
if res.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("deleting last board should 400, got %d", res.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package api
|
||||
|
||||
// Opt-in local auth — disabled by default; enabling creates the first admin.
|
||||
// Local username + bcrypt password only (no OIDC/SSO — deliberate non-goal).
|
||||
// Sessions are random tokens in SQLite behind an httpOnly SameSite=Lax cookie;
|
||||
// login is rate-limited per IP. Public endpoints (status pages, badges, push,
|
||||
// agent ingest, metrics) bypass the middleware.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookie = "dash_session"
|
||||
sessionTTL = 7 * 24 * time.Hour
|
||||
// Rotate when less than half the TTL remains.
|
||||
sessionRotate = sessionTTL / 2
|
||||
)
|
||||
|
||||
func (s *Server) authEnabled() bool {
|
||||
var raw string
|
||||
if err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'auth_enabled'`).Scan(&raw); err != nil {
|
||||
return false
|
||||
}
|
||||
var on bool
|
||||
_ = json.Unmarshal([]byte(raw), &on)
|
||||
return on
|
||||
}
|
||||
|
||||
func (s *Server) userCount() int {
|
||||
var n int
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
|
||||
return n
|
||||
}
|
||||
|
||||
// ---------- sessions ----------
|
||||
|
||||
func (s *Server) createSession(c *gin.Context, userID string) {
|
||||
var tok [32]byte
|
||||
_, _ = rand.Read(tok[:])
|
||||
token := hex.EncodeToString(tok[:])
|
||||
exp := time.Now().Add(sessionTTL)
|
||||
_, _ = s.db.Exec(`INSERT INTO sessions (token, user_id, expires_at) VALUES (?,?,?)`,
|
||||
token, userID, exp.UTC().Format("2006-01-02T15:04:05.000Z"))
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
Expires: exp,
|
||||
HttpOnly: true,
|
||||
Secure: c.Request.TLS != nil,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// sessionUser resolves the cookie to a user, sliding-rotating near expiry.
|
||||
// Returns "" when absent/expired.
|
||||
func (s *Server) sessionUser(c *gin.Context) string {
|
||||
ck, err := c.Cookie(sessionCookie)
|
||||
if err != nil || ck == "" {
|
||||
return ""
|
||||
}
|
||||
var userID, expires string
|
||||
err = s.db.QueryRow(`SELECT user_id, expires_at FROM sessions WHERE token = ?`, ck).Scan(&userID, &expires)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
exp, err := time.Parse("2006-01-02T15:04:05.000Z", expires)
|
||||
if err != nil || time.Now().After(exp) {
|
||||
_, _ = s.db.Exec(`DELETE FROM sessions WHERE token = ?`, ck)
|
||||
return ""
|
||||
}
|
||||
if time.Until(exp) < sessionRotate {
|
||||
// Rotate: drop the old token, issue a fresh one.
|
||||
_, _ = s.db.Exec(`DELETE FROM sessions WHERE token = ?`, ck)
|
||||
s.createSession(c, userID)
|
||||
}
|
||||
return userID
|
||||
}
|
||||
|
||||
// ---------- rate limiting (login only, in-memory per-IP) ----------
|
||||
|
||||
var loginMu sync.Mutex
|
||||
var loginHits = map[string][]time.Time{}
|
||||
|
||||
func loginAllowed(ip string) bool {
|
||||
loginMu.Lock()
|
||||
defer loginMu.Unlock()
|
||||
cut := time.Now().Add(-time.Minute)
|
||||
kept := loginHits[ip][:0]
|
||||
for _, t := range loginHits[ip] {
|
||||
if t.After(cut) {
|
||||
kept = append(kept, t)
|
||||
}
|
||||
}
|
||||
if len(kept) >= 5 {
|
||||
loginHits[ip] = kept
|
||||
return false
|
||||
}
|
||||
loginHits[ip] = append(kept, time.Now())
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------- middleware ----------
|
||||
|
||||
// publicAPI lists /api paths reachable without a session. Auth endpoints must
|
||||
// stay open (login itself); push/ingest/badge/metrics/public status carry
|
||||
// their own tokens or are intentionally public.
|
||||
func publicAPI(path string) bool {
|
||||
switch {
|
||||
case strings.HasPrefix(path, "/api/auth/"),
|
||||
strings.HasPrefix(path, "/api/push/"),
|
||||
path == "/api/systems/ingest",
|
||||
strings.HasPrefix(path, "/api/badge/"),
|
||||
path == "/api/metrics",
|
||||
strings.HasPrefix(path, "/api/healthz"),
|
||||
strings.HasSuffix(path, "/public"):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// requireAuth gates every /api route when the auth_enabled setting is on.
|
||||
func (s *Server) requireAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !s.authEnabled() || publicAPI(c.Request.URL.Path) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if s.sessionUser(c) == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- handlers ----------
|
||||
|
||||
type credentials struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// sessionInfo reports auth state for the frontend shell — always public so
|
||||
// the UI can decide between the board and the login screen.
|
||||
func (s *Server) authSession(c *gin.Context) {
|
||||
on := s.authEnabled()
|
||||
out := gin.H{"enabled": on, "authed": false, "setup": false}
|
||||
if !on {
|
||||
c.JSON(http.StatusOK, out)
|
||||
return
|
||||
}
|
||||
out["setup"] = s.userCount() == 0
|
||||
if uid := s.sessionUser(c); uid != "" {
|
||||
var name string
|
||||
_ = s.db.QueryRow(`SELECT username FROM users WHERE id = ?`, uid).Scan(&name)
|
||||
out["authed"] = true
|
||||
out["username"] = name
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// setup creates the first user — only while auth is on and no users exist.
|
||||
func (s *Server) authSetup(c *gin.Context) {
|
||||
if !s.authEnabled() {
|
||||
fail(c, http.StatusBadRequest, "auth is not enabled")
|
||||
return
|
||||
}
|
||||
if s.userCount() > 0 {
|
||||
fail(c, http.StatusConflict, "admin user already exists")
|
||||
return
|
||||
}
|
||||
var in credentials
|
||||
if err := c.ShouldBindJSON(&in); err != nil ||
|
||||
len(strings.TrimSpace(in.Username)) < 2 || len(in.Password) < 8 {
|
||||
fail(c, http.StatusBadRequest, "username (2+) and password (8+) required")
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, "hash failed")
|
||||
return
|
||||
}
|
||||
uid := newID("u")
|
||||
if _, err := s.db.Exec(`INSERT INTO users (id, username, pass_hash) VALUES (?,?,?)`,
|
||||
uid, strings.TrimSpace(in.Username), string(hash)); err != nil {
|
||||
fail(c, http.StatusConflict, "username taken")
|
||||
return
|
||||
}
|
||||
s.createSession(c, uid)
|
||||
c.JSON(http.StatusCreated, gin.H{"username": in.Username})
|
||||
}
|
||||
|
||||
func (s *Server) authLogin(c *gin.Context) {
|
||||
if !s.authEnabled() {
|
||||
fail(c, http.StatusBadRequest, "auth is not enabled")
|
||||
return
|
||||
}
|
||||
ip := c.ClientIP()
|
||||
if !loginAllowed(ip) {
|
||||
fail(c, http.StatusTooManyRequests, "too many attempts — wait a minute")
|
||||
return
|
||||
}
|
||||
var in credentials
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
fail(c, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
var uid, hash string
|
||||
err := s.db.QueryRow(`SELECT id, pass_hash FROM users WHERE username = ?`,
|
||||
strings.TrimSpace(in.Username)).Scan(&uid, &hash)
|
||||
if err != nil {
|
||||
// Unknown user — run bcrypt anyway so timing doesn't leak existence.
|
||||
hash = "$2a$10$7EqJtq98hPqEX7fNZaFWoOhi5sA5t6p7K0dGm7ZC4yQ0yCk0cFk0K"
|
||||
}
|
||||
// CompareHashAndPassword is constant-time; err != nil covers both
|
||||
// unknown user (dummy hash above) and wrong password.
|
||||
if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(in.Password)) != nil {
|
||||
fail(c, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
s.createSession(c, uid)
|
||||
c.JSON(http.StatusOK, gin.H{"username": in.Username})
|
||||
}
|
||||
|
||||
func (s *Server) authLogout(c *gin.Context) {
|
||||
if ck, err := c.Cookie(sessionCookie); err == nil {
|
||||
_, _ = s.db.Exec(`DELETE FROM sessions WHERE token = ?`, ck)
|
||||
}
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true,
|
||||
})
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// authDisable turns auth off and wipes users+sessions. Requires a session —
|
||||
// you can't switch the lock off from outside.
|
||||
func (s *Server) authDisable(c *gin.Context) {
|
||||
if s.sessionUser(c) == "" {
|
||||
fail(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
_, _ = s.db.Exec(`DELETE FROM sessions`)
|
||||
_, _ = s.db.Exec(`DELETE FROM users`)
|
||||
if _, err := s.db.Exec(`INSERT INTO settings (key, value) VALUES ('auth_enabled','false')
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`); err != nil {
|
||||
s.log.Error("auth disable", zap.Error(err))
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tdvorak/dash/internal/db"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// authServer returns the bare server (no httptest) so tests can plant
|
||||
// sessions and inspect cookies directly.
|
||||
func authServer(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
d, err := db.Open(filepath.Join(t.TempDir(), "dash.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { d.Close() })
|
||||
gin.SetMode(gin.TestMode)
|
||||
// Login rate limits are keyed by IP in a package-global map — reset so
|
||||
// tests don't count each other's attempts.
|
||||
loginMu.Lock()
|
||||
loginHits = map[string][]time.Time{}
|
||||
loginMu.Unlock()
|
||||
return newServer(zap.NewNop(), d, t.TempDir())
|
||||
}
|
||||
|
||||
func req(t *testing.T, s *Server, method, path, body, cookie string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
if cookie != "" {
|
||||
r.Header.Set("Cookie", sessionCookie+"="+cookie)
|
||||
}
|
||||
s.routes().ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
func sessionCookieOf(w *httptest.ResponseRecorder) string {
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == sessionCookie {
|
||||
return c.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func enableAuth(t *testing.T, s *Server) {
|
||||
t.Helper()
|
||||
if w := req(t, s, "PUT", "/api/settings", `{"auth_enabled":true}`, ""); w.Code != 200 {
|
||||
t.Fatalf("enable auth: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthDisabledByDefault(t *testing.T) {
|
||||
s := authServer(t)
|
||||
if w := req(t, s, "GET", "/api/sections", "", ""); w.Code != 200 {
|
||||
t.Fatalf("sections open when auth off: %d", w.Code)
|
||||
}
|
||||
var out map[string]any
|
||||
w := req(t, s, "GET", "/api/auth/session", "", "")
|
||||
_ = json.NewDecoder(w.Body).Decode(&out)
|
||||
if out["enabled"] != false {
|
||||
t.Fatalf("auth should report disabled: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLifecycle(t *testing.T) {
|
||||
s := authServer(t)
|
||||
enableAuth(t, s)
|
||||
|
||||
// Everything gates now.
|
||||
if w := req(t, s, "GET", "/api/sections", "", ""); w.Code != 401 {
|
||||
t.Fatalf("expected 401, got %d", w.Code)
|
||||
}
|
||||
// Session endpoint stays public and reports setup mode.
|
||||
var sess map[string]any
|
||||
w := req(t, s, "GET", "/api/auth/session", "", "")
|
||||
_ = json.NewDecoder(w.Body).Decode(&sess)
|
||||
if sess["enabled"] != true || sess["setup"] != true || sess["authed"] != false {
|
||||
t.Fatalf("bad session payload: %v", sess)
|
||||
}
|
||||
// Public surfaces stay open even gated.
|
||||
for _, p := range []string{"/api/healthz", "/api/metrics", "/api/badge/uptime/x.svg"} {
|
||||
if w := req(t, s, "GET", p, "", ""); w.Code == 401 {
|
||||
t.Fatalf("public path %s gated", p)
|
||||
}
|
||||
}
|
||||
|
||||
// Setup creates the first admin and returns a session cookie.
|
||||
w = req(t, s, "POST", "/api/auth/setup", `{"username":"admin","password":"correct-horse"}`, "")
|
||||
if w.Code != 201 {
|
||||
t.Fatalf("setup: %d body=%s", w.Code, w.Body)
|
||||
}
|
||||
cookie := sessionCookieOf(w)
|
||||
if cookie == "" {
|
||||
t.Fatal("setup did not set a session cookie")
|
||||
}
|
||||
// Second setup blocked.
|
||||
if w := req(t, s, "POST", "/api/auth/setup", `{"username":"x","password":"another-pass"}`, ""); w.Code != 409 {
|
||||
t.Fatalf("second setup should 409, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Authed request with the cookie.
|
||||
if w := req(t, s, "GET", "/api/sections", "", cookie); w.Code != 200 {
|
||||
t.Fatalf("authed request: %d", w.Code)
|
||||
}
|
||||
|
||||
// Logout kills the session.
|
||||
req(t, s, "POST", "/api/auth/logout", "", cookie)
|
||||
if w := req(t, s, "GET", "/api/sections", "", cookie); w.Code != 401 {
|
||||
t.Fatalf("expected 401 after logout, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Login wrong password -> 401; right -> 200 + fresh cookie.
|
||||
if w := req(t, s, "POST", "/api/auth/login", `{"username":"admin","password":"wrong-password"}`, ""); w.Code != 401 {
|
||||
t.Fatalf("bad login should 401, got %d", w.Code)
|
||||
}
|
||||
w = req(t, s, "POST", "/api/auth/login", `{"username":"admin","password":"correct-horse"}`, "")
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("login: %d", w.Code)
|
||||
}
|
||||
if sessionCookieOf(w) == "" {
|
||||
t.Fatal("login set no cookie")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRateLimit(t *testing.T) {
|
||||
s := authServer(t)
|
||||
enableAuth(t, s)
|
||||
req(t, s, "POST", "/api/auth/setup", `{"username":"admin","password":"correct-horse"}`, "")
|
||||
req(t, s, "POST", "/api/auth/logout", "", "")
|
||||
for i := 0; i < 5; i++ {
|
||||
req(t, s, "POST", "/api/auth/login", `{"username":"admin","password":"nope-nope"}`, "")
|
||||
}
|
||||
if w := req(t, s, "POST", "/api/auth/login", `{"username":"admin","password":"correct-horse"}`, ""); w.Code != 429 {
|
||||
t.Fatalf("6th login should be rate-limited, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthSessionExpiryAndRotation(t *testing.T) {
|
||||
s := authServer(t)
|
||||
enableAuth(t, s)
|
||||
w := req(t, s, "POST", "/api/auth/setup", `{"username":"admin","password":"correct-horse"}`, "")
|
||||
cookie := sessionCookieOf(w)
|
||||
|
||||
// Expired session -> 401.
|
||||
_, _ = s.db.Exec(`UPDATE sessions SET expires_at = ? WHERE token = ?`,
|
||||
time.Now().Add(-time.Hour).UTC().Format("2006-01-02T15:04:05.000Z"), cookie)
|
||||
if w := req(t, s, "GET", "/api/sections", "", cookie); w.Code != 401 {
|
||||
t.Fatalf("expired session should 401, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Near-expiry session rotates: response carries a new cookie.
|
||||
fresh := req(t, s, "POST", "/api/auth/login", `{"username":"admin","password":"correct-horse"}`, "")
|
||||
cookie = sessionCookieOf(fresh)
|
||||
_, _ = s.db.Exec(`UPDATE sessions SET expires_at = ? WHERE token = ?`,
|
||||
time.Now().Add(time.Hour).UTC().Format("2006-01-02T15:04:05.000Z"), cookie)
|
||||
w = req(t, s, "GET", "/api/sections", "", cookie)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("rotating request should pass, got %d", w.Code)
|
||||
}
|
||||
newCookie := sessionCookieOf(w)
|
||||
if newCookie == "" || newCookie == cookie {
|
||||
t.Fatal("expected session rotation to issue a new token")
|
||||
}
|
||||
// Old token is dead.
|
||||
if w := req(t, s, "GET", "/api/sections", "", cookie); w.Code != 401 {
|
||||
t.Fatalf("old token should be invalid after rotation, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthDisable(t *testing.T) {
|
||||
s := authServer(t)
|
||||
enableAuth(t, s)
|
||||
w := req(t, s, "POST", "/api/auth/setup", `{"username":"admin","password":"correct-horse"}`, "")
|
||||
cookie := sessionCookieOf(w)
|
||||
|
||||
// Unauthenticated disable is refused.
|
||||
if w := req(t, s, "POST", "/api/auth/disable", "", ""); w.Code != 401 {
|
||||
t.Fatalf("disable without session should 401, got %d", w.Code)
|
||||
}
|
||||
if w := req(t, s, "POST", "/api/auth/disable", "", cookie); w.Code != 204 {
|
||||
t.Fatalf("disable: %d", w.Code)
|
||||
}
|
||||
if w := req(t, s, "GET", "/api/sections", "", ""); w.Code != 200 {
|
||||
t.Fatalf("sections should reopen after disable, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,16 @@ import (
|
||||
|
||||
// board loads the full tree: sections ordered, each with items and urls.
|
||||
// Three flat queries assembled in memory — the whole board is ~100 rows.
|
||||
func (s *Server) board() ([]Section, error) {
|
||||
secRows, err := s.db.Query(`SELECT id, name, position, collapsed FROM sections ORDER BY position`)
|
||||
// boardID filters to one board; "" returns every section (export, legacy).
|
||||
// COALESCE maps pre-boards rows onto the default 'b_home' board.
|
||||
func (s *Server) board(boardID string) ([]Section, error) {
|
||||
q := `SELECT id, name, position, collapsed, COALESCE(board_id,'b_home') FROM sections`
|
||||
var args []any
|
||||
if boardID != "" {
|
||||
q += ` WHERE board_id = ?`
|
||||
args = append(args, boardID)
|
||||
}
|
||||
secRows, err := s.db.Query(q+` ORDER BY position`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -21,7 +29,7 @@ func (s *Server) board() ([]Section, error) {
|
||||
byID := map[string]*Section{}
|
||||
for secRows.Next() {
|
||||
var sec Section
|
||||
if err := secRows.Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed); err != nil {
|
||||
if err := secRows.Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed, &sec.BoardID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sec.Items = []Item{}
|
||||
@@ -89,7 +97,7 @@ func (s *Server) board() ([]Section, error) {
|
||||
}
|
||||
|
||||
func (s *Server) listSections(c *gin.Context) {
|
||||
sections, err := s.board()
|
||||
sections, err := s.board(c.Query("board"))
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -99,19 +107,27 @@ func (s *Server) listSections(c *gin.Context) {
|
||||
|
||||
func (s *Server) createSection(c *gin.Context) {
|
||||
var in struct {
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name"`
|
||||
BoardID string `json:"boardId"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&in); err != nil || in.Name == "" {
|
||||
fail(c, http.StatusBadRequest, "name required")
|
||||
return
|
||||
}
|
||||
boardID := in.BoardID
|
||||
if boardID == "" {
|
||||
boardID = "b_home"
|
||||
}
|
||||
var maxPos sql.NullFloat64
|
||||
if err := s.db.QueryRow(`SELECT MAX(position) FROM sections`).Scan(&maxPos); err != nil {
|
||||
if err := s.db.QueryRow(`SELECT MAX(position) FROM sections WHERE board_id = ?`, boardID).Scan(&maxPos); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
sec := Section{ID: newID("s"), Name: in.Name, Position: midpoint(ptrOr(maxPos), nil), Items: []Item{}}
|
||||
if _, err := s.db.Exec(`INSERT INTO sections (id, name, position) VALUES (?, ?, ?)`, sec.ID, sec.Name, sec.Position); err != nil {
|
||||
sec := Section{ID: newID("s"), Name: in.Name, Position: midpoint(ptrOr(maxPos), nil), BoardID: boardID, Items: []Item{}}
|
||||
// The subquery yields NULL for an unknown board id instead of an FK error;
|
||||
// board() maps NULL back onto the default board.
|
||||
if _, err := s.db.Exec(`INSERT INTO sections (id, name, position, board_id)
|
||||
VALUES (?, ?, ?, (SELECT id FROM boards WHERE id = ?))`, sec.ID, sec.Name, sec.Position, boardID); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -140,8 +156,8 @@ func (s *Server) updateSection(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
var sec Section
|
||||
err := s.db.QueryRow(`SELECT id, name, position, collapsed FROM sections WHERE id = ?`, c.Param("id")).
|
||||
Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed)
|
||||
err := s.db.QueryRow(`SELECT id, name, position, collapsed, COALESCE(board_id,'b_home') FROM sections WHERE id = ?`, c.Param("id")).
|
||||
Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed, &sec.BoardID)
|
||||
if notFound(err) {
|
||||
fail(c, http.StatusNotFound, "section not found")
|
||||
return
|
||||
@@ -192,8 +208,8 @@ func (s *Server) reorderSection(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var sec Section
|
||||
if err := s.db.QueryRow(`SELECT id, name, position, collapsed FROM sections WHERE id = ?`, in.ID).
|
||||
Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed); err != nil {
|
||||
if err := s.db.QueryRow(`SELECT id, name, position, collapsed, COALESCE(board_id,'b_home') FROM sections WHERE id = ?`, in.ID).
|
||||
Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed, &sec.BoardID); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package api
|
||||
|
||||
// Multiple boards — sections carry board_id; the board list is small and
|
||||
// lives next to the section tree. Deleting a board cascades its sections.
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Board is a named dashboard page reachable at /b/<slug>.
|
||||
type Board struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Position float64 `json:"position"`
|
||||
}
|
||||
|
||||
func (s *Server) listBoards(c *gin.Context) {
|
||||
rows, err := s.db.Query(`SELECT id, slug, name, position FROM boards ORDER BY position`)
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Board{}
|
||||
for rows.Next() {
|
||||
var b Board
|
||||
if err := rows.Scan(&b.ID, &b.Slug, &b.Name, &b.Position); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) createBoard(c *gin.Context) {
|
||||
var in struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&in); err != nil || strings.TrimSpace(in.Name) == "" {
|
||||
fail(c, http.StatusBadRequest, "name required")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(in.Name)
|
||||
slug := slugify(name)
|
||||
if slug == "" {
|
||||
slug = "board"
|
||||
}
|
||||
// Uniquify: 'media', 'media-2', 'media-3'…
|
||||
base := slug
|
||||
for i := 2; ; i++ {
|
||||
var n int
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM boards WHERE slug = ?`, slug).Scan(&n)
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
slug = base + "-" + strconv.Itoa(i)
|
||||
}
|
||||
var maxPos sql.NullFloat64
|
||||
_ = s.db.QueryRow(`SELECT MAX(position) FROM boards`).Scan(&maxPos)
|
||||
b := Board{ID: newID("b"), Slug: slug, Name: name, Position: midpoint(ptrOr(maxPos), nil)}
|
||||
if _, err := s.db.Exec(`INSERT INTO boards (id, slug, name, position) VALUES (?,?,?,?)`,
|
||||
b.ID, b.Slug, b.Name, b.Position); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, b)
|
||||
}
|
||||
|
||||
func (s *Server) updateBoard(c *gin.Context) {
|
||||
var in struct {
|
||||
Name *string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&in); err != nil || in.Name == nil || strings.TrimSpace(*in.Name) == "" {
|
||||
fail(c, http.StatusBadRequest, "name required")
|
||||
return
|
||||
}
|
||||
// Slug stays fixed on rename — links shouldn't break.
|
||||
res, err := s.db.Exec(`UPDATE boards SET name = ? WHERE id = ?`, strings.TrimSpace(*in.Name), c.Param("id"))
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
fail(c, http.StatusNotFound, "board not found")
|
||||
return
|
||||
}
|
||||
var b Board
|
||||
if err := s.db.QueryRow(`SELECT id, slug, name, position FROM boards WHERE id = ?`, c.Param("id")).
|
||||
Scan(&b.ID, &b.Slug, &b.Name, &b.Position); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, b)
|
||||
}
|
||||
|
||||
func (s *Server) deleteBoard(c *gin.Context) {
|
||||
var n int
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM boards`).Scan(&n)
|
||||
if n <= 1 {
|
||||
fail(c, http.StatusBadRequest, "cannot delete the last board")
|
||||
return
|
||||
}
|
||||
res, err := s.db.Exec(`DELETE FROM boards WHERE id = ?`, c.Param("id"))
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
fail(c, http.StatusNotFound, "board not found")
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -27,7 +27,7 @@ const domainCols = `id, name, tld, active, auto_renew, alert_days_before, tags,
|
||||
ssl_key_size, ssl_sig_algo, ssl_alt_names, host_country, host_country_code,
|
||||
host_region, host_city, host_isp, host_org, host_as, host_lat, host_lon,
|
||||
dns_provider, email_provider, hosting_provider, ca_provider, headers,
|
||||
favicon_url, lookup_error, interval_h, last_checked, position, created_at`
|
||||
favicon_url, lookup_error, interval_h, last_checked, position, alerts, created_at`
|
||||
|
||||
// domainRow mirrors the domains table; JSON text columns decode on scan.
|
||||
type domainRow struct {
|
||||
@@ -87,12 +87,13 @@ type domainRow struct {
|
||||
IntervalH int `json:"intervalH"`
|
||||
LastChecked *string `json:"lastChecked"`
|
||||
Position float64 `json:"position"`
|
||||
Alerts json.RawMessage `json:"alerts"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
func scanDomain(row interface{ Scan(...any) error }) (*domainRow, error) {
|
||||
var d domainRow
|
||||
var tags, statuses, ns, mx, txt, v4, v6, altNames, headers string
|
||||
var tags, statuses, ns, mx, txt, v4, v6, altNames, headers, alerts string
|
||||
var active, autoRenew, privacy, lock int
|
||||
err := row.Scan(&d.ID, &d.Name, &d.TLD, &active, &autoRenew, &d.AlertDaysBefore,
|
||||
&tags, &d.Notes, &d.ExpiryDate, &d.CreationDate, &d.UpdatedDate,
|
||||
@@ -104,10 +105,11 @@ func scanDomain(row interface{ Scan(...any) error }) (*domainRow, error) {
|
||||
&d.HostRegion, &d.HostCity, &d.HostISP, &d.HostOrg, &d.HostAS, &d.HostLat,
|
||||
&d.HostLon, &d.DNSProvider, &d.EmailProvider, &d.HostingProvider, &d.CAProvider,
|
||||
&headers, &d.FaviconURL, &d.LookupError, &d.IntervalH, &d.LastChecked,
|
||||
&d.Position, &d.CreatedAt)
|
||||
&d.Position, &alerts, &d.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.Alerts = json.RawMessage(alerts)
|
||||
d.Active = active != 0
|
||||
d.AutoRenew = autoRenew != 0
|
||||
d.PrivacyEnabled = privacy != 0
|
||||
@@ -200,6 +202,7 @@ type domainInput struct {
|
||||
IntervalH *int `json:"intervalH"`
|
||||
Tags []string `json:"tags"`
|
||||
Notes *string `json:"notes"`
|
||||
Alerts json.RawMessage `json:"alerts"`
|
||||
}
|
||||
|
||||
func (s *Server) createDomain(c *gin.Context) {
|
||||
@@ -218,6 +221,13 @@ func (s *Server) createDomain(c *gin.Context) {
|
||||
if in.Tags == nil {
|
||||
tags = []byte("[]")
|
||||
}
|
||||
if in.Alerts != nil {
|
||||
var rules alertRules
|
||||
if err := json.Unmarshal(in.Alerts, &rules); err != nil {
|
||||
fail(c, http.StatusBadRequest, "alerts must be an object")
|
||||
return
|
||||
}
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO domains
|
||||
(id, name, tld, active, auto_renew, alert_days_before, tags, notes, interval_h, position)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
||||
@@ -228,6 +238,9 @@ func (s *Server) createDomain(c *gin.Context) {
|
||||
fail(c, http.StatusConflict, "domain already tracked or insert failed")
|
||||
return
|
||||
}
|
||||
if in.Alerts != nil {
|
||||
_, _ = s.db.Exec(`UPDATE domains SET alerts = ? WHERE id = ?`, string(in.Alerts), id)
|
||||
}
|
||||
// First lookup runs inline so the response carries real data.
|
||||
s.refreshDomain(id)
|
||||
d, err := s.loadDomain(id)
|
||||
@@ -298,6 +311,14 @@ func (s *Server) patchDomain(c *gin.Context) {
|
||||
tags, _ := json.Marshal(in.Tags)
|
||||
_, _ = s.db.Exec(`UPDATE domains SET tags = ? WHERE id = ?`, string(tags), id)
|
||||
}
|
||||
if in.Alerts != nil {
|
||||
var rules alertRules
|
||||
if err := json.Unmarshal(in.Alerts, &rules); err != nil {
|
||||
fail(c, http.StatusBadRequest, "alerts must be an object")
|
||||
return
|
||||
}
|
||||
_, _ = s.db.Exec(`UPDATE domains SET alerts = ? WHERE id = ?`, string(in.Alerts), id)
|
||||
}
|
||||
d, err := s.loadDomain(id)
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
@@ -338,6 +359,10 @@ func (s *Server) refreshDomain(id string) {
|
||||
return
|
||||
}
|
||||
nv := viewOf(next)
|
||||
rules := parseAlertRules(next.Alerts)
|
||||
if rules.Mute {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case nv.DaysUntilExpiry != nil && *nv.DaysUntilExpiry < 0 && !(prev.DaysUntilExpiry != nil && *prev.DaysUntilExpiry < 0):
|
||||
s.notify("domain.expired", next.Name, fmt.Sprintf("Domain %s has expired", next.Name))
|
||||
@@ -345,10 +370,19 @@ func (s *Server) refreshDomain(id string) {
|
||||
s.notify("domain.expiring", next.Name,
|
||||
fmt.Sprintf("Domain %s expires in %d days", next.Name, *nv.DaysUntilExpiry))
|
||||
}
|
||||
if nv.SSLExpiring && !prev.SSLExpiring && nv.SSLDaysUntilExpiry != nil {
|
||||
// TLS threshold: alerts.certDays overrides alertDaysBefore.
|
||||
certDays := next.AlertDaysBefore
|
||||
if rules.CertDays > 0 {
|
||||
certDays = rules.CertDays
|
||||
}
|
||||
sslNow := nv.SSLDaysUntilExpiry != nil && *nv.SSLDaysUntilExpiry <= certDays
|
||||
sslPrev := prev.SSLDaysUntilExpiry != nil && *prev.SSLDaysUntilExpiry <= certDays
|
||||
if sslNow && !sslPrev {
|
||||
s.notify("domain.sslExpiring", next.Name,
|
||||
fmt.Sprintf("TLS certificate for %s expires in %d days", next.Name, *nv.SSLDaysUntilExpiry))
|
||||
}
|
||||
// Subdomain discovery rides the refresh schedule, gated to once/day.
|
||||
s.maybeSweepSubdomains(next)
|
||||
}
|
||||
|
||||
func (s *Server) refreshDomainH(c *gin.Context) {
|
||||
|
||||
@@ -30,11 +30,11 @@ func (s *Server) importExternal(c *gin.Context) {
|
||||
fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.replaceBoard(sections, nil); err != nil {
|
||||
if err := s.replaceBoard(sections, nil, nil); err != nil {
|
||||
failImport(c, err)
|
||||
return
|
||||
}
|
||||
board, err := s.board()
|
||||
board, err := s.board("")
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
|
||||
@@ -20,21 +20,22 @@ import (
|
||||
|
||||
const monitorCols = `id, name, type, url, hostname, port, method, headers, body,
|
||||
keyword, keyword_invert, json_query, expected, dns_type, interval_s, timeout_s,
|
||||
retries, active, status, push_token, tags, notes, position, last_check, created_at`
|
||||
retries, active, status, push_token, tags, notes, alerts, position, last_check, created_at`
|
||||
|
||||
func scanMonitor(row interface{ Scan(...any) error }) (*monitor.Monitor, error) {
|
||||
var m monitor.Monitor
|
||||
var tags string
|
||||
var tags, alerts string
|
||||
var invert, active int
|
||||
err := row.Scan(&m.ID, &m.Name, &m.Type, &m.URL, &m.Hostname, &m.Port, &m.Method,
|
||||
&m.Headers, &m.Body, &m.Keyword, &invert, &m.JSONQuery, &m.Expected, &m.DNSType,
|
||||
&m.IntervalS, &m.TimeoutS, &m.Retries, &active, &m.Status, &m.PushToken,
|
||||
&tags, &m.Notes, &m.Position, &m.LastCheck, &m.CreatedAt)
|
||||
&tags, &m.Notes, &alerts, &m.Position, &m.LastCheck, &m.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.KeywordInvert = invert != 0
|
||||
m.Active = active != 0
|
||||
m.Alerts = json.RawMessage(alerts)
|
||||
_ = json.Unmarshal([]byte(tags), &m.Tags)
|
||||
if m.Tags == nil {
|
||||
m.Tags = []string{}
|
||||
@@ -142,6 +143,7 @@ type monitorInput struct {
|
||||
Active *bool `json:"active"`
|
||||
Tags []string `json:"tags"`
|
||||
Notes *string `json:"notes"`
|
||||
Alerts json.RawMessage `json:"alerts"`
|
||||
}
|
||||
|
||||
func validMonitorType(t string) bool {
|
||||
@@ -209,17 +211,26 @@ func (s *Server) createMonitor(c *gin.Context) {
|
||||
if in.Tags == nil {
|
||||
tags = []byte("[]")
|
||||
}
|
||||
alerts := "{}"
|
||||
if in.Alerts != nil {
|
||||
var rules alertRules
|
||||
if err := json.Unmarshal(in.Alerts, &rules); err != nil {
|
||||
fail(c, http.StatusBadRequest, "alerts must be an object")
|
||||
return
|
||||
}
|
||||
alerts = string(in.Alerts)
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO monitors
|
||||
(id, name, type, url, hostname, port, method, headers, body, keyword,
|
||||
keyword_invert, json_query, expected, dns_type, interval_s, timeout_s,
|
||||
retries, active, push_token, tags, notes, position)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
retries, active, push_token, tags, notes, alerts, position)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
id, *in.Name, *in.Type, strOr(in.URL), strOr(in.Hostname), intOr(in.Port),
|
||||
strOr2(in.Method, "GET"), strOr2(in.Headers, "{}"), strOr(in.Body),
|
||||
strOr(in.Keyword), boolOr(in.KeywordInvert), strOr(in.JSONQuery),
|
||||
strOr(in.Expected), strOr2(in.DNSType, "A"), intOr2(in.IntervalS, 60),
|
||||
intOr2(in.TimeoutS, 10), intOr(in.Retries), boolOr2(in.Active, true),
|
||||
token, string(tags), strOr(in.Notes), float64(time.Now().UnixNano())/1e9)
|
||||
token, string(tags), strOr(in.Notes), alerts, float64(time.Now().UnixNano())/1e9)
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -294,6 +305,14 @@ func (s *Server) patchMonitor(c *gin.Context) {
|
||||
tags, _ := json.Marshal(in.Tags)
|
||||
_, _ = s.db.Exec(`UPDATE monitors SET tags = ? WHERE id = ?`, string(tags), id)
|
||||
}
|
||||
if in.Alerts != nil {
|
||||
var rules alertRules
|
||||
if err := json.Unmarshal(in.Alerts, &rules); err != nil {
|
||||
fail(c, http.StatusBadRequest, "alerts must be an object")
|
||||
return
|
||||
}
|
||||
_, _ = s.db.Exec(`UPDATE monitors SET alerts = ? WHERE id = ?`, string(in.Alerts), id)
|
||||
}
|
||||
if in.Active != nil {
|
||||
// pause/resume flips the stored status back to pending for a fresh probe
|
||||
status := "pending"
|
||||
@@ -412,6 +431,9 @@ func (s *Server) runScheduler(done <-chan struct{}) {
|
||||
s.log.Error("heartbeat prune", zap.Error(err))
|
||||
}
|
||||
// System samples are high-rate; a week of raw ticks is enough.
|
||||
if _, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at < strftime('%Y-%m-%dT%H:%M:%fZ','now')`); err != nil {
|
||||
s.log.Error("session prune", zap.Error(err))
|
||||
}
|
||||
if _, err := s.db.Exec(`DELETE FROM system_stats WHERE ts < datetime('now','-7 days')`); err != nil {
|
||||
s.log.Error("system stats prune", zap.Error(err))
|
||||
}
|
||||
@@ -473,8 +495,13 @@ func (s *Server) runCheck(m *monitor.Monitor) *monitor.Result {
|
||||
s.writeHeartbeat(m, res)
|
||||
prev := m.Status
|
||||
next := "down"
|
||||
rules := parseAlertRules(m.Alerts)
|
||||
if res.Up {
|
||||
next = "up"
|
||||
} else if n := rules.ConsecutiveFailures; n > 1 && prev != "down" &&
|
||||
s.consecutiveDowns(m.ID, n) < n {
|
||||
// Flap damping: hold the previous status until N checks in a row fail.
|
||||
next = prev
|
||||
}
|
||||
if _, err := s.db.Exec(`UPDATE monitors SET status = ?, last_check = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
|
||||
next, m.ID); err != nil {
|
||||
@@ -483,6 +510,14 @@ func (s *Server) runCheck(m *monitor.Monitor) *monitor.Result {
|
||||
if prev != next && prev != "pending" {
|
||||
s.onMonitorTransition(m, prev, next)
|
||||
}
|
||||
// Latency warn: notify once when ping crosses above the threshold.
|
||||
if rules.LatencyWarnMs > 0 && res.Up && res.PingMs >= rules.LatencyWarnMs &&
|
||||
!rules.Mute && prev != "pending" && !s.inMaintenance(m.ID) {
|
||||
if p := s.prevPing(m.ID); p >= 0 && p < rules.LatencyWarnMs {
|
||||
s.notify("monitor.slow", m.Name,
|
||||
fmt.Sprintf("Monitor %q latency %dms (threshold %dms)", m.Name, res.PingMs, rules.LatencyWarnMs))
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -507,6 +542,9 @@ func (s *Server) onMonitorTransition(m *monitor.Monitor, prev, next string) {
|
||||
return
|
||||
}
|
||||
s.autoIncident(m.ID, m.Name, next)
|
||||
if parseAlertRules(m.Alerts).Mute {
|
||||
return
|
||||
}
|
||||
target := m.URL
|
||||
if target == "" {
|
||||
target = m.Hostname
|
||||
|
||||
@@ -3,19 +3,29 @@ package api
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Notifications: one outbound webhook, configured via the notify_webhook
|
||||
// settings key. Payload carries event/name/text/content/message so the same
|
||||
// POST satisfies Slack ({text}), Discord ({content}), ntfy, and generic
|
||||
// receivers. SMTP and per-service channels are deliberately out of scope —
|
||||
// a webhook bridge (e.g. ntfy, Gotify, shoutrrr) covers them.
|
||||
// Notifications: a small transport registry. Each transport is configured via
|
||||
// the settings key notify_<name> holding a JSON object of fields; a transport
|
||||
// is active when its required fields are set. Multiple transports may be
|
||||
// active — notify() fans out to all of them.
|
||||
//
|
||||
// notify_webhook keeps back-compat: a plain string value means {"url": value}.
|
||||
//
|
||||
// Secrets (smtp_pass, tokens) live in settings like any other value and are
|
||||
// never logged. Single-user self-hosted is the threat model; P5d auth gates
|
||||
// the settings endpoint when enabled.
|
||||
|
||||
type notifyPayload struct {
|
||||
Event string `json:"event"`
|
||||
@@ -26,54 +36,213 @@ type notifyPayload struct {
|
||||
At string `json:"at"`
|
||||
}
|
||||
|
||||
func (s *Server) webhookURL() string {
|
||||
var raw string
|
||||
if err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'notify_webhook'`).Scan(&raw); err != nil {
|
||||
return ""
|
||||
}
|
||||
// Settings values are stored as JSON — unwrap the quoted string.
|
||||
var u string
|
||||
if err := json.Unmarshal([]byte(raw), &u); err != nil {
|
||||
return ""
|
||||
}
|
||||
return u
|
||||
type transport struct {
|
||||
name string
|
||||
required []string
|
||||
send func(ctx context.Context, cfg map[string]string, p notifyPayload) error
|
||||
}
|
||||
|
||||
// notify fires the webhook asynchronously; delivery failures are logged, never fatal.
|
||||
var transports = []transport{
|
||||
{"webhook", []string{"url"}, sendWebhook},
|
||||
{"slack", []string{"url"}, sendSlack},
|
||||
{"discord", []string{"url"}, sendDiscord},
|
||||
{"telegram", []string{"token", "chat_id"}, sendTelegram},
|
||||
{"gotify", []string{"server", "token"}, sendGotify},
|
||||
{"ntfy", []string{"server", "topic"}, sendNtfy},
|
||||
{"smtp", []string{"host", "port", "from", "to"}, sendSMTP},
|
||||
}
|
||||
|
||||
// notifyCfg loads notify_<name> from settings. Values are JSON: an object for
|
||||
// presets, or a bare string (legacy webhook) meaning {"url": s}.
|
||||
func (s *Server) notifyCfg(name string) map[string]string {
|
||||
var raw string
|
||||
if err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'notify_`+name+`'`).Scan(&raw); err != nil {
|
||||
return nil
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||
var str string
|
||||
if err := json.Unmarshal([]byte(raw), &str); err == nil && name == "webhook" {
|
||||
return map[string]string{"url": str}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(m))
|
||||
for k, v := range m {
|
||||
if str, ok := v.(string); ok {
|
||||
out[k] = str
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// configured reports whether all required fields are present. Partially
|
||||
// configured transports count as configured so their send error surfaces.
|
||||
func (t transport) configured(cfg map[string]string) bool {
|
||||
if cfg == nil {
|
||||
return false
|
||||
}
|
||||
for _, k := range t.required {
|
||||
if strings.TrimSpace(cfg[k]) == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// notify fans an event out to every configured transport, asynchronously.
|
||||
func (s *Server) notify(event, name, msg string) {
|
||||
url := s.webhookURL()
|
||||
if url == "" {
|
||||
return
|
||||
}
|
||||
if u := parseHTTPURL(url); u == "" {
|
||||
s.log.Warn("notify_webhook is not an http(s) URL", zap.String("event", event))
|
||||
return
|
||||
}
|
||||
p := notifyPayload{Event: event, Name: name, Text: msg, Content: msg, Message: msg,
|
||||
At: time.Now().UTC().Format(time.RFC3339)}
|
||||
go s.deliver(url, p)
|
||||
for _, tr := range transports {
|
||||
cfg := s.notifyCfg(tr.name)
|
||||
if !tr.configured(cfg) {
|
||||
continue
|
||||
}
|
||||
tr, cfg := tr, cfg
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := tr.send(ctx, cfg, p); err != nil {
|
||||
s.log.Warn("notify failed", zap.String("transport", tr.name),
|
||||
zap.String("event", event), zap.Error(err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) deliver(url string, p notifyPayload) {
|
||||
body, _ := json.Marshal(p)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
// postJSON posts a JSON body and reports non-2xx as an error.
|
||||
func postJSON(ctx context.Context, url string, payload any, headers map[string]string) error {
|
||||
if parseHTTPURL(url) == "" {
|
||||
return fmt.Errorf("not an http(s) URL")
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Title", p.Text) // ntfy reads the title header
|
||||
req.Header.Set("X-Dash-Event", p.Event)
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
s.log.Warn("webhook delivery failed", zap.String("event", p.Event), zap.Error(err))
|
||||
return
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
s.log.Warn("webhook rejected", zap.String("event", p.Event), zap.Int("status", resp.StatusCode))
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("returned %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendWebhook(ctx context.Context, cfg map[string]string, p notifyPayload) error {
|
||||
return postJSON(ctx, cfg["url"], p, map[string]string{
|
||||
"Title": p.Text, "X-Dash-Event": p.Event,
|
||||
})
|
||||
}
|
||||
|
||||
func sendSlack(ctx context.Context, cfg map[string]string, p notifyPayload) error {
|
||||
return postJSON(ctx, cfg["url"], map[string]any{"text": p.Text}, nil)
|
||||
}
|
||||
|
||||
func sendDiscord(ctx context.Context, cfg map[string]string, p notifyPayload) error {
|
||||
return postJSON(ctx, cfg["url"], map[string]any{"content": p.Content}, nil)
|
||||
}
|
||||
|
||||
func sendTelegram(ctx context.Context, cfg map[string]string, p notifyPayload) error {
|
||||
base := strings.TrimRight(cfg["server"], "/") // optional self-hosted bot-api
|
||||
if base == "" {
|
||||
base = "https://api.telegram.org"
|
||||
}
|
||||
url := base + "/bot" + cfg["token"] + "/sendMessage"
|
||||
return postJSON(ctx, url, map[string]any{
|
||||
"chat_id": cfg["chat_id"], "text": p.Text,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func sendGotify(ctx context.Context, cfg map[string]string, p notifyPayload) error {
|
||||
server := strings.TrimRight(cfg["server"], "/")
|
||||
url := server + "/message?token=" + cfg["token"]
|
||||
return postJSON(ctx, url, map[string]any{
|
||||
"title": p.Name, "message": p.Message, "priority": 5,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func sendNtfy(ctx context.Context, cfg map[string]string, p notifyPayload) error {
|
||||
server := strings.TrimRight(cfg["server"], "/")
|
||||
url := server + "/" + strings.TrimLeft(cfg["topic"], "/")
|
||||
if parseHTTPURL(url) == "" {
|
||||
return fmt.Errorf("not an http(s) URL")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(p.Message))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Title", p.Name)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("returned %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendSMTP uses net/smtp with a hard deadline, opportunistic STARTTLS, and
|
||||
// plain auth only when a user is configured.
|
||||
func sendSMTP(ctx context.Context, cfg map[string]string, p notifyPayload) error {
|
||||
host, port := cfg["host"], cfg["port"]
|
||||
addr := net.JoinHostPort(host, port)
|
||||
d := net.Dialer{Timeout: 10 * time.Second}
|
||||
conn, err := d.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
if dl, ok := ctx.Deadline(); ok {
|
||||
_ = conn.SetDeadline(dl)
|
||||
} else {
|
||||
_ = conn.SetDeadline(time.Now().Add(15 * time.Second))
|
||||
}
|
||||
c, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.Close()
|
||||
if ok, _ := c.Extension("STARTTLS"); ok {
|
||||
if err := c.StartTLS(&tls.Config{ServerName: host}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if cfg["user"] != "" {
|
||||
if err := c.Auth(smtp.PlainAuth("", cfg["user"], cfg["pass"], host)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := c.Mail(cfg["from"]); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, rcpt := range strings.Split(cfg["to"], ",") {
|
||||
if err := c.Rcpt(strings.TrimSpace(rcpt)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: [Dash] %s\r\n\r\n%s\r\n",
|
||||
cfg["from"], cfg["to"], p.Text, p.Message)
|
||||
if _, err := w.Write([]byte(msg)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Quit()
|
||||
}
|
||||
|
||||
// parseHTTPURL enforces http/https on the configured webhook — same SSRF
|
||||
@@ -85,12 +254,42 @@ func parseHTTPURL(raw string) string {
|
||||
return raw
|
||||
}
|
||||
|
||||
// notifyTest lets the settings UI verify the configured webhook.
|
||||
// notifyTest sends a test event synchronously so the UI gets a real result.
|
||||
// Optional body {"transport": "smtp"} limits the test to one transport.
|
||||
func (s *Server) notifyTest(c *gin.Context) {
|
||||
if s.webhookURL() == "" {
|
||||
fail(c, http.StatusBadRequest, "notify_webhook not configured")
|
||||
var in struct {
|
||||
Transport string `json:"transport"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&in)
|
||||
p := notifyPayload{Event: "test", Name: "dash", Text: "Dash test notification",
|
||||
Content: "Dash test notification", Message: "Dash test notification",
|
||||
At: time.Now().UTC().Format(time.RFC3339)}
|
||||
|
||||
results := map[string]string{}
|
||||
for _, tr := range transports {
|
||||
if in.Transport != "" && tr.name != in.Transport {
|
||||
continue
|
||||
}
|
||||
cfg := s.notifyCfg(tr.name)
|
||||
if !tr.configured(cfg) {
|
||||
if in.Transport != "" {
|
||||
fail(c, http.StatusBadRequest, tr.name+" not configured")
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
|
||||
err := tr.send(ctx, cfg, p)
|
||||
cancel()
|
||||
if err != nil {
|
||||
results[tr.name] = "error: " + err.Error()
|
||||
} else {
|
||||
results[tr.name] = "ok"
|
||||
}
|
||||
}
|
||||
if len(results) == 0 {
|
||||
fail(c, http.StatusBadRequest, "no notification transport configured")
|
||||
return
|
||||
}
|
||||
s.notify("test", "dash", "Dash test notification")
|
||||
c.JSON(http.StatusOK, gin.H{"status": "sent"})
|
||||
c.JSON(http.StatusOK, gin.H{"results": results})
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tdvorak/dash/internal/domain"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Subdomain discovery: crt.sh + DNS, persisted per domain and swept at most
|
||||
// once a day inside the domain refresh. CT names are untrusted strings —
|
||||
// stored as text, rendered as text, never linked.
|
||||
|
||||
type subdomainView struct {
|
||||
Name string `json:"name"`
|
||||
IPs []string `json:"ips"`
|
||||
FirstSeen string `json:"firstSeen"`
|
||||
LastSeen string `json:"lastSeen"`
|
||||
}
|
||||
|
||||
func (s *Server) listSubdomains(c *gin.Context) {
|
||||
rows, err := s.db.Query(`SELECT name, ips, first_seen, last_seen
|
||||
FROM subdomains WHERE domain_id = ? ORDER BY name`, c.Param("id"))
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []subdomainView{}
|
||||
for rows.Next() {
|
||||
var v subdomainView
|
||||
var ips string
|
||||
if err := rows.Scan(&v.Name, &ips, &v.FirstSeen, &v.LastSeen); err != nil {
|
||||
continue
|
||||
}
|
||||
_ = json.Unmarshal([]byte(ips), &v.IPs)
|
||||
if v.IPs == nil {
|
||||
v.IPs = []string{}
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// refreshSubdomains forces a discovery run for one domain.
|
||||
func (s *Server) refreshSubdomains(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
d, err := s.loadDomain(id)
|
||||
if notFound(err) {
|
||||
fail(c, http.StatusNotFound, "domain not found")
|
||||
return
|
||||
}
|
||||
s.sweepSubdomains(d)
|
||||
s.listSubdomains(c)
|
||||
}
|
||||
|
||||
// sweepSubdomains runs CT+DNS discovery and upserts rows. Called from
|
||||
// refreshDomain when sub_checked_at is stale (>24h), and on manual refresh.
|
||||
func (s *Server) sweepSubdomains(d *domainRow) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
found, err := domain.DiscoverSubdomains(ctx, d.Name)
|
||||
if err != nil {
|
||||
s.log.Warn("subdomain discovery", zap.String("domain", d.Name), zap.Error(err))
|
||||
return
|
||||
}
|
||||
for _, f := range found {
|
||||
ips, _ := json.Marshal(f.IPs)
|
||||
if _, err := s.db.Exec(`INSERT INTO subdomains (id, domain_id, name, ips)
|
||||
VALUES (?,?,?,?)
|
||||
ON CONFLICT(domain_id, name) DO UPDATE SET ips = excluded.ips,
|
||||
last_seen = strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
|
||||
newID("sub"), d.ID, f.Name, string(ips)); err != nil {
|
||||
s.log.Error("subdomain upsert", zap.Error(err))
|
||||
}
|
||||
}
|
||||
_, _ = s.db.Exec(`UPDATE domains SET sub_checked_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, d.ID)
|
||||
}
|
||||
|
||||
// maybeSweepSubdomains gates discovery to once per day per domain.
|
||||
func (s *Server) maybeSweepSubdomains(d *domainRow) {
|
||||
var checked *string
|
||||
_ = s.db.QueryRow(`SELECT sub_checked_at FROM domains WHERE id = ?`, d.ID).Scan(&checked)
|
||||
if checked != nil && *checked != "" {
|
||||
if t, err := time.Parse("2006-01-02T15:04:05.000Z", *checked); err == nil &&
|
||||
time.Since(t) < 24*time.Hour {
|
||||
return
|
||||
}
|
||||
}
|
||||
s.sweepSubdomains(d)
|
||||
}
|
||||
@@ -41,13 +41,46 @@ type StatSample struct {
|
||||
Load15 float64 `json:"load15"`
|
||||
Temps map[string]float64 `json:"temps"`
|
||||
Containers []ContainerStat `json:"containers"`
|
||||
// Optional collectors — present only when the host has the tools.
|
||||
Smart []SmartDisk `json:"smart,omitempty"`
|
||||
ZFS []ZFSPool `json:"zfs,omitempty"`
|
||||
GPU []GPUStat `json:"gpu,omitempty"`
|
||||
}
|
||||
|
||||
// ContainerStat is one Docker container's liveness; per-container CPU/mem
|
||||
// needs cgroup sampling — deferred (v1 ships name+state only).
|
||||
// ContainerStat is one Docker container's liveness plus resource use.
|
||||
// CPU is percent of one core (may exceed 100); Mem* are bytes. Zero when the
|
||||
// agent couldn't reach the stats endpoint.
|
||||
type ContainerStat struct {
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
CPU float64 `json:"cpu,omitempty"`
|
||||
MemUsed float64 `json:"memUsed,omitempty"`
|
||||
MemLimit float64 `json:"memLimit,omitempty"`
|
||||
}
|
||||
|
||||
// SmartDisk is one drive's SMART summary (smartctl).
|
||||
type SmartDisk struct {
|
||||
Device string `json:"device"`
|
||||
Model string `json:"model"`
|
||||
Passed *bool `json:"passed,omitempty"`
|
||||
TempC float64 `json:"tempC,omitempty"`
|
||||
}
|
||||
|
||||
// ZFSPool is one pool's health and space (zpool).
|
||||
type ZFSPool struct {
|
||||
Name string `json:"name"`
|
||||
Health string `json:"health"`
|
||||
Size float64 `json:"size"`
|
||||
Free float64 `json:"free"`
|
||||
}
|
||||
|
||||
// GPUStat is one GPU's basic metrics (sysfs or nvidia-smi).
|
||||
type GPUStat struct {
|
||||
Name string `json:"name"`
|
||||
TempC float64 `json:"tempC,omitempty"`
|
||||
UtilPct float64 `json:"utilPct,omitempty"`
|
||||
MemUsed float64 `json:"memUsed,omitempty"`
|
||||
MemTotal float64 `json:"memTotal,omitempty"`
|
||||
}
|
||||
|
||||
// System is one monitored host; `latest` is the newest StatSample.
|
||||
|
||||
@@ -28,6 +28,7 @@ type Export struct {
|
||||
Version int `json:"version"`
|
||||
Settings map[string]json.RawMessage `json:"settings"`
|
||||
Sections []Section `json:"sections"`
|
||||
Boards []Board `json:"boards,omitempty"`
|
||||
Monitors []json.RawMessage `json:"monitors,omitempty"`
|
||||
Domains []json.RawMessage `json:"domains,omitempty"`
|
||||
Systems []json.RawMessage `json:"systems,omitempty"`
|
||||
@@ -74,12 +75,21 @@ func (s *Server) exportBoard(c *gin.Context) {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
sections, err := s.board()
|
||||
sections, err := s.board("")
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
ex := Export{Version: 2, Settings: settings, Sections: sections}
|
||||
if bRows, err := s.db.Query(`SELECT id, slug, name, position FROM boards ORDER BY position`); err == nil {
|
||||
for bRows.Next() {
|
||||
var b Board
|
||||
if bRows.Scan(&b.ID, &b.Slug, &b.Name, &b.Position) == nil {
|
||||
ex.Boards = append(ex.Boards, b)
|
||||
}
|
||||
}
|
||||
bRows.Close()
|
||||
}
|
||||
for _, t := range []struct {
|
||||
name string
|
||||
dst *[]json.RawMessage
|
||||
@@ -103,11 +113,11 @@ func (s *Server) importBoard(c *gin.Context) {
|
||||
fail(c, http.StatusBadRequest, "invalid export payload")
|
||||
return
|
||||
}
|
||||
if err := s.replaceBoard(in.Sections, in.Settings); err != nil {
|
||||
if err := s.replaceBoard(in.Sections, in.Boards, in.Settings); err != nil {
|
||||
failImport(c, err)
|
||||
return
|
||||
}
|
||||
sections, err := s.board()
|
||||
sections, err := s.board("")
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -116,8 +126,10 @@ func (s *Server) importBoard(c *gin.Context) {
|
||||
}
|
||||
|
||||
// replaceBoard swaps all board rows inside one transaction — shared by the
|
||||
// Dash export import and the external-config importers.
|
||||
func (s *Server) replaceBoard(sections []Section, settings map[string]json.RawMessage) error {
|
||||
// Dash export import and the external-config importers. A payload carrying
|
||||
// boards replaces the board list too; one without (v1/v2 exports, foreign
|
||||
// imports) leaves boards alone and sections land on the default board.
|
||||
func (s *Server) replaceBoard(sections []Section, boards []Board, settings map[string]json.RawMessage) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -129,12 +141,35 @@ func (s *Server) replaceBoard(sections []Section, settings map[string]json.RawMe
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(boards) > 0 {
|
||||
if _, err := tx.Exec(`DELETE FROM boards`); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, b := range boards {
|
||||
id, slug := b.ID, b.Slug
|
||||
if slug == "" {
|
||||
slug = slugify(b.Name)
|
||||
}
|
||||
if id == "" || slug == "" {
|
||||
return fmt.Errorf("%w: board missing id or slug", errBadInput)
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO boards (id, slug, name, position) VALUES (?, ?, ?, ?)`,
|
||||
id, slug, orDefault(b.Name, slug), b.Position); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// The default board must survive import — orphaned sections map to it.
|
||||
if _, err := tx.Exec(`INSERT OR IGNORE INTO boards (id, slug, name, position) VALUES ('b_home','home','Home',0)`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, sec := range sections {
|
||||
if sec.ID == "" || sec.Name == "" {
|
||||
return fmt.Errorf("%w: section missing id or name", errBadInput)
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO sections (id, name, position, collapsed) VALUES (?, ?, ?, ?)`,
|
||||
sec.ID, sec.Name, sec.Position, sec.Collapsed); err != nil {
|
||||
if _, err := tx.Exec(`INSERT INTO sections (id, name, position, collapsed, board_id)
|
||||
VALUES (?, ?, ?, ?, (SELECT id FROM boards WHERE id = ?))`,
|
||||
sec.ID, sec.Name, sec.Position, sec.Collapsed, sec.BoardID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, it := range sec.Items {
|
||||
|
||||
Reference in new issue
Block a user