eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "context"
5 "encoding/json"
6 "log"
7 "net/http"
8 "time"
9
10 "github.com/coder/websocket"
11)
12
13// maxBlobBytes caps a single ciphertext payload. Plenty for a chat line plus
14// AES-GCM overhead; keeps a misbehaving client from flooding the DB.
15const maxBlobBytes = 64 * 1024
16
17// client is one connected browser. send is the per-client outbound queue; the
18// hub never blocks on a slow client — it drops them instead. userID lets the hub
19// route a thread's traffic only to that thread's members.
20type client struct {
21 userID string
22 send chan []byte
23}
24
25// outbound is one fan-out: data delivered to every connected client whose user
26// is in members (nil members = everyone, unused now that all traffic is
27// thread-scoped).
28type outbound struct {
29 members map[string]bool
30 data []byte
31}
32
33// Hub fans out ciphertext blobs to connected clients. It only ever handles
34// opaque bytes — it cannot read message contents — and routes each blob to the
35// thread's members only.
36type Hub struct {
37 register chan *client
38 unregister chan *client
39 broadcast chan outbound
40 control chan func() // runs fn on the hub goroutine (safe map access)
41 clients map[*client]bool
42}
43
44func newHub() *Hub {
45 return &Hub{
46 register: make(chan *client),
47 unregister: make(chan *client),
48 broadcast: make(chan outbound, 256),
49 control: make(chan func()),
50 clients: make(map[*client]bool),
51 }
52}
53
54func (h *Hub) run() {
55 for {
56 select {
57 case fn := <-h.control:
58 fn()
59 case c := <-h.register:
60 h.clients[c] = true
61 case c := <-h.unregister:
62 if h.clients[c] {
63 delete(h.clients, c)
64 close(c.send)
65 }
66 case o := <-h.broadcast:
67 for c := range h.clients {
68 if o.members != nil && !o.members[c.userID] {
69 continue // not a member of this thread
70 }
71 select {
72 case c.send <- o.data:
73 default:
74 // Backed-up client: drop it. Its read loop will clean up.
75 delete(h.clients, c)
76 close(c.send)
77 }
78 }
79 }
80 }
81}
82
83// shutdown closes every connected client so their browsers reconnect — to the
84// new process, after a zero-downtime deploy (see docs/instance-router.md). It
85// runs the close on the hub goroutine so it can't race broadcast/register. The
86// closed send ends each client's writer, which closes the socket; the client's
87// read loop then unregisters (a no-op, already removed).
88func (h *Hub) shutdown() {
89 done := make(chan struct{})
90 h.control <- func() {
91 for c := range h.clients {
92 delete(h.clients, c)
93 close(c.send)
94 }
95 close(done)
96 }
97 <-done
98}
99
100// broadcastToThread fans data out to the members of threadID only.
101func (s *Server) broadcastToThread(threadID string, data []byte) {
102 members, err := s.db.memberUserIDs(threadID)
103 if err != nil {
104 log.Println("broadcast: members:", err)
105 return
106 }
107 set := make(map[string]bool, len(members))
108 for _, u := range members {
109 set[u] = true
110 }
111 s.hub.broadcast <- outbound{members: set, data: data}
112}
113
114type wsIn struct {
115 Type string `json:"type"`
116 ThreadID string `json:"thread_id"`
117 Blob string `json:"blob"`
118 MessageID int64 `json:"message_id"`
119 Emoji string `json:"emoji"`
120 // BlobIDs are the attachment blob ids a deleted message referenced. The server
121 // can't read them from the (encrypted) message body, so the client — which can
122 // — reports them on delete, and the server GCs those blobs scoped to the thread
123 // (a client can only drop its own thread's blobs). See the "delete" case.
124 BlobIDs []string `json:"blob_ids,omitempty"`
125 // Ephemeral asks for relay-only delivery: fan the blob out to the thread's
126 // members without storing or pushing it (presence-style traffic — typing,
127 // live cursors). The flag lives on the envelope, not the ciphertext, so the
128 // server can honour it while staying blind to the content.
129 Ephemeral bool `json:"ephemeral,omitempty"`
130}
131
132type wsOut struct {
133 Type string `json:"type"`
134 ThreadID string `json:"thread_id,omitempty"`
135 ID int64 `json:"id"`
136 CreatedAt int64 `json:"created_at,omitempty"`
137 Blob string `json:"blob"`
138 EditedAt int64 `json:"edited_at,omitempty"`
139 Ephemeral bool `json:"ephemeral,omitempty"`
140}
141
142// editWindow is how long after sending a message its author may still edit it.
143// Enforced server-side against the message's created_at (see editMessage).
144const editWindow = 15 * time.Minute
145
146// activeWindow: a device counts as "active" (open + focused) if it signalled within
147// this window. New messages skip push to a person active on ANY of their devices, so
148// a chat you're reading on one device doesn't also notify your others.
149const activeWindow = 45 * time.Second
150
151func (s *Server) markActive(token string) {
152 now := time.Now().Unix()
153 s.active.Store(token, now)
154 // Roll the signal up to the owner so suppression is per-person, not per-device.
155 if uid, ok := s.db.deviceTokenUser(token); ok && uid != "" {
156 s.activeUsers.Store(uid, now)
157 // Fresh activity may flip an idle-away member back online (presence.go);
158 // a pinned-away one stays away — presenceChanged resolves that.
159 s.presenceChanged(uid)
160 }
161}
162
163func (s *Server) isActive(token string) bool {
164 if v, ok := s.active.Load(token); ok {
165 return time.Since(time.Unix(v.(int64), 0)) < activeWindow
166 }
167 return false
168}
169
170// isUserActive reports whether the user is reading on any of their devices right now.
171func (s *Server) isUserActive(uid string) bool {
172 if uid == "" {
173 return false
174 }
175 if v, ok := s.activeUsers.Load(uid); ok {
176 return time.Since(time.Unix(v.(int64), 0)) < activeWindow
177 }
178 return false
179}
180
181// wsTypingOut relays a typing indicator. Like everything else it carries only an
182// opaque blob (the sender's name + expiry ride encrypted inside) — the server
183// never stores it and never pushes it; it's a transient presence hint.
184type wsTypingOut struct {
185 Type string `json:"type"`
186 ThreadID string `json:"thread_id,omitempty"`
187 Blob string `json:"blob"`
188}
189
190// wsReadOut tells a user's other devices their read watermark for a thread
191// advanced, so their unread dots clear live. Routed only to that user's own
192// connections — never to other members; reading habits aren't shared.
193type wsReadOut struct {
194 Type string `json:"type"`
195 ThreadID string `json:"thread_id"`
196 LastReadAt int64 `json:"last_read_at"`
197}
198
199func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
200 token := r.URL.Query().Get("token")
201 if !s.tokenOK(token) {
202 http.Error(w, "invalid invite", http.StatusForbidden)
203 return
204 }
205
206 conn, err := websocket.Accept(w, r, nil) // same-origin check is on by default
207 if err != nil {
208 return
209 }
210 conn.SetReadLimit(maxBlobBytes + 1024)
211
212 // The hub routes a thread's traffic to its members, so each client carries the
213 // user it belongs to (empty for legacy non-device tokens — they receive nothing
214 // thread-scoped until migrated).
215 userID, _ := s.db.deviceTokenUser(token)
216 cl := &client{userID: userID, send: make(chan []byte, 32)}
217 s.hub.register <- cl
218 // Presence: connecting counts as activity (a device that never sends
219 // "active" frames — native, today — still reads online, then idles out).
220 s.presence.addConn(userID)
221 s.markActive(token)
222
223 ctx := r.Context()
224
225 // Writer: drains this client's queue. Closing send (by the hub) ends it.
226 go func() {
227 defer conn.CloseNow()
228 for b := range cl.send {
229 wctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
230 err := conn.Write(wctx, websocket.MessageText, b)
231 cancel()
232 if err != nil {
233 return
234 }
235 }
236 }()
237
238 // Reader: accepts new ciphertext, stores it, and broadcasts to everyone.
239 // However the socket dies — clean close, error, or the hub dropping a
240 // backed-up client — this defer runs, so the presence count stays true.
241 defer func() {
242 s.hub.unregister <- cl
243 conn.CloseNow()
244 s.presence.dropConn(userID)
245 s.presenceChanged(userID)
246 }()
247 for {
248 _, data, err := conn.Read(ctx)
249 if err != nil {
250 return
251 }
252 var in wsIn
253 if json.Unmarshal(data, &in) != nil {
254 continue
255 }
256 if in.Type == "active" {
257 s.markActive(token) // this device is open + focused; mute its siblings' push
258 continue
259 }
260 if in.Type != "msg" && in.Type != "edit" && in.Type != "delete" && in.Type != "typing" {
261 continue
262 }
263 // Re-check the credential so a revoked invite / removed device can't keep
264 // posting or reacting.
265 if !s.tokenOK(token) {
266 return
267 }
268 // All traffic is thread-scoped: you must be a member of the thread you're
269 // posting/reacting to. This is the server-side gate on the social graph.
270 if in.ThreadID == "" || !s.db.threadMember(in.ThreadID, userID) {
271 continue
272 }
273 // A closed DM (issue #578) accepts no new traffic — messages, edits,
274 // and typing are all communication. Deletes still pass: taking your
275 // own words back is always allowed.
276 if in.Type != "delete" {
277 if closed, _ := s.db.dmClosed(in.ThreadID); closed {
278 continue
279 }
280 }
281
282 switch in.Type {
283 case "typing":
284 // Transient presence hint: relay the opaque blob to the thread's other
285 // members (the sender ignores its own echo) without storing or pushing
286 // it. The receiver decrypts the name + expiry and shows it briefly.
287 //
288 // This is a dedicated relay-only frame, like "active"/"ping" — typing
289 // isn't message-shaped (no id, created_at, reactions). If more
290 // ephemeral kinds appear later, consider generalising to a plaintext
291 // `ephemeral` flag on the frame (read by the server to skip storage —
292 // it must live on the envelope, not the ciphertext, which stays E2E).
293 if in.Blob == "" || len(in.Blob) > maxBlobBytes {
294 continue
295 }
296 out, _ := json.Marshal(wsTypingOut{Type: "typing", ThreadID: in.ThreadID, Blob: in.Blob})
297 s.broadcastToThread(in.ThreadID, out)
298
299 case "msg":
300 if in.Blob == "" || len(in.Blob) > maxBlobBytes {
301 continue
302 }
303 // The content-blind message brake (issue #578): far above any
304 // human pace; a flood just stops landing. Admins exempt.
305 if s.db.userRole(userID) != "admin" && !s.brake().messages.allow(userID) {
306 continue
307 }
308 // Ephemeral: relay to the thread's members and forget — no store, no
309 // push. This generalises the dedicated "typing" frame below for
310 // mini-app presence traffic (the client marks the frame; content
311 // stays opaque). Delivered with no id, so clients can't confuse it
312 // with a stored message.
313 if in.Ephemeral {
314 out, _ := json.Marshal(wsOut{Type: "msg", ThreadID: in.ThreadID, Blob: in.Blob, Ephemeral: true})
315 s.broadcastToThread(in.ThreadID, out)
316 continue
317 }
318 m, err := s.db.insertMessage(in.ThreadID, in.Blob, userID)
319 if err != nil {
320 log.Println("insert message:", err)
321 continue
322 }
323 out, _ := json.Marshal(wsOut{Type: "msg", ThreadID: in.ThreadID, ID: m.ID, CreatedAt: m.CreatedAt, Blob: m.Blob})
324 s.broadcastToThread(in.ThreadID, out)
325 // Notify the thread's other members (not the sender), best-effort, async.
326 go s.pushNewMessage(m.Blob, in.ThreadID, token, m.ID, m.CreatedAt)
327
328 case "edit":
329 if in.MessageID <= 0 || in.Blob == "" || len(in.Blob) > maxBlobBytes ||
330 !s.db.messageInThread(in.MessageID, in.ThreadID) {
331 continue
332 }
333 // Edits share the message bucket — each one broadcasts to the whole
334 // thread, so edit-churn is just message spam wearing a hat. (Typing
335 // and ephemeral relays stay unthrottled on purpose: never stored,
336 // never pushed, and burning message tokens on typing beats would
337 // starve real sends.)
338 if s.db.userRole(userID) != "admin" && !s.brake().messages.allow(userID) {
339 continue
340 }
341 m, ok, err := s.db.editMessage(in.MessageID, in.Blob, userID, editWindow)
342 if err != nil {
343 log.Println("edit message:", err)
344 continue
345 }
346 if !ok {
347 continue // wrong author, too old, or unknown id — silently ignore
348 }
349 out, _ := json.Marshal(wsOut{Type: "edit", ThreadID: in.ThreadID, ID: m.ID, Blob: m.Blob, EditedAt: m.EditedAt})
350 s.broadcastToThread(in.ThreadID, out)
351
352 case "delete":
353 if in.MessageID <= 0 || !s.db.messageInThread(in.MessageID, in.ThreadID) {
354 continue
355 }
356 ok, err := s.db.deleteMessage(in.MessageID, userID)
357 if err != nil {
358 log.Println("delete message:", err)
359 continue
360 }
361 if !ok {
362 continue // not the author, or unknown id — silently ignore
363 }
364 // Reclaim the message's attachment blobs (scoped to this thread, so a
365 // client can't drop another thread's blobs). Best-effort — a failure
366 // here shouldn't block the delete fan-out.
367 if len(in.BlobIDs) > 0 {
368 if err := s.db.deleteBlobs(in.ThreadID, in.BlobIDs); err != nil {
369 log.Println("gc attachment blobs:", err)
370 }
371 }
372 out, _ := json.Marshal(wsOut{Type: "delete", ThreadID: in.ThreadID, ID: in.MessageID})
373 s.broadcastToThread(in.ThreadID, out)
374
375 }
376 }
377}
378