mirror of
https://github.com/arkorty/Osborne.git
synced 2026-03-17 16:51:44 +00:00
1245 lines
31 KiB
Go
1245 lines
31 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"math/rand"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
"github.com/joho/godotenv"
|
|
_ "github.com/mattn/go-sqlite3"
|
|
)
|
|
|
|
type MessageType string
|
|
|
|
const (
|
|
TextUpdate MessageType = "text-update"
|
|
InitialContent MessageType = "initial-content"
|
|
JoinRoom MessageType = "join-room"
|
|
Ping MessageType = "ping"
|
|
Pong MessageType = "pong"
|
|
CommentAdd MessageType = "comment-add"
|
|
CommentUpdate MessageType = "comment-update"
|
|
CommentDelete MessageType = "comment-delete"
|
|
CommentsSync MessageType = "comments-sync"
|
|
UserJoined MessageType = "user-joined"
|
|
UserLeft MessageType = "user-left"
|
|
UserActivity MessageType = "user-activity"
|
|
UsersSync MessageType = "users-sync"
|
|
MediaUpload MessageType = "media-upload"
|
|
MediaDelete MessageType = "media-delete"
|
|
MediaSync MessageType = "media-sync"
|
|
)
|
|
|
|
type BaseMessage struct {
|
|
Type MessageType `json:"type"`
|
|
Code string `json:"code"`
|
|
}
|
|
|
|
type TextUpdateMessage struct {
|
|
BaseMessage
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
type InitialContentMessage struct {
|
|
BaseMessage
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
type JoinRoomMessage struct {
|
|
BaseMessage
|
|
User User `json:"user"`
|
|
}
|
|
|
|
type PingMessage struct {
|
|
BaseMessage
|
|
}
|
|
|
|
type PongMessage struct {
|
|
BaseMessage
|
|
}
|
|
|
|
type Comment struct {
|
|
ID string `json:"id"`
|
|
LineNumber *int `json:"lineNumber"`
|
|
LineRange *string `json:"lineRange"`
|
|
Author string `json:"author"`
|
|
AuthorID string `json:"authorId"`
|
|
Content string `json:"content"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
}
|
|
|
|
type CommentMessage struct {
|
|
BaseMessage
|
|
Comment Comment `json:"comment"`
|
|
}
|
|
|
|
type CommentsMessage struct {
|
|
BaseMessage
|
|
Comments []Comment `json:"comments"`
|
|
}
|
|
|
|
type User struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Color string `json:"color"`
|
|
LastSeen time.Time `json:"lastSeen"`
|
|
IsTyping bool `json:"isTyping"`
|
|
CurrentLine *int `json:"currentLine"`
|
|
}
|
|
|
|
type UserMessage struct {
|
|
BaseMessage
|
|
User User `json:"user"`
|
|
}
|
|
|
|
type UsersMessage struct {
|
|
BaseMessage
|
|
Users []User `json:"users"`
|
|
}
|
|
|
|
type UserActivityMessage struct {
|
|
BaseMessage
|
|
UserID string `json:"userId"`
|
|
IsTyping bool `json:"isTyping"`
|
|
CurrentLine *int `json:"currentLine"`
|
|
}
|
|
|
|
type MediaFile struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Size int64 `json:"size"`
|
|
URL string `json:"url"`
|
|
UploadedAt time.Time `json:"uploadedAt"`
|
|
UploadedBy string `json:"uploadedBy"`
|
|
}
|
|
|
|
type MediaMessage struct {
|
|
BaseMessage
|
|
Media MediaFile `json:"media"`
|
|
}
|
|
|
|
type MediaSyncMessage struct {
|
|
BaseMessage
|
|
MediaFiles []MediaFile `json:"mediaFiles"`
|
|
}
|
|
|
|
type Client struct {
|
|
Conn *websocket.Conn
|
|
User User
|
|
LastPing time.Time
|
|
}
|
|
|
|
type Room struct {
|
|
Content string
|
|
Clients map[string]*Client
|
|
Comments []Comment
|
|
MediaFiles []MediaFile
|
|
mutex sync.RWMutex
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
var (
|
|
rooms = make(map[string]*Room)
|
|
roomsMutex sync.RWMutex
|
|
upgrader = websocket.Upgrader{
|
|
ReadBufferSize: 1024,
|
|
WriteBufferSize: 1024,
|
|
CheckOrigin: func(r *http.Request) bool {
|
|
return true
|
|
},
|
|
}
|
|
db *sql.DB
|
|
filesDir string
|
|
)
|
|
|
|
func main() {
|
|
// Load environment variables
|
|
if err := godotenv.Load(); err != nil {
|
|
log.Printf("Warning: Could not load .env file: %v", err)
|
|
}
|
|
|
|
// Get files directory from environment
|
|
filesDir = os.Getenv("FILES_DIR")
|
|
if filesDir == "" {
|
|
filesDir = "./uploads"
|
|
}
|
|
|
|
// Create files directory if it doesn't exist
|
|
if err := os.MkdirAll(filesDir, 0755); err != nil {
|
|
log.Fatal("Failed to create files directory:", err)
|
|
}
|
|
|
|
var err error
|
|
db, err = sql.Open("sqlite3", "./rooms.db")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
|
|
// Create tables
|
|
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS rooms (
|
|
code TEXT PRIMARY KEY,
|
|
content TEXT,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
)`)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS comments (
|
|
id TEXT PRIMARY KEY,
|
|
room_code TEXT,
|
|
line_number INTEGER,
|
|
line_range TEXT,
|
|
author TEXT,
|
|
author_id TEXT,
|
|
content TEXT,
|
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY(room_code) REFERENCES rooms(code)
|
|
)`)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS media_files (
|
|
id TEXT PRIMARY KEY,
|
|
room_code TEXT,
|
|
name TEXT,
|
|
type TEXT,
|
|
size INTEGER,
|
|
url TEXT,
|
|
uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
uploaded_by TEXT,
|
|
FOREIGN KEY(room_code) REFERENCES rooms(code)
|
|
)`)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
go startRoomCleanup()
|
|
go startPingChecker()
|
|
|
|
// Start HTTP server for file operations in a separate goroutine
|
|
go func() {
|
|
httpMux := http.NewServeMux()
|
|
httpMux.HandleFunc("/o/upload", corsMiddleware(handleFileUpload))
|
|
httpMux.HandleFunc("/o/files/", corsMiddleware(handleFileServe))
|
|
httpMux.HandleFunc("/o/delete/", corsMiddleware(handleFileDelete))
|
|
httpMux.HandleFunc("/o/purge/", corsMiddleware(handleRoomPurge))
|
|
|
|
http_port := 8090
|
|
|
|
log.Printf("HTTP file server starting on port %d...", http_port)
|
|
if err := http.ListenAndServe(fmt.Sprintf("0.0.0.0:%d", http_port), httpMux); err != nil {
|
|
log.Fatal("HTTP server error:", err)
|
|
}
|
|
}()
|
|
|
|
wsMux := http.NewServeMux()
|
|
wsMux.HandleFunc("/o/socket", handleWebSocket)
|
|
|
|
ws_port := 8100
|
|
log.Printf("WebSocket server starting on port %d...", ws_port)
|
|
if err := http.ListenAndServe(fmt.Sprintf("0.0.0.0:%d", ws_port), wsMux); err != nil {
|
|
log.Fatal("WebSocket server error:", err)
|
|
}
|
|
}
|
|
|
|
func addCORSHeaders(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
|
}
|
|
|
|
func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
addCORSHeaders(w, r)
|
|
|
|
// Handle preflight OPTIONS request
|
|
if r.Method == "OPTIONS" {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
func startPingChecker() {
|
|
for {
|
|
time.Sleep(45 * time.Second) // Check every 45 seconds
|
|
checkClientPings()
|
|
}
|
|
}
|
|
|
|
func handleFileUpload(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "POST" {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Parse multipart form
|
|
err := r.ParseMultipartForm(32 << 20) // 32MB max memory
|
|
if err != nil {
|
|
http.Error(w, "Failed to parse multipart form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
roomCode := r.FormValue("roomCode")
|
|
if roomCode == "" {
|
|
http.Error(w, "Room code is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
uploadedBy := r.FormValue("uploadedBy")
|
|
if uploadedBy == "" {
|
|
uploadedBy = "Unknown"
|
|
}
|
|
|
|
file, header, err := r.FormFile("file")
|
|
if err != nil {
|
|
http.Error(w, "Failed to get file from form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
// Generate unique filename with UUID
|
|
fileID := fmt.Sprintf("file_%d_%d", time.Now().UnixNano(), rand.Intn(10000))
|
|
filename := fmt.Sprintf("%s_%s", fileID, header.Filename)
|
|
|
|
// Create room directory if it doesn't exist
|
|
roomDir := filepath.Join(filesDir, roomCode)
|
|
if err := os.MkdirAll(roomDir, 0755); err != nil {
|
|
http.Error(w, "Failed to create room directory", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Save file to disk
|
|
filePath := filepath.Join(roomDir, filename)
|
|
dst, err := os.Create(filePath)
|
|
if err != nil {
|
|
http.Error(w, "Failed to create file", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer dst.Close()
|
|
|
|
if _, err := io.Copy(dst, file); err != nil {
|
|
http.Error(w, "Failed to save file", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Create media file object
|
|
mediaFile := MediaFile{
|
|
ID: fileID,
|
|
Name: header.Filename,
|
|
Type: header.Header.Get("Content-Type"),
|
|
Size: header.Size,
|
|
URL: fmt.Sprintf("/files/%s/%s", roomCode, filename),
|
|
UploadedAt: time.Now(),
|
|
UploadedBy: uploadedBy,
|
|
}
|
|
|
|
// Save to database
|
|
if err := saveMediaFile(roomCode, mediaFile); err != nil {
|
|
log.Printf("Error saving media file to database: %v", err)
|
|
http.Error(w, "Failed to save file metadata", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Add to room and broadcast
|
|
roomsMutex.RLock()
|
|
room, exists := rooms[roomCode]
|
|
roomsMutex.RUnlock()
|
|
|
|
if exists {
|
|
room.mutex.Lock()
|
|
room.MediaFiles = append(room.MediaFiles, mediaFile)
|
|
room.mutex.Unlock()
|
|
|
|
// Broadcast to all clients in the room
|
|
mediaMsg := MediaMessage{
|
|
BaseMessage: BaseMessage{Type: MediaUpload, Code: roomCode},
|
|
Media: mediaFile,
|
|
}
|
|
broadcastToRoom(room, mediaMsg, "")
|
|
}
|
|
|
|
// Return file info as JSON
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(mediaFile)
|
|
}
|
|
|
|
func handleFileServe(w http.ResponseWriter, r *http.Request) {
|
|
// Extract room code and filename from URL path
|
|
path := r.URL.Path[9:] // Remove "/files/" prefix
|
|
if path == "" {
|
|
http.Error(w, "Invalid file path", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Split path into room code and filename using forward slash
|
|
dir, filename := filepath.Split(path)
|
|
if dir == "" || filename == "" {
|
|
http.Error(w, "Invalid file path format", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Remove trailing slash from directory part and use as room code
|
|
roomCode := filepath.Clean(dir)
|
|
|
|
// Construct full file path
|
|
filePath := filepath.Join(filesDir, roomCode, filename)
|
|
|
|
// Security check: ensure the file is within the allowed directory
|
|
absFilesDir, _ := filepath.Abs(filesDir)
|
|
absFilePath, _ := filepath.Abs(filePath)
|
|
relPath, err := filepath.Rel(absFilesDir, absFilePath)
|
|
if err != nil || filepath.IsAbs(relPath) || len(relPath) > 0 && relPath[0] == '.' {
|
|
http.Error(w, "Access denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
// Check if file exists
|
|
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
|
http.Error(w, "File not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Set headers to force download
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
|
|
// Serve the file
|
|
http.ServeFile(w, r, filePath)
|
|
}
|
|
|
|
func handleFileDelete(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "DELETE" {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Extract room code and file ID from URL path
|
|
path := r.URL.Path[10:] // Remove "/o/delete/" prefix
|
|
if path == "" {
|
|
http.Error(w, "Invalid file path", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Split path into room code and file ID
|
|
dir, fileID := filepath.Split(path)
|
|
if dir == "" || fileID == "" {
|
|
http.Error(w, "Invalid file path format", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Remove trailing slash from directory part and use as room code
|
|
roomCode := filepath.Clean(dir)
|
|
|
|
// Get file info from database first
|
|
var filename, url string
|
|
err := db.QueryRow("SELECT name, url FROM media_files WHERE id = ? AND room_code = ?", fileID, roomCode).Scan(&filename, &url)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
http.Error(w, "File not found", http.StatusNotFound)
|
|
} else {
|
|
http.Error(w, "Database error", http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Delete file from filesystem
|
|
filePath := filepath.Join(filesDir, roomCode, filename)
|
|
if err := os.Remove(filePath); err != nil {
|
|
log.Printf("Warning: Could not delete file %s: %v", filePath, err)
|
|
}
|
|
|
|
// Delete from database
|
|
if err := deleteMediaFile(fileID); err != nil {
|
|
http.Error(w, "Failed to delete file metadata", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Remove from room and broadcast
|
|
roomsMutex.RLock()
|
|
room, exists := rooms[roomCode]
|
|
roomsMutex.RUnlock()
|
|
|
|
if exists {
|
|
room.mutex.Lock()
|
|
for i, media := range room.MediaFiles {
|
|
if media.ID == fileID {
|
|
room.MediaFiles = append(room.MediaFiles[:i], room.MediaFiles[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
room.mutex.Unlock()
|
|
|
|
// Broadcast to all clients in the room
|
|
mediaMsg := MediaMessage{
|
|
BaseMessage: BaseMessage{Type: MediaDelete, Code: roomCode},
|
|
Media: MediaFile{ID: fileID},
|
|
}
|
|
broadcastToRoom(room, mediaMsg, "")
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("File deleted successfully"))
|
|
}
|
|
|
|
func handleRoomPurge(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "DELETE" {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Extract room code from URL path
|
|
path := r.URL.Path[9:] // Remove "/purge/" prefix
|
|
if path == "" {
|
|
http.Error(w, "Room code required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
roomCode := path
|
|
|
|
// First, disconnect all clients and remove room from memory
|
|
roomsMutex.Lock()
|
|
room, exists := rooms[roomCode]
|
|
if exists {
|
|
// Disconnect all clients
|
|
room.mutex.Lock()
|
|
for _, client := range room.Clients {
|
|
if client.Conn != nil {
|
|
client.Conn.Close()
|
|
}
|
|
}
|
|
room.mutex.Unlock()
|
|
|
|
// Remove room from memory
|
|
delete(rooms, roomCode)
|
|
}
|
|
roomsMutex.Unlock()
|
|
|
|
// Delete from database - room content
|
|
if err := deleteRoomContent(roomCode); err != nil {
|
|
log.Printf("Error deleting room content: %v", err)
|
|
http.Error(w, "Failed to delete room content", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Delete all comments for this room
|
|
if err := deleteRoomComments(roomCode); err != nil {
|
|
log.Printf("Error deleting room comments: %v", err)
|
|
http.Error(w, "Failed to delete room comments", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Delete all media files for this room
|
|
if err := deleteRoomMedia(roomCode); err != nil {
|
|
log.Printf("Error deleting room media files: %v", err)
|
|
http.Error(w, "Failed to delete room media files", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Delete physical files from filesystem
|
|
roomDir := filepath.Join(filesDir, roomCode)
|
|
if err := os.RemoveAll(roomDir); err != nil {
|
|
log.Printf("Warning: Could not delete room directory %s: %v", roomDir, err)
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("Room purged successfully"))
|
|
}
|
|
|
|
func checkClientPings() {
|
|
roomsMutex.RLock()
|
|
defer roomsMutex.RUnlock()
|
|
|
|
for roomCode, room := range rooms {
|
|
room.mutex.Lock()
|
|
var disconnectedClients []string
|
|
|
|
for clientID, client := range room.Clients {
|
|
if time.Since(client.LastPing) > 60*time.Second { // 60 second timeout
|
|
disconnectedClients = append(disconnectedClients, clientID)
|
|
}
|
|
}
|
|
|
|
for _, clientID := range disconnectedClients {
|
|
if client, exists := room.Clients[clientID]; exists {
|
|
client.Conn.Close()
|
|
delete(room.Clients, clientID)
|
|
log.Printf("Client %s timed out in room %s", clientID, roomCode)
|
|
|
|
// Broadcast user left
|
|
userLeftMsg := UserMessage{
|
|
BaseMessage: BaseMessage{Type: UserLeft, Code: roomCode},
|
|
User: client.User,
|
|
}
|
|
broadcastToRoom(room, userLeftMsg, "")
|
|
}
|
|
}
|
|
room.mutex.Unlock()
|
|
}
|
|
}
|
|
|
|
func startRoomCleanup() {
|
|
for {
|
|
time.Sleep(2 * time.Hour)
|
|
deleteOldRooms()
|
|
}
|
|
}
|
|
|
|
func deleteOldRooms() {
|
|
roomsMutex.Lock()
|
|
defer roomsMutex.Unlock()
|
|
|
|
now := time.Now()
|
|
rows, err := db.Query("SELECT code FROM rooms WHERE created_at < ?", now.Add(-24*time.Hour))
|
|
if err != nil {
|
|
log.Printf("Error querying old rooms: %v", err)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var roomCode string
|
|
for rows.Next() {
|
|
if err := rows.Scan(&roomCode); err != nil {
|
|
log.Printf("Error scanning room code: %v", err)
|
|
continue
|
|
}
|
|
|
|
deleteRoomContent(roomCode)
|
|
deleteRoomComments(roomCode)
|
|
deleteRoomMedia(roomCode)
|
|
|
|
if _, exists := rooms[roomCode]; exists {
|
|
delete(rooms, roomCode)
|
|
log.Printf("Deleted room %s (older than 1 day)", roomCode)
|
|
}
|
|
}
|
|
|
|
if err := rows.Err(); err != nil {
|
|
log.Printf("Error after scanning rows: %v", err)
|
|
}
|
|
}
|
|
|
|
func handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
|
conn, err := upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
log.Printf("Error upgrading connection: %v", err)
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
|
|
log.Printf("New client connected from %s", conn.RemoteAddr())
|
|
|
|
var currentRoom string
|
|
var clientID string
|
|
|
|
for {
|
|
messageType, message, err := conn.ReadMessage()
|
|
if err != nil {
|
|
log.Printf("Error reading message from %s: %v", conn.RemoteAddr(), err)
|
|
handleClientDisconnection(currentRoom, clientID)
|
|
break
|
|
}
|
|
|
|
if messageType != websocket.TextMessage {
|
|
continue
|
|
}
|
|
|
|
var baseMsg BaseMessage
|
|
if err := json.Unmarshal(message, &baseMsg); err != nil {
|
|
log.Printf("Error unmarshaling message from %s: %v", conn.RemoteAddr(), err)
|
|
continue
|
|
}
|
|
|
|
switch baseMsg.Type {
|
|
case JoinRoom:
|
|
clientID = handleJoinRoom(conn, message, ¤tRoom)
|
|
case TextUpdate:
|
|
handleTextUpdate(conn, message, currentRoom, clientID)
|
|
case Ping:
|
|
handlePing(conn, message, currentRoom, clientID)
|
|
case CommentAdd:
|
|
handleCommentAdd(conn, message, currentRoom, clientID)
|
|
case CommentUpdate:
|
|
handleCommentUpdate(conn, message, currentRoom, clientID)
|
|
case CommentDelete:
|
|
handleCommentDelete(conn, message, currentRoom, clientID)
|
|
case UserActivity:
|
|
handleUserActivity(conn, message, currentRoom, clientID)
|
|
case MediaUpload:
|
|
handleMediaUpload(conn, message, currentRoom, clientID)
|
|
case MediaDelete:
|
|
handleMediaDelete(conn, message, currentRoom, clientID)
|
|
default:
|
|
log.Printf("Unknown message type received: %s", baseMsg.Type)
|
|
}
|
|
}
|
|
}
|
|
|
|
func generateClientID() string {
|
|
return fmt.Sprintf("client_%d_%d", time.Now().UnixNano(), rand.Intn(10000))
|
|
}
|
|
|
|
func generateUserColor() string {
|
|
colors := []string{"#e74c3c", "#3498db", "#2ecc71", "#f39c12", "#9b59b6", "#1abc9c", "#e67e22", "#34495e"}
|
|
return colors[rand.Intn(len(colors))]
|
|
}
|
|
|
|
func generateUserName() string {
|
|
adjectives := []string{"Red", "Blue", "Green", "Yellow", "Purple", "Orange", "Pink", "Brown"}
|
|
nouns := []string{"Cat", "Dog", "Bird", "Fish", "Bear", "Lion", "Tiger", "Wolf"}
|
|
return fmt.Sprintf("%s %s", adjectives[rand.Intn(len(adjectives))], nouns[rand.Intn(len(nouns))])
|
|
}
|
|
|
|
func broadcastToRoom(room *Room, message interface{}, excludeClientID string) {
|
|
for clientID, client := range room.Clients {
|
|
if clientID != excludeClientID {
|
|
if err := client.Conn.WriteJSON(message); err != nil {
|
|
log.Printf("Error broadcasting to client %s: %v", clientID, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func handleJoinRoom(conn *websocket.Conn, message []byte, currentRoom *string) string {
|
|
var joinMsg JoinRoomMessage
|
|
if err := json.Unmarshal(message, &joinMsg); err != nil {
|
|
log.Printf("Error unmarshaling join room message: %v", err)
|
|
return ""
|
|
}
|
|
|
|
if *currentRoom != "" {
|
|
leaveRoom(*currentRoom, "")
|
|
}
|
|
|
|
*currentRoom = joinMsg.Code
|
|
clientID := generateClientID()
|
|
|
|
// Create user if not provided
|
|
user := joinMsg.User
|
|
if user.ID == "" {
|
|
user.ID = clientID
|
|
}
|
|
if user.Name == "" {
|
|
user.Name = generateUserName()
|
|
}
|
|
if user.Color == "" {
|
|
user.Color = generateUserColor()
|
|
}
|
|
user.LastSeen = time.Now()
|
|
|
|
log.Printf("Client %s joining room: %s as user %s", conn.RemoteAddr(), *currentRoom, user.Name)
|
|
|
|
roomsMutex.Lock()
|
|
if _, exists := rooms[*currentRoom]; !exists {
|
|
content, err := getRoomContent(*currentRoom)
|
|
if err != nil {
|
|
log.Printf("Error retrieving content for room %s: %v", *currentRoom, err)
|
|
}
|
|
|
|
comments, err := getRoomComments(*currentRoom)
|
|
if err != nil {
|
|
log.Printf("Error retrieving comments for room %s: %v", *currentRoom, err)
|
|
}
|
|
|
|
mediaFiles, err := getRoomMedia(*currentRoom)
|
|
if err != nil {
|
|
log.Printf("Error retrieving media for room %s: %v", *currentRoom, err)
|
|
}
|
|
|
|
rooms[*currentRoom] = &Room{
|
|
Content: content,
|
|
Clients: make(map[string]*Client),
|
|
Comments: comments,
|
|
MediaFiles: mediaFiles,
|
|
mutex: sync.RWMutex{},
|
|
CreatedAt: time.Now(),
|
|
}
|
|
log.Printf("Created new room: %s", *currentRoom)
|
|
}
|
|
room := rooms[*currentRoom]
|
|
room.mutex.Lock()
|
|
room.Clients[clientID] = &Client{
|
|
Conn: conn,
|
|
User: user,
|
|
LastPing: time.Now(),
|
|
}
|
|
room.mutex.Unlock()
|
|
roomsMutex.Unlock()
|
|
|
|
// Send initial content
|
|
initialMsg := InitialContentMessage{
|
|
BaseMessage: BaseMessage{
|
|
Type: InitialContent,
|
|
Code: *currentRoom,
|
|
},
|
|
Content: room.Content,
|
|
}
|
|
|
|
if err := conn.WriteJSON(initialMsg); err != nil {
|
|
log.Printf("Error sending initial content to %s: %v", conn.RemoteAddr(), err)
|
|
}
|
|
|
|
// Send comments
|
|
commentsMsg := CommentsMessage{
|
|
BaseMessage: BaseMessage{Type: CommentsSync, Code: *currentRoom},
|
|
Comments: room.Comments,
|
|
}
|
|
if err := conn.WriteJSON(commentsMsg); err != nil {
|
|
log.Printf("Error sending comments to %s: %v", conn.RemoteAddr(), err)
|
|
}
|
|
|
|
// Send media files
|
|
mediaMsg := MediaSyncMessage{
|
|
BaseMessage: BaseMessage{Type: MediaSync, Code: *currentRoom},
|
|
MediaFiles: room.MediaFiles,
|
|
}
|
|
if err := conn.WriteJSON(mediaMsg); err != nil {
|
|
log.Printf("Error sending media to %s: %v", conn.RemoteAddr(), err)
|
|
}
|
|
|
|
// Send current users
|
|
var users []User
|
|
for _, client := range room.Clients {
|
|
users = append(users, client.User)
|
|
}
|
|
usersMsg := UsersMessage{
|
|
BaseMessage: BaseMessage{Type: UsersSync, Code: *currentRoom},
|
|
Users: users,
|
|
}
|
|
if err := conn.WriteJSON(usersMsg); err != nil {
|
|
log.Printf("Error sending users to %s: %v", conn.RemoteAddr(), err)
|
|
}
|
|
|
|
// Broadcast user joined to others
|
|
userJoinedMsg := UserMessage{
|
|
BaseMessage: BaseMessage{Type: UserJoined, Code: *currentRoom},
|
|
User: user,
|
|
}
|
|
broadcastToRoom(room, userJoinedMsg, clientID)
|
|
|
|
return clientID
|
|
}
|
|
|
|
func handleTextUpdate(conn *websocket.Conn, message []byte, currentRoom string, clientID string) {
|
|
if currentRoom == "" || clientID == "" {
|
|
return
|
|
}
|
|
|
|
var updateMsg TextUpdateMessage
|
|
if err := json.Unmarshal(message, &updateMsg); err != nil {
|
|
log.Printf("Error unmarshaling text update message: %v", err)
|
|
return
|
|
}
|
|
|
|
roomsMutex.RLock()
|
|
room, exists := rooms[currentRoom]
|
|
roomsMutex.RUnlock()
|
|
|
|
if !exists {
|
|
return
|
|
}
|
|
|
|
room.mutex.Lock()
|
|
room.Content = updateMsg.Content
|
|
room.mutex.Unlock()
|
|
|
|
if err := saveRoomContent(currentRoom, room.Content); err != nil {
|
|
log.Printf("Error saving content for room %s: %v", currentRoom, err)
|
|
}
|
|
|
|
log.Printf("Broadcasting text update in room %s from client %s", currentRoom, clientID)
|
|
|
|
room.mutex.RLock()
|
|
broadcastToRoom(room, updateMsg, clientID)
|
|
room.mutex.RUnlock()
|
|
}
|
|
|
|
func handlePing(conn *websocket.Conn, message []byte, currentRoom string, clientID string) {
|
|
if currentRoom == "" || clientID == "" {
|
|
return
|
|
}
|
|
|
|
roomsMutex.RLock()
|
|
room, exists := rooms[currentRoom]
|
|
roomsMutex.RUnlock()
|
|
|
|
if !exists {
|
|
return
|
|
}
|
|
|
|
room.mutex.Lock()
|
|
if client, exists := room.Clients[clientID]; exists {
|
|
client.LastPing = time.Now()
|
|
client.User.LastSeen = time.Now()
|
|
}
|
|
room.mutex.Unlock()
|
|
|
|
// Send pong response
|
|
pongMsg := PongMessage{
|
|
BaseMessage: BaseMessage{Type: Pong, Code: currentRoom},
|
|
}
|
|
conn.WriteJSON(pongMsg)
|
|
}
|
|
|
|
func handleCommentAdd(conn *websocket.Conn, message []byte, currentRoom string, clientID string) {
|
|
if currentRoom == "" || clientID == "" {
|
|
return
|
|
}
|
|
|
|
var commentMsg CommentMessage
|
|
if err := json.Unmarshal(message, &commentMsg); err != nil {
|
|
log.Printf("Error unmarshaling comment message: %v", err)
|
|
return
|
|
}
|
|
|
|
roomsMutex.RLock()
|
|
room, exists := rooms[currentRoom]
|
|
roomsMutex.RUnlock()
|
|
|
|
if !exists {
|
|
return
|
|
}
|
|
|
|
// Generate comment ID and set timestamp
|
|
commentMsg.Comment.ID = fmt.Sprintf("comment_%d", time.Now().UnixNano())
|
|
commentMsg.Comment.Timestamp = time.Now()
|
|
|
|
// Set author from client user
|
|
room.mutex.RLock()
|
|
if client, exists := room.Clients[clientID]; exists {
|
|
commentMsg.Comment.Author = client.User.Name
|
|
commentMsg.Comment.AuthorID = client.User.ID
|
|
}
|
|
room.mutex.RUnlock()
|
|
|
|
// Save to database
|
|
if err := saveComment(currentRoom, commentMsg.Comment); err != nil {
|
|
log.Printf("Error saving comment: %v", err)
|
|
return
|
|
}
|
|
|
|
// Add to room
|
|
room.mutex.Lock()
|
|
room.Comments = append(room.Comments, commentMsg.Comment)
|
|
room.mutex.Unlock()
|
|
|
|
// Broadcast to all clients
|
|
broadcastToRoom(room, commentMsg, "")
|
|
}
|
|
|
|
func handleCommentUpdate(conn *websocket.Conn, message []byte, currentRoom string, clientID string) {
|
|
// Similar implementation for comment updates
|
|
}
|
|
|
|
func handleCommentDelete(conn *websocket.Conn, message []byte, currentRoom string, clientID string) {
|
|
if currentRoom == "" || clientID == "" {
|
|
return
|
|
}
|
|
|
|
var commentMsg CommentMessage
|
|
if err := json.Unmarshal(message, &commentMsg); err != nil {
|
|
log.Printf("Error unmarshaling comment delete message: %v", err)
|
|
return
|
|
}
|
|
|
|
roomsMutex.RLock()
|
|
room, exists := rooms[currentRoom]
|
|
roomsMutex.RUnlock()
|
|
|
|
if !exists {
|
|
return
|
|
}
|
|
|
|
// Allow anyone to delete comments - no authorization check needed
|
|
|
|
// Delete from database
|
|
if err := deleteComment(commentMsg.Comment.ID); err != nil {
|
|
log.Printf("Error deleting comment from database: %v", err)
|
|
return
|
|
}
|
|
|
|
// Remove from room
|
|
room.mutex.Lock()
|
|
for i, comment := range room.Comments {
|
|
if comment.ID == commentMsg.Comment.ID {
|
|
room.Comments = append(room.Comments[:i], room.Comments[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
room.mutex.Unlock()
|
|
|
|
// Broadcast to all clients
|
|
broadcastToRoom(room, commentMsg, "")
|
|
}
|
|
|
|
func handleUserActivity(conn *websocket.Conn, message []byte, currentRoom string, clientID string) {
|
|
if currentRoom == "" || clientID == "" {
|
|
return
|
|
}
|
|
|
|
var activityMsg UserActivityMessage
|
|
if err := json.Unmarshal(message, &activityMsg); err != nil {
|
|
log.Printf("Error unmarshaling user activity message: %v", err)
|
|
return
|
|
}
|
|
|
|
roomsMutex.RLock()
|
|
room, exists := rooms[currentRoom]
|
|
roomsMutex.RUnlock()
|
|
|
|
if !exists {
|
|
return
|
|
}
|
|
|
|
room.mutex.Lock()
|
|
if client, exists := room.Clients[clientID]; exists {
|
|
client.User.IsTyping = activityMsg.IsTyping
|
|
client.User.CurrentLine = activityMsg.CurrentLine
|
|
client.User.LastSeen = time.Now()
|
|
}
|
|
room.mutex.Unlock()
|
|
|
|
// Broadcast activity to others
|
|
broadcastToRoom(room, activityMsg, clientID)
|
|
}
|
|
|
|
func handleMediaUpload(conn *websocket.Conn, message []byte, currentRoom string, clientID string) {
|
|
if currentRoom == "" || clientID == "" {
|
|
return
|
|
}
|
|
|
|
var mediaMsg MediaMessage
|
|
if err := json.Unmarshal(message, &mediaMsg); err != nil {
|
|
log.Printf("Error unmarshaling media upload message: %v", err)
|
|
return
|
|
}
|
|
|
|
roomsMutex.RLock()
|
|
room, exists := rooms[currentRoom]
|
|
roomsMutex.RUnlock()
|
|
|
|
if !exists {
|
|
return
|
|
}
|
|
|
|
// Set upload metadata from client user
|
|
room.mutex.RLock()
|
|
if client, exists := room.Clients[clientID]; exists {
|
|
mediaMsg.Media.UploadedBy = client.User.Name
|
|
}
|
|
room.mutex.RUnlock()
|
|
|
|
// Generate unique ID if not provided
|
|
if mediaMsg.Media.ID == "" {
|
|
mediaMsg.Media.ID = fmt.Sprintf("media_%d", time.Now().UnixNano())
|
|
}
|
|
|
|
// Save to database
|
|
if err := saveMediaFile(currentRoom, mediaMsg.Media); err != nil {
|
|
log.Printf("Error saving media file: %v", err)
|
|
return
|
|
}
|
|
|
|
// Add to room
|
|
room.mutex.Lock()
|
|
room.MediaFiles = append(room.MediaFiles, mediaMsg.Media)
|
|
room.mutex.Unlock()
|
|
|
|
// Broadcast to all clients
|
|
broadcastToRoom(room, mediaMsg, "")
|
|
}
|
|
|
|
func handleMediaDelete(_ *websocket.Conn, message []byte, currentRoom string, clientID string) {
|
|
if currentRoom == "" || clientID == "" {
|
|
return
|
|
}
|
|
|
|
var mediaMsg MediaMessage
|
|
if err := json.Unmarshal(message, &mediaMsg); err != nil {
|
|
log.Printf("Error unmarshaling media delete message: %v", err)
|
|
return
|
|
}
|
|
|
|
roomsMutex.RLock()
|
|
room, exists := rooms[currentRoom]
|
|
roomsMutex.RUnlock()
|
|
|
|
if !exists {
|
|
return
|
|
}
|
|
|
|
// Remove from database
|
|
if err := deleteMediaFile(mediaMsg.Media.ID); err != nil {
|
|
log.Printf("Error deleting media file: %v", err)
|
|
return
|
|
}
|
|
|
|
// Remove from room
|
|
room.mutex.Lock()
|
|
for i, media := range room.MediaFiles {
|
|
if media.ID == mediaMsg.Media.ID {
|
|
room.MediaFiles = append(room.MediaFiles[:i], room.MediaFiles[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
room.mutex.Unlock()
|
|
|
|
// Broadcast to all clients
|
|
broadcastToRoom(room, mediaMsg, "")
|
|
}
|
|
|
|
func leaveRoom(roomCode string, clientID string) {
|
|
roomsMutex.Lock()
|
|
defer roomsMutex.Unlock()
|
|
|
|
if room, exists := rooms[roomCode]; exists {
|
|
room.mutex.Lock()
|
|
if client, exists := room.Clients[clientID]; exists {
|
|
delete(room.Clients, clientID)
|
|
|
|
// Broadcast user left
|
|
userLeftMsg := UserMessage{
|
|
BaseMessage: BaseMessage{Type: UserLeft, Code: roomCode},
|
|
User: client.User,
|
|
}
|
|
broadcastToRoom(room, userLeftMsg, "")
|
|
}
|
|
|
|
clientCount := len(room.Clients)
|
|
room.mutex.Unlock()
|
|
|
|
if clientCount == 0 && time.Since(room.CreatedAt) > 24*time.Hour {
|
|
deleteRoomContent(roomCode)
|
|
deleteRoomComments(roomCode)
|
|
deleteRoomMedia(roomCode)
|
|
delete(rooms, roomCode)
|
|
log.Printf("Room %s deleted (no clients remaining and older than 1 day)", roomCode)
|
|
}
|
|
log.Printf("Client %s left room %s", clientID, roomCode)
|
|
}
|
|
}
|
|
|
|
func handleClientDisconnection(currentRoom string, clientID string) {
|
|
if currentRoom != "" && clientID != "" {
|
|
leaveRoom(currentRoom, clientID)
|
|
}
|
|
log.Printf("Client %s disconnected", clientID)
|
|
}
|
|
|
|
func saveRoomContent(code, content string) error {
|
|
_, err := db.Exec("INSERT OR REPLACE INTO rooms (code, content) VALUES (?, ?)", code, content)
|
|
return err
|
|
}
|
|
|
|
func getRoomContent(code string) (string, error) {
|
|
var content string
|
|
err := db.QueryRow("SELECT content FROM rooms WHERE code = ?", code).Scan(&content)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return "", nil
|
|
}
|
|
return "", err
|
|
}
|
|
return content, nil
|
|
}
|
|
|
|
func deleteRoomContent(code string) error {
|
|
_, err := db.Exec("DELETE FROM rooms WHERE code = ?", code)
|
|
return err
|
|
}
|
|
|
|
func saveComment(roomCode string, comment Comment) error {
|
|
_, err := db.Exec(`INSERT INTO comments (id, room_code, line_number, line_range, author, author_id, content, timestamp)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
comment.ID, roomCode, comment.LineNumber, comment.LineRange,
|
|
comment.Author, comment.AuthorID, comment.Content, comment.Timestamp)
|
|
return err
|
|
}
|
|
|
|
func deleteComment(commentID string) error {
|
|
_, err := db.Exec(`DELETE FROM comments WHERE id = ?`, commentID)
|
|
return err
|
|
}
|
|
|
|
func getRoomComments(roomCode string) ([]Comment, error) {
|
|
rows, err := db.Query(`SELECT id, line_number, line_range, author, author_id, content, timestamp
|
|
FROM comments WHERE room_code = ? ORDER BY timestamp ASC`, roomCode)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var comments []Comment
|
|
for rows.Next() {
|
|
var comment Comment
|
|
err := rows.Scan(&comment.ID, &comment.LineNumber, &comment.LineRange,
|
|
&comment.Author, &comment.AuthorID, &comment.Content, &comment.Timestamp)
|
|
if err != nil {
|
|
log.Printf("Error scanning comment: %v", err)
|
|
continue
|
|
}
|
|
comments = append(comments, comment)
|
|
}
|
|
return comments, nil
|
|
}
|
|
|
|
func deleteRoomComments(roomCode string) error {
|
|
_, err := db.Exec("DELETE FROM comments WHERE room_code = ?", roomCode)
|
|
return err
|
|
}
|
|
|
|
func getRoomMedia(roomCode string) ([]MediaFile, error) {
|
|
rows, err := db.Query(`SELECT id, name, type, size, url, uploaded_at, uploaded_by
|
|
FROM media_files WHERE room_code = ? ORDER BY uploaded_at ASC`, roomCode)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var mediaFiles []MediaFile
|
|
for rows.Next() {
|
|
var media MediaFile
|
|
err := rows.Scan(&media.ID, &media.Name, &media.Type, &media.Size,
|
|
&media.URL, &media.UploadedAt, &media.UploadedBy)
|
|
if err != nil {
|
|
log.Printf("Error scanning media file: %v", err)
|
|
continue
|
|
}
|
|
mediaFiles = append(mediaFiles, media)
|
|
}
|
|
return mediaFiles, nil
|
|
}
|
|
|
|
func deleteRoomMedia(roomCode string) error {
|
|
_, err := db.Exec("DELETE FROM media_files WHERE room_code = ?", roomCode)
|
|
return err
|
|
}
|
|
|
|
func saveMediaFile(roomCode string, media MediaFile) error {
|
|
_, err := db.Exec(`INSERT INTO media_files (id, room_code, name, type, size, url, uploaded_at, uploaded_by)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
media.ID, roomCode, media.Name, media.Type, media.Size,
|
|
media.URL, media.UploadedAt, media.UploadedBy)
|
|
return err
|
|
}
|
|
|
|
func deleteMediaFile(mediaID string) error {
|
|
_, err := db.Exec("DELETE FROM media_files WHERE id = ?", mediaID)
|
|
return err
|
|
}
|