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
}
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"` Created string `json:"created"` }
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.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 }
func sendEmailNotification(user User) error {
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)
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))
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 {
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)
}
}
}
users = append(users, user)
jsonData, err := json.MarshalIndent(users, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal data: %v", err)
}
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 {
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()
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 {
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()
menu := list.New(items, list.NewDefaultDelegate(), 0, 0)
menu.Title = "Datorföreningen vid LU & LTH medlemskapsansökan"
menu.SetShowStatusBar(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 inputs[i] = t
}
inputs[0].Focus()
ti := textarea.New()
ti.Placeholder = "Klistra in din SSH-nyckel här..."
ti.ShowLineNumbers = true
ti.MaxWidth = 60
ti.MaxHeight = 10
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 }
selectedItem, ok := selected.(item)
if !ok {
return m, nil }
switch selectedItem.title {
case "Registrering":
m.state = stateRegister
m.statusMsg = ""
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
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 {
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
}
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 {
keys, _ := io.ReadAll(resp.Body)
sshKey = string(keys)
} else {
m.statusMsg = "Error: GitHub user not found"
return m, nil
}
}
user := User{
Username: username,
Name: name,
Address: address,
Zip: zip,
Phone: phone,
Email: email,
SSHKey: sshKey,
GithubUser: githubUsername,
Created: createDate,
}
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
}
if err := sendEmailNotification(user); err != nil {
m.statusMsg = fmt.Sprintf("Error sending notification: %v", err)
return m, nil
}
log.Info("User registered", "username", username)
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 {
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"))
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"
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(), 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)
}
}
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
pty, _, _ := s.Pty()
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()}
}