diff --git a/cmd/tui/main.go b/cmd/tui/main.go index b2dd9d7..6460c96 100644 --- a/cmd/tui/main.go +++ b/cmd/tui/main.go @@ -4,16 +4,16 @@ import ( "fmt" "os" - "git.juancwu.dev/juancwu/porkbacon/internal/app" + "git.juancwu.dev/juancwu/porkbacon/internal/ui" tea "github.com/charmbracelet/bubbletea" ) func main() { - a := app.New() + m := ui.New() - p := tea.NewProgram(&a) + p := tea.NewProgram(m, tea.WithAltScreen()) if _, err := p.Run(); err != nil { fmt.Println("Error:", err) os.Exit(1) } -} +} \ No newline at end of file diff --git a/go.mod b/go.mod index 8076c9b..b0b179b 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/text v0.3.8 // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect ) diff --git a/go.sum b/go.sum index a5aaf28..d1ec1e6 100644 --- a/go.sum +++ b/go.sum @@ -45,11 +45,17 @@ github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= diff --git a/internal/app/app.go b/internal/app/app.go deleted file mode 100644 index fe2a9e2..0000000 --- a/internal/app/app.go +++ /dev/null @@ -1,248 +0,0 @@ -package app - -import ( - "fmt" - - "git.juancwu.dev/juancwu/porkbacon/internal/pass" - "git.juancwu.dev/juancwu/porkbacon/internal/porkbun" - "github.com/charmbracelet/bubbles/list" - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" -) - -const ( - StateInit uint = iota - StateLoadingCredentials - StateMenu - StateExecuting - StateResult -) - -type CredentialsLoadedMsg struct { - APIKey string - SecretKey string -} - -type PingResultMsg struct { - IP string -} - -type ErrMsg error - -type item struct { - title, desc string -} - -func (i item) Title() string { return i.title } -func (i item) Description() string { return i.desc } -func (i item) FilterValue() string { return i.title } - -type App struct { - state uint - - apiKeyName string - secretApiKeyName string - client *porkbun.Client - - textInput textinput.Model - spinner spinner.Model - list list.Model - - result string - err error -} - -func New() App { - ti := textinput.New() - ti.Placeholder = "Pass Name" - ti.Focus() - ti.Width = 20 - - s := spinner.New() - s.Spinner = spinner.Dot - - items := []list.Item{ - item{title: "Domain: List All", desc: "List all domains in your account"}, - item{title: "Domain: Get Details", desc: "Get details for a specific domain"}, - item{title: "DNS: Retrieve Records", desc: "Retrieve DNS records for a domain"}, - item{title: "DNS: Create Record", desc: "Create a new DNS record"}, - item{title: "DNS: Edit Record", desc: "Edit an existing DNS record"}, - item{title: "DNS: Delete Record", desc: "Delete a DNS record"}, - item{title: "Util: Ping", desc: "Ping Porkbun"}, - } - - l := list.New(items, list.NewDefaultDelegate(), 0, 0) - l.Title = "Porkbun Actions" - - return App{ - state: StateInit, - textInput: ti, - spinner: s, - list: l, - } -} - -func (a App) Init() tea.Cmd { - return tea.Sequence(textinput.Blink, a.spinner.Tick) -} - -func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "ctrl+c": - return a, tea.Quit - case "esc": - if a.state == StateResult { - a.state = StateMenu - a.result = "" - a.err = nil - return a, nil - } - case "enter": - if a.state == StateInit { - return a.handleEnter() - } else if a.state == StateMenu { - return a.handleMenuEnter() - } - } - - case tea.WindowSizeMsg: - a.list.SetWidth(msg.Width) - a.list.SetHeight(msg.Height) - - case CredentialsLoadedMsg: - a.client = porkbun.New(msg.APIKey, msg.SecretKey) - a.state = StateMenu - return a, nil - - case PingResultMsg: - a.result = fmt.Sprintf("Ping successful!\nYour IP: %s", msg.IP) - a.state = StateResult - return a, nil - - case ErrMsg: - a.err = msg - a.state = StateResult // Show error in result view - return a, nil - } - - if a.state == StateInit { - a.textInput, cmd = a.textInput.Update(msg) - cmds = append(cmds, cmd) - } - - if a.state == StateLoadingCredentials || a.state == StateExecuting { - a.spinner, cmd = a.spinner.Update(msg) - cmds = append(cmds, cmd) - } - - if a.state == StateMenu { - a.list, cmd = a.list.Update(msg) - cmds = append(cmds, cmd) - } - - return a, tea.Sequence(cmds...) -} - -func (a *App) View() string { - if a.state == StateResult { - content := a.result - if a.err != nil { - content = fmt.Sprintf("Error: %v", a.err) - } - return fmt.Sprintf("%s\n\n(Press Esc to go back)", content) - } - - switch a.state { - case StateInit: - return a.renderPorkbunCredentialForm() - case StateLoadingCredentials: - return fmt.Sprintf("%s Loading credentials...", a.spinner.View()) - case StateMenu: - return a.list.View() - case StateExecuting: - return fmt.Sprintf("%s Executing...", a.spinner.View()) - } - return "Invalid state" -} - -func (a *App) renderPorkbunCredentialForm() string { - title := "Enter Porkbun API Key Name (pass entry):" - if a.apiKeyName != "" { - title = "Enter Porkbun Secret API Key Name (pass entry):" - } - - return fmt.Sprintf("%s\n%s\n\n(Press Enter to confirm)", title, a.textInput.View()) -} - -func (a *App) handleEnter() (tea.Model, tea.Cmd) { - val := a.textInput.Value() - if val == "" { - return a, nil - } - - if a.apiKeyName == "" { - a.apiKeyName = val - a.textInput.Reset() - a.textInput.Placeholder = "Secret Key Name" - return a, nil - } - - if a.secretApiKeyName == "" { - a.secretApiKeyName = val - a.textInput.Reset() - a.state = StateLoadingCredentials - return a, retrievePorkbunCredentialsCmd(a.apiKeyName, a.secretApiKeyName) - } - - return a, nil -} - -func (a *App) handleMenuEnter() (tea.Model, tea.Cmd) { - i, ok := a.list.SelectedItem().(item) - if !ok { - return a, nil - } - - switch i.title { - case "Util: Ping": - a.state = StateExecuting - return a, a.performPing() - } - return a, nil -} - -func (a *App) performPing() tea.Cmd { - return func() tea.Msg { - resp, err := a.client.Ping() - if err != nil { - return ErrMsg(err) - } - return PingResultMsg{IP: resp.YourIP} - } -} - -func retrievePorkbunCredentialsCmd(apiKeyName, secretKeyName string) tea.Cmd { - return func() tea.Msg { - apiKey, err := pass.Get(apiKeyName) - if err != nil { - return ErrMsg(err) - } - - secretKey, err := pass.Get(secretKeyName) - if err != nil { - return ErrMsg(err) - } - - return CredentialsLoadedMsg{ - APIKey: apiKey, - SecretKey: secretKey, - } - } -} - diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..dcdd3bf --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,103 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "git.juancwu.dev/juancwu/porkbacon/internal/security" +) + +type Config struct { + EncryptedApiKey string `json:"encrypted_api_key"` + EncryptedSecretApiKey string `json:"encrypted_secret_api_key"` + + filename string `json:"-"` +} + +func Load() (*Config, error) { + userConfigDir, err := os.UserConfigDir() + if err != nil { + return nil, fmt.Errorf("failed to get user configuration directory: %w", err) + } + + configDir := filepath.Join(userConfigDir, "porkbacon") + if err := os.MkdirAll(configDir, 0700); err != nil { + return nil, fmt.Errorf("failed to create config directory: %w", err) + } + + filename := filepath.Join(configDir, "config.json") + + // If file doesn't exist, return empty config with filename set + if _, err := os.Stat(filename); os.IsNotExist(err) { + return &Config{filename: filename}, nil + } + + fileData, err := os.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + var cfg Config + err = json.Unmarshal(fileData, &cfg) + if err != nil { + // If JSON is invalid, return empty config (overwrite) or error? + // Let's return error so user knows something is wrong. + return nil, fmt.Errorf("failed to unmarshal config file: %w", err) + } + cfg.filename = filename + + return &cfg, nil +} + +func (cfg *Config) Save() error { + fileData, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + err = os.WriteFile(cfg.filename, fileData, 0600) + if err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + return nil +} + +func (cfg *Config) SetCredentials(apiKey, secretKey, password string) error { + encApiKey, err := security.Encrypt(apiKey, password) + if err != nil { + return err + } + encSecretKey, err := security.Encrypt(secretKey, password) + if err != nil { + return err + } + + cfg.EncryptedApiKey = encApiKey + cfg.EncryptedSecretApiKey = encSecretKey + return nil +} + +func (cfg *Config) GetCredentials(password string) (apiKey, secretKey string, err error) { + if cfg.EncryptedApiKey == "" || cfg.EncryptedSecretApiKey == "" { + return "", "", fmt.Errorf("credentials not set") + } + + apiKey, err = security.Decrypt(cfg.EncryptedApiKey, password) + if err != nil { + return "", "", fmt.Errorf("invalid password or corrupted api key") + } + + secretKey, err = security.Decrypt(cfg.EncryptedSecretApiKey, password) + if err != nil { + return "", "", fmt.Errorf("invalid password or corrupted secret key") + } + + return apiKey, secretKey, nil +} + +func (cfg *Config) HasCredentials() bool { + return cfg.EncryptedApiKey != "" && cfg.EncryptedSecretApiKey != "" +} diff --git a/internal/pass/pass.go b/internal/pass/pass.go deleted file mode 100644 index cd5e4b4..0000000 --- a/internal/pass/pass.go +++ /dev/null @@ -1,18 +0,0 @@ -package pass - -import ( - "fmt" - "os/exec" - "strings" -) - -func Get(name string) (string, error) { - cmd := exec.Command("pass", "show", name) - - output, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("command execution failed: %w", err) - } - - return strings.TrimSpace(string(output)), nil -} diff --git a/internal/security/security.go b/internal/security/security.go new file mode 100644 index 0000000..cf3c372 --- /dev/null +++ b/internal/security/security.go @@ -0,0 +1,98 @@ +package security + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "io" + + "golang.org/x/crypto/scrypt" +) + +const ( + scryptN = 32768 + scryptR = 8 + scryptP = 1 + scryptKeyLen = 32 +) + +// Encrypt takes a plain text string and a password, returning a base64 encoded string +// containing the salt, nonce, and ciphertext. +func Encrypt(plainText, password string) (string, error) { + salt := make([]byte, 16) + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return "", fmt.Errorf("failed to generate salt: %w", err) + } + + key, err := scrypt.Key([]byte(password), salt, scryptN, scryptR, scryptP, scryptKeyLen) + if err != nil { + return "", fmt.Errorf("failed to derive key: %w", err) + } + + block, err := aes.NewCipher(key) + if err != nil { + return "", fmt.Errorf("failed to create cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("failed to create GCM: %w", err) + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", fmt.Errorf("failed to generate nonce: %w", err) + } + + cipherText := gcm.Seal(nonce, nonce, []byte(plainText), nil) + + combined := append(salt, cipherText...) + return base64.StdEncoding.EncodeToString(combined), nil +} + +// Decrypt takes the base64 encoded string and password, returning the plain text. +func Decrypt(encodedData, password string) (string, error) { + data, err := base64.StdEncoding.DecodeString(encodedData) + if err != nil { + return "", fmt.Errorf("failed to decode base64: %w", err) + } + + if len(data) < 16+12 { // 16 salt + 12 nonce (min) + return "", errors.New("invalid data length") + } + + salt := data[:16] + cipherTextWithNonce := data[16:] + + key, err := scrypt.Key([]byte(password), salt, scryptN, scryptR, scryptP, scryptKeyLen) + if err != nil { + return "", fmt.Errorf("failed to derive key: %w", err) + } + + block, err := aes.NewCipher(key) + if err != nil { + return "", fmt.Errorf("failed to create cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("failed to create GCM: %w", err) + } + + nonceSize := gcm.NonceSize() + if len(cipherTextWithNonce) < nonceSize { + return "", errors.New("ciphertext too short") + } + + nonce, actualCipherText := cipherTextWithNonce[:nonceSize], cipherTextWithNonce[nonceSize:] + + plainText, err := gcm.Open(nil, nonce, actualCipherText, nil) + if err != nil { + return "", errors.New("invalid password or corrupted data") + } + + return string(plainText), nil +} diff --git a/internal/ui/messages/messages.go b/internal/ui/messages/messages.go new file mode 100644 index 0000000..c099f34 --- /dev/null +++ b/internal/ui/messages/messages.go @@ -0,0 +1,20 @@ +package messages + +import "git.juancwu.dev/juancwu/porkbacon/internal/porkbun" + +type Page int + +const ( + PageLogin Page = iota + PageMenu +) + +type SwitchPageMsg struct { + Page Page +} + +type SessionReadyMsg struct { + Client *porkbun.Client +} + +type ErrorMsg error diff --git a/internal/ui/model.go b/internal/ui/model.go new file mode 100644 index 0000000..73bb767 --- /dev/null +++ b/internal/ui/model.go @@ -0,0 +1,86 @@ +package ui + +import ( + "fmt" + "os" + + "git.juancwu.dev/juancwu/porkbacon/internal/config" + "git.juancwu.dev/juancwu/porkbacon/internal/ui/messages" + "git.juancwu.dev/juancwu/porkbacon/internal/ui/pages/login" + "git.juancwu.dev/juancwu/porkbacon/internal/ui/pages/menu" + tea "github.com/charmbracelet/bubbletea" +) + +type MainModel struct { + currentPage messages.Page + login *login.Model + menu *menu.Model + isMenuInit bool + width int + height int +} + +func New() MainModel { + cfg, err := config.Load() + if err != nil { + fmt.Printf("Error loading config: %v\n", err) + os.Exit(1) + } + + return MainModel{ + currentPage: messages.PageLogin, + login: login.New(cfg), + } +} + +func (m MainModel) Init() tea.Cmd { + return m.login.Init() +} + +func (m MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + case messages.SwitchPageMsg: + m.currentPage = msg.Page + return m, nil + case messages.SessionReadyMsg: + m.menu = menu.New(msg.Client) + if m.width > 0 && m.height > 0 { + newMenu, _ := m.menu.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.menu = newMenu.(*menu.Model) + } + m.currentPage = messages.PageMenu + m.isMenuInit = true + return m, m.menu.Init() + } + + switch m.currentPage { + case messages.PageLogin: + var newLogin tea.Model + newLogin, cmd = m.login.Update(msg) + m.login = newLogin.(*login.Model) + case messages.PageMenu: + var newMenu tea.Model + newMenu, cmd = m.menu.Update(msg) + m.menu = newMenu.(*menu.Model) + } + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func (m MainModel) View() string { + switch m.currentPage { + case messages.PageLogin: + return m.login.View() + case messages.PageMenu: + return m.menu.View() + default: + return "Unknown Page" + } +} diff --git a/internal/ui/pages/login/model.go b/internal/ui/pages/login/model.go new file mode 100644 index 0000000..95fd4de --- /dev/null +++ b/internal/ui/pages/login/model.go @@ -0,0 +1,192 @@ +package login + +import ( + "fmt" + "strings" + + "git.juancwu.dev/juancwu/porkbacon/internal/config" + "git.juancwu.dev/juancwu/porkbacon/internal/porkbun" + "git.juancwu.dev/juancwu/porkbacon/internal/ui/messages" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +type Mode int + +const ( + ModeSetup Mode = iota + ModeUnlock +) + +type Model struct { + cfg *config.Config + inputs []textinput.Model + focusIndex int + mode Mode + err error +} + +func New(cfg *config.Config) *Model { + m := Model{ + cfg: cfg, + } + + if cfg.HasCredentials() { + m.mode = ModeUnlock + m.inputs = make([]textinput.Model, 1) + m.inputs[0] = textinput.New() + m.inputs[0].Placeholder = "Master Password" + m.inputs[0].EchoMode = textinput.EchoPassword + m.inputs[0].Width = 50 + m.inputs[0].Focus() + } else { + m.mode = ModeSetup + m.inputs = make([]textinput.Model, 3) + + m.inputs[0] = textinput.New() + m.inputs[0].Placeholder = "API Key" + m.inputs[0].EchoMode = textinput.EchoPassword + m.inputs[0].Width = 50 + m.inputs[0].Focus() + + m.inputs[1] = textinput.New() + m.inputs[1].Placeholder = "Secret API Key" + m.inputs[1].EchoMode = textinput.EchoPassword + m.inputs[1].Width = 50 + + m.inputs[2] = textinput.New() + m.inputs[2].Placeholder = "Master Password (to encrypt keys)" + m.inputs[2].EchoMode = textinput.EchoPassword + m.inputs[2].Width = 50 + } + + return &m +} + +func (m *Model) Init() tea.Cmd { + return textinput.Blink +} + +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "tab", "shift+tab", "enter", "up", "down": + s := msg.String() + + if s == "enter" && m.focusIndex == len(m.inputs)-1 { + return m.submit() + } + + if s == "up" || s == "shift+tab" { + m.focusIndex-- + } else { + m.focusIndex++ + } + + if m.focusIndex > len(m.inputs)-1 { + m.focusIndex = 0 + } else if m.focusIndex < 0 { + m.focusIndex = len(m.inputs) - 1 + } + + cmds := make([]tea.Cmd, len(m.inputs)) + for i := 0; i <= len(m.inputs)-1; i++ { + if i == m.focusIndex { + cmds[i] = m.inputs[i].Focus() + continue + } + m.inputs[i].Blur() + } + return m, tea.Batch(cmds...) + } + } + + // Handle character input and blinking + cmd := m.updateInputs(msg) + return m, cmd +} + +func (m *Model) updateInputs(msg tea.Msg) tea.Cmd { + cmds := make([]tea.Cmd, len(m.inputs)) + for i := range m.inputs { + m.inputs[i], cmds[i] = m.inputs[i].Update(msg) + } + return tea.Batch(cmds...) +} + +func (m *Model) View() string { + var b strings.Builder + + if m.mode == ModeSetup { + b.WriteString("Welcome to Porkbacon! Let's set up your credentials.\n\n") + } else { + b.WriteString("Welcome back! Please unlock your vault.\n\n") + } + + for i := range m.inputs { + b.WriteString(m.inputs[i].View()) + if i < len(m.inputs)-1 { + b.WriteRune('\n') + } + } + + b.WriteString("\n\n(Press Enter to confirm, Ctrl+C to quit)\n") + + if m.err != nil { + b.WriteString(fmt.Sprintf("\nError: %v\n", m.err)) + } + + return b.String() +} + +func (m *Model) submit() (tea.Model, tea.Cmd) { + var client *porkbun.Client + + if m.mode == ModeSetup { + apiKey := m.inputs[0].Value() + secretKey := m.inputs[1].Value() + password := m.inputs[2].Value() + + if apiKey == "" || secretKey == "" || password == "" { + m.err = fmt.Errorf("all fields are required") + return m, nil + } + + client = porkbun.New(apiKey, secretKey) + _, err := client.Ping() + if err != nil { + m.err = err + return m, nil + } + + err = m.cfg.SetCredentials(apiKey, secretKey, password) + if err != nil { + m.err = err + return m, nil + } + + err = m.cfg.Save() + if err != nil { + m.err = err + return m, nil + } + } else { + password := m.inputs[0].Value() + apiKey, secretKey, err := m.cfg.GetCredentials(password) + if err != nil { + m.err = err + // Clear password field on error + m.inputs[0].SetValue("") + return m, nil + } + + client = porkbun.New(apiKey, secretKey) + } + + return m, func() tea.Msg { + return messages.SessionReadyMsg{Client: client} + } +} diff --git a/internal/ui/pages/menu/model.go b/internal/ui/pages/menu/model.go new file mode 100644 index 0000000..d73c370 --- /dev/null +++ b/internal/ui/pages/menu/model.go @@ -0,0 +1,124 @@ +package menu + +import ( + "fmt" + + "git.juancwu.dev/juancwu/porkbacon/internal/porkbun" + "git.juancwu.dev/juancwu/porkbacon/internal/ui/messages" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" +) + +type pingResultMsg struct { + IP string +} + +type item struct { + id uint8 + title, desc string +} + +func (i item) ID() uint8 { return i.id } +func (i item) Title() string { return i.title } +func (i item) Description() string { return i.desc } +func (i item) FilterValue() string { return i.title } + +const ( + domainListAll uint8 = iota + domainGetDetails + dnsRetrieveRecords + dnsCreateRecord + dnsEditRecord + dnsDeleteRecord + utilPing +) + +type Model struct { + list list.Model + client *porkbun.Client + err error + output string +} + +func New(client *porkbun.Client) *Model { + items := []list.Item{ + item{id: domainListAll, title: "Domain: List All", desc: "List all domains in your account"}, + item{id: domainGetDetails, title: "Domain: Get Details", desc: "Get details for a specific domain"}, + item{id: dnsRetrieveRecords, title: "DNS: Retrieve Records", desc: "Retrieve DNS records for a domain"}, + item{id: dnsCreateRecord, title: "DNS: Create Record", desc: "Create a new DNS record"}, + item{id: dnsEditRecord, title: "DNS: Edit Record", desc: "Edit an existing DNS record"}, + item{id: dnsDeleteRecord, title: "DNS: Delete Record", desc: "Delete a DNS record"}, + item{id: utilPing, title: "Util: Ping", desc: "Ping Porkbun"}, + } + + l := list.New(items, list.NewDefaultDelegate(), 0, 0) + l.Title = "Porkbun Actions" + + return &Model{ + list: l, + client: client, + } +} + +func (m *Model) Init() tea.Cmd { + return nil +} + +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.list.SetWidth(msg.Width) + m.list.SetHeight(msg.Height) + return m, nil + + case tea.KeyMsg: + if m.output != "" { + if msg.String() == "esc" { + m.output = "" + return m, nil + } + return m, nil + } + + if msg.String() == "enter" { + i, ok := m.list.SelectedItem().(item) + if ok { + return m.handleSelection(i) + } + } + + case pingResultMsg: + m.output = fmt.Sprintf("Ping successful!\nYour IP: %s", msg.IP) + return m, nil + + case messages.ErrorMsg: + m.output = fmt.Sprintf("Error: %v", msg) + return m, nil + } + + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd +} + +func (m *Model) View() string { + if m.output != "" { + return fmt.Sprintf("%s\n\n(Press Esc to go back)", m.output) + } + return m.list.View() +} + +func (m *Model) handleSelection(i item) (tea.Model, tea.Cmd) { + // TODO: Implement other actions. For now, just Ping. + switch i.id { + case utilPing: + return m, func() tea.Msg { + resp, err := m.client.Ping() + if err != nil { + return messages.ErrorMsg(err) + } + return pingResultMsg{IP: resp.YourIP} + } + } + return m, nil +}