// api.go package main import ( "encoding/json" "log" "net/http" "path/filepath" "strings" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/gorilla/websocket" "github.com/golang-jwt/jwt/v5" ) var upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} // Helper pour récupérer IP réelle derrière proxy HF func getClientIP(c *gin.Context) string { ip := c.ClientIP() // HF Spaces utilise un proxy, X-Forwarded-For est géré par c.ClientIP() si TrustedProxies configuré // Mais on a fait r.SetTrustedProxies(nil), donc c.ClientIP() = RemoteAddr direct. return ip } // --- Auth Handlers --- func HandleRegister(c *gin.Context) { var req struct { Username string `json:"username" binding:"required"` Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required,min=6"` } if c.BindJSON(&req) != nil { return } if _, err := store.GetUserByEmail(req.Email); err == nil { c.JSON(400, gin.H{"error": "Email déjà utilisé"}); return } if _, err := store.GetUserByUsername(req.Username); err == nil { c.JSON(400, gin.H{"error": "Pseudo déjà pris"}); return } hash, _ := HashPassword(req.Password) u := &User{ID: uuid.New().String(), Username: req.Username, Email: req.Email, Password: hash, CreatedAt: time.Now()} store.SaveUser(u) token, _ := GenerateToken(u.ID) SetTokenCookie(c, token) ip := getClientIP(c) LogAccountCreated(u.Username, u.Email, ip) // LOG PERSISTANT c.JSON(200, gin.H{"user": u.Sanitize()}) } func HandleLogin(c *gin.Context) { var req struct { Email, Password string } if c.BindJSON(&req) != nil { return } u, err := store.GetUserByEmail(req.Email) if err != nil || !CheckPassword(u.Password, req.Password) { c.JSON(401, gin.H{"error": "Identifiants invalides"}); return } token, _ := GenerateToken(u.ID) SetTokenCookie(c, token) ip := getClientIP(c) LogUserConnected(u.Username, ip) // LOG PERSISTANT c.JSON(200, gin.H{"user": u.Sanitize()}) } func HandleLogout(c *gin.Context) { ClearTokenCookie(c); c.JSON(200, gin.H{"ok": true}) } func HandleMe(c *gin.Context) { uid := c.GetString("userID"); u, _ := store.GetUserByID(uid); c.JSON(200, u.Sanitize()) } func (u *User) Sanitize() *User { cp := *u; cp.Password = ""; return &cp } // --- Quiz Handlers (Logs ajoutés sur actions critiques) --- func HandleListQuizzes(c *gin.Context) { uid := c.GetString("userID"); list, _ := store.ListQuizzesByUser(uid); c.JSON(200, list) } func HandleCreateQuiz(c *gin.Context) { uid := c.GetString("userID"); q := &Quiz{ID: uuid.New().String(), Title: "Nouveau Quiz", CreatorID: uid, CreatedAt: time.Now(), UpdatedAt: time.Now(), Questions: []Question{}}; store.SaveQuiz(q); c.JSON(200, q) } func HandleGetQuiz(c *gin.Context) { id := c.Param("id"); q, err := store.GetQuiz(id); if err != nil { c.JSON(404, gin.H{"error": "Quiz introuvable"}); return }; uid := c.GetString("userID"); if q.CreatorID != uid && !q.IsPublic { c.JSON(403, gin.H{"error": "Accès refusé"}); return }; c.JSON(200, q) } func HandleUpdateQuiz(c *gin.Context) { uid := c.GetString("userID"); id := c.Param("id"); q, err := store.GetQuiz(id); if err != nil || q.CreatorID != uid { c.JSON(403, gin.H{"error": "Accès refusé"}); return }; if c.BindJSON(q) != nil { return }; store.SaveQuiz(q); c.JSON(200, q) } func HandleDeleteQuiz(c *gin.Context) { uid := c.GetString("userID"); id := c.Param("id"); q, err := store.GetQuiz(id); if err != nil || q.CreatorID != uid { c.JSON(403, gin.H{"error": "Accès refusé"}); return }; store.DeleteQuiz(id); Alert("Quiz DELETED: %s by %s", id, uid); c.JSON(200, gin.H{"ok": true}) } func HandlePublishQuiz(c *gin.Context) { uid := c.GetString("userID"); id := c.Param("id"); q, err := store.GetQuiz(id); if err != nil || q.CreatorID != uid { c.JSON(403, gin.H{"error": "Accès refusé"}); return }; q.IsPublic = !q.IsPublic; store.SaveQuiz(q); Info("Quiz Publish toggled: %s -> Public: %v", id, q.IsPublic); c.JSON(200, q) } func HandleListPublicQuizzes(c *gin.Context) { list, _ := store.ListPublicQuizzes(20); c.JSON(200, list) } // --- Image Upload --- func HandleUploadImage(c *gin.Context) { file, err := c.FormFile("file") if err != nil { c.JSON(400, gin.H{"error": "Fichier manquant"}); return } if file.Size > 5<<20 { c.JSON(400, gin.H{"error": "Max 5MB"}); return } ext := strings.ToLower(filepath.Ext(file.Filename)) allowed := map[string]bool{".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".webp": true} if !allowed[ext] { c.JSON(400, gin.H{"error": "Format non supporté (png, jpg, gif, webp)"}); return } filename := uuid.New().String() + ext savePath := "/data/uploads/" + filename if err := c.SaveUploadedFile(file, savePath); err != nil { LogError("Upload save failed", err); c.JSON(500, gin.H{"error": "Échec sauvegarde"}); return } Info("Image Uploaded: %s by %s", filename, c.GetString("userID")) c.JSON(200, gin.H{"url": "/uploads/" + filename}) } // --- Game Handlers --- func HandleCreateGame(c *gin.Context) { uid := c.GetString("userID") var req struct { QuizID string `json:"quiz_id" binding:"required"`; MaxPlayers int `json:"max_players"` } if c.BindJSON(&req) != nil { return } if req.MaxPlayers == 0 { req.MaxPlayers = 50 } if req.MaxPlayers > 200 { req.MaxPlayers = 200 } quiz, err := store.GetQuiz(req.QuizID) if err != nil || (quiz.CreatorID != uid && !quiz.IsPublic) { c.JSON(403, gin.H{"error": "Quiz introuvable ou privé"}); return } pin := GeneratePin() for i := 0; i < 5; i++ { if _, err := store.GetGame(pin); err != nil { break }; pin = GeneratePin() } game := &GameSession{Pin: pin, QuizID: quiz.ID, HostID: uid, State: StateWaiting, Players: make(map[string]*Player), Settings: GameSettings{MaxPlayers: req.MaxPlayers}, CreatedAt: time.Now()} hostUser, _ := store.GetUserByID(uid) game.Players[uid] = &Player{ID: uid, Name: hostUser.Username, Avatar: hostUser.AvatarURL, IsHost: true, Connected: true} store.SaveGame(game) Info("Game CREATED: Pin=%s Quiz=%s Host=%s MaxPlayers=%d", pin, quiz.ID, uid, req.MaxPlayers) c.JSON(200, gin.H{"pin": pin, "game": game}) } func HandleGetGameInfo(c *gin.Context) { pin := c.Param("pin"); game, err := store.GetGame(pin); if err != nil { c.JSON(404, gin.H{"error": "Partie introuvable"}); return }; c.JSON(200, game) } func HandleHostStartGame(c *gin.Context) { uid := c.GetString("userID") pin := c.Param("pin") game, err := store.GetGame(pin) if err != nil || game.HostID != uid { c.JSON(403, gin.H{"error": "Non autorisé"}); return } granted, notifyCh := slotManager.RequestSlots(pin, uid, game.Settings.MaxPlayers) if granted { game.State = StateLobby store.SaveGame(game) hub.BroadcastToRoom(pin, WSMessage{Type: MsgLobbyUpdate, Data: game.GetLobbyPlayers()}, "") LogGameStart(pin, uid) // LOG c.JSON(200, gin.H{"status": "started", "state": "lobby"}) } else { game.State = StateWaiting store.SaveGame(game) Alert("Game QUEUED: %s (Host: %s) waiting for %d slots", pin, uid, game.Settings.MaxPlayers) select { case <-notifyCh: game.State = StateLobby; store.SaveGame(game) hub.BroadcastToRoom(pin, WSMessage{Type: MsgLobbyUpdate, Data: game.GetLobbyPlayers()}, "") LogGameStart(pin, uid) // LOG quand démarre après queue c.JSON(200, gin.H{"status": "started", "state": "lobby"}) case <-time.After(5 * time.Minute): Error("Game Queue TIMEOUT: %s", pin) c.JSON(408, gin.H{"error": "Timeout file d'attente"}) } } } func (g *GameSession) GetLobbyPlayers() []*Player { list := make([]*Player, 0, len(g.Players)) for _, p := range g.Players { list = append(list, p) } return list } // --- WebSocket Handler --- func HandleWebSocket(c *gin.Context) { pin := c.Param("pin") conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { return } tokenStr, _ := c.Cookie("token") var userID string if tokenStr != "" { claims := &Claims{} token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) { return jwtSecret, nil }) if err == nil && token.Valid { userID = claims.UserID } } playerID := userID isHost := false if playerID == "" { playerID = "guest_" + uuid.New().String()[:8] } game, _ := store.GetGame(pin) if game != nil && game.HostID == playerID { isHost = true } client := &Client{Conn: conn, Send: make(chan []byte, 256), GamePin: pin, PlayerID: playerID, IsHost: isHost} hub.Register <- client hub.Mutex.RLock() room := hub.Rooms[pin] hub.Mutex.RUnlock() if room != nil && room.Game == nil && game != nil { room.Mutex.Lock(); room.Game = game; room.Mutex.Unlock() } // Write Pump go func() { ticker := time.NewTicker(30 * time.Second) defer func() { ticker.Stop(); hub.Unregister <- client }() for { select { case msg, ok := <-client.Send: if !ok { conn.WriteMessage(websocket.CloseMessage, []byte{}); return } conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil { return } case <-ticker.C: conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { return } } } }() // Read Pump for { _, data, err := conn.ReadMessage() if err != nil { break } HandleWSMessage(client, data) } } func HandleWSMessage(client *Client, data []byte) { var msg WSMessage if err := json.Unmarshal(data, &msg); err != nil { return } hub.Mutex.RLock() room := hub.Rooms[client.GamePin] hub.Mutex.RUnlock() if room == nil || room.Game == nil { return } game := room.Game switch msg.Type { case MsgJoin: var payload struct { Name, Avatar string } if m, ok := msg.Data.(map[string]interface{}); ok { if v, ok := m["name"].(string); ok { payload.Name = v } if v, ok := m["avatar"].(string); ok { payload.Avatar = v } } if payload.Name == "" { payload.Name = "Player " + client.PlayerID[:4] } room.Mutex.Lock() if _, exists := room.Clients[client.PlayerID]; !exists || !room.Clients[client.PlayerID].Connected { p := &Player{ID: client.PlayerID, Name: payload.Name, Avatar: payload.Avatar, Connected: true} game.Players[client.PlayerID] = p room.Clients[client.PlayerID] = client } else { game.Players[client.PlayerID].Connected = true client.Connected = true } room.Mutex.Unlock() store.SaveGame(game) // LOG JOIN ip := client.Conn.RemoteAddr().String() if strings.HasPrefix(client.PlayerID, "guest_") { LogAnonymousJoin(client.GamePin, ip) } else { LogPlayerJoin(payload.Name, client.GamePin) } hub.BroadcastToRoom(client.GamePin, WSMessage{Type: MsgPlayerJoined, Data: game.Players[client.PlayerID]}, client.PlayerID) if game.State == StateLobby { hub.BroadcastToRoom(client.GamePin, WSMessage{Type: MsgLobbyUpdate, Data: game.GetLobbyPlayers()}, "") } case MsgAnswer: if game.State != StateQuestion { return } var payload struct { QIndex int; Answer interface{} } if m, ok := msg.Data.(map[string]interface{}); ok { if v, ok := m["q_index"].(float64); ok { payload.QIndex = int(v) } payload.Answer = m["answer"] } if payload.QIndex != game.CurrentQIndex { return } room.Mutex.RLock(); p := game.Players[client.PlayerID]; room.Mutex.RUnlock() if p == nil { return } elapsed := time.Since(game.QuestionStartTime).Milliseconds() p.Answer = &PlayerAnswer{QuestionIndex: payload.QIndex, Answer: payload.Answer, TimeTaken: int(elapsed)} store.SaveGame(game) case MsgStartGame: if !client.IsHost || game.State != StateLobby { return } go StartGameLoop(hub, store, client.GamePin) case MsgNextQuestion: if !client.IsHost || game.State != StateAnswer { return } game.CurrentQIndex++ NextQuestion(hub, store, client.GamePin) } }