This commit is contained in:
2026-09-15 09:15:53 -05:00
parent 97c930196a
commit ab9573b875
21 changed files with 1664 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
.git
.env
*.log
tmp/
dist/
+2
View File
@@ -29,3 +29,5 @@ go.work.sum
.env*
.ssh
tmp
+18
View File
@@ -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"]
+8
View File
@@ -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
+530
View File
@@ -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{}
}
}
+47
View File
@@ -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
}
+11
View File
@@ -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"
+31
View File
@@ -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:
+100
View File
@@ -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
)
+269
View File
@@ -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=
+100
View File
@@ -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
}
+114
View File
@@ -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
}
+16
View File
@@ -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)
}
+122
View File
@@ -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)
}
+108
View File
@@ -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")
}
}
+59
View File
@@ -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)
}
@@ -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
@@ -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
@@ -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
+6
View File
@@ -0,0 +1,6 @@
package migrations
import "embed"
//go:embed *.sql
var Files embed.FS
+52
View File
@@ -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"`
}