Add Go project scaffold for Wails v2 SSH/Telnet/RAW TCP client
- go.mod with Wails v2.9.2, x/crypto, go-sqlite3, go-keyring, uuid - cmd/app/main.go: Wails entry point (Nexus Terminal, 1400x900, #1a1a2e) - internal/app/app.go: lifecycle (Startup/Shutdown/DomReady) - internal/models/models.go: full model definitions (Profile, SSHConfig, TelnetConfig, RAWConfig, SessionInfo, KnownHost, AppPreferences, etc.) - internal/models/defaults.go: default values and NewProfile factory - internal/validation/validation.go: thorough field-level validation - frontend/embed.go: embedded frontend assets - Updated existing storage, secrets, config to match new model structure
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
-- Initial schema for Nexus Terminal
|
||||
-- Creates the core tables for profiles, known hosts, preferences, and audit log.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS profiles (
|
||||
id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
protocol TEXT NOT NULL CHECK(protocol IN ('ssh', 'telnet', 'raw')),
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username TEXT DEFAULT '',
|
||||
tags TEXT DEFAULT '[]',
|
||||
folder TEXT DEFAULT '',
|
||||
environment TEXT DEFAULT '',
|
||||
notes TEXT DEFAULT '',
|
||||
favourite INTEGER DEFAULT 0,
|
||||
last_connected TEXT,
|
||||
connect_timeout INTEGER DEFAULT 30,
|
||||
read_timeout INTEGER DEFAULT 0,
|
||||
terminal_font_family TEXT DEFAULT '',
|
||||
terminal_font_size INTEGER DEFAULT 0,
|
||||
terminal_scrollback INTEGER DEFAULT 0,
|
||||
terminal_cursor_style TEXT DEFAULT '',
|
||||
ssh_config TEXT,
|
||||
telnet_config TEXT,
|
||||
raw_config TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS known_hosts (
|
||||
id TEXT PRIMARY KEY,
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
key_type TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
first_seen TEXT NOT NULL,
|
||||
last_seen TEXT NOT NULL,
|
||||
trusted INTEGER DEFAULT 1,
|
||||
UNIQUE(host, port, key_type)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS preferences (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
timestamp TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
profile_id TEXT DEFAULT '',
|
||||
session_id TEXT DEFAULT '',
|
||||
details TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (1, datetime('now'));
|
||||
@@ -0,0 +1,10 @@
|
||||
// Package migrations provides embedded SQL migration files for the storage layer.
|
||||
package migrations
|
||||
|
||||
import "embed"
|
||||
|
||||
// MigrationFS contains all embedded SQL migration files.
|
||||
// Each migration is numbered sequentially (e.g. 001_initial.sql).
|
||||
//
|
||||
//go:embed *.sql
|
||||
var MigrationFS embed.FS
|
||||
@@ -0,0 +1,661 @@
|
||||
// Package storage provides a SQLite-backed persistence layer for profiles,
|
||||
// known hosts, audit entries, and application preferences.
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
||||
"github.com/copilot-ssh-client/app/internal/models"
|
||||
"github.com/copilot-ssh-client/app/internal/storage/migrations"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Store wraps a SQLite database and exposes typed CRUD methods for
|
||||
// all application entities.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewStore opens (or creates) the SQLite database at dbPath, applies any
|
||||
// pending migrations, and returns an initialised Store.
|
||||
func NewStore(dbPath string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to open database: %w", err)
|
||||
}
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("storage: database not reachable: %w", err)
|
||||
}
|
||||
|
||||
s := &Store{db: db}
|
||||
if err := s.migrate(); err != nil {
|
||||
return nil, fmt.Errorf("storage: migration failed: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Close shuts down the database connection.
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Migrations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// migrate reads embedded SQL files and applies them in order.
|
||||
func (s *Store) migrate() error {
|
||||
// Ensure the schema_version table exists before querying it.
|
||||
_, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create schema_version table: %w", err)
|
||||
}
|
||||
|
||||
var current int
|
||||
row := s.db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM schema_version")
|
||||
if err := row.Scan(¤t); err != nil {
|
||||
return fmt.Errorf("failed to read schema version: %w", err)
|
||||
}
|
||||
|
||||
entries, err := migrations.MigrationFS.ReadDir(".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read migration files: %w", err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if !strings.HasSuffix(name, ".sql") {
|
||||
continue
|
||||
}
|
||||
// Extract version number from filename prefix (e.g. "001_initial.sql" → 1).
|
||||
var ver int
|
||||
if _, err := fmt.Sscanf(name, "%03d_", &ver); err != nil {
|
||||
continue
|
||||
}
|
||||
if ver <= current {
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := migrations.MigrationFS.ReadFile(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read migration %s: %w", name, err)
|
||||
}
|
||||
if _, err := s.db.Exec(string(data)); err != nil {
|
||||
return fmt.Errorf("failed to apply migration %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CreateProfile inserts a new profile into the database.
|
||||
func (s *Store) CreateProfile(p *models.Profile) error {
|
||||
tagsJSON, _ := json.Marshal(p.Tags)
|
||||
sshJSON, _ := json.Marshal(p.SSH)
|
||||
telnetJSON, _ := json.Marshal(p.Telnet)
|
||||
rawJSON, _ := json.Marshal(p.RAW)
|
||||
|
||||
_, err := s.db.Exec(`INSERT INTO profiles (
|
||||
id, display_name, description, protocol, host, port, username,
|
||||
tags, folder, environment, notes, favourite, last_connected,
|
||||
connect_timeout, read_timeout,
|
||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||
ssh_config, telnet_config, raw_config,
|
||||
created_at, updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
p.ID, p.DisplayName, p.Description, string(p.Protocol),
|
||||
p.Host, p.Port, p.Username,
|
||||
string(tagsJSON), p.Folder, p.Environment, p.Notes,
|
||||
boolToInt(p.Favourite), timeToStr(p.LastConnected),
|
||||
p.ConnectTimeout, p.ReadTimeout,
|
||||
p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle,
|
||||
nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON),
|
||||
p.CreatedAt.Format(time.RFC3339), p.UpdatedAt.Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: failed to create profile: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetProfile retrieves a single profile by ID.
|
||||
func (s *Store) GetProfile(id string) (*models.Profile, error) {
|
||||
row := s.db.QueryRow(`SELECT
|
||||
id, display_name, description, protocol, host, port, username,
|
||||
tags, folder, environment, notes, favourite, last_connected,
|
||||
connect_timeout, read_timeout,
|
||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||
ssh_config, telnet_config, raw_config,
|
||||
created_at, updated_at
|
||||
FROM profiles WHERE id = ?`, id)
|
||||
|
||||
p, err := scanProfile(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("storage: profile %q not found", id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to get profile: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// UpdateProfile overwrites an existing profile.
|
||||
func (s *Store) UpdateProfile(p *models.Profile) error {
|
||||
tagsJSON, _ := json.Marshal(p.Tags)
|
||||
sshJSON, _ := json.Marshal(p.SSH)
|
||||
telnetJSON, _ := json.Marshal(p.Telnet)
|
||||
rawJSON, _ := json.Marshal(p.RAW)
|
||||
|
||||
res, err := s.db.Exec(`UPDATE profiles SET
|
||||
display_name=?, description=?, protocol=?, host=?, port=?, username=?,
|
||||
tags=?, folder=?, environment=?, notes=?, favourite=?, last_connected=?,
|
||||
connect_timeout=?, read_timeout=?,
|
||||
terminal_font_family=?, terminal_font_size=?, terminal_scrollback=?, terminal_cursor_style=?,
|
||||
ssh_config=?, telnet_config=?, raw_config=?,
|
||||
updated_at=?
|
||||
WHERE id=?`,
|
||||
p.DisplayName, p.Description, string(p.Protocol),
|
||||
p.Host, p.Port, p.Username,
|
||||
string(tagsJSON), p.Folder, p.Environment, p.Notes,
|
||||
boolToInt(p.Favourite), timeToStr(p.LastConnected),
|
||||
p.ConnectTimeout, p.ReadTimeout,
|
||||
p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle,
|
||||
nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON),
|
||||
p.UpdatedAt.Format(time.RFC3339),
|
||||
p.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: failed to update profile: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("storage: profile %q not found", p.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteProfile removes a profile by ID.
|
||||
func (s *Store) DeleteProfile(id string) error {
|
||||
res, err := s.db.Exec("DELETE FROM profiles WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: failed to delete profile: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("storage: profile %q not found", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListProfiles returns all profiles ordered by display name.
|
||||
func (s *Store) ListProfiles() ([]*models.Profile, error) {
|
||||
rows, err := s.db.Query(`SELECT
|
||||
id, display_name, description, protocol, host, port, username,
|
||||
tags, folder, environment, notes, favourite, last_connected,
|
||||
connect_timeout, read_timeout,
|
||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||
ssh_config, telnet_config, raw_config,
|
||||
created_at, updated_at
|
||||
FROM profiles ORDER BY display_name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to list profiles: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanProfiles(rows)
|
||||
}
|
||||
|
||||
// SearchProfiles returns profiles whose display name, host, or tags contain
|
||||
// the query string (case-insensitive).
|
||||
func (s *Store) SearchProfiles(query string) ([]*models.Profile, error) {
|
||||
q := "%" + query + "%"
|
||||
rows, err := s.db.Query(`SELECT
|
||||
id, display_name, description, protocol, host, port, username,
|
||||
tags, folder, environment, notes, favourite, last_connected,
|
||||
connect_timeout, read_timeout,
|
||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||
ssh_config, telnet_config, raw_config,
|
||||
created_at, updated_at
|
||||
FROM profiles
|
||||
WHERE display_name LIKE ? OR host LIKE ? OR tags LIKE ?
|
||||
ORDER BY display_name`, q, q, q)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to search profiles: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanProfiles(rows)
|
||||
}
|
||||
|
||||
// ListProfilesByProtocol returns profiles filtered by protocol.
|
||||
func (s *Store) ListProfilesByProtocol(protocol string) ([]*models.Profile, error) {
|
||||
rows, err := s.db.Query(`SELECT
|
||||
id, display_name, description, protocol, host, port, username,
|
||||
tags, folder, environment, notes, favourite, last_connected,
|
||||
connect_timeout, read_timeout,
|
||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||
ssh_config, telnet_config, raw_config,
|
||||
created_at, updated_at
|
||||
FROM profiles WHERE protocol = ? ORDER BY display_name`, protocol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to list profiles by protocol: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanProfiles(rows)
|
||||
}
|
||||
|
||||
// ListProfilesByTag returns profiles that contain the specified tag.
|
||||
func (s *Store) ListProfilesByTag(tag string) ([]*models.Profile, error) {
|
||||
q := `%"` + tag + `"%`
|
||||
rows, err := s.db.Query(`SELECT
|
||||
id, display_name, description, protocol, host, port, username,
|
||||
tags, folder, environment, notes, favourite, last_connected,
|
||||
connect_timeout, read_timeout,
|
||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||
ssh_config, telnet_config, raw_config,
|
||||
created_at, updated_at
|
||||
FROM profiles WHERE tags LIKE ? ORDER BY display_name`, q)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to list profiles by tag: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanProfiles(rows)
|
||||
}
|
||||
|
||||
// ListProfilesByFolder returns profiles in the specified folder.
|
||||
func (s *Store) ListProfilesByFolder(folder string) ([]*models.Profile, error) {
|
||||
rows, err := s.db.Query(`SELECT
|
||||
id, display_name, description, protocol, host, port, username,
|
||||
tags, folder, environment, notes, favourite, last_connected,
|
||||
connect_timeout, read_timeout,
|
||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||
ssh_config, telnet_config, raw_config,
|
||||
created_at, updated_at
|
||||
FROM profiles WHERE folder = ? ORDER BY display_name`, folder)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to list profiles by folder: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanProfiles(rows)
|
||||
}
|
||||
|
||||
// ListFavourites returns all profiles marked as favourites.
|
||||
func (s *Store) ListFavourites() ([]*models.Profile, error) {
|
||||
rows, err := s.db.Query(`SELECT
|
||||
id, display_name, description, protocol, host, port, username,
|
||||
tags, folder, environment, notes, favourite, last_connected,
|
||||
connect_timeout, read_timeout,
|
||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||
ssh_config, telnet_config, raw_config,
|
||||
created_at, updated_at
|
||||
FROM profiles WHERE favourite = 1 ORDER BY display_name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to list favourites: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanProfiles(rows)
|
||||
}
|
||||
|
||||
// SetFavourite updates the favourite flag on a profile.
|
||||
func (s *Store) SetFavourite(id string, favourite bool) error {
|
||||
res, err := s.db.Exec("UPDATE profiles SET favourite = ?, updated_at = ? WHERE id = ?",
|
||||
boolToInt(favourite), time.Now().Format(time.RFC3339), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: failed to update favourite: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("storage: profile %q not found", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Known hosts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GetKnownHost retrieves a known host entry by host, port, and key type.
|
||||
func (s *Store) GetKnownHost(host string, port int) (*models.KnownHost, error) {
|
||||
row := s.db.QueryRow(`SELECT id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted
|
||||
FROM known_hosts WHERE host = ? AND port = ?`, host, port)
|
||||
|
||||
kh := &models.KnownHost{}
|
||||
var firstSeen, lastSeen string
|
||||
var trusted int
|
||||
if err := row.Scan(&kh.ID, &kh.Host, &kh.Port, &kh.KeyType, &kh.Fingerprint,
|
||||
&kh.PublicKey, &firstSeen, &lastSeen, &trusted); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("storage: failed to get known host: %w", err)
|
||||
}
|
||||
kh.FirstSeen, _ = time.Parse(time.RFC3339, firstSeen)
|
||||
kh.LastSeen, _ = time.Parse(time.RFC3339, lastSeen)
|
||||
kh.Trusted = trusted == 1
|
||||
return kh, nil
|
||||
}
|
||||
|
||||
// SaveKnownHost inserts or updates a known host entry.
|
||||
func (s *Store) SaveKnownHost(kh *models.KnownHost) error {
|
||||
if kh.ID == "" {
|
||||
kh.ID = uuid.New().String()
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO known_hosts (id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(host, port, key_type) DO UPDATE SET
|
||||
fingerprint=excluded.fingerprint, public_key=excluded.public_key,
|
||||
last_seen=excluded.last_seen, trusted=excluded.trusted`,
|
||||
kh.ID, kh.Host, kh.Port, kh.KeyType, kh.Fingerprint, kh.PublicKey,
|
||||
kh.FirstSeen.Format(time.RFC3339), kh.LastSeen.Format(time.RFC3339),
|
||||
boolToInt(kh.Trusted))
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: failed to save known host: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListKnownHosts returns all stored known host entries.
|
||||
func (s *Store) ListKnownHosts() ([]*models.KnownHost, error) {
|
||||
rows, err := s.db.Query(`SELECT id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted
|
||||
FROM known_hosts ORDER BY host, port`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to list known hosts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var hosts []*models.KnownHost
|
||||
for rows.Next() {
|
||||
kh := &models.KnownHost{}
|
||||
var firstSeen, lastSeen string
|
||||
var trusted int
|
||||
if err := rows.Scan(&kh.ID, &kh.Host, &kh.Port, &kh.KeyType, &kh.Fingerprint,
|
||||
&kh.PublicKey, &firstSeen, &lastSeen, &trusted); err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to scan known host: %w", err)
|
||||
}
|
||||
kh.FirstSeen, _ = time.Parse(time.RFC3339, firstSeen)
|
||||
kh.LastSeen, _ = time.Parse(time.RFC3339, lastSeen)
|
||||
kh.Trusted = trusted == 1
|
||||
hosts = append(hosts, kh)
|
||||
}
|
||||
return hosts, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteKnownHost removes a known host entry by ID.
|
||||
func (s *Store) DeleteKnownHost(id string) error {
|
||||
res, err := s.db.Exec("DELETE FROM known_hosts WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: failed to delete known host: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("storage: known host %q not found", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audit log
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// AddAuditEntry inserts an audit log entry.
|
||||
func (s *Store) AddAuditEntry(entry *models.AuditEntry) error {
|
||||
_, err := s.db.Exec(`INSERT INTO audit_log (id, timestamp, action, profile_id, session_id, details)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
entry.ID, entry.Timestamp.Format(time.RFC3339), entry.Action,
|
||||
entry.ProfileID, entry.SessionID, entry.Details)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: failed to add audit entry: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListAuditEntries returns the most recent audit entries, limited to count.
|
||||
func (s *Store) ListAuditEntries(limit int) ([]*models.AuditEntry, error) {
|
||||
rows, err := s.db.Query(`SELECT id, timestamp, action, profile_id, session_id, details
|
||||
FROM audit_log ORDER BY timestamp DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to list audit entries: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []*models.AuditEntry
|
||||
for rows.Next() {
|
||||
e := &models.AuditEntry{}
|
||||
var ts string
|
||||
if err := rows.Scan(&e.ID, &ts, &e.Action, &e.ProfileID, &e.SessionID, &e.Details); err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to scan audit entry: %w", err)
|
||||
}
|
||||
e.Timestamp, _ = time.Parse(time.RFC3339, ts)
|
||||
entries = append(entries, e)
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preferences (key-value)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GetPreferences reads all preference keys and deserialises them into an
|
||||
// AppPreferences struct.
|
||||
func (s *Store) GetPreferences() (*models.AppPreferences, error) {
|
||||
rows, err := s.db.Query("SELECT key, value FROM preferences")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to read preferences: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
kv := make(map[string]string)
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if err := rows.Scan(&k, &v); err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to scan preference: %w", err)
|
||||
}
|
||||
kv[k] = v
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If no preferences stored yet, return nil to signal defaults should be used.
|
||||
if len(kv) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
data, err := json.Marshal(kv)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to marshal preference map: %w", err)
|
||||
}
|
||||
|
||||
prefs := &models.AppPreferences{}
|
||||
if err := json.Unmarshal(data, prefs); err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to unmarshal preferences: %w", err)
|
||||
}
|
||||
return prefs, nil
|
||||
}
|
||||
|
||||
// SavePreferences serialises the AppPreferences into key-value pairs and
|
||||
// persists them to the preferences table.
|
||||
func (s *Store) SavePreferences(prefs *models.AppPreferences) error {
|
||||
data, err := json.Marshal(prefs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: failed to marshal preferences: %w", err)
|
||||
}
|
||||
|
||||
var kv map[string]interface{}
|
||||
if err := json.Unmarshal(data, &kv); err != nil {
|
||||
return fmt.Errorf("storage: failed to unmarshal preferences map: %w", err)
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: failed to begin transaction: %w", err)
|
||||
}
|
||||
|
||||
for k, v := range kv {
|
||||
valStr := fmt.Sprintf("%v", v)
|
||||
if _, err := tx.Exec(`INSERT INTO preferences (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, k, valStr); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("storage: failed to save preference %q: %w", k, err)
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// scanProfile reads a single profile row from the given scanner.
|
||||
func scanProfile(row *sql.Row) (*models.Profile, error) {
|
||||
p := &models.Profile{}
|
||||
var (
|
||||
protocol, tagsJSON string
|
||||
sshJSON, telnetJSON, rawJSON sql.NullString
|
||||
lastConn sql.NullString
|
||||
favourite int
|
||||
createdAt, updatedAt string
|
||||
)
|
||||
|
||||
err := row.Scan(
|
||||
&p.ID, &p.DisplayName, &p.Description, &protocol,
|
||||
&p.Host, &p.Port, &p.Username,
|
||||
&tagsJSON, &p.Folder, &p.Environment, &p.Notes,
|
||||
&favourite, &lastConn,
|
||||
&p.ConnectTimeout, &p.ReadTimeout,
|
||||
&p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle,
|
||||
&sshJSON, &telnetJSON, &rawJSON,
|
||||
&createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.Protocol = models.Protocol(protocol)
|
||||
p.Favourite = favourite == 1
|
||||
p.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
p.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
||||
if lastConn.Valid && lastConn.String != "" {
|
||||
t, err := time.Parse(time.RFC3339, lastConn.String)
|
||||
if err == nil {
|
||||
p.LastConnected = &t
|
||||
}
|
||||
}
|
||||
|
||||
_ = json.Unmarshal([]byte(tagsJSON), &p.Tags)
|
||||
if p.Tags == nil {
|
||||
p.Tags = []string{}
|
||||
}
|
||||
|
||||
if sshJSON.Valid && sshJSON.String != "" {
|
||||
p.SSH = &models.SSHConfig{}
|
||||
_ = json.Unmarshal([]byte(sshJSON.String), p.SSH)
|
||||
}
|
||||
if telnetJSON.Valid && telnetJSON.String != "" {
|
||||
p.Telnet = &models.TelnetConfig{}
|
||||
_ = json.Unmarshal([]byte(telnetJSON.String), p.Telnet)
|
||||
}
|
||||
if rawJSON.Valid && rawJSON.String != "" {
|
||||
p.RAW = &models.RAWConfig{}
|
||||
_ = json.Unmarshal([]byte(rawJSON.String), p.RAW)
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// scanProfiles iterates over rows and returns a slice of profiles.
|
||||
func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
|
||||
var result []*models.Profile
|
||||
for rows.Next() {
|
||||
p := &models.Profile{}
|
||||
var (
|
||||
protocol, tagsJSON string
|
||||
sshJSON, telnetJSON, rawJSON sql.NullString
|
||||
lastConn sql.NullString
|
||||
favourite int
|
||||
createdAt, updatedAt string
|
||||
)
|
||||
|
||||
err := rows.Scan(
|
||||
&p.ID, &p.DisplayName, &p.Description, &protocol,
|
||||
&p.Host, &p.Port, &p.Username,
|
||||
&tagsJSON, &p.Folder, &p.Environment, &p.Notes,
|
||||
&favourite, &lastConn,
|
||||
&p.ConnectTimeout, &p.ReadTimeout,
|
||||
&p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle,
|
||||
&sshJSON, &telnetJSON, &rawJSON,
|
||||
&createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to scan profile row: %w", err)
|
||||
}
|
||||
|
||||
p.Protocol = models.Protocol(protocol)
|
||||
p.Favourite = favourite == 1
|
||||
p.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
p.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
||||
if lastConn.Valid && lastConn.String != "" {
|
||||
t, err := time.Parse(time.RFC3339, lastConn.String)
|
||||
if err == nil {
|
||||
p.LastConnected = &t
|
||||
}
|
||||
}
|
||||
|
||||
_ = json.Unmarshal([]byte(tagsJSON), &p.Tags)
|
||||
if p.Tags == nil {
|
||||
p.Tags = []string{}
|
||||
}
|
||||
|
||||
if sshJSON.Valid && sshJSON.String != "" {
|
||||
p.SSH = &models.SSHConfig{}
|
||||
_ = json.Unmarshal([]byte(sshJSON.String), p.SSH)
|
||||
}
|
||||
if telnetJSON.Valid && telnetJSON.String != "" {
|
||||
p.Telnet = &models.TelnetConfig{}
|
||||
_ = json.Unmarshal([]byte(telnetJSON.String), p.Telnet)
|
||||
}
|
||||
if rawJSON.Valid && rawJSON.String != "" {
|
||||
p.RAW = &models.RAWConfig{}
|
||||
_ = json.Unmarshal([]byte(rawJSON.String), p.RAW)
|
||||
}
|
||||
|
||||
result = append(result, p)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func timeToStr(t *time.Time) interface{} {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
return t.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// nullableJSON returns nil when the source object pointer is nil,
|
||||
// otherwise the marshalled JSON bytes as a string.
|
||||
func nullableJSON(ptr interface{}, data []byte) interface{} {
|
||||
if ptr == nil || string(data) == "null" {
|
||||
return nil
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
Reference in New Issue
Block a user