diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 3187230..0000000 --- a/TODO.md +++ /dev/null @@ -1,33 +0,0 @@ -# TODO — Code Logic Errors - -## Critical - -- [ ] **Login: nil pointer dereference** (`http.go:111`) - `CacheGetClientByName` returns `nil` on miss, then `DbSetClientByName` is called with that nil `client` → panic. Should query DB by username directly. - -- [ ] **Login: password never verified** (`http.go:87–131`) - No call to `PasswordVerify`/`bcrypt.CompareHashAndPassword`. Anyone with a valid username can log in. - -## High - -- [ ] **Login: validates `username` length instead of `password`** (`http.go:98`) - `if len(username) < 8` should be `if len(password) < 8`. Password is never length-checked. - -- [ ] **DB: missing `&` in `Scan` for `pronouns`** (`database.go:87`) - `client.Pronouns` should be `&client.Pronouns`. Compare with `DbSetClientById` which does it correctly. - -- [ ] **WS: 30s context kills entire connection** (`wsServer.go:23`) - A single 30s timeout context is shared across all reads in the loop. Should use per-read deadlines or `context.Background()` for the loop. - -## Medium - -- [ ] **NewUser: missing `return` after bad color error** (`http.go:54–56`) - On `parseRgb` error, `http.Error` is called but execution continues with `color = [0,0,0]`. - -- [ ] **WS: unauth disconnect deletes ID=0 from cache** (`wsServer.go:115`) - `closeConnection` calls `CacheDeleteClient(client.Id)` but unauthenticated clients have `Id=0`, wiping whatever sits at key 0. - -## Low - -- [ ] **`CacheSetGroup` is a no-op** (`cache.go:59`) - Function body is empty. The `Groups` cache is never populated, so every `CacheGetGroup` call misses and falls back to DB. diff --git a/cache.go b/cache.go index cd1bbf9..9b4bbd3 100644 --- a/cache.go +++ b/cache.go @@ -42,7 +42,7 @@ func CacheGetClientByName(name string) (*Client, error) { return nil, fmt.Errorf("client %s not found", name) } -func CacheSetClient(client *Client) { +func CacheSaveClient(client *Client) { mu.Lock() defer mu.Unlock() @@ -56,7 +56,12 @@ func CacheDeleteClient(id uint32) { delete(CacheClients, id) } -func CacheSetGroup() {} +func CacheSaveGroup(group *Group) { + mu.Lock() + defer mu.Unlock() + + Groups[group.Id] = group +} func CacheGetGroup(id uint32) (*Group, error) { mu.RLock() diff --git a/database.go b/database.go index f798dbe..6e0cab9 100644 --- a/database.go +++ b/database.go @@ -18,7 +18,7 @@ func DbInit(ctx context.Context) { } _, err = dbConn.Exec(ctx, ` - CREATE TABLE IF NOT EXISTS client ( + CREATE TABLE IF NOT EXISTS clients ( id SERIAL PRIMARY KEY, name VARCHAR(20) UNIQUE NOT NULL, pass_hash VARCHAR(60) NOT NULL, @@ -37,8 +37,8 @@ func DbInit(ctx context.Context) { CREATE TABLE IF NOT EXISTS chat_groups ( id SERIAL PRIMARY KEY, name VARCHAR(48) NOT NULL, - creator_id INTEGER NOT NULL REFERENCES client(id) ON DELETE CASCADE, - owner_id INTEGER NOT NULL REFERENCES client(id) ON DELETE CASCADE, + creator_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + owner_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, enable_client_colors BOOLEAN NOT NULL DEFAULT true, color_red SMALLINT DEFAULT NULL, color_green SMALLINT DEFAULT NULL, @@ -53,7 +53,7 @@ func DbInit(ctx context.Context) { _, err = dbConn.Exec(ctx, ` CREATE TABLE IF NOT EXISTS chat_group_members ( group_id INTEGER NOT NULL REFERENCES chat_groups(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES client(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, joined_at TIMESTAMP NOT NULL DEFAULT NOW(), PRIMARY KEY (group_id, user_id) ) @@ -73,22 +73,17 @@ func DbSaveClientWithoutGroups(ctx context.Context, client *Client) error { return err } -func DbGetIdByClientName(ctx context.Context, name string) (uint32, error) { - var id uint32 - err := dbConn.QueryRow(ctx, ` - SELECT id FROM clients WHERE name = $1 - `, name).Scan(&id) - return id, err -} - func DbSetClientByName(ctx context.Context, client *Client) error { err := dbConn.QueryRow(ctx, ` - SELECT name, pass_hash, color_red, color_green, color_blue, created_at FROM clients WHERE name = $1 - `, client.Name).Scan(&client.Name, &client.PasswordHash, &client.Pronouns, &client.Color[0], &client.Color[1], &client.Color[2], &client.CreatedAt) - return err + SELECT id, name, pass_hash, pronouns, color_red, color_green, color_blue, created_at FROM clients WHERE name = $1 + `, client.Name).Scan(&client.Id, &client.Name, &client.PasswordHash, &client.Pronouns, &client.Color[0], &client.Color[1], &client.Color[2], &client.CreatedAt) + if err != nil { + return err + } + return DbSetClientGroups(ctx, client) } -func DbSetClientById(ctx context.Context, client *Client) error { +func DbSetClientByIdWithoutGroups(ctx context.Context, client *Client) error { err := dbConn.QueryRow(ctx, ` SELECT name, pass_hash, pronouns, color_red, color_green, color_blue, created_at FROM clients WHERE id = $1 `, client.Id).Scan(&client.Name, &client.PasswordHash, &client.Pronouns, &client.Color[0], &client.Color[1], &client.Color[2], &client.CreatedAt) @@ -102,10 +97,17 @@ func DbSaveGroupWithoutClients(ctx context.Context, group *Group) error { RETURNING id `, group.Name, group.CreatorId, group.OwnerId, group.EnableClientColors, group.Color[0], group.Color[1], group.Color[2], group.CreatedAt). Scan(&group.Id) + if err != nil { + return err + } + _, err = dbConn.Exec(ctx, ` + INSERT INTO chat_group_members (group_id, user_id, joined_at) + VALUES ($1, $2, $3) + `, group.Id, group.OwnerId, group.CreatedAt) return err } -func DbSetGroupById(ctx context.Context, group *Group) error { +func DbSetGroupByIdWithoutClients(ctx context.Context, group *Group) error { err := dbConn.QueryRow(ctx, ` SELECT name, creator_id, owner_id, enable_client_colors, color_red, color_green, color_blue, created_at FROM chat_groups WHERE id = $1 `, group.Id).Scan(&group.Name, &group.CreatorId, &group.OwnerId, &group.EnableClientColors, &group.Color[0], &group.Color[1], &group.Color[2], &group.CreatedAt) @@ -132,6 +134,34 @@ func DbSetGroupById(ctx context.Context, group *Group) error { return rows.Err() } +func DbSetGroupById(ctx context.Context, group *Group) error { + err := DbSetGroupByIdWithoutClients(ctx, group) + if err != nil { + return err + } + return DbSetGroupMemberClients(ctx, group) +} + +func DbSetGroupMemberClients(ctx context.Context, group *Group) error { + rows, err := dbConn.Query(ctx, ` + SELECT user_id FROM chat_group_members WHERE group_id = $1 + `, group.Id) + if err != nil { + return err + } + defer rows.Close() + + group.Clients = make(map[uint32]struct{}) + for rows.Next() { + var userId uint32 + if err := rows.Scan(&userId); err != nil { + return err + } + group.Clients[userId] = struct{}{} + } + return rows.Err() +} + func DbAddClientsToGroup(ctx context.Context, groupId uint32, clientIds []uint32) error { batch := &pgx.Batch{} now := time.Now() @@ -151,3 +181,23 @@ func DbAddClientsToGroup(ctx context.Context, groupId uint32, clientIds []uint32 } return nil } + +func DbSetClientGroups(ctx context.Context, client *Client) error { + rows, err := dbConn.Query(ctx, ` + SELECT group_id FROM chat_group_members WHERE user_id = $1 + `, client.Id) + if err != nil { + return err + } + defer rows.Close() + + client.Groups = make(map[uint32]struct{}) + for rows.Next() { + var groupId uint32 + if err := rows.Scan(&groupId); err != nil { + return err + } + client.Groups[groupId] = struct{}{} + } + return rows.Err() +} diff --git a/go-socket b/go-socket index 8d1d71e..80bc2b8 100755 Binary files a/go-socket and b/go-socket differ diff --git a/http.go b/http.go index 7f48e63..67ab799 100644 --- a/http.go +++ b/http.go @@ -117,15 +117,15 @@ func HttpHandleLogin(response http.ResponseWriter, request *http.Request) { err := DbSetClientByName(ctx, client) if err != nil { - http.Error(response, "bad login", http.StatusUnauthorized) + http.Error(response, "bad login1", http.StatusUnauthorized) return } - CacheSetClient(client) + CacheSaveClient(client) } err = bcrypt.CompareHashAndPassword([]byte(client.PasswordHash), []byte(password)) if err != nil { - http.Error(response, "bad login", http.StatusUnauthorized) + http.Error(response, "bad login2", http.StatusUnauthorized) return } @@ -178,7 +178,7 @@ func HttpHandleGroupCreate(response http.ResponseWriter, request *http.Request) if err == nil { client = *cacheClient } else { - err = DbSetClientById(ctx, &client) + err = DbSetClientByIdWithoutGroups(ctx, &client) if err != nil { http.Error(response, "internal server error", http.StatusInternalServerError) return @@ -191,6 +191,7 @@ func HttpHandleGroupCreate(response http.ResponseWriter, request *http.Request) OwnerId: clientId, CreatorId: clientId, Color: color, + Clients: map[uint32]struct{}{clientId: {}}, } enableClientColors := request.FormValue("enableClientColors") @@ -200,14 +201,14 @@ func HttpHandleGroupCreate(response http.ResponseWriter, request *http.Request) err = DbSaveGroupWithoutClients(ctx, &group) if err != nil { - http.Error(response, "internal server error", http.StatusInternalServerError) + http.Error(response, err.Error(), http.StatusInternalServerError) return } groupIdBytes := make([]byte, 4) binary.BigEndian.PutUint32(groupIdBytes, group.Id) response.WriteHeader(http.StatusCreated) - response.Write([]byte(groupIdBytes)) + response.Write(groupIdBytes) } func HttpHandleGroupAddClient(response http.ResponseWriter, request *http.Request) { @@ -236,7 +237,7 @@ func HttpHandleGroupAddClient(response http.ResponseWriter, request *http.Reques if err == nil { group = *groupPtr } else { - err = DbSetGroupById(ctx, &group) + err = DbSetGroupByIdWithoutClients(ctx, &group) if err != nil { http.Error(response, "no such group", http.StatusUnauthorized) return diff --git a/machine-client/index.html b/machine-client/index.html index ea69602..e265772 100644 --- a/machine-client/index.html +++ b/machine-client/index.html @@ -72,13 +72,14 @@ +
- +
@@ -86,7 +87,7 @@ - + @@ -100,6 +101,18 @@ +
+ + + + + + + + + +
+
Not connected
@@ -141,7 +154,7 @@ const body = new URLSearchParams({ username, password, color }); try { - const res = await fetch('http://localhost:8080/newuser', { method: 'POST', body }); + const res = await fetch('http://localhost:8080/new/client', { method: 'POST', body }); const text = await res.text(); if (res.ok) { setFormStatus('Registered! Now login.', 'ok'); @@ -160,11 +173,12 @@ const body = new URLSearchParams({ username, password }); try { - const res = await fetch('http://localhost:8080/login', { method: 'POST', body }); + const res = await fetch('http://localhost:8080/new/token', { method: 'POST', body }); const text = await res.text(); if (res.ok) { document.getElementById('username-input').value = username; document.getElementById('token-input').value = text; + document.getElementById('group-token').value = text; setFormStatus('Logged in! Token copied to Connect tab.', 'ok'); switchTab('connect'); } else { @@ -175,6 +189,32 @@ } } + async function createGroup() { + const token = document.getElementById('group-token').value.trim(); + if (!token) { setFormStatus('Paste a token first', 'err'); return; } + + const body = new URLSearchParams({ token }); + const name = document.getElementById('group-name').value.trim(); + if (name) body.set('name', name); + const color = document.getElementById('group-color').value.trim(); + if (color) body.set('color', color); + const enableClientColors = document.getElementById('group-client-colors').value.trim(); + if (enableClientColors) body.set('enableClientColors', enableClientColors); + + try { + const res = await fetch('http://localhost:8080/new/group', { method: 'POST', body }); + if (res.ok) { + const buf = await res.arrayBuffer(); + const id = new DataView(buf).getUint32(0, false); + setFormStatus('Group created! ID: ' + id, 'ok'); + } else { + setFormStatus('Error: ' + await res.text(), 'err'); + } + } catch (e) { + setFormStatus('Request failed: ' + e.message, 'err'); + } + } + function setStatus(text, cls) { const el = document.getElementById('status'); el.textContent = text; diff --git a/main.go b/main.go index aefa470..e9c1b29 100644 --- a/main.go +++ b/main.go @@ -6,14 +6,21 @@ import ( "net/http" ) +func withCORS(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + h(w, r) + } +} + func main() { ctx := context.Background() DbInit(ctx) - http.HandleFunc("/newuser", HttpHandleNewUser) - http.HandleFunc("/login", HttpHandleLogin) - http.HandleFunc("/group/create", HttpHandleGroupCreate) - http.HandleFunc("/group/addclient", HttpHandleGroupAddClient) + http.HandleFunc("/new/client", withCORS(HttpHandleNewUser)) + http.HandleFunc("/new/token", withCORS(HttpHandleLogin)) + http.HandleFunc("/new/group", withCORS(HttpHandleGroupCreate)) + http.HandleFunc("/mod/group/addclients", withCORS(HttpHandleGroupAddClient)) http.HandleFunc("/ws", ServeWsConnection) log.Println("listening on :8080") diff --git a/todo.txt b/todo.txt new file mode 100644 index 0000000..055e50f --- /dev/null +++ b/todo.txt @@ -0,0 +1,53 @@ +crash on adding to group + +2026/03/27 18:44:04 listening on :8080 +2026/03/27 18:45:17 read error: failed to read JSON message: failed to get reader: received close frame: status = StatusNoStatusRcvd and reason = "" +2026/03/27 18:45:17 http: panic serving 127.0.0.1:54644: runtime error: invalid memory address or nil pointer dereference +goroutine 25 [running]: +net/http.(*conn).serve.func1() + /usr/lib/go/src/net/http/server.go:1907 +0xbd +panic({0x8e0f20?, 0xe2b190?}) + /usr/lib/go/src/runtime/panic.go:860 +0x13a +github.com/coder/websocket.(*Conn).casClosing(...) + /home/ffus/go/pkg/mod/github.com/coder/websocket@v1.8.14/close.go:325 +github.com/coder/websocket.(*Conn).CloseNow(0x2d8e00000002?) + /home/ffus/go/pkg/mod/github.com/coder/websocket@v1.8.14/close.go:135 +0x48 +main.closeConnection(0x2d8ef7ddd990?, 0xb8?) + /home/ffus/Projects/go-socket/wsServer.go:183 +0x2d +main.ServeWsConnection({0x9c4a68?, 0x2d8ef7ee41e0?}, 0x2d8ef7d5db30?) + /home/ffus/Projects/go-socket/wsServer.go:36 +0x350 +net/http.HandlerFunc.ServeHTTP(0xe43400?, {0x9c4a68?, 0x2d8ef7ee41e0?}, 0x7e1176?) + /usr/lib/go/src/net/http/server.go:2286 +0x29 +net/http.(*ServeMux).ServeHTTP(0x482d39?, {0x9c4a68, 0x2d8ef7ee41e0}, 0x2d8ef7de4a00) + /usr/lib/go/src/net/http/server.go:2828 +0x1c7 +net/http.serverHandler.ServeHTTP({0x2d8ef7d748c0?}, {0x9c4a68?, 0x2d8ef7ee41e0?}, 0x6?) + /usr/lib/go/src/net/http/server.go:3311 +0x8e +net/http.(*conn).serve(0x2d8ef7df8360, {0x9c5e48, 0x2d8ef7e1c570}) + /usr/lib/go/src/net/http/server.go:2073 +0x650 +created by net/http.(*Server).Serve in goroutine 1 + /usr/lib/go/src/net/http/server.go:3464 +0x485 +2026/03/27 18:47:47 read error: failed to read JSON message: failed to get reader: received close frame: status = StatusNoStatusRcvd and reason = "" +2026/03/27 18:47:47 http: panic serving 127.0.0.1:54648: runtime error: invalid memory address or nil pointer dereference +goroutine 53 [running]: +net/http.(*conn).serve.func1() + /usr/lib/go/src/net/http/server.go:1907 +0xbd +panic({0x8e0f20?, 0xe2b190?}) + /usr/lib/go/src/runtime/panic.go:860 +0x13a +github.com/coder/websocket.(*Conn).casClosing(...) + /home/ffus/go/pkg/mod/github.com/coder/websocket@v1.8.14/close.go:325 +github.com/coder/websocket.(*Conn).CloseNow(0x2d8e00000001?) + /home/ffus/go/pkg/mod/github.com/coder/websocket@v1.8.14/close.go:135 +0x48 +main.closeConnection(0x2d8ef7f61990?, 0xb8?) + /home/ffus/Projects/go-socket/wsServer.go:183 +0x2d +main.ServeWsConnection({0x9c4a68?, 0x2d8ef7f2c1e0?}, 0x2d8ef7f56b30?) + /home/ffus/Projects/go-socket/wsServer.go:36 +0x350 +net/http.HandlerFunc.ServeHTTP(0xe43400?, {0x9c4a68?, 0x2d8ef7f2c1e0?}, 0x7e1176?) + /usr/lib/go/src/net/http/server.go:2286 +0x29 +net/http.(*ServeMux).ServeHTTP(0x482d39?, {0x9c4a68, 0x2d8ef7f2c1e0}, 0x2d8ef7f26140) + /usr/lib/go/src/net/http/server.go:2828 +0x1c7 +net/http.serverHandler.ServeHTTP({0x2d8ef7f38080?}, {0x9c4a68?, 0x2d8ef7f2c1e0?}, 0x6?) + /usr/lib/go/src/net/http/server.go:3311 +0x8e +net/http.(*conn).serve(0x2d8ef7f2a2d0, {0x9c5e48, 0x2d8ef7e1c570}) + /usr/lib/go/src/net/http/server.go:2073 +0x650 +created by net/http.(*Server).Serve in goroutine 1 + /usr/lib/go/src/net/http/server.go:3464 +0x485 \ No newline at end of file diff --git a/wsServer.go b/wsServer.go index cfd0914..3e322b7 100644 --- a/wsServer.go +++ b/wsServer.go @@ -42,7 +42,7 @@ func ServeWsConnection(responseWriter http.ResponseWriter, request *http.Request return } } else { - if !handleUnauthenticatedMessage(&client, &clientMessage) { + if !handleUnauthenticatedMessage(ctx, &client, &clientMessage) { ignoreCache = true return } @@ -53,6 +53,10 @@ func ServeWsConnection(responseWriter http.ResponseWriter, request *http.Request } func sendMessageCloseIfTimeout(client *Client, message *map[string]any) { + if client.WsConn == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) defer cancel() @@ -74,11 +78,52 @@ func sendToAllMessageCloseIfTimeout(message *map[string]any) { } func handleAuthenticatedMessage(client *Client, clientMessage *map[string]any) bool { - sendToAllMessageCloseIfTimeout(clientMessage) + subject, ok := (*clientMessage)["subject"].(uint32) + if !ok { + var msg = map[string]any{ + "from": "server", + "error": "subject invalid", + } + sendMessageCloseIfTimeout(client, &msg) + } + + content, ok := (*clientMessage)["content"].(string) + if !ok { + var msg = map[string]any{ + "from": "server", + "error": "content invalid", + } + sendMessageCloseIfTimeout(client, &msg) + } + + group, err := CacheGetGroup(subject) + if err != nil { + var msg = map[string]any{ + "from": "server", + "error": "subject invalid", + } + sendMessageCloseIfTimeout(client, &msg) + } + + for groupClientId, _ := range group.Clients { + var msg = map[string]any{ + "from": "group", + "group": group.Id, + "sender": client.Name, + "content": content, + } + + var groupClient *Client + groupClient, err = CacheGetClientById(groupClientId) + if err != nil { + sendMessageCloseIfTimeout(groupClient, &msg) + } + } + return true } -func handleUnauthenticatedMessage(client *Client, clientMessage *map[string]any) bool { +func handleUnauthenticatedMessage(ctx context.Context, client *Client, clientMessage *map[string]any) bool { token, ok := (*clientMessage)["token"].(string) if !ok { var msg = map[string]any{ @@ -110,6 +155,24 @@ func handleUnauthenticatedMessage(client *Client, clientMessage *map[string]any) } *client = *clientFromCache + + for groupId, _ := range clientFromCache.Groups { + _, err = CacheGetGroup(groupId) + if err != nil { + dbGroup := &Group{Id: groupId} + + err = DbSetGroupById(ctx, dbGroup) + if err != nil { + var msg = map[string]any{ + "from": "server", + "error": "invalid client data", + } + sendMessageCloseIfTimeout(client, &msg) + return false + } + CacheSaveGroup(dbGroup) + } + } return true }