github.com/Azareal/Gosora@v0.0.0-20210729070923-553e66b59003/common/websockets.go (about) 1 // +build !no_ws 2 3 /* 4 * 5 * Gosora WebSocket Subsystem 6 * Copyright Azareal 2017 - 2021 7 * 8 */ 9 package common 10 11 import ( 12 "bytes" 13 "errors" 14 "fmt" 15 "net/http" 16 "runtime" 17 "strconv" 18 "strings" 19 "sync" 20 "time" 21 22 p "github.com/Azareal/Gosora/common/phrases" 23 "github.com/Azareal/gopsutil/cpu" 24 "github.com/Azareal/gopsutil/mem" 25 "github.com/gorilla/websocket" 26 ) 27 28 // TODO: Disable WebSockets on high load? Add a Control Panel interface for disabling it? 29 var EnableWebsockets = true // Put this in caps for consistency with the other constants? 30 31 var wsUpgrader = websocket.Upgrader{ReadBufferSize: 1024, WriteBufferSize: 1024} 32 var errWsNouser = errors.New("This user isn't connected via WebSockets") 33 34 func init() { 35 adminStatsWatchers = make(map[*websocket.Conn]*WSUser) 36 topicListWatchers = make(map[*WSUser]struct{}) 37 topicWatchers = make(map[int]map[*WSUser]struct{}) 38 } 39 40 //easyjson:json 41 type WsTopicList struct { 42 Topics []*WsTopicsRow 43 LastPage int // Not for WebSockets, but for the JSON endpoint for /topics/ to keep the paginator functional 44 LastUpdate int64 45 } 46 47 // TODO: How should we handle errors for this? 48 // TODO: Move this out of common? 49 func RouteWebsockets(w http.ResponseWriter, r *http.Request, user *User) RouteError { 50 // TODO: Spit out a 500 instead of nil? 51 conn, err := wsUpgrader.Upgrade(w, r, nil) 52 if err != nil { 53 return LocalError("unable to upgrade", w, r, user) 54 } 55 defer conn.Close() 56 57 wsUser, err := WsHub.AddConn(user, conn) 58 if err != nil { 59 return nil 60 } 61 62 //conn.SetReadLimit(/* put the max request size from earlier here? */) 63 //conn.SetReadDeadline(time.Now().Add(60 * time.Second)) 64 var currentPage string 65 for { 66 _, message, err := conn.ReadMessage() 67 if err != nil { 68 if user.ID == 0 { 69 WsHub.GuestLock.Lock() 70 delete(WsHub.OnlineGuests, wsUser) 71 WsHub.GuestLock.Unlock() 72 } else { 73 // TODO: Make sure the admin is removed from the admin stats list in the case that an error happens 74 WsHub.RemoveConn(wsUser, conn) 75 } 76 break 77 } 78 if conn == nil { 79 panic("conn must not be nil") 80 } 81 82 for _, msg := range bytes.Split(message, []byte("\r")) { 83 //StoppedServer("Profile end") // A bit of code for me to profile the software 84 if bytes.HasPrefix(msg, []byte("page ")) { 85 msgblocks := bytes.SplitN(msg, []byte(" "), 2) 86 if len(msgblocks) < 2 { 87 continue 88 } 89 90 if !bytes.Equal(msgblocks[1], []byte(currentPage)) { 91 wsLeavePage(wsUser, conn, currentPage) 92 currentPage = string(msgblocks[1]) 93 wsPageResponses(wsUser, conn, currentPage) 94 } 95 } else if bytes.HasPrefix(msg, []byte("resume ")) { 96 msgblocks := bytes.SplitN(msg, []byte(" "), 3) 97 if len(msgblocks) < 3 { 98 continue 99 } 100 //log.Print("resuming on " + string(msgblocks[1]) + " at " + string(msgblocks[2])) 101 102 if !bytes.Equal(msgblocks[1], []byte(currentPage)) { 103 wsLeavePage(wsUser, conn, currentPage) // Avoid clients abusing late resumes 104 currentPage = string(msgblocks[1]) 105 // TODO: Synchronise this better? 106 resume, err := strconv.ParseInt(string(msgblocks[2]), 10, 64) 107 wsPageResponses(wsUser, conn, currentPage) 108 if err != nil { 109 wsPageResume(wsUser, conn, currentPage, resume) 110 } 111 } 112 } 113 /*if bytes.Equal(message,[]byte(`start-view`)) { 114 } else if bytes.Equal(message,[]byte(`end-view`)) { 115 }*/ 116 } 117 } 118 DebugLog("Closing connection for user " + strconv.Itoa(user.ID)) 119 return nil 120 } 121 122 // TODO: Copied from routes package for use in wsPageResponse, find a more elegant solution. 123 func ParseSEOURL(urlBit string) (slug string, id int, err error) { 124 halves := strings.Split(urlBit, ".") 125 if len(halves) < 2 { 126 halves = append(halves, halves[0]) 127 } 128 tid, err := strconv.Atoi(halves[1]) 129 return halves[0], tid, err 130 } 131 132 // TODO: Use a map instead of a switch to make this more modular? 133 func wsPageResponses(wsUser *WSUser, conn *websocket.Conn, page string) { 134 if page == "/" { 135 page = Config.DefaultPath 136 } 137 138 DebugLog("Entering page " + page) 139 switch { 140 // Live Topic List is an experimental feature 141 // TODO: Optimise this to reduce the amount of contention 142 case page == "/topics/": 143 topicListMutex.Lock() 144 topicListWatchers[wsUser] = struct{}{} 145 topicListMutex.Unlock() 146 // TODO: Evict from page when permissions change? Or check user perms every-time before sending data? 147 case strings.HasPrefix(page, "/topic/"): 148 //fmt.Println("entering topic prefix websockets zone") 149 if wsUser.User.ID == 0 { 150 return 151 } 152 _, tid, e := ParseSEOURL(page) 153 if e != nil { 154 return 155 } 156 topic, e := Topics.Get(tid) 157 if e != nil { 158 return 159 } 160 if !Forums.Exists(topic.ParentID) { 161 return 162 } 163 usercpy := BlankUser() 164 *usercpy = *wsUser.User 165 usercpy.Init() 166 167 /*skip, rerr := header.Hooks.VhookSkippable("ws_topic_check_pre_perms", w, r, usercpy, &fid, &header) 168 if skip || rerr != nil { 169 return 170 }*/ 171 172 fperms, e := FPStore.Get(topic.ParentID, usercpy.Group) 173 if e == ErrNoRows { 174 fperms = BlankForumPerms() 175 } else if e != nil { 176 return 177 } 178 cascadeForumPerms(fperms, usercpy) 179 if !usercpy.Perms.ViewTopic { 180 return 181 } 182 183 topicMutex.Lock() 184 _, ok := topicWatchers[topic.ID] 185 if !ok { 186 topicWatchers[topic.ID] = make(map[*WSUser]struct{}) 187 } 188 topicWatchers[topic.ID][wsUser] = struct{}{} 189 topicMutex.Unlock() 190 case page == "/panel/": 191 if !wsUser.User.IsSuperMod { 192 return 193 } 194 // Listen for changes and inform the admins... 195 adminStatsMutex.Lock() 196 watchers := len(adminStatsWatchers) 197 adminStatsWatchers[conn] = wsUser 198 if watchers == 0 { 199 go func() { 200 defer EatPanics() 201 adminStatsTicker() 202 }() 203 } 204 adminStatsMutex.Unlock() 205 default: 206 return 207 } 208 e := wsUser.SetPageForSocket(conn, page) 209 if e != nil { 210 LogError(e) 211 } 212 } 213 214 // TODO: Use a map instead of a switch to make this more modular? 215 // TODO: Implement this 216 func wsPageResume(wsUser *WSUser, conn *websocket.Conn, page string, resume int64) { 217 if page == "/" { 218 page = Config.DefaultPath 219 } 220 221 switch { 222 // TODO: Synchronise this bit of resume with tick updating lastTopicList? 223 case page == "/topics/": 224 /*if resume >= hub.lastTick.Unix() { 225 conn.Write([]byte("resume tooslow")) 226 } else { 227 conn.Write([]byte("resume success")) 228 }*/ 229 default: 230 return 231 } 232 } 233 234 // TODO: Use a map instead of a switch to make this more modular? 235 func wsLeavePage(wsUser *WSUser, conn *websocket.Conn, page string) { 236 if page == "/" { 237 page = Config.DefaultPath 238 } else if page != "" { 239 DebugLog("Leaving page " + page) 240 } 241 switch { 242 case page == "/topics/": 243 wsUser.FinalizePage("/topics/", func() { 244 topicListMutex.Lock() 245 delete(topicListWatchers, wsUser) 246 topicListMutex.Unlock() 247 }) 248 case strings.HasPrefix(page, "/topic/"): 249 //fmt.Println("leaving topic prefix websockets zone") 250 if wsUser.User.ID == 0 { 251 return 252 } 253 wsUser.FinalizePage(page, func() { 254 _, tid, e := ParseSEOURL(page) 255 if e != nil { 256 return 257 } 258 topicMutex.Lock() 259 defer topicMutex.Unlock() 260 topic, ok := topicWatchers[tid] 261 if !ok { 262 return 263 } 264 if _, ok = topic[wsUser]; !ok { 265 return 266 } 267 delete(topic, wsUser) 268 if len(topic) == 0 { 269 delete(topicWatchers, tid) 270 } 271 }) 272 case page == "/panel/": 273 adminStatsMutex.Lock() 274 delete(adminStatsWatchers, conn) 275 adminStatsMutex.Unlock() 276 } 277 e := wsUser.SetPageForSocket(conn, "") 278 if e != nil { 279 LogError(e) 280 } 281 } 282 283 // TODO: Abstract this 284 // TODO: Use odd-even sharding 285 var topicListWatchers map[*WSUser]struct{} 286 var topicListMutex sync.RWMutex 287 var topicWatchers map[int]map[*WSUser]struct{} // map[tid]watchers 288 var topicMutex sync.RWMutex 289 var adminStatsWatchers map[*websocket.Conn]*WSUser 290 var adminStatsMutex sync.RWMutex 291 292 func adminStatsTicker() { 293 time.Sleep(time.Second) 294 295 lastUonline, lastGonline, lastTotonline := -1, -1, -1 296 lastCPUPerc := -1 297 var lastAvailableRAM int64 = -1 298 var noStatUpdates, noRAMUpdates bool 299 300 var onlineColour, onlineGuestsColour, onlineUsersColour, cpustr, cpuColour, ramstr, ramColour string 301 var cpuerr, ramerr error 302 var memres *mem.VirtualMemoryStat 303 var cpuPerc []float64 304 305 var totunit, uunit, gunit string 306 307 lessThanSwitch := func(number, lowerBound, midBound int) string { 308 switch { 309 case number < lowerBound: 310 return "stat_green" 311 case number < midBound: 312 return "stat_orange" 313 } 314 return "stat_red" 315 } 316 greaterThanSwitch := func(number, lowerBound, midBound int) string { 317 switch { 318 case number > midBound: 319 return "stat_green" 320 case number > lowerBound: 321 return "stat_orange" 322 } 323 return "stat_red" 324 } 325 326 AdminStatLoop: 327 for { 328 adminStatsMutex.RLock() 329 watchCount := len(adminStatsWatchers) 330 adminStatsMutex.RUnlock() 331 if watchCount == 0 { 332 break AdminStatLoop 333 } 334 335 cpuPerc, cpuerr = cpu.Percent(time.Second, true) 336 memres, ramerr = mem.VirtualMemory() 337 uonline := WsHub.UserCount() 338 gonline := WsHub.GuestCount() 339 totonline := uonline + gonline 340 reqCount := 0 341 342 // It's far more likely that the CPU Usage will change than the other stats, so we'll optimise them separately... 343 noStatUpdates = (uonline == lastUonline && gonline == lastGonline && totonline == lastTotonline) 344 noRAMUpdates = (lastAvailableRAM == int64(memres.Available)) 345 if int(cpuPerc[0]) == lastCPUPerc && noStatUpdates && noRAMUpdates { 346 time.Sleep(time.Second) 347 continue 348 } 349 350 if !noStatUpdates { 351 onlineColour = greaterThanSwitch(totonline, 3, 10) 352 onlineGuestsColour = greaterThanSwitch(gonline, 1, 10) 353 onlineUsersColour = greaterThanSwitch(uonline, 1, 5) 354 355 totonline, totunit = ConvertFriendlyUnit(totonline) 356 uonline, uunit = ConvertFriendlyUnit(uonline) 357 gonline, gunit = ConvertFriendlyUnit(gonline) 358 } 359 360 if cpuerr != nil { 361 cpustr = "Unknown" 362 } else { 363 calcperc := int(cpuPerc[0]) / runtime.NumCPU() 364 cpustr = strconv.Itoa(calcperc) 365 switch { 366 case calcperc < 30: 367 cpuColour = "stat_green" 368 case calcperc < 75: 369 cpuColour = "stat_orange" 370 default: 371 cpuColour = "stat_red" 372 } 373 } 374 375 if !noRAMUpdates { 376 if ramerr != nil { 377 ramstr = "Unknown" 378 } else { 379 totalCount, totalUnit := ConvertByteUnit(float64(memres.Total)) 380 usedCount := ConvertByteInUnit(float64(memres.Total-memres.Available), totalUnit) 381 382 // Round totals with .9s up, it's how most people see it anyway. Floats are notoriously imprecise, so do it off 0.85 383 var totstr string 384 if (totalCount - float64(int(totalCount))) > 0.85 { 385 usedCount += 1.0 - (totalCount - float64(int(totalCount))) 386 totstr = strconv.Itoa(int(totalCount) + 1) 387 } else { 388 totstr = fmt.Sprintf("%.1f", totalCount) 389 } 390 391 if usedCount > totalCount { 392 usedCount = totalCount 393 } 394 ramstr = fmt.Sprintf("%.1f", usedCount) + " / " + totstr + totalUnit 395 396 ramperc := ((memres.Total - memres.Available) * 100) / memres.Total 397 ramColour = lessThanSwitch(int(ramperc), 50, 75) 398 } 399 } 400 401 // Acquire a write lock for now, so we can handle the delete() case below and the read one simultaneously 402 // TODO: Stop taking a write lock here if it isn't necessary 403 adminStatsMutex.Lock() 404 for conn := range adminStatsWatchers { 405 w, err := conn.NextWriter(websocket.TextMessage) 406 if err != nil { 407 delete(adminStatsWatchers, conn) 408 continue 409 } 410 411 // nolint 412 // TODO: Use JSON for this to make things more portable and easier to convert to MessagePack, if need be? 413 write := func(msg string) { 414 w.Write([]byte(msg + "\r")) 415 } 416 push := func(id, msg string) { 417 write("set #" + id + " <span>" + msg + "</span>") 418 } 419 pushc := func(id, classes string) { 420 write("set-class #" + id + " " + classes) 421 } 422 if !noStatUpdates { 423 push("dash-totonline", p.GetTmplPhrasef("panel_dashboard_online", totonline, totunit)) 424 push("dash-gonline", p.GetTmplPhrasef("panel_dashboard_guests_online", gonline, gunit)) 425 push("dash-uonline", p.GetTmplPhrasef("panel_dashboard_users_online", uonline, uunit)) 426 push("dash-reqs", strconv.Itoa(reqCount)+" reqs / second") 427 pushc("dash-totonline", "grid_item grid_stat "+onlineColour) 428 pushc("dash-gonline", "grid_item grid_stat "+onlineGuestsColour) 429 pushc("dash-uonline", "grid_item grid_stat "+onlineUsersColour) 430 //pushc("dash-reqs","grid_item grid_stat grid_end_group") 431 } 432 push("dash-cpu", p.GetTmplPhrasef("panel_dashboard_cpu", cpustr)+"%") 433 pushc("dash-cpu", "grid_item grid_istat "+cpuColour) 434 435 if !noRAMUpdates { 436 push("dash-ram", p.GetTmplPhrasef("panel_dashboard_ram", ramstr)) 437 pushc("dash-ram", "grid_item grid_istat "+ramColour) 438 } 439 w.Close() 440 } 441 adminStatsMutex.Unlock() 442 443 lastUonline = uonline 444 lastGonline = gonline 445 lastTotonline = totonline 446 lastCPUPerc = int(cpuPerc[0]) 447 lastAvailableRAM = int64(memres.Available) 448 } 449 }