From fb1e8515231ceb32ca4f3397cd937f2028a4e2bd Mon Sep 17 00:00:00 2001 From: alireza parvaresh Date: Thu, 29 Jan 2026 13:23:54 +0330 Subject: [PATCH] feat: WhatsApp-like chat app with SSE, auth, groups, media upload --- .gitignore | 16 ++ Dockerfile | 13 +- README.md | 401 ++++++++++++++-------------- cmd/server/main.go | 602 ++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 23 +- go.mod | 2 + go.sum | 2 + public/app.js | 582 ++++++++++++++++++++++++++++++++++++++++ public/index.html | 641 +++++++++++++++++++++++++++++++++++++++++++++ run.sh | 49 ++++ 10 files changed, 2107 insertions(+), 224 deletions(-) create mode 100644 .gitignore create mode 100644 cmd/server/main.go create mode 100644 public/app.js create mode 100644 public/index.html create mode 100755 run.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a0ff092 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Binary +chat-server + +# Database +chat.db + +# Uploads +uploads/ + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db diff --git a/Dockerfile b/Dockerfile index 1aac6d3..ec47c04 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,8 @@ FROM golang:1.21 AS builder WORKDIR /app +RUN apt-get update && apt-get install -y gcc sqlite3 libsqlite3-dev + # Copy go.mod and go.sum for dependency caching COPY go.mod go.sum ./ RUN go mod download @@ -9,17 +11,20 @@ RUN go mod download # Copy all source files COPY . . -# Build server and client binaries -RUN go build -o chat-server ./server.go -RUN go build -o chat-client ./client.go +# Build server binary +RUN CGO_ENABLED=1 go build -o chat-server ./cmd/server # Stage 2: Runtime FROM debian:bullseye-slim WORKDIR /app +RUN apt-get update && apt-get install -y ca-certificates sqlite3 && rm -rf /var/lib/apt/lists/* + # Copy binaries from builder COPY --from=builder /app/chat-server . -COPY --from=builder /app/chat-client . +COPY --from=builder /app/public ./public + +RUN mkdir -p uploads # Expose server port EXPOSE 8080 diff --git a/README.md b/README.md index 408c8b1..8247e27 100644 --- a/README.md +++ b/README.md @@ -1,118 +1,110 @@ # TCP Chat Application -> **⚠️ WARNING - CRITICAL INFRASTRUCTURE** +> **WARNING - CRITICAL INFRASTRUCTURE** > This repository is designed to facilitate communication for Iranian citizens during protests and internet shutdowns. It operates on domestic servers to maintain connectivity when external internet access is restricted or censored. Use responsibly and ensure secure deployment in sensitive environments. -A feature-rich TCP chat application built in Go with user registration and file sharing capabilities, designed for resilient local network communication. +A feature-rich chat application with a WhatsApp-like UI, built with Go and Server-Sent Events for real-time communication. --- -## Features - -* **User Registration** - Register users with first and last name -* **Real-time Messaging** - Instant messaging between connected users -* **File Sharing** - Upload and share files (images, videos, documents) -* **Multi-Client Support** - Support for multiple simultaneous users -* **TCP Server** - Chat server on port 8080 -* **HTTP File Server** - File upload server on port 8081 -* **Dockerized** - Ready for deployment with Docker and Docker Compose -* **Offline-First** - Works on local networks without internet connectivity +## Features + +### Authentication and Security +* **User Registration and Login** - Complete authentication system +* **Password Hashing** - Using SHA256 +* **Secure Sessions** - User management with SSE + +### Messaging +* **Private Chat** - One-on-one messaging +* **Group Chats** - Create and manage groups +* **File Sharing** - Send images, videos, and files +* **Typing Indicator** - Shows when user is typing +* **Real-time Messaging** - Using Server-Sent Events + +### User Interface +* **WhatsApp-like Design** - Modern and familiar UI +* **Dark Mode** - Eye-comfortable dark theme +* **Responsive** - Works on desktop and mobile +* **Media Preview** - Display images and videos in chat +* **Smooth Animations** - Professional user experience + +### Technical +* **SQLite Database** - Data storage +* **Server-Sent Events** - Real-time communications +* **RESTful API** - Modern architecture +* **Docker** - Ready for deployment +* **Offline Capability** - Works on local networks without internet --- -## Project Structure +## Project Structure ``` -├── server.go # Chat and file upload server code -├── client.go # Client code with registration and upload -├── Dockerfile # Docker image for server and client -├── docker-compose.yml # Service configuration -├── go.mod # Go module file -├── go.sum # Dependency checksums -└── README.md # Project documentation +chatApp/ +├── cmd/ +│ └── server/ +│ └── main.go # Main server with SSE and API +├── public/ +│ ├── index.html # Web UI +│ └── app.js # Frontend logic +├── uploads/ # Uploaded files +├── chat.db # SQLite database +├── Dockerfile # Docker image +├── docker-compose.yml # Service configuration +├── go.mod # Go dependencies +└── README.md # This file ``` --- -## Quick Start +## Quick Start ### Prerequisites * Go 1.21 or later -* Docker (optional - for containerized deployment) -* Docker Compose (optional) +* Docker and Docker Compose (optional) +* Modern web browser --- -## Running Locally +## Running Locally -### 1. Run Server +### 1. Install Dependencies ```bash -# Build -go build -o chat-server ./server.go - -# Run -./chat-server +go mod download ``` -The server runs on two ports: -- **Port 8080**: TCP Chat Server -- **Port 8081**: HTTP File Upload Server - -### 2. Run Client +### 2. Run Server ```bash -# Build -go build -o chat-client ./client.go - -# Run -./chat-client +go run cmd/server/main.go ``` -Upon running, you will be prompted for: -1. **First name** -2. **Last name** - ---- +Server runs on `http://localhost:8080` -## How to Use - -### Send Text Message -Simply type your message and press Enter: -``` -Hello everyone! -``` - -### Upload File -Use the `/upload` command: -``` -/upload /path/to/your/file.jpg -``` +### 3. Open in Browser -**Notes:** -- Image files: `.jpg`, `.png`, `.gif`, `.webp` (displayed as image) -- Video files: `.mp4`, `.mov`, `.webm` (displayed as video) -- Other files: displayed as file -- Maximum file size: 50MB +Go to `http://localhost:8080` and: +1. Register or login +2. Search for other users +3. Start chatting! --- -## Running with Docker +## Running with Docker -### 1. Build Images +### Build and Run ```bash -docker-compose build +docker-compose up --build ``` -### 2. Start Services +### Access the Application -```bash -docker-compose up -``` +Go to `http://localhost:8080` -### 3. Stop Services +### Stop Services ```bash docker-compose down @@ -120,187 +112,192 @@ docker-compose down --- -## Architecture - -### Server Architecture - -**The server consists of two main components:** - -1. **TCP Server (Port 8080)** - - Manages client connections - - User registration - - Broadcasts messages to all connected users - -2. **HTTP Server (Port 8081)** - - Receives and stores files in `uploads/` directory - - Serves uploaded files - - Generates URLs for file access - -### Message Types - -```json -// Registration -{ - "type": "register", - "first": "John", - "last": "Doe" -} - -// Text Message -{ - "type": "text", - "text": "Your message here" -} - -// Media Message -{ - "type": "media", - "url": "http://localhost:8081/uploads/file.jpg", - "mediaType": "image|video|file" -} - -// Info Message (from server) -{ - "type": "info", - "text": "Information message" -} -``` +## Usage Guide ---- +### Registration and Login -## 🔧 Configuration +1. Open the login page +2. Click "Register" +3. Enter username, full name, and password +4. After registration, login -### Change Server Address +### Starting a Private Chat -In `client.go`: -```go -conn, err := net.Dial("tcp", "localhost:8080") -``` +1. Click the chat button (New Chat) +2. Select a user from the list +3. Start sending messages! -### Change Maximum File Size +### Creating a Group -In `server.go`: -```go -err := r.ParseMultipartForm(50 << 20) // 50MB - you can change this -``` +1. Click the group button (New Group) +2. Enter group name +3. Select members from the list +4. Click "Create Group" ---- +### Sending Media -## CI/CD with GitHub Actions +1. Click the attachment button in the input area +2. Select an image or video +3. File is automatically uploaded and sent -The project includes GitHub Actions workflow for: -- Building Go binaries -- Running tests -- Building Docker images -- Deployment (optional) +### Additional Features + +* **Search**: Use the search box to find contacts +* **Typing**: When you type, the other party sees it +* **Online Status**: See online status indicator --- -## Development +## API Endpoints -### Add New Features +### Authentication -Some development ideas: -- **Chat Rooms** - Create multiple channels -- **Message Encryption** - Enhanced security for data transmission -- **Message History** - Store messages in database -- **WebSocket Support** - Web browser compatibility -- **Private Messages** - Direct messaging between two users -- **Notifications** - Alerts for new messages -- **Authentication** - Login system with passwords -- **End-to-End Encryption** - For maximum privacy in sensitive communications +* `POST /api/register` - Register new user +* `POST /api/login` - User login -### Code Structure +### Users and Groups -```go -// Server Components -- handleConnection() // Manage connections -- broadcast() // Broadcast messages to all -- uploadHandler() // Handle file uploads +* `GET /api/users` - Get user list +* `GET /api/groups?userId={id}` - Get user's groups +* `POST /api/groups` - Create new group -// Client Components -- uploadFile() // Upload file -- detectMediaType() // Detect media type -``` +### Messages + +* `GET /api/messages?userId={id}&contactId={id}` - Get private messages +* `GET /api/messages?userId={id}&groupId={id}` - Get group messages +* `GET /events?userId={id}` - SSE connection + +### Media + +* `POST /api/upload` - Upload file +* `GET /uploads/{filename}` - Get uploaded file --- -## Troubleshooting +## Database Schema -### "Connection refused" Error -```bash -# Make sure the server is running -ps aux | grep server.go +### users table +```sql +- id (INTEGER PRIMARY KEY) +- username (TEXT UNIQUE) +- full_name (TEXT) +- password (TEXT - SHA256 hash) +- created_at (DATETIME) ``` -### File Upload Issues -- Check file size (maximum 50MB) -- Enter the correct file path -- Make sure HTTP server is running on port 8081 +### groups table +```sql +- id (INTEGER PRIMARY KEY) +- name (TEXT) +- creator_id (INTEGER) +- created_at (DATETIME) +``` -### Port Already in Use -```bash -# Find process on port 8080 or 8081 -lsof -i :8080 -lsof -i :8081 +### group_members table +```sql +- group_id (INTEGER) +- user_id (INTEGER) +- joined_at (DATETIME) +``` -# Kill the process -kill -9 +### messages table +```sql +- id (INTEGER PRIMARY KEY) +- from_user (INTEGER) +- to_user (INTEGER, nullable) +- group_id (INTEGER, nullable) +- content (TEXT, nullable) +- media_url (TEXT, nullable) +- media_type (TEXT, nullable) +- timestamp (DATETIME) ``` --- -## API Reference +## Production Deployment -### TCP Protocol +### Environment Variables -**Register:** -```json -{"type": "register", "first": "Ali", "last": "Ahmadi"} +```bash +PORT=8080 # Server port ``` -**Send Text:** -```json -{"type": "text", "text": "Hello World"} -``` +### Security Notes -**Send Media:** -```json -{"type": "media", "url": "http://...", "mediaType": "image"} -``` +1. Use HTTPS in production +2. Enforce strong passwords +3. Implement rate limiting +4. Regular database backups +5. Input validation and sanitization -### HTTP API +--- + +## Development + +### Build from Source -**Upload File:** ```bash -curl -X POST http://localhost:8081/upload \ - -F "file=@/path/to/file.jpg" +CGO_ENABLED=1 go build -o chat-server ./cmd/server +./chat-server ``` -**Response:** -```json -{"url": "http://localhost:8081/uploads/1234567890.jpg"} -``` +### Dependencies + +* `github.com/mattn/go-sqlite3` - SQLite driver --- -## Contributing +## TODO / Future Improvements -Contributions are welcome! Feel free to: -- Report bugs -- Suggest new features -- Submit pull requests +- [ ] JWT authentication +- [ ] End-to-end encryption +- [ ] Voice/video calls +- [ ] Push notifications +- [ ] Emoji reactions +- [ ] Location sharing +- [ ] Voice messages +- [ ] Custom themes +- [ ] Mobile app (React Native) --- -## 👨‍💻 Author +## Contributing + +Contributions are welcome! Please: -Built with ❤️ using Go +1. Fork the repository +2. Create a feature branch +3. Commit your changes +4. Push to the branch +5. Create a Pull Request --- -## 📄 License +## License + +This project is released under the MIT License. + +--- + +## Ethical Use + +This tool is designed to help free communication during critical times. Please: + +* Use responsibly and legally +* Respect user privacy +* Avoid misuse +* Be aware of security requirements + +--- + +## Support + +For issues, questions, or feature requests: +* Create an Issue on GitHub +* Join discussions + +--- -This project is licensed under the MIT License. +**Built for free communication** diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..03eed86 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,602 @@ +package main + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strconv" + "sync" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +var ( + db *sql.DB + clients = make(map[int]*Client) + mu sync.RWMutex +) + +// Client represents a connected user +type Client struct { + ID int + Username string + Messages chan []byte + Done chan bool +} + +// User represents a user in the database +type User struct { + ID int `json:"id"` + Username string `json:"username"` + FullName string `json:"fullName"` +} + +// Message represents a chat message +type Message struct { + Type string `json:"type"` + Content string `json:"content,omitempty"` + From int `json:"from,omitempty"` + To int `json:"to,omitempty"` + GroupID int `json:"groupId,omitempty"` + MediaURL string `json:"mediaUrl,omitempty"` + MediaType string `json:"mediaType,omitempty"` + Timestamp int64 `json:"timestamp,omitempty"` +} + +// Group represents a chat group +type Group struct { + ID int `json:"id"` + Name string `json:"name"` + Creator int `json:"creator"` +} + +// initDB initializes the SQLite database and creates tables +func initDB() { + var err error + db, err = sql.Open("sqlite3", "./chat.db") + if err != nil { + log.Fatal(err) + } + + // Create users table + db.Exec(`CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + full_name TEXT NOT NULL, + password TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`) + + // Create groups table + db.Exec(`CREATE TABLE IF NOT EXISTS groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + creator_id INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`) + + // Create group members table + db.Exec(`CREATE TABLE IF NOT EXISTS group_members ( + group_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + joined_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY(group_id, user_id) + )`) + + // Create messages table + db.Exec(`CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_user INTEGER NOT NULL, + to_user INTEGER, + group_id INTEGER, + content TEXT, + media_url TEXT, + media_type TEXT, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP + )`) + + log.Println("[OK] Database initialized") +} + +// hashPassword creates a SHA256 hash of the password +func hashPassword(password string) string { + hash := sha256.Sum256([]byte(password + "chat_salt_2024")) + return hex.EncodeToString(hash[:]) +} + +// enableCORS sets CORS headers for cross-origin requests +func enableCORS(w http.ResponseWriter) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") +} + +// registerHandler handles user registration +func registerHandler(w http.ResponseWriter, r *http.Request) { + enableCORS(w) + if r.Method == "OPTIONS" { + return + } + + var req struct { + Username string `json:"username"` + FullName string `json:"fullName"` + Password string `json:"password"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + hash := hashPassword(req.Password) + + result, err := db.Exec("INSERT INTO users (username, full_name, password) VALUES (?, ?, ?)", + req.Username, req.FullName, hash) + if err != nil { + http.Error(w, "Username already exists", http.StatusConflict) + return + } + + id, _ := result.LastInsertId() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": id, + "username": req.Username, + "fullName": req.FullName, + }) +} + +// loginHandler handles user authentication +func loginHandler(w http.ResponseWriter, r *http.Request) { + enableCORS(w) + if r.Method == "OPTIONS" { + return + } + + var req struct { + Username string `json:"username"` + Password string `json:"password"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + var user User + var storedHash string + err := db.QueryRow("SELECT id, username, full_name, password FROM users WHERE username = ?", + req.Username).Scan(&user.ID, &user.Username, &user.FullName, &storedHash) + + if err != nil || hashPassword(req.Password) != storedHash { + http.Error(w, "Invalid credentials", http.StatusUnauthorized) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": user.ID, + "username": user.Username, + "fullName": user.FullName, + }) +} + +// getUsersHandler returns list of all users +func getUsersHandler(w http.ResponseWriter, r *http.Request) { + enableCORS(w) + rows, err := db.Query("SELECT id, username, full_name FROM users ORDER BY username") + if err != nil { + http.Error(w, "Server error", http.StatusInternalServerError) + return + } + defer rows.Close() + + var users []User + for rows.Next() { + var u User + rows.Scan(&u.ID, &u.Username, &u.FullName) + users = append(users, u) + } + + if users == nil { + users = []User{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(users) +} + +// createGroupHandler creates a new chat group +func createGroupHandler(w http.ResponseWriter, r *http.Request) { + enableCORS(w) + if r.Method == "OPTIONS" { + return + } + + var req struct { + Name string `json:"name"` + CreatorID int `json:"creatorId"` + MemberIDs []int `json:"memberIds"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + tx, _ := db.Begin() + result, err := tx.Exec("INSERT INTO groups (name, creator_id) VALUES (?, ?)", + req.Name, req.CreatorID) + if err != nil { + tx.Rollback() + http.Error(w, "Failed to create group", http.StatusInternalServerError) + return + } + + groupID, _ := result.LastInsertId() + + // Add creator as member + tx.Exec("INSERT INTO group_members (group_id, user_id) VALUES (?, ?)", + groupID, req.CreatorID) + + // Add other members + for _, memberID := range req.MemberIDs { + tx.Exec("INSERT INTO group_members (group_id, user_id) VALUES (?, ?)", + groupID, memberID) + } + + tx.Commit() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": groupID, + "name": req.Name, + }) +} + +// getGroupsHandler returns groups for a specific user +func getGroupsHandler(w http.ResponseWriter, r *http.Request) { + enableCORS(w) + userID := r.URL.Query().Get("userId") + rows, err := db.Query(` + SELECT DISTINCT g.id, g.name, g.creator_id + FROM groups g + JOIN group_members gm ON g.id = gm.group_id + WHERE gm.user_id = ? + ORDER BY g.name`, userID) + if err != nil { + http.Error(w, "Server error", http.StatusInternalServerError) + return + } + defer rows.Close() + + var groups []Group + for rows.Next() { + var g Group + rows.Scan(&g.ID, &g.Name, &g.Creator) + groups = append(groups, g) + } + + if groups == nil { + groups = []Group{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(groups) +} + +// getMessagesHandler returns messages for private or group chat +func getMessagesHandler(w http.ResponseWriter, r *http.Request) { + enableCORS(w) + userID, _ := strconv.Atoi(r.URL.Query().Get("userId")) + contactID, _ := strconv.Atoi(r.URL.Query().Get("contactId")) + groupID, _ := strconv.Atoi(r.URL.Query().Get("groupId")) + + var rows *sql.Rows + var err error + + if groupID > 0 { + // Get group messages + rows, err = db.Query(` + SELECT id, from_user, to_user, group_id, content, media_url, media_type, + strftime('%s', timestamp) as ts + FROM messages + WHERE group_id = ? + ORDER BY timestamp ASC + LIMIT 100`, groupID) + } else { + // Get private messages between two users + rows, err = db.Query(` + SELECT id, from_user, to_user, group_id, content, media_url, media_type, + strftime('%s', timestamp) as ts + FROM messages + WHERE (from_user = ? AND to_user = ?) OR (from_user = ? AND to_user = ?) + ORDER BY timestamp ASC + LIMIT 100`, userID, contactID, contactID, userID) + } + + if err != nil { + http.Error(w, "Server error", http.StatusInternalServerError) + return + } + defer rows.Close() + + var messages []Message + for rows.Next() { + var m Message + var id int + var toUser, groupIDVal sql.NullInt64 + var content, mediaURL, mediaType sql.NullString + rows.Scan(&id, &m.From, &toUser, &groupIDVal, &content, &mediaURL, &mediaType, &m.Timestamp) + if toUser.Valid { + m.To = int(toUser.Int64) + } + if groupIDVal.Valid { + m.GroupID = int(groupIDVal.Int64) + } + if content.Valid { + m.Content = content.String + } + if mediaURL.Valid { + m.MediaURL = mediaURL.String + } + if mediaType.Valid { + m.MediaType = mediaType.String + } + m.Type = "message" + messages = append(messages, m) + } + + if messages == nil { + messages = []Message{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(messages) +} + +// uploadHandler handles file uploads +func uploadHandler(w http.ResponseWriter, r *http.Request) { + enableCORS(w) + if r.Method == "OPTIONS" { + return + } + + err := r.ParseMultipartForm(50 << 20) // 50MB max + if err != nil { + http.Error(w, "Failed to parse form", http.StatusBadRequest) + return + } + + file, header, err := r.FormFile("file") + if err != nil { + http.Error(w, "File field required", http.StatusBadRequest) + return + } + defer file.Close() + + // Create uploads directory if not exists + os.MkdirAll("uploads", 0755) + + // Generate unique filename + ext := filepath.Ext(header.Filename) + filename := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext) + dst, err := os.Create(filepath.Join("uploads", filename)) + if err != nil { + http.Error(w, "Failed to save file", http.StatusInternalServerError) + return + } + defer dst.Close() + + io.Copy(dst, file) + + url := fmt.Sprintf("/uploads/%s", filename) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"url": url}) +} + +// sseHandler handles Server-Sent Events for real-time messaging +func sseHandler(w http.ResponseWriter, r *http.Request) { + userID, _ := strconv.Atoi(r.URL.Query().Get("userId")) + if userID == 0 { + http.Error(w, "Invalid user ID", http.StatusBadRequest) + return + } + + // Set SSE headers + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "Streaming not supported", http.StatusInternalServerError) + return + } + + // Create client + client := &Client{ + ID: userID, + Messages: make(chan []byte, 100), + Done: make(chan bool), + } + + // Register client + mu.Lock() + clients[userID] = client + mu.Unlock() + + // Cleanup on disconnect + defer func() { + mu.Lock() + delete(clients, userID) + mu.Unlock() + }() + + // Send initial connection message + fmt.Fprintf(w, "data: {\"type\":\"connected\"}\n\n") + flusher.Flush() + + // Listen for messages + for { + select { + case msg := <-client.Messages: + fmt.Fprintf(w, "data: %s\n\n", msg) + flusher.Flush() + case <-r.Context().Done(): + return + case <-client.Done: + return + } + } +} + +// sendMessageHandler handles sending messages +func sendMessageHandler(w http.ResponseWriter, r *http.Request) { + enableCORS(w) + if r.Method == "OPTIONS" { + return + } + + var msg Message + if err := json.NewDecoder(r.Body).Decode(&msg); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + msg.Timestamp = time.Now().Unix() + + // Save message to database + if msg.GroupID > 0 { + // Group message + db.Exec("INSERT INTO messages (from_user, group_id, content, media_url, media_type) VALUES (?, ?, ?, ?, ?)", + msg.From, msg.GroupID, msg.Content, msg.MediaURL, msg.MediaType) + + // Send to all group members + rows, _ := db.Query("SELECT user_id FROM group_members WHERE group_id = ?", msg.GroupID) + for rows.Next() { + var memberID int + rows.Scan(&memberID) + if memberID != msg.From { + mu.RLock() + if client, ok := clients[memberID]; ok { + data, _ := json.Marshal(msg) + select { + case client.Messages <- data: + default: + } + } + mu.RUnlock() + } + } + rows.Close() + } else { + // Private message + db.Exec("INSERT INTO messages (from_user, to_user, content, media_url, media_type) VALUES (?, ?, ?, ?, ?)", + msg.From, msg.To, msg.Content, msg.MediaURL, msg.MediaType) + + // Send to recipient + mu.RLock() + if client, ok := clients[msg.To]; ok { + data, _ := json.Marshal(msg) + select { + case client.Messages <- data: + default: + } + } + mu.RUnlock() + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +// typingHandler handles typing indicator notifications +func typingHandler(w http.ResponseWriter, r *http.Request) { + enableCORS(w) + if r.Method == "OPTIONS" { + return + } + + var msg struct { + From int `json:"from"` + To int `json:"to"` + } + + if err := json.NewDecoder(r.Body).Decode(&msg); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + // Forward typing indicator to recipient + mu.RLock() + if client, ok := clients[msg.To]; ok { + data, _ := json.Marshal(map[string]interface{}{ + "type": "typing", + "from": msg.From, + }) + select { + case client.Messages <- data: + default: + } + } + mu.RUnlock() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +func main() { + initDB() + + // API routes + http.HandleFunc("/api/register", registerHandler) + http.HandleFunc("/api/login", loginHandler) + http.HandleFunc("/api/users", getUsersHandler) + http.HandleFunc("/api/groups", func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" { + createGroupHandler(w, r) + } else { + getGroupsHandler(w, r) + } + }) + http.HandleFunc("/api/messages", getMessagesHandler) + http.HandleFunc("/api/send", sendMessageHandler) + http.HandleFunc("/api/typing", typingHandler) + http.HandleFunc("/api/upload", uploadHandler) + http.HandleFunc("/events", sseHandler) + + // Serve uploaded files + http.Handle("/uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir("./uploads")))) + + // Serve frontend files + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + if path == "/" { + path = "/index.html" + } + + filePath := "./public" + path + if _, err := os.Stat(filePath); err == nil { + http.ServeFile(w, r, filePath) + } else { + http.ServeFile(w, r, "./public/index.html") + } + }) + + fmt.Println("========================================") + fmt.Println("Chat Server Started!") + fmt.Println("http://localhost:8080") + fmt.Println("========================================") + log.Fatal(http.ListenAndServe(":8080", nil)) +} diff --git a/docker-compose.yml b/docker-compose.yml index feb7b0a..e039570 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,23 +6,10 @@ services: container_name: chat-server ports: - "8080:8080" + volumes: + - ./uploads:/app/uploads + - ./chat.db:/app/chat.db restart: unless-stopped + environment: + - PORT=8080 command: ./chat-server - - chat-client1: - build: . - container_name: chat-client1 - depends_on: - - chat-server - stdin_open: true - tty: true - command: ./chat-client - - chat-client2: - build: . - container_name: chat-client2 - depends_on: - - chat-server - stdin_open: true - tty: true - command: ./chat-client diff --git a/go.mod b/go.mod index 9f0d449..3df6349 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/username/tcp-chat go 1.21 + +require github.com/mattn/go-sqlite3 v1.14.19 diff --git a/go.sum b/go.sum index e69de29..3042612 100644 --- a/go.sum +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI= +github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..defa4ad --- /dev/null +++ b/public/app.js @@ -0,0 +1,582 @@ +let currentUser = null; +let eventSource = null; +let contacts = []; +let groups = []; +let currentChat = null; +let typingTimeouts = {}; + +// Auth functions +function toggleAuth() { + const loginForm = document.getElementById('login-form'); + const registerForm = document.getElementById('register-form'); + + if (loginForm.style.display === 'none') { + loginForm.style.display = 'block'; + registerForm.style.display = 'none'; + } else { + loginForm.style.display = 'none'; + registerForm.style.display = 'block'; + } +} + +async function register() { + const username = document.getElementById('reg-username').value; + const fullName = document.getElementById('reg-fullname').value; + const password = document.getElementById('reg-password').value; + + if (!username || !fullName || !password) { + alert('Please fill in all fields'); + return; + } + + try { + const res = await fetch('/api/register', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({username, fullName, password}) + }); + + if (res.ok) { + const user = await res.json(); + alert('Registration successful! Please login now'); + toggleAuth(); + } else { + const error = await res.text(); + alert('Error: ' + error); + } + } catch (err) { + alert('Connection error'); + } +} + +async function login() { + const username = document.getElementById('login-username').value; + const password = document.getElementById('login-password').value; + + if (!username || !password) { + alert('Please enter username and password'); + return; + } + + try { + const res = await fetch('/api/login', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({username, password}) + }); + + if (res.ok) { + currentUser = await res.json(); + localStorage.setItem('user', JSON.stringify(currentUser)); + initChat(); + } else { + alert('Invalid username or password'); + } + } catch (err) { + alert('Connection error'); + } +} + +function logout() { + localStorage.removeItem('user'); + if (eventSource) eventSource.close(); + location.reload(); +} + +// Chat initialization +async function initChat() { + document.getElementById('auth-screen').style.display = 'none'; + document.getElementById('chat-screen').style.display = 'block'; + + document.getElementById('user-name').textContent = currentUser.fullName; + document.getElementById('user-avatar').textContent = currentUser.fullName.charAt(0).toUpperCase(); + + await loadContacts(); + await loadGroups(); + connectSSE(); +} + +function connectSSE() { + eventSource = new EventSource(`/events?userId=${currentUser.id}`); + + eventSource.onopen = () => { + console.log('SSE connected'); + }; + + eventSource.onmessage = (event) => { + const msg = JSON.parse(event.data); + handleIncomingMessage(msg); + }; + + eventSource.onerror = () => { + console.log('SSE disconnected'); + setTimeout(connectSSE, 3000); + }; +} + +function handleIncomingMessage(msg) { + if (msg.type === 'typing') { + showTypingIndicator(msg.from); + return; + } + + if (msg.type === 'message') { + // Check if message is for current chat + if (currentChat) { + if (currentChat.isGroup && msg.groupId === currentChat.id) { + displayMessage(msg); + } else if (!currentChat.isGroup && msg.from === currentChat.id) { + displayMessage(msg); + } + } + + // Update contact preview + updateContactPreview(msg); + } +} + +// Load contacts and groups +async function loadContacts() { + try { + const res = await fetch('/api/users'); + const users = await res.json(); + contacts = users.filter(u => u.id !== currentUser.id); + } catch (err) { + console.error('Failed to load contacts:', err); + } +} + +async function loadGroups() { + try { + const res = await fetch(`/api/groups?userId=${currentUser.id}`); + groups = await res.json(); + renderContacts(); + } catch (err) { + console.error('Failed to load groups:', err); + } +} + +function renderContacts() { + const list = document.getElementById('contacts-list'); + list.innerHTML = ''; + + // Render groups + groups.forEach(group => { + const item = createContactItem({ + id: group.id, + name: group.name, + isGroup: true, + preview: 'Group' + }); + list.appendChild(item); + }); + + // Render individual contacts + contacts.forEach(contact => { + const item = createContactItem({ + id: contact.id, + name: contact.fullName, + username: contact.username, + isGroup: false, + preview: '' + }); + list.appendChild(item); + }); +} + +function createContactItem(data) { + const div = document.createElement('div'); + div.className = 'contact-item'; + div.onclick = () => openChat(data); + + const avatar = document.createElement('div'); + avatar.className = 'avatar'; + avatar.textContent = data.isGroup ? 'G' : data.name.charAt(0).toUpperCase(); + + const info = document.createElement('div'); + info.className = 'contact-info'; + + const name = document.createElement('div'); + name.className = 'contact-name'; + name.textContent = data.name; + + const preview = document.createElement('div'); + preview.className = 'contact-preview'; + preview.textContent = data.preview; + + info.appendChild(name); + info.appendChild(preview); + + div.appendChild(avatar); + div.appendChild(info); + + return div; +} + +function filterContacts() { + const search = document.getElementById('search-input').value.toLowerCase(); + const items = document.querySelectorAll('.contact-item'); + + items.forEach(item => { + const name = item.querySelector('.contact-name').textContent.toLowerCase(); + item.style.display = name.includes(search) ? 'flex' : 'none'; + }); +} + +function updateContactPreview(msg) { + // Implementation for updating last message preview +} + +// Open chat +async function openChat(contact) { + currentChat = contact; + + // Update UI + document.querySelectorAll('.contact-item').forEach(item => { + item.classList.remove('active'); + }); + event.currentTarget.classList.add('active'); + + // Build chat area + const chatArea = document.getElementById('chat-area'); + chatArea.innerHTML = ` +
+
${contact.isGroup ? 'G' : contact.name.charAt(0).toUpperCase()}
+
+
${contact.name}
+
${contact.isGroup ? 'Group' : 'Online'}
+
+
+
+
+
+ + + +
+
+
+
+
+ +
+ + +
+ `; + + await loadMessages(); +} + +async function loadMessages() { + try { + const params = currentChat.isGroup + ? `groupId=${currentChat.id}&userId=${currentUser.id}` + : `userId=${currentUser.id}&contactId=${currentChat.id}`; + + const res = await fetch(`/api/messages?${params}`); + const messages = await res.json(); + + const container = document.getElementById('messages-container'); + const typingIndicator = container.querySelector('#typing-indicator'); + container.innerHTML = ''; + container.appendChild(typingIndicator); + + messages.forEach(msg => displayMessage(msg)); + scrollToBottom(); + } catch (err) { + console.error('Failed to load messages:', err); + } +} + +function displayMessage(msg) { + const container = document.getElementById('messages-container'); + if (!container) return; + + const div = document.createElement('div'); + div.className = `message ${msg.from === currentUser.id ? 'sent' : 'received'}`; + + const bubble = document.createElement('div'); + bubble.className = 'message-bubble'; + + // Show sender name in groups + if (currentChat && currentChat.isGroup && msg.from !== currentUser.id) { + const sender = document.createElement('div'); + sender.className = 'message-sender'; + sender.textContent = getSenderName(msg.from); + bubble.appendChild(sender); + } + + if (msg.mediaUrl) { + const media = document.createElement('img'); + media.className = 'message-media'; + media.src = msg.mediaUrl; + media.alt = msg.mediaType; + media.onclick = () => window.open(msg.mediaUrl, '_blank'); + bubble.appendChild(media); + } + + if (msg.content) { + const content = document.createElement('div'); + content.className = 'message-content'; + content.textContent = msg.content; + bubble.appendChild(content); + } + + const time = document.createElement('div'); + time.className = 'message-time'; + time.textContent = formatTime(msg.timestamp); + bubble.appendChild(time); + + div.appendChild(bubble); + + const typingIndicator = container.querySelector('#typing-indicator'); + container.insertBefore(div, typingIndicator); + scrollToBottom(); +} + +function getSenderName(userId) { + const contact = contacts.find(c => c.id === userId); + return contact ? contact.fullName : 'User'; +} + +function formatTime(timestamp) { + const date = new Date(timestamp * 1000); + return date.toLocaleTimeString('en-US', {hour: '2-digit', minute: '2-digit'}); +} + +function scrollToBottom() { + const container = document.getElementById('messages-container'); + if (container) { + container.scrollTop = container.scrollHeight; + } +} + +// Send message +function handleKeyPress(event) { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + sendMessage(); + } +} + +function handleTyping() { + if (!currentChat || currentChat.isGroup) return; + + fetch('/api/typing', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + from: currentUser.id, + to: currentChat.id + }) + }); +} + +function showTypingIndicator(fromUserId) { + if (!currentChat || currentChat.id !== fromUserId) return; + + const indicator = document.getElementById('typing-indicator'); + if (indicator) { + indicator.classList.add('active'); + + clearTimeout(typingTimeouts[fromUserId]); + typingTimeouts[fromUserId] = setTimeout(() => { + indicator.classList.remove('active'); + }, 3000); + } +} + +async function sendMessage() { + const input = document.getElementById('message-input'); + const content = input.value.trim(); + + if (!content || !currentChat) return; + + const msg = { + type: 'message', + content: content, + from: currentUser.id, + to: currentChat.isGroup ? 0 : currentChat.id, + groupId: currentChat.isGroup ? currentChat.id : 0 + }; + + try { + await fetch('/api/send', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(msg) + }); + + // Display immediately + displayMessage({ + ...msg, + timestamp: Date.now() / 1000 + }); + + input.value = ''; + } catch (err) { + alert('Failed to send message'); + } +} + +// File upload +async function uploadFile() { + const fileInput = document.getElementById('file-input'); + const file = fileInput.files[0]; + + if (!file) return; + + const formData = new FormData(); + formData.append('file', file); + + try { + const res = await fetch('/api/upload', { + method: 'POST', + body: formData + }); + + if (res.ok) { + const data = await res.json(); + + const mediaType = file.type.startsWith('image/') ? 'image' : + file.type.startsWith('video/') ? 'video' : 'file'; + + const msg = { + type: 'message', + mediaUrl: data.url, + mediaType: mediaType, + from: currentUser.id, + to: currentChat.isGroup ? 0 : currentChat.id, + groupId: currentChat.isGroup ? currentChat.id : 0 + }; + + await fetch('/api/send', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(msg) + }); + + displayMessage({ + ...msg, + timestamp: Date.now() / 1000 + }); + } else { + alert('Failed to upload file'); + } + } catch (err) { + alert('Failed to upload file'); + } + + fileInput.value = ''; +} + +// Modals +function showNewChatModal() { + const modal = document.getElementById('new-chat-modal'); + const usersList = document.getElementById('users-for-chat'); + + usersList.innerHTML = ''; + contacts.forEach(contact => { + const div = document.createElement('div'); + div.className = 'user-item'; + div.style.cursor = 'pointer'; + div.onclick = () => { + closeModal('new-chat-modal'); + openChat({ + id: contact.id, + name: contact.fullName, + isGroup: false + }); + }; + + div.innerHTML = ` +
${contact.fullName.charAt(0).toUpperCase()}
+
+
${contact.fullName}
+
@${contact.username}
+
+ `; + usersList.appendChild(div); + }); + + modal.classList.add('active'); +} + +function showNewGroupModal() { + const modal = document.getElementById('new-group-modal'); + const usersList = document.getElementById('users-for-group'); + + usersList.innerHTML = ''; + contacts.forEach(contact => { + const div = document.createElement('div'); + div.className = 'user-item'; + div.innerHTML = ` + +
${contact.fullName.charAt(0).toUpperCase()}
+ + `; + usersList.appendChild(div); + }); + + modal.classList.add('active'); +} + +async function createGroup() { + const name = document.getElementById('group-name').value.trim(); + const checkboxes = document.querySelectorAll('#users-for-group input[type="checkbox"]:checked'); + const memberIds = Array.from(checkboxes).map(cb => parseInt(cb.value)); + + if (!name) { + alert('Please enter a group name'); + return; + } + + if (memberIds.length === 0) { + alert('Please select at least one member'); + return; + } + + try { + const res = await fetch('/api/groups', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + name: name, + creatorId: currentUser.id, + memberIds: memberIds + }) + }); + + if (res.ok) { + const group = await res.json(); + alert('Group created successfully'); + closeModal('new-group-modal'); + document.getElementById('group-name').value = ''; + await loadGroups(); + } else { + alert('Failed to create group'); + } + } catch (err) { + alert('Failed to create group'); + } +} + +function closeModal(modalId) { + document.getElementById(modalId).classList.remove('active'); +} + +// Initialize on load +window.onload = () => { + const savedUser = localStorage.getItem('user'); + if (savedUser) { + currentUser = JSON.parse(savedUser); + initChat(); + } +}; diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..11e3606 --- /dev/null +++ b/public/index.html @@ -0,0 +1,641 @@ + + + + + + Chat Room + + + + +
+
+

Login to Chat Room

+
+
+ + +
+
+ + +
+ +
+ Don't have an account? Register +
+
+ +
+
+ + +
+
+ + + + +
+
+
[Chat]
+

Select a contact to start chatting

+
+
+
+
+ + + + + + + + + + + + diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..1056e05 --- /dev/null +++ b/run.sh @@ -0,0 +1,49 @@ +#!/bin/bash + +echo "Starting Chat Server..." +echo "" + +# Colors for output +GREEN='\033[0;32m' +BLUE='\033[0;34m' +RED='\033[0;31m' +NC='\033[0m' # No color + +# Check Go installation +if ! command -v go &> /dev/null; then + echo -e "${RED}[ERROR] Go is not installed. Please install Go 1.21+${NC}" + exit 1 +fi + +echo -e "${GREEN}[OK] Go found${NC}" + +# Download dependencies +echo -e "${BLUE}[INFO] Downloading sqlite3...${NC}" +GOPROXY=https://goproxy.io,direct go mod download 2>/dev/null || go mod download 2>/dev/null || true + +# Build the application +echo -e "${BLUE}[INFO] Building server...${NC}" +CGO_ENABLED=1 go build -o chat-server ./cmd/server/main.go + +if [ $? -ne 0 ]; then + echo -e "${RED}[ERROR] Failed to build server${NC}" + echo -e "${BLUE}[INFO] Make sure gcc and sqlite3-dev are installed:${NC}" + echo " sudo apt install gcc libsqlite3-dev" + exit 1 +fi + +echo -e "${GREEN}[OK] Server built successfully${NC}" + +# Create uploads directory +mkdir -p uploads + +# Run the server +echo "" +echo -e "${GREEN}[OK] Server is ready!${NC}" +echo -e "${BLUE}[INFO] Running on http://localhost:8080${NC}" +echo "" +echo -e "To stop the server: ${RED}Ctrl+C${NC}" +echo "" +echo "========================================" + +./chat-server