init
This commit is contained in:
+530
@@ -0,0 +1,530 @@
|
||||
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{}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user