From ab9573b87550e0676f1904f14d942e485554158a Mon Sep 17 00:00:00 2001 From: Jaron Date: Tue, 15 Sep 2026 09:15:53 -0500 Subject: [PATCH] init --- .dockerignore | 5 + .gitignore | 2 + Dockerfile | 18 + Dockerfile.dev | 8 + cmd/api/main.go | 530 ++++++++++++++++++ cmd/worker/main.go | 47 ++ compose.override.yml | 11 + compose.yml | 31 + go.mod | 100 ++++ go.sum | 269 +++++++++ internal/api/utils.go | 100 ++++ internal/db/db.go | 114 ++++ internal/handlers/handlers.go | 16 + internal/handlers/v1/auth.go | 122 ++++ internal/handlers/v1/gameplay.go | 108 ++++ internal/handlers/v1/v1.go | 59 ++ internal/migrations/20260601232400_init.sql | 14 + .../migrations/20260604202900_more_models.sql | 37 ++ .../20260912220000_add_ssh_keys.sql | 15 + internal/migrations/embed.go | 6 + internal/models/models.go | 52 ++ 21 files changed, 1664 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 Dockerfile.dev create mode 100644 cmd/api/main.go create mode 100644 cmd/worker/main.go create mode 100644 compose.override.yml create mode 100644 compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/utils.go create mode 100644 internal/db/db.go create mode 100644 internal/handlers/handlers.go create mode 100644 internal/handlers/v1/auth.go create mode 100644 internal/handlers/v1/gameplay.go create mode 100644 internal/handlers/v1/v1.go create mode 100644 internal/migrations/20260601232400_init.sql create mode 100644 internal/migrations/20260604202900_more_models.sql create mode 100644 internal/migrations/20260912220000_add_ssh_keys.sql create mode 100644 internal/migrations/embed.go create mode 100644 internal/models/models.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..22432aa --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +.env +*.log +tmp/ +dist/ diff --git a/.gitignore b/.gitignore index 2f0538d..2c0aed1 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,5 @@ go.work.sum .env* .ssh + +tmp diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4196dc0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM golang:1.26-alpine AS builder + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -o app ./cmd/api + +FROM alpine:3.22 + +WORKDIR /app + +COPY --from=builder /app/app ./app + +CMD ["./app"] diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..c866d88 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,8 @@ +FROM golang:1.26-alpine + +WORKDIR /app + +RUN go install github.com/air-verse/air@latest + +COPY go.mod go.sum ./ +RUN go mod download diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..9226640 --- /dev/null +++ b/cmd/api/main.go @@ -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{} + } +} diff --git a/cmd/worker/main.go b/cmd/worker/main.go new file mode 100644 index 0000000..361a792 --- /dev/null +++ b/cmd/worker/main.go @@ -0,0 +1,47 @@ +package main + +import ( + "context" + "net/http" + "os" + "time" + + "github.com/inngest/inngestgo" + + openrouter "github.com/OpenRouterTeam/go-sdk" + "github.com/OpenRouterTeam/go-sdk/models/components" +) + +func main() { + client, err := inngestgo.NewClient(inngestgo.ClientOpts{ + AppID: "worker", + }) + + if err != nil { + panic("Couldn't initialize inngest client") + } + + _, err = inngestgo.CreateFunction( + client, + inngestgo.FunctionOpts{ + ID: "create-new-fish", + }, + inngestgo.CronTriggerWithJitter("0 0 * * *", time.Duration(60)*time.Minute), + CreateNewFishCron, + ) + + http.ListenAndServe(":3000", client.Serve()) +} + +func CreateNewFish(ctx context.Context) { + s := openrouter.New( + openrouter.WithSecurity(os.Getenv("OPENROUTER_API_KEY")), + ) + + res, err := s.Chat.Send(ctx, components.ChatRequest{}) +} + +func CreateNewFishCron(ctx context.Context, input inngestgo.Input[any]) (any, error) { + CreateNewFish() + return nil, nil +} diff --git a/compose.override.yml b/compose.override.yml new file mode 100644 index 0000000..9ca0038 --- /dev/null +++ b/compose.override.yml @@ -0,0 +1,11 @@ +services: + server: + build: + context: . + dockerfile: Dockerfile.dev + volumes: + - .:/app + command: air --build.cmd "go build -o /tmp/api ./cmd/api" --build.entrypoint "/tmp/api" + ports: + - "9900:9900" + - "2222:22" diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..c61eace --- /dev/null +++ b/compose.yml @@ -0,0 +1,31 @@ +services: + server: + image: git.hat.fish/jaron/go-fishing:latest + restart: always + depends_on: + db: + condition: service_healthy + environment: + COOKIE_STORE_SECRET: ${COOKIE_STORE_SECRET} + DB_CONNECTION_STRING: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + ports: + - "22:22" + - "9900:9900" + db: + image: postgres:18-alpine + restart: always + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - postgres_data:/var/lib/postgresql + + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 10 + +volumes: + postgres_data: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0e2e6d9 --- /dev/null +++ b/go.mod @@ -0,0 +1,100 @@ +module jp.wtf/go-fishing + +go 1.26.8 + +require ( + charm.land/bubbles/v2 v2.2.1 + charm.land/bubbletea/v2 v2.0.9 + charm.land/lipgloss/v2 v2.0.6 + charm.land/ssh v0.4.3 + charm.land/wish/v2 v2.0.4 + github.com/OpenRouterTeam/go-sdk v0.7.23 + github.com/gin-contrib/sessions v1.1.0 + github.com/gin-gonic/gin v1.12.0 + github.com/google/uuid v1.6.0 + github.com/inngest/inngestgo v0.15.3 + github.com/jackc/pgx/v5 v5.9.2 + github.com/jmoiron/sqlx v1.4.0 + github.com/joho/godotenv v1.5.1 + github.com/pressly/goose/v3 v3.27.1 + golang.org/x/crypto v0.57.0 + golang.org/x/sync v0.23.0 +) + +require ( + charm.land/log/v2 v2.0.1 // indirect + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/keygen v0.5.4 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260906173415-0277a179edd9 // indirect + github.com/charmbracelet/x/ansi v0.11.8 // indirect + github.com/charmbracelet/x/conpty v0.2.0 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/charmbracelet/x/xpty v0.1.4 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/coder/websocket v1.8.14 // indirect + github.com/creack/pty v1.1.24 // indirect + github.com/fatih/structs v1.1.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-logfmt/logfmt v0.6.1 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/gorilla/context v1.1.2 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/gorilla/sessions v1.4.0 // indirect + github.com/gosimple/slug v1.12.0 // indirect + github.com/gosimple/unidecode v1.0.1 // indirect + github.com/gowebpki/jcs v1.0.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/inngest/inngest v1.19.3 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.4.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/lmittmann/tint v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.1 // indirect + github.com/mattn/go-isatty v0.0.21 // indirect + github.com/mattn/go-runewidth v0.0.30 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/oklog/ulid/v2 v2.1.1 // indirect + github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pires/go-proxyproto v0.15.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/spyzhov/ajson v0.8.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + github.com/xhit/go-str2duration/v2 v2.1.0 // indirect + github.com/xo/terminfo v1.0.0 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect + golang.org/x/net v0.59.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect + google.golang.org/grpc v1.80.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..92cbe0e --- /dev/null +++ b/go.sum @@ -0,0 +1,269 @@ +charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU= +charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc= +charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8= +charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss= +charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= +charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= +charm.land/log/v2 v2.0.1 h1:uMk+t1hM/fomIqt7vn/Ub0AKo7IHvnDu6NK17nbOVx0= +charm.land/log/v2 v2.0.1/go.mod h1:VXZNB8zoPPEIcvd7bRi2fpQpGCWweCSfWOwczA9dibs= +charm.land/ssh v0.4.3 h1:hr5cYmlUYsP+KxyG/ug7ZMzKKN9f6VOD9yPpcGqdzzM= +charm.land/ssh v0.4.3/go.mod h1:so/3IECPNlYZSnE7JKn7NFmcUyyxJqIAeM4TJy35qPk= +charm.land/wish/v2 v2.0.4 h1:GcXfPaag1GNp2FqL6A1/zR4s8ae6sPHBU0fxigE4PZI= +charm.land/wish/v2 v2.0.4/go.mod h1:l09dLMs+e7kLT70z7ZPvcsrOBuqiURHUAnkRSs4cvdU= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/OpenRouterTeam/go-sdk v0.7.23 h1:ZSosRLY1KzR6l4dgNdSDFYlW2gJaopHLLBZaw4Wo0wM= +github.com/OpenRouterTeam/go-sdk v0.7.23/go.mod h1:8ZRHxPEBG2Lu7BXRGbcoTdhRQnXGFZ+y0jKVpCAQR8c= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/keygen v0.5.4 h1:XQYgf6UEaTGgQSSmiPpIQ78WfseNQp4Pz8N/c1OsrdA= +github.com/charmbracelet/keygen v0.5.4/go.mod h1:t4oBRr41bvK7FaJsAaAQhhkUuHslzFXVjOBwA55CZNM= +github.com/charmbracelet/ultraviolet v0.0.0-20260906173415-0277a179edd9 h1:tYBWVoMfQHTwp88mWeWH7o0uJFZqRJeEGJmeHpIR8Ms= +github.com/charmbracelet/ultraviolet v0.0.0-20260906173415-0277a179edd9/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro= +github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= +github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= +github.com/charmbracelet/x/conpty v0.2.0 h1:eKtA2hm34qNfgJCDp/M6Dc0gLy7e07YEK4qAdNGOvVY= +github.com/charmbracelet/x/conpty v0.2.0/go.mod h1:fexgUnVrZgw8scD49f6VSi0Ggj9GWYIrpedRthAwW/8= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/charmbracelet/x/xpty v0.1.4 h1:4jaW7u+8AHQMxesiVc+zUMsspu7GyDwtJO+gy/tFtW4= +github.com/charmbracelet/x/xpty v0.1.4/go.mod h1:7t8P7BpPiolHJ1pLzz7/4ujDbD+sUxI9yA3CBOLOIcU= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sessions v1.1.0 h1:00mhHfNEGF5sP2fwxa98aRqj1FOJdL6IkR86n2hOiBo= +github.com/gin-contrib/sessions v1.1.0/go.mod h1:TyYZDIs6qCQg2SOoYPgMT9pAkmZceVNEJMcv5qbIy60= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= +github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= +github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= +github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= +github.com/gosimple/slug v1.12.0 h1:xzuhj7G7cGtd34NXnW/yF0l+AGNfWqwgh/IXgFy7dnc= +github.com/gosimple/slug v1.12.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= +github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o= +github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc= +github.com/gowebpki/jcs v1.0.0 h1:0pZtOgGetfH/L7yXb4KWcJqIyZNA43WXFyMd7ftZACw= +github.com/gowebpki/jcs v1.0.0/go.mod h1:CID1cNZ+sHp1CCpAR8mPf6QRtagFBgPJE0FCUQ6+BrI= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/inngest/inngest v1.19.3 h1:cLsOkwVhNldcsW3qdczEz89EQfVXtxn1A1R2xss7sVQ= +github.com/inngest/inngest v1.19.3/go.mod h1:QaKa4/z+QXLtFRIuRomRJUbjatZh0y1rUkow4h2cURQ= +github.com/inngest/inngestgo v0.15.3 h1:oB/ONIiT9O8in0rJk6/UbL4gcgRsY3CKLUreW3lEOOw= +github.com/inngest/inngestgo v0.15.3/go.mod h1:ddbrAwZKj4AdO6C2HsRbSI37E5rLjpMu32/heAX9NXg= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lmittmann/tint v1.1.0 h1:0hDmvuGv3U+Cep/jHpPxwjrCFjT6syam7iY7nTmA7ug= +github.com/lmittmann/tint v1.1.0/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= +github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= +github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk= +github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= +github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= +github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI= +github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4= +github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/sashabaranov/go-openai v1.35.6 h1:oi0rwCvyxMxgFALDGnyqFTyCJm6n72OnEG3sybIFR0g= +github.com/sashabaranov/go-openai v1.35.6/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/spyzhov/ajson v0.8.0 h1:sFXyMbi4Y/BKjrsfkUZHSjA2JM1184enheSjjoT/zCc= +github.com/spyzhov/ajson v0.8.0/go.mod h1:63V+CGM6f1Bu/p4nLIN8885ojBdt88TbLoSFzyqMuVA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= +github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= +github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY= +github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU= +golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0= +modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U= +modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= diff --git a/internal/api/utils.go b/internal/api/utils.go new file mode 100644 index 0000000..9be864b --- /dev/null +++ b/internal/api/utils.go @@ -0,0 +1,100 @@ +package api + +import ( + "fmt" + "net/http" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" + "jp.wtf/go-fishing/internal/models" +) + +type CreateAnglerRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +type Response struct { + Data any `json:"$data,omitempty"` + Meta *APIMetadata `json:"$meta,omitempty"` + Error *APIError `json:"$error,omitempty"` +} + +type APIMetadata struct { + Version string `json:"version,omitempty"` +} + +type APIErrorCode string + +const ( + APIErrorCodeUnknown APIErrorCode = "API_ERR_UNKNOWN" + APIErrorCodeUnauthorized APIErrorCode = "API_ERR_UNAUTHORIZED" + APIErrorCodeNotFound APIErrorCode = "API_ERR_NOT_FOUND" + APIErrorCodeInvalidBody APIErrorCode = "API_ERR_INVALID_BODY" + APIErrorCodeInvalidRequest APIErrorCode = "API_ERR_INVALID_REQUEST" +) + +type APIError struct { + Code APIErrorCode `json:"code"` + Message string `json:"message"` +} + +func SendJSONResponse(c *gin.Context, status int, response Response) { + c.JSON(status, response) +} + +func OK(c *gin.Context, data any) { + SendJSONResponse(c, http.StatusOK, Response{ + Data: data, + Meta: &APIMetadata{ + Version: c.GetString("APIVersion"), + }, + }) +} + +func Fail(c *gin.Context, status int, code APIErrorCode, message string) { + SendJSONResponse(c, status, Response{ + Error: &APIError{ + Code: code, + Message: message, + }, + }) +} + +func FailAndAbort(c *gin.Context, status int, code APIErrorCode, message string) { + Fail(c, status, code, message) + c.Abort() +} + +func CheckIfLoggedIn(c *gin.Context) bool { + s := sessions.Default(c) + val := s.Get("angler_id") + return val != nil && val != "" +} + +func GetAnglerFromSession(c *gin.Context) (models.Angler, error) { + val, exists := c.Get("angler") + + if !exists { + return models.Angler{}, fmt.Errorf("No angler") + } + + angler, ok := val.(models.Angler) + + if !ok { + return models.Angler{}, fmt.Errorf("Malformed or invalid angler") + } + + return angler, nil +} + +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(bytes), err +} + +func CheckPassword(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..04c19f8 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,114 @@ +package db + +import ( + "fmt" + "time" + + "charm.land/ssh" + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + "golang.org/x/crypto/bcrypt" + gossh "golang.org/x/crypto/ssh" + "jp.wtf/go-fishing/internal/models" +) + +type Database struct { + Conn *sqlx.DB +} + +func (db *Database) GetAnglerByID(angler_id uuid.UUID) (models.Angler, error) { + var angler models.Angler + err := db.Conn.Get(&angler, "SELECT angler_id, angler_username, angler_email FROM anglers WHERE angler_id = $1", angler_id) + + return angler, err +} + +func (db *Database) GetAnglerByKey(ssh_key ssh.PublicKey) (models.Angler, error) { + var angler models.Angler + err := db.Conn.Get(&angler, "SELECT angler_id, angler_username, angler_email FROM ssh_keys INNER JOIN anglers USING (angler_id) WHERE key_data = $1", ssh_key.Marshal()) + + return angler, err +} + +func (db *Database) GetRawAnglerByUsername(username string) (models.AnglerDB, error) { + var angler models.AnglerDB + err := db.Conn.Get(&angler, "SELECT angler_id, angler_username, angler_email, angler_password FROM anglers WHERE angler_username = $1", username) + + return angler, err +} + +func (db *Database) CreateNewAngler(username string, hashed_password string) (models.Angler, error) { + var new_angler models.Angler + err := db.Conn.Get(&new_angler, ` + INSERT INTO public.anglers (angler_username, angler_password) + VALUES ($1, $2) + RETURNING angler_id, angler_username, angler_email + `, username, hashed_password) + + return new_angler, err +} + +func (db *Database) CreateNewAnglerFromKey(ssh_key ssh.PublicKey) (models.Angler, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(uuid.New().String()), bcrypt.DefaultCost) + + if err != nil { + return models.Angler{}, err + } + + fingerprint := gossh.FingerprintSHA256(ssh_key) + + angler, err := db.CreateNewAngler(fingerprint, string(bytes)) + if err != nil { + return models.Angler{}, err + } + + if _, err := db.AttachKeyToAngler(angler.ID, ssh_key); err != nil { + return models.Angler{}, err + } + + return angler, err +} + +func (db *Database) AttachKeyToAngler(angler_id uuid.UUID, ssh_key ssh.PublicKey) (models.SSHKey, error) { + var new_key models.SSHKey + err := db.Conn.Get(&new_key, ` + INSERT INTO public.ssh_keys (key_type, key_data, key_fingerprint, angler_id) + VALUES ($1, $2, $3, $4) + RETURNING key_id, key_type, key_data, key_fingerprint + `, ssh_key.Type(), ssh_key.Marshal(), gossh.FingerprintSHA256(ssh_key), angler_id) + + return new_key, err +} + +func (db *Database) IsCasting(angler_id uuid.UUID) (bool, error) { + var exists bool + err := db.Conn.Get(&exists, "SELECT EXISTS( SELECT 1 FROM public.casts WHERE angler_id = $1 AND cast_ended_at IS NULL )", angler_id) + + if err != nil { + fmt.Print(err.Error()) + return false, err + } + + return exists, err +} + +func (db *Database) GetMostRecentCast(angler_id uuid.UUID) (models.Cast, error) { + var cast models.Cast + err := db.Conn.Get(&cast, "SELECT cast_id, cast_started_at, cast_ended_at, catch_id FROM public.casts WHERE angler_id =! ORDER cast_started_at DESC LIMIT 1", angler_id) + + return cast, err +} + +func (db *Database) CreateNewCast(angler_id uuid.UUID) (models.Cast, error) { + var cast models.Cast + err := db.Conn.Get(&cast, "INSERT INTO public.casts (angler_id) VALUES ($1) RETURNING *", angler_id) + + return cast, err +} + +func (db *Database) EndCastByID(cast_id uuid.UUID) (models.Cast, error) { + var cast models.Cast + err := db.Conn.Get(&cast, "UPDATE public.casts SET cast_ended_at = $1 WHERE cast_id = $2 RETURNING *", time.Now(), cast_id) + + return cast, err +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go new file mode 100644 index 0000000..c2c16f6 --- /dev/null +++ b/internal/handlers/handlers.go @@ -0,0 +1,16 @@ +package handlers + +import ( + "github.com/gin-gonic/gin" + "jp.wtf/go-fishing/internal/db" + v1 "jp.wtf/go-fishing/internal/handlers/v1" +) + +type Env struct { + DB *db.Database +} + +func (env *Env) RegisterRoutes(rg *gin.RouterGroup) { + v1Router := v1.V1Router{DB: env.DB} + v1Router.RegisterRoutes(rg) +} diff --git a/internal/handlers/v1/auth.go b/internal/handlers/v1/auth.go new file mode 100644 index 0000000..d672ab3 --- /dev/null +++ b/internal/handlers/v1/auth.go @@ -0,0 +1,122 @@ +package v1 + +import ( + "database/sql" + "errors" + "net/http" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "jp.wtf/go-fishing/internal/api" + "jp.wtf/go-fishing/internal/models" +) + +func (h *V1Router) Login(c *gin.Context) { + if api.CheckIfLoggedIn(c) { + api.Fail(c, http.StatusBadRequest, api.APIErrorCodeInvalidRequest, "You are already logged in!") + return + } + + var json struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := c.ShouldBind(&json); err != nil { + api.Fail(c, http.StatusBadRequest, api.APIErrorCodeInvalidBody, err.Error()) + return + } + + angler, err := h.DB.GetRawAnglerByUsername(json.Username) + + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + api.Fail(c, http.StatusUnauthorized, api.APIErrorCodeUnauthorized, "Invalid username or password") + return + } + + api.Fail(c, http.StatusInternalServerError, api.APIErrorCodeUnknown, err.Error()) + return + } + + if valid := api.CheckPassword(json.Password, angler.PasswordHash); valid { + s := sessions.Default(c) + s.Set("angler_id", angler.ID) + err := s.Save() + + if err != nil { + api.Fail(c, http.StatusInternalServerError, api.APIErrorCodeUnknown, "Failed to create new session") + return + } + + api.OK(c, models.Angler{ + ID: angler.ID, + Username: angler.Username, + }) + return + } + + api.Fail(c, http.StatusUnauthorized, api.APIErrorCodeUnauthorized, "Invalid username or password") +} + +func (h *V1Router) Signup(c *gin.Context) { + if api.CheckIfLoggedIn(c) { + api.Fail(c, http.StatusBadRequest, api.APIErrorCodeInvalidRequest, "You are already logged in!") + return + } + + var json api.CreateAnglerRequest + if err := c.ShouldBind(&json); err != nil { + api.Fail(c, http.StatusBadRequest, api.APIErrorCodeInvalidBody, err.Error()) + return + } + + hashed_password, hash_err := api.HashPassword(json.Password) + + if hash_err != nil { + api.Fail(c, http.StatusInternalServerError, api.APIErrorCodeUnknown, hash_err.Error()) + return + } + + new_angler, create_err := h.DB.CreateNewAngler(json.Username, hashed_password) + + if create_err != nil { + api.Fail(c, http.StatusInternalServerError, api.APIErrorCodeUnknown, create_err.Error()) + return + } + + s := sessions.Default(c) + s.Set("angler_id", new_angler.ID) + + if err := s.Save(); err != nil { + api.Fail(c, http.StatusInternalServerError, api.APIErrorCodeUnknown, "Failed to create new session") + return + } + + api.OK(c, models.Angler{ + ID: new_angler.ID, + Username: new_angler.Username, + }) +} + +func (h *V1Router) Logout(c *gin.Context) { + if !api.CheckIfLoggedIn(c) { + api.Fail(c, http.StatusBadRequest, api.APIErrorCodeInvalidRequest, "You are not logged in...thus you cannot logout!") + return + } + + s := sessions.Default(c) + s.Clear() + s.Save() + api.OK(c, nil) +} + +func (h *V1Router) GetMe(c *gin.Context) { + angler, err := api.GetAnglerFromSession(c) + + if err != nil { + api.Fail(c, http.StatusInternalServerError, api.APIErrorCodeUnknown, err.Error()) + return + } + + api.OK(c, angler) +} diff --git a/internal/handlers/v1/gameplay.go b/internal/handlers/v1/gameplay.go new file mode 100644 index 0000000..db71772 --- /dev/null +++ b/internal/handlers/v1/gameplay.go @@ -0,0 +1,108 @@ +package v1 + +import ( + "math/rand/v2" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "jp.wtf/go-fishing/internal/api" +) + +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 (h *V1Router) Cast(c *gin.Context) { + angler, err := api.GetAnglerFromSession(c) + + if err != nil { + api.Fail(c, http.StatusInternalServerError, api.APIErrorCodeUnknown, err.Error()) + return + } + + is_casting, check_err := h.DB.IsCasting(angler.ID) + + if check_err != nil { + api.Fail(c, http.StatusInternalServerError, api.APIErrorCodeUnknown, "Unable to check for existing cast") + return + } + + if is_casting { + api.Fail(c, http.StatusBadRequest, api.APIErrorCodeInvalidRequest, "You are already casting! >:|") + return + } + + cast, cast_create_err := h.DB.CreateNewCast(angler.ID) + + if cast_create_err != nil { + api.Fail(c, http.StatusInternalServerError, api.APIErrorCodeUnknown, "Unable to start your cast") + return + } + + // Fishing can take anywhere from 2-5 seconds + minTime, maxTime := 2, 5 + timeToWait := rand.IntN(maxTime-minTime+1) + minTime + + select { + case <-time.After(time.Duration(timeToWait) * time.Second): + // One we complete fishing we must decide if we caught anything + chance := 75 + gen := rand.IntN(100) + caught := gen < chance + + _, end_cast_err := h.DB.EndCastByID(cast.ID) + + if end_cast_err != nil { + api.Fail(c, http.StatusInternalServerError, api.APIErrorCodeUnknown, "Unable to update your cast — may have left you in a bad state :(") + return + } + + if caught { + // If so then we must select a fish. + // Then we must determine a size between the min and max of said fish + // Finally record the catch and return it + api.OK(c, "Got one! Nice!") + } else { + // Otherwise return. You are left empty handed + api.Fail(c, http.StatusNotFound, api.APIErrorCodeNotFound, sayings[rand.IntN(len(sayings))]) + } + case <-c.Done(): + _, end_cast_err := h.DB.EndCastByID(cast.ID) + + if end_cast_err != nil { + api.Fail(c, http.StatusInternalServerError, api.APIErrorCodeUnknown, "Unable to update your cast — may have left you in a bad state :(") + return + } + + api.Fail(c, http.StatusRequestTimeout, api.APIErrorCodeUnknown, "Cast terminiated for unknown reason") + } +} diff --git a/internal/handlers/v1/v1.go b/internal/handlers/v1/v1.go new file mode 100644 index 0000000..e939ab2 --- /dev/null +++ b/internal/handlers/v1/v1.go @@ -0,0 +1,59 @@ +package v1 + +import ( + "log" + "net/http" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "jp.wtf/go-fishing/internal/api" + "jp.wtf/go-fishing/internal/db" +) + +type V1Router struct { + DB *db.Database +} + +func (h *V1Router) AnglerAuthMiddleware(c *gin.Context) { + s := sessions.Default(c) + val := s.Get("angler_id") + + if val == nil || val == "" { + api.FailAndAbort(c, http.StatusUnauthorized, api.APIErrorCodeUnauthorized, "You are not logged in!") + return + } + + valStr, ok := val.(string) + angler_id, uuid_err := uuid.Parse(valStr) + + if uuid_err != nil || !ok { + s.Clear() + s.Save() + api.FailAndAbort(c, http.StatusInternalServerError, api.APIErrorCodeUnknown, "Session data malformed") + return + } + + angler, err := h.DB.GetAnglerByID(angler_id) + + if err != nil { + s.Clear() + s.Save() + log.Print("Failed to find angler: ", err.Error()) + api.FailAndAbort(c, http.StatusUnauthorized, api.APIErrorCodeUnauthorized, "Bad or expired session") + return + } + + c.Set("angler", angler) + s.Save() +} + +func (h *V1Router) RegisterRoutes(rg *gin.RouterGroup) { + auth := rg.Group("/auth") + auth.POST("/login", h.Login) + auth.POST("/signup", h.Signup) + auth.POST("/logout", h.Logout) + auth.GET("/me", h.GetMe) + + rg.GET("/cast", h.AnglerAuthMiddleware, h.Cast) +} diff --git a/internal/migrations/20260601232400_init.sql b/internal/migrations/20260601232400_init.sql new file mode 100644 index 0000000..2693caf --- /dev/null +++ b/internal/migrations/20260601232400_init.sql @@ -0,0 +1,14 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE public.anglers ( + angler_id UUID NOT NULL DEFAULT gen_random_uuid(), + angler_username TEXT NOT NULL UNIQUE, + angler_password TEXT NOT NULL, + angler_email TEXT +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE public.anglers; +-- +goose StatementEnd diff --git a/internal/migrations/20260604202900_more_models.sql b/internal/migrations/20260604202900_more_models.sql new file mode 100644 index 0000000..a523268 --- /dev/null +++ b/internal/migrations/20260604202900_more_models.sql @@ -0,0 +1,37 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE public.anglers ADD PRIMARY KEY (angler_id); + +CREATE TABLE public.fish ( + fish_id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + fish_name TEXT NOT NULL, + fish_desc TEXT NOT NULL, + fish_size_oz_max NUMERIC NOT NULL, + fish_size_oz_min NUMERIC NOT NULL +); + +CREATE TABLE public.catches ( + catch_id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + catch_caught_at TIMESTAMPTZ NOT NULL, + catch_size_oz NUMERIC NOT NULL, + angler_id UUID NOT NULL REFERENCES public.anglers(angler_id), + fish_id UUID NOT NULL REFERENCES public.fish(fish_id) +); + +CREATE TABLE public.casts ( + cast_id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + cast_started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + cast_ended_at TIMESTAMPTZ, + angler_id UUID NOT NULL REFERENCES public.anglers(angler_id), + catch_id UUID REFERENCES public.catches(catch_id) +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE public.anglers DROP CONSTRAINT anglers_pkey + +DROP TABLE public.fish; +DROP TABLE public.catches; +DROP TABLE public.casts; +-- +goose StatementEnd diff --git a/internal/migrations/20260912220000_add_ssh_keys.sql b/internal/migrations/20260912220000_add_ssh_keys.sql new file mode 100644 index 0000000..83b3bbc --- /dev/null +++ b/internal/migrations/20260912220000_add_ssh_keys.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE public.ssh_keys ( + key_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + angler_id UUID NOT NULL REFERENCES public.anglers(angler_id), + key_type TEXT NOT NULL, + key_data BYTEA NOT NULL UNIQUE, + key_fingerprint TEXT +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE public.ssh_keys; +-- +goose StatementEnd diff --git a/internal/migrations/embed.go b/internal/migrations/embed.go new file mode 100644 index 0000000..381665a --- /dev/null +++ b/internal/migrations/embed.go @@ -0,0 +1,6 @@ +package migrations + +import "embed" + +//go:embed *.sql +var Files embed.FS diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..09027a5 --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,52 @@ +package models + +import ( + "database/sql" + "time" + + "github.com/google/uuid" + + _ "github.com/jackc/pgx/v5/stdlib" +) + +type Angler struct { + ID uuid.UUID `db:"angler_id" json:"id"` + Username string `db:"angler_username" json:"name"` + Email *string `db:"angler_email" json:"email"` +} + +type SSHKey struct { + ID uuid.UUID `db:"key_id" json:"id"` + SSH_Type string `db:"key_type" json:"ssh_type"` + Date []byte `db:"key_data" json:"data"` + Fingerprint string `db:"key_fingerprint" json:"fingerprint"` +} + +type AnglerDB struct { + Angler + PasswordHash string `db:"angler_password"` +} + +type Fish struct { + ID uuid.UUID `db:"fish_id" json:"id"` + Name string `db:"fish_name" json:"name"` + Description string `db:"fish_desc" json:"desc"` + SizeOZMax uint16 `db:"fish_size_oz_max" json:"size_oz_max"` + SizeOZMin uint16 `db:"fish_size_oz_maxin" json:"size_oz_min"` +} + +type Catch struct { + ID uuid.UUID `db:"catch_id" json:"id"` + At time.Time `db:"catch_caught_at" json:"caught_at"` + AnglerID uuid.UUID `db:"angler_id" json:"angler_id"` + FishID uuid.UUID `db:"fish_id" json:"fish_id"` + SizeOZ uint16 `db:"catch_size_oz" json:"size_oz"` +} + +type Cast struct { + ID uuid.UUID `db:"cast_id" json:"id"` + Start time.Time `db:"cast_started_at" json:"start"` + End sql.NullTime `db:"cast_ended_at" json:"end"` + AnglerID uuid.UUID `db:"angler_id" json:"angler_id"` + CatchID uuid.NullUUID `db:"catch_id" json:"catch_id"` +}