summaryrefslogtreecommitdiff
path: root/api/ws/ws.go
blob: 63706dbbb558299f7f231a8f79e86b1e9135df6e (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
package ws

import (
	"database/sql"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"time"

	"github.com/gorilla/websocket"

	"penguins.lan/api/types"
	"penguins.lan/database"
	"penguins.lan/utils"
)

const (
	writeWait      = 10 * time.Second
	pongWait       = 60 * time.Second
	pingPeriod     = (pongWait * 9) / 10
	maxMessageSize = 4096

	historyLimit = 50
)

var upgrader = websocket.Upgrader{
	ReadBufferSize:  1024,
	WriteBufferSize: 1024,
	// It's a LAN intranet; we don't police origins.
	CheckOrigin: func(r *http.Request) bool { return true },
}

// Message is the wire format shared by client and server. `Type` discriminates
// the payload so we can grow beyond chat without breaking old clients.
type Message struct {
	Type string `json:"type"` // "chat" | "system" | "history"
	Nick string `json:"nick,omitempty"`
	Body string `json:"body,omitempty"`
	At   int64  `json:"at,omitempty"` // unix millis
	// History carries a batch of past messages on join.
	History []Message `json:"history,omitempty"`
}

// Hub fans messages out to every connected client.
type Hub struct {
	db         *sql.DB
	clients    map[*Client]bool
	broadcast  chan []byte
	register   chan *Client
	unregister chan *Client
	online     chan chan map[string]bool
}

func NewHub(db *sql.DB) *Hub {
	return &Hub{
		db:         db,
		clients:    make(map[*Client]bool),
		broadcast:  make(chan []byte),
		register:   make(chan *Client),
		unregister: make(chan *Client),
		online:     make(chan chan map[string]bool),
	}
}

// Run owns all mutations to the client set, so no locks are needed elsewhere.
func (h *Hub) Run() {
	for {
		select {
		case client := <-h.register:
			h.clients[client] = true
			h.systemf("%s waddled in 🐧", h.displayName(client.userID))
		case client := <-h.unregister:
			if _, ok := h.clients[client]; ok {
				delete(h.clients, client)
				close(client.send)
				h.systemf("%s slid away", h.displayName(client.userID))
			}
		case payload := <-h.broadcast:
			h.deliver(payload)
		case reply := <-h.online:
			set := make(map[string]bool, len(h.clients))
			for client := range h.clients {
				set[client.userID] = true
			}
			reply <- set
		}
	}
}

// Online returns the set of userIDs currently connected to the chat.
func (h *Hub) Online() map[string]bool {
	reply := make(chan map[string]bool)
	h.online <- reply
	return <-reply
}

// displayName resolves a userID to its current username. The chat hub never
// caches names, so a rename is reflected everywhere immediately.
func (h *Hub) displayName(userID string) string {
	user, err := database.GetUserByID(h.db, userID)
	if err != nil {
		log.Println("ws: could not resolve user", userID, err)
		return "a mystery penguin"
	}
	return user.Username
}

// deliver fans a payload out to every client, dropping any that can't keep up.
// Only safe to call from the Run goroutine, which owns the clients map.
func (h *Hub) deliver(payload []byte) {
	for client := range h.clients {
		select {
		case client.send <- payload:
		default:
			// slow consumer: drop them rather than block the hub
			delete(h.clients, client)
			close(client.send)
		}
	}
}

// sendJSON queues a message for broadcast from outside the hub goroutine
// (e.g. a chat message arriving on a client's readPump).
func (h *Hub) sendJSON(msg Message) {
	payload, err := json.Marshal(msg)
	if err != nil {
		log.Println("ws: marshal error", err)
		return
	}
	h.broadcast <- payload
}

// systemf emits a system notice. It's only ever called from within Run, so it
// fans out directly — re-entering the broadcast channel here would deadlock the
// hub (the loop can't both send and receive on it at once).
func (h *Hub) systemf(format string, args ...interface{}) {
	payload, err := json.Marshal(Message{
		Type: "system",
		Body: fmt.Sprintf(format, args...),
		At:   time.Now().UnixMilli(),
	})
	if err != nil {
		log.Println("ws: marshal error", err)
		return
	}
	h.deliver(payload)
}

// Client is one websocket connection. Reads happen on readPump, writes on
// writePump, communicating through the buffered `send` channel. Identity is the
// userID alone — the display name is always resolved from it, never cached.
type Client struct {
	hub    *Hub
	conn   *websocket.Conn
	send   chan []byte
	userID string
}

func (c *Client) readPump() {
	defer func() {
		c.hub.unregister <- c
		c.conn.Close()
	}()

	c.conn.SetReadLimit(maxMessageSize)
	c.conn.SetReadDeadline(time.Now().Add(pongWait))
	c.conn.SetPongHandler(func(string) error {
		c.conn.SetReadDeadline(time.Now().Add(pongWait))
		return nil
	})

	for {
		_, raw, err := c.conn.ReadMessage()
		if err != nil {
			if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
				log.Println("ws: read error", err)
			}
			break
		}

		var incoming Message
		if err := json.Unmarshal(raw, &incoming); err != nil {
			continue
		}

		// The server is authoritative about who you are and when it happened.
		body := utils.Sanitize(incoming.Body)
		if incoming.Type != "chat" || body == "" {
			continue
		}

		stored := &database.ChatMessage{
			ID:     utils.RandomId(),
			UserID: c.userID,
			Body:   utils.Truncate(incoming.Body, 500),
		}
		if _, err := database.SaveChatMessage(c.hub.db, stored); err != nil {
			log.Println("ws: failed to persist chat", err)
		}

		c.hub.sendJSON(Message{
			Type: "chat",
			Nick: c.hub.displayName(c.userID),
			Body: stored.Body,
			At:   time.Now().UnixMilli(),
		})
	}
}

func (c *Client) writePump() {
	ticker := time.NewTicker(pingPeriod)
	defer func() {
		ticker.Stop()
		c.conn.Close()
	}()

	for {
		select {
		case payload, ok := <-c.send:
			c.conn.SetWriteDeadline(time.Now().Add(writeWait))
			if !ok {
				c.conn.WriteMessage(websocket.CloseMessage, []byte{})
				return
			}
			if err := c.conn.WriteMessage(websocket.TextMessage, payload); err != nil {
				return
			}
		case <-ticker.C:
			c.conn.SetWriteDeadline(time.Now().Add(writeWait))
			if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
				return
			}
		}
	}
}

// sendHistory replays recent messages to a freshly-connected client only.
func (c *Client) sendHistory() {
	rows, err := database.GetRecentChat(c.hub.db, historyLimit)
	if err != nil {
		log.Println("ws: failed to load history", err)
		return
	}

	history := make([]Message, 0, len(rows))
	for _, row := range rows {
		history = append(history, Message{
			Type: "chat",
			Nick: row.Nick,
			Body: row.Body,
			At:   row.CreatedAt.UnixMilli(),
		})
	}

	payload, err := json.Marshal(Message{Type: "history", History: history})
	if err != nil {
		log.Println("ws: marshal history error", err)
		return
	}
	c.send <- payload
}

// ServeWSContinuation upgrades the request to a websocket and wires the client
// into the hub. It's terminal in a route chain — after upgrade there's no HTTP
// response left to render.
func ServeWSContinuation(hub *Hub) types.Continuation {
	return func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
		return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
			conn, err := upgrader.Upgrade(resp, req, nil)
			if err != nil {
				log.Println("ws: upgrade failed", err)
				return failure(context, req, resp)
			}

			client := &Client{
				hub:    hub,
				conn:   conn,
				send:   make(chan []byte, 256),
				userID: context.User.ID,
			}

			// Queue history first (send is buffered), then announce the join.
			client.sendHistory()
			client.hub.register <- client

			go client.writePump()
			go client.readPump()

			return success(context, req, resp)
		}
	}
}