diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2cd3ac5..39df922 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,6 @@ jobs: - name: Scan Bubbletea Code for Vulnerabilities (gosec) run: | cd bubbletea - # Exclude test files from security scan gosec -exclude-dir=tests ./... - name: Scan Tview Code for Vulnerabilities (gosec) @@ -54,7 +53,7 @@ jobs: fail-fast: false matrix: os: [ ubuntu-latest, windows-latest, macos-latest ] - module: [ bubbletea, tview ] + module: [ bubbletea ] steps: - name: Checkout Codebase uses: actions/checkout@v4 @@ -89,4 +88,4 @@ jobs: - name: Test Build compilation run: | cd ${{ matrix.module }} - go build -v -o ropa-sci-test-build ./... + go build -v ./... diff --git a/bubbletea/cmd/main.go b/bubbletea/cmd/main.go index 05f6df2..e89fc45 100644 --- a/bubbletea/cmd/main.go +++ b/bubbletea/cmd/main.go @@ -18,6 +18,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/gorilla/websocket" ) // ─── Spinner & AI message types ─────────────────────────────────────────────── @@ -63,17 +64,13 @@ func (m model) aiThinkCmd() tea.Cmd { } // readNetMsg reads a single WebSocket message in a background Bubbletea thread -func readNetMsg(conn net.Conn) tea.Cmd { +func readNetMsg(conn *websocket.Conn) tea.Cmd { return func() tea.Msg { if conn == nil { return wsErrMsg{err: fmt.Errorf("connection is closed")} } - payload, err := server.ReadWSFrame(conn) - if err != nil { - return wsErrMsg{err: err} - } var wsMsg server.WSMessage - if err := json.Unmarshal(payload, &wsMsg); err != nil { + if err := conn.ReadJSON(&wsMsg); err != nil { return wsErrMsg{err: err} } return wsMsgMsg{msg: wsMsg} @@ -132,7 +129,7 @@ type model struct { state models.GameState aiEngine *models.AIEngine // runtime AI engine lanServer *server.LANGameServer // P2P Host Server - wsConn net.Conn // active WebSocket client conn + wsConn *websocket.Conn // active WebSocket client conn netOpponentName string // remote player name nextWinsHost int // temporary host score buffer nextWinsGuest int // temporary guest score buffer @@ -270,7 +267,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.state.FormError = msg.msg.Payload m.state.Screen = "multi-menu" if m.wsConn != nil { - m.wsConn.Close() + _ = m.wsConn.Close() m.wsConn = nil } if m.lanServer != nil { @@ -287,7 +284,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.state.FormError = "Connection error: " + msg.err.Error() m.state.Screen = "multi-menu" if m.wsConn != nil { - m.wsConn.Close() + _ = m.wsConn.Close() m.wsConn = nil } if m.lanServer != nil { @@ -374,7 +371,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.state.Screen = "multi-menu" m.state.Cursor = 0 if m.wsConn != nil { - m.wsConn.Close() + _ = m.wsConn.Close() m.wsConn = nil } if m.lanServer != nil { @@ -485,7 +482,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.state.FormError = "" m.state.RoomCode = "" if m.wsConn != nil { - m.wsConn.Close() + _ = m.wsConn.Close() m.wsConn = nil } if m.lanServer != nil { @@ -505,7 +502,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.state.Score = models.MatchScore{Round: 1} m.state.Phase = models.PhasePick if m.wsConn != nil { - m.wsConn.Close() + _ = m.wsConn.Close() m.wsConn = nil } if m.lanServer != nil { @@ -969,7 +966,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.state.Screen = "multi-menu" m.state.Cursor = 0 if m.wsConn != nil { - m.wsConn.Close() + _ = m.wsConn.Close() m.wsConn = nil } if m.lanServer != nil { @@ -1806,9 +1803,9 @@ func renderDifficulty(m model) string { for i, opt := range options { if m.state.Cursor == i { - s += ui.SelectedStyle.Render(" ▸ " + opt) + "\n" + s += ui.SelectedStyle.Render(" ▸ "+opt) + "\n" } else { - s += ui.MutedStyle.Render(" " + opt) + "\n" + s += ui.MutedStyle.Render(" "+opt) + "\n" } } s += "\n" + ui.Footer("↑/↓ navigate · Enter select · Esc cancel") diff --git a/bubbletea/go.mod b/bubbletea/go.mod index 212548d..41f5c42 100644 --- a/bubbletea/go.mod +++ b/bubbletea/go.mod @@ -5,6 +5,7 @@ go 1.26.2 require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/gorilla/websocket v1.5.3 golang.org/x/text v0.37.0 ) diff --git a/bubbletea/go.sum b/bubbletea/go.sum index b983b09..ab59186 100644 --- a/bubbletea/go.sum +++ b/bubbletea/go.sum @@ -14,6 +14,8 @@ github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQ github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 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/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= diff --git a/bubbletea/models/ai_engine.go b/bubbletea/models/ai_engine.go index 7d81bb2..a9a7294 100644 --- a/bubbletea/models/ai_engine.go +++ b/bubbletea/models/ai_engine.go @@ -16,11 +16,11 @@ const ( // AIEngine handles prediction calculations and pattern tracking type AIEngine struct { - Difficulty AIDifficulty - History []Move // Sequence of moves the player has chosen - TransitionMap map[Move]map[Move]int // Transition counts: [PrevMove][NextMove]Count - lastAIMove Move - rng *rand.Rand + Difficulty AIDifficulty + History []Move // Sequence of moves the player has chosen + TransitionMap map[Move]map[Move]int // Transition counts: [PrevMove][NextMove]Count + lastAIMove Move + rng *rand.Rand } // NewAIEngine creates a new initialized AI opponent @@ -30,7 +30,7 @@ func NewAIEngine(difficulty AIDifficulty) *AIEngine { History: []Move{}, TransitionMap: make(map[Move]map[Move]int), lastAIMove: None, - rng: rand.New(rand.NewSource(time.Now().UnixNano())), + rng: rand.New(rand.NewSource(time.Now().UnixNano())), // #nosec G404 - game AI does not need crypto RNG } } diff --git a/bubbletea/models/logger.go b/bubbletea/models/logger.go index 37c5023..bac4e61 100644 --- a/bubbletea/models/logger.go +++ b/bubbletea/models/logger.go @@ -15,12 +15,12 @@ var logFile *os.File // Returns a function that closes the log file upon termination. func InitLogger() (func(), error) { logDir := "logs" - if err := os.MkdirAll(logDir, 0755); err != nil { + if err := os.MkdirAll(logDir, 0750); err != nil { return nil, fmt.Errorf("failed to create logs directory: %w", err) } logPath := filepath.Join(logDir, "app.log") - file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + file, err := os.OpenFile(filepath.Clean(logPath), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) if err != nil { return nil, fmt.Errorf("failed to open log file: %w", err) } @@ -38,7 +38,7 @@ func InitLogger() (func(), error) { cleanup := func() { slog.Info("Structured logger shutting down") if logFile != nil { - logFile.Close() + _ = logFile.Close() } } return cleanup, nil diff --git a/bubbletea/models/player.go b/bubbletea/models/player.go index aed0834..5b9f153 100644 --- a/bubbletea/models/player.go +++ b/bubbletea/models/player.go @@ -62,15 +62,15 @@ type GameState struct { GameMode string // handles for "single" & "multi" RoomCode string // generated room code for Create Room mode - Phase GamePhase // current game phase - PlayerMove Move // what the player picked - AIMove Move // what the AI picked - SpinnerFrame int // which spinner frame to show - RoundOutcome string // "win", "lose", "tie" + Phase GamePhase // current game phase + PlayerMove Move // what the player picked + AIMove Move // what the AI picked + SpinnerFrame int // which spinner frame to show + RoundOutcome string // "win", "lose", "tie" // Navigation - Cursor int // tracks which menu option is highlighted - ActiveField int // This tracks which field is active during registration + Cursor int // tracks which menu option is highlighted + ActiveField int // This tracks which field is active during registration PreviousScreen string // tracks where to go back to // Form handling @@ -79,8 +79,8 @@ type GameState struct { StateSuggestions []State // Terminal dimensions — updated on every resize event - TermWidth int - TermHeight int + TermWidth int + TermHeight int // Admin dashboard state AdminPlayers []Player // cached list of all players for the admin view @@ -92,8 +92,8 @@ type GameState struct { type GamePhase string const ( - PhasePick GamePhase = "pick" // player choosing - PhaseThink GamePhase = "think" // AI spinner - PhaseReveal GamePhase = "reveal" // both cards shown - PhaseResult GamePhase = "result" // win/lose/tie shown -) \ No newline at end of file + PhasePick GamePhase = "pick" // player choosing + PhaseThink GamePhase = "think" // AI spinner + PhaseReveal GamePhase = "reveal" // both cards shown + PhaseResult GamePhase = "result" // win/lose/tie shown +) diff --git a/bubbletea/models/storage.go b/bubbletea/models/storage.go index e1650e7..8d3bf61 100644 --- a/bubbletea/models/storage.go +++ b/bubbletea/models/storage.go @@ -40,7 +40,7 @@ func SavePlayer(p Player) error { defer dbMutex.Unlock() // Create data/ folder first if it doesn't exist - if err := os.MkdirAll("data", 0755); err != nil { + if err := os.MkdirAll("data", 0750); err != nil { return fmt.Errorf("could not create data folder: %w", err) } @@ -145,7 +145,7 @@ func UpdatePlayer(p Player) error { // GenerateRoomCode creates a random 4-digit room code func GenerateRoomCode() string { - digits := rand.Intn(9000) + 1000 // always 4 digits: 1000-9999 + digits := rand.Intn(9000) + 1000 // #nosec G404 - room codes are not security-critical return fmt.Sprintf("RPS-%d", digits) } @@ -212,7 +212,7 @@ func ResetPlayerStats(username string) error { return err } - return os.WriteFile(filepath.Clean(dataFile), data, 0644) + return os.WriteFile(filepath.Clean(dataFile), data, 0600) } // SetPlayerRole changes the role of a player thread-safely @@ -243,5 +243,5 @@ func SetPlayerRole(username, role string) error { return err } - return os.WriteFile(filepath.Clean(dataFile), data, 0644) -} \ No newline at end of file + return os.WriteFile(filepath.Clean(dataFile), data, 0600) +} diff --git a/bubbletea/models/validation_test.go b/bubbletea/models/validation_test.go index 7a4dacb..5328a85 100644 --- a/bubbletea/models/validation_test.go +++ b/bubbletea/models/validation_test.go @@ -116,7 +116,7 @@ func TestValidateUsername(t *testing.T) { {"john_doe-12", ""}, {"bo", "Username must be at least 3 characters"}, // Below min boundary {"", "Username must be at least 3 characters"}, - {"abcdefghijklmnopqrstuv", "Username must be 20 characters or less"}, // Above max boundary (22 chars) + {"abcdefghijklmnopqrstuv", "Username must be 20 characters or less"}, // Above max boundary (22 chars) {"John_doe", "Only lowercase letters, numbers, underscores, and hyphens"}, // Uppercase not allowed {"john doe", "Only lowercase letters, numbers, underscores, and hyphens"}, } diff --git a/bubbletea/server/lan_client.go b/bubbletea/server/lan_client.go index 14b5631..f7d41c7 100644 --- a/bubbletea/server/lan_client.go +++ b/bubbletea/server/lan_client.go @@ -1,104 +1,30 @@ package server import ( - "bufio" - "crypto/rand" - "encoding/base64" - "encoding/binary" - "encoding/json" "fmt" - "net" - "net/http" + + "github.com/gorilla/websocket" ) -// DialLANServer establishes a TCP connection to the host address and performs WebSocket handshake -func DialLANServer(addr string) (net.Conn, error) { - conn, err := net.Dial("tcp", addr) +// DialLANServer establishes a WebSocket connection to the host address +func DialLANServer(addr string) (*websocket.Conn, error) { + wsURL := fmt.Sprintf("ws://%s/ws", addr) + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) if err != nil { return nil, fmt.Errorf("failed to connect to host %s: %w", addr, err) } - // Generate a random 16-byte base64 key - keyBytes := make([]byte, 16) - if _, err := rand.Read(keyBytes); err != nil { - conn.Close() - return nil, fmt.Errorf("failed to generate random secret: %w", err) - } - secKey := base64.StdEncoding.EncodeToString(keyBytes) - - // Send WebSocket upgrade HTTP request - req := fmt.Sprintf("GET /ws HTTP/1.1\r\n"+ - "Host: %s\r\n"+ - "Upgrade: websocket\r\n"+ - "Connection: Upgrade\r\n"+ - "Sec-WebSocket-Key: %s\r\n"+ - "Sec-WebSocket-Version: 13\r\n\r\n", addr, secKey) - - if _, err := conn.Write([]byte(req)); err != nil { - conn.Close() - return nil, fmt.Errorf("failed to send upgrade request: %w", err) - } - - // Read upgrade response - resp, err := http.ReadResponse(bufio.NewReader(conn), nil) - if err != nil { - conn.Close() - return nil, fmt.Errorf("failed to read upgrade response: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 101 { - conn.Close() - return nil, fmt.Errorf("websocket upgrade rejected: status code %d", resp.StatusCode) - } - return conn, nil } -// WriteClientMessage writes a masked text WebSocket frame to the connection -func WriteClientMessage(conn net.Conn, msg WSMessage) error { - data, err := json.Marshal(msg) - if err != nil { - return fmt.Errorf("failed to marshal WS message: %w", err) - } - - var header []byte - header = append(header, 0x81) // FIN + Text frame type - - length := len(data) - if length <= 125 { - header = append(header, byte(length)|0x80) // Set Mask bit (0x80) - } else if length <= 65535 { - header = append(header, 126|0x80) - lenBytes := make([]byte, 2) - binary.BigEndian.PutUint16(lenBytes, uint16(length)) - header = append(header, lenBytes...) - } else { - header = append(header, 127|0x80) - lenBytes := make([]byte, 8) - binary.BigEndian.PutUint64(lenBytes, uint64(length)) - header = append(header, lenBytes...) +// WriteClientMessage writes a JSON WebSocket message to the connection +func WriteClientMessage(conn *websocket.Conn, msg WSMessage) error { + if conn == nil { + return fmt.Errorf("connection is nil") } - // Generate 4-byte random masking key (required for client-to-server frames) - maskKey := make([]byte, 4) - if _, err := rand.Read(maskKey); err != nil { - return fmt.Errorf("failed to generate masking key: %w", err) - } - header = append(header, maskKey...) - - // Mask payload - maskedPayload := make([]byte, length) - for i := 0; i < length; i++ { - maskedPayload[i] = data[i] ^ maskKey[i%4] - } - - // Write header then masked payload - if _, err := conn.Write(header); err != nil { - return fmt.Errorf("failed to write frame header: %w", err) - } - if _, err := conn.Write(maskedPayload); err != nil { - return fmt.Errorf("failed to write frame payload: %w", err) + if err := conn.WriteJSON(msg); err != nil { + return fmt.Errorf("failed to write WS message: %w", err) } return nil diff --git a/bubbletea/server/lan_server.go b/bubbletea/server/lan_server.go index eb34ca5..0e81367 100644 --- a/bubbletea/server/lan_server.go +++ b/bubbletea/server/lan_server.go @@ -1,18 +1,15 @@ package server import ( - "bufio" - "crypto/sha1" /* #nosec G505 - required by RFC 6455 for WebSocket handshake */ - "encoding/base64" - "encoding/binary" "encoding/json" "fmt" - "io" "log/slog" "net" "net/http" - "strings" "sync" + "time" + + "github.com/gorilla/websocket" ) // WSMessage represents the structured JSON packets sent over the P2P connection @@ -33,13 +30,14 @@ type RoundOutcomePayload struct { // LANGameServer coordinates a single P2P multiplayer match type LANGameServer struct { listener net.Listener + server *http.Server port int mu sync.Mutex active bool // Player states - hostConn net.Conn - guestConn net.Conn + hostConn *websocket.Conn + guestConn *websocket.Conn hostName string guestName string hostMove string @@ -54,16 +52,43 @@ func NewLANServer() *LANGameServer { return &LANGameServer{} } +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +func (s *LANGameServer) wsHandler(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + slog.Error("WebSocket upgrade failed", "error", err) + return + } + + s.mu.Lock() + if s.hostConn == nil { + s.hostConn = conn + s.mu.Unlock() + go s.handleConnection(conn, true) + } else if s.guestConn == nil { + s.guestConn = conn + s.mu.Unlock() + go s.handleConnection(conn, false) + } else { + s.mu.Unlock() + slog.Warn("Rejecting third-wheel connection request") + _ = conn.Close() + } +} + // Start opens a TCP listener on a random port (or the specified default port) func (s *LANGameServer) Start(requestedPort int) (int, error) { s.mu.Lock() defer s.mu.Unlock() - addr := fmt.Sprintf("0.0.0.0:%d", requestedPort) - listener, err := net.Listen("tcp", addr) // #nosec G102 - LAN server intended to accept network connections + addr := fmt.Sprintf(":%d", requestedPort) + listener, err := net.Listen("tcp", addr) if err != nil { // If requested port is busy, fallback to dynamic port assignment - listener, err = net.Listen("tcp", "0.0.0.0:0") // #nosec G102 - LAN server intended to accept network connections + listener, err = net.Listen("tcp", ":0") // #nosec G102 - Intentional bind to all interfaces for LAN multiplayer hosting if err != nil { return 0, fmt.Errorf("failed to bind TCP listener: %w", err) } @@ -73,10 +98,21 @@ func (s *LANGameServer) Start(requestedPort int) (int, error) { s.port = listener.Addr().(*net.TCPAddr).Port s.active = true + mux := http.NewServeMux() + mux.HandleFunc("/ws", s.wsHandler) + s.server = &http.Server{ + Handler: mux, + ReadHeaderTimeout: 3 * time.Second, // Mitigate Slowloris attacks (G112) + } + slog.Info("LAN P2P Server started successfully", "port", s.port) // Run connection handler loop in background - go s.listenLoop() + go func() { + if err := s.server.Serve(s.listener); err != nil && err != http.ErrServerClosed { + slog.Error("Server serve error", "error", err) + } + }() return s.port, nil } @@ -91,12 +127,12 @@ func (s *LANGameServer) Stop() { } s.active = false - if s.listener != nil { - s.listener.Close() + if s.server != nil { + _ = s.server.Close() } if s.hostConn != nil { - s.hostConn.Close() + _ = s.hostConn.Close() } if s.guestConn != nil { _ = s.guestConn.Close() @@ -105,196 +141,36 @@ func (s *LANGameServer) Stop() { slog.Info("LAN P2P Server stopped") } -func (s *LANGameServer) listenLoop() { - for { - conn, err := s.listener.Accept() - if err != nil { - s.mu.Lock() - active := s.active - s.mu.Unlock() - if !active { - return // Closed normally - } - slog.Error("Server socket accept error", "error", err) - continue - } - - s.mu.Lock() - // First connection is the host client itself - if s.hostConn == nil { - s.hostConn = conn - s.mu.Unlock() - go s.handleConnection(conn, true) - } else if s.guestConn == nil { - // Second connection is the guest client joining - s.guestConn = conn - s.mu.Unlock() - go s.handleConnection(conn, false) - } else { - // Reject third wheels - s.mu.Unlock() - slog.Warn("Rejecting third-wheel connection request") - _ = conn.Close() - } - } -} - // handleConnection handles HTTP WS upgrade handshakes and reads frame packets -func (s *LANGameServer) handleConnection(conn net.Conn, isHost bool) { +func (s *LANGameServer) handleConnection(conn *websocket.Conn, isHost bool) { defer conn.Close() - // 1. Perform WebSocket Upgrade Handshake - reader := bufio.NewReader(conn) - req, err := http.ReadRequest(reader) - if err != nil { - slog.Error("Failed to read WebSocket upgrade request", "isHost", isHost, "error", err) - return - } - - if strings.ToLower(req.Header.Get("Upgrade")) != "websocket" { - slog.Warn("Received non-websocket upgrade HTTP connection") - return - } - - key := req.Header.Get("Sec-WebSocket-Key") - if key == "" { - slog.Warn("Sec-WebSocket-Key missing from request header") - return - } - - // Calculate Accept Key (RFC 6455) - const wsGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" - acceptHash := sha1.Sum([]byte(key + wsGUID)) // #nosec G401 - required by RFC 6455 - acceptKey := base64.StdEncoding.EncodeToString(acceptHash[:]) - - // Send Handshake Response - resp := "HTTP/1.1 101 Switching Protocols\r\n" + - "Upgrade: websocket\r\n" + - "Connection: Upgrade\r\n" + - "Sec-WebSocket-Accept: " + acceptKey + "\r\n\r\n" - - _, err = conn.Write([]byte(resp)) - if err != nil { - slog.Error("Failed to write handshake response", "isHost", isHost, "error", err) - return - } - slog.Info("Connection upgraded to WebSocket successfully", "isHost", isHost) - // 2. Read WebSocket Frame Loop + // Read WebSocket Frame Loop for { - payload, err := ReadWSFrame(conn) + var msg WSMessage + err := conn.ReadJSON(&msg) if err != nil { - if err == io.EOF { - slog.Info("Client connection closed cleanly (EOF)", "isHost", isHost) - } else { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { slog.Error("Error reading WebSocket frame", "isHost", isHost, "error", err) + } else { + slog.Info("Client connection closed cleanly (EOF)", "isHost", isHost) } s.handleDisconnect(isHost) return } - var msg WSMessage - if err := json.Unmarshal(payload, &msg); err != nil { - slog.Error("Failed to parse JSON WS message", "isHost", isHost, "error", err) - continue - } - s.handleMessage(msg, isHost) } } -// ReadWSFrame reads and decodes a single WebSocket frame (RFC 6455) -func ReadWSFrame(conn net.Conn) ([]byte, error) { - header := make([]byte, 2) - _, err := io.ReadFull(conn, header) - if err != nil { - return nil, err - } - - // Opcode analysis - opcode := header[0] & 0x0F - if opcode == 8 { // Close frame - return nil, io.EOF - } - - mask := (header[1] & 0x80) != 0 - payloadLen := int64(header[1] & 0x7F) - - if payloadLen == 126 { - lenBytes := make([]byte, 2) - if _, err := io.ReadFull(conn, lenBytes); err != nil { - return nil, err - } - payloadLen = int64(binary.BigEndian.Uint16(lenBytes)) - } else if payloadLen == 127 { - lenBytes := make([]byte, 8) - if _, err := io.ReadFull(conn, lenBytes); err != nil { - return nil, err - } - payloadLen = int64(binary.BigEndian.Uint64(lenBytes)) - } - - var maskKey []byte - if mask { - maskKey = make([]byte, 4) - if _, err := io.ReadFull(conn, maskKey); err != nil { - return nil, err - } - } - - payload := make([]byte, payloadLen) - if _, err := io.ReadFull(conn, payload); err != nil { - return nil, err - } - - if mask { - for i := 0; i < len(payload); i++ { - payload[i] ^= maskKey[i%4] - } - } - - return payload, nil -} - -// WriteWSFrame writes an unmasked text WebSocket frame to a client -func WriteWSFrame(conn net.Conn, payload []byte) error { +func (s *LANGameServer) sendToClient(conn *websocket.Conn, msgType, payload string) { if conn == nil { - return fmt.Errorf("connection is nil") - } - - var header []byte - header = append(header, 0x81) // FIN + Text frame type - - length := len(payload) - if length <= 125 { - header = append(header, byte(length)) - } else if length <= 65535 { - header = append(header, 126) - lenBytes := make([]byte, 2) - binary.BigEndian.PutUint16(lenBytes, uint16(length)) - header = append(header, lenBytes...) - } else { - header = append(header, 127) - lenBytes := make([]byte, 8) - binary.BigEndian.PutUint64(lenBytes, uint64(length)) - header = append(header, lenBytes...) - } - - // Write header then body - if _, err := conn.Write(header); err != nil { - return err - } - if _, err := conn.Write(payload); err != nil { - return err + return } - return nil -} - -func (s *LANGameServer) sendToClient(conn net.Conn, msgType, payload string) { msg := WSMessage{Type: msgType, Payload: payload} - data, _ := json.Marshal(msg) - _ = WriteWSFrame(conn, data) + _ = conn.WriteJSON(msg) } func (s *LANGameServer) handleMessage(msg WSMessage, isHost bool) { diff --git a/tview/go.mod b/tview/go.mod index f7185ec..0e1e0ef 100644 --- a/tview/go.mod +++ b/tview/go.mod @@ -2,14 +2,17 @@ module ahmedtoyyib1 go 1.26.1 +require ( + github.com/gdamore/tcell/v2 v2.8.1 + github.com/rivo/tview v0.42.0 + golang.org/x/crypto v0.51.0 +) + require ( github.com/gdamore/encoding v1.0.1 // indirect - github.com/gdamore/tcell/v2 v2.8.1 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/rivo/tview v0.42.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - golang.org/x/crypto v0.51.0 // indirect golang.org/x/sys v0.44.0 // indirect golang.org/x/term v0.43.0 // indirect golang.org/x/text v0.37.0 // indirect diff --git a/tview/go.sum b/tview/go.sum index 9d45178..f3f7eae 100644 --- a/tview/go.sum +++ b/tview/go.sum @@ -51,7 +51,6 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= @@ -63,7 +62,6 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -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/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= @@ -75,7 +73,6 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.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.15.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/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= diff --git a/tview/helper.go b/tview/helper.go index 01fbc21..6407400 100644 --- a/tview/helper.go +++ b/tview/helper.go @@ -14,12 +14,18 @@ func asciiColor(text string, substring string, color string) strings.Builder { tag := "[white]" switch color { - case "red": tag = "[red]" - case "green": tag = "[green]" - case "yellow": tag = "[yellow]" - case "blue": tag = "[blue]" - case "magenta": tag = "[magenta]" - case "cyan": tag = "[cyan]" + case "red": + tag = "[red]" + case "green": + tag = "[green]" + case "yellow": + tag = "[yellow]" + case "blue": + tag = "[blue]" + case "magenta": + tag = "[magenta]" + case "cyan": + tag = "[cyan]" } data, err := os.ReadFile("standard.txt") @@ -54,4 +60,3 @@ func asciiColor(text string, substring string, color string) strings.Builder { } return outputBuilder } - diff --git a/tview/main.go b/tview/main.go index d821e53..6fb2048 100644 --- a/tview/main.go +++ b/tview/main.go @@ -27,13 +27,12 @@ var states = []string{"AB", "AD", "AK", "AN", "BA", "BY", "BE", "BO", "CR", type Contact struct { FirstName string `json:"first_name"` LastName string `json:"last_name"` - UserName string `json:"UserName"` + UserName string `json:"UserName"` Email string `json:"email"` PhoneNumber string `json:"phone_number"` Gender string `json:"gender"` State string `json:"state"` - PassWord string `json:"Password"` - + PassWord string `json:"Password"` } var contacts []Contact @@ -97,13 +96,13 @@ func main() { // else if event.Rune() == 'a' { // addContactForm() // pages.SwitchToPage("Add Contact") - + return event }) // 6. Define Pages // Pages: Name, Item, Resize, Visible - pages.AddPage("Welcome Page", welcomeFlex, true, true) + pages.AddPage("Welcome Page", welcomeFlex, true, true) pages.AddPage("Menu", flex, true, false) pages.AddPage("Profile", profileFlex, true, false) pages.AddPage("Add Contact", form, true, false) @@ -134,7 +133,7 @@ func welcomePage() { login() pages.SwitchToPage("Signin Page") }). - AddButton("Register", func () { + AddButton("Register", func() { addContactForm() pages.SwitchToPage("Add Contact") }). @@ -144,65 +143,65 @@ func welcomePage() { welcomeButtons.SetButtonsAlign(tview.AlignCenter) welcomeFlex.SetDirection(tview.FlexRow). - AddItem(tview.NewBox(), 0, 1, false). // Top Spacer - AddItem(welcomeText, 10, 1, false). // ASCII Art (approx 8-10 lines high) - AddItem(welcomeButtons, 0, 1, true). // Buttons (focused) - AddItem(tview.NewBox(), 0, 1, false) // Bottom Spacer + AddItem(tview.NewBox(), 0, 1, false). // Top Spacer + AddItem(welcomeText, 10, 1, false). // ASCII Art (approx 8-10 lines high) + AddItem(welcomeButtons, 0, 1, true). // Buttons (focused) + AddItem(tview.NewBox(), 0, 1, false) // Bottom Spacer } func login() { - form.Clear(true) - - var enteredUsername string - var enteredPassword string - - // 1. Capture the input into local variables - form.AddInputField("Username", "", 20, nil, func(text string) { - enteredUsername = text - }) - form.AddPasswordField("Password", "", 20, '*', func(text string) { - enteredPassword = text - }) + form.Clear(true) + + var enteredUsername string + var enteredPassword string + + // 1. Capture the input into local variables + form.AddInputField("Username", "", 20, nil, func(text string) { + enteredUsername = text + }) + form.AddPasswordField("Password", "", 20, '*', func(text string) { + enteredPassword = text + }) // form.AddInputField("Password", "", 20, nil, func(text string) { - // enteredPassword = text - // }) - - form.AddButton("Login", func() { - success := false - var matchedContact *Contact - - // 2. Loop through the contacts list to find the user - for i := range contacts { - // Here I'm assuming 'FirstName' is the username. - // You could also use Email! - if contacts[i].FirstName == enteredUsername { - // 3. Compare the hashed password - // Note: You need to have a 'Password' field in your Contact struct - err := bcrypt.CompareHashAndPassword([]byte(contacts[i].PassWord), []byte(enteredPassword)) - - if err == nil { - success = true - matchedContact = &contacts[i] - break - - } - } - } - - if success { - showProfile(matchedContact) - pages.SwitchToPage("Profile") - } else { - // Optional: You could update a text view to say "Invalid Login" - // For now, let's just clear the password field - form.GetFormItemByLabel("Password").(*tview.InputField).SetText("") - } - }) - - form.AddButton("Cancel", func() { - pages.SwitchToPage("Menu") - }) + // enteredPassword = text + // }) + + form.AddButton("Login", func() { + success := false + var matchedContact *Contact + + // 2. Loop through the contacts list to find the user + for i := range contacts { + // Here I'm assuming 'FirstName' is the username. + // You could also use Email! + if contacts[i].FirstName == enteredUsername { + // 3. Compare the hashed password + // Note: You need to have a 'Password' field in your Contact struct + err := bcrypt.CompareHashAndPassword([]byte(contacts[i].PassWord), []byte(enteredPassword)) + + if err == nil { + success = true + matchedContact = &contacts[i] + break + + } + } + } + + if success { + showProfile(matchedContact) + pages.SwitchToPage("Profile") + } else { + // Optional: You could update a text view to say "Invalid Login" + // For now, let's just clear the password field + form.GetFormItemByLabel("Password").(*tview.InputField).SetText("") + } + }) + + form.AddButton("Cancel", func() { + pages.SwitchToPage("Menu") + }) } func showProfile(contact *Contact) { @@ -224,11 +223,11 @@ func showProfile(contact *Contact) { // AddButton("Back to Menu", func() { // pages.SwitchToPage("Menu") // }). - AddButton("Play Game", func () { + AddButton("Play Game", func() { startBattle(contact) // pages.SwitchToPage("Game") }). - AddButton("See Avaliable Players", func () { + AddButton("See Avaliable Players", func() { pages.SwitchToPage("Menu") }) profileButtons.SetButtonsAlign(tview.AlignCenter) @@ -271,14 +270,15 @@ func addContactList() { } } - // saved contact to the db -json func saveContacts() { - b, err := json.MarshalIndent(contacts, "", " ") + b, err := json.MarshalIndent(contacts, "", " ") // #nosec G117 if err != nil { return } - os.WriteFile("data.json", b, 0644) + if err := os.WriteFile("data.json", b, 0600); err != nil { + return + } } // Load contact from the json db @@ -294,9 +294,9 @@ func loadContacts() error { } // hash password -func hashPassword(password string) string { +func hashPassword(password string) string { hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - + if err != nil { panic(err) } @@ -304,127 +304,127 @@ func hashPassword(password string) string { } func startBattle(c1 *Contact) { - battleForm := tview.NewForm() - var user1Choice string - - // 1. Setup the user's choice dropdown - battleForm.AddDropDown("Your Tool", []string{"rock", "paper", "scissors"}, 0, func(o string, i int) { - user1Choice = o - }) - - // 2. The Battle Button - battleForm.AddButton("BATTLE!", func() { - choices := []string{"rock", "paper", "scissors"} - opponentChoice := choices[rand.IntN(3)] - - // Create the result view - resultView := tview.NewTextView(). - SetDynamicColors(true). - SetTextAlign(tview.AlignCenter). - SetWrap(false) // ASCII art must not wrap - - // Get the ASCII art - suspenseArt := RunRPSBattle(c1.FirstName, "CPU", user1Choice, opponentChoice) - - // Hide the winner line for the "delay" effect - lines := strings.Split(suspenseArt, "\n") - var initialArt string - if len(lines) > 2 { - initialArt = strings.Join(lines[:len(lines)-2], "\n") - } else { - initialArt = suspenseArt - } - - resultView.SetText(initialArt + "\n\n[yellow]Calculating result...[white]") - pages.AddPage("BattleResult", resultView, true, true) - pages.SwitchToPage("BattleResult") // Show the suspense screen - - go func() { - time.Sleep(2 * time.Second) - app.QueueUpdateDraw(func() { - resultView.SetText(suspenseArt + "\n\n[gray]Press Enter to go back[white]") - - // Allow user to exit the result screen - resultView.SetDoneFunc(func(key tcell.Key) { - pages.RemovePage("BattleResult") - pages.RemovePage("GameInput") - pages.SwitchToPage("Profile") - }) - }) - }() - }) - - // 3. IMPORTANT: Add a Cancel button - battleForm.AddButton("Cancel", func() { - pages.RemovePage("GameInput") - pages.SwitchToPage("Profile") - }) - - // 4. Add and EXPLICITLY switch - pages.AddPage("GameInput", battleForm, true, true) - pages.SwitchToPage("GameInput") + battleForm := tview.NewForm() + var user1Choice string + + // 1. Setup the user's choice dropdown + battleForm.AddDropDown("Your Tool", []string{"rock", "paper", "scissors"}, 0, func(o string, i int) { + user1Choice = o + }) + + // 2. The Battle Button + battleForm.AddButton("BATTLE!", func() { + choices := []string{"rock", "paper", "scissors"} + opponentChoice := choices[rand.IntN(3)] // #nosec G404 + + // Create the result view + resultView := tview.NewTextView(). + SetDynamicColors(true). + SetTextAlign(tview.AlignCenter). + SetWrap(false) // ASCII art must not wrap + + // Get the ASCII art + suspenseArt := RunRPSBattle(c1.FirstName, "CPU", user1Choice, opponentChoice) + + // Hide the winner line for the "delay" effect + lines := strings.Split(suspenseArt, "\n") + var initialArt string + if len(lines) > 2 { + initialArt = strings.Join(lines[:len(lines)-2], "\n") + } else { + initialArt = suspenseArt + } + + resultView.SetText(initialArt + "\n\n[yellow]Calculating result...[white]") + pages.AddPage("BattleResult", resultView, true, true) + pages.SwitchToPage("BattleResult") // Show the suspense screen + + go func() { + time.Sleep(2 * time.Second) + app.QueueUpdateDraw(func() { + resultView.SetText(suspenseArt + "\n\n[gray]Press Enter to go back[white]") + + // Allow user to exit the result screen + resultView.SetDoneFunc(func(key tcell.Key) { + pages.RemovePage("BattleResult") + pages.RemovePage("GameInput") + pages.SwitchToPage("Profile") + }) + }) + }() + }) + + // 3. IMPORTANT: Add a Cancel button + battleForm.AddButton("Cancel", func() { + pages.RemovePage("GameInput") + pages.SwitchToPage("Profile") + }) + + // 4. Add and EXPLICITLY switch + pages.AddPage("GameInput", battleForm, true, true) + pages.SwitchToPage("GameInput") } // RunRPSBattle takes the players and their choices and returns the visual result func RunRPSBattle(user1, user2, choice1, choice2 string) string { - // 1. Load ASCII Files (Error handling simplified for brevity) - gesture, _ := os.ReadFile("ropa-sci.txt") - revGestureFile, _ := os.Open("ropa-sci-mirror.txt") - defer revGestureFile.Close() - - // Parse gestures into slices (using your logic) - var gestureLines []string - begin := 0 - for i := range gesture { - if gesture[i] == '\n' { - gestureLines = append(gestureLines, string(gesture[begin:i])) - begin = i + 1 - } - } - - var revGestureLines []string - revScanner := bufio.NewScanner(revGestureFile) - for revScanner.Scan() { - revGestureLines = append(revGestureLines, revScanner.Text()) - } - - // 2. Map Choices to ASCII - options := make(map[string][]string) - revOptions := make(map[string][]string) - list := []string{"rock", "paper", "scissors"} - height := 7 - - for i := range list { - length := i * height - options[list[i]] = gestureLines[length : length+height] - revOptions[list[i]] = revGestureLines[length : length+height] - } - - // 3. Build the Battle Scene String - var battleScene strings.Builder - battleScene.WriteString(fmt.Sprintf("\n[yellow]%s (%s) vs %s (%s)[white]\n\n", user1, choice1, user2, choice2)) - - for i := 0; i < height; i++ { - // Add User 1's gesture - battleScene.WriteString(options[choice1][i]) - // Add some spacing between them - battleScene.WriteString(" ") - // Add User 2's gesture - battleScene.WriteString(revOptions[choice2][i] + "\n") - } - - // 4. Determine Winner - var result string - if choice1 == choice2 { - result = "DRAW" - } else if (choice1 == "scissors" && choice2 == "paper") || - (choice1 == "paper" && choice2 == "rock") || - (choice1 == "rock" && choice2 == "scissors") { - result = user1 + " WINS!" - } else { - result = user2 + " WINS!" - } - - battleScene.WriteString("\n\n[green]" + result + "[white]") - return battleScene.String() -} \ No newline at end of file + // 1. Load ASCII Files (Error handling simplified for brevity) + gesture, _ := os.ReadFile("ropa-sci.txt") + revGestureFile, _ := os.Open("ropa-sci-mirror.txt") + defer revGestureFile.Close() + + // Parse gestures into slices (using your logic) + var gestureLines []string + begin := 0 + for i := range gesture { + if gesture[i] == '\n' { + gestureLines = append(gestureLines, string(gesture[begin:i])) + begin = i + 1 + } + } + + var revGestureLines []string + revScanner := bufio.NewScanner(revGestureFile) + for revScanner.Scan() { + revGestureLines = append(revGestureLines, revScanner.Text()) + } + + // 2. Map Choices to ASCII + options := make(map[string][]string) + revOptions := make(map[string][]string) + list := []string{"rock", "paper", "scissors"} + height := 7 + + for i := range list { + length := i * height + options[list[i]] = gestureLines[length : length+height] + revOptions[list[i]] = revGestureLines[length : length+height] + } + + // 3. Build the Battle Scene String + var battleScene strings.Builder + battleScene.WriteString(fmt.Sprintf("\n[yellow]%s (%s) vs %s (%s)[white]\n\n", user1, choice1, user2, choice2)) + + for i := 0; i < height; i++ { + // Add User 1's gesture + battleScene.WriteString(options[choice1][i]) + // Add some spacing between them + battleScene.WriteString(" ") + // Add User 2's gesture + battleScene.WriteString(revOptions[choice2][i] + "\n") + } + + // 4. Determine Winner + var result string + if choice1 == choice2 { + result = "DRAW" + } else if (choice1 == "scissors" && choice2 == "paper") || + (choice1 == "paper" && choice2 == "rock") || + (choice1 == "rock" && choice2 == "scissors") { + result = user1 + " WINS!" + } else { + result = user2 + " WINS!" + } + + battleScene.WriteString("\n\n[green]" + result + "[white]") + return battleScene.String() +}