commit 1bf98ab3eb2eea68be8315e3de4e4b1e45950116 Author: Radon Date: Sat Jan 18 22:01:02 2025 -0600 changes diff --git a/chatserver b/chatserver new file mode 100644 index 0000000..54f38b3 Binary files /dev/null and b/chatserver differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8bea3f6 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module chat + +go 1.23.4 + +require github.com/mattn/go-sqlite3 v1.14.24 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9dcdc9b --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= +github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= diff --git a/main.go b/main.go new file mode 100644 index 0000000..b0a60bb --- /dev/null +++ b/main.go @@ -0,0 +1,627 @@ +package main + +import ( + "compress/gzip" + "database/sql" + "encoding/json" + "fmt" + _ "github.com/mattn/go-sqlite3" + "io" + "net" + "net/http" + "os" + "strconv" + "strings" + "sync" + "time" +) + +type Database struct { + db *sql.DB +} + +func TimezoneToLocation(timezone string) *time.Location { + defaultLocation := time.FixedZone("UTC", 0) + location, err := time.LoadLocation(timezone) + if err != nil { + return defaultLocation + } else { + return location + } +} + +func TimeStringToTime(timeString string) time.Time { + t, _ := time.Parse("2006-01-02 15:04:05", timeString) + return t +} + +func TimeStringToTimeInLocation(timeString string, timezone string) string { + t := TimeStringToTime(timeString) + location := TimezoneToLocation(timezone) + return t.In(location).Format("2006-01-02 15:04:05") +} + +func OpenDatabase(filepath string) *Database { + if db, err := sql.Open("sqlite3", filepath); err != nil { + return nil + } else { + return &Database{ + db: db, + } + } +} + +func (db *Database) Close() { + db.db.Close() +} + +func (db *Database) DbCreateTableMessages() { + stmt := `CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip_address TEXT NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )` + db.db.Exec(stmt) +} + +func (db *Database) DbCreateTableUsers() { + stmt := `CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip_address TEXT NOT NULL, + username TEXT NOT NULL UNIQUE, + timezone TEXT DEFAULT 'America/New_York', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )` + db.db.Exec(stmt) +} + +func (db *Database) UserTimezoneSet(ip_address, timezone string) { + stmt, err := db.db.Prepare("UPDATE users SET timezone = ? WHERE ip_address = ?") + if err != nil { + fmt.Println(err) + } + stmt.Exec(timezone, ip_address) +} + +func (db *Database) UserAdd(ip_address, username string) { + stmt, err := db.db.Prepare("INSERT INTO users (username, ip_address) VALUES (?, ?)") + if err != nil { + fmt.Println(err) + } + stmt.Exec(username, ip_address) +} + +func (db *Database) MessageAdd(ip_address string, content string) { + stmt, err := db.db.Prepare("INSERT INTO messages (ip_address, content) VALUES (?, ?)") + if err != nil { + fmt.Println(err) + } + stmt.Exec(ip_address, content) +} + +func (db *Database) UserNameGet(ip_address string) string { + rows, err := db.db.Query("SELECT username FROM users WHERE ip_address = ?", ip_address) + if err != nil { + fmt.Println(err) + } + defer rows.Close() + var username string + rows.Next() + rows.Scan(&username) + return username +} + +func (db *Database) UserGetTimezone(ip_address string) string { + rows, err := db.db.Query("SELECT timezone FROM users WHERE ip_address = ?", ip_address) + if err != nil { + fmt.Println(err) + } + defer rows.Close() + var timezone string + rows.Next() + rows.Scan(&timezone) + return timezone +} + +func (db *Database) UsersGet() []User { + rows, err := db.db.Query("SELECT * FROM users") + if err != nil { + fmt.Println(err) + } + defer rows.Close() + var users []User + for rows.Next() { + var id string + var ip_address string + var username string + var created_at string + var timezone string + rows.Scan(&id, &ip_address, &username, &created_at, &timezone) + user := User{ + Id: id, + Username: username, + IpAddress: ip_address, + Timezone: timezone, + } + users = append(users, user) + } + return users +} + +func (db *Database) MessagesGet() []Message { + rows, err := db.db.Query(` + SELECT messages.id, messages.ip_address, messages.content, + strftime('%Y-%m-%d %H:%M:%S', messages.created_at) as created_at, + users.username + FROM messages + LEFT JOIN users ON messages.ip_address = users.ip_address; + `) + if err != nil { + fmt.Println(err) + } + defer rows.Close() + + var messages []Message + + for rows.Next() { + var id string + var content string + var ip_address string + var created_at string + var username string + rows.Scan(&id, &ip_address, &content, &created_at, &username) + message := Message{ + Id: id, + Content: content, + SenderIp: ip_address, + SenderUsername: username, + Timestamp: created_at, + } + messages = append(messages, message) + } + return messages +} + +func (db *Database) UserNameExists(username string) bool { + rows, err := db.db.Query("SELECT * FROM users WHERE username = ?", username) + if err != nil { + fmt.Println(err) + } + defer rows.Close() + return rows.Next() +} + +func (db *Database) UserExists(ip string) bool { + rows, err := db.db.Query("SELECT * FROM users WHERE ip_address = ?", ip) + if err != nil { + fmt.Println(err) + } + defer rows.Close() + return rows.Next() +} + +func (db *Database) UserNameChange(ip, newUsername string) { + stmt, err := db.db.Prepare("UPDATE users SET username = ? WHERE ip_address = ?") + if err != nil { + fmt.Println(err) + } + stmt.Exec(newUsername, ip) +} + +func (db *Database) UserMessagesGet(ip string) []Message { + rows, err := db.db.Query(` + SELECT messages.*, users.username + FROM messages + LEFT JOIN users ON messages.ip_address = users.ip_address + WHERE messages.ip_address = ? + ORDER BY messages.created_at DESC; + `, ip) + if err != nil { + fmt.Println(err) + } + defer rows.Close() + + var messages []Message + + for rows.Next() { + var id string + var content string + var ip_address string + var created_at string + var username string + rows.Scan(&id, &ip_address, &content, &created_at, &username) + t, _ := time.Parse(created_at, created_at) + message := Message{ + Id: id, + Content: content, + SenderIp: ip_address, + SenderUsername: username, + Timestamp: t.Format(created_at), + } + messages = append(messages, message) + } + return messages +} + +func (db *Database) MessageDelete(id string) { + stmt, err := db.db.Prepare("DELETE FROM messages WHERE id = ?") + if err != nil { + fmt.Println(err) + } + stmt.Exec(id) +} + +func (db *Database) UserDelete(ip string) { + stmt, err := db.db.Prepare("DELETE FROM users WHERE ip_address = ?") + if err != nil { + fmt.Println(err) + } + stmt.Exec(ip) +} + +func (db *Database) UsersDelete() { + stmt, err := db.db.Prepare("DELETE FROM users") + if err != nil { + fmt.Println(err) + } + stmt.Exec() +} + +func (db *Database) MessagesDelete() { + stmt, err := db.db.Prepare("DELETE FROM messages") + if err != nil { + fmt.Println(err) + } + stmt.Exec() +} + +type gzipResponseWriter struct { + http.ResponseWriter + io.Writer +} + +func (g *gzipResponseWriter) Write(data []byte) (int, error) { + return g.Writer.Write(data) +} + +func GzipMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { + next.ServeHTTP(w, r) + return + } + + gz := gzip.NewWriter(w) + defer gz.Close() + + w.Header().Set("Content-Encoding", "gzip") + next.ServeHTTP(&gzipResponseWriter{ResponseWriter: w, Writer: gz}, r) + }) +} + +func getClientIP(r *http.Request) string { + if fwdIP := r.Header.Get("X-Forwarded-For"); fwdIP != "" { + return strings.Split(fwdIP, ",")[0] + } + + clientIP := r.RemoteAddr + if host, _, err := net.SplitHostPort(clientIP); err == nil { + return host + } + return clientIP +} + +type User struct { + Id string + Username string + IpAddress string + Timezone string +} + +type Message struct { + Id string + Content string + SenderIp string + SenderUsername string + Timestamp string +} + +type Server struct { + Ip string + Port int + Connected map[string]time.Time // Map IP -> Last activity time + Database *Database + mu sync.Mutex // For thread safety +} + +func NewServer(ip string, port int, dbpath string) *Server { + return &Server{ + Ip: ip, + Port: port, + Connected: make(map[string]time.Time), + Database: OpenDatabase(dbpath), + mu: sync.Mutex{}, + } +} + +func (s *Server) AddMessage(userip string, contents string) { + s.Database.MessageAdd(userip, contents) +} + +func (s *Server) updateActivity(ip string) { + s.mu.Lock() + defer s.mu.Unlock() + s.Connected[ip] = time.Now() +} + +func (s *Server) cleanupActivity() { + s.mu.Lock() + defer s.mu.Unlock() + for ip, lastActivity := range s.Connected { + if time.Since(lastActivity) > 10*time.Second { + delete(s.Connected, ip) + } + } +} + +func (s *Server) handlePing(w http.ResponseWriter, r *http.Request) { + clientIP := getClientIP(r) + s.updateActivity(clientIP) + s.cleanupActivity() + w.WriteHeader(http.StatusOK) +} + +func validUsername(username string) bool { + for _, c := range username { + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_') { + return false + } + } + return true +} + +func (s *Server) handleUsername(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + http.Error(w, `{"error": "Method not allowed"}`, http.StatusMethodNotAllowed) + return + } + + w.Header().Set("Content-Type", "application/json") + + clientIP := getClientIP(r) + + var req struct { + Username string `json:"username"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error": "Invalid JSON"}`, http.StatusBadRequest) + return + } + + s.mu.Lock() + + if len(req.Username) > 64 { + http.Error(w, fmt.Sprintf(`{"error": "Username too long (must be less than 64 characters)"}`), http.StatusRequestEntityTooLarge) + s.mu.Unlock() + return + } + + if !validUsername(req.Username) { + http.Error(w, fmt.Sprintf(`{"error": "Username must only contain alphanumeric characters and/or underscores"}`), http.StatusBadRequest) + s.mu.Unlock() + return + } + + if s.Database.UserNameExists(req.Username) { + s.mu.Unlock() + http.Error(w, fmt.Sprintf(`{"error": "Username already exists"}`), http.StatusConflict) + return + } + + if s.Database.UserExists(clientIP) { + s.Database.UserNameChange(clientIP, req.Username) + } else { + s.Database.UserAdd(clientIP, req.Username) + } + + s.mu.Unlock() + + json.NewEncoder(w).Encode(map[string]string{"status": "Username registered"}) +} + +func getMessageTemplate(file string, body string) string { + contents, _ := os.ReadFile(file) + return strings.Replace(string(contents), "{{body}}", body, 1) +} + +func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.Header().Set("Content-Type", "text/html") + + var body string + messages := s.Database.MessagesGet() + for _, msg := range messages { + clientIP := getClientIP(r) + timeZone := s.Database.UserGetTimezone(clientIP) + timeLocal := TimeStringToTimeInLocation(msg.Timestamp, timeZone) + body += fmt.Sprintf(`

%s %s
%s

`, + msg.SenderUsername, timeLocal, msg.Content) + } + + w.Write([]byte(getMessageTemplate("messages.html", body))) + + case http.MethodPut: + w.Header().Set("Content-Type", "application/json") + + // Get client's IP + clientIP := getClientIP(r) + + s.mu.Lock() + exists := s.Database.UserExists(clientIP) + username := s.Database.UserNameGet(clientIP) + s.mu.Unlock() + + if !exists { + errorFmt := fmt.Sprintf(`{"error": "IP %s not registered with username"}`, clientIP) + http.Error(w, errorFmt, http.StatusUnauthorized) + return + } + + var msg struct { + Message string `json:"message"` + } + + if err := json.NewDecoder(r.Body).Decode(&msg); err != nil { + http.Error(w, `{"error": "Invalid JSON"}`, http.StatusBadRequest) + return + } + + s.Database.MessageAdd(clientIP, msg.Message) + + json.NewEncoder(w).Encode(map[string]string{ + "status": "Message received", + "from": username, + }) + default: + http.Error(w, `{"error": "Method not allowed"}`, http.StatusMethodNotAllowed) + return + } +} + +func (s *Server) handleUsernameStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, `{"error": "Method not allowed"}`, http.StatusMethodNotAllowed) + return + } + + w.Header().Set("Content-Type", "application/json") + clientIP := getClientIP(r) + + s.mu.Lock() + exists := s.Database.UserExists(clientIP) + username := s.Database.UserNameGet(clientIP) + s.mu.Unlock() + + json.NewEncoder(w).Encode(map[string]interface{}{ + "hasUsername": exists, + "username": username, + }) +} + +func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, `{"error": "Method not allowed"}`, http.StatusMethodNotAllowed) + return + } + + w.Header().Set("Content-Type", "application/json") + + clientIP := getClientIP(r) + s.updateActivity(clientIP) + s.cleanupActivity() + s.mu.Lock() + var users []string + for ip := range s.Connected { + // for all connected, get their usernames + users = append(users, s.Database.UserNameGet(ip)) + } + s.mu.Unlock() + + json.NewEncoder(w).Encode(map[string]interface{}{ + "users": users, + }) +} + +func (s *Server) handleTimezone(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, `{"error": "Method not allowed"}`, http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + + clientIP := getClientIP(r) + + var req struct { + Timezone string `json:"timezone"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error": "Invalid JSON"}`, http.StatusBadRequest) + return + } + + s.mu.Lock() + + if !s.Database.UserExists(clientIP) { + http.Error(w, `{"error": "User not registered"}`, http.StatusUnauthorized) + s.mu.Unlock() + return + } + + s.Database.UserTimezoneSet(clientIP, req.Timezone) + s.mu.Unlock() + json.NewEncoder(w).Encode(map[string]string{ + "status": "success", + }) +} + +func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html") + w.Write(readFile("root.html")) +} + +func readFile(filepath string) []byte { + contents, _ := os.ReadFile(filepath) + return contents +} + +func (s *Server) handleJs(w http.ResponseWriter, r *http.Request) { + _ = r + w.Header().Set("Content-Type", "application/javascript") + w.Write(readFile("root.js")) +} + +func (s *Server) handleCss(w http.ResponseWriter, r *http.Request) { + _ = r + w.Header().Set("Content-Type", "text/css") + w.Write(readFile("root.css")) +} + +func (s *Server) Run() { + handler := http.NewServeMux() + handler.HandleFunc("/ping", s.handlePing) + handler.HandleFunc("/username", s.handleUsername) + handler.HandleFunc("/messages", s.handleMessages) + handler.HandleFunc("/username/status", s.handleUsernameStatus) + handler.HandleFunc("/users", s.handleUsers) + handler.HandleFunc("/", s.handleRoot) + handler.HandleFunc("/root.js", s.handleJs) + handler.HandleFunc("/root.css", s.handleCss) + handler.HandleFunc("/timezone", s.handleTimezone) + fmt.Printf("Server starting on %s:%d\n", s.Ip, s.Port) + if err := http.ListenAndServe(fmt.Sprintf("%s:%d", s.Ip, s.Port), GzipMiddleware(handler)); err != nil { + fmt.Printf("Server error: %v\n", err) + } +} + +func (s *Server) Stop() { + s.Database.Close() +} + +func main() { + ip := os.Args[1] + port, _ := strconv.Atoi(os.Args[2]) + databaseFile := os.Args[3] + server := NewServer(ip, port, databaseFile) + server.Database.DbCreateTableMessages() + server.Database.DbCreateTableUsers() + defer server.Stop() + server.Run() +} diff --git a/messages.html b/messages.html new file mode 100644 index 0000000..2e97c0c --- /dev/null +++ b/messages.html @@ -0,0 +1,7 @@ + + +
+ {{body}} +
+ + diff --git a/notes/todo.md b/notes/todo.md new file mode 100644 index 0000000..6e6c7d3 --- /dev/null +++ b/notes/todo.md @@ -0,0 +1,17 @@ +# Changes To Make +## Frontend +### High Priority +- Nothing yet +### Mid Priority +- Nothing yet +### Low Priority +- Mobile formatting @media +## Backend +### High Priority +- Nothing yet +### Mid Priority +- Nothing yet +### Low Priority +- Search functionality? +- Delete messages? (maybe later) +- Old messages should be deleted? Or if database is over a certain size? Not sure if this is really necessary. diff --git a/old/main.go b/old/main.go new file mode 100644 index 0000000..4d5ccde --- /dev/null +++ b/old/main.go @@ -0,0 +1,333 @@ +package main + +import ( + "compress/gzip" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "strconv" + "strings" + "sync" + "time" +) + +type gzipResponseWriter struct { + http.ResponseWriter + io.Writer +} + +func (g *gzipResponseWriter) Write(data []byte) (int, error) { + return g.Writer.Write(data) +} + +func GzipMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { + next.ServeHTTP(w, r) + return + } + + gz := gzip.NewWriter(w) + defer gz.Close() + + w.Header().Set("Content-Encoding", "gzip") + next.ServeHTTP(&gzipResponseWriter{ResponseWriter: w, Writer: gz}, r) + }) +} + +func getClientIP(r *http.Request) string { + if fwdIP := r.Header.Get("X-Forwarded-For"); fwdIP != "" { + return strings.Split(fwdIP, ",")[0] + } + + clientIP := r.RemoteAddr + if host, _, err := net.SplitHostPort(clientIP); err == nil { + return host + } + return clientIP +} + +type Message struct { + Content string + SenderIp string + SenderUsername string + Timestamp string +} + +func NewMessage(username, ip, content string) *Message { + timestamp := time.Now().Format("2006-01-02 15:04:05") + return &Message{ + Content: content, + SenderIp: ip, + SenderUsername: username, + Timestamp: timestamp, + } +} + +type Server struct { + Ip string + Port int + Messages []Message + // Add mappings for IPs and usernames + Connected map[string]time.Time // Map IP -> Last activity time + Usernames map[string]string // Map IP -> Username + mu sync.Mutex // For thread safety +} + +func NewServer(ip string, port int) *Server { + return &Server{ + Ip: ip, + Port: port, + Messages: make([]Message, 0), + Connected: make(map[string]time.Time), + Usernames: make(map[string]string), + mu: sync.Mutex{}, + } +} + +func (s *Server) AddMessage(username string, userip string, contents string) { + message := NewMessage(username, userip, contents) + s.Messages = append(s.Messages, *message) +} + +func (s *Server) updateActivity(ip string) { + s.mu.Lock() + defer s.mu.Unlock() + s.Connected[ip] = time.Now() +} + +func (s *Server) cleanupActivity() { + s.mu.Lock() + defer s.mu.Unlock() + for ip, lastActivity := range s.Connected { + if time.Since(lastActivity) > 10*time.Second { + delete(s.Connected, ip) + } + } +} + +func (s *Server) handlePing(w http.ResponseWriter, r *http.Request) { + clientIP := getClientIP(r) + s.updateActivity(clientIP) + s.cleanupActivity() + w.WriteHeader(http.StatusOK) +} + +func validUsername(username string) bool { + for _, c := range username { + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_') { + return false + } + } + return true +} + +func (s *Server) handleUsername(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + http.Error(w, `{"error": "Method not allowed"}`, http.StatusMethodNotAllowed) + return + } + + w.Header().Set("Content-Type", "application/json") + + clientIP := getClientIP(r) + + var req struct { + Username string `json:"username"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error": "Invalid JSON"}`, http.StatusBadRequest) + return + } + + s.mu.Lock() + + if len(req.Username) > 64 { + http.Error(w, fmt.Sprintf(`{"error": "Username too long (must be less than 64 characters)"}`), http.StatusRequestEntityTooLarge) + s.mu.Unlock() + return + } + + if !validUsername(req.Username) { + http.Error(w, fmt.Sprintf(`{"error": "Username must only contain alphanumeric characters and/or underscores"}`), http.StatusBadRequest) + s.mu.Unlock() + return + } + + for _, username := range s.Usernames { + if username == req.Username { + s.mu.Unlock() + http.Error(w, fmt.Sprintf(`{"error": "Username already exists"}`), http.StatusConflict) + return + } + } + + if _, ok := s.Usernames[clientIP]; ok { + fmt.Println("Name change detected, fixing messages to reflect the change") + for i, msg := range s.Messages { + if msg.SenderIp == clientIP { + s.Messages[i].SenderUsername = req.Username + } + } + } + s.Usernames[clientIP] = req.Username + + s.mu.Unlock() + + json.NewEncoder(w).Encode(map[string]string{"status": "Username registered"}) + fmt.Println(clientIP, "->", req.Username) +} + +func getMessageTemplate(file string, body string) string { + contents, _ := os.ReadFile(file) + return strings.Replace(string(contents), "{{body}}", body, 1) +} + +func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.Header().Set("Content-Type", "text/html") + + var body string + for _, msg := range s.Messages { + body += fmt.Sprintf(`

%s %s
%s

`, + msg.SenderUsername, msg.Timestamp, msg.Content) + } + + w.Write([]byte(getMessageTemplate("messages.html", body))) + + case http.MethodPut: + w.Header().Set("Content-Type", "application/json") + + // Get client's IP + clientIP := getClientIP(r) + + // Check if IP is registered + s.mu.Lock() + username, exists := s.Usernames[clientIP] + s.mu.Unlock() + + if !exists { + errorFmt := fmt.Sprintf(`{"error": "IP %s not registered with username"}`, clientIP) + http.Error(w, errorFmt, http.StatusUnauthorized) + return + } + + var msg struct { + Message string `json:"message"` + } + + if err := json.NewDecoder(r.Body).Decode(&msg); err != nil { + http.Error(w, `{"error": "Invalid JSON"}`, http.StatusBadRequest) + return + } + + // Add message using the stored username and IP + s.AddMessage(username, clientIP, msg.Message) + + json.NewEncoder(w).Encode(map[string]string{ + "status": "Message received", + "from": username, + }) + default: + http.Error(w, `{"error": "Method not allowed"}`, http.StatusMethodNotAllowed) + return + } +} + +func (s *Server) handleUsernameStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, `{"error": "Method not allowed"}`, http.StatusMethodNotAllowed) + return + } + + w.Header().Set("Content-Type", "application/json") + clientIP := getClientIP(r) + + s.mu.Lock() + username, exists := s.Usernames[clientIP] + s.mu.Unlock() + + json.NewEncoder(w).Encode(map[string]interface{}{ + "hasUsername": exists, + "username": username, + }) +} + +func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, `{"error": "Method not allowed"}`, http.StatusMethodNotAllowed) + return + } + + w.Header().Set("Content-Type", "application/json") + + clientIP := getClientIP(r) + s.updateActivity(clientIP) + s.cleanupActivity() + s.mu.Lock() + var users []string + for ip := range s.Connected { + users = append(users, s.Usernames[ip]) + } + s.mu.Unlock() + + json.NewEncoder(w).Encode(map[string]interface{}{ + "users": users, + }) +} + +func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html") + w.Write(readFile("root.html")) +} + +func readFile(filepath string) []byte { + contents, _ := os.ReadFile(filepath) + return contents +} + +func (s *Server) handleJs(w http.ResponseWriter, r *http.Request) { + _ = r + w.Header().Set("Content-Type", "application/javascript") + w.Write(readFile("root.js")) +} + +func (s *Server) handleCss(w http.ResponseWriter, r *http.Request) { + _ = r + w.Header().Set("Content-Type", "text/css") + w.Write(readFile("root.css")) +} + +func (s *Server) Run() { + handler := http.NewServeMux() + handler.HandleFunc("/ping", s.handlePing) + handler.HandleFunc("/username", s.handleUsername) + handler.HandleFunc("/messages", s.handleMessages) + handler.HandleFunc("/username/status", s.handleUsernameStatus) + handler.HandleFunc("/users", s.handleUsers) + handler.HandleFunc("/", s.handleRoot) + handler.HandleFunc("/root.js", s.handleJs) + handler.HandleFunc("/root.css", s.handleCss) + fmt.Printf("Server starting on %s:%d\n", s.Ip, s.Port) + if err := http.ListenAndServe(fmt.Sprintf("%s:%d", s.Ip, s.Port), GzipMiddleware(handler)); err != nil { + fmt.Printf("Server error: %v\n", err) + } +} + +func main() { + ip := os.Args[1] + port, _ := strconv.Atoi(os.Args[2]) + // ip := "localhost" + // port := 8080 + server := NewServer(ip, port) + server.Run() +} diff --git a/old/main.go.bak b/old/main.go.bak new file mode 100644 index 0000000..cd9773e --- /dev/null +++ b/old/main.go.bak @@ -0,0 +1,366 @@ +package main + +import ( + "compress/gzip" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "strconv" + "strings" + "sync" + "time" +) + +type gzipResponseWriter struct { + http.ResponseWriter + io.Writer +} + +func (g *gzipResponseWriter) Write(data []byte) (int, error) { + return g.Writer.Write(data) +} + +func GzipMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { + next.ServeHTTP(w, r) + return + } + + gz := gzip.NewWriter(w) + defer gz.Close() + + w.Header().Set("Content-Encoding", "gzip") + next.ServeHTTP(&gzipResponseWriter{ResponseWriter: w, Writer: gz}, r) + }) +} + +func getClientIP(r *http.Request) string { + if fwdIP := r.Header.Get("X-Forwarded-For"); fwdIP != "" { + return strings.Split(fwdIP, ",")[0] + } + + clientIP := r.RemoteAddr + if host, _, err := net.SplitHostPort(clientIP); err == nil { + return host + } + return clientIP +} + +type Message struct { + Content string + SenderIp string + SenderUsername string + Timestamp string +} + +func NewMessage(username, ip, content string) *Message { + timestamp := time.Now().Format("2006-01-02 15:04:05") + return &Message{ + Content: content, + SenderIp: ip, + SenderUsername: username, + Timestamp: timestamp, + } +} + +// TODO: Use it +func writeMessageToFile(msg Message) { + contents := fmt.Sprintf("%s,%s,%s,%s\n", msg.SenderUsername, msg.SenderIp, msg.Timestamp, msg.Content) + os.WriteFile("messages.txt", []byte(contents), os.ModeAppend) +} + +// TODO: Use it +func writeMessagesToFile(messages []Message) { + var contents string + for _, msg := range messages { + contents += fmt.Sprintf("%s,%s,%s,%s\n", msg.SenderUsername, msg.SenderIp, msg.Timestamp, msg.Content) + } + os.WriteFile("messages.txt", []byte(contents), os.ModeAppend) +} + +// TODO: Use it +func readMessagesFromFile(filepath string) []Message { + contents, _ := os.ReadFile(filepath) + lines := strings.Split(string(contents), "\n") + var messages []Message + for _, line := range lines { + if line == "" { + continue + } + parts := strings.Split(line, ",") + username := parts[0] + timestamp := parts[1] + content := strings.Join(parts[2:], " ") + messages = append(messages, Message{username, "", content, timestamp}) + } + return messages +} + +type Server struct { + Ip string + Port int + Messages []Message + // Add mappings for IPs and usernames + Connected map[string]time.Time // Map IP -> Last activity time + Usernames map[string]string // Map IP -> Username + mu sync.Mutex // For thread safety +} + +func NewServer(ip string, port int) *Server { + return &Server{ + Ip: ip, + Port: port, + Messages: make([]Message, 0), + Connected: make(map[string]time.Time), + Usernames: make(map[string]string), + mu: sync.Mutex{}, + } +} + +func (s *Server) AddMessage(username string, userip string, contents string) { + message := NewMessage(username, userip, contents) + s.Messages = append(s.Messages, *message) +} + +func (s *Server) updateActivity(ip string) { + s.mu.Lock() + defer s.mu.Unlock() + s.Connected[ip] = time.Now() +} + +func (s *Server) cleanupActivity() { + s.mu.Lock() + defer s.mu.Unlock() + for ip, lastActivity := range s.Connected { + if time.Since(lastActivity) > 10*time.Second { + delete(s.Connected, ip) + } + } +} + +func (s *Server) handlePing(w http.ResponseWriter, r *http.Request) { + clientIP := getClientIP(r) + s.updateActivity(clientIP) + s.cleanupActivity() + w.WriteHeader(http.StatusOK) +} + +func validUsername(username string) bool { + for _, c := range username { + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_') { + return false + } + } + return true +} + +func (s *Server) handleUsername(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + http.Error(w, `{"error": "Method not allowed"}`, http.StatusMethodNotAllowed) + return + } + + w.Header().Set("Content-Type", "application/json") + + clientIP := getClientIP(r) + + var req struct { + Username string `json:"username"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error": "Invalid JSON"}`, http.StatusBadRequest) + return + } + + s.mu.Lock() + + if len(req.Username) > 64 { + http.Error(w, fmt.Sprintf(`{"error": "Username too long (must be less than 64 characters)"}`), http.StatusRequestEntityTooLarge) + s.mu.Unlock() + return + } + + if !validUsername(req.Username) { + http.Error(w, fmt.Sprintf(`{"error": "Username must only contain alphanumeric characters and/or underscores"}`), http.StatusBadRequest) + s.mu.Unlock() + return + } + + for _, username := range s.Usernames { + if username == req.Username { + s.mu.Unlock() + http.Error(w, fmt.Sprintf(`{"error": "Username already exists"}`), http.StatusConflict) + return + } + } + + if _, ok := s.Usernames[clientIP]; ok { + fmt.Println("Name change detected, fixing messages to reflect the change") + for i, msg := range s.Messages { + if msg.SenderIp == clientIP { + s.Messages[i].SenderUsername = req.Username + } + } + } + s.Usernames[clientIP] = req.Username + + s.mu.Unlock() + + json.NewEncoder(w).Encode(map[string]string{"status": "Username registered"}) + fmt.Println(clientIP, "->", req.Username) +} + +func getMessageTemplate(file string, body string) string { + contents, _ := os.ReadFile(file) + return strings.Replace(string(contents), "{{body}}", body, 1) +} + +func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.Header().Set("Content-Type", "text/html") + + var body string + for _, msg := range s.Messages { + body += fmt.Sprintf(`

%s %s
%s

`, + msg.SenderUsername, msg.Timestamp, msg.Content) + } + + w.Write([]byte(getMessageTemplate("messages.html", body))) + + case http.MethodPut: + w.Header().Set("Content-Type", "application/json") + + // Get client's IP + clientIP := getClientIP(r) + + // Check if IP is registered + s.mu.Lock() + username, exists := s.Usernames[clientIP] + s.mu.Unlock() + + if !exists { + errorFmt := fmt.Sprintf(`{"error": "IP %s not registered with username"}`, clientIP) + http.Error(w, errorFmt, http.StatusUnauthorized) + return + } + + var msg struct { + Message string `json:"message"` + } + + if err := json.NewDecoder(r.Body).Decode(&msg); err != nil { + http.Error(w, `{"error": "Invalid JSON"}`, http.StatusBadRequest) + return + } + + // Add message using the stored username and IP + s.AddMessage(username, clientIP, msg.Message) + + json.NewEncoder(w).Encode(map[string]string{ + "status": "Message received", + "from": username, + }) + default: + http.Error(w, `{"error": "Method not allowed"}`, http.StatusMethodNotAllowed) + return + } +} + +func (s *Server) handleUsernameStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, `{"error": "Method not allowed"}`, http.StatusMethodNotAllowed) + return + } + + w.Header().Set("Content-Type", "application/json") + clientIP := getClientIP(r) + + s.mu.Lock() + username, exists := s.Usernames[clientIP] + s.mu.Unlock() + + json.NewEncoder(w).Encode(map[string]interface{}{ + "hasUsername": exists, + "username": username, + }) +} + +func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, `{"error": "Method not allowed"}`, http.StatusMethodNotAllowed) + return + } + + w.Header().Set("Content-Type", "application/json") + + clientIP := getClientIP(r) + s.updateActivity(clientIP) + s.cleanupActivity() + s.mu.Lock() + var users []string + for ip := range s.Connected { + users = append(users, s.Usernames[ip]) + } + s.mu.Unlock() + + json.NewEncoder(w).Encode(map[string]interface{}{ + "users": users, + }) +} + +func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html") + w.Write(readFile("root.html")) +} + +func readFile(filepath string) []byte { + contents, _ := os.ReadFile(filepath) + return contents +} + +func (s *Server) handleJs(w http.ResponseWriter, r *http.Request) { + _ = r + w.Header().Set("Content-Type", "application/javascript") + w.Write(readFile("root.js")) +} + +func (s *Server) handleCss(w http.ResponseWriter, r *http.Request) { + _ = r + w.Header().Set("Content-Type", "text/css") + w.Write(readFile("root.css")) +} + +func (s *Server) Run() { + handler := http.NewServeMux() + handler.HandleFunc("/ping", s.handlePing) + handler.HandleFunc("/username", s.handleUsername) + handler.HandleFunc("/messages", s.handleMessages) + handler.HandleFunc("/username/status", s.handleUsernameStatus) + handler.HandleFunc("/users", s.handleUsers) + handler.HandleFunc("/", s.handleRoot) + handler.HandleFunc("/root.js", s.handleJs) + handler.HandleFunc("/root.css", s.handleCss) + fmt.Printf("Server starting on %s:%d\n", s.Ip, s.Port) + if err := http.ListenAndServe(fmt.Sprintf("%s:%d", s.Ip, s.Port), GzipMiddleware(handler)); err != nil { + fmt.Printf("Server error: %v\n", err) + } +} + +func main() { + ip := os.Args[1] + port, _ := strconv.Atoi(os.Args[2]) + // ip := "localhost" + // port := 8080 + server := NewServer(ip, port) + server.Run() +} diff --git a/old/messages.html b/old/messages.html new file mode 100644 index 0000000..2e97c0c --- /dev/null +++ b/old/messages.html @@ -0,0 +1,7 @@ + + +
+ {{body}} +
+ + diff --git a/old/root.css b/old/root.css new file mode 100644 index 0000000..05ceb78 --- /dev/null +++ b/old/root.css @@ -0,0 +1,247 @@ +:root { + --radchat-color: #40a02b; + --main-bg-color: #11111b; + --pum-button-inactive-fg: #cdd6f4; + --pum-button-inactive-bg: #11111b; + --pum-button-active-fg: #89b4fa; + --pum-button-active-bg: #1e1e2e; + --pum-title-color: #cdd6f4; + --pum-bg-color: #1e1e2e; + --user-color: #89b4fa; + --timestamp-color: #313244; + --separator-color: #181825; + --message-color: #cdd6f4; + --message-bg-color: #1e1e2e; + --input-bg-color: #181825; + --input-text-color: #cdd6f4; + --input-button-inactive-bg: #b4befe; + --input-button-inactive-fg: #11111b; + --input-button-active-bg: #89b4fa; + --input-button-active-fg: #11111b; +} +body { + font-family: Arial, sans-serif; + margin: 0; + padding: 0; /* Remove padding from body */ + background-color: var(--main-bg-color); + color: var(--pum-title-color); + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; /* Prevent body scroll */ +} +.container { + max-width: 800px; + margin: 0 auto; + width: 100%; + position: relative; + height: 100vh; + padding: 20px; /* Move padding here */ + padding-bottom: 80px; /* Space for message input */ + display: flex; + flex-direction: column; +} + +.header-controls { + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 20px; /* Space between buttons */ + align-items: center; + justify-content: center; +} + +@media (max-width: 768px) { + .header-controls { + gap: 10px; + } +} + +.settings-button, .users-button { + top: 20px; + background: none; + border: none; + cursor: pointer; + padding: 5px; + border-radius: 50%; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--pum-button-inactive-bg); +} + +.settings-button { + right: calc(48%); +} + +.users-button { + right: calc(52%); +} + +.settings-icon, .users-icon { + width: 24px; + height: 24px; + fill: var(--pum-button-inactive-fg); +} + +.settings-button:hover, .users-button:hover { + background-color: var(--pum-button-active-bg); +} + +.settings-button:hover .settings-icon, +.users-button:hover .users-icon { + fill: var(--pum-button-active-fg); +} + +#users-list { + margin-top: 0px; +} + +.current-user { + position: absolute; + right: 0; + top: 28px; + color: var(--user-color); + font-weight: bold; +} + +.username-section, .users-section { + position: fixed; + transform: translateX(-50%); + top: 70px; + padding: 15px; + background-color: var(--pum-bg-color); + border-radius: 5px; + box-shadow: 0 2px 10px rgba(0,0,0,0.3); + z-index: 100; + display: none; + min-width: 250px; +} + +.username-section { + right: 50%; + transform: translateX(10%); +} + +.users-section { + right: 50%; + transform: translateX(-10%); +} + +.users-section h3 { + margin-top: 0; + margin-bottom: 10px; +} + +.user-item { + padding: 8px 0; + color: var(--user-color); + border-bottom: 1px solid var(--separator-color); +} + +.user-item:last-child { + border-bottom: none; +} +.messages-section { + flex-grow: 1; + margin-top: 60px; /* Space for gear icon */ + margin-bottom: 20px; + overflow-y: auto; + height: 0; /* Allow flex-grow to work */ +} +.message { + padding: 10px 0; +} +.message .username { + color: var(--user-color); + font-weight: bold; +} +.message .timestamp { + color: var(--timestamp-color); + font-weight: thin; + font-size: 0.8em; +} + +.message .content { + margin-top: 5px; + color: var(--message-color); + word-wrap: break-word; /* Handle long words */ + word-break: break-word; /* Break words at arbitrary points if needed */ + white-space: pre-wrap; /* Preserve whitespace and wraps */ + max-width: 100%; /* Ensure it doesn't exceed container width */ +} +.message-section { + position: fixed; + bottom: 0; + left: 0; + right: 0; + padding: 20px; + background-color: var(--message-bg-color); + z-index: 100; /* Ensure it stays on top */ +} + +.message-container { + max-width: 800px; + margin: 0 auto; + display: flex; + gap: 10px; +} +input { + padding: 10px; + border: none; + border-radius: 4px; + background-color: var(--input-bg-color); + color: var(--input-text-color); + flex-grow: 1; +} +button { + padding: 10px 20px; + border: none; + border-radius: 4px; + background-color: var(--input-button-inactive-bg); + color: var(--input-button-inactive-fg); + cursor: pointer; +} +button:hover { + background-color: var(--input-button-active-bg); + color: var(--input-button-active-fg) +} + +button.scroll { + padding: 10px 20px; + border-radius: 4px; + border: none; + background-color: var(--input-button-inactive-bg); + padding: 10px; + cursor: pointer; +} + +button.scroll:hover { + background-color: var(--input-button-active-bg); +} + +.scroll-icon { + width: 14px; + height: 14px; + fill: var(--var-input-button-inactive-fg); +} + +.scroll-icon:hover { + fill: var(--var-input-button-active-fg); +} + +.radchat { + position: absolute; + left: 0; + font-family: Arial, sans-serif; + font-size: 2em; + font-weight: bold; + color: var(--radchat-color); +} +#status, #username-status { + margin-top: 10px; +} diff --git a/old/root.html b/old/root.html new file mode 100644 index 0000000..9ad3beb --- /dev/null +++ b/old/root.html @@ -0,0 +1,66 @@ + + + + + + + + + + RadChat + + + + +
+
+ RadChat +
+
+ + +
+
+ +
+

Online Users

+
+ +
+
+ +
+

Username

+
+ + +
+
+
+ +
+ +
+
+ +
+
+ + + +
+
+
+ + + diff --git a/old/root.js b/old/root.js new file mode 100644 index 0000000..f144121 --- /dev/null +++ b/old/root.js @@ -0,0 +1,244 @@ +function toggleSettings() { + const panel = document.getElementById('settings-panel'); + panel.style.display = panel.style.display === 'none' ? 'block' : 'none'; + if (panel.style.display === 'block') { + const username = document.getElementById('username'); + username.focus(); + username.selectionStart = username.selectionEnd = username.value.length + } +} + +function toggleUsers() { + const panel = document.getElementById('users-panel'); + panel.style.display = panel.style.display === 'none' ? 'block' : 'none'; +} + +document.addEventListener('click', function(event) { + const settingsPanel = document.getElementById('settings-panel'); + const settingsButton = document.querySelector('.settings-button'); + const usersPanel = document.getElementById('users-panel'); + const usersButton = document.querySelector('.users-button'); + + if (!settingsPanel.contains(event.target) && !settingsButton.contains(event.target)) { + settingsPanel.style.display = 'none'; + } + + if (!usersPanel.contains(event.target) && !usersButton.contains(event.target)) { + usersPanel.style.display = 'none'; + } +}); + +document.addEventListener('keypress', function(event) { + if (event.key === 'Enter') { + const settingsPanel = document.getElementById('settings-panel'); + const inputPanel = document.getElementById('message'); + if (settingsPanel.contains(event.target)) { + setUsername(); + } + if (inputPanel.contains(event.target)) { + sendMessage(); + } + } +}); + +async function loadUsers() { + try { + const response = await fetch('/users'); + const data = await response.json(); + + const usersList = document.getElementById('users-list'); + usersList.innerHTML = ''; + + data.users.sort().forEach(user => { + const userDiv = document.createElement('div'); + userDiv.className = 'user-item'; + userDiv.textContent = user; + usersList.appendChild(userDiv); + }); + } catch (error) { + console.error('Error loading users:', error); + } +} + + +let lastMessageCount = 0; +async function loadMessages() { + try { + const messagesDiv = document.getElementById('messages'); + const response = await fetch('/messages'); + const text = await response.text(); + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = text; + + if (messagesDiv.scrollTop != bottom) { + // show a button to scroll to the bottom + const scrollToBottomButton = document.getElementById('scroll'); + scrollToBottomButton.style.display = 'block'; + scrollToBottomButton.onclick = scrollToBottom; + } else { + // hide the button + const scrollToBottomButton = document.getElementById('scroll'); + scrollToBottomButton.style.display = 'none'; + } + + const messages = tempDiv.getElementsByTagName('p'); + + const newMessageCount = messages.length; + + if (newMessageCount > lastMessageCount || lastMessageCount === 0) { + messagesDiv.innerHTML = ''; + Array.from(messages).forEach(msg => { + const messageDiv = document.createElement('div'); + messageDiv.className = 'message'; + const [username, content] = msg.innerHTML.split('
'); + messageDiv.innerHTML = '
' + username + '
' + + '
' + content + '
'; + messagesDiv.appendChild(messageDiv); + }); + scrollToBottom(); + lastMessageCount = newMessageCount; + } + } catch (error) { + console.error('Error loading messages:', error); + } +} + +var bottom = 0; + +function scrollToBottom() { + const messagesDiv = document.getElementById('messages'); + messagesDiv.scrollTop = messagesDiv.scrollHeight; + bottom = messagesDiv.scrollTop; +} + +async function checkUsername() { + try { + const response = await fetch('/username/status'); + const data = await response.json(); + if (!data.hasUsername) { + document.getElementById('settings-panel').style.display = 'block' + const username = document.getElementById('username'); + username.focus(); + username.selectionStart = username.selectionEnd = username.value.length + } + } catch (error) { + console.error('Error checking username status:', error); + } +} + +async function updateCurrentUser() { + try { + const response = await fetch('/username/status'); + const data = await response.json(); + const userDiv = document.getElementById('current-user'); + if (data.hasUsername) { + userDiv.textContent = data.username; + } else { + userDiv.textContent = ''; + } + } catch (error) { + console.error('Error getting username:', error); + } +} + +async function setUsername() { + const username = document.getElementById('username').value; + if (!username) { + showUsernameStatus('Please enter a username', 'red'); + return; + } + + try { + const response = await fetch('/username', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ username: username }) + }); + + const data = await response.json(); + + if (response.ok) { + showUsernameStatus('Username set successfully!', 'green'); + updateCurrentUser(); + setTimeout(() => { + document.getElementById('settings-panel').style.display = 'none'; + }, 500); + location.reload(); + } else { + showUsernameStatus(data.error || 'Failed to set username', 'red'); + } + } catch (error) { + showUsernameStatus('Error connecting to server', 'red'); + } +} + +async function sendMessage() { + const message = document.getElementById('message').value; + if (!message) { + return; + } + + try { + const response = await fetch('/messages', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ message: message }) + }); + + const data = await response.json(); + + if (response.ok) { + document.getElementById('message').value = ''; + // showStatus('Message sent!', '#64b5f6'); + loadMessages(); + } else { + showStatus(data.error || 'Failed to send message', 'red'); + } + } catch (error) { + showStatus('Error connecting to server', 'red'); + } +} + +function showStatus(message, color) { + const status = document.getElementById('status'); + status.textContent = message; + status.style.color = color; + setTimeout(() => { + status.textContent = ''; + }, 3000); +} + +function showUsernameStatus(message, color) { + const status = document.getElementById('username-status'); + status.textContent = message; + status.style.color = color; + setTimeout(() => { + status.textContent = ''; + }, 3000); +} + +async function pingCheck() { + try { + await fetch('/ping', {method: 'POST'}); + } catch (error) { + console.error('Ping failed:', error); + } +} + +async function initialize() { + await loadMessages(); + document.getElementById('users-panel').style.display = 'none'; + document.getElementById('settings-panel').style.display = 'none'; + checkUsername(); + updateCurrentUser(); + setInterval(loadMessages, 1000); + setInterval(loadUsers, 1000); + setInterval(pingCheck, 3000); + scrollToBottom(); +} + +initialize(); diff --git a/root.css b/root.css new file mode 100644 index 0000000..6d3e92c --- /dev/null +++ b/root.css @@ -0,0 +1,321 @@ +[data-theme="dark"] { + --radchat-color: #40a02b; + --main-bg-color: #11111b; + --pum-button-inactive-fg: #cdd6f4; + --pum-button-inactive-bg: #11111b; + --pum-button-active-fg: #89b4fa; + --pum-button-active-bg: #1e1e2e; + --pum-title-color: #cdd6f4; + --pum-bg-color: #1e1e2e; + --user-color: #89b4fa; + --timestamp-color: #313244; + --separator-color: #181825; + --message-color: #cdd6f4; + --message-bg-color: #1e1e2e; + --input-bg-color: #181825; + --input-text-color: #cdd6f4; + --input-button-inactive-bg: #b4befe; + --input-button-inactive-fg: #11111b; + --input-button-active-bg: #89b4fa; + --input-button-active-fg: #11111b; +} + +[data-theme="light"] { + --radchat-color: #40a02b; + --main-bg-color: #dce0e8; + --pum-button-inactive-fg: #4c4f69; + --pum-button-inactive-bg: #dce0e8; + --pum-button-active-fg: #1e66f5; + --pum-button-active-bg: #eff1f5; + --pum-title-color: #4c4f69; + --pum-bg-color: #eff1f5; + --user-color: #1e66f5; + --timestamp-color: #8c8fa1; + --separator-color: #e6e9ef; + --message-color: #4c4f69; + --message-bg-color: #eff1f5; + --input-bg-color: #e6e9ef; + --input-text-color: #4c4f69; + --input-button-inactive-bg: #7287fd; + --input-button-inactive-fg: #dce0e8; + --input-button-active-bg: #1e66f5; + --input-button-active-fg: #dce0e8; +} + +body { + font-family: Arial, sans-serif; + margin: 0; + padding: 0; /* Remove padding from body */ + background-color: var(--main-bg-color); + color: var(--pum-title-color); + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; /* Prevent body scroll */ +} +.container { + max-width: 800px; + margin: 0 auto; + width: 100%; + position: relative; + height: 100vh; + padding: 20px; /* Move padding here */ + padding-bottom: 80px; /* Space for message input */ + display: flex; + flex-direction: column; +} + +.header-controls { + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 20px; /* Space between buttons */ + align-items: center; + justify-content: center; +} + + +/* Show/hide appropriate icon based on theme */ +[data-theme="light"] .theme-icon .sun { display: none; } +[data-theme="dark"] .theme-icon .moon { display: none; } + +@media (max-width: 100px) { + .header-controls { + gap: 10px; + } +} + +.settings-button, .users-button, .theme-button { + top: 20px; + background: none; + border: none; + cursor: pointer; + padding: 5px; + border-radius: 50%; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--pum-button-inactive-bg); +} + +.settings-button { + right: calc(50%); +} + +.users-button { + right: calc(60%); +} + +.theme-button { + right: calc(30%); +} + +.settings-icon, .users-icon, .theme-icon{ + width: 24px; + height: 24px; + fill: var(--pum-button-inactive-fg); +} + +.settings-button:hover, .users-button:hover, .theme-button:hover { + background-color: var(--pum-button-active-bg); +} + +.settings-button:hover .settings-icon, +.users-button:hover .users-icon, +.theme-button:hover .theme-icon { + fill: var(--pum-button-active-fg); +} + +#users-list { + margin-top: 0px; +} + +.current-user { + position: absolute; + right: 0; + top: 28px; + color: var(--user-color); + font-weight: bold; +} + +.username-section, .users-section { + position: fixed; + transform: translateX(-50%); + top: 70px; + padding: 15px; + background-color: var(--pum-bg-color); + border-radius: 5px; + box-shadow: 0 2px 10px rgba(0,0,0,0.3); + z-index: 100; + display: none; + min-width: 250px; +} + +.username-section { + right: 50%; + transform: translateX(10%); +} + +.username-section h3 { + margin-top: 0; + margin-bottom: 20px; +} + +.users-section { + right: 50%; + transform: translateX(-10%); +} + +.users-section h3 { + margin-top: 0; + margin-bottom: 10px; +} + +.user-item { + padding: 8px 0; + color: var(--user-color); + border-bottom: 1px solid var(--separator-color); +} + +.user-item:last-child { + border-bottom: none; +} +.messages-section { + flex-grow: 1; + margin-top: 60px; /* Space for gear icon */ + margin-bottom: 60px; + overflow-y: auto; + height: 0; /* Allow flex-grow to work */ +} +.message { + padding: 10px 0; +} +.message .username { + color: var(--user-color); + font-weight: bold; +} +.message .timestamp { + color: var(--timestamp-color); + font-weight: thin; + font-size: 0.8em; +} + +.message .content { + margin-top: 5px; + color: var(--message-color); + word-wrap: break-word; /* Handle long words */ + word-break: break-word; /* Break words at arbitrary points if needed */ + white-space: pre-wrap; /* Preserve whitespace and wraps */ + max-width: 100%; /* Ensure it doesn't exceed container width */ +} +.message-section { + position: fixed; + bottom: 0; + left: 0; + right: 0; + padding: 20px; + background-color: var(--message-bg-color); + z-index: 100; /* Ensure it stays on top */ +} + +.message-container { + max-width: 800px; + margin: 0 auto; + display: flex; + gap: 10px; +} + +.youtube-embed { + position: relative; + padding-top: 10px; + width: 100%; + max-width: 560px; /* Standard YouTube width */ +} + +.youtube-embed iframe { + border-radius: 4px; +} + +.image-embed { + padding-top: 10px; + max-width: 560px; /* Match YouTube width */ +} + +.image-embed img { + max-width: 100%; + height: auto; + border-radius: 4px; + display: block; +} + +input { + padding: 10px; + border: none; + border-radius: 4px; + background-color: var(--input-bg-color); + color: var(--input-text-color); + flex-grow: 1; +} +textarea { + padding: 10px; + border: none; + border-radius: 4px; + background-color: var(--input-bg-color); + color: var(--input-text-color); + flex-grow: 1; + resize: none; /* Prevents manual resizing */ + min-height: 38px; /* Match your previous input height */ + line-height: 1.4; + font-family: Arial, sans-serif; /* Match your body font */ +} +button { + padding: 10px 20px; + border: none; + border-radius: 4px; + background-color: var(--input-button-inactive-bg); + color: var(--input-button-inactive-fg); + cursor: pointer; +} +button:hover { + background-color: var(--input-button-active-bg); + color: var(--input-button-active-fg) +} + +button.scroll { + padding: 10px 20px; + border-radius: 4px; + border: none; + background-color: var(--input-button-inactive-bg); + padding: 10px; + cursor: pointer; +} + +button.scroll:hover { + background-color: var(--input-button-active-bg); +} + +.scroll-icon { + width: 14px; + height: 14px; + fill: var(--var-input-button-inactive-fg); +} + +.scroll-icon:hover { + fill: var(--var-input-button-active-fg); +} + +.radchat { + position: absolute; + left: 0; + font-family: Arial, sans-serif; + font-size: 2em; + font-weight: bold; + color: var(--radchat-color); +} +#status, #username-status { + margin-top: 10px; +} diff --git a/root.html b/root.html new file mode 100644 index 0000000..8d6e529 --- /dev/null +++ b/root.html @@ -0,0 +1,74 @@ + + + + + + + + + + RadChat + + + + +
+
+ RadChat +
+
+ + + +
+
+ +
+

Online Users

+
+ +
+
+ +
+

Username

+
+ + +
+
+
+ +
+ +
+
+ +
+
+ + + +
+
+
+ + + diff --git a/root.js b/root.js new file mode 100644 index 0000000..b5a4c92 --- /dev/null +++ b/root.js @@ -0,0 +1,418 @@ +function toggleSettings() { + const panel = document.getElementById("settings-panel"); + panel.style.display = panel.style.display === "none" ? "block" : "none"; + if (panel.style.display === "block") { + const username = document.getElementById("username"); + username.focus(); + username.selectionStart = username.selectionEnd = username.value + .length; + } +} + +function toggleUsers() { + const panel = document.getElementById("users-panel"); + panel.style.display = panel.style.display === "none" ? "block" : "none"; +} + +function toggleTheme() { + const currentTheme = document.documentElement.getAttribute( + "data-theme", + ); + const newTheme = currentTheme === "dark" ? "light" : "dark"; + document.documentElement.setAttribute("data-theme", newTheme); + localStorage.setItem("theme", newTheme); +} + +document.addEventListener("click", function (event) { + const settingsPanel = document.getElementById("settings-panel"); + const settingsButton = document.querySelector(".settings-button"); + const usersPanel = document.getElementById("users-panel"); + const usersButton = document.querySelector(".users-button"); + + if ( + !settingsPanel.contains(event.target) && + !settingsButton.contains(event.target) + ) { + settingsPanel.style.display = "none"; + } + + if ( + !usersPanel.contains(event.target) && + !usersButton.contains(event.target) + ) { + usersPanel.style.display = "none"; + } +}); + +document.addEventListener("keypress", function (event) { + if (event.key === "Enter" && !event.shiftKey) { + const settingsPanel = document.getElementById("settings-panel"); + const inputPanel = document.getElementById("message"); + if (settingsPanel.contains(event.target)) { + setUsername(); + } + if (inputPanel.contains(event.target)) { + event.preventDefault(); + sendMessage(); + } + } +}); + +document.addEventListener("input", function (_) { + const msg = document.getElementById("message"); + msg.style.height = "auto"; + msg.style.height = (msg.scrollHeight) + "px"; +}); + +document.addEventListener("blur", function (_) { + const msg = document.getElementById("message"); + msg.style.height = "auto"; +}, true); + +async function loadUsers() { + try { + const response = await fetch("/users"); + const data = await response.json(); + + const usersList = document.getElementById("users-list"); + usersList.innerHTML = ""; + + data.users.sort().forEach((user) => { + const userDiv = document.createElement("div"); + userDiv.className = "user-item"; + userDiv.textContent = user; + usersList.appendChild(userDiv); + }); + } catch (error) { + console.error("Error loading users:", error); + } +} + +let lastMessageCount = 0; +async function loadMessages() { + try { + let messagesDiv = document.getElementById("messages"); + const response = await fetch("/messages"); + const text = await response.text(); + const tempDiv = document.createElement("div"); + tempDiv.innerHTML = text; + + // getting an error right here that messagesDiv is null + // but only on the first load, so maybe we can just hold off? + while (messagesDiv === null) { + await new Promise((resolve) => + setTimeout(resolve, 100) + ); + messagesDiv = document.getElementById("messages"); + } + if (messagesDiv.scrollTop != bottom) { + // show a button to scroll to the bottom + const scrollToBottomButton = document.getElementById( + "scroll", + ); + scrollToBottomButton.style.display = "block"; + scrollToBottomButton.onclick = scrollToBottom; + } else { + // hide the button + const scrollToBottomButton = document.getElementById( + "scroll", + ); + scrollToBottomButton.style.display = "none"; + } + + const messages = tempDiv.getElementsByTagName("p"); + + const newMessageCount = messages.length; + + if ( + newMessageCount > lastMessageCount || + lastMessageCount === 0 + ) { + messagesDiv.innerHTML = ""; + Array.from(messages).forEach((msg) => { + const messageDiv = document.createElement( + "div", + ); + messageDiv.className = "message"; + const [username, content] = msg.innerHTML.split( + "
", + ); + const linkedContent = content.replace( + /(?![^<]*>)(https?:\/\/[^\s<]+)/g, + function (url) { + console.log( + "Processing URL:", + url, + ); // Debug log + + const videoId = getYouTubeID( + url, + ); + if (videoId) { + return `${url} +
+ +
`; + } else if (isImageUrl(url)) { + console.log( + "Attempting to embed image:", + url, + ); + return `${url} +
+ Embedded image +
`; + } + return `${url}`; + }, + ); + messageDiv.innerHTML = + '
' + username + + "
" + + '
' + + linkedContent + "
"; + messagesDiv.appendChild(messageDiv); + }); + scrollToBottom(); + lastMessageCount = newMessageCount; + } + } catch (error) { + console.error("Error loading messages:", error); + } +} + +function isImageUrl(url) { + return url.match(/\.(jpeg|jpg|gif|png|webp|bmp)($|\?)/i) != null; +} + +function getYouTubeID(url) { + // First check if it's a Shorts URL + if (url.includes("/shorts/")) { + const shortsMatch = url.match(/\/shorts\/([^/?]+)/); + return shortsMatch ? shortsMatch[1] : false; + } + + // Otherwise check regular YouTube URLs + const regExp = + /^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#&?]*).*/; + const match = url.match(regExp); + return (match && match[7].length == 11) ? match[7] : false; +} + +var bottom = 0; + +function scrollToBottom() { + const messagesDiv = document.getElementById("messages"); + messagesDiv.scrollTop = messagesDiv.scrollHeight; + bottom = messagesDiv.scrollTop; +} + +async function checkUsername() { + try { + const response = await fetch("/username/status"); + const data = await response.json(); + if (!data.hasUsername) { + document.getElementById("settings-panel").style + .display = "block"; + const username = document.getElementById("username"); + username.focus(); + username.selectionStart = + username.selectionEnd = + username.value.length; + } + } catch (error) { + console.error("Error checking username status:", error); + } +} + +async function updateCurrentUser() { + try { + const response = await fetch("/username/status"); + const data = await response.json(); + const userDiv = document.getElementById("current-user"); + if (data.hasUsername) { + userDiv.textContent = data.username; + } else { + userDiv.textContent = ""; + } + } catch (error) { + console.error("Error getting username:", error); + } +} + +async function setUsername() { + const username = document.getElementById("username").value; + if (!username) { + showUsernameStatus("Please enter a username", "red"); + return; + } + + try { + const response = await fetch("/username", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ username: username }), + }); + + const data = await response.json(); + + if (response.ok) { + showUsernameStatus( + "Username set successfully!", + "green", + ); + updateCurrentUser(); + setTimeout(() => { + document.getElementById("settings-panel").style + .display = "none"; + }, 500); + location.reload(); + } else { + showUsernameStatus( + data.error || "Failed to set username", + "red", + ); + } + } catch (error) { + showUsernameStatus("Error connecting to server", "red"); + } +} + +async function sendMessage() { + const messageInput = document.getElementById("message"); + const message = messageInput.value; + if (!message) { + return; + } + + try { + const response = await fetch("/messages", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ message: message }), + }); + + const data = await response.json(); + + if (response.ok) { + messageInput.value = ""; + messageInput.style.height = "auto"; + loadMessages(); + } else { + showStatus( + data.error || "Failed to send message", + "red", + ); + } + } catch (error) { + showStatus("Error connecting to server", "red"); + } +} + +function showStatus(message, color) { + const status = document.getElementById("status"); + status.textContent = message; + status.style.color = color; + setTimeout(() => { + status.textContent = ""; + }, 3000); +} + +function showUsernameStatus(message, color) { + const status = document.getElementById("username-status"); + status.textContent = message; + status.style.color = color; + setTimeout(() => { + status.textContent = ""; + }, 3000); +} + +async function pingCheck() { + try { + await fetch("/ping", { method: "POST" }); + } catch (error) { + console.error("Ping failed:", error); + } +} + +async function timeZoneCheck() { + try { + const timeZone = + Intl.DateTimeFormat().resolvedOptions().timeZone; + const response = await fetch("/timezone", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ timezone: timeZone }), + }); + const data = await response.json(); + if (!response.ok) { + console.error("Failed to set timezone:", data.error); + } + } catch (error) { + console.error("Error checking timezone:", error); + } +} + +function initializeTheme() { + const savedTheme = localStorage.getItem("theme"); + if (savedTheme) { + document.documentElement.setAttribute("data-theme", savedTheme); + } else { + // Check system preference + if ( + window.matchMedia && + window.matchMedia("(prefers-color-scheme: light)") + .matches + ) { + document.documentElement.setAttribute( + "data-theme", + "light", + ); + } else { + document.documentElement.setAttribute( + "data-theme", + "dark", + ); + } + } +} + +async function initialize() { + usersPanel = document.getElementById("users-panel"); + if (usersPanel) { + usersPanel.style.display = "none"; + } + settingsPanel = document.getElementById("settings-panel"); + if (settingsPanel) { + settingsPanel.style.display = "none"; + } + initializeTheme(); + checkUsername(); + updateCurrentUser(); + timeZoneCheck(); + setInterval(loadMessages, 1000); + setInterval(loadUsers, 1000); + setInterval(pingCheck, 3000); + await loadMessages(); + scrollToBottom(); +} + +initialize();