package main // HTTP handlers and hub run loop integration for RadChat. // // This file contains: // - WebSocket and HTTP endpoints (username check, user count, file upload/download) // - Helpers to send messages to specific clients and broadcast user lists // - The hub Run loop that manages registration, unregistration, and broadcasting // // The actual Hub/Client definitions live in the server package. Here we glue // them to HTTP. import ( "encoding/json" "fmt" "io" "log" "net/http" "net/url" "os" "path/filepath" "radchat/server" "strings" "time" ) // IsUsernameTaken returns true if the given username already exists among connected clients (case-insensitive). func IsUsernameTaken(h *server.Hub, username string) bool { h.Mutex.RLock() defer h.Mutex.RUnlock() for client := range h.Clients { if client.Username == username { return true } } return false } // HandleUserCountCheck responds with the current number of connected users. func HandleUserCountCheck(hub *server.Hub, w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } response := map[string]any{ "userCount": int64(len(hub.Clients)), } err := json.NewEncoder(w).Encode(response) if err != nil { return } } // HandleUsernameCheck validates a requested username and checks for collisions. func HandleUsernameCheck(hub *server.Hub, w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var request struct { Username string `json:"username"` } if err := json.NewDecoder(r.Body).Decode(&request); err != nil { http.Error(w, "Invalid JSON", http.StatusBadRequest) return } w.Header().Set("Content-Type", "application/json") // Check if username is taken errorString := "" isTaken := IsUsernameTaken(hub, request.Username) if isTaken { errorString = "Username is already taken. Please choose a different username." } isValid := IsUsernameValid(request.Username) if !isValid { errorString = "Invalid username. Please use 3-24 characters, letters, numbers, underscores, or hyphens." } response := map[string]any{ "available": isValid && !isTaken, "username": request.Username, "error": errorString, } err := json.NewEncoder(w).Encode(response) if err != nil { return } } func SendUsersList(h *server.Hub) { h.Mutex.RLock() users := make([]map[string]string, 0) for client := range h.Clients { if client.Username != "" { users = append(users, map[string]string{ "id": client.Id, "username": client.Username, }) } } h.Mutex.RUnlock() msg := Message{ Type: "users_list", Data: users, } data, err := json.Marshal(msg) if err != nil { return } select { case h.Broadcast <- data: default: } } func SendToClient(h *server.Hub, targetID string, message []byte) { h.Mutex.RLock() defer h.Mutex.RUnlock() for client := range h.Clients { if client.Id == targetID { select { case client.Send <- message: default: close(client.Send) delete(h.Clients, client) } return } } } func HandleWebSocket(hub *server.Hub, w http.ResponseWriter, r *http.Request) { conn, err := hub.Upgrader.Upgrade(w, r, nil) if err != nil { log.Println("WebSocket upgrade error:", err) return } clientID := r.URL.Query().Get("client_id") if clientID == "" { clientID = GenerateId() log.Println("Client joined. Generated new client ID:", clientID) } else { log.Println("Client joined. Used existing client ID:", clientID) } client := &server.Client{ Conn: conn, Id: clientID, Hub: hub, Send: make(chan []byte, 256), } userIdMsg := map[string]string{ "type": "client_id", "clientId": clientID, } if msgBytes, err := json.Marshal(userIdMsg); err == nil { client.Send <- msgBytes } client.Hub.Register <- client go WritePump(client) go ReadPump(client) } func HandleFileUpload(h *server.Hub, filesDir string, fileTimeout time.Duration, w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } err := r.ParseMultipartForm(256 << 20) if err != nil { http.Error(w, "Error parsing form", http.StatusBadRequest) return } // Get all files files := r.MultipartForm.File _ = r.ParseForm() username := r.FormValue("username") clientId := r.FormValue("client_id") var uploadedFiles []string for _, fileHeaders := range files { for _, fileHeader := range fileHeaders { file, err := fileHeader.Open() if err != nil { continue } fileBytes, err := io.ReadAll(file) if err != nil { _ = file.Close() continue } filename := SanitizeFilename(fileHeader.Filename) // get filename before the extension fileExt := filepath.Ext(filename) fileName := strings.TrimSuffix(filename, fileExt) fileFullName := fileName + "_" + time.Now().Format("20060102150405") + fileExt filePath := filepath.Join(filesDir, fileFullName) err = os.WriteFile(filePath, fileBytes, 0644) if err != nil { _ = file.Close() continue } uploadedFiles = append(uploadedFiles, fileFullName) fileFullName = url.PathEscape(fileFullName) fileLocation := filepath.Join(r.URL.Path, fileFullName) scheme := GetScheme(r) fileLink := scheme + "://" + filepath.Join(r.Host, fileLocation) // find our client and send a message as them clients := h.Clients for client := range clients { if client.Id == clientId { broadcastMsg := Message{ Type: "chat_message", Username: username, Data: map[string]any{ "message": fileLink, "timestamp": time.Now().UnixMilli(), }, } data, _ := json.Marshal(broadcastMsg) h.Broadcast <- data break } } go func() { time.Sleep(fileTimeout) err := os.Remove(filename) if err != nil { return } }() _ = file.Close() } } // Respond with success response := map[string]interface{}{ "success": true, "uploaded_files": uploadedFiles, "count": len(uploadedFiles), } w.Header().Set("Content-Type", "application/json") err = json.NewEncoder(w).Encode(response) if err != nil { return } } func HandleFileDownload(filesDir string, w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } path := strings.TrimPrefix(r.URL.Path, "/") segments := strings.Split(path, "/") if len(segments) < 2 { http.Error(w, "Filename required", http.StatusBadRequest) return } filename := segments[len(segments)-1] filename = SanitizeFilename(filename) if filename == "" { http.Error(w, "Invalid filename", http.StatusBadRequest) return } filePath := filepath.Join(filesDir, filename) absUploadsDir, err := filepath.Abs(filesDir) if err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } absFilePath, err := filepath.Abs(filePath) if err != nil { http.Error(w, "Invalid file path", http.StatusBadRequest) return } if !strings.HasPrefix(absFilePath, absUploadsDir) { http.Error(w, "Access denied", http.StatusForbidden) return } if _, err := os.Stat(filePath); os.IsNotExist(err) { http.Error(w, "File not found", http.StatusNotFound) return } http.ServeFile(w, r, filePath) } func Run(h *server.Hub) { log.Println("Hub started and running...") for { select { case client := <-h.Register: h.Mutex.Lock() h.Clients[client] = true h.Mutex.Unlock() SendUsersList(h) case client := <-h.Unregister: var messageJson []byte messageJson, _ = json.Marshal(Message{ Type: "system_message", Data: fmt.Sprintf("%s left the voice chat", client.Username), DataType: "system_message_leave", // FIX: Please... // Using CONFIG.APP.SYSTEM_MSG_DEFAULT_TIMEOUT from javascript config // Hard coding it here for now DataTime: 5000, }) for cli := range client.Hub.Clients { SendToClient(client.Hub, cli.Id, messageJson) } h.Mutex.Lock() if _, ok := h.Clients[client]; ok { delete(h.Clients, client) close(client.Send) } h.Mutex.Unlock() log.Println("Client left. Client ID:", client.Id) SendUsersList(h) case message := <-h.Broadcast: h.Mutex.RLock() successCount := 0 for client := range h.Clients { select { case client.Send <- message: successCount++ default: close(client.Send) delete(h.Clients, client) } } h.Mutex.RUnlock() } } }