package store import ( "context" "crypto/subtle" "database/sql" "errors" "os" "path/filepath" "strings" "time" "mengyamonitor-backend-server/internal/model" "github.com/google/uuid" _ "modernc.org/sqlite" ) type Store struct { db *sql.DB } func Open(dbPath string) (*Store, error) { dir := filepath.Dir(dbPath) if dir != "." && dir != "" { if err := os.MkdirAll(dir, 0o755); err != nil { return nil, err } } db, err := sql.Open("sqlite", dbPath+"?_pragma=foreign_keys(1)") if err != nil { return nil, err } if err := db.Ping(); err != nil { _ = db.Close() return nil, err } s := &Store{db: db} if err := s.migrate(); err != nil { _ = db.Close() return nil, err } return s, nil } func (s *Store) Close() error { return s.db.Close() } func (s *Store) migrate() error { const ddl = ` CREATE TABLE IF NOT EXISTS servers ( id TEXT PRIMARY KEY, name TEXT NOT NULL, url TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, sort_order INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_servers_sort ON servers(sort_order); ` if _, err := s.db.Exec(ddl); err != nil { return err } return s.ensureAgentKeyColumn() } func (s *Store) ensureAgentKeyColumn() error { rows, err := s.db.Query(`PRAGMA table_info(servers)`) if err != nil { return err } defer rows.Close() for rows.Next() { var cid int var name, typ string var notnull, pk int var dflt sql.NullString if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil { return err } if name == "agent_key" { return nil } } if err := rows.Err(); err != nil { return err } if _, err = s.db.Exec(`ALTER TABLE servers ADD COLUMN agent_key TEXT`); err != nil { return err } // Backfill keys for existing rows so gRPC can be enabled without manual SQL. bRows, err := s.db.Query(`SELECT id FROM servers WHERE agent_key IS NULL OR agent_key = ''`) if err != nil { return err } var ids []string for bRows.Next() { var id string if err := bRows.Scan(&id); err != nil { _ = bRows.Close() return err } ids = append(ids, id) } _ = bRows.Close() for _, id := range ids { if _, err := s.db.Exec(`UPDATE servers SET agent_key = ? WHERE id = ?`, uuid.NewString(), id); err != nil { return err } } return nil } func (s *Store) ListEnabled(ctx context.Context) ([]model.MonitoredServer, error) { rows, err := s.db.QueryContext(ctx, ` SELECT id, name, url, enabled, sort_order, agent_key, created_at FROM servers WHERE enabled = 1 ORDER BY sort_order ASC, created_at ASC`) if err != nil { return nil, err } defer rows.Close() return scanServers(rows) } func (s *Store) ListAll(ctx context.Context) ([]model.MonitoredServer, error) { rows, err := s.db.QueryContext(ctx, ` SELECT id, name, url, enabled, sort_order, agent_key, created_at FROM servers ORDER BY sort_order ASC, created_at ASC`) if err != nil { return nil, err } defer rows.Close() return scanServers(rows) } func scanServers(rows *sql.Rows) ([]model.MonitoredServer, error) { out := make([]model.MonitoredServer, 0) for rows.Next() { var m model.MonitoredServer var enabled int var created, agKey sql.NullString if err := rows.Scan(&m.ID, &m.Name, &m.URL, &enabled, &m.SortOrder, &agKey, &created); err != nil { return nil, err } m.Enabled = enabled != 0 if agKey.Valid { m.AgentKey = agKey.String } if t, err := time.Parse(time.RFC3339Nano, created.String); err == nil { m.CreatedAt = t } out = append(out, m) } return out, rows.Err() } func (s *Store) Create(ctx context.Context, in model.CreateServerInput) (*model.MonitoredServer, error) { name := strings.TrimSpace(in.Name) urlStr := normalizeURL(strings.TrimSpace(in.URL)) if name == "" { return nil, errors.New("invalid name") } enabled := true if in.Enabled != nil { enabled = *in.Enabled } var maxOrder int _ = s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(sort_order), -1) FROM servers`).Scan(&maxOrder) id := uuid.NewString() agentKey := uuid.NewString() now := time.Now().UTC() _, err := s.db.ExecContext(ctx, ` INSERT INTO servers (id, name, url, enabled, sort_order, agent_key, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, id, name, urlStr, boolToInt(enabled), maxOrder+1, agentKey, now.Format(time.RFC3339Nano)) if err != nil { return nil, err } return &model.MonitoredServer{ ID: id, Name: name, URL: urlStr, Enabled: enabled, SortOrder: maxOrder + 1, AgentKey: agentKey, CreatedAt: now, }, nil } func (s *Store) Update(ctx context.Context, id string, in model.UpdateServerInput) (*model.MonitoredServer, error) { cur, err := s.Get(ctx, id) if err != nil { return nil, err } name := cur.Name urlStr := cur.URL enabled := cur.Enabled if in.Name != "" { name = strings.TrimSpace(in.Name) } if in.URL != nil { urlStr = normalizeURL(strings.TrimSpace(*in.URL)) } if in.Enabled != nil { enabled = *in.Enabled } if name == "" { return nil, errors.New("invalid name") } agentKey := cur.AgentKey if in.AgentKey != nil { agentKey = *in.AgentKey } _, err = s.db.ExecContext(ctx, `UPDATE servers SET name = ?, url = ?, enabled = ?, agent_key = ? WHERE id = ?`, name, urlStr, boolToInt(enabled), nullIfEmpty(agentKey), id) if err != nil { return nil, err } cur.Name, cur.URL, cur.Enabled, cur.AgentKey = name, urlStr, enabled, agentKey return cur, nil } func (s *Store) Get(ctx context.Context, id string) (*model.MonitoredServer, error) { var m model.MonitoredServer var enabled int var created, agKey sql.NullString err := s.db.QueryRowContext(ctx, ` SELECT id, name, url, enabled, sort_order, agent_key, created_at FROM servers WHERE id = ?`, id). Scan(&m.ID, &m.Name, &m.URL, &enabled, &m.SortOrder, &agKey, &created) if errors.Is(err, sql.ErrNoRows) { return nil, err } if err != nil { return nil, err } m.Enabled = enabled != 0 if agKey.Valid { m.AgentKey = agKey.String } if t, err := time.Parse(time.RFC3339Nano, created.String); err == nil { m.CreatedAt = t } return &m, nil } // VerifyAgent checks server_id + agent_key for gRPC ingest. func (s *Store) VerifyAgent(ctx context.Context, id, key string) (*model.MonitoredServer, error) { srv, err := s.Get(ctx, id) if err != nil { return nil, err } if !srv.Enabled { return nil, errors.New("disabled") } if srv.AgentKey == "" { return nil, errors.New("agent_key not set") } if subtle.ConstantTimeCompare([]byte(srv.AgentKey), []byte(key)) != 1 { return nil, errors.New("invalid agent_key") } return srv, nil } func nullIfEmpty(s string) any { if s == "" { return nil } return s } func (s *Store) Delete(ctx context.Context, id string) error { res, err := s.db.ExecContext(ctx, `DELETE FROM servers WHERE id = ?`, id) if err != nil { return err } n, _ := res.RowsAffected() if n == 0 { return sql.ErrNoRows } return nil } func (s *Store) Reorder(ctx context.Context, ids []string) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() for i, id := range ids { if _, err := tx.ExecContext(ctx, `UPDATE servers SET sort_order = ? WHERE id = ?`, i, id); err != nil { return err } } return tx.Commit() } func boolToInt(b bool) int { if b { return 1 } return 0 } func normalizeURL(u string) string { if u == "" { return "" } if !strings.HasPrefix(strings.ToLower(u), "http://") && !strings.HasPrefix(strings.ToLower(u), "https://") { return "http://" + u } return u }