package main import ( "context" "database/sql" "errors" "fmt" "log" "net" "os" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/spinner" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "charm.land/ssh" "charm.land/wish/v2" "charm.land/wish/v2/activeterm" "charm.land/wish/v2/bubbletea" "charm.land/wish/v2/logging" gossh "golang.org/x/crypto/ssh" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/stdlib" "github.com/joho/godotenv" "golang.org/x/sync/errgroup" "jp.wtf/go-fishing/internal/db" "jp.wtf/go-fishing/internal/handlers" "jp.wtf/go-fishing/internal/migrations" "jp.wtf/go-fishing/internal/models" // "charm.land/log/v2" _ "github.com/jackc/pgx/v5/stdlib" "github.com/jmoiron/sqlx" "github.com/pressly/goose/v3" ) const ( ssh_host = "0.0.0.0" ssh_port = "22" api_port = "9900" ) func VersionMiddleware(version string) gin.HandlerFunc { return func(c *gin.Context) { c.Set("APIVersion", version) } } func main() { err := godotenv.Load() if err != nil && !errors.Is(err, os.ErrNotExist) { log.Fatal("Failed to load .env: ", err.Error()) } g, ctx := errgroup.WithContext(context.Background()) ENVIRONMENT := os.Getenv("ENV") DB_CONNECTION_STRING, db_conn_string_exists := os.LookupEnv("DB_CONNECTION_STRING") COOKIE_SECRET, secret_env_exists := os.LookupEnv("COOKIE_STORE_SECRET") if !db_conn_string_exists { log.Fatal("DB_CONNECTION_STRING env var is required") } if !secret_env_exists { log.Fatal("COOKIE_STORE_SECRET env var is required") } pool, err := pgxpool.New(ctx, DB_CONNECTION_STRING) if err != nil { log.Fatal("Failed to connect to DB: ", err.Error()) } defer pool.Close() database := sqlx.NewDb(stdlib.OpenDBFromPool(pool), "pgx") if err := database.Ping(); err != nil { log.Fatal("Database is not reachable: ", err) } db := db.Database{Conn: database} goose.SetBaseFS(migrations.Files) if err := goose.SetDialect("postgres"); err != nil { log.Fatal("Failed to set goos dialect: ", err) } if err := goose.Up(database.DB, "."); err != nil { log.Fatal("Failed to execute migrations: ", err) } g.Go(func() error { if ENVIRONMENT == "PRODUCTION" { gin.SetMode(gin.ReleaseMode) } r := gin.Default() if ENVIRONMENT == "PRODUCTION" { r.TrustedPlatform = gin.PlatformFlyIO } else { r.SetTrustedProxies(nil) } store := cookie.NewStore([]byte(COOKIE_SECRET)) r.Use(sessions.Sessions("auth", store)) v1 := r.Group("/api/v1") { v1.Use(VersionMiddleware("v1")) handlers := handlers.Env{DB: &db} handlers.RegisterRoutes(v1) } return r.Run(fmt.Sprintf(":%s", api_port)) }) srv, err := wish.NewServer( // The address the server will listen to. wish.WithAddress(net.JoinHostPort(ssh_host, ssh_port)), // The SSH server need its own keys, this will create a keypair in the // given path if it doesn't exist yet. // By default, it will create an ED25519 key. wish.WithHostKeyPath(".ssh/id_ed25519"), wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool { angler, err := db.GetAnglerByKey(key) if err != nil { if !errors.Is(sql.ErrNoRows, err) { return false } } else { ctx.SetValue("angler", angler) } return key.Type() == "ssh-ed25519" }), // Middlewares do something on a ssh.Session, and then call the next // middleware in the stack. wish.WithMiddleware( bubbletea.Middleware(teaHandler(&db)), activeterm.Middleware(), // Bubble Tea apps usually require a PTY. func(next ssh.Handler) ssh.Handler { return func(sess ssh.Session) { raw := sess.Context().Value("angler") if raw != nil { next(sess) } else { angler, err := db.CreateNewAnglerFromKey(sess.PublicKey()) if err != nil { // TODO: hangle error println("err", err.Error()) } sess.Context().SetValue("angler", angler) next(sess) } } }, // The last item in the chain is the first to be called. logging.Middleware(), ), ) if err != nil { log.Fatal("Could not start server", "error", err) } log.Print("Starting SSH server @", ssh_host, ":", ssh_port) g.Go(func() error { return srv.ListenAndServe() }) g.Go(func() error { <-ctx.Done() // one of the servers failed, // or SIGINT/SIGTERM happened return nil }) g.Wait() } type page int const ( PageChooseName page = iota PageGameplay ) type tab int const ( TabFishin tab = iota TabWares TabDock TabSettings ) type model struct { profile string term string width int height int bg string DB *db.Database page page angler models.Angler tabs []string tab tab error string ti textinput.Model spinner spinner.Model active tea.Model cast models.Cast } func (m model) Init() tea.Cmd { var cmd tea.Cmd var cmds []tea.Cmd // Pages that need initialization should be called here if we can start on them switch m.page { case PageGameplay: m, cmd = m.gotoGameplayView() cmds = append(cmds, cmd) } // default values return tea.Batch( append(cmds, tea.RequestBackgroundColor)..., ) } func (m model) ChooseNameView() tea.View { var error string if len(m.error) > 0 { error = lipgloss.NewStyle().Foreground(lipgloss.Red).Render(m.error) } input := lipgloss.JoinVertical(lipgloss.Top, "aye fresh meat, got a name laddie?", m.ti.View(), error) center := lipgloss.Place( m.width, m.height-3, lipgloss.Center, lipgloss.Center, input, ) footer := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("(esc to quit)") bottom := lipgloss.Place( m.width, 3, lipgloss.Center, lipgloss.Center, footer, ) str := lipgloss.JoinVertical( lipgloss.Left, center, bottom, ) var c *tea.Cursor c = m.ti.Cursor() c.Y += lipgloss.Height("aye fresh meat, got a name laddie?") + (m.height-3-lipgloss.Height(input))/2 c.X += (m.width - lipgloss.Width(input)) / 2 v := tea.NewView(str) v.Cursor = c v.AltScreen = true return v } func (m model) ChooseNameUpdate(msg tea.Msg) (model, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd switch msg := msg.(type) { case tea.KeyPressMsg: m.error = "" switch msg.String() { case "enter": existing := 0 desired_username := m.ti.Value() err := m.DB.Conn.Get(&existing, "SELECT 1 FROM anglers WHERE angler_username = $1", desired_username) if err != nil { // TODO: handle error } if existing > 0 { m.error = "that one is taken" } else { err = m.DB.Conn.Get( &m.angler, "UPDATE anglers SET angler_username = $1 WHERE angler_id = $2 RETURNING angler_id, angler_username, angler_email", desired_username, m.angler.ID, ) if err != nil { // TODO: handle error } m, cmd = m.gotoGameplayView() cmds = append(cmds, cmd) } } } m.ti.Placeholder = m.angler.Username m.ti, cmd = m.ti.Update(msg) cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } var buttonStyle = lipgloss.NewStyle().Padding(0, 2).Align(lipgloss.Center).Background(lipgloss.Color("8")) var buttonActiveStyle = buttonStyle.Background(lipgloss.Color("30")) var sidebarBtnStyle = lipgloss.NewStyle().Padding(0, 2).Align(lipgloss.Center).Foreground(lipgloss.Color("30")) var sidebarBtnActiveStyle = sidebarBtnStyle.UnsetForeground().Background(lipgloss.Color("30")) func (m model) GameplayView() tea.View { var renderedTabs []string for i, tab := range m.tabs { if i == int(m.tab) { renderedTabs = append(renderedTabs, sidebarBtnActiveStyle.Render(tab)) } else { renderedTabs = append(renderedTabs, sidebarBtnStyle.Render(tab)) } } tabs := lipgloss.NewStyle().Margin(0, 1).Render(lipgloss.JoinHorizontal( lipgloss.Top, renderedTabs..., )) paddingY, paddingX := 1, 2 content := lipgloss.NewStyle(). Width(m.width-paddingX*2). Height(m.height-2-paddingY*2). BorderStyle(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color("30")). Padding(paddingY, paddingX). Render(lipgloss.JoinVertical( lipgloss.Left, lipgloss.NewStyle().MarginBottom(1).Render(fmt.Sprintf("Aye matie %s...time to get fishin'", m.angler.Username)), buttonActiveStyle.Render("Cast"), )) footer := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("(esc to quit)") box := lipgloss.NewStyle(). Width(m.width). Height(m.height). Padding(paddingY, paddingX). Render(lipgloss.JoinVertical( lipgloss.Left, tabs, content, footer, )) page := lipgloss.Place( m.width, m.height-1, lipgloss.Center, lipgloss.Center, box, ) // bottom := lipgloss.Place( // m.width, // 3, // lipgloss.Center, // lipgloss.Center, // footer, // ) v := tea.NewView(page) v.AltScreen = true return v } type GameplayKeyMap struct { CycleTab key.Binding AltCycleTab key.Binding Interact key.Binding } var DefaultGameplayKeyMap = GameplayKeyMap{ CycleTab: key.NewBinding( key.WithKeys("tab"), key.WithHelp("tab", "Cycle Tab Right"), ), AltCycleTab: key.NewBinding( key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "Cycle Tab Left"), ), Interact: key.NewBinding( key.WithKeys("enter"), key.WithHelp("enter", "Interact with selected option"), ), } var sayings = []string{ // Near Misses "Close but no cigar", "Almost only counts in horseshoes and hand grenades", "A day late and a dollar short", "A miss is as good as a mile", "Always a bridesmaid, never a bride", "Snatched defeat from the jaws of victory", "Fell at the last hurdle", "Missed the boat", "Missed it by a whisker", "So near, and yet so far", "A swing and a miss", // Futile Efforts "Beating a dead horse", "Tilting at windmills", "Squeezing blood from a stone", "Nailing jelly to a wall", "Herding cats", "Rearranging deck chairs on the Titanic", "Pushing a rope", "Spitting in the wind", "Putting toothpaste back in the tube", "Shoveling snow in a blizzard", "Spinning your wheels", "Emptying the ocean with a teaspoon", "Barking up the wrong tree", "A wild goose chase", "Catching the wind in a net", } func (m model) GameplayUpdate(msg tea.Msg) (model, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd switch msg := msg.(type) { case spinner.TickMsg: m.spinner, cmd = m.spinner.Update(msg) cmds = append(cmds, cmd) case tea.KeyPressMsg: switch { case key.Matches(msg, DefaultGameplayKeyMap.CycleTab): if int(m.tab) >= len(m.tabs)-1 { m.tab = 0 } else { m.tab += 1 } case key.Matches(msg, DefaultGameplayKeyMap.AltCycleTab): if int(m.tab) <= 0 { m.tab = tab(len(m.tabs) - 1) } else { m.tab -= 1 } case key.Matches(msg, DefaultGameplayKeyMap.Interact): // if m.active = { is_casting, err := m.DB.IsCasting(m.angler.ID) if is_casting { m.error = "looks like you are already casting somewhere" // TODO: clear after a timeout } m.cast, err = m.DB.CreateNewCast(m.angler.ID) if err != nil { m.error = "unable to start your cast" // TODO: handle error } // TODO: implement rest of casting logic // } } } cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } func (m model) gotoGameplayView() (model, tea.Cmd) { m.page = PageGameplay return m, m.spinner.Tick } type GlobalKeyMap struct { Close key.Binding } var DefaultGlobalKeyMap = GlobalKeyMap{ Close: key.NewBinding( key.WithKeys("esc", "ctrl+c"), key.WithHelp("esc", "Quit"), ), } 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.ColorProfileMsg: m.profile = msg.String() case tea.BackgroundColorMsg: if msg.IsDark() { m.bg = "dark" } case tea.WindowSizeMsg: m.height = msg.Height m.width = msg.Width case tea.KeyPressMsg: switch { case key.Matches(msg, DefaultGlobalKeyMap.Close): return m, tea.Quit } } switch m.page { case PageChooseName: m, cmd = m.ChooseNameUpdate(msg) cmds = append(cmds, cmd) case PageGameplay: m, cmd = m.GameplayUpdate(msg) cmds = append(cmds, cmd) } cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } func (m model) View() tea.View { switch m.page { case PageChooseName: return m.ChooseNameView() case PageGameplay: return m.GameplayView() } return tea.NewView("") } func teaHandler(DB *db.Database) func(ssh.Session) (tea.Model, []tea.ProgramOption) { return func(s ssh.Session) (tea.Model, []tea.ProgramOption) { pty, _, _ := s.Pty() angler, _ := s.Context().Value("angler").(models.Angler) m := model{ term: pty.Term, width: pty.Window.Width, height: pty.Window.Height, bg: "light", DB: DB, page: PageChooseName, angler: angler, tabs: []string{"fishin'", "wares", "dock", "settings"}, tab: TabFishin, ti: textinput.New(), spinner: spinner.New(), } if angler.Username == gossh.FingerprintSHA256(s.PublicKey()) { m.page = PageChooseName } else { m.page = PageGameplay } m.spinner.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("30")) m.spinner.Spinner = spinner.Jump m.ti.SetVirtualCursor(false) m.ti.Focus() m.ti.CharLimit = 156 m.ti.SetWidth(20) return m, []tea.ProgramOption{} } }