add client and groups member logic

This commit is contained in:
2026-03-27 18:49:45 +01:00
parent a95bc43be6
commit c5fc74d142
9 changed files with 256 additions and 70 deletions
-33
View File
@@ -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:87131`)
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:5456`)
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.
+7 -2
View File
@@ -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()
+66 -16
View File
@@ -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)
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()
}
BIN
View File
Binary file not shown.
+8 -7
View File
@@ -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
+44 -4
View File
@@ -72,13 +72,14 @@
<button class="tab-btn active" onclick="switchTab('login')">Login</button>
<button class="tab-btn" onclick="switchTab('register')">Register</button>
<button class="tab-btn" onclick="switchTab('connect')">Connect</button>
<button class="tab-btn" onclick="switchTab('group')">Group</button>
</div>
<div id="tab-login" class="tab active">
<label>Username</label>
<input id="login-username" type="text" placeholder="alice">
<label style="margin-top:10px;">Password</label>
<input id="login-password" type="password" placeholder="••••••••">
<input id="login-password" type="text" placeholder="password">
<button onclick="login()">Login</button>
</div>
@@ -86,7 +87,7 @@
<label>Username</label>
<input id="reg-username" type="text" placeholder="alice">
<label style="margin-top:10px;">Password</label>
<input id="reg-password" type="password" placeholder="••••••••">
<input id="reg-password" type="text" placeholder="password">
<label style="margin-top:10px;">Color (r,g,b)</label>
<input id="reg-color" type="text" placeholder="255,100,50">
<button onclick="register()">Register</button>
@@ -100,6 +101,18 @@
<button id="connect-btn" onclick="connect()">Connect</button>
</div>
<div id="tab-group" class="tab">
<label>Token</label>
<input id="group-token" type="text" placeholder="Paste token from login">
<label style="margin-top:10px;">Name (optional)</label>
<input id="group-name" type="text" placeholder="Best group ever">
<label style="margin-top:10px;">Color (r,g,b or red/green/blue)</label>
<input id="group-color" type="text" placeholder="255,100,50">
<label style="margin-top:10px;">Enable client colors</label>
<input id="group-client-colors" type="text" placeholder="1 or 0">
<button onclick="createGroup()">Create Group</button>
</div>
<div id="form-status"></div>
<div id="status">Not connected</div>
</div>
@@ -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;
+11 -4
View File
@@ -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")
+53
View File
@@ -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
+66 -3
View File
@@ -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
}