radchat/hub.go

387 lines
8.5 KiB
Go

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()
}
}
}