From 7e617eb1af09564838dec3679c656dee947fa533 Mon Sep 17 00:00:00 2001 From: Radon Date: Wed, 22 Jan 2025 20:04:47 -0600 Subject: [PATCH] changes --- config.json | 7 +- content/messages.html | 7 - db/db.go | 326 +++++++++++ main.go | 779 +------------------------ content/root.html => public/index.html | 34 +- content/root.js => public/index.js | 138 ++++- content/root.css => public/style.css | 112 ++-- readme.md | 8 +- srv/handle.go | 285 +++++++++ srv/srv.go | 181 ++++++ tu/tu.go | 26 + 11 files changed, 1006 insertions(+), 897 deletions(-) delete mode 100644 content/messages.html create mode 100644 db/db.go rename content/root.html => public/index.html (84%) rename content/root.js => public/index.js (84%) rename content/root.css => public/style.css (90%) create mode 100644 srv/handle.go create mode 100644 srv/srv.go create mode 100644 tu/tu.go diff --git a/config.json b/config.json index 503e709..bb9b9f7 100644 --- a/config.json +++ b/config.json @@ -5,10 +5,9 @@ }, "paths": { "databasePath": "/home/radon/Documents/chattest.db", - "indexJsPath": "./content/root.js", - "indexCssPath": "./content/root.css", - "indexHtmlPath": "./content/root.html", - "messagesHtmlPath": "./content/messages.html" + "indexJsPath": "./public/index.js", + "indexCssPath": "./public/style.css", + "indexHtmlPath": "./public/index.html" }, "options": { "messageMaxAge": 259200, diff --git a/content/messages.html b/content/messages.html deleted file mode 100644 index 2e97c0c..0000000 --- a/content/messages.html +++ /dev/null @@ -1,7 +0,0 @@ - - -
- {{body}} -
- - diff --git a/db/db.go b/db/db.go new file mode 100644 index 0000000..c443fbf --- /dev/null +++ b/db/db.go @@ -0,0 +1,326 @@ +package db + +import ( + "database/sql" + "fmt" + _ "github.com/mattn/go-sqlite3" + "strconv" + "time" +) + +type User struct { + Id string + Username string + IpAddress string + Timezone string +} + +type Message struct { + Id string + SenderIp string + SenderUsername string + Content string + Timestamp string + Edited bool +} + +type Database struct { + db *sql.DB +} + +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, + edited INTEGER DEFAULT 0, + 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) { + _, err := db.db.Exec("UPDATE users SET timezone = ? WHERE ip_address = ?", timezone, ip_address) + if err != nil { + fmt.Println(err) + } +} + +func (db *Database) UserAdd(ip_address, username string) { + _, err := db.db.Exec("INSERT INTO users (username, ip_address) VALUES (?, ?)", username, ip_address) + if err != nil { + fmt.Println(err) + } +} + +func (db *Database) MessageAdd(ip_address string, content string) { + _, err := db.db.Exec("INSERT INTO messages (ip_address, content) VALUES (?, ?)", ip_address, content) + if err != nil { + fmt.Println(err) + } +} + +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) UserIpGet(username string) string { + rows, err := db.db.Query("SELECT ip_address FROM users WHERE username = ?", username) + if err != nil { + fmt.Println(err) + } + defer rows.Close() + var ip_address string + rows.Next() + rows.Scan(&ip_address) + return ip_address +} + +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, messages.edited + 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 + var edited int + rows.Scan(&id, &ip_address, &content, &created_at, &username, &edited) + + editedBool := false + if edited == 1 { + editedBool = true + } + + message := Message{ + Id: id, + Content: content, + SenderIp: ip_address, + SenderUsername: username, + Edited: editedBool, + 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) { + _, err := db.db.Exec("UPDATE users SET username = ? WHERE ip_address = ?", newUsername, ip) + if err != nil { + fmt.Println(err) + } +} + +func (db *Database) UserMessagesDelete(ip string) { + _, err := db.db.Exec("DELETE FROM messages WHERE ip_address = ?", ip) + if err != nil { + fmt.Println(err) + } +} + +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 + var edited int + rows.Scan(&id, &ip_address, &content, &created_at, &username, &edited) + t, _ := time.Parse(created_at, created_at) + editedBool := false + if edited == 1 { + editedBool = true + } + message := Message{ + Id: id, + Content: content, + SenderIp: ip_address, + SenderUsername: username, + Edited: editedBool, + Timestamp: t.Format(created_at), + } + messages = append(messages, message) + } + return messages +} + +func (db *Database) MessageDeleteId(id string) { + _, err := db.db.Exec("DELETE FROM messages WHERE id = ?", id) + if err != nil { + fmt.Println(err) + } +} +func (db *Database) MessageDeleteIfOwner(id string, ip string) (int, error) { + res, err := db.db.Exec("DELETE FROM messages WHERE id = ? AND ip_address = ?", id, ip) + if err != nil { + return 0, err + } + affected, err := res.RowsAffected() + if err != nil { + return 0, err + } + return int(affected), nil + +} + +func (db *Database) MessageEditIfOwner(id string, content string, ip string) (int, error) { + res, err := db.db.Exec("UPDATE messages SET content = ?, edited = 1 WHERE id = ? AND ip_address = ?", content, id, ip) + if err != nil { + return 0, err + } + affected, err := res.RowsAffected() + if err != nil { + return 0, err + } + return int(affected), nil +} + +func (db *Database) DeleteOldMessages(ageMinutes int) { + if ageMinutes <= 0 { + return + } + age := strconv.Itoa(ageMinutes) + _, err := db.db.Exec("DELETE FROM messages WHERE created_at < datetime('now', ? || ' minutes')", "-"+age) + if err != nil { + fmt.Println(err) + } +} + +func (db *Database) UserDeleteIp(ip string) { + _, err := db.db.Exec("DELETE FROM users WHERE ip_address = ?", ip) + if err != nil { + fmt.Println(err) + } +} + +func (db *Database) UsersDelete() { + _, err := db.db.Exec("DELETE FROM users") + if err != nil { + fmt.Println(err) + } +} + +func (db *Database) MessagesDelete() { + _, err := db.db.Exec("DELETE FROM messages") + if err != nil { + fmt.Println(err) + } +} diff --git a/main.go b/main.go index b7273d4..0691d0d 100644 --- a/main.go +++ b/main.go @@ -1,789 +1,22 @@ package main import ( - "compress/gzip" - "database/sql" - "encoding/json" + "chat/srv" "fmt" - _ "github.com/mattn/go-sqlite3" - "io" - "net" - "net/http" "os" - "path/filepath" - "strconv" - "strings" - "sync" - "time" ) -type Database struct { - db *sql.DB -} +var config srv.Config -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) { - _, err := db.db.Exec("UPDATE users SET timezone = ? WHERE ip_address = ?", timezone, ip_address) - if err != nil { - fmt.Println(err) - } -} - -func (db *Database) UserAdd(ip_address, username string) { - _, err := db.db.Exec("INSERT INTO users (username, ip_address) VALUES (?, ?)", username, ip_address) - if err != nil { - fmt.Println(err) - } -} - -func (db *Database) MessageAdd(ip_address string, content string) { - _, err := db.db.Exec("INSERT INTO messages (ip_address, content) VALUES (?, ?)", ip_address, content) - if err != nil { - fmt.Println(err) - } -} - -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) UserIpGet(username string) string { - rows, err := db.db.Query("SELECT ip_address FROM users WHERE username = ?", username) - if err != nil { - fmt.Println(err) - } - defer rows.Close() - var ip_address string - rows.Next() - rows.Scan(&ip_address) - return ip_address -} - -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) { - _, err := db.db.Exec("UPDATE users SET username = ? WHERE ip_address = ?", newUsername, ip) - if err != nil { - fmt.Println(err) - } -} - -func (db *Database) UserMessagesDelete(ip string) { - _, err := db.db.Exec("DELETE FROM messages WHERE ip_address = ?", ip) - if err != nil { - fmt.Println(err) - } -} - -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) MessageDeleteId(id string) { - _, err := db.db.Exec("DELETE FROM messages WHERE id = ?", id) - if err != nil { - fmt.Println(err) - } -} -func (db *Database) MessageDeleteIfOwner(id string, ip string) (int, error) { - res, err := db.db.Exec("DELETE FROM messages WHERE id = ? AND ip_address = ?", id, ip) - if err != nil { - return 0, err - } - affected, err := res.RowsAffected() - if err != nil { - return 0, err - } - return int(affected), nil - -} - -func (db *Database) MessageEditIfOwner(id string, content string, ip string) (int, error) { - res, err := db.db.Exec("UPDATE messages SET content = ? WHERE id = ? AND ip_address = ?", content, id, ip) - if err != nil { - return 0, err - } - affected, err := res.RowsAffected() - if err != nil { - return 0, err - } - return int(affected), nil -} - -func (db *Database) DeleteOldMessages(ageMinutes int) { - if ageMinutes <= 0 { - return - } - age := strconv.Itoa(ageMinutes) - _, err := db.db.Exec("DELETE FROM messages WHERE created_at < datetime('now', ? || ' minutes')", "-"+age) - if err != nil { - fmt.Println(err) - } -} - -func (db *Database) UserDeleteIp(ip string) { - _, err := db.db.Exec("DELETE FROM users WHERE ip_address = ?", ip) - if err != nil { - fmt.Println(err) - } -} - -func (db *Database) UsersDelete() { - _, err := db.db.Exec("DELETE FROM users") - if err != nil { - fmt.Println(err) - } -} - -func (db *Database) MessagesDelete() { - _, err := db.db.Exec("DELETE FROM messages") - if err != nil { - fmt.Println(err) - } -} - -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 { - Connected map[string]time.Time // Map IP -> Last activity time - Database *Database - Config Config - mu sync.Mutex // For thread safety -} - -func NewServer(config Config) *Server { - - return &Server{ - Connected: make(map[string]time.Time), - Database: OpenDatabase(config.Paths.DatabasePath), - Config: config, - 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) > s.Config.Options.NameMaxLength { - http.Error(w, fmt.Sprintf(`{"error": "Username too long (%v out of %v characters maximum)"}`, len(req.Username), s.Config.Options.NameMaxLength), 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) { - http.Error(w, fmt.Sprintf(`{"error": "Username already exists"}`), http.StatusConflict) - s.mu.Unlock() - 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(filepath string, body string) string { - contents, _ := os.ReadFile(filepath) - return strings.Replace(string(contents), "{{body}}", body, 1) -} - -func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodPatch: - w.Header().Set("Content-Type", "application/json") - var req struct { - MessageId string `json:"messageId"` - MessageContent string `json:"messageContent"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, `{"error": "Invalid JSON"}`, http.StatusBadRequest) - return - } - - clientIP := getClientIP(r) - if affected, err := s.Database.MessageEditIfOwner(req.MessageId, req.MessageContent, clientIP); err != nil { - http.Error(w, `{"error": "Unauthorized"}`, http.StatusNotFound) - return - } else if affected == 0 { - http.Error(w, `{"error": "Message not found"}`, http.StatusNotFound) - return - } - - json.NewEncoder(w).Encode(map[string]string{ - "status": "Message edited successfully", - }) - - 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
%s

`, - msg.Id, msg.SenderUsername, timeLocal, msg.Content) - } - - w.Write([]byte(getMessageTemplate(s.Config.Paths.MessagesHtmlPath, 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, - }) - case http.MethodDelete: - w.Header().Set("Content-Type", "application/json") - - var req struct { - MessageId string `json:"messageId"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, `{"error": "Invalid JSON"}`, http.StatusBadRequest) - return - } - - clientIP := getClientIP(r) - if affected, err := s.Database.MessageDeleteIfOwner(req.MessageId, clientIP); err != nil { - http.Error(w, `{"error": "Unauthorized"}`, http.StatusNotFound) - return - } else if affected == 0 { - http.Error(w, `{"error": "Message not found"}`, http.StatusNotFound) - return - } - - json.NewEncoder(w).Encode(map[string]string{ - "status": "Message deleted", - }) - 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") - file := readFile(s.Config.Paths.IndexHtmlPath) - w.Write(file) -} - -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") - file := readFile(s.Config.Paths.IndexJsPath) - w.Write(file) -} - -func (s *Server) handleCss(w http.ResponseWriter, r *http.Request) { - _ = r - w.Header().Set("Content-Type", "text/css") - file := readFile(s.Config.Paths.IndexCssPath) - w.Write(file) -} - -func (s *Server) handleMessagesLength(w http.ResponseWriter, r *http.Request) { - // should return the number of messages in the database - if r.Method != http.MethodGet { - http.Error(w, `{"error": "Method not allowed"}`, http.StatusMethodNotAllowed) - return - } - w.Header().Set("Content-Type", "application/json") - messages := s.Database.MessagesGet() - json.NewEncoder(w).Encode(map[string]int{ - "length": len(messages), - }) -} - -func (s *Server) Run() { - s.Database.DbCreateTableMessages() - s.Database.DbCreateTableUsers() - s.Database.DeleteOldMessages(s.Config.Options.MessageMaxAge) - handler := http.NewServeMux() - handler.HandleFunc("/ping", s.handlePing) - handler.HandleFunc("/username", s.handleUsername) - handler.HandleFunc("/messages", s.handleMessages) - handler.HandleFunc("/messages/length", s.handleMessagesLength) - 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.Config.Server.IpAddress, s.Config.Server.Port) - defer s.Stop() - if err := http.ListenAndServe(fmt.Sprintf("%s:%d", s.Config.Server.IpAddress, s.Config.Server.Port), GzipMiddleware(handler)); err != nil { - fmt.Printf("Server error: %v\n", err) - } -} - -func (s *Server) Stop() { - s.Database.Close() -} - -func main() { +func init() { if len(os.Args) < 2 { fmt.Printf("Usage: %s \n", os.Args[0]) os.Exit(1) } - _, err := os.OpenFile(os.Args[1], os.O_RDONLY, 0) - if err != nil { - fmt.Println("Error opening config file: ", err) - os.Exit(1) - } - config := LoadConfig(os.Args[1]) + config = srv.LoadConfig(os.Args[1]) fmt.Println("Config loaded") - server := NewServer(config) - server.Run() } -type Config struct { - Server struct { - IpAddress string `json:"ipAddress"` - Port int `json:"port"` - } `json:"server"` - Paths struct { - DatabasePath string `json:"databasePath"` - IndexJsPath string `json:"indexJsPath"` - IndexCssPath string `json:"indexCssPath"` - IndexHtmlPath string `json:"indexHtmlPath"` - MessagesHtmlPath string `json:"messagesHtmlPath"` - } `json:"paths"` - Options struct { - MessageMaxAge int `json:"messageMaxAge"` - NameMaxLength int `json:"nameMaxLength"` - } `json:"options"` -} - -func LoadConfig(filepath string) Config { - contents, _ := os.ReadFile(filepath) - var config Config - err := json.Unmarshal(contents, &config) - config.Paths.IndexHtmlPath = pathMaker(config.Paths.IndexHtmlPath) - config.Paths.IndexJsPath = pathMaker(config.Paths.IndexJsPath) - config.Paths.IndexCssPath = pathMaker(config.Paths.IndexCssPath) - config.Paths.MessagesHtmlPath = pathMaker(config.Paths.MessagesHtmlPath) - config.Paths.DatabasePath = pathMaker(config.Paths.DatabasePath) - if err != nil { - fmt.Println("Error parsing config file: ", err) - os.Exit(1) - } - return config -} - -func pathMaker(path string) string { - absPath, _ := filepath.Abs(path) - absPath = filepath.Clean(absPath) - fmt.Println(absPath) - return absPath +func main() { + srv.NewServer(config).Run() } diff --git a/content/root.html b/public/index.html similarity index 84% rename from content/root.html rename to public/index.html index 7847664..3d5f672 100644 --- a/content/root.html +++ b/public/index.html @@ -29,12 +29,26 @@ +
+
+ + + +
+
@@ -58,7 +72,6 @@ -
@@ -67,22 +80,7 @@
-
-
- - - -
-
- diff --git a/content/root.js b/public/index.js similarity index 84% rename from content/root.js rename to public/index.js index 5b1512e..3f9fdaf 100644 --- a/content/root.js +++ b/public/index.js @@ -27,17 +27,42 @@ function toggleTheme() { localStorage.setItem("theme", newTheme); } -async function editMessage(messageId, content) { - const newContent = prompt("Edit message:", content); +function replaceDiv(oldDiv, newDiv) { + oldDiv.parentNode.replaceChild(newDiv, oldDiv); +} - if (newContent === null) { +function cancelEditMessage(messageId) { + const editMessageDiv = document.getElementById( + ".edit-message@" + messageId, + ); + replaceDiv(editMessageDiv, editMessageOriginalDiv); + editMessageOriginalDiv = null; + editing = false; + editMessageNumber = null; +} + +async function submitEditMessage(messageId) { + const editMessageDiv = document.getElementById( + ".edit-message@" + messageId, + ); + + const content = + editMessageOriginalDiv.querySelector(".content").textContent; + + const newContent = + editMessageDiv.querySelector(".edit-message-textarea").value; + + if (content === null) { + cancelEditMessage(messageId); return; } - if (newContent === "") { + if (content === "") { + cancelEditMessage(messageId); deleteMessage(messageId); return; } if (newContent === content) { + cancelEditMessage(messageId); return; } @@ -54,6 +79,9 @@ async function editMessage(messageId, content) { }); if (response.ok) { + editing = false; + editMessageNumber = null; + editMessageOriginalDiv = null; updateMessagesInPlace(); } else { console.error("Failed to edit message"); @@ -63,6 +91,61 @@ async function editMessage(messageId, content) { } } +editMessageOriginalDiv = null; +editMessageNumber = null; +editing = false; + +async function editMessage(messageId) { + if (editing) { + cancelEditMessage(editMessageNumber); + } + editing = true; + editMessageNumber = messageId; + const originalMessageDiv = document.getElementById( + ".message@" + messageId, + ); + + const height = originalMessageDiv.scrollHeight; + + const queryContent = originalMessageDiv.querySelector(".content"); + + // we need to replace all the html elements with their text content, this includes links like img, a, iframe + queryContent.querySelectorAll("a").forEach((link) => { + link.textContent = link.href; + }); + + queryContent.querySelectorAll("img").forEach((img) => { + img.textContent = img.src; + }); + + queryContent.querySelectorAll("iframe").forEach((iframe) => { + iframe.textContent = iframe.src; + }); + + const content = + originalMessageDiv.querySelector(".content").textContent; + + editMessageOriginalDiv = originalMessageDiv.cloneNode(true); + + const editMessageDiv = document.createElement("div"); + const editTextArea = document.createElement("textarea"); + editTextArea.className = "edit-message-textarea"; + editTextArea.id = "edit-message-textarea"; + editTextArea.value = content; + editTextArea.style.height = height + "px"; + editMessageDiv.className = "edit-message"; + editMessageDiv.id = ".edit-message@" + messageId; + + editMessageDiv.innerHTML += `
` + + `` + + `` + + `
`; + + editMessageDiv.appendChild(editTextArea); + + replaceDiv(originalMessageDiv, editMessageDiv); +} + async function deleteMessage(messageId) { if (confirm("Delete this message?")) { try { @@ -101,7 +184,7 @@ function setScrollLocation(height) { messagesDiv.scrollTop = height; } -document.addEventListener("click", function (event) { +document.addEventListener("click", function(event) { const settingsPanel = document.getElementById("settings-panel"); const settingsButton = document.querySelector(".settings-button"); const usersPanel = document.getElementById("users-panel"); @@ -122,7 +205,7 @@ document.addEventListener("click", function (event) { } }); -document.addEventListener("keypress", function (event) { +document.addEventListener("keypress", function(event) { if (event.key === "Enter" && !event.shiftKey) { const settingsPanel = document.getElementById("settings-panel"); const inputPanel = document.getElementById("message"); @@ -136,13 +219,13 @@ document.addEventListener("keypress", function (event) { } }); -document.addEventListener("input", function (_) { +document.addEventListener("input", function(_) { const msg = document.getElementById("message"); msg.style.height = "auto"; msg.style.height = (msg.scrollHeight) + "px"; }); -document.addEventListener("blur", function (_) { +document.addEventListener("blur", function(_) { const msg = document.getElementById("message"); msg.style.height = "auto"; }, true); @@ -178,6 +261,9 @@ async function getMessageCount() { let lastMessageCount = 0; async function loadMessages(forceUpdate = false, scrollLocation) { + if (editing) { + return; + } try { const newMessageCount = await getMessageCount(); @@ -232,6 +318,7 @@ async function loadMessages(forceUpdate = false, scrollLocation) { "
", ); + messageDiv.id = ".message@" + messageId; const usernameDiv = document.createElement( "div", ); @@ -255,23 +342,19 @@ async function loadMessages(forceUpdate = false, scrollLocation) { doc.querySelector("span").textContent; const isMultiline = contentString.match( - "\\n", - ) && true || false; + "\\n", + ) && true || false; if ( compareUsername === - document.getElementById( - "current-user", - ).textContent + document.getElementById( + "current-user", + ).textContent ) { deleteHtml = ``; editHtml = - ``; - } - // TODO: Add edit for multiline messages - if (isMultiline) { - editHtml = ""; + ``; } messageDiv.innerHTML = `
@@ -296,7 +379,7 @@ async function loadMessages(forceUpdate = false, scrollLocation) { function contentEmbedding(content) { return content.replace( /(?![^<]*>)(https?:\/\/[^\s<]+)/g, - function (url) { + function(url) { const videoId = getYouTubeID( url, ); @@ -306,15 +389,15 @@ function contentEmbedding(content) { height="315" src="https://www.youtube.com/embed/${videoId}" frameborder="0" - onerror="console.log('Video failed to load:', this.src); this.style.display='none'" + onerror="console.log('Video failed to load:', this.src); this.value=this.src" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen>
`; } else if (isImageUrl(url)) { return `
Embedded image
`; + onerror="console.log('Image failed to load:', this.src); this.value=this.src">`; } return `${url}`; }, @@ -356,7 +439,7 @@ async function checkUsername() { username.focus(); username.selectionStart = username.selectionEnd = - username.value.length; + username.value.length; } } catch (error) { console.error("Error checking username status:", error); @@ -523,7 +606,7 @@ function initializeTheme() { } } -document.addEventListener("keyup", function (event) { +document.addEventListener("keyup", function(event) { const inputPanel = document.getElementById("message"); if (inputPanel.contains(event.target) && event.key === "ArrowUp") { if (inputPanel.value === "") { @@ -681,9 +764,8 @@ function initializeSearchBox() { behavior: "smooth", block: "center", }); - searchCount.textContent = `${ - currentMatchIndex + 1 - }/${matches.length}`; + searchCount.textContent = `${currentMatchIndex + 1 + }/${matches.length}`; } searchButton.addEventListener("click", () => { @@ -710,7 +792,7 @@ function initializeSearchBox() { }); // Debug function to check search coverage (call from console: checkSearchCoverage()) - window.checkSearchCoverage = function () { + window.checkSearchCoverage = function() { const textNodes = findTextNodes(document.body); console.log( "Total searchable text nodes found:", diff --git a/content/root.css b/public/style.css similarity index 90% rename from content/root.css rename to public/style.css index 0a1285c..0ab27e9 100644 --- a/content/root.css +++ b/public/style.css @@ -42,47 +42,60 @@ --input-button-active-fg: #dce0e8; } +.edit-message { + display: flex; + flex-direction: column; + margin-top: 10px; +} + +.edit-message textarea { + order: 1; /* Make textarea come first */ + padding: 10px; + border: none; + border-radius: 4px; + background-color: var(--input-bg-color); + color: var(--input-text-color); + resize: none; + line-height: 1.4; + margin-bottom: 10px; +} + +.edit-message .button-container { + order: 2; /* Make buttons container come second */ + display: flex; + gap: 10px; +} + +.edit-message button { + padding: 10px 20px; + border: none; + border-radius: 4px; + background-color: var(--input-button-inactive-bg); + color: var(--input-button-inactive-fg); + cursor: pointer; +} + .search-trigger { position: fixed; bottom: 20px; right: 20px; z-index: 1000; } - .search-container { - position: flex; - display: flex; + position: absolute; + display: inline; align-items: center; - background: var(--pum-button-active-bg); + background: var(--pum-button-inactive-bg); border-radius: 24px; transition: width 0.2s ease; - overflow: hidden; - width: 48px; - height: 48px; + width: 24px; + height: 24px; } .search-container.expanded { width: 300px; } -.search-button { - background: none; - border: none; - padding: 12px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - background: var(--pum-button-active-bg); - color: var(--pum-button-inactive-fg); -} - -.search-button:hover { - background: var(--pum-button-active-bg); - color: var(--pum-button-active-fg); -} - .search-input { display: none; flex: 2; @@ -107,25 +120,6 @@ color: black; } -/* Search icon using CSS */ -.search-icon { - width: 15px; - height: 15px; - border: 2px solid var(--pum-button-inactive-fg); - border-radius: 50%; - position: relative; -} - -.search-icon::after { - content: ''; - position: absolute; - width: 2px; - height: 10px; - background: var(--pum-button-inactive-fg); - bottom: -8px; - right: -3px; - transform: rotate(-45deg); -} .search-count { display: none; @@ -166,20 +160,25 @@ body { position: fixed; top: 20px; left: 50%; - transform: translateX(-50%); + transform: translateX(-65%); display: flex; gap: 20px; /* Space between buttons */ align-items: center; justify-content: center; } +.header-controls .search-trigger { + position: relative; + right: 0%; +} + /* Show/hide appropriate icon based on theme */ [data-theme="light"] .theme-icon .sun { display: none; } [data-theme="dark"] .theme-icon .moon { display: none; } -.settings-button, .users-button, .theme-button { +.settings-button, .users-button, .theme-button, .search-button { top: 20px; background: none; border: none; @@ -194,18 +193,6 @@ body { background-color: var(--pum-button-inactive-bg); } -.settings-button { - right: calc(50%); -} - -.users-button { - right: calc(60%); -} - -.theme-button { - right: calc(30%); -} - .message-header { display: flex; justify-content: space-between; @@ -251,19 +238,20 @@ body { background: var(--timestamp-color); } -.settings-icon, .users-icon, .theme-icon{ +.settings-icon, .users-icon, .theme-icon, .search-icon { width: 24px; height: 24px; fill: var(--pum-button-inactive-fg); } -.settings-button:hover, .users-button:hover, .theme-button:hover { +.settings-button:hover, .users-button:hover, .theme-button:hover, .search-button:hover { background-color: var(--pum-button-active-bg); } .settings-button:hover .settings-icon, .users-button:hover .users-icon, -.theme-button:hover .theme-icon { +.theme-button:hover .theme-icon, +.search-button:hover .search-icon { fill: var(--pum-button-active-fg); } @@ -488,7 +476,7 @@ button.scroll:hover { gap: 15px; width: 100%; justify-content: center; - } + } .settings-button, .users-button, .theme-button { width: 36px; diff --git a/readme.md b/readme.md index e0945f7..3c0eae9 100644 --- a/readme.md +++ b/readme.md @@ -2,17 +2,15 @@ ## Frontend ### High Priority - Lazy load with pagination (frontend and backend) -- Edit support for multiline message +- Clicking edit message should reposition you at the message with it centered or where the textarea edit box is at the top of the screen at least ### Mid Priority - Other embeds (Twitter posts, spotify tracks, soundcloud, github repos, instagram posts, other video platforms) ### Low Priority -- Reposition the search button -- Fix mobile views instead of hiding elements that you don't want to position properly +- Fix mobile views instead of hiding elements that you don't want to position properly (search, radchat title, username) ## Backend ### High Priority - Lazy load with pagination (frontend and backend) ### Mid Priority - Nothing yet ### Low Priority -- Tidy up -- Perhaps a boolean value in the database to be able to display whether or not a message has been edited or not +- Nothing yet diff --git a/srv/handle.go b/srv/handle.go new file mode 100644 index 0000000..94daa9e --- /dev/null +++ b/srv/handle.go @@ -0,0 +1,285 @@ +package srv + +import ( + tu "chat/tu" + "encoding/json" + "fmt" + "net/http" +) + +func (s *Server) handlePing(w http.ResponseWriter, r *http.Request) { + clientIP := getClientIP(r) + s.updateActivity(clientIP) + s.cleanupActivity() + w.WriteHeader(http.StatusOK) +} + +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) > s.Config.Options.NameMaxLength { + http.Error(w, fmt.Sprintf(`{"error": "Username too long (%v out of %v characters maximum)"}`, len(req.Username), s.Config.Options.NameMaxLength), 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) { + http.Error(w, fmt.Sprintf(`{"error": "Username already exists"}`), http.StatusConflict) + s.mu.Unlock() + 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 (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPatch: + w.Header().Set("Content-Type", "application/json") + var req struct { + MessageId string `json:"messageId"` + MessageContent string `json:"messageContent"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error": "Invalid JSON"}`, http.StatusBadRequest) + return + } + + clientIP := getClientIP(r) + if affected, err := s.Database.MessageEditIfOwner(req.MessageId, req.MessageContent, clientIP); err != nil { + http.Error(w, `{"error": "Unauthorized"}`, http.StatusNotFound) + return + } else if affected == 0 { + http.Error(w, `{"error": "Message not found"}`, http.StatusNotFound) + return + } + + json.NewEncoder(w).Encode(map[string]string{ + "status": "Message edited successfully", + }) + + 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 := tu.TimeStringToTimeInLocation(msg.Timestamp, timeZone) + edited := "" + if msg.Edited { + edited = "(edited)" + } + body += fmt.Sprintf(`

%s
%s
%s %s
%s

`, + msg.Id, msg.SenderUsername, timeLocal, edited, msg.Content) + } + + w.Write([]byte(getMessageTemplate(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, + }) + case http.MethodDelete: + w.Header().Set("Content-Type", "application/json") + + var req struct { + MessageId string `json:"messageId"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error": "Invalid JSON"}`, http.StatusBadRequest) + return + } + + clientIP := getClientIP(r) + if affected, err := s.Database.MessageDeleteIfOwner(req.MessageId, clientIP); err != nil { + http.Error(w, `{"error": "Unauthorized"}`, http.StatusNotFound) + return + } else if affected == 0 { + http.Error(w, `{"error": "Message not found"}`, http.StatusNotFound) + return + } + + json.NewEncoder(w).Encode(map[string]string{ + "status": "Message deleted", + }) + 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") + file := readFile(s.Config.Paths.IndexHtmlPath) + w.Write(file) +} + +func (s *Server) handleJs(w http.ResponseWriter, r *http.Request) { + _ = r + w.Header().Set("Content-Type", "application/javascript") + file := readFile(s.Config.Paths.IndexJsPath) + w.Write(file) +} + +func (s *Server) handleCss(w http.ResponseWriter, r *http.Request) { + _ = r + w.Header().Set("Content-Type", "text/css") + file := readFile(s.Config.Paths.IndexCssPath) + w.Write(file) +} + +func (s *Server) handleMessagesLength(w http.ResponseWriter, r *http.Request) { + // should return the number of messages in the database + if r.Method != http.MethodGet { + http.Error(w, `{"error": "Method not allowed"}`, http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + messages := s.Database.MessagesGet() + json.NewEncoder(w).Encode(map[string]int{ + "length": len(messages), + }) +} diff --git a/srv/srv.go b/srv/srv.go new file mode 100644 index 0000000..6226f98 --- /dev/null +++ b/srv/srv.go @@ -0,0 +1,181 @@ +package srv + +import ( + db "chat/db" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +type Config struct { + Server struct { + IpAddress string `json:"ipAddress"` + Port int `json:"port"` + } `json:"server"` + Paths struct { + DatabasePath string `json:"databasePath"` + IndexJsPath string `json:"indexJsPath"` + IndexCssPath string `json:"indexCssPath"` + IndexHtmlPath string `json:"indexHtmlPath"` + MessagesHtmlPath string `json:"messagesHtmlPath"` + } `json:"paths"` + Options struct { + MessageMaxAge int `json:"messageMaxAge"` + NameMaxLength int `json:"nameMaxLength"` + } `json:"options"` +} + +func LoadConfig(filepath string) Config { + contents, _ := os.ReadFile(filepath) + var config Config + err := json.Unmarshal(contents, &config) + config.Paths.IndexHtmlPath = pathMaker(config.Paths.IndexHtmlPath) + config.Paths.IndexJsPath = pathMaker(config.Paths.IndexJsPath) + config.Paths.IndexCssPath = pathMaker(config.Paths.IndexCssPath) + config.Paths.MessagesHtmlPath = pathMaker(config.Paths.MessagesHtmlPath) + config.Paths.DatabasePath = pathMaker(config.Paths.DatabasePath) + if err != nil { + fmt.Println("Error parsing config file: ", err) + os.Exit(1) + } + return config +} + +func pathMaker(path string) string { + absPath, _ := filepath.Abs(path) + absPath = filepath.Clean(absPath) + fmt.Println(absPath) + return absPath +} + +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 getMessageTemplate(body string) string { + template := ` + +
+ {{body}} +
+ +` + return strings.Replace(template, "{{body}}", body, 1) +} + +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 +} + +func readFile(filepath string) []byte { + contents, _ := os.ReadFile(filepath) + return contents +} + +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 +} + +type Server struct { + Connected map[string]time.Time // Map IP -> Last activity time + Database *db.Database + Config Config + mu sync.Mutex // For thread safety +} + +func NewServer(config Config) *Server { + + return &Server{ + Connected: make(map[string]time.Time), + Database: db.OpenDatabase(config.Paths.DatabasePath), + Config: config, + 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) Run() { + s.Database.DbCreateTableMessages() + s.Database.DbCreateTableUsers() + s.Database.DeleteOldMessages(s.Config.Options.MessageMaxAge) + handler := http.NewServeMux() + handler.HandleFunc("/", s.handleRoot) + handler.HandleFunc("/root.js", s.handleJs) + handler.HandleFunc("/root.css", s.handleCss) + handler.HandleFunc("/ping", s.handlePing) + handler.HandleFunc("/timezone", s.handleTimezone) + handler.HandleFunc("/username/status", s.handleUsernameStatus) + handler.HandleFunc("/messages/length", s.handleMessagesLength) + handler.HandleFunc("/users", s.handleUsers) + handler.HandleFunc("/username", s.handleUsername) + handler.HandleFunc("/messages", s.handleMessages) + fmt.Printf("Server starting on %s:%d\n", s.Config.Server.IpAddress, s.Config.Server.Port) + defer s.Stop() + if err := http.ListenAndServe(fmt.Sprintf("%s:%d", s.Config.Server.IpAddress, s.Config.Server.Port), GzipMiddleware(handler)); err != nil { + fmt.Printf("Server error: %v\n", err) + } +} + +func (s *Server) Stop() { + s.Database.Close() +} diff --git a/tu/tu.go b/tu/tu.go new file mode 100644 index 0000000..b663167 --- /dev/null +++ b/tu/tu.go @@ -0,0 +1,26 @@ +package tu + +import ( + "time" +) + +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") +}