Files
go-fishing/cmd/api/main.go
T
2026-09-15 09:15:53 -05:00

531 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
DB *db.Database
page page
tabs []string
tab tab
term string
profile string
width int
height int
bg string
ti textinput.Model
spinner spinner.Model
angler models.Angler
}
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 {
input := lipgloss.JoinVertical(lipgloss.Top, "Got a name?", m.ti.View())
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:
switch msg.String() {
case "enter":
err := m.DB.Conn.Get(
&m.angler,
"UPDATE anglers SET angler_username = $1 WHERE angler_id = $2 RETURNING angler_id, angler_username, angler_email",
m.ti.Value(), 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().Background(lipgloss.Color("30")).Padding(0, 3).Align(lipgloss.Center)
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(fmt.Sprintf("Ay matie %s", m.angler.Username))
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
}
var DefaultGameplayKeyMap = GameplayKeyMap{
CycleTab: key.NewBinding(
key.WithKeys("tab"),
key.WithHelp("shift+tab", "Cycle Tab Right"),
),
AltCycleTab: key.NewBinding(
key.WithKeys("shift+tab"),
key.WithHelp("shift+tab", "Cycle Tab Left"),
),
}
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
}
}
}
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{
DB: DB,
tabs: []string{"fishin'", "wares", "dock", "settings"},
tab: TabFishin,
page: PageChooseName,
term: pty.Term,
width: pty.Window.Width,
height: pty.Window.Height,
bg: "light",
ti: textinput.New(),
spinner: spinner.New(),
angler: angler,
}
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{}
}
}