531 lines
11 KiB
Go
531 lines
11 KiB
Go
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{}
|
||
}
|
||
}
|