From 1f0d9ec8452f15c27cd33c4e3874454c35993743 Mon Sep 17 00:00:00 2001 From: Santo Cariotti Date: Tue, 8 Apr 2025 14:37:33 +0200 Subject: Use internal/pkg structure --- api/auth/auth.go | 57 ---- api/auth/auth_test.go | 74 ----- api/database/database.go | 32 -- api/database/models.go | 24 -- api/handlers/handlers.go | 200 ------------ api/middleware/middleware.go | 36 --- cmd/api/main.go | 6 +- cmd/relay/main.go | 36 --- cmd/ui/main.go | 2 +- go.mod | 23 -- go.sum | 135 -------- internal/api/auth/auth.go | 57 ++++ internal/api/auth/auth_test.go | 74 +++++ internal/api/database/database.go | 32 ++ internal/api/database/models.go | 24 ++ internal/api/handlers/handlers.go | 197 ++++++++++++ internal/api/handlers/utils.go | 34 ++ internal/api/middleware/middleware.go | 36 +++ internal/network/ip.go | 33 ++ internal/network/network.go | 213 ++++++++++++ internal/network/network_test.go | 52 +++ internal/network/session.go | 23 ++ network/ip.go | 33 -- network/network.go | 213 ------------ network/network_test.go | 52 --- network/session.go | 23 -- pkg/ui/views/auth.go | 590 ++++++++++++++++++++++++++++++++++ pkg/ui/views/play.go | 427 ++++++++++++++++++++++++ pkg/ui/views/tabs.go | 32 ++ pkg/ui/views/views.go | 144 +++++++++ pkg/utils.go | 35 -- ui/.rahannarc | 1 - ui/views/auth.go | 590 ---------------------------------- ui/views/play.go | 427 ------------------------ ui/views/tabs.go | 32 -- ui/views/views.go | 144 --------- 36 files changed, 1972 insertions(+), 2171 deletions(-) delete mode 100644 api/auth/auth.go delete mode 100644 api/auth/auth_test.go delete mode 100644 api/database/database.go delete mode 100644 api/database/models.go delete mode 100644 api/handlers/handlers.go delete mode 100644 api/middleware/middleware.go delete mode 100644 cmd/relay/main.go create mode 100644 internal/api/auth/auth.go create mode 100644 internal/api/auth/auth_test.go create mode 100644 internal/api/database/database.go create mode 100644 internal/api/database/models.go create mode 100644 internal/api/handlers/handlers.go create mode 100644 internal/api/handlers/utils.go create mode 100644 internal/api/middleware/middleware.go create mode 100644 internal/network/ip.go create mode 100644 internal/network/network.go create mode 100644 internal/network/network_test.go create mode 100644 internal/network/session.go delete mode 100644 network/ip.go delete mode 100644 network/network.go delete mode 100644 network/network_test.go delete mode 100644 network/session.go create mode 100644 pkg/ui/views/auth.go create mode 100644 pkg/ui/views/play.go create mode 100644 pkg/ui/views/tabs.go create mode 100644 pkg/ui/views/views.go delete mode 100644 pkg/utils.go delete mode 100644 ui/.rahannarc delete mode 100644 ui/views/auth.go delete mode 100644 ui/views/play.go delete mode 100644 ui/views/tabs.go delete mode 100644 ui/views/views.go diff --git a/api/auth/auth.go b/api/auth/auth.go deleted file mode 100644 index b382beb..0000000 --- a/api/auth/auth.go +++ /dev/null @@ -1,57 +0,0 @@ -package auth - -import ( - "errors" - "os" - "strings" - "time" - - "github.com/golang-jwt/jwt/v5" -) - -var jwtKey = []byte(os.Getenv("JWT_SECRET")) - -type Claims struct { - UserID int `json:"user_id"` - jwt.RegisteredClaims -} - -func GenerateJWT(userID int) (string, error) { - expirationTime := time.Now().Add(5 * time.Hour) - claims := &Claims{ - UserID: userID, - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(expirationTime), - }, - } - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - tokenString, err := token.SignedString(jwtKey) - if err != nil { - return "", err - } - return tokenString, nil -} - -func ValidateJWT(tokenString string) (*Claims, error) { - claims := &Claims{} - // A token has a form `Bearer ...` - tokenParts := strings.Split(tokenString, " ") - if len(tokenParts) != 2 { - return nil, errors.New("not valid JWT") - } - - token, err := jwt.ParseWithClaims(tokenParts[1], claims, func(token *jwt.Token) (interface{}, error) { - return jwtKey, nil - }) - - if err != nil { - return nil, err - } - - if !token.Valid { - return nil, err - } - - return claims, nil -} diff --git a/api/auth/auth_test.go b/api/auth/auth_test.go deleted file mode 100644 index 50b6c9b..0000000 --- a/api/auth/auth_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package auth - -import ( - "os" - "testing" - "time" - - "github.com/golang-jwt/jwt/v5" - "github.com/stretchr/testify/assert" -) - -func TestGenerateAndValidateJWT(t *testing.T) { - // Set up the JWT secret for the test. - os.Setenv("JWT_SECRET", "testsecret") - jwtKey = []byte(os.Getenv("JWT_SECRET")) - - userID := 123 - tokenString, err := GenerateJWT(userID) - assert.NoError(t, err) - assert.NotEmpty(t, tokenString) - - claims, err := ValidateJWT(tokenString) - assert.NoError(t, err) - assert.NotNil(t, claims) - assert.Equal(t, userID, claims.UserID) - assert.True(t, claims.ExpiresAt.After(time.Now())) -} - -func TestValidateJWT_InvalidToken(t *testing.T) { - os.Setenv("JWT_SECRET", "testsecret") - jwtKey = []byte(os.Getenv("JWT_SECRET")) - - _, err := ValidateJWT("invalid_token") - assert.Error(t, err) -} - -func TestValidateJWT_ExpiredToken(t *testing.T) { - os.Setenv("JWT_SECRET", "testsecret") - jwtKey = []byte(os.Getenv("JWT_SECRET")) - - // Create a token that has already expired. - claims := &Claims{ - UserID: 123, - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)), - }, - } - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - tokenString, err := token.SignedString(jwtKey) - assert.NoError(t, err) - - _, err = ValidateJWT(tokenString) - assert.Error(t, err) -} - -func TestValidateJWT_WrongSecret(t *testing.T) { - os.Setenv("JWT_SECRET", "testsecret") - jwtKey = []byte(os.Getenv("JWT_SECRET")) - - userID := 123 - tokenString, err := GenerateJWT(userID) - assert.NoError(t, err) - - // Set a different secret for validation. - wrongKey := []byte("wrongsecret") - - claims := &Claims{} - _, err = jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { - return wrongKey, nil - }) - - assert.Error(t, err) -} diff --git a/api/database/database.go b/api/database/database.go deleted file mode 100644 index 4470c58..0000000 --- a/api/database/database.go +++ /dev/null @@ -1,32 +0,0 @@ -package database - -import ( - "gorm.io/driver/postgres" - "gorm.io/gorm" - - "errors" -) - -// Global variable but private -var db *gorm.DB = nil - -// Init the database from a DSN string which must be a valid PostgreSQL dsn. -// Also, auto migrate all the models. -func InitDb(dsn string) (*gorm.DB, error) { - var err error - db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{}) - - if err == nil { - db.AutoMigrate(&User{}, &Game{}) - } - - return db, err -} - -// Return the instance or error if the config is not laoded yet -func GetDb() (*gorm.DB, error) { - if db == nil { - return nil, errors.New("You must call `InitDb()` first.") - } - return db, nil -} diff --git a/api/database/models.go b/api/database/models.go deleted file mode 100644 index a6e76c5..0000000 --- a/api/database/models.go +++ /dev/null @@ -1,24 +0,0 @@ -package database - -import "time" - -type User struct { - ID int `json:"id"` - Username string `json:"username"` - Password string `json:"password"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type Game struct { - ID int `json:"id"` - Player1ID int `json:"-"` - Player1 User `gorm:"foreignKey:Player1ID" json:"player1"` - Player2ID *int `json:"-"` - Player2 *User `gorm:"foreignKey:Player2ID;null" json:"player2"` - Name string `json:"name"` - IP1 string `json:"ip1"` - IP2 string `json:"ip2"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} diff --git a/api/handlers/handlers.go b/api/handlers/handlers.go deleted file mode 100644 index 601b770..0000000 --- a/api/handlers/handlers.go +++ /dev/null @@ -1,200 +0,0 @@ -package handlers - -import ( - "encoding/json" - "log/slog" - "net/http" - "time" - - "github.com/boozec/rahanna/api/auth" - "github.com/boozec/rahanna/api/database" - "github.com/boozec/rahanna/network" - utils "github.com/boozec/rahanna/pkg" - "golang.org/x/crypto/bcrypt" - "gorm.io/gorm" -) - -type NewGameRequest struct { - IP string `json:"ip"` -} - -func RegisterUser(w http.ResponseWriter, r *http.Request) { - slog.Info("POST /auth/register") - var user database.User - err := json.NewDecoder(r.Body).Decode(&user) - if err != nil { - utils.JsonError(&w, err.Error()) - return - } - - if len(user.Password) < 4 { - utils.JsonError(&w, "password too short") - return - } - - var storedUser database.User - db, _ := database.GetDb() - result := db.Where("username = ?", user.Username).First(&storedUser) - - if result.Error == nil { - utils.JsonError(&w, "user with this username already exists") - return - } - - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost) - if err != nil { - utils.JsonError(&w, err.Error()) - return - } - user.Password = string(hashedPassword) - - result = db.Create(&user) - if result.Error != nil { - utils.JsonError(&w, result.Error.Error()) - return - } - - token, err := auth.GenerateJWT(user.ID) - if err != nil { - utils.JsonError(&w, err.Error()) - return - } - - json.NewEncoder(w).Encode(map[string]string{"token": token}) -} - -func LoginUser(w http.ResponseWriter, r *http.Request) { - slog.Info("POST /auth/login") - var inputUser database.User - err := json.NewDecoder(r.Body).Decode(&inputUser) - if err != nil { - utils.JsonError(&w, err.Error()) - return - } - - var storedUser database.User - - db, _ := database.GetDb() - result := db.Where("username = ?", inputUser.Username).First(&storedUser) - if result.Error != nil { - utils.JsonError(&w, "invalid credentials") - return - } - - err = bcrypt.CompareHashAndPassword([]byte(storedUser.Password), []byte(inputUser.Password)) - if err != nil { - utils.JsonError(&w, "invalid credentials") - return - } - - token, err := auth.GenerateJWT(storedUser.ID) - if err != nil { - utils.JsonError(&w, err.Error()) - return - } - - json.NewEncoder(w).Encode(map[string]string{"token": token}) -} - -func NewPlay(w http.ResponseWriter, r *http.Request) { - slog.Info("POST /play") - claims, err := auth.ValidateJWT(r.Header.Get("Authorization")) - - if err != nil { - utils.JsonError(&w, err.Error()) - return - } - - var payload struct { - IP string `json:"ip"` - } - - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - utils.JsonError(&w, err.Error()) - return - } - - if err != nil { - utils.JsonError(&w, err.Error()) - return - } - - db, _ := database.GetDb() - - name := network.NewSession() - play := database.Game{ - Player1ID: claims.UserID, - Player2ID: nil, - Name: name, - IP1: payload.IP, - IP2: "", - } - - result := db.Create(&play) - if result.Error != nil { - utils.JsonError(&w, result.Error.Error()) - return - } - - json.NewEncoder(w).Encode(map[string]string{"name": name}) -} - -func EnterGame(w http.ResponseWriter, r *http.Request) { - slog.Info("POST /enter-game") - claims, err := auth.ValidateJWT(r.Header.Get("Authorization")) - - if err != nil { - utils.JsonError(&w, err.Error()) - return - } - - var payload struct { - Name string `json:"name"` - IP string `json:"ip"` - } - - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - utils.JsonError(&w, err.Error()) - return - } - - if err != nil { - utils.JsonError(&w, err.Error()) - return - } - - db, _ := database.GetDb() - - var play database.Game - - result := db.Where("name = ? AND player2_id IS NULL", payload.Name).First(&play) - if result.Error != nil { - utils.JsonError(&w, result.Error.Error()) - return - } - - play.Player2ID = &claims.UserID - play.IP2 = payload.IP - play.UpdatedAt = time.Now() - - if err := db.Save(&play).Error; err != nil { - utils.JsonError(&w, err.Error()) - return - } - - result = db.Where("id = ?", play.ID). - Preload("Player1", func(db *gorm.DB) *gorm.DB { - return db.Omit("Password") - }). - Preload("Player2", func(db *gorm.DB) *gorm.DB { - return db.Omit("Password") - }). - First(&play) - - if result.Error != nil { - utils.JsonError(&w, result.Error.Error()) - return - } - - json.NewEncoder(w).Encode(play) -} diff --git a/api/middleware/middleware.go b/api/middleware/middleware.go deleted file mode 100644 index 29ed8b6..0000000 --- a/api/middleware/middleware.go +++ /dev/null @@ -1,36 +0,0 @@ -package middleware - -import ( - "encoding/json" - "net/http" - - "github.com/boozec/rahanna/api/auth" -) - -func AuthMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - tokenString := r.Header.Get("Authorization") - - payloadMap := map[string]string{"error": "unauthorized"} - payload, _ := json.Marshal(payloadMap) - - if tokenString == "" { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusUnauthorized) - - w.Write([]byte(payload)) - return - } - - _, err := auth.ValidateJWT(tokenString) - if err != nil { - w.WriteHeader(http.StatusUnauthorized) - - payload, _ := json.Marshal(payloadMap) - - w.Write([]byte(payload)) - return - } - next.ServeHTTP(w, r) - }) -} diff --git a/cmd/api/main.go b/cmd/api/main.go index 96f7a2f..633f339 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -5,9 +5,9 @@ import ( "net/http" "os" - "github.com/boozec/rahanna/api/database" - "github.com/boozec/rahanna/api/handlers" - "github.com/boozec/rahanna/api/middleware" + "github.com/boozec/rahanna/internal/api/database" + "github.com/boozec/rahanna/internal/api/handlers" + "github.com/boozec/rahanna/internal/api/middleware" "github.com/gorilla/mux" "github.com/rs/cors" ) diff --git a/cmd/relay/main.go b/cmd/relay/main.go deleted file mode 100644 index 150da8a..0000000 --- a/cmd/relay/main.go +++ /dev/null @@ -1,36 +0,0 @@ -package main - -import ( - "net" - "os" - - "github.com/boozec/rahanna/relay" - pb "github.com/boozec/rahanna/relay/proto" - "go.uber.org/zap" - "google.golang.org/grpc" - "google.golang.org/grpc/reflection" -) - -func main() { - logger, _ := zap.NewProduction() - defer logger.Sync() - lis, err := net.Listen("tcp", ":50051") - - if err != nil { - logger.Sugar().Errorln("Failed to listen", "err", err) - os.Exit(1) - } - - s := grpc.NewServer() - server := &relay.Server{} - pb.RegisterRelayServer(s, server) - - reflection.Register(s) - - logger.Sugar().Infoln("Server listening", "address", lis.Addr()) - - if err := s.Serve(lis); err != nil { - logger.Sugar().Errorln("Failed to serve", "err", err) - os.Exit(1) - } -} diff --git a/cmd/ui/main.go b/cmd/ui/main.go index 4bdcce9..0b8d4bb 100644 --- a/cmd/ui/main.go +++ b/cmd/ui/main.go @@ -3,7 +3,7 @@ package main import ( "log" - "github.com/boozec/rahanna/ui/views" + "github.com/boozec/rahanna/pkg/ui/views" tea "github.com/charmbracelet/bubbletea" ) diff --git a/go.mod b/go.mod index 05cb855..904a85a 100644 --- a/go.mod +++ b/go.mod @@ -8,15 +8,11 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/gorilla/mux v1.8.1 - github.com/gorilla/websocket v1.5.3 - github.com/pion/webrtc/v3 v3.3.5 github.com/rs/cors v1.11.1 github.com/stretchr/testify v1.10.0 go.uber.org/zap v1.27.0 golang.org/x/crypto v0.32.0 golang.org/x/term v0.28.0 - google.golang.org/grpc v1.71.0 - google.golang.org/protobuf v1.36.4 gorm.io/driver/postgres v1.5.11 gorm.io/gorm v1.25.12 ) @@ -30,7 +26,6 @@ require ( github.com/charmbracelet/x/term v0.2.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgx/v5 v5.5.5 // indirect @@ -44,31 +39,13 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect - github.com/pion/datachannel v1.5.8 // indirect - github.com/pion/dtls/v2 v2.2.12 // indirect - github.com/pion/ice/v2 v2.3.36 // indirect - github.com/pion/interceptor v0.1.29 // indirect - github.com/pion/logging v0.2.2 // indirect - github.com/pion/mdns v0.0.12 // indirect - github.com/pion/randutil v0.1.0 // indirect - github.com/pion/rtcp v1.2.14 // indirect - github.com/pion/rtp v1.8.7 // indirect - github.com/pion/sctp v1.8.19 // indirect - github.com/pion/sdp/v3 v3.0.9 // indirect - github.com/pion/srtp/v2 v2.0.20 // indirect - github.com/pion/stun v0.6.1 // indirect - github.com/pion/transport/v2 v2.2.10 // indirect - github.com/pion/turn/v2 v2.1.6 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.6.1 // indirect - github.com/wlynxg/anet v0.0.3 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.uber.org/multierr v1.10.0 // indirect - golang.org/x/net v0.34.0 // indirect golang.org/x/sync v0.12.0 // indirect golang.org/x/sys v0.30.0 // indirect golang.org/x/text v0.21.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index cbb986b..f7d7423 100644 --- a/go.sum +++ b/go.sum @@ -21,23 +21,10 @@ 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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/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/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -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.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -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/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 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-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= @@ -71,48 +58,6 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/pion/datachannel v1.5.8 h1:ph1P1NsGkazkjrvyMfhRBUAWMxugJjq2HfQifaOoSNo= -github.com/pion/datachannel v1.5.8/go.mod h1:PgmdpoaNBLX9HNzNClmdki4DYW5JtI7Yibu8QzbL3tI= -github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= -github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk= -github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE= -github.com/pion/ice/v2 v2.3.36 h1:SopeXiVbbcooUg2EIR8sq4b13RQ8gzrkkldOVg+bBsc= -github.com/pion/ice/v2 v2.3.36/go.mod h1:mBF7lnigdqgtB+YHkaY/Y6s6tsyRyo4u4rPGRuOjUBQ= -github.com/pion/interceptor v0.1.29 h1:39fsnlP1U8gw2JzOFWdfCU82vHvhW9o0rZnZF56wF+M= -github.com/pion/interceptor v0.1.29/go.mod h1:ri+LGNjRUc5xUNtDEPzfdkmSqISixVTBF/z/Zms/6T4= -github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= -github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= -github.com/pion/mdns v0.0.12 h1:CiMYlY+O0azojWDmxdNr7ADGrnZ+V6Ilfner+6mSVK8= -github.com/pion/mdns v0.0.12/go.mod h1:VExJjv8to/6Wqm1FXK+Ii/Z9tsVk/F5sD/N70cnYFbk= -github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= -github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= -github.com/pion/rtcp v1.2.12/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4= -github.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE= -github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4= -github.com/pion/rtp v1.8.3/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= -github.com/pion/rtp v1.8.7 h1:qslKkG8qxvQ7hqaxkmL7Pl0XcUm+/Er7nMnu6Vq+ZxM= -github.com/pion/rtp v1.8.7/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= -github.com/pion/sctp v1.8.19 h1:2CYuw+SQ5vkQ9t0HdOPccsCz1GQMDuVy5PglLgKVBW8= -github.com/pion/sctp v1.8.19/go.mod h1:P6PbDVA++OJMrVNg2AL3XtYHV4uD6dvfyOovCgMs0PE= -github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY= -github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M= -github.com/pion/srtp/v2 v2.0.20 h1:HNNny4s+OUmG280ETrCdgFndp4ufx3/uy85EawYEhTk= -github.com/pion/srtp/v2 v2.0.20/go.mod h1:0KJQjA99A6/a0DOVTu1PhDSw0CXF2jTkqOoMg3ODqdA= -github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4= -github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8= -github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= -github.com/pion/transport/v2 v2.2.3/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0= -github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0= -github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q= -github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E= -github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0= -github.com/pion/transport/v3 v3.0.2 h1:r+40RJR25S9w3jbA6/5uEPTzcdn7ncyU44RWCbHkLg4= -github.com/pion/transport/v3 v3.0.2/go.mod h1:nIToODoOlb5If2jF9y2Igfx3PFYWfuXi37m0IlWa/D0= -github.com/pion/turn/v2 v2.1.3/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY= -github.com/pion/turn/v2 v2.1.6 h1:Xr2niVsiPTB0FPtt+yAWKFUkU1eotQbGgpTIld4x1Gc= -github.com/pion/turn/v2 v2.1.6/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY= -github.com/pion/webrtc/v3 v3.3.5 h1:ZsSzaMz/i9nblPdiAkZoP+E6Kmjw+jnyq3bEmU3EtRg= -github.com/pion/webrtc/v3 v3.3.5/go.mod h1:liNa+E1iwyzyXqNUwvoMRNQ10x8h8FOeJKL8RkIbamE= 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/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -123,114 +68,34 @@ github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTE github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= 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.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg= -github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= -go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= -go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= -go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= -go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= -go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= -go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= -go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= -go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= -go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= -golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= -golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= -golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= -golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= -golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50= -google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= -google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= -google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM= -google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= diff --git a/internal/api/auth/auth.go b/internal/api/auth/auth.go new file mode 100644 index 0000000..b382beb --- /dev/null +++ b/internal/api/auth/auth.go @@ -0,0 +1,57 @@ +package auth + +import ( + "errors" + "os" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +var jwtKey = []byte(os.Getenv("JWT_SECRET")) + +type Claims struct { + UserID int `json:"user_id"` + jwt.RegisteredClaims +} + +func GenerateJWT(userID int) (string, error) { + expirationTime := time.Now().Add(5 * time.Hour) + claims := &Claims{ + UserID: userID, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expirationTime), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString(jwtKey) + if err != nil { + return "", err + } + return tokenString, nil +} + +func ValidateJWT(tokenString string) (*Claims, error) { + claims := &Claims{} + // A token has a form `Bearer ...` + tokenParts := strings.Split(tokenString, " ") + if len(tokenParts) != 2 { + return nil, errors.New("not valid JWT") + } + + token, err := jwt.ParseWithClaims(tokenParts[1], claims, func(token *jwt.Token) (interface{}, error) { + return jwtKey, nil + }) + + if err != nil { + return nil, err + } + + if !token.Valid { + return nil, err + } + + return claims, nil +} diff --git a/internal/api/auth/auth_test.go b/internal/api/auth/auth_test.go new file mode 100644 index 0000000..50b6c9b --- /dev/null +++ b/internal/api/auth/auth_test.go @@ -0,0 +1,74 @@ +package auth + +import ( + "os" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" +) + +func TestGenerateAndValidateJWT(t *testing.T) { + // Set up the JWT secret for the test. + os.Setenv("JWT_SECRET", "testsecret") + jwtKey = []byte(os.Getenv("JWT_SECRET")) + + userID := 123 + tokenString, err := GenerateJWT(userID) + assert.NoError(t, err) + assert.NotEmpty(t, tokenString) + + claims, err := ValidateJWT(tokenString) + assert.NoError(t, err) + assert.NotNil(t, claims) + assert.Equal(t, userID, claims.UserID) + assert.True(t, claims.ExpiresAt.After(time.Now())) +} + +func TestValidateJWT_InvalidToken(t *testing.T) { + os.Setenv("JWT_SECRET", "testsecret") + jwtKey = []byte(os.Getenv("JWT_SECRET")) + + _, err := ValidateJWT("invalid_token") + assert.Error(t, err) +} + +func TestValidateJWT_ExpiredToken(t *testing.T) { + os.Setenv("JWT_SECRET", "testsecret") + jwtKey = []byte(os.Getenv("JWT_SECRET")) + + // Create a token that has already expired. + claims := &Claims{ + UserID: 123, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString(jwtKey) + assert.NoError(t, err) + + _, err = ValidateJWT(tokenString) + assert.Error(t, err) +} + +func TestValidateJWT_WrongSecret(t *testing.T) { + os.Setenv("JWT_SECRET", "testsecret") + jwtKey = []byte(os.Getenv("JWT_SECRET")) + + userID := 123 + tokenString, err := GenerateJWT(userID) + assert.NoError(t, err) + + // Set a different secret for validation. + wrongKey := []byte("wrongsecret") + + claims := &Claims{} + _, err = jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + return wrongKey, nil + }) + + assert.Error(t, err) +} diff --git a/internal/api/database/database.go b/internal/api/database/database.go new file mode 100644 index 0000000..4470c58 --- /dev/null +++ b/internal/api/database/database.go @@ -0,0 +1,32 @@ +package database + +import ( + "gorm.io/driver/postgres" + "gorm.io/gorm" + + "errors" +) + +// Global variable but private +var db *gorm.DB = nil + +// Init the database from a DSN string which must be a valid PostgreSQL dsn. +// Also, auto migrate all the models. +func InitDb(dsn string) (*gorm.DB, error) { + var err error + db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{}) + + if err == nil { + db.AutoMigrate(&User{}, &Game{}) + } + + return db, err +} + +// Return the instance or error if the config is not laoded yet +func GetDb() (*gorm.DB, error) { + if db == nil { + return nil, errors.New("You must call `InitDb()` first.") + } + return db, nil +} diff --git a/internal/api/database/models.go b/internal/api/database/models.go new file mode 100644 index 0000000..a6e76c5 --- /dev/null +++ b/internal/api/database/models.go @@ -0,0 +1,24 @@ +package database + +import "time" + +type User struct { + ID int `json:"id"` + Username string `json:"username"` + Password string `json:"password"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Game struct { + ID int `json:"id"` + Player1ID int `json:"-"` + Player1 User `gorm:"foreignKey:Player1ID" json:"player1"` + Player2ID *int `json:"-"` + Player2 *User `gorm:"foreignKey:Player2ID;null" json:"player2"` + Name string `json:"name"` + IP1 string `json:"ip1"` + IP2 string `json:"ip2"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/internal/api/handlers/handlers.go b/internal/api/handlers/handlers.go new file mode 100644 index 0000000..b448502 --- /dev/null +++ b/internal/api/handlers/handlers.go @@ -0,0 +1,197 @@ +package handlers + +import ( + "encoding/json" + "log/slog" + "net/http" + "time" + + "github.com/boozec/rahanna/internal/api/auth" + "github.com/boozec/rahanna/internal/api/database" + "github.com/boozec/rahanna/internal/network" + "gorm.io/gorm" +) + +type NewGameRequest struct { + IP string `json:"ip"` +} + +func RegisterUser(w http.ResponseWriter, r *http.Request) { + slog.Info("POST /auth/register") + var user database.User + err := json.NewDecoder(r.Body).Decode(&user) + if err != nil { + JsonError(&w, err.Error()) + return + } + + if len(user.Password) < 4 { + JsonError(&w, "password too short") + return + } + + var storedUser database.User + db, _ := database.GetDb() + result := db.Where("username = ?", user.Username).First(&storedUser) + + if result.Error == nil { + JsonError(&w, "user with this username already exists") + return + } + + hashedPassword, err := HashPassword(user.Password) + if err != nil { + JsonError(&w, err.Error()) + return + } + user.Password = string(hashedPassword) + + result = db.Create(&user) + if result.Error != nil { + JsonError(&w, result.Error.Error()) + return + } + + token, err := auth.GenerateJWT(user.ID) + if err != nil { + JsonError(&w, err.Error()) + return + } + + json.NewEncoder(w).Encode(map[string]string{"token": token}) +} + +func LoginUser(w http.ResponseWriter, r *http.Request) { + slog.Info("POST /auth/login") + var inputUser database.User + err := json.NewDecoder(r.Body).Decode(&inputUser) + if err != nil { + JsonError(&w, err.Error()) + return + } + + var storedUser database.User + + db, _ := database.GetDb() + result := db.Where("username = ?", inputUser.Username).First(&storedUser) + if result.Error != nil { + JsonError(&w, "invalid credentials") + return + } + + if err := CheckPasswordHash(storedUser.Password, inputUser.Password); err != nil { + JsonError(&w, "invalid credentials") + return + } + + token, err := auth.GenerateJWT(storedUser.ID) + if err != nil { + JsonError(&w, err.Error()) + return + } + + json.NewEncoder(w).Encode(map[string]string{"token": token}) +} + +func NewPlay(w http.ResponseWriter, r *http.Request) { + slog.Info("POST /play") + claims, err := auth.ValidateJWT(r.Header.Get("Authorization")) + + if err != nil { + JsonError(&w, err.Error()) + return + } + + var payload struct { + IP string `json:"ip"` + } + + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + JsonError(&w, err.Error()) + return + } + + if err != nil { + JsonError(&w, err.Error()) + return + } + + db, _ := database.GetDb() + + name := network.NewSession() + play := database.Game{ + Player1ID: claims.UserID, + Player2ID: nil, + Name: name, + IP1: payload.IP, + IP2: "", + } + + result := db.Create(&play) + if result.Error != nil { + JsonError(&w, result.Error.Error()) + return + } + + json.NewEncoder(w).Encode(map[string]string{"name": name}) +} + +func EnterGame(w http.ResponseWriter, r *http.Request) { + slog.Info("POST /enter-game") + claims, err := auth.ValidateJWT(r.Header.Get("Authorization")) + + if err != nil { + JsonError(&w, err.Error()) + return + } + + var payload struct { + Name string `json:"name"` + IP string `json:"ip"` + } + + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + JsonError(&w, err.Error()) + return + } + + if err != nil { + JsonError(&w, err.Error()) + return + } + + db, _ := database.GetDb() + + var play database.Game + + result := db.Where("name = ? AND player2_id IS NULL", payload.Name).First(&play) + if result.Error != nil { + JsonError(&w, result.Error.Error()) + return + } + + play.Player2ID = &claims.UserID + play.IP2 = payload.IP + play.UpdatedAt = time.Now() + + if err := db.Save(&play).Error; err != nil { + JsonError(&w, err.Error()) + return + } + + result = db.Where("id = ?", play.ID). + Preload("Player1", func(db *gorm.DB) *gorm.DB { + return db.Omit("Password") + }). + Preload("Player2", func(db *gorm.DB) *gorm.DB { + return db.Omit("Password") + }). + First(&play) + + if result.Error != nil { + JsonError(&w, result.Error.Error()) + return + } + + json.NewEncoder(w).Encode(play) +} diff --git a/internal/api/handlers/utils.go b/internal/api/handlers/utils.go new file mode 100644 index 0000000..d6cc0d6 --- /dev/null +++ b/internal/api/handlers/utils.go @@ -0,0 +1,34 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "golang.org/x/crypto/bcrypt" +) + +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(bytes), err +} + +func CheckPasswordHash(hash, password string) error { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) +} + +// Set a JSON response with status code 400 +func JsonError(w *http.ResponseWriter, error string) { + payloadMap := map[string]string{"error": error} + + (*w).Header().Set("Content-Type", "application/json") + (*w).WriteHeader(http.StatusBadRequest) + + payload, err := json.Marshal(payloadMap) + + if err != nil { + (*w).WriteHeader(http.StatusBadGateway) + (*w).Write([]byte(err.Error())) + } else { + (*w).Write(payload) + } +} diff --git a/internal/api/middleware/middleware.go b/internal/api/middleware/middleware.go new file mode 100644 index 0000000..0334e78 --- /dev/null +++ b/internal/api/middleware/middleware.go @@ -0,0 +1,36 @@ +package middleware + +import ( + "encoding/json" + "net/http" + + "github.com/boozec/rahanna/internal/api/auth" +) + +func AuthMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tokenString := r.Header.Get("Authorization") + + payloadMap := map[string]string{"error": "unauthorized"} + payload, _ := json.Marshal(payloadMap) + + if tokenString == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + + w.Write([]byte(payload)) + return + } + + _, err := auth.ValidateJWT(tokenString) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + + payload, _ := json.Marshal(payloadMap) + + w.Write([]byte(payload)) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/internal/network/ip.go b/internal/network/ip.go new file mode 100644 index 0000000..0c6451e --- /dev/null +++ b/internal/network/ip.go @@ -0,0 +1,33 @@ +package network + +import ( + "fmt" + "log/slog" + "math/rand" + "net" +) + +func GetOutboundIP() net.IP { + conn, err := net.Dial("udp", "8.8.8.8:80") + if err != nil { + slog.Error("err", err) + } + defer conn.Close() + + localAddr := conn.LocalAddr().(*net.UDPAddr) + + return localAddr.IP +} + +func GetRandomAvailablePort() (int, error) { + for i := 0; i < 100; i += 1 { + port := rand.Intn(65535-1024) + 1024 + addr := fmt.Sprintf(":%d", port) + ln, err := net.Listen("tcp", addr) + if err == nil { + defer ln.Close() + return port, nil + } + } + return 0, fmt.Errorf("failed to find an available port after multiple attempts") +} diff --git a/internal/network/network.go b/internal/network/network.go new file mode 100644 index 0000000..8283993 --- /dev/null +++ b/internal/network/network.go @@ -0,0 +1,213 @@ +package network + +import ( + "bufio" + "encoding/json" + "fmt" + "net" + "sync" + "time" + + "go.uber.org/zap" +) + +var logger *zap.Logger + +// PeerInfo represents a peer's ID and IP. +type PeerInfo struct { + ID string `json:"id"` + IP string `json:"ip"` + Port int `json:"port"` +} + +// Message represents a structured message. +type Message struct { + Type string `json:"type"` + Payload []byte `json:"payload"` + Source PeerInfo `json:"source"` + Target PeerInfo `json:"target"` + Timestamp int64 `json:"timestamp"` +} + +type NetworkCallback func(msg Message) + +// TCPNetwork represents a full-duplex TCP peer. +type TCPNetwork struct { + localPeer PeerInfo + connections map[string]net.Conn + listener net.Listener + callbacks map[string]NetworkCallback + callbacksMu sync.RWMutex + isConnected bool + retryDelay time.Duration + sync.Mutex +} + +// initializes a TCP peer +func NewTCPNetwork(localID, localIP string, localPort int) *TCPNetwork { + n := &TCPNetwork{ + localPeer: PeerInfo{ID: localID, IP: localIP, Port: localPort}, + connections: make(map[string]net.Conn), + callbacks: make(map[string]NetworkCallback), + isConnected: false, + retryDelay: 2 * time.Second, + } + + go n.startServer() + + logger, _ = zap.NewProduction() + + return n +} + +// Add a new peer connection to the local peer +func (n *TCPNetwork) AddPeer(remoteID string, remoteIP string, remotePort int) { + go n.retryConnect(remoteID, remoteIP, remotePort) +} + +// startServer starts a TCP server to accept connections. +func (n *TCPNetwork) startServer() { + address := fmt.Sprintf("%s:%d", n.localPeer.IP, n.localPeer.Port) + listener, err := net.Listen("tcp", address) + if err != nil { + logger.Sugar().Errorf("failed to start server: %v", err) + } + n.listener = listener + logger.Sugar().Infof("server started on %s\n", address) + + for { + conn, err := listener.Accept() + if err != nil { + logger.Sugar().Errorf("failed to accept connection: %v\n", err) + continue + } + + remoteAddr := conn.RemoteAddr().String() + n.Lock() + n.connections[remoteAddr] = conn + n.Unlock() + n.isConnected = true + n.retryDelay = 2 * time.Second + + logger.Sugar().Infof("connected to remote peer %s\n", remoteAddr) + go n.listenForMessages(conn) + } +} + +// retryConnect attempts to connect to a remote peer. +func (n *TCPNetwork) retryConnect(remoteID, remoteIP string, remotePort int) { + for { + n.Lock() + _, exists := n.connections[remoteID] + n.Unlock() + + if exists { + time.Sleep(5 * time.Second) + continue + } + + address := fmt.Sprintf("%s:%d", remoteIP, remotePort) + conn, err := net.Dial("tcp", address) + + if err != nil { + logger.Sugar().Errorf("failed to connect to %s: %v. Retrying in %v...", remoteID, err, n.retryDelay) + time.Sleep(n.retryDelay) + if n.retryDelay < 30*time.Second { + n.retryDelay *= 2 + } + continue + } + + n.Lock() + n.connections[remoteID] = conn + n.Unlock() + logger.Sugar().Infof("successfully connected to peer %s!", remoteID) + + go n.listenForMessages(conn) + } +} + +// Send sends a message to a specified remote peer. +func (n *TCPNetwork) Send(remoteID, messageType string, payload []byte) error { + n.Lock() + conn, exists := n.connections[remoteID] + n.Unlock() + + if !exists { + return fmt.Errorf("not connected to peer %s", remoteID) + } + + msg := Message{ + Type: messageType, + Payload: payload, + Source: n.localPeer, + Target: PeerInfo{ID: remoteID}, + Timestamp: time.Now().Unix(), + } + + data, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("failed to marshal message: %v", err) + } + + _, err = conn.Write(append(data, '\n')) + if err != nil { + logger.Sugar().Errorf("failed to send message to %s: %v. Reconnecting...", remoteID, err) + n.Lock() + delete(n.connections, remoteID) + n.Unlock() + go n.retryConnect(remoteID, "", 0) + return fmt.Errorf("failed to send message: %v", err) + } + + return nil +} + +// RegisterHandler registers a callback for a message type. +func (n *TCPNetwork) RegisterHandler(messageType string, callback NetworkCallback) { + n.callbacksMu.Lock() + n.callbacks[messageType] = callback + n.callbacksMu.Unlock() +} + +// listenForMessages listens for incoming messages. +func (n *TCPNetwork) listenForMessages(conn net.Conn) { + reader := bufio.NewReader(conn) + + for { + data, err := reader.ReadBytes('\n') + if err != nil { + logger.Debug("connection lost. Reconnecting...") + n.Lock() + for id, c := range n.connections { + if c == conn { + delete(n.connections, id) + go n.retryConnect(id, "", 0) + break + } + } + n.Unlock() + return + } + + var message Message + if err := json.Unmarshal(data, &message); err != nil { + logger.Sugar().Errorf("failed to unmarshal message: %v\n", err) + continue + } + + n.callbacksMu.RLock() + callback, exists := n.callbacks[message.Type] + n.callbacksMu.RUnlock() + + if exists { + go callback(message) + } + } +} + +func (n *TCPNetwork) IsConnected() bool { + n.Lock() + defer n.Unlock() + return n.isConnected +} diff --git a/internal/network/network_test.go b/internal/network/network_test.go new file mode 100644 index 0000000..9dbc416 --- /dev/null +++ b/internal/network/network_test.go @@ -0,0 +1,52 @@ +package network + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// TestPeerToPeerCommunication tests if two peers can communicate. +func TestPeerToPeerCommunication(t *testing.T) { + // Create a mock of the first peer (peer-1) + peer1IP := "127.0.0.1" + peer1Port := 9001 + peer1 := NewTCPNetwork("peer-1", peer1IP, peer1Port) + + // Create a mock of the second peer (peer-2) + peer2IP := "127.0.0.1" + peer2Port := 9002 + peer2 := NewTCPNetwork("peer-2", peer2IP, peer2Port) + + // Register a message handler on peer-2 to receive the message from peer-1 + peer2.RegisterHandler("chat", func(msg Message) { + assert.Equal(t, "peer-1", msg.Source.ID) + assert.Equal(t, "Hey from peer-1!", string(msg.Payload)) + }) + + // Start the first peer and add the second peer + go peer1.AddPeer("peer-2", peer2IP, peer2Port) + go peer2.AddPeer("peer-1", peer1IP, peer1Port) + + // Wait for the connections to be established + // You might need a little more time based on network delay and retry logic + time.Sleep(5 * time.Second) + + // Send a message from peer-1 to peer-2 + err := peer1.Send("peer-2", "chat", []byte("Hey from peer-1!")) + assert.NoError(t, err) + + // Allow some time for the message to be received and handled + time.Sleep(2 * time.Second) +} + +// TestSendFailure tests if sending a message fails when no connection exists. +func TestSendFailure(t *testing.T) { + peer1 := NewTCPNetwork("peer-1", "127.0.0.1", 9001) + _ = NewTCPNetwork("peer-2", "127.0.0.1", 9002) + + // Attempt to send a message without establishing a connection first + err := peer1.Send("peer-2", "chat", []byte("Message without connection")) + assert.Error(t, err, "Expected error when sending to a non-connected peer") +} diff --git a/internal/network/session.go b/internal/network/session.go new file mode 100644 index 0000000..a4f60aa --- /dev/null +++ b/internal/network/session.go @@ -0,0 +1,23 @@ +package network + +import ( + "math/rand" +) + +var adjectives = []string{ + "adamant", "adept", "adventurous", "arcadian", "auspicious", + "awesome", "blossoming", "brave", "charming", "chatty", + "circular", "considerate", "cubic", "curious", "delighted", +} + +var nouns = []string{ + "aardvark", "accordion", "apple", "apricot", "bee", + "brachiosaur", "cactus", "capsicum", "clarinet", "cowbell", + "crab", "cuckoo", "cymbal", "diplodocus", "donkey", +} + +func NewSession() string { + noun := nouns[rand.Intn(len(nouns))] + adjective := adjectives[rand.Intn(len(adjectives))] + return noun + "-" + adjective +} diff --git a/network/ip.go b/network/ip.go deleted file mode 100644 index 0c6451e..0000000 --- a/network/ip.go +++ /dev/null @@ -1,33 +0,0 @@ -package network - -import ( - "fmt" - "log/slog" - "math/rand" - "net" -) - -func GetOutboundIP() net.IP { - conn, err := net.Dial("udp", "8.8.8.8:80") - if err != nil { - slog.Error("err", err) - } - defer conn.Close() - - localAddr := conn.LocalAddr().(*net.UDPAddr) - - return localAddr.IP -} - -func GetRandomAvailablePort() (int, error) { - for i := 0; i < 100; i += 1 { - port := rand.Intn(65535-1024) + 1024 - addr := fmt.Sprintf(":%d", port) - ln, err := net.Listen("tcp", addr) - if err == nil { - defer ln.Close() - return port, nil - } - } - return 0, fmt.Errorf("failed to find an available port after multiple attempts") -} diff --git a/network/network.go b/network/network.go deleted file mode 100644 index 8283993..0000000 --- a/network/network.go +++ /dev/null @@ -1,213 +0,0 @@ -package network - -import ( - "bufio" - "encoding/json" - "fmt" - "net" - "sync" - "time" - - "go.uber.org/zap" -) - -var logger *zap.Logger - -// PeerInfo represents a peer's ID and IP. -type PeerInfo struct { - ID string `json:"id"` - IP string `json:"ip"` - Port int `json:"port"` -} - -// Message represents a structured message. -type Message struct { - Type string `json:"type"` - Payload []byte `json:"payload"` - Source PeerInfo `json:"source"` - Target PeerInfo `json:"target"` - Timestamp int64 `json:"timestamp"` -} - -type NetworkCallback func(msg Message) - -// TCPNetwork represents a full-duplex TCP peer. -type TCPNetwork struct { - localPeer PeerInfo - connections map[string]net.Conn - listener net.Listener - callbacks map[string]NetworkCallback - callbacksMu sync.RWMutex - isConnected bool - retryDelay time.Duration - sync.Mutex -} - -// initializes a TCP peer -func NewTCPNetwork(localID, localIP string, localPort int) *TCPNetwork { - n := &TCPNetwork{ - localPeer: PeerInfo{ID: localID, IP: localIP, Port: localPort}, - connections: make(map[string]net.Conn), - callbacks: make(map[string]NetworkCallback), - isConnected: false, - retryDelay: 2 * time.Second, - } - - go n.startServer() - - logger, _ = zap.NewProduction() - - return n -} - -// Add a new peer connection to the local peer -func (n *TCPNetwork) AddPeer(remoteID string, remoteIP string, remotePort int) { - go n.retryConnect(remoteID, remoteIP, remotePort) -} - -// startServer starts a TCP server to accept connections. -func (n *TCPNetwork) startServer() { - address := fmt.Sprintf("%s:%d", n.localPeer.IP, n.localPeer.Port) - listener, err := net.Listen("tcp", address) - if err != nil { - logger.Sugar().Errorf("failed to start server: %v", err) - } - n.listener = listener - logger.Sugar().Infof("server started on %s\n", address) - - for { - conn, err := listener.Accept() - if err != nil { - logger.Sugar().Errorf("failed to accept connection: %v\n", err) - continue - } - - remoteAddr := conn.RemoteAddr().String() - n.Lock() - n.connections[remoteAddr] = conn - n.Unlock() - n.isConnected = true - n.retryDelay = 2 * time.Second - - logger.Sugar().Infof("connected to remote peer %s\n", remoteAddr) - go n.listenForMessages(conn) - } -} - -// retryConnect attempts to connect to a remote peer. -func (n *TCPNetwork) retryConnect(remoteID, remoteIP string, remotePort int) { - for { - n.Lock() - _, exists := n.connections[remoteID] - n.Unlock() - - if exists { - time.Sleep(5 * time.Second) - continue - } - - address := fmt.Sprintf("%s:%d", remoteIP, remotePort) - conn, err := net.Dial("tcp", address) - - if err != nil { - logger.Sugar().Errorf("failed to connect to %s: %v. Retrying in %v...", remoteID, err, n.retryDelay) - time.Sleep(n.retryDelay) - if n.retryDelay < 30*time.Second { - n.retryDelay *= 2 - } - continue - } - - n.Lock() - n.connections[remoteID] = conn - n.Unlock() - logger.Sugar().Infof("successfully connected to peer %s!", remoteID) - - go n.listenForMessages(conn) - } -} - -// Send sends a message to a specified remote peer. -func (n *TCPNetwork) Send(remoteID, messageType string, payload []byte) error { - n.Lock() - conn, exists := n.connections[remoteID] - n.Unlock() - - if !exists { - return fmt.Errorf("not connected to peer %s", remoteID) - } - - msg := Message{ - Type: messageType, - Payload: payload, - Source: n.localPeer, - Target: PeerInfo{ID: remoteID}, - Timestamp: time.Now().Unix(), - } - - data, err := json.Marshal(msg) - if err != nil { - return fmt.Errorf("failed to marshal message: %v", err) - } - - _, err = conn.Write(append(data, '\n')) - if err != nil { - logger.Sugar().Errorf("failed to send message to %s: %v. Reconnecting...", remoteID, err) - n.Lock() - delete(n.connections, remoteID) - n.Unlock() - go n.retryConnect(remoteID, "", 0) - return fmt.Errorf("failed to send message: %v", err) - } - - return nil -} - -// RegisterHandler registers a callback for a message type. -func (n *TCPNetwork) RegisterHandler(messageType string, callback NetworkCallback) { - n.callbacksMu.Lock() - n.callbacks[messageType] = callback - n.callbacksMu.Unlock() -} - -// listenForMessages listens for incoming messages. -func (n *TCPNetwork) listenForMessages(conn net.Conn) { - reader := bufio.NewReader(conn) - - for { - data, err := reader.ReadBytes('\n') - if err != nil { - logger.Debug("connection lost. Reconnecting...") - n.Lock() - for id, c := range n.connections { - if c == conn { - delete(n.connections, id) - go n.retryConnect(id, "", 0) - break - } - } - n.Unlock() - return - } - - var message Message - if err := json.Unmarshal(data, &message); err != nil { - logger.Sugar().Errorf("failed to unmarshal message: %v\n", err) - continue - } - - n.callbacksMu.RLock() - callback, exists := n.callbacks[message.Type] - n.callbacksMu.RUnlock() - - if exists { - go callback(message) - } - } -} - -func (n *TCPNetwork) IsConnected() bool { - n.Lock() - defer n.Unlock() - return n.isConnected -} diff --git a/network/network_test.go b/network/network_test.go deleted file mode 100644 index 9dbc416..0000000 --- a/network/network_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package network - -import ( - "testing" - "time" - - "github.com/stretchr/testify/assert" -) - -// TestPeerToPeerCommunication tests if two peers can communicate. -func TestPeerToPeerCommunication(t *testing.T) { - // Create a mock of the first peer (peer-1) - peer1IP := "127.0.0.1" - peer1Port := 9001 - peer1 := NewTCPNetwork("peer-1", peer1IP, peer1Port) - - // Create a mock of the second peer (peer-2) - peer2IP := "127.0.0.1" - peer2Port := 9002 - peer2 := NewTCPNetwork("peer-2", peer2IP, peer2Port) - - // Register a message handler on peer-2 to receive the message from peer-1 - peer2.RegisterHandler("chat", func(msg Message) { - assert.Equal(t, "peer-1", msg.Source.ID) - assert.Equal(t, "Hey from peer-1!", string(msg.Payload)) - }) - - // Start the first peer and add the second peer - go peer1.AddPeer("peer-2", peer2IP, peer2Port) - go peer2.AddPeer("peer-1", peer1IP, peer1Port) - - // Wait for the connections to be established - // You might need a little more time based on network delay and retry logic - time.Sleep(5 * time.Second) - - // Send a message from peer-1 to peer-2 - err := peer1.Send("peer-2", "chat", []byte("Hey from peer-1!")) - assert.NoError(t, err) - - // Allow some time for the message to be received and handled - time.Sleep(2 * time.Second) -} - -// TestSendFailure tests if sending a message fails when no connection exists. -func TestSendFailure(t *testing.T) { - peer1 := NewTCPNetwork("peer-1", "127.0.0.1", 9001) - _ = NewTCPNetwork("peer-2", "127.0.0.1", 9002) - - // Attempt to send a message without establishing a connection first - err := peer1.Send("peer-2", "chat", []byte("Message without connection")) - assert.Error(t, err, "Expected error when sending to a non-connected peer") -} diff --git a/network/session.go b/network/session.go deleted file mode 100644 index a4f60aa..0000000 --- a/network/session.go +++ /dev/null @@ -1,23 +0,0 @@ -package network - -import ( - "math/rand" -) - -var adjectives = []string{ - "adamant", "adept", "adventurous", "arcadian", "auspicious", - "awesome", "blossoming", "brave", "charming", "chatty", - "circular", "considerate", "cubic", "curious", "delighted", -} - -var nouns = []string{ - "aardvark", "accordion", "apple", "apricot", "bee", - "brachiosaur", "cactus", "capsicum", "clarinet", "cowbell", - "crab", "cuckoo", "cymbal", "diplodocus", "donkey", -} - -func NewSession() string { - noun := nouns[rand.Intn(len(nouns))] - adjective := adjectives[rand.Intn(len(adjectives))] - return noun + "-" + adjective -} diff --git a/pkg/ui/views/auth.go b/pkg/ui/views/auth.go new file mode 100644 index 0000000..a695466 --- /dev/null +++ b/pkg/ui/views/auth.go @@ -0,0 +1,590 @@ +package views + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const ( + SignInTab TabType = iota + SignUpTab +) + +// AuthModel is the main container model for both login and signup tabsuth +type AuthModel struct { + loginModel loginModel + signupModel signupModel + activeTab TabType + width int + height int +} + +// Model holds the state for login page +type loginModel struct { + username textinput.Model + password textinput.Model + focus int + err error + isLoading bool + token string + width int + height int +} + +// Model holds the state for signup page +type signupModel struct { + loginModel + confirmPassword textinput.Model +} + +// Response from API +type authResponse struct { + Token string `json:"token"` + Error string `json:"error"` +} + +// Initialize AuthModel which contains both tabs +func NewAuthModel(width, height int) AuthModel { + return AuthModel{ + loginModel: initLoginModel(width, height), + signupModel: initSignupModel(width, height), + activeTab: SignInTab, + width: width, + height: height, + } +} + +// Initialize loginModel +func initLoginModel(width, height int) loginModel { + username := textinput.New() + username.Prompt = " " + username.TextStyle = inputStyle + username.Placeholder = "mario.rossi" + username.Focus() + username.CharLimit = 156 + username.Width = 30 + + password := textinput.New() + password.Prompt = " " + password.TextStyle = inputStyle + password.Placeholder = "*****" + password.EchoMode = textinput.EchoPassword + password.CharLimit = 156 + password.Width = 30 + + return loginModel{ + username: username, + password: password, + focus: 0, + err: nil, + isLoading: false, + token: "", + width: width, + height: height, + } +} + +// Initialize signupModel +func initSignupModel(width, height int) signupModel { + inputStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#7EE2A8")) + + username := textinput.New() + username.Prompt = " " + username.TextStyle = inputStyle + username.Placeholder = "mario.rossi" + username.Focus() + username.CharLimit = 156 + username.Width = 30 + + password := textinput.New() + password.Prompt = " " + password.TextStyle = inputStyle + password.Placeholder = "*****" + password.EchoMode = textinput.EchoPassword + password.CharLimit = 156 + password.Width = 30 + + confirmPassword := textinput.New() + confirmPassword.Prompt = " " + confirmPassword.TextStyle = inputStyle + confirmPassword.Placeholder = "*****" + confirmPassword.EchoMode = textinput.EchoPassword + confirmPassword.CharLimit = 156 + confirmPassword.Width = 30 + + return signupModel{ + loginModel: loginModel{ + username: username, + password: password, + focus: 0, + err: nil, + isLoading: false, + token: "", + width: width, + height: height, + }, + confirmPassword: confirmPassword, + } +} + +// Init function for AuthModel +func (m AuthModel) Init() tea.Cmd { + ClearScreen() + return textinput.Blink +} + +func (m AuthModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + if exit := handleExit(msg); exit != nil { + return m, exit + } + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case tea.KeyMsg: + switch msg.String() { + case "alt+1": + // Switch to sign-in tab + if m.activeTab != SignInTab { + m.activeTab = SignInTab + m.loginModel.focus = 0 + m.loginModel.username.Focus() + m.loginModel.password.Blur() + m.signupModel.username.Blur() + m.signupModel.password.Blur() + m.signupModel.confirmPassword.Blur() + } + return m, nil + + case "alt+2": + // Switch to sign-up tab + if m.activeTab != SignUpTab { + m.activeTab = SignUpTab + m.signupModel.focus = 0 + m.signupModel.username.Focus() + m.signupModel.password.Blur() + m.signupModel.confirmPassword.Blur() + m.loginModel.username.Blur() + m.loginModel.password.Blur() + } + return m, nil + + } + } + + if m.activeTab == SignInTab { + var cmd tea.Cmd + m.loginModel, cmd = m.loginModel.Update(msg) + cmds = append(cmds, cmd) + } else { + var cmd tea.Cmd + m.signupModel, cmd = m.signupModel.Update(msg) + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + +// View function for AuthModel +func (m AuthModel) View() string { + width, height := m.width, m.height + + // Get the content of the active tab + var tabContent string + if m.activeTab == SignInTab { + tabContent = m.loginModel.renderContent() + } else { + tabContent = m.signupModel.renderContent() + } + + // Create the window with tab content + ui := lipgloss.JoinVertical(lipgloss.Center, + getTabsRow([]string{"Sign In", "Sign Up"}, m.activeTab), + windowStyle.Width(getFormWidth(width)).Render(tabContent), + ) + + // Center logo and form in available space + contentHeight := lipgloss.Height(logo) + lipgloss.Height(ui) + 2 + paddingTop := (height - contentHeight) / 2 + if paddingTop < 0 { + paddingTop = 0 + } + + // Combine logo and tabs with vertical centering + output := lipgloss.NewStyle(). + MarginTop(paddingTop). + Render( + lipgloss.JoinVertical(lipgloss.Center, + getLogo(m.width), + lipgloss.PlaceHorizontal(width, lipgloss.Center, ui), + ), + ) + + return output +} + +// Update function for loginModel +func (m loginModel) Update(msg tea.Msg) (loginModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyUp: + m.focus = (m.focus - 1) % 2 + if m.focus < 0 { + m.focus = 1 + } + m.updateFocus() + case tea.KeyDown: + m.focus = (m.focus + 1) % 2 + m.updateFocus() + case tea.KeyEnter: + if !m.isLoading { + m.isLoading = true + return m, m.loginCallback() + } + case tea.KeyTab: + m.focus = (m.focus + 1) % 2 + m.updateFocus() + } + case authResponse: + m.isLoading = false + if msg.Error != "" { + m.err = fmt.Errorf(msg.Error) + m.focus = 0 + m.updateFocus() + } else { + m.token = msg.Token + ClearScreen() + f, err := os.OpenFile(".rahannarc", os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + m.err = err + break + } + defer f.Close() + f.Write([]byte(m.token)) + return m, SwitchModelCmd(NewPlayModel(m.width, m.height)) + } + case error: + m.isLoading = false + m.err = msg + m.focus = 0 + m.updateFocus() + } + + var cmd tea.Cmd + m.username, cmd = m.username.Update(msg) + cmdPassword := tea.Batch(cmd) + m.password, cmd = m.password.Update(msg) + return m, tea.Batch(cmd, cmdPassword) +} + +// Update function for signupModel +func (m signupModel) Update(msg tea.Msg) (signupModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyUp: + m.focus = (m.focus - 1) % 3 + if m.focus < 0 { + m.focus = 2 + } + m.updateFocus() + case tea.KeyDown: + m.focus = (m.focus + 1) % 3 + m.updateFocus() + case tea.KeyEnter: + if !m.isLoading { + m.isLoading = true + return m, m.signupCallback() + } + case tea.KeyTab: + m.focus = (m.focus + 1) % 3 + m.updateFocus() + } + case authResponse: + m.isLoading = false + if msg.Error != "" { + m.err = fmt.Errorf(msg.Error) + m.focus = 0 + m.updateFocus() + } else { + m.token = msg.Token + ClearScreen() + f, err := os.OpenFile(".rahannarc", os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + m.err = err + break + } + defer f.Close() + f.Write([]byte(m.token)) + return m, SwitchModelCmd(NewPlayModel(m.width, m.height)) + } + case error: + m.isLoading = false + m.err = msg + m.focus = 0 + m.updateFocus() + } + + var cmds []tea.Cmd + var cmd tea.Cmd + + m.username, cmd = m.username.Update(msg) + cmds = append(cmds, cmd) + + m.password, cmd = m.password.Update(msg) + cmds = append(cmds, cmd) + + m.confirmPassword, cmd = m.confirmPassword.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +// Helper function to update input focus for signup +func (m *signupModel) updateFocus() { + m.username.Blur() + m.password.Blur() + m.confirmPassword.Blur() + + switch m.focus { + case 0: + m.username.Focus() + case 1: + m.password.Focus() + case 2: + m.confirmPassword.Focus() + } +} + +// Helper function to update input focus for signin +func (m *loginModel) updateFocus() { + m.username.Blur() + m.password.Blur() + + switch m.focus { + case 0: + m.username.Focus() + case 1: + m.password.Focus() + } +} + +// Login API callback +func (m loginModel) loginCallback() tea.Cmd { + return func() tea.Msg { + url := os.Getenv("API_BASE") + "/auth/login" + + payload, err := json.Marshal(map[string]string{ + "username": m.username.Value(), + "password": m.password.Value(), + }) + + if err != nil { + return authResponse{Error: err.Error()} + } + + resp, err := http.Post(url, "application/json", bytes.NewReader(payload)) + if err != nil { + return authResponse{Error: err.Error()} + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + var response authResponse + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return authResponse{Error: fmt.Sprintf("HTTP error: %d, unable to decode body", resp.StatusCode)} + } + return authResponse{Error: response.Error} + } + + var response authResponse + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return authResponse{Error: fmt.Sprintf("Error decoding JSON: %v", err)} + } + + return response + } +} + +// Signup API callback +func (m signupModel) signupCallback() tea.Cmd { + return func() tea.Msg { + // Validate that passwords match + if m.password.Value() != m.confirmPassword.Value() { + return authResponse{Error: "Passwords do not match"} + } + + url := os.Getenv("API_BASE") + "/auth/register" + + payload, err := json.Marshal(map[string]string{ + "username": m.username.Value(), + "password": m.password.Value(), + }) + + if err != nil { + return authResponse{Error: err.Error()} + } + + resp, err := http.Post(url, "application/json", bytes.NewReader(payload)) + if err != nil { + return authResponse{Error: err.Error()} + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + var response authResponse + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return authResponse{Error: fmt.Sprintf("HTTP error: %d, unable to decode body", resp.StatusCode)} + } + return authResponse{Error: response.Error} + } + + var response authResponse + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return authResponse{Error: fmt.Sprintf("Error decoding JSON: %v", err)} + } + + return response + } +} + +// Render content of the login tab +func (m loginModel) renderContent() string { + formWidth := getFormWidth(m.width) + + // Styles + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#7ee2a8")). + Align(lipgloss.Center). + Width(formWidth - 4) // Account for padding + + labelStyle := lipgloss.NewStyle(). + Width(10). + Align(lipgloss.Right) + + inputWrapStyle := lipgloss.NewStyle(). + Align(lipgloss.Center). + Width(formWidth - 4) // Account for padding + + statusStyle := lipgloss.NewStyle(). + Align(lipgloss.Center). + Bold(true). + Width(formWidth - 4) // Account for padding + + // Error message + formError := "" + if m.err != nil { + formError = fmt.Sprintf("Error: %v", m.err.Error()) + } + + // Status message + statusMsg := fmt.Sprintf("Press %s to login", lipgloss.NewStyle().Italic(true).Render("Enter")) + if m.isLoading { + statusMsg = "Logging in..." + } + + form := lipgloss.JoinVertical(lipgloss.Center, + titleStyle.Render("Sign in to your account"), + "\n", + errorStyle.Align(lipgloss.Center).Width(formWidth-4).Render(formError), + inputWrapStyle.Render( + lipgloss.JoinHorizontal(lipgloss.Left, + labelStyle.Render("Username:"), + m.username.View(), + ), + ), + inputWrapStyle.Render( + lipgloss.JoinHorizontal(lipgloss.Left, + labelStyle.Render("Password:"), + m.password.View(), + ), + ), + "\n", + statusStyle.Render(statusMsg), + ) + + return form +} + +// Render content of the signup tab +func (m signupModel) renderContent() string { + formWidth := getFormWidth(m.width) + + // Styles + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#7ee2a8")). + Align(lipgloss.Center). + Width(formWidth - 4) // Account for padding + + labelStyle := lipgloss.NewStyle(). + Width(16). + Align(lipgloss.Right) + + inputWrapStyle := lipgloss.NewStyle(). + Align(lipgloss.Center). + Width(formWidth - 4) // Account for padding + + statusStyle := lipgloss.NewStyle(). + Align(lipgloss.Center). + Bold(true). + Width(formWidth - 4) // Account for padding + + // Error message + formError := "" + if m.err != nil { + formError = fmt.Sprintf("Error: %v", m.err.Error()) + } + + // Status message + statusMsg := fmt.Sprintf("Press %s to register", lipgloss.NewStyle().Italic(true).Render("Enter")) + if m.isLoading { + statusMsg = "Creating account..." + } + + form := lipgloss.JoinVertical(lipgloss.Center, + titleStyle.Render("Create a new account"), + "\n", + errorStyle.Align(lipgloss.Center).Width(formWidth-4).Render(formError), + inputWrapStyle.Render( + lipgloss.JoinHorizontal(lipgloss.Left, + labelStyle.Render("Username:"), + m.username.View(), + ), + ), + inputWrapStyle.Render( + lipgloss.JoinHorizontal(lipgloss.Left, + labelStyle.Render("Password:"), + m.password.View(), + ), + ), + inputWrapStyle.Render( + lipgloss.JoinHorizontal(lipgloss.Left, + labelStyle.Render("Confirm:"), + m.confirmPassword.View(), + ), + ), + "\n", + statusStyle.Render(statusMsg), + ) + + return form +} diff --git a/pkg/ui/views/play.go b/pkg/ui/views/play.go new file mode 100644 index 0000000..991849e --- /dev/null +++ b/pkg/ui/views/play.go @@ -0,0 +1,427 @@ +package views + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + + "github.com/boozec/rahanna/internal/api/database" + "github.com/boozec/rahanna/internal/network" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var chess string = ` + A B C D E F G H ++---------------+ +8 |♜ ♞ ♝ ♛ ♚ ♝ ♞ ♜| 8 +7 |♟ ♟ ♟ ♟ ♟ ♟ ♟ ♟| 7 +6 |. . . . . . . .| 6 +5 |. . . . . . . .| 5 +4 |. . . . . . . .| 4 +3 |. . . . . . . .| 3 +2 |♙ ♙ ♙ ♙ ♙ ♙ ♙ ♙| 2 +1 |♖ ♘ ♗ ♕ ♔ ♗ ♘ ♖| 1 ++---------------+ + A B C D E F G H +` + +type playKeyMap struct { + EnterNewGame key.Binding + StartNewGame key.Binding + GoLogout key.Binding + Quit key.Binding +} + +type playResponse struct { + Name string `json:"name"` + Error string `json:"error"` +} + +var defaultGameKeyMap = playKeyMap{ + EnterNewGame: key.NewBinding( + key.WithKeys("alt+E", "alt+e"), + key.WithHelp("Alt+E", "Enter a play using code"), + ), + StartNewGame: key.NewBinding( + key.WithKeys("alt+s", "alt+s"), + key.WithHelp("Alt+S", "Start a new play"), + ), + GoLogout: key.NewBinding( + key.WithKeys("alt+Q", "alt+q"), + key.WithHelp("Alt+Q", "Logout"), + ), + Quit: key.NewBinding( + key.WithKeys("Q", "q"), + key.WithHelp(" Q", "Quit"), + ), +} + +type PlayModelPage int + +const ( + LandingPage PlayModelPage = iota + InsertCodePage + StartGamePage +) + +type PlayModel struct { + width int + height int + err error + keys playKeyMap + namePrompt textinput.Model + page PlayModelPage + isLoading bool + playName string + play *database.Game +} + +func NewPlayModel(width, height int) PlayModel { + namePrompt := textinput.New() + namePrompt.Prompt = " " + namePrompt.TextStyle = inputStyle + namePrompt.Placeholder = "rectangular-lake" + namePrompt.Focus() + namePrompt.CharLimit = 23 + namePrompt.Width = 23 + + return PlayModel{ + width: width, + height: height, + err: nil, + keys: defaultGameKeyMap, + namePrompt: namePrompt, + page: LandingPage, + isLoading: false, + playName: "", + play: nil, + } +} + +func (m PlayModel) Init() tea.Cmd { + ClearScreen() + return textinput.Blink +} + +func (m PlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if exit := handleExit(msg); exit != nil { + return m, exit + } + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keys.EnterNewGame): + m.page = InsertCodePage + return m, nil + case key.Matches(msg, m.keys.StartNewGame): + m.page = StartGamePage + if !m.isLoading { + m.isLoading = true + return m, m.newGameCallback() + } + case key.Matches(msg, m.keys.GoLogout): + return m, m.logout() + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + case msg.Type == tea.KeyEnter: + if m.page == InsertCodePage { + if !m.isLoading { + m.isLoading = true + return m, m.enterGame() + } + } + } + case playResponse: + m.isLoading = false + m.err = nil + if msg.Error != "" { + m.err = fmt.Errorf(msg.Error) + if msg.Error == "unauthorized" { + return m, m.logout() + } + } else { + m.playName = msg.Name + } + return m, nil + case database.Game: + m.isLoading = false + m.play = &msg + m.err = nil + return m, nil + case error: + m.isLoading = false + m.err = msg + } + + var cmd tea.Cmd = nil + + if m.page == InsertCodePage { + m.namePrompt, cmd = m.namePrompt.Update(msg) + } + + return m, tea.Batch(cmd) +} + +func (m PlayModel) View() string { + formWidth := getFormWidth(m.width) + + var content string + base := lipgloss.NewStyle().Align(lipgloss.Center).Width(m.width) + + switch m.page { + case LandingPage: + content = chess + m.namePrompt.Blur() + case InsertCodePage: + m.namePrompt.Focus() + var statusMsg string + if m.isLoading { + statusMsg = "Loading..." + content = base. + Render( + lipgloss.NewStyle(). + Align(lipgloss.Center). + Bold(true). + Render(statusMsg), + ) + } else if m.play != nil { + statusMsg = fmt.Sprintf("You are playing versus %s", lipgloss.NewStyle().Foreground(lipgloss.Color("#e67e22")).Render(m.play.Player1.Username)) + content = base. + Render( + lipgloss.NewStyle(). + Align(lipgloss.Center). + Width(m.width). + Bold(true). + Render(statusMsg), + ) + } else { + statusMsg = fmt.Sprintf("Press %s to join", lipgloss.NewStyle().Italic(true).Render("Enter")) + content = base. + Render( + lipgloss.JoinVertical(lipgloss.Left, + lipgloss.NewStyle().Width(23).Render("Insert play code:"), + m.namePrompt.View(), + lipgloss.NewStyle(). + Align(lipgloss.Center). + PaddingTop(2). + Width(23). + Bold(true). + Render(statusMsg), + ), + ) + } + + case StartGamePage: + var statusMsg string + if m.isLoading { + statusMsg = "Loading..." + } else if m.playName != "" { + statusMsg = fmt.Sprintf("Share `%s` to your friend", lipgloss.NewStyle().Italic(true).Foreground(lipgloss.Color("#F39C12")).Render(m.playName)) + } + + content = base. + Render(statusMsg) + } + + var windowContent string + if m.err != nil { + formError := fmt.Sprintf("Error: %v", m.err.Error()) + windowContent = lipgloss.JoinVertical( + lipgloss.Center, + windowStyle.Width(formWidth).Render(lipgloss.JoinVertical( + lipgloss.Center, + errorStyle.Align(lipgloss.Center).Width(formWidth-4).Render(formError), + content, + )), + ) + } else { + windowContent = lipgloss.JoinVertical( + lipgloss.Center, + windowStyle.Width(formWidth).Render(lipgloss.JoinVertical( + lipgloss.Center, + content, + )), + ) + } + + enterKey := fmt.Sprintf("%s %s", altCodeStyle.Render(m.keys.EnterNewGame.Help().Key), m.keys.EnterNewGame.Help().Desc) + startKey := fmt.Sprintf("%s %s", altCodeStyle.Render(m.keys.StartNewGame.Help().Key), m.keys.StartNewGame.Help().Desc) + logoutKey := fmt.Sprintf("%s %s", altCodeStyle.Render(m.keys.GoLogout.Help().Key), m.keys.GoLogout.Help().Desc) + quitKey := fmt.Sprintf("%s %s", altCodeStyle.Render(m.keys.Quit.Help().Key), m.keys.Quit.Help().Desc) + + // Vertically align the buttons + buttons := lipgloss.JoinVertical( + lipgloss.Left, + enterKey, + startKey, + logoutKey, + quitKey, + ) + + centeredContent := lipgloss.JoinVertical( + lipgloss.Center, + getLogo(m.width), + windowContent, + lipgloss.NewStyle().MarginTop(2).Render(buttons), + ) + + return lipgloss.Place( + m.width, + m.height, + lipgloss.Center, + lipgloss.Center, + centeredContent, + ) +} + +func (m PlayModel) newGameCallback() tea.Cmd { + return func() tea.Msg { + f, err := os.Open(".rahannarc") + if err != nil { + return playResponse{Error: err.Error()} + } + defer f.Close() + + scanner := bufio.NewScanner(f) + var authorization string + for scanner.Scan() { + authorization = scanner.Text() + } + + if err := scanner.Err(); err != nil { + fmt.Println("Error during scanning:", err) + } + + url := os.Getenv("API_BASE") + "/play" + + port, err := network.GetRandomAvailablePort() + if err != nil { + return playResponse{Error: err.Error()} + } + + payload, err := json.Marshal(map[string]string{ + "ip": fmt.Sprintf("%s:%d", network.GetOutboundIP().String(), port), + }) + + if err != nil { + return playResponse{Error: err.Error()} + } + + req, err := http.NewRequest("POST", url, bytes.NewReader(payload)) + if err != nil { + return playResponse{Error: err.Error()} + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", authorization)) + + client := &http.Client{} + + resp, err := client.Do(req) + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + var response playResponse + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return playResponse{Error: fmt.Sprintf("HTTP error: %d, unable to decode body", resp.StatusCode)} + } + return playResponse{Error: response.Error} + } + + var response playResponse + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return playResponse{Error: fmt.Sprintf("Error decoding JSON: %v", err)} + } + + return response + } +} + +func (m PlayModel) enterGame() tea.Cmd { + return func() tea.Msg { + f, err := os.Open(".rahannarc") + if err != nil { + return playResponse{Error: err.Error()} + } + defer f.Close() + + scanner := bufio.NewScanner(f) + var authorization string + for scanner.Scan() { + authorization = scanner.Text() + } + + if err := scanner.Err(); err != nil { + fmt.Println("Error during scanning:", err) + } + + url := os.Getenv("API_BASE") + "/enter-game" + + port, err := network.GetRandomAvailablePort() + if err != nil { + return playResponse{Error: err.Error()} + } + + payload, err := json.Marshal(map[string]string{ + "ip": fmt.Sprintf("%s:%d", network.GetOutboundIP().String(), port), + "name": m.namePrompt.Value(), + }) + + if err != nil { + return playResponse{Error: err.Error()} + } + + req, err := http.NewRequest("POST", url, bytes.NewReader(payload)) + if err != nil { + return playResponse{Error: err.Error()} + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", authorization)) + + client := &http.Client{} + + resp, err := client.Do(req) + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + var response playResponse + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return playResponse{Error: fmt.Sprintf("HTTP error: %d, unable to decode body", resp.StatusCode)} + } + return playResponse{Error: response.Error} + } + + var response database.Game + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return playResponse{Error: fmt.Sprintf("Error decoding JSON: %v", err)} + } + + return response + } +} + +func (m PlayModel) logout() tea.Cmd { + if err := os.Remove(".rahannarc"); err != nil { + return nil + } + return SwitchModelCmd(NewAuthModel(m.width, m.height+1)) +} diff --git a/pkg/ui/views/tabs.go b/pkg/ui/views/tabs.go new file mode 100644 index 0000000..13e3672 --- /dev/null +++ b/pkg/ui/views/tabs.go @@ -0,0 +1,32 @@ +package views + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" +) + +type TabType int + +var ( + tabStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(highlightColor).Padding(0, 2) + inactiveTabStyle = tabStyle + activeTabStyle = tabStyle +) + +func getTabsRow(tabsText []string, activeTab TabType) string { + tabs := make([]string, len(tabsText)) + + for i, tab := range tabsText { + if TabType(i) == activeTab { + tabs[i] = fmt.Sprintf("%s %s", altCodeStyle.Render(fmt.Sprintf("Alt+%d", i+1)), lipgloss.NewStyle().Bold(true).Foreground(highlightColor).Render(tab)) + tabs[i] = activeTabStyle.Foreground(highlightColor).Render(tabs[i]) + } else { + tabs[i] = fmt.Sprintf("%s %s", altCodeStyle.Render(fmt.Sprintf("Alt+%d", i+1)), lipgloss.NewStyle().Render(tab)) + tabs[i] = inactiveTabStyle.Foreground(highlightColor).Render(tabs[i]) + } + } + + return lipgloss.JoinHorizontal(lipgloss.Top, tabs...) + +} diff --git a/pkg/ui/views/views.go b/pkg/ui/views/views.go new file mode 100644 index 0000000..fa70035 --- /dev/null +++ b/pkg/ui/views/views.go @@ -0,0 +1,144 @@ +package views + +import ( + "errors" + "os" + + "os/exec" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "golang.org/x/term" +) + +var logo = ` +▗▄▄▖ ▗▄▖ ▗▖ ▗▖ ▗▄▖ ▗▖ ▗▖▗▖ ▗▖ ▗▄▖ +▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▛▚▖▐▌▐▛▚▖▐▌▐▌ ▐▌ +▐▛▀▚▖▐▛▀▜▌▐▛▀▜▌▐▛▀▜▌▐▌ ▝▜▌▐▌ ▝▜▌▐▛▀▜▌ +▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌ +` + +var ( + highlightColor = lipgloss.Color("#7ee2a8") + errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#ff0000")) + altCodeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Bold(true) + windowStyle = lipgloss.NewStyle().BorderForeground(highlightColor).Padding(2, 0).Align(lipgloss.Center).Border(lipgloss.RoundedBorder()) + inputStyle = lipgloss.NewStyle().Foreground(highlightColor) +) + +// Get terminal size dynamically +func GetTerminalSize() (width, height int) { + fd := int(os.Stdin.Fd()) + if w, h, err := term.GetSize(fd); err == nil { + return w, h + } + return 80, 24 // Default size if detection fails +} + +// Clear terminal screen +func ClearScreen() { + if len(os.Getenv("DEBUG")) == 0 { + cmd := exec.Command("clear") + if os.Getenv("OS") == "Windows_NT" { + cmd = exec.Command("cmd", "/c", "cls") + } + cmd.Stdout = os.Stdout + cmd.Run() + } +} + +func getFormWidth(width int) int { + formWidth := width * 2 / 3 + if formWidth > 80 { + formWidth = 80 // Cap at 80 chars for readability + } else if formWidth < 40 { + formWidth = width - 4 // For small terminals + } + + return formWidth +} + +type RahannaModel struct { + width int + height int + currentModel tea.Model + auth AuthModel + play PlayModel +} + +func NewRahannaModel() RahannaModel { + width, height := GetTerminalSize() + + auth := NewAuthModel(width, height) + play := NewPlayModel(width, height) + + var currentModel tea.Model = auth + + if _, err := os.Stat(".rahannarc"); !errors.Is(err, os.ErrNotExist) { + currentModel = play + } + + return RahannaModel{ + width: width, + height: height, + currentModel: currentModel, + auth: auth, + play: play, + } +} + +func (m RahannaModel) Init() tea.Cmd { + return m.currentModel.Init() +} + +type switchModel struct { + model tea.Model +} + +func SwitchModelCmd(model tea.Model) tea.Cmd { + s := switchModel{ + model: model, + } + + return func() tea.Msg { + return s + } +} + +func (m RahannaModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case switchModel: + m.currentModel = msg.model + return m, nil + } + var cmd tea.Cmd + m.currentModel, cmd = m.currentModel.Update(msg) + return m, cmd +} + +func (m RahannaModel) View() string { + return m.currentModel.View() +} + +func handleExit(msg tea.Msg) tea.Cmd { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c": + return tea.Quit + } + } + + return nil +} + +func getLogo(width int) string { + logoStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#7ee2a8")). + Bold(true). + Align(lipgloss.Center). + Width(width) + + return logoStyle.Render(logo) + +} diff --git a/pkg/utils.go b/pkg/utils.go deleted file mode 100644 index 9246854..0000000 --- a/pkg/utils.go +++ /dev/null @@ -1,35 +0,0 @@ -package utils - -import ( - "encoding/json" - "net/http" - - "golang.org/x/crypto/bcrypt" -) - -func HashPassword(password string) (string, error) { - bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14) - return string(bytes), err -} - -func CheckPasswordHash(password, hash string) bool { - err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) - return err == nil -} - -// Set a JSON response with status code 400 -func JsonError(w *http.ResponseWriter, error string) { - payloadMap := map[string]string{"error": error} - - (*w).Header().Set("Content-Type", "application/json") - (*w).WriteHeader(http.StatusBadRequest) - - payload, err := json.Marshal(payloadMap) - - if err != nil { - (*w).WriteHeader(http.StatusBadGateway) - (*w).Write([]byte(err.Error())) - } else { - (*w).Write(payload) - } -} diff --git a/ui/.rahannarc b/ui/.rahannarc deleted file mode 100644 index 572a8a5..0000000 --- a/ui/.rahannarc +++ /dev/null @@ -1 +0,0 @@ -eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjo5LCJleHAiOjE3NDM5NjE2OTF9.4d0HpvRiwMD5IfNoctRXAg8DI6CxdZvouxKL4--LTJU \ No newline at end of file diff --git a/ui/views/auth.go b/ui/views/auth.go deleted file mode 100644 index a695466..0000000 --- a/ui/views/auth.go +++ /dev/null @@ -1,590 +0,0 @@ -package views - -import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - "os" - - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -const ( - SignInTab TabType = iota - SignUpTab -) - -// AuthModel is the main container model for both login and signup tabsuth -type AuthModel struct { - loginModel loginModel - signupModel signupModel - activeTab TabType - width int - height int -} - -// Model holds the state for login page -type loginModel struct { - username textinput.Model - password textinput.Model - focus int - err error - isLoading bool - token string - width int - height int -} - -// Model holds the state for signup page -type signupModel struct { - loginModel - confirmPassword textinput.Model -} - -// Response from API -type authResponse struct { - Token string `json:"token"` - Error string `json:"error"` -} - -// Initialize AuthModel which contains both tabs -func NewAuthModel(width, height int) AuthModel { - return AuthModel{ - loginModel: initLoginModel(width, height), - signupModel: initSignupModel(width, height), - activeTab: SignInTab, - width: width, - height: height, - } -} - -// Initialize loginModel -func initLoginModel(width, height int) loginModel { - username := textinput.New() - username.Prompt = " " - username.TextStyle = inputStyle - username.Placeholder = "mario.rossi" - username.Focus() - username.CharLimit = 156 - username.Width = 30 - - password := textinput.New() - password.Prompt = " " - password.TextStyle = inputStyle - password.Placeholder = "*****" - password.EchoMode = textinput.EchoPassword - password.CharLimit = 156 - password.Width = 30 - - return loginModel{ - username: username, - password: password, - focus: 0, - err: nil, - isLoading: false, - token: "", - width: width, - height: height, - } -} - -// Initialize signupModel -func initSignupModel(width, height int) signupModel { - inputStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#7EE2A8")) - - username := textinput.New() - username.Prompt = " " - username.TextStyle = inputStyle - username.Placeholder = "mario.rossi" - username.Focus() - username.CharLimit = 156 - username.Width = 30 - - password := textinput.New() - password.Prompt = " " - password.TextStyle = inputStyle - password.Placeholder = "*****" - password.EchoMode = textinput.EchoPassword - password.CharLimit = 156 - password.Width = 30 - - confirmPassword := textinput.New() - confirmPassword.Prompt = " " - confirmPassword.TextStyle = inputStyle - confirmPassword.Placeholder = "*****" - confirmPassword.EchoMode = textinput.EchoPassword - confirmPassword.CharLimit = 156 - confirmPassword.Width = 30 - - return signupModel{ - loginModel: loginModel{ - username: username, - password: password, - focus: 0, - err: nil, - isLoading: false, - token: "", - width: width, - height: height, - }, - confirmPassword: confirmPassword, - } -} - -// Init function for AuthModel -func (m AuthModel) Init() tea.Cmd { - ClearScreen() - return textinput.Blink -} - -func (m AuthModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - - if exit := handleExit(msg); exit != nil { - return m, exit - } - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - return m, nil - - case tea.KeyMsg: - switch msg.String() { - case "alt+1": - // Switch to sign-in tab - if m.activeTab != SignInTab { - m.activeTab = SignInTab - m.loginModel.focus = 0 - m.loginModel.username.Focus() - m.loginModel.password.Blur() - m.signupModel.username.Blur() - m.signupModel.password.Blur() - m.signupModel.confirmPassword.Blur() - } - return m, nil - - case "alt+2": - // Switch to sign-up tab - if m.activeTab != SignUpTab { - m.activeTab = SignUpTab - m.signupModel.focus = 0 - m.signupModel.username.Focus() - m.signupModel.password.Blur() - m.signupModel.confirmPassword.Blur() - m.loginModel.username.Blur() - m.loginModel.password.Blur() - } - return m, nil - - } - } - - if m.activeTab == SignInTab { - var cmd tea.Cmd - m.loginModel, cmd = m.loginModel.Update(msg) - cmds = append(cmds, cmd) - } else { - var cmd tea.Cmd - m.signupModel, cmd = m.signupModel.Update(msg) - cmds = append(cmds, cmd) - } - - return m, tea.Batch(cmds...) -} - -// View function for AuthModel -func (m AuthModel) View() string { - width, height := m.width, m.height - - // Get the content of the active tab - var tabContent string - if m.activeTab == SignInTab { - tabContent = m.loginModel.renderContent() - } else { - tabContent = m.signupModel.renderContent() - } - - // Create the window with tab content - ui := lipgloss.JoinVertical(lipgloss.Center, - getTabsRow([]string{"Sign In", "Sign Up"}, m.activeTab), - windowStyle.Width(getFormWidth(width)).Render(tabContent), - ) - - // Center logo and form in available space - contentHeight := lipgloss.Height(logo) + lipgloss.Height(ui) + 2 - paddingTop := (height - contentHeight) / 2 - if paddingTop < 0 { - paddingTop = 0 - } - - // Combine logo and tabs with vertical centering - output := lipgloss.NewStyle(). - MarginTop(paddingTop). - Render( - lipgloss.JoinVertical(lipgloss.Center, - getLogo(m.width), - lipgloss.PlaceHorizontal(width, lipgloss.Center, ui), - ), - ) - - return output -} - -// Update function for loginModel -func (m loginModel) Update(msg tea.Msg) (loginModel, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.Type { - case tea.KeyUp: - m.focus = (m.focus - 1) % 2 - if m.focus < 0 { - m.focus = 1 - } - m.updateFocus() - case tea.KeyDown: - m.focus = (m.focus + 1) % 2 - m.updateFocus() - case tea.KeyEnter: - if !m.isLoading { - m.isLoading = true - return m, m.loginCallback() - } - case tea.KeyTab: - m.focus = (m.focus + 1) % 2 - m.updateFocus() - } - case authResponse: - m.isLoading = false - if msg.Error != "" { - m.err = fmt.Errorf(msg.Error) - m.focus = 0 - m.updateFocus() - } else { - m.token = msg.Token - ClearScreen() - f, err := os.OpenFile(".rahannarc", os.O_WRONLY|os.O_CREATE, 0644) - if err != nil { - m.err = err - break - } - defer f.Close() - f.Write([]byte(m.token)) - return m, SwitchModelCmd(NewPlayModel(m.width, m.height)) - } - case error: - m.isLoading = false - m.err = msg - m.focus = 0 - m.updateFocus() - } - - var cmd tea.Cmd - m.username, cmd = m.username.Update(msg) - cmdPassword := tea.Batch(cmd) - m.password, cmd = m.password.Update(msg) - return m, tea.Batch(cmd, cmdPassword) -} - -// Update function for signupModel -func (m signupModel) Update(msg tea.Msg) (signupModel, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.Type { - case tea.KeyUp: - m.focus = (m.focus - 1) % 3 - if m.focus < 0 { - m.focus = 2 - } - m.updateFocus() - case tea.KeyDown: - m.focus = (m.focus + 1) % 3 - m.updateFocus() - case tea.KeyEnter: - if !m.isLoading { - m.isLoading = true - return m, m.signupCallback() - } - case tea.KeyTab: - m.focus = (m.focus + 1) % 3 - m.updateFocus() - } - case authResponse: - m.isLoading = false - if msg.Error != "" { - m.err = fmt.Errorf(msg.Error) - m.focus = 0 - m.updateFocus() - } else { - m.token = msg.Token - ClearScreen() - f, err := os.OpenFile(".rahannarc", os.O_WRONLY|os.O_CREATE, 0644) - if err != nil { - m.err = err - break - } - defer f.Close() - f.Write([]byte(m.token)) - return m, SwitchModelCmd(NewPlayModel(m.width, m.height)) - } - case error: - m.isLoading = false - m.err = msg - m.focus = 0 - m.updateFocus() - } - - var cmds []tea.Cmd - var cmd tea.Cmd - - m.username, cmd = m.username.Update(msg) - cmds = append(cmds, cmd) - - m.password, cmd = m.password.Update(msg) - cmds = append(cmds, cmd) - - m.confirmPassword, cmd = m.confirmPassword.Update(msg) - cmds = append(cmds, cmd) - - return m, tea.Batch(cmds...) -} - -// Helper function to update input focus for signup -func (m *signupModel) updateFocus() { - m.username.Blur() - m.password.Blur() - m.confirmPassword.Blur() - - switch m.focus { - case 0: - m.username.Focus() - case 1: - m.password.Focus() - case 2: - m.confirmPassword.Focus() - } -} - -// Helper function to update input focus for signin -func (m *loginModel) updateFocus() { - m.username.Blur() - m.password.Blur() - - switch m.focus { - case 0: - m.username.Focus() - case 1: - m.password.Focus() - } -} - -// Login API callback -func (m loginModel) loginCallback() tea.Cmd { - return func() tea.Msg { - url := os.Getenv("API_BASE") + "/auth/login" - - payload, err := json.Marshal(map[string]string{ - "username": m.username.Value(), - "password": m.password.Value(), - }) - - if err != nil { - return authResponse{Error: err.Error()} - } - - resp, err := http.Post(url, "application/json", bytes.NewReader(payload)) - if err != nil { - return authResponse{Error: err.Error()} - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - var response authResponse - err = json.NewDecoder(resp.Body).Decode(&response) - if err != nil { - return authResponse{Error: fmt.Sprintf("HTTP error: %d, unable to decode body", resp.StatusCode)} - } - return authResponse{Error: response.Error} - } - - var response authResponse - err = json.NewDecoder(resp.Body).Decode(&response) - if err != nil { - return authResponse{Error: fmt.Sprintf("Error decoding JSON: %v", err)} - } - - return response - } -} - -// Signup API callback -func (m signupModel) signupCallback() tea.Cmd { - return func() tea.Msg { - // Validate that passwords match - if m.password.Value() != m.confirmPassword.Value() { - return authResponse{Error: "Passwords do not match"} - } - - url := os.Getenv("API_BASE") + "/auth/register" - - payload, err := json.Marshal(map[string]string{ - "username": m.username.Value(), - "password": m.password.Value(), - }) - - if err != nil { - return authResponse{Error: err.Error()} - } - - resp, err := http.Post(url, "application/json", bytes.NewReader(payload)) - if err != nil { - return authResponse{Error: err.Error()} - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { - var response authResponse - err = json.NewDecoder(resp.Body).Decode(&response) - if err != nil { - return authResponse{Error: fmt.Sprintf("HTTP error: %d, unable to decode body", resp.StatusCode)} - } - return authResponse{Error: response.Error} - } - - var response authResponse - err = json.NewDecoder(resp.Body).Decode(&response) - if err != nil { - return authResponse{Error: fmt.Sprintf("Error decoding JSON: %v", err)} - } - - return response - } -} - -// Render content of the login tab -func (m loginModel) renderContent() string { - formWidth := getFormWidth(m.width) - - // Styles - titleStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#7ee2a8")). - Align(lipgloss.Center). - Width(formWidth - 4) // Account for padding - - labelStyle := lipgloss.NewStyle(). - Width(10). - Align(lipgloss.Right) - - inputWrapStyle := lipgloss.NewStyle(). - Align(lipgloss.Center). - Width(formWidth - 4) // Account for padding - - statusStyle := lipgloss.NewStyle(). - Align(lipgloss.Center). - Bold(true). - Width(formWidth - 4) // Account for padding - - // Error message - formError := "" - if m.err != nil { - formError = fmt.Sprintf("Error: %v", m.err.Error()) - } - - // Status message - statusMsg := fmt.Sprintf("Press %s to login", lipgloss.NewStyle().Italic(true).Render("Enter")) - if m.isLoading { - statusMsg = "Logging in..." - } - - form := lipgloss.JoinVertical(lipgloss.Center, - titleStyle.Render("Sign in to your account"), - "\n", - errorStyle.Align(lipgloss.Center).Width(formWidth-4).Render(formError), - inputWrapStyle.Render( - lipgloss.JoinHorizontal(lipgloss.Left, - labelStyle.Render("Username:"), - m.username.View(), - ), - ), - inputWrapStyle.Render( - lipgloss.JoinHorizontal(lipgloss.Left, - labelStyle.Render("Password:"), - m.password.View(), - ), - ), - "\n", - statusStyle.Render(statusMsg), - ) - - return form -} - -// Render content of the signup tab -func (m signupModel) renderContent() string { - formWidth := getFormWidth(m.width) - - // Styles - titleStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#7ee2a8")). - Align(lipgloss.Center). - Width(formWidth - 4) // Account for padding - - labelStyle := lipgloss.NewStyle(). - Width(16). - Align(lipgloss.Right) - - inputWrapStyle := lipgloss.NewStyle(). - Align(lipgloss.Center). - Width(formWidth - 4) // Account for padding - - statusStyle := lipgloss.NewStyle(). - Align(lipgloss.Center). - Bold(true). - Width(formWidth - 4) // Account for padding - - // Error message - formError := "" - if m.err != nil { - formError = fmt.Sprintf("Error: %v", m.err.Error()) - } - - // Status message - statusMsg := fmt.Sprintf("Press %s to register", lipgloss.NewStyle().Italic(true).Render("Enter")) - if m.isLoading { - statusMsg = "Creating account..." - } - - form := lipgloss.JoinVertical(lipgloss.Center, - titleStyle.Render("Create a new account"), - "\n", - errorStyle.Align(lipgloss.Center).Width(formWidth-4).Render(formError), - inputWrapStyle.Render( - lipgloss.JoinHorizontal(lipgloss.Left, - labelStyle.Render("Username:"), - m.username.View(), - ), - ), - inputWrapStyle.Render( - lipgloss.JoinHorizontal(lipgloss.Left, - labelStyle.Render("Password:"), - m.password.View(), - ), - ), - inputWrapStyle.Render( - lipgloss.JoinHorizontal(lipgloss.Left, - labelStyle.Render("Confirm:"), - m.confirmPassword.View(), - ), - ), - "\n", - statusStyle.Render(statusMsg), - ) - - return form -} diff --git a/ui/views/play.go b/ui/views/play.go deleted file mode 100644 index 389c302..0000000 --- a/ui/views/play.go +++ /dev/null @@ -1,427 +0,0 @@ -package views - -import ( - "bufio" - "bytes" - "encoding/json" - "fmt" - "net/http" - "os" - - "github.com/boozec/rahanna/api/database" - "github.com/boozec/rahanna/network" - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -var chess string = ` - A B C D E F G H -+---------------+ -8 |♜ ♞ ♝ ♛ ♚ ♝ ♞ ♜| 8 -7 |♟ ♟ ♟ ♟ ♟ ♟ ♟ ♟| 7 -6 |. . . . . . . .| 6 -5 |. . . . . . . .| 5 -4 |. . . . . . . .| 4 -3 |. . . . . . . .| 3 -2 |♙ ♙ ♙ ♙ ♙ ♙ ♙ ♙| 2 -1 |♖ ♘ ♗ ♕ ♔ ♗ ♘ ♖| 1 -+---------------+ - A B C D E F G H -` - -type playKeyMap struct { - EnterNewGame key.Binding - StartNewGame key.Binding - GoLogout key.Binding - Quit key.Binding -} - -type playResponse struct { - Name string `json:"name"` - Error string `json:"error"` -} - -var defaultGameKeyMap = playKeyMap{ - EnterNewGame: key.NewBinding( - key.WithKeys("alt+E", "alt+e"), - key.WithHelp("Alt+E", "Enter a play using code"), - ), - StartNewGame: key.NewBinding( - key.WithKeys("alt+s", "alt+s"), - key.WithHelp("Alt+S", "Start a new play"), - ), - GoLogout: key.NewBinding( - key.WithKeys("alt+Q", "alt+q"), - key.WithHelp("Alt+Q", "Logout"), - ), - Quit: key.NewBinding( - key.WithKeys("Q", "q"), - key.WithHelp(" Q", "Quit"), - ), -} - -type PlayModelPage int - -const ( - LandingPage PlayModelPage = iota - InsertCodePage - StartGamePage -) - -type PlayModel struct { - width int - height int - err error - keys playKeyMap - namePrompt textinput.Model - page PlayModelPage - isLoading bool - playName string - play *database.Game -} - -func NewPlayModel(width, height int) PlayModel { - namePrompt := textinput.New() - namePrompt.Prompt = " " - namePrompt.TextStyle = inputStyle - namePrompt.Placeholder = "rectangular-lake" - namePrompt.Focus() - namePrompt.CharLimit = 23 - namePrompt.Width = 23 - - return PlayModel{ - width: width, - height: height, - err: nil, - keys: defaultGameKeyMap, - namePrompt: namePrompt, - page: LandingPage, - isLoading: false, - playName: "", - play: nil, - } -} - -func (m PlayModel) Init() tea.Cmd { - ClearScreen() - return textinput.Blink -} - -func (m PlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - if exit := handleExit(msg); exit != nil { - return m, exit - } - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - return m, nil - - case tea.KeyMsg: - switch { - case key.Matches(msg, m.keys.EnterNewGame): - m.page = InsertCodePage - return m, nil - case key.Matches(msg, m.keys.StartNewGame): - m.page = StartGamePage - if !m.isLoading { - m.isLoading = true - return m, m.newGameCallback() - } - case key.Matches(msg, m.keys.GoLogout): - return m, m.logout() - case key.Matches(msg, m.keys.Quit): - return m, tea.Quit - case msg.Type == tea.KeyEnter: - if m.page == InsertCodePage { - if !m.isLoading { - m.isLoading = true - return m, m.enterGame() - } - } - } - case playResponse: - m.isLoading = false - m.err = nil - if msg.Error != "" { - m.err = fmt.Errorf(msg.Error) - if msg.Error == "unauthorized" { - return m, m.logout() - } - } else { - m.playName = msg.Name - } - return m, nil - case database.Game: - m.isLoading = false - m.play = &msg - m.err = nil - return m, nil - case error: - m.isLoading = false - m.err = msg - } - - var cmd tea.Cmd = nil - - if m.page == InsertCodePage { - m.namePrompt, cmd = m.namePrompt.Update(msg) - } - - return m, tea.Batch(cmd) -} - -func (m PlayModel) View() string { - formWidth := getFormWidth(m.width) - - var content string - base := lipgloss.NewStyle().Align(lipgloss.Center).Width(m.width) - - switch m.page { - case LandingPage: - content = chess - m.namePrompt.Blur() - case InsertCodePage: - m.namePrompt.Focus() - var statusMsg string - if m.isLoading { - statusMsg = "Loading..." - content = base. - Render( - lipgloss.NewStyle(). - Align(lipgloss.Center). - Bold(true). - Render(statusMsg), - ) - } else if m.play != nil { - statusMsg = fmt.Sprintf("You are playing versus %s", lipgloss.NewStyle().Foreground(lipgloss.Color("#e67e22")).Render(m.play.Player1.Username)) - content = base. - Render( - lipgloss.NewStyle(). - Align(lipgloss.Center). - Width(m.width). - Bold(true). - Render(statusMsg), - ) - } else { - statusMsg = fmt.Sprintf("Press %s to join", lipgloss.NewStyle().Italic(true).Render("Enter")) - content = base. - Render( - lipgloss.JoinVertical(lipgloss.Left, - lipgloss.NewStyle().Width(23).Render("Insert play code:"), - m.namePrompt.View(), - lipgloss.NewStyle(). - Align(lipgloss.Center). - PaddingTop(2). - Width(23). - Bold(true). - Render(statusMsg), - ), - ) - } - - case StartGamePage: - var statusMsg string - if m.isLoading { - statusMsg = "Loading..." - } else if m.playName != "" { - statusMsg = fmt.Sprintf("Share `%s` to your friend", lipgloss.NewStyle().Italic(true).Foreground(lipgloss.Color("#F39C12")).Render(m.playName)) - } - - content = base. - Render(statusMsg) - } - - var windowContent string - if m.err != nil { - formError := fmt.Sprintf("Error: %v", m.err.Error()) - windowContent = lipgloss.JoinVertical( - lipgloss.Center, - windowStyle.Width(formWidth).Render(lipgloss.JoinVertical( - lipgloss.Center, - errorStyle.Align(lipgloss.Center).Width(formWidth-4).Render(formError), - content, - )), - ) - } else { - windowContent = lipgloss.JoinVertical( - lipgloss.Center, - windowStyle.Width(formWidth).Render(lipgloss.JoinVertical( - lipgloss.Center, - content, - )), - ) - } - - enterKey := fmt.Sprintf("%s %s", altCodeStyle.Render(m.keys.EnterNewGame.Help().Key), m.keys.EnterNewGame.Help().Desc) - startKey := fmt.Sprintf("%s %s", altCodeStyle.Render(m.keys.StartNewGame.Help().Key), m.keys.StartNewGame.Help().Desc) - logoutKey := fmt.Sprintf("%s %s", altCodeStyle.Render(m.keys.GoLogout.Help().Key), m.keys.GoLogout.Help().Desc) - quitKey := fmt.Sprintf("%s %s", altCodeStyle.Render(m.keys.Quit.Help().Key), m.keys.Quit.Help().Desc) - - // Vertically align the buttons - buttons := lipgloss.JoinVertical( - lipgloss.Left, - enterKey, - startKey, - logoutKey, - quitKey, - ) - - centeredContent := lipgloss.JoinVertical( - lipgloss.Center, - getLogo(m.width), - windowContent, - lipgloss.NewStyle().MarginTop(2).Render(buttons), - ) - - return lipgloss.Place( - m.width, - m.height, - lipgloss.Center, - lipgloss.Center, - centeredContent, - ) -} - -func (m PlayModel) newGameCallback() tea.Cmd { - return func() tea.Msg { - f, err := os.Open(".rahannarc") - if err != nil { - return playResponse{Error: err.Error()} - } - defer f.Close() - - scanner := bufio.NewScanner(f) - var authorization string - for scanner.Scan() { - authorization = scanner.Text() - } - - if err := scanner.Err(); err != nil { - fmt.Println("Error during scanning:", err) - } - - url := os.Getenv("API_BASE") + "/play" - - port, err := network.GetRandomAvailablePort() - if err != nil { - return playResponse{Error: err.Error()} - } - - payload, err := json.Marshal(map[string]string{ - "ip": fmt.Sprintf("%s:%d", network.GetOutboundIP().String(), port), - }) - - if err != nil { - return playResponse{Error: err.Error()} - } - - req, err := http.NewRequest("POST", url, bytes.NewReader(payload)) - if err != nil { - return playResponse{Error: err.Error()} - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", authorization)) - - client := &http.Client{} - - resp, err := client.Do(req) - - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - var response playResponse - err = json.NewDecoder(resp.Body).Decode(&response) - if err != nil { - return playResponse{Error: fmt.Sprintf("HTTP error: %d, unable to decode body", resp.StatusCode)} - } - return playResponse{Error: response.Error} - } - - var response playResponse - err = json.NewDecoder(resp.Body).Decode(&response) - if err != nil { - return playResponse{Error: fmt.Sprintf("Error decoding JSON: %v", err)} - } - - return response - } -} - -func (m PlayModel) enterGame() tea.Cmd { - return func() tea.Msg { - f, err := os.Open(".rahannarc") - if err != nil { - return playResponse{Error: err.Error()} - } - defer f.Close() - - scanner := bufio.NewScanner(f) - var authorization string - for scanner.Scan() { - authorization = scanner.Text() - } - - if err := scanner.Err(); err != nil { - fmt.Println("Error during scanning:", err) - } - - url := os.Getenv("API_BASE") + "/enter-game" - - port, err := network.GetRandomAvailablePort() - if err != nil { - return playResponse{Error: err.Error()} - } - - payload, err := json.Marshal(map[string]string{ - "ip": fmt.Sprintf("%s:%d", network.GetOutboundIP().String(), port), - "name": m.namePrompt.Value(), - }) - - if err != nil { - return playResponse{Error: err.Error()} - } - - req, err := http.NewRequest("POST", url, bytes.NewReader(payload)) - if err != nil { - return playResponse{Error: err.Error()} - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", authorization)) - - client := &http.Client{} - - resp, err := client.Do(req) - - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - var response playResponse - err = json.NewDecoder(resp.Body).Decode(&response) - if err != nil { - return playResponse{Error: fmt.Sprintf("HTTP error: %d, unable to decode body", resp.StatusCode)} - } - return playResponse{Error: response.Error} - } - - var response database.Game - err = json.NewDecoder(resp.Body).Decode(&response) - if err != nil { - return playResponse{Error: fmt.Sprintf("Error decoding JSON: %v", err)} - } - - return response - } -} - -func (m PlayModel) logout() tea.Cmd { - if err := os.Remove(".rahannarc"); err != nil { - return nil - } - return SwitchModelCmd(NewAuthModel(m.width, m.height+1)) -} diff --git a/ui/views/tabs.go b/ui/views/tabs.go deleted file mode 100644 index 13e3672..0000000 --- a/ui/views/tabs.go +++ /dev/null @@ -1,32 +0,0 @@ -package views - -import ( - "fmt" - - "github.com/charmbracelet/lipgloss" -) - -type TabType int - -var ( - tabStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(highlightColor).Padding(0, 2) - inactiveTabStyle = tabStyle - activeTabStyle = tabStyle -) - -func getTabsRow(tabsText []string, activeTab TabType) string { - tabs := make([]string, len(tabsText)) - - for i, tab := range tabsText { - if TabType(i) == activeTab { - tabs[i] = fmt.Sprintf("%s %s", altCodeStyle.Render(fmt.Sprintf("Alt+%d", i+1)), lipgloss.NewStyle().Bold(true).Foreground(highlightColor).Render(tab)) - tabs[i] = activeTabStyle.Foreground(highlightColor).Render(tabs[i]) - } else { - tabs[i] = fmt.Sprintf("%s %s", altCodeStyle.Render(fmt.Sprintf("Alt+%d", i+1)), lipgloss.NewStyle().Render(tab)) - tabs[i] = inactiveTabStyle.Foreground(highlightColor).Render(tabs[i]) - } - } - - return lipgloss.JoinHorizontal(lipgloss.Top, tabs...) - -} diff --git a/ui/views/views.go b/ui/views/views.go deleted file mode 100644 index fa70035..0000000 --- a/ui/views/views.go +++ /dev/null @@ -1,144 +0,0 @@ -package views - -import ( - "errors" - "os" - - "os/exec" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "golang.org/x/term" -) - -var logo = ` -▗▄▄▖ ▗▄▖ ▗▖ ▗▖ ▗▄▖ ▗▖ ▗▖▗▖ ▗▖ ▗▄▖ -▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▛▚▖▐▌▐▛▚▖▐▌▐▌ ▐▌ -▐▛▀▚▖▐▛▀▜▌▐▛▀▜▌▐▛▀▜▌▐▌ ▝▜▌▐▌ ▝▜▌▐▛▀▜▌ -▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌ -` - -var ( - highlightColor = lipgloss.Color("#7ee2a8") - errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#ff0000")) - altCodeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Bold(true) - windowStyle = lipgloss.NewStyle().BorderForeground(highlightColor).Padding(2, 0).Align(lipgloss.Center).Border(lipgloss.RoundedBorder()) - inputStyle = lipgloss.NewStyle().Foreground(highlightColor) -) - -// Get terminal size dynamically -func GetTerminalSize() (width, height int) { - fd := int(os.Stdin.Fd()) - if w, h, err := term.GetSize(fd); err == nil { - return w, h - } - return 80, 24 // Default size if detection fails -} - -// Clear terminal screen -func ClearScreen() { - if len(os.Getenv("DEBUG")) == 0 { - cmd := exec.Command("clear") - if os.Getenv("OS") == "Windows_NT" { - cmd = exec.Command("cmd", "/c", "cls") - } - cmd.Stdout = os.Stdout - cmd.Run() - } -} - -func getFormWidth(width int) int { - formWidth := width * 2 / 3 - if formWidth > 80 { - formWidth = 80 // Cap at 80 chars for readability - } else if formWidth < 40 { - formWidth = width - 4 // For small terminals - } - - return formWidth -} - -type RahannaModel struct { - width int - height int - currentModel tea.Model - auth AuthModel - play PlayModel -} - -func NewRahannaModel() RahannaModel { - width, height := GetTerminalSize() - - auth := NewAuthModel(width, height) - play := NewPlayModel(width, height) - - var currentModel tea.Model = auth - - if _, err := os.Stat(".rahannarc"); !errors.Is(err, os.ErrNotExist) { - currentModel = play - } - - return RahannaModel{ - width: width, - height: height, - currentModel: currentModel, - auth: auth, - play: play, - } -} - -func (m RahannaModel) Init() tea.Cmd { - return m.currentModel.Init() -} - -type switchModel struct { - model tea.Model -} - -func SwitchModelCmd(model tea.Model) tea.Cmd { - s := switchModel{ - model: model, - } - - return func() tea.Msg { - return s - } -} - -func (m RahannaModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case switchModel: - m.currentModel = msg.model - return m, nil - } - var cmd tea.Cmd - m.currentModel, cmd = m.currentModel.Update(msg) - return m, cmd -} - -func (m RahannaModel) View() string { - return m.currentModel.View() -} - -func handleExit(msg tea.Msg) tea.Cmd { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "ctrl+c": - return tea.Quit - } - } - - return nil -} - -func getLogo(width int) string { - logoStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#7ee2a8")). - Bold(true). - Align(lipgloss.Center). - Width(width) - - return logoStyle.Render(logo) - -} -- cgit v1.2.3-18-g5258