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  }