package main

import (
        "context"
        "encoding/csv"
        "encoding/json"
        "errors"
        "fmt"
        "io"
        "net"
        "net/http"
        "os"
        "os/exec"
        "os/signal"
        _ "runtime"
        "strings"
        "syscall"
        "time"

        "github.com/charmbracelet/bubbles/list"
        "github.com/charmbracelet/bubbles/textarea"
        "github.com/charmbracelet/bubbles/textinput"
        "github.com/charmbracelet/bubbles/viewport"
        "github.com/charmbracelet/bubbletea"
        "github.com/charmbracelet/glamour"
        "github.com/charmbracelet/lipgloss"
        "github.com/charmbracelet/log"
        "github.com/charmbracelet/ssh"
        "github.com/charmbracelet/wish"
        "github.com/charmbracelet/wish/activeterm"
        "github.com/charmbracelet/wish/bubbletea"
        "github.com/charmbracelet/wish/logging"
        "github.com/muesli/termenv"
)

type model struct {
        state       string
        menu        list.Model
        inputs      []textinput.Model
        sshKeyInput textarea.Model
        userInput   textinput.Model
        activeInput int
        viewport    viewport.Model
        rules       string
        userOutput  string
        width       int
        height      int
        statusMsg   string
        term        string
        profile     string
        bg          string
        txtStyle    lipgloss.Style
        quitStyle   lipgloss.Style
}

type item struct {
        title       string
        description string
}

// // Define a struct to hold user data
// type User struct {
//      Username  string
//      Name      string
//      Address   string
//      Zip       string
//      Phone     string
//      Email     string
//      SSHKey    string
//      githubkey string
// }

// Updated User struct to include json tags and timestamps
type User struct {
        Username   string `json:"username"`
        Name       string `json:"name"`
        Address    string `json:"address"`
        Zip        string `json:"zip"`
        Phone      string `json:"phone"`
        Email      string `json:"email"`
        SSHKey     string `json:"ssh_key"`
        GithubUser string `json:"github_user"` // Changed to exported field with proper JSON tag
        Created    string `json:"created"`     // Added to store timestamp
}

func (i item) Title() string       { return i.title }
func (i item) Description() string { return i.description }
func (i item) FilterValue() string { return i.title }

const (
        stateMenu     = "menu"
        stateRegister = "register"
        stateRules    = "rules"
        stateUser     = "user"
        // usersFile     = "users.csv"
        usersFile = "users.json"
)

var (
        focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
        blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
        statusStyle  = lipgloss.NewStyle().Foreground(lipgloss.Color("86"))
)

func checkUserExists(username string) bool {
        cmd := exec.Command("getent", "passwd", username)
        err := cmd.Run()
        return err == nil // Returns true if user exists (exit code 0)
}

func sendEmailNotification(user User) error {
        // Create the email body
        emailBody := fmt.Sprintf("Tjena gänget, nu vill en ny lirare slira in i vår förening,\n\n"+
                "Username: %s\n"+
                "Full name: %s\n"+
                "Address: %s\n"+
                "Zip: %s\n"+
                "Phone: %s\n"+
                "Email: %s\n"+
                "Github username: %s\n"+
                "Registration date: %s\n"+
                "SSH KEY: %s",
                user.Username,
                user.Name,
                user.Address,
                user.Zip,
                user.Phone,
                user.Email,
                user.GithubUser,
                user.Created,
                user.SSHKey)

        // Build the command similar to the working terminal command
        subject := fmt.Sprintf("Medlemskapsansökan: %s", user.Username)
        cmd := exec.Command("sh", "-c", fmt.Sprintf("echo '%s' | mail -s '%s' styrelsen@dflund.se", emailBody, subject))

        // Run the command
        if output, err := cmd.CombinedOutput(); err != nil {
                return fmt.Errorf("failed to send email: %v, output: %s", err, string(output))
        }

        return nil
}

func saveUserJSON(user User) error {
        // Set creation timestamp
        // user.Created = time.Now().Format(time.DateOnly)
        // Use ISO 8601 format (e.g., "2025-01-20T15:04:05Z")
        // user.Created = time.Now().Format(time.RFC3339)

        // Read existing users if file exists
        var users []User
        if _, err := os.Stat(usersFile); err == nil {
                fileData, err := os.ReadFile(usersFile)
                if err != nil {
                        return fmt.Errorf("failed to read file: %v", err)
                }

                if len(fileData) > 0 {
                        if err := json.Unmarshal(fileData, &users); err != nil {
                                return fmt.Errorf("failed to parse existing data: %v", err)
                        }
                }
        }

        // Append new user
        users = append(users, user)

        // Marshal with indentation
        jsonData, err := json.MarshalIndent(users, "", "    ")
        if err != nil {
                return fmt.Errorf("failed to marshal data: %v", err)
        }

        // Write to file
        if err := os.WriteFile(usersFile, jsonData, 0644); err != nil {
                return fmt.Errorf("failed to write file: %v", err)
        }

        log.Info("User saved successfully", "username", user.Username)

        return nil
}

func saveUserCSV(user User) error {
        // Create file if it doesn't exist
        file, err := os.OpenFile(usersFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
        if err != nil {
                return fmt.Errorf("failed to open file: %v", err)
        }
        defer file.Close()

        writer := csv.NewWriter(file)
        defer writer.Flush()

        // Write user data with timestamp
        record := []string{
                user.Username,
                user.Name,
                user.Address,
                user.Zip,
                user.Phone,
                user.Email,
                user.SSHKey,
                time.Now().Format(time.DateOnly),
        }

        if err := writer.Write(record); err != nil {
                return fmt.Errorf("failed to write record: %v", err)
        }

        return nil
}

func initialModel() model {
        // Initialize menu items
        items := []list.Item{
                item{title: "Registrering", description: "Ansök om medlemskap"},
                item{title: "Stadgar", description: "Läs föreningens stadgar"},
                item{title: "Hitta användare", description: "Kolla upp info om medlemmar (finger)"},
                item{title: "Avsluta", description: "Avsluta programmet"},
        }

        ui := textinput.New()
        ui.Placeholder = "Knappa in användarnamn att söka efter"
        ui.Focus()

        // Initialize menu
        menu := list.New(items, list.NewDefaultDelegate(), 0, 0)
        menu.Title = "Datorföreningen vid LU & LTH medlemskapsansökan"
        menu.SetShowStatusBar(false)
        // menu.SetShowTitle(false)

        placeholders := []string{"Användarnamn", "Namn", "Adress", "Postkod", "Telefon", "Epost", "Github användarnamn"}
        inputs := make([]textinput.Model, len(placeholders))

        for i, placeholder := range placeholders {
                t := textinput.New()
                t.CharLimit = 50
                t.Width = 40
                t.Placeholder = placeholder // Set placeholder from the slice
                inputs[i] = t
        }
        inputs[0].Focus()

        // Initialize SSH key textarea
        ti := textarea.New()
        ti.Placeholder = "Klistra in din SSH-nyckel här..."
        ti.ShowLineNumbers = true
        ti.MaxWidth = 60
        ti.MaxHeight = 10

        // Initialize viewport for rules
        vp := viewport.New(80, 20)
        vp.Style = lipgloss.NewStyle().Border(lipgloss.RoundedBorder())

        return model{
                state:       stateMenu,
                menu:        menu,
                inputs:      inputs,
                sshKeyInput: ti,
                userInput:   ui,
                viewport:    vp,
                activeInput: 0,
        }
}

func (m model) Init() tea.Cmd {
        return nil
}

func sanitizeField(input string) string {
        return strings.ReplaceAll(strings.TrimSpace(input), ",", ".")
}

func (m model) 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
                m.menu.SetWidth(msg.Width)
                m.menu.SetHeight(msg.Height - 4)
                m.viewport.Width = msg.Width - 4
                m.viewport.Height = msg.Height - 4
                m.sshKeyInput.SetWidth(msg.Width - 4)
                return m, nil

        case tea.KeyMsg:
                switch msg.String() {
                case "ctrl+c", "escape":
                        if m.state == stateMenu {
                                return m, tea.Quit
                        }
                        m.state = stateMenu
                        return m, nil

                case "up":
                        if m.state == stateRegister {
                                if m.sshKeyInput.Focused() {
                                        m.sshKeyInput.Blur()
                                        m.inputs[len(m.inputs)-1].Focus()
                                        m.activeInput = len(m.inputs) - 1
                                } else if m.activeInput > 0 {
                                        m.inputs[m.activeInput].Blur()
                                        m.activeInput--
                                        m.inputs[m.activeInput].Focus()
                                }
                        }

                case "down", "tab":
                        if m.state == stateRegister {
                                if m.activeInput < len(m.inputs)-1 {
                                        m.inputs[m.activeInput].Blur()
                                        m.activeInput++
                                        m.inputs[m.activeInput].Focus()
                                } else if !m.sshKeyInput.Focused() {
                                        m.inputs[m.activeInput].Blur()
                                        m.sshKeyInput.Focus()
                                }
                        }

                case "enter":
                        if m.state == stateMenu {
                                selected := m.menu.SelectedItem()
                                if selected == nil {
                                        return m, nil // No item selected, do nothing
                                }

                                selectedItem, ok := selected.(item)
                                if !ok {
                                        return m, nil // Type assertion failed, do nothing
                                }

                                switch selectedItem.title {
                                case "Registrering":
                                        m.state = stateRegister
                                        m.statusMsg = ""
                                        // Reset inputs and focus the first one
                                        for i := range m.inputs {
                                                m.inputs[i].Reset()
                                                m.inputs[i].Blur()
                                        }
                                        m.activeInput = 0
                                        m.inputs[0].Focus()
                                case "Stadgar":
                                        m.state = stateRules
                                        // Read rules from file
                                        rulesContent, err := os.ReadFile("stadgar.md")
                                        if err != nil {
                                                m.viewport.SetContent(fmt.Sprintf("Error reading rules file: %v", err))
                                                return m, nil
                                        }

                                        renderer, _ := glamour.NewTermRenderer(
                                                glamour.WithAutoStyle(),
                                                glamour.WithWordWrap(m.viewport.Width),
                                        )
                                        rendered, _ := renderer.Render(string(rulesContent))
                                        m.viewport.SetContent(rendered)
                                case "Hitta användare":
                                        m.state = stateUser
                                case "Avsluta":
                                        return m, tea.Quit
                                }
                        } else if m.state == stateRegister {
                                if m.activeInput == len(m.inputs)-1 {

                                        // Get fields with sanitization added
                                        username := sanitizeField(m.inputs[0].Value())
                                        name := sanitizeField(m.inputs[1].Value())
                                        address := sanitizeField(m.inputs[2].Value())
                                        zip := sanitizeField(m.inputs[3].Value())
                                        phone := sanitizeField(m.inputs[4].Value())
                                        email := sanitizeField(m.inputs[5].Value())
                                        sshKey := sanitizeField(m.sshKeyInput.Value())
                                        githubUsername := sanitizeField(m.inputs[6].Value())
                                        createDate := time.Now().Format(time.RFC3339)
                                        
                                        if checkUserExists(username) {
                                                m.statusMsg = "Error: User already exists with that username"
                                                log.Error("User attempted to register with existing username", "username", username)
                                                return m, nil
                                        }

                                        // now we need to curl github.com/<username>.keys and save the output to a file and if the http status code is not 404, save the github username to the user struct
                                        if githubUsername != "" {

                                                resp, err := http.Get(fmt.Sprintf("https://github.com/%s.keys", githubUsername))
                                                if err != nil {
                                                        m.statusMsg = fmt.Sprintf("Error: %v", err)
                                                        return m, nil
                                                }
                                                defer resp.Body.Close()

                                                if resp.StatusCode != http.StatusNotFound {
                                                        // Read the response body
                                                        keys, _ := io.ReadAll(resp.Body)
                                                        sshKey = string(keys)
                                                } else {
                                                        m.statusMsg = "Error: GitHub user not found"
                                                        return m, nil
                                                }
                                        } // no github username specified, use the ssh key from the textarea

                                        user := User{
                                                Username:   username,
                                                Name:       name,
                                                Address:    address,
                                                Zip:        zip,
                                                Phone:      phone,
                                                Email:      email,
                                                SSHKey:     sshKey,
                                                GithubUser: githubUsername,
                                                Created:   createDate,
                                        }

                                        // Basic validation
                                        if username == "" || name == "" || zip == "" || phone == "" || address == "" || email == "" {
                                                m.statusMsg = "Error: All fields are required"
                                                return m, nil
                                        }

                                        if err := saveUserJSON(user); err != nil {
                                                m.statusMsg = fmt.Sprintf("Error saving user: %v", err)
                                                return m, nil
                                        }

                                        // Add the email notification right after saving:
                                        if err := sendEmailNotification(user); err != nil {
                                                m.statusMsg = fmt.Sprintf("Error sending notification: %v", err)
                                                return m, nil
                                        }

                                        log.Info("User registered", "username", username)

                                        // Clear inputs and return to menu
                                        for i := range m.inputs {
                                                m.inputs[i].Reset()
                                        }
                                        m.sshKeyInput.Reset()
                                        m.activeInput = 0
                                        m.inputs[0].Focus()
                                        m.state = stateMenu
                                        m.statusMsg = "User registered successfully!"
                                } else {
                                        m.activeInput++
                                        m.inputs[m.activeInput].Focus()
                                        m.inputs[m.activeInput-1].Blur()
                                }
                        } else if m.state == stateUser {
                                // Execute finger command with user input
                                username := strings.TrimSpace(m.userInput.Value())
                                if username != "" {
                                        cmd := exec.Command("finger", "-m", username)
                                        output, err := cmd.Output()
                                        if err != nil {
                                                m.userOutput = fmt.Sprintf("Error: %v", err)
                                        } else {
                                                m.userOutput = string(output)
                                        }
                                        m.userInput.Reset()
                                }
                        }
                }
        }

        switch m.state {
        case stateMenu:
                m.menu, cmd = m.menu.Update(msg)
                cmds = append(cmds, cmd)
        case stateRegister:
                if m.sshKeyInput.Focused() {
                        m.sshKeyInput, cmd = m.sshKeyInput.Update(msg)
                        cmds = append(cmds, cmd)
                } else {
                        for i := range m.inputs {
                                m.inputs[i], cmd = m.inputs[i].Update(msg)
                                cmds = append(cmds, cmd)
                        }
                }
        case stateRules:
                m.viewport, cmd = m.viewport.Update(msg)
                cmds = append(cmds, cmd)
        case stateUser:
                m.userInput, cmd = m.userInput.Update(msg)
                cmds = append(cmds, cmd)
        }

        return m, tea.Batch(cmds...)
}

func (m model) View() string {
        var b strings.Builder

        switch m.state {
        case stateMenu:

                b.WriteString(m.menu.View())
                if m.statusMsg != "" {
                        b.WriteString("\n\n")
                        b.WriteString(statusStyle.Render(m.statusMsg))
                }
        case stateRegister:
                b.WriteString("Medlemskapsansökan\n\n")
                for i := range m.inputs {
                        if i == m.activeInput {
                                b.WriteString(focusedStyle.Render(m.inputs[i].View()))
                        } else {
                                b.WriteString(blurredStyle.Render(m.inputs[i].View()))
                        }
                        b.WriteString("\n")
                }
                b.WriteString(blurredStyle.Render("^Hämta ssh nyckel från github (valfritt)\n"))
                b.WriteString("\nSSH Nyckel: (valfritt)\n")
                b.WriteString(m.sshKeyInput.View())
                b.WriteString("\n\n")
                b.WriteString(focusedStyle.Render("Att skicka in ansökan innebär att du godkänner föreningens stadgar.\n\n"))

                if m.statusMsg != "" {
                        b.WriteString("\n")
                        b.WriteString(statusStyle.Render(m.statusMsg))
                }

                b.WriteString(blurredStyle.Render("\n\nTryck Enter för att skicka in ansökan • Esc för att avbryta"))
                // b.WriteString("\n\nPress Enter to submit • Esc to cancel")
        case stateRules:
                b.WriteString(m.viewport.View())
                b.WriteString("\nPress Esc to return to menu")
        case stateUser:
                b.WriteString("Find User\n\n")
                b.WriteString(m.userInput.View())
                b.WriteString("\n\nPress Enter to search • Esc to return to menu\n\n")
                if m.userOutput != "" {
                        b.WriteString(m.userOutput)
                }
        }
        return b.String()
}

const (
        host = "localhost"
        // host = "signup.dflund.se"
        port = "1337"
)

func main() {
        s, err := wish.NewServer(
                wish.WithAddress(net.JoinHostPort(host, port)),
                wish.WithHostKeyPath(".ssh/id_ed25519"),
                wish.WithMiddleware(
                        bubbletea.Middleware(teaHandler),
                        activeterm.Middleware(), // Bubble Tea apps usually require a PTY.
                        logging.Middleware(),
                ),
        )
        if err != nil {
                log.Error("Could not start server", "error", err)
        }

        done := make(chan os.Signal, 1)
        signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
        log.Info("Starting SSH server", "host", host, "port", port)
        go func() {
                if err = s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) {
                        log.Error("Could not start server", "error", err)
                        done <- nil
                }
        }()

        <-done
        log.Info("Stopping SSH server")
        ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
        defer func() { cancel() }()
        if err := s.Shutdown(ctx); err != nil && !errors.Is(err, ssh.ErrServerClosed) {
                log.Error("Could not stop server", "error", err)
        }
}

// You can wire any Bubble Tea model up to the middleware with a function that
// handles the incoming ssh.Session. Here we just grab the terminal info and
// pass it to the new model. You can also return tea.ProgramOptions (such as
// tea.WithAltScreen) on a session by session basis.
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
        // This should never fail, as we are using the activeterm middleware.
        pty, _, _ := s.Pty()

        // When running a Bubble Tea app over SSH, you shouldn't use the default
        // lipgloss.NewStyle function.
        // That function will use the color profile from the os.Stdin, which is the
        // server, not the client.
        // We provide a MakeRenderer function in the bubbletea middleware package,
        // so you can easily get the correct renderer for the current session, and
        // use it to create the styles.
        // The recommended way to use these styles is to then pass them down to
        // your Bubble Tea model.
        renderer := bubbletea.MakeRenderer(s)
        txtStyle := renderer.NewStyle().Foreground(lipgloss.Color("10"))
        quitStyle := renderer.NewStyle().Foreground(lipgloss.Color("8"))

        bg := "dark"

        m := initialModel()
        m.term = pty.Term
        m.profile = renderer.ColorProfile().Name()
        m.width = pty.Window.Width
        m.height = pty.Window.Height
        m.bg = bg
        m.txtStyle = txtStyle
        m.quitStyle = quitStyle
        lipgloss.SetColorProfile(termenv.TrueColor)
        return m, []tea.ProgramOption{tea.WithAltScreen()}
}

Generated by Getz using scpaste at Sat Jan 25 00:47:00 2025. CET. (original)