├── .github └── workflows │ ├── chat-backend-cd.yml │ ├── chat-backend-ci.yaml │ └── dev-chat-backend-cd.yml ├── .gitignore ├── Dockerfile ├── angel ├── .gitignore ├── build ├── debug.go └── main.go ├── bans.go ├── bans_test.go ├── combos.go ├── connection.go ├── data.go ├── database.go ├── db-init.sql ├── debug.go ├── entities.go ├── go.mod ├── go.sum ├── hub.go ├── main.go ├── mutes.go ├── mutes_test.go ├── namescache.go ├── namescache_test.go ├── rares.go ├── user.go ├── users_test.go └── viewerstate.go /.github/workflows/chat-backend-cd.yml: -------------------------------------------------------------------------------- 1 | name: chat-backend-cd 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | jobs: 8 | build: 9 | name: Build 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | with: 14 | submodules: true 15 | - name: Docker login 16 | env: 17 | username: ${{ github.actor }} 18 | password: ${{ secrets.GITHUB_TOKEN }} 19 | run: docker login ghcr.io -u ${username} -p ${password} 20 | - name: Build image 21 | run: docker build . -t ghcr.io/memelabs/chat/chat-backend:latest 22 | - name: Publish image 23 | run: docker push ghcr.io/memelabs/chat/chat-backend:latest 24 | - name: ssh-deploy for chat 25 | uses: appleboy/ssh-action@122f35dca5c7a216463c504741deb0de5b301953 26 | with: 27 | host: ${{ secrets.HOST }} 28 | username: ${{ secrets.USERNAME }} 29 | key: ${{ secrets.KEY }} 30 | script: | 31 | ./hooks/chat-backend.sh 32 | -------------------------------------------------------------------------------- /.github/workflows/chat-backend-ci.yaml: -------------------------------------------------------------------------------- 1 | name: chat-backend-ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*' 7 | pull_request: 8 | branches: 9 | - '*' 10 | jobs: 11 | build: 12 | name: Build 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Set up Go 1.13 16 | uses: actions/setup-go@v1 17 | with: 18 | go-version: 1.13 19 | id: go 20 | 21 | - name: Check out code into the Go module directory 22 | uses: actions/checkout@v2 23 | 24 | - name: Get dependencies 25 | run: | 26 | go get -v -t -d ./... 27 | 28 | - name: Test 29 | run: go test -v ./... 30 | 31 | - name: Build 32 | run: go build -v . 33 | -------------------------------------------------------------------------------- /.github/workflows/dev-chat-backend-cd.yml: -------------------------------------------------------------------------------- 1 | name: dev-chat-backend-cd 2 | 3 | on: 4 | push: 5 | branches: [ dev ] 6 | 7 | jobs: 8 | build: 9 | name: Build 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | with: 14 | submodules: true 15 | - name: Docker login 16 | env: 17 | username: ${{ github.actor }} 18 | password: ${{ secrets.GITHUB_TOKEN }} 19 | run: docker login ghcr.io -u ${username} -p ${password} 20 | - name: Build image 21 | run: docker build . -t ghcr.io/memelabs/chat/chat-backend:dev 22 | - name: Publish image 23 | run: docker push ghcr.io/memelabs/chat/chat-backend:dev 24 | - name: ssh-deploy for chat 25 | uses: appleboy/ssh-action@122f35dca5c7a216463c504741deb0de5b301953 26 | with: 27 | host: ${{ secrets.HOST }} 28 | username: ${{ secrets.USERNAME }} 29 | key: ${{ secrets.KEY }} 30 | script: | 31 | ./hooks/dev-chat-backend.sh 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.sqlite 2 | *.cfg 3 | chat_backend 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine AS builder 2 | RUN apk --no-cache add \ 3 | ca-certificates \ 4 | build-base 5 | 6 | ENV GO111MODULE=on \ 7 | CGO_ENABLED=1 8 | 9 | WORKDIR /build 10 | 11 | COPY go.mod . 12 | COPY go.sum . 13 | RUN go mod download 14 | RUN go mod verify 15 | 16 | COPY *.go ./ 17 | RUN go build -o chat -a -ldflags '-extldflags "-static"' . 18 | WORKDIR /dist 19 | RUN cp /build/chat . 20 | 21 | FROM scratch 22 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 23 | COPY --from=builder /dist/chat / 24 | COPY db-init.sql / 25 | 26 | EXPOSE 9998 27 | 28 | ENTRYPOINT ["/chat"] 29 | -------------------------------------------------------------------------------- /angel/.gitignore: -------------------------------------------------------------------------------- 1 | angel.cfg 2 | angel 3 | -------------------------------------------------------------------------------- /angel/build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | go build $1 -o angel main.go debug.go 3 | -------------------------------------------------------------------------------- /angel/debug.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | //"github.com/emicklei/hopwatch" 7 | "bufio" 8 | "bytes" 9 | "io" 10 | "log" 11 | "os" 12 | "runtime" 13 | "strings" 14 | "sync" 15 | "time" 16 | ) 17 | 18 | var ( 19 | logger *log.Logger 20 | logfile *os.File 21 | accumulatewriter = bytes.NewBufferString("") 22 | accumulatelock sync.Mutex 23 | ) 24 | 25 | func initLog() { 26 | logger = log.New(accumulatewriter, "angel> ", log.Ldate|log.Ltime) 27 | go consumeLog() 28 | } 29 | 30 | func consumeLog() { 31 | // only one consumer 32 | for { 33 | accumulatelock.Lock() 34 | if accumulatewriter.Len() <= 0 { 35 | accumulatelock.Unlock() 36 | time.Sleep(500 * time.Millisecond) 37 | continue 38 | } 39 | 40 | s, _ := accumulatewriter.ReadString('\n') 41 | s = strings.TrimSpace(s) 42 | println(s) 43 | logfile.WriteString(s) 44 | logfile.WriteString("\n") 45 | accumulatelock.Unlock() 46 | } 47 | } 48 | 49 | func initLogWriter() { 50 | defer accumulatelock.Unlock() 51 | accumulatelock.Lock() 52 | filename := time.Now().Format("logs/log-20060201-150405.txt") 53 | if logfile != nil { 54 | logfile.Close() // do not care about error 55 | } 56 | var err error 57 | logfile, err = os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) 58 | if err != nil { 59 | println("logfile creation error: ", err) 60 | } 61 | } 62 | 63 | func accumulateLog(r io.Reader) { 64 | // multiple 65 | br := bufio.NewReader(r) 66 | for { 67 | l, err := br.ReadString('\n') 68 | if err != nil { 69 | return 70 | } 71 | l = strings.TrimSpace(l) 72 | 73 | accumulatelock.Lock() 74 | _, err = accumulatewriter.WriteString(l) 75 | _, err = accumulatewriter.WriteString("\n") 76 | accumulatelock.Unlock() 77 | 78 | if err != nil { 79 | return 80 | } 81 | } 82 | } 83 | 84 | // source https://groups.google.com/forum/?fromgroups#!topic/golang-nuts/C24fRw8HDmI 85 | // from David Wright 86 | type ErrorTrace struct { 87 | err error 88 | trace string 89 | } 90 | 91 | func NewErrorTrace(v ...interface{}) error { 92 | msg := fmt.Sprint(v...) 93 | pc, file, line, ok := runtime.Caller(2) 94 | if ok { 95 | fun := runtime.FuncForPC(pc) 96 | loc := fmt.Sprint(fun.Name(), "\n\t", file, ":", line) 97 | return ErrorTrace{err: errors.New(msg), trace: loc} 98 | } 99 | return errors.New("error generating error") 100 | } 101 | 102 | func (et ErrorTrace) Error() string { 103 | return et.err.Error() + "\n " + et.trace 104 | } 105 | 106 | func B(v ...interface{}) { 107 | ts := time.Now().Format("2006-02-01 15:04:05: ") 108 | println(ts, NewErrorTrace(v...).Error()) 109 | } 110 | 111 | func D(v ...interface{}) { 112 | if debuggingenabled { 113 | logger.Println(v...) 114 | } 115 | } 116 | 117 | func DP(v ...interface{}) { 118 | if debuggingenabled { 119 | logger.Print(v...) 120 | } 121 | } 122 | 123 | func P(v ...interface{}) { 124 | logger.Println(v...) 125 | } 126 | 127 | func F(v ...interface{}) { 128 | logger.Fatalln(v...) 129 | } 130 | -------------------------------------------------------------------------------- /angel/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "os" 7 | "os/exec" 8 | "os/signal" 9 | "path" 10 | "strings" 11 | "sync" 12 | "syscall" 13 | "time" 14 | 15 | "github.com/gorilla/websocket" 16 | conf "github.com/msbranco/goconfig" 17 | ) 18 | 19 | var debuggingenabled bool 20 | var ( 21 | dialer = websocket.Dialer{ 22 | HandshakeTimeout: 5 * time.Second, 23 | } 24 | headers = http.Header{ 25 | "Origin": []string{"http://localhost"}, 26 | } 27 | ) 28 | 29 | var ( 30 | mu = sync.Mutex{} 31 | pingrunning = false 32 | namesrunning = false 33 | ) 34 | 35 | func main() { 36 | c, err := conf.ReadConfigFile("angel.cfg") 37 | if err != nil { 38 | nc := conf.NewConfigFile() 39 | nc.AddOption("default", "debug", "false") 40 | nc.AddOption("default", "binarypath", "./") 41 | nc.AddOption("default", "serverurl", "ws://localhost:9998/ws") 42 | nc.AddOption("default", "origin", "http://localhost") 43 | 44 | if err := nc.WriteConfigFile("angel.cfg", 0644, "Chat Angel, watching over the chat and restarting it as needed"); err != nil { 45 | log.Fatal("Unable to create angel.cfg: ", err) 46 | } 47 | if c, err = conf.ReadConfigFile("angel.cfg"); err != nil { 48 | log.Fatal("Unable to read angel.cfg: ", err) 49 | } 50 | } 51 | 52 | debuggingenabled, _ = c.GetBool("default", "debug") 53 | binpath, _ := c.GetString("default", "binarypath") 54 | serverurl, _ := c.GetString("default", "serverurl") 55 | origin, _ := c.GetString("default", "origin") 56 | initLog() 57 | headers.Set("Origin", origin) 58 | 59 | base := path.Base(binpath) 60 | basedir := strings.TrimSuffix(binpath, "/"+base) 61 | os.Chdir(basedir) // so that the chat can still read the settings.cfg 62 | 63 | shouldrestart := make(chan bool) 64 | processexited := make(chan bool) 65 | t := time.NewTicker(10 * time.Second) 66 | sct := make(chan os.Signal, 1) 67 | signal.Notify(sct, syscall.SIGTERM) 68 | 69 | go (func() { 70 | for _ = range sct { 71 | P("CAUGHT SIGTERM, restarting the chat") 72 | shouldrestart <- true 73 | } 74 | })() 75 | 76 | var restarting bool 77 | 78 | again: 79 | restarting = true 80 | initLogWriter() 81 | cmd := exec.Command(binpath) 82 | stdout, err := cmd.StdoutPipe() 83 | if err != nil { 84 | F("Stdoutpipe error: ", err) 85 | } 86 | stderr, err := cmd.StderrPipe() 87 | if err != nil { 88 | F("Stderrpipe error: ", err) 89 | } 90 | 91 | go accumulateLog(stdout) 92 | go accumulateLog(stderr) 93 | 94 | if err := cmd.Start(); err != nil { 95 | P("Error starting", binpath, err) 96 | return 97 | } 98 | 99 | go (func() { 100 | if err := cmd.Wait(); err != nil { 101 | P("Error while waiting for process to exit ", err) 102 | } 103 | P("Chat process exited, restarting") 104 | processexited <- true 105 | })() 106 | 107 | time.Sleep(10 * time.Second) 108 | restarting = false 109 | 110 | for { 111 | select { 112 | case <-t.C: 113 | if !restarting { 114 | go checkNames(serverurl, shouldrestart) 115 | go checkPing(serverurl, shouldrestart) 116 | } 117 | case <-shouldrestart: 118 | if !restarting { 119 | cmd.Process.Signal(syscall.SIGTERM) 120 | } else { 121 | P("Received from shouldrestart but already restarting, ignored") 122 | } 123 | // TODO move pprof files out of the dir 124 | case <-processexited: 125 | if !restarting { 126 | time.Sleep(200 * time.Millisecond) 127 | goto again 128 | } 129 | } 130 | } 131 | } 132 | 133 | func checkPing(serverurl string, shouldrestart chan bool) { 134 | mu.Lock() 135 | if pingrunning { 136 | mu.Unlock() 137 | return 138 | } 139 | pingrunning = true 140 | mu.Unlock() 141 | defer (func() { 142 | mu.Lock() 143 | pingrunning = false 144 | mu.Unlock() 145 | })() 146 | 147 | var pongreceived bool 148 | ws, _, err := dialer.Dial(serverurl, headers) 149 | if err != nil { 150 | P("Unable to connect to ", serverurl) 151 | shouldrestart <- true 152 | return 153 | } 154 | 155 | defer ws.Close() 156 | start := time.Now() 157 | 158 | ws.SetReadDeadline(time.Now().Add(2 * time.Second)) 159 | ws.SetWriteDeadline(time.Now().Add(5 * time.Second)) 160 | ws.SetPingHandler(func(m string) error { 161 | return ws.WriteMessage(websocket.PongMessage, []byte(m)) 162 | }) 163 | ws.SetPongHandler(func(m string) error { 164 | ws.SetReadDeadline(time.Now().Add(2 * time.Second)) 165 | pongreceived = true 166 | return nil 167 | }) 168 | ws.WriteMessage(websocket.PingMessage, []byte{}) 169 | 170 | checkpingagain: 171 | _, _, err = ws.ReadMessage() 172 | if !pongreceived && err != nil { 173 | B("Unable to read from the websocket ", err) 174 | shouldrestart <- true 175 | return 176 | } 177 | 178 | if !pongreceived && time.Since(start) > 5*time.Second { 179 | P("Didnt receive PONG in 5s, restarting") 180 | shouldrestart <- true 181 | return 182 | } 183 | 184 | if !pongreceived { 185 | goto checkpingagain 186 | } 187 | 188 | D("PING check OK") 189 | } 190 | 191 | func checkNames(serverurl string, shouldrestart chan bool) { 192 | mu.Lock() 193 | if namesrunning { 194 | mu.Unlock() 195 | return 196 | } 197 | namesrunning = true 198 | mu.Unlock() 199 | defer (func() { 200 | mu.Lock() 201 | namesrunning = false 202 | mu.Unlock() 203 | })() 204 | 205 | ws, _, err := dialer.Dial(serverurl, headers) 206 | if err != nil { 207 | P("Unable to connect to ", serverurl) 208 | shouldrestart <- true 209 | return 210 | } 211 | 212 | ws.SetReadDeadline(time.Now().Add(10 * time.Second)) 213 | ws.SetWriteDeadline(time.Now().Add(10 * time.Second)) 214 | ws.SetPingHandler(func(m string) error { 215 | return ws.WriteMessage(websocket.PongMessage, []byte(m)) 216 | }) 217 | 218 | defer ws.Close() 219 | start := time.Now() 220 | 221 | checknamesagain: 222 | msgtype, message, err := ws.ReadMessage() 223 | if err != nil { 224 | B("Unable to read from the websocket ", err) 225 | shouldrestart <- true 226 | return 227 | } 228 | 229 | if time.Since(start) > 5*time.Second { 230 | P("Didnt receive NAMES in 5s, restarting") 231 | shouldrestart <- true 232 | return 233 | } 234 | 235 | if msgtype != websocket.TextMessage || string(message[:5]) != "NAMES" { 236 | goto checknamesagain 237 | } 238 | 239 | D("NAMES check OK") 240 | } 241 | -------------------------------------------------------------------------------- /bans.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "sync" 6 | "time" 7 | ) 8 | 9 | type Bans struct { 10 | users map[Userid]time.Time 11 | userlock sync.RWMutex 12 | ips map[string]time.Time 13 | userips map[Userid][]string 14 | iplock sync.RWMutex // protects both ips/userips 15 | } 16 | 17 | var bans = Bans{make(map[Userid]time.Time), sync.RWMutex{}, make(map[string]time.Time), make(map[Userid][]string), sync.RWMutex{}} 18 | 19 | func (b *Bans) run() { // TODO in init? probably need to init the structs here from db on start 20 | t := time.NewTicker(time.Minute) 21 | for range t.C { 22 | b.clean() 23 | } 24 | } 25 | 26 | func (b *Bans) clean() { // TODO is this used / necessary??? 27 | b.userlock.Lock() 28 | defer b.userlock.Unlock() 29 | b.iplock.Lock() 30 | defer b.iplock.Unlock() 31 | 32 | for uid, unbantime := range b.users { 33 | if isExpiredUTC(unbantime) { 34 | delete(b.users, uid) 35 | b.userips[uid] = nil 36 | } 37 | } 38 | 39 | for ip, unbantime := range b.ips { 40 | if isExpiredUTC(unbantime) { 41 | delete(b.ips, ip) 42 | } 43 | } 44 | } 45 | 46 | func (b *Bans) banUser(uid Userid, targetuid Userid, ban *BanIn) { 47 | var expiretime time.Time 48 | 49 | if ban.Ispermanent { 50 | expiretime = getFuturetimeUTC() 51 | } else { 52 | expiretime = addDurationUTC(time.Duration(ban.Duration)) 53 | } 54 | 55 | b.userlock.Lock() 56 | b.users[targetuid] = expiretime 57 | b.userlock.Unlock() 58 | b.log(uid, targetuid, ban, "") 59 | 60 | if ban.BanIP { 61 | // ips := getIPCacheForUser(targetuid) //TODO 62 | // if len(ips) == 0 { 63 | ips := hub.getIPsForUserid(targetuid) 64 | if len(ips) == 0 { 65 | D("No ips found for user (offline)", targetuid) 66 | } 67 | //} 68 | 69 | b.iplock.Lock() 70 | defer b.iplock.Unlock() 71 | for _, ip := range ips { 72 | b.banIP(targetuid, ip, expiretime, true) 73 | hub.ipbans <- ip 74 | b.log(uid, targetuid, ban, ip) 75 | D("IPBanned user", ban.Nick, targetuid, "with ip:", ip) 76 | } 77 | } 78 | 79 | hub.bans <- targetuid 80 | D("Banned user", ban.Nick, targetuid) 81 | } 82 | 83 | func (b *Bans) banIP(uid Userid, ip string, t time.Time, skiplock bool) { 84 | if !skiplock { // because the caller holds the locks 85 | b.iplock.Lock() 86 | defer b.iplock.Unlock() 87 | } 88 | 89 | b.ips[ip] = t 90 | if _, ok := b.userips[uid]; !ok { 91 | b.userips[uid] = make([]string, 0, 1) 92 | } 93 | b.userips[uid] = append(b.userips[uid], ip) 94 | } 95 | 96 | func (b *Bans) unbanUserid(uid Userid) { 97 | b.logUnban(uid) 98 | b.userlock.Lock() 99 | defer b.userlock.Unlock() 100 | b.iplock.Lock() 101 | defer b.iplock.Unlock() 102 | 103 | delete(b.users, uid) 104 | for _, ip := range b.userips[uid] { 105 | delete(b.ips, ip) 106 | D("Unbanned IP: ", ip, "for uid:", uid) 107 | } 108 | b.userips[uid] = nil 109 | D("Unbanned uid: ", uid) 110 | } 111 | 112 | func isStillBanned(t time.Time, ok bool) bool { 113 | if !ok { 114 | return false 115 | } 116 | return !isExpiredUTC(t) 117 | } 118 | 119 | func (b *Bans) isUseridBanned(uid Userid) bool { 120 | if uid == 0 { 121 | return false 122 | } 123 | b.userlock.RLock() 124 | defer b.userlock.RUnlock() 125 | t, ok := b.users[uid] 126 | return isStillBanned(t, ok) 127 | } 128 | 129 | func (b *Bans) isIPBanned(ip string) bool { 130 | b.iplock.RLock() 131 | defer b.iplock.RUnlock() 132 | t, ok := b.ips[ip] 133 | return isStillBanned(t, ok) 134 | } 135 | 136 | func (b *Bans) loadActive() { 137 | b.userlock.Lock() 138 | defer b.userlock.Unlock() 139 | b.iplock.Lock() 140 | defer b.iplock.Unlock() 141 | 142 | // purge all the bans 143 | b.users = make(map[Userid]time.Time) 144 | b.ips = make(map[string]time.Time) 145 | b.userips = make(map[Userid][]string) 146 | 147 | db.getBans(func(uid Userid, ipaddress sql.NullString, endtimestamp time.Time) { 148 | if endtimestamp.String() == "" { // TODO check is done before already? clean up... 149 | endtimestamp = getFuturetimeUTC() 150 | } 151 | 152 | if ipaddress.Valid { 153 | b.ips[ipaddress.String] = endtimestamp 154 | if _, ok := b.userips[uid]; !ok { 155 | b.userips[uid] = make([]string, 0, 1) 156 | } 157 | b.userips[uid] = append(b.userips[uid], ipaddress.String) 158 | hub.ipbans <- ipaddress.String 159 | } else { 160 | b.users[uid] = endtimestamp 161 | } 162 | }) 163 | } 164 | 165 | func (b *Bans) log(uid Userid, targetuid Userid, ban *BanIn, ip string) { 166 | db.insertBan(uid, targetuid, ban, ip) 167 | } 168 | 169 | func (b *Bans) logUnban(targetuid Userid) { 170 | db.deleteBan(targetuid) 171 | } 172 | -------------------------------------------------------------------------------- /bans_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestBanTimes(t *testing.T) { 9 | timeinfuture := time.Date(time.Now().Year()+1, time.September, 10, 23, 0, 0, 0, time.UTC) 10 | timeinpast := time.Date(time.Now().Year()-1, time.September, 10, 23, 0, 0, 0, time.UTC) 11 | uid := Userid(1) 12 | ip := "10.1.2.3" 13 | 14 | bans.users[uid] = timeinfuture 15 | if !bans.isUseridBanned(uid) { 16 | t.Error("user should be banned because the expiretime is in the future") 17 | } 18 | bans.users[uid] = timeinpast 19 | if bans.isUseridBanned(uid) { 20 | t.Error("user should NOT be banned because the expiretime is in the past") 21 | } 22 | 23 | bans.ips[ip] = timeinfuture 24 | if !bans.isIPBanned(ip) { 25 | t.Error("ip should be banned because the expiretime is in the future") 26 | } 27 | bans.ips[ip] = timeinpast 28 | if bans.isIPBanned(ip) { 29 | t.Error("ip should NOT be banned because the expiretime is in the past") 30 | } 31 | 32 | bans.clean() 33 | if len(bans.users) > 0 { 34 | t.Error("bans.clean did not clean the users") 35 | } 36 | if len(bans.ips) > 0 { 37 | t.Error("bans.clean did not clean the ips") 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /combos.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | "sync" 7 | ) 8 | 9 | var ErrComboDuplicate = errors.New("user has already participated in combo") 10 | 11 | var combos = Combos{} 12 | 13 | type comboVariant struct { 14 | modifiers []string 15 | count int 16 | } 17 | 18 | type Combos struct { 19 | lock sync.Mutex 20 | emote string 21 | count int 22 | variants map[string]*comboVariant 23 | participants map[string]struct{} 24 | } 25 | 26 | func (c *Combos) Transform(msg *EventDataOut) error { 27 | c.lock.Lock() 28 | defer c.lock.Unlock() 29 | 30 | if !isEmoteMessage(msg) { 31 | c.reset() 32 | return nil 33 | } 34 | 35 | emote := msg.Entities.Emotes[0] 36 | 37 | // if the combo was broken by another emote message reset 38 | if c.emote != emote.Name { 39 | c.reset() 40 | } 41 | 42 | if _, ok := c.participants[msg.Nick]; ok { 43 | return ErrComboDuplicate 44 | } 45 | 46 | c.emote = emote.Name 47 | c.count++ 48 | c.participants[msg.Nick] = struct{}{} 49 | 50 | variant := strings.Join(emote.Modifiers, ":") 51 | if _, ok := c.variants[variant]; !ok { 52 | c.variants[variant] = &comboVariant{ 53 | modifiers: emote.Modifiers, 54 | count: 0, 55 | } 56 | } 57 | c.variants[variant].count++ 58 | 59 | // if this was the first emote in the combo don't mark a combo yet 60 | if c.count == 1 { 61 | return nil 62 | } 63 | 64 | emote.Combo = c.count 65 | 66 | topVariantCount := -1 67 | for _, v := range c.variants { 68 | if v.count > topVariantCount { 69 | topVariantCount = c.count 70 | emote.Modifiers = v.modifiers 71 | } 72 | } 73 | 74 | return nil 75 | } 76 | 77 | func (c *Combos) reset() { 78 | c.emote = "" 79 | c.count = 0 80 | c.variants = map[string]*comboVariant{} 81 | c.participants = map[string]struct{}{} 82 | } 83 | 84 | func isEmoteMessage(msg *EventDataOut) bool { 85 | if len(msg.Entities.Emotes) != 1 { 86 | return false 87 | } 88 | b := msg.Entities.Emotes[0].Bounds 89 | return b[0] == 0 && b[1] == len(msg.Data) 90 | } 91 | -------------------------------------------------------------------------------- /connection.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "crypto/md5" 6 | "regexp" 7 | "strings" 8 | "sync" 9 | "sync/atomic" 10 | "time" 11 | "unicode/utf8" 12 | 13 | "github.com/gorilla/websocket" 14 | ) 15 | 16 | // regexp to detect three or more consecutive characters intended to be combined 17 | // with another char (like accents, diacritics), if there are more than 5 18 | // its most likely a zalgo pattern 19 | // we also do not allow unicode non-breaking space/page/paragraph separators 20 | // for an explanation on the unicode char classes used see: 21 | // https://code.google.com/p/re2/wiki/Syntax 22 | // cannot use the Z (separator) or Zs (space separator) because most of those 23 | // are legitimate, we do not want non-breaking space characters tho 24 | // http://www.fileformat.info/info/unicode/char/202f/index.htm 25 | // http://www.fileformat.info/info/unicode/char/00a0/index.htm 26 | var invalidmessage = regexp.MustCompile(`\p{M}{5,}|[\p{Zl}\p{Zp}\x{202f}\x{00a0}]`) 27 | 28 | type Connection struct { 29 | socket *websocket.Conn 30 | ip string 31 | send chan *message 32 | sendmarshalled chan *message 33 | blocksend chan *message 34 | banned chan bool 35 | stop chan bool 36 | user *User 37 | ping chan time.Time 38 | sync.RWMutex 39 | } 40 | 41 | type SimplifiedUser struct { 42 | Nick string `json:"nick"` 43 | Features *[]string `json:"features"` 44 | } 45 | 46 | type EventDataIn struct { 47 | Data string `json:"data"` 48 | Extradata string `json:"extradata"` 49 | Duration int64 `json:"duration"` 50 | } 51 | 52 | type EventDataOut struct { 53 | *SimplifiedUser 54 | Targetuserid Userid `json:"-"` 55 | Timestamp int64 `json:"timestamp"` 56 | Data string `json:"data,omitempty"` 57 | Extradata string `json:"extradata,omitempty"` 58 | Entities *Entities `json:"entities,omitempty"` 59 | } 60 | 61 | type BanIn struct { 62 | Nick string `json:"nick"` 63 | BanIP bool `json:"banip"` 64 | Duration int64 `json:"duration"` 65 | Ispermanent bool `json:"ispermanent"` 66 | Reason string `json:"reason"` 67 | } 68 | 69 | type PingOut struct { 70 | Timestamp int64 `json:"data"` 71 | } 72 | 73 | type message struct { 74 | msgtyp int 75 | event string 76 | data interface{} 77 | } 78 | 79 | type PrivmsgIn struct { 80 | Nick string `json:"nick"` 81 | Data string `json:"data"` 82 | } 83 | 84 | type PrivmsgOut struct { 85 | message 86 | targetuid Userid 87 | Messageid int64 `json:"messageid"` 88 | Timestamp int64 `json:"timestamp"` 89 | Nick string `json:"nick,omitempty"` 90 | TargetNick string `json:"targetNick,omitempty"` 91 | Data string `json:"data,omitempty"` 92 | Entities *Entities `json:"entities,omitempty"` 93 | } 94 | 95 | // Create a new connection using the specified socket and router. 96 | func newConnection(s *websocket.Conn, user *User, ip string) { 97 | c := &Connection{ 98 | socket: s, 99 | ip: ip, 100 | send: make(chan *message, SENDCHANNELSIZE), 101 | sendmarshalled: make(chan *message, SENDCHANNELSIZE), 102 | blocksend: make(chan *message), 103 | banned: make(chan bool, 8), 104 | stop: make(chan bool), 105 | user: user, 106 | ping: make(chan time.Time, 2), 107 | RWMutex: sync.RWMutex{}, 108 | } 109 | 110 | go c.writePumpText() 111 | c.readPumpText() 112 | } 113 | 114 | func (c *Connection) readPumpText() { 115 | defer func() { 116 | namescache.disconnect(c.user) 117 | c.Quit() 118 | c.socket.Close() 119 | }() 120 | 121 | c.socket.SetReadLimit(MAXMESSAGESIZE) 122 | c.socket.SetReadDeadline(time.Now().Add(READTIMEOUT)) 123 | c.socket.SetPongHandler(func(string) error { 124 | c.socket.SetReadDeadline(time.Now().Add(PINGTIMEOUT)) 125 | return nil 126 | }) 127 | c.socket.SetPingHandler(func(string) error { 128 | c.sendmarshalled <- &message{ 129 | msgtyp: websocket.PongMessage, 130 | event: "PONG", 131 | data: []byte{}, 132 | } 133 | return nil 134 | }) 135 | 136 | if c.user != nil { 137 | c.rlockUserIfExists() 138 | n := atomic.LoadInt32(&c.user.connections) 139 | if n > 5 { 140 | c.runlockUserIfExists() 141 | c.SendError("toomanyconnections") 142 | c.stop <- true 143 | return 144 | } 145 | c.runlockUserIfExists() 146 | } else { 147 | namescache.addConnection() 148 | } 149 | 150 | hub.register <- c 151 | c.Names() 152 | c.Join() // broadcast to the chat that a user has connected 153 | 154 | for { 155 | msgtype, message, err := c.socket.ReadMessage() 156 | if err != nil || msgtype == websocket.BinaryMessage { 157 | return 158 | } 159 | 160 | name, data, err := Unpack(string(message)) 161 | if err != nil { 162 | // invalid protocol message from the client, just ignore it, 163 | // disconnect the user 164 | return 165 | } 166 | 167 | // dispatch 168 | switch name { 169 | case "MSG": 170 | c.OnMsg(data) 171 | case "MUTE": 172 | c.OnMute(data) 173 | case "UNMUTE": 174 | c.OnUnmute(data) 175 | case "BAN": 176 | c.OnBan(data) 177 | case "UNBAN": 178 | c.OnUnban(data) 179 | case "SUBONLY": 180 | c.OnSubonly(data) 181 | case "PING": 182 | c.OnPing(data) 183 | case "PONG": 184 | c.OnPong(data) 185 | case "BROADCAST": 186 | c.OnBroadcast(data) 187 | case "PRIVMSG": 188 | c.OnPrivmsg(data) 189 | } 190 | } 191 | } 192 | 193 | func (c *Connection) write(mt int, payload []byte) error { 194 | c.socket.SetWriteDeadline(time.Now().Add(WRITETIMEOUT)) 195 | return c.socket.WriteMessage(mt, payload) 196 | } 197 | 198 | func (c *Connection) writePumpText() { 199 | defer func() { 200 | hub.unregister <- c 201 | c.socket.Close() // Necessary to force reading to stop, will start the cleanup 202 | }() 203 | 204 | for { 205 | select { 206 | case _, ok := <-c.ping: 207 | if !ok { 208 | return 209 | } 210 | m, _ := time.Now().MarshalBinary() 211 | if err := c.write(websocket.PingMessage, m); err != nil { 212 | return 213 | } 214 | case <-c.banned: 215 | c.write(websocket.TextMessage, []byte(`ERR "banned"`)) 216 | c.write(websocket.CloseMessage, []byte{}) 217 | return 218 | case <-c.stop: 219 | return 220 | case m := <-c.blocksend: 221 | c.rlockUserIfExists() 222 | if data, err := Marshal(m.data); err == nil { 223 | c.runlockUserIfExists() 224 | if data, err := Pack(m.event, data); err == nil { 225 | if err := c.write(websocket.TextMessage, data); err != nil { 226 | return 227 | } 228 | } 229 | } else { 230 | c.runlockUserIfExists() 231 | } 232 | case m := <-c.send: 233 | c.rlockUserIfExists() 234 | if data, err := Marshal(m.data); err == nil { 235 | c.runlockUserIfExists() 236 | if data, err := Pack(m.event, data); err == nil { 237 | typ := m.msgtyp 238 | if typ == 0 { 239 | typ = websocket.TextMessage 240 | } 241 | if err := c.write(typ, data); err != nil { 242 | return 243 | } 244 | } 245 | } else { 246 | c.runlockUserIfExists() 247 | } 248 | case message := <-c.sendmarshalled: 249 | data := message.data.([]byte) 250 | if data, err := Pack(message.event, data); err == nil { 251 | typ := message.msgtyp 252 | if typ == 0 { 253 | typ = websocket.TextMessage 254 | } 255 | if err := c.write(typ, data); err != nil { 256 | return 257 | } 258 | } 259 | } 260 | } 261 | } 262 | 263 | func (c *Connection) rlockUserIfExists() { 264 | if c.user == nil { 265 | return 266 | } 267 | 268 | c.user.RLock() 269 | } 270 | 271 | func (c *Connection) runlockUserIfExists() { 272 | if c.user == nil { 273 | return 274 | } 275 | 276 | c.user.RUnlock() 277 | } 278 | 279 | func (c *Connection) Emit(event string, data interface{}) { 280 | c.send <- &message{ 281 | event: event, 282 | data: data, 283 | } 284 | } 285 | 286 | func (c *Connection) EmitBlock(event string, data interface{}) { 287 | c.blocksend <- &message{ 288 | event: event, 289 | data: data, 290 | } 291 | } 292 | 293 | func (c *Connection) Broadcast(event string, data *EventDataOut) { 294 | c.rlockUserIfExists() 295 | marshalled, _ := Marshal(data) 296 | c.runlockUserIfExists() 297 | 298 | m := &message{ 299 | event: event, 300 | data: marshalled, 301 | } 302 | hub.broadcast <- m 303 | } 304 | 305 | func (c *Connection) canModerateUser(nick string) (bool, Userid) { 306 | if c.user == nil || utf8.RuneCountInString(nick) == 0 { 307 | return false, 0 308 | } 309 | 310 | uid, protected := usertools.getUseridForNick(nick) 311 | if uid == 0 || c.user.id == uid || protected { 312 | return false, uid 313 | } 314 | 315 | return true, uid 316 | } 317 | 318 | func (c *Connection) getEventDataOut() *EventDataOut { 319 | out := &EventDataOut{ 320 | Timestamp: unixMilliTime(), 321 | } 322 | if c.user != nil { 323 | out.SimplifiedUser = c.user.simplified 324 | } 325 | return out 326 | } 327 | 328 | func (c *Connection) Join() { 329 | if c.user != nil { 330 | c.rlockUserIfExists() 331 | defer c.runlockUserIfExists() 332 | n := atomic.LoadInt32(&c.user.connections) 333 | if n == 1 { 334 | c.Broadcast("JOIN", c.getEventDataOut()) 335 | } 336 | } 337 | } 338 | 339 | func (c *Connection) Quit() { 340 | if c.user != nil { 341 | c.rlockUserIfExists() 342 | defer c.runlockUserIfExists() 343 | n := atomic.LoadInt32(&c.user.connections) 344 | if n <= 0 { 345 | c.Broadcast("QUIT", c.getEventDataOut()) 346 | } 347 | } 348 | } 349 | 350 | func (c *Connection) OnBroadcast(data []byte) { 351 | m := &EventDataIn{} 352 | if err := Unmarshal(data, m); err != nil { 353 | c.SendError("protocolerror") 354 | return 355 | } 356 | 357 | if c.user == nil { 358 | c.SendError("needlogin") 359 | return 360 | } 361 | 362 | if !c.user.featureGet(ISADMIN) { 363 | c.SendError("nopermission") 364 | return 365 | } 366 | 367 | msg := strings.TrimSpace(m.Data) 368 | msglen := utf8.RuneCountInString(msg) 369 | if !utf8.ValidString(msg) || msglen == 0 || msglen > 512 || invalidmessage.MatchString(msg) { 370 | c.SendError("invalidmsg") 371 | return 372 | } 373 | 374 | out := c.getEventDataOut() 375 | out.Data = msg 376 | out.Entities = entities.Extract(msg) 377 | c.Broadcast("BROADCAST", out) 378 | } 379 | 380 | func (c *Connection) canMsg(msg string, ignoresilence bool) bool { 381 | msglen := utf8.RuneCountInString(msg) 382 | if !utf8.ValidString(msg) || msglen == 0 || msglen > 512 || invalidmessage.MatchString(msg) { 383 | c.SendError("invalidmsg") 384 | return false 385 | } 386 | 387 | if !ignoresilence { 388 | if mutes.isUserMuted(c) { 389 | c.SendError("muted") 390 | return false 391 | } 392 | 393 | if !hub.canUserSpeak(c) { 394 | c.SendError("submode") 395 | return false 396 | } 397 | } 398 | 399 | if c.user != nil && !c.user.isBot() { 400 | 401 | // very simple heuristics of "punishing" the flooding user 402 | // if the user keeps spamming, the delay between messages increases 403 | // this delay resets after a fixed amount of time 404 | now := time.Now() 405 | difference := now.Sub(c.user.lastmessagetime) 406 | switch { 407 | case difference <= DELAY: 408 | c.user.delayscale *= 2 409 | case difference > MAXTHROTTLETIME: 410 | c.user.delayscale = 1 411 | } 412 | sendtime := c.user.lastmessagetime.Add(time.Duration(c.user.delayscale) * DELAY) 413 | if sendtime.After(now) { 414 | c.SendError("throttled") 415 | return false 416 | } 417 | c.user.lastmessagetime = now 418 | 419 | } 420 | 421 | return true 422 | } 423 | 424 | func (c *Connection) OnMsg(data []byte) { 425 | m := &EventDataIn{} 426 | if err := Unmarshal(data, m); err != nil { 427 | c.SendError("protocolerror") 428 | return 429 | } 430 | 431 | if c.user == nil { 432 | c.SendError("needlogin") 433 | return 434 | } 435 | 436 | msg := strings.TrimSpace(m.Data) 437 | if !c.canMsg(msg, false) { 438 | return 439 | } 440 | 441 | // strip off /me for anti-spam purposes 442 | var bmsg []byte 443 | if len(msg) > 4 && msg[:4] == "/me " { 444 | bmsg = []byte(strings.TrimSpace(msg[4:])) 445 | } else { 446 | bmsg = []byte(msg) 447 | } 448 | 449 | tsum := md5.Sum(bmsg) 450 | sum := tsum[:] 451 | if bytes.Equal(sum, c.user.lastmessage) { 452 | c.user.delayscale++ 453 | c.SendError("duplicate") 454 | return 455 | } 456 | c.user.lastmessage = sum 457 | 458 | out := c.getEventDataOut() 459 | out.Data = msg 460 | out.Entities = entities.Extract(msg) 461 | 462 | if err := combos.Transform(out); err == ErrComboDuplicate { 463 | c.SendError("duplicate") 464 | return 465 | } 466 | TransformRares(out) 467 | 468 | c.Broadcast("MSG", out) 469 | } 470 | 471 | func (c *Connection) OnPrivmsg(data []byte) { 472 | pin := &PrivmsgIn{} 473 | if err := Unmarshal(data, pin); err != nil { 474 | c.SendError("protocolerror") 475 | return 476 | } 477 | 478 | if c.user == nil { 479 | c.SendError("needlogin") 480 | return 481 | } 482 | 483 | msg := pin.Data 484 | if !c.canMsg(msg, true) { 485 | return 486 | } 487 | 488 | tuid, _ := usertools.getUseridForNick(pin.Nick) 489 | if tuid == 0 || tuid == c.user.id { 490 | c.SendError("notfound") 491 | return 492 | } 493 | 494 | // ephemeral private messages 495 | // in particular, messages sent to users that are offline will never be delivered 496 | // TODO search db instead? -> can tell user that name is right, but just offline. 497 | 498 | pout := &PrivmsgOut{ 499 | message: message{ 500 | event: "PRIVMSG", 501 | }, 502 | Nick: c.user.nick, 503 | TargetNick: pin.Nick, 504 | targetuid: Userid(tuid), 505 | Data: msg, 506 | Messageid: 1337, // no saving in db means ids do not matter 507 | Timestamp: unixMilliTime(), 508 | Entities: entities.Extract(msg), 509 | } 510 | 511 | pout.message.data, _ = Marshal(pout) 512 | 513 | c.Emit("PRIVMSGSENT", pout) 514 | 515 | hub.privmsg <- pout 516 | } 517 | 518 | func (c *Connection) Names() { 519 | n := namescache.getNames() 520 | if string(n) == "" { // handle empty cache on very first connection. TODO: connectioncount? 521 | n = []byte("{}") 522 | } 523 | c.sendmarshalled <- &message{ 524 | event: "NAMES", 525 | data: n, 526 | } 527 | } 528 | 529 | func (c *Connection) OnMute(data []byte) { 530 | mute := &EventDataIn{} // Data is the nick 531 | if err := Unmarshal(data, mute); err != nil { 532 | c.SendError("protocolerror") 533 | return 534 | } 535 | 536 | if c.user == nil || !c.user.isModerator() { 537 | c.SendError("nopermission") 538 | return 539 | } 540 | 541 | ok, uid := c.canModerateUser(mute.Data) 542 | 543 | if !ok || uid == 0 { 544 | c.SendError("nopermission") 545 | return 546 | } 547 | 548 | if mute.Duration == 0 { 549 | mute.Duration = int64(DEFAULTMUTEDURATION) 550 | } 551 | 552 | if time.Duration(mute.Duration) > 7*24*time.Hour { 553 | c.SendError("protocolerror") // too long mute 554 | return 555 | } 556 | 557 | mutes.muteUserid(uid, mute.Duration) 558 | out := c.getEventDataOut() 559 | out.Data = mute.Data 560 | out.Targetuserid = uid 561 | c.Broadcast("MUTE", out) 562 | } 563 | 564 | func (c *Connection) OnUnmute(data []byte) { 565 | user := &EventDataIn{} // Data is the nick 566 | if err := Unmarshal(data, user); err != nil || utf8.RuneCountInString(user.Data) == 0 { 567 | c.SendError("protocolerror") 568 | return 569 | } 570 | 571 | if c.user == nil || !c.user.isModerator() { 572 | c.SendError("nopermission") 573 | return 574 | } 575 | 576 | uid, _ := usertools.getUseridForNick(user.Data) 577 | if uid == 0 { 578 | c.SendError("notfound") 579 | return 580 | } 581 | 582 | mutes.unmuteUserid(uid) 583 | out := c.getEventDataOut() 584 | out.Data = user.Data 585 | out.Targetuserid = uid 586 | c.Broadcast("UNMUTE", out) 587 | } 588 | 589 | func (c *Connection) Muted() { 590 | } 591 | 592 | func (c *Connection) OnBan(data []byte) { 593 | ban := &BanIn{} 594 | if err := Unmarshal(data, ban); err != nil { 595 | c.SendError("protocolerror") 596 | return 597 | } 598 | 599 | if c.user == nil { 600 | c.SendError("nopermission") 601 | return 602 | } 603 | 604 | if !c.user.isModerator() { 605 | c.SendError("nopermission") 606 | return 607 | } 608 | 609 | ok, uid := c.canModerateUser(ban.Nick) 610 | if uid == 0 { 611 | c.SendError("notfound") 612 | return 613 | } else if !ok { 614 | c.SendError("nopermission") 615 | return 616 | } 617 | 618 | reason := strings.TrimSpace(ban.Reason) 619 | if utf8.RuneCountInString(reason) == 0 || !utf8.ValidString(reason) { 620 | c.SendError("needbanreason") 621 | return 622 | } 623 | 624 | if ban.Duration == 0 { 625 | ban.Duration = int64(DEFAULTBANDURATION) 626 | } 627 | 628 | bans.banUser(c.user.id, uid, ban) 629 | out := c.getEventDataOut() 630 | out.Data = ban.Nick 631 | out.Targetuserid = uid 632 | c.Broadcast("BAN", out) 633 | } 634 | 635 | func (c *Connection) OnUnban(data []byte) { 636 | user := &EventDataIn{} 637 | if err := Unmarshal(data, user); err != nil { 638 | c.SendError("protocolerror") 639 | return 640 | } 641 | 642 | if c.user == nil || !c.user.isModerator() { 643 | c.SendError("nopermission") 644 | return 645 | } 646 | 647 | uid, _ := usertools.getUseridForNick(user.Data) 648 | if uid == 0 { 649 | c.SendError("notfound") 650 | return 651 | } 652 | 653 | bans.unbanUserid(uid) 654 | mutes.unmuteUserid(uid) 655 | out := c.getEventDataOut() 656 | out.Data = user.Data 657 | out.Targetuserid = uid 658 | c.Broadcast("UNBAN", out) 659 | } 660 | 661 | func (c *Connection) Banned() { 662 | c.banned <- true 663 | } 664 | 665 | func (c *Connection) OnSubonly(data []byte) { 666 | m := &EventDataIn{} // Data is on/off 667 | if err := Unmarshal(data, m); err != nil { 668 | c.SendError("protocolerror") 669 | return 670 | } 671 | 672 | if c.user == nil || !c.user.isModerator() { 673 | c.SendError("nopermission") 674 | return 675 | } 676 | 677 | switch { 678 | case m.Data == "on": 679 | hub.toggleSubmode(true) 680 | case m.Data == "off": 681 | hub.toggleSubmode(false) 682 | default: 683 | c.SendError("protocolerror") 684 | return 685 | } 686 | 687 | out := c.getEventDataOut() 688 | out.Data = m.Data 689 | c.Broadcast("SUBONLY", out) 690 | } 691 | 692 | func (c *Connection) Ping() { 693 | d := &PingOut{ 694 | time.Now().UnixNano(), 695 | } 696 | 697 | c.Emit("PING", d) 698 | } 699 | 700 | func (c *Connection) OnPing(data []byte) { 701 | c.Emit("PONG", data) 702 | } 703 | 704 | func (c *Connection) OnPong(data []byte) { 705 | } 706 | 707 | func (c *Connection) SendError(identifier string) { 708 | c.EmitBlock("ERR", identifier) 709 | } 710 | 711 | func (c *Connection) Refresh() { 712 | c.EmitBlock("REFRESH", c.getEventDataOut()) 713 | c.stop <- true 714 | } 715 | -------------------------------------------------------------------------------- /data.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "strings" 7 | ) 8 | 9 | func Unpack(data string) (string, []byte, error) { 10 | result := strings.SplitN(data, " ", 2) 11 | if len(result) != 2 { 12 | return "", nil, errors.New("Unable to extract event name from data.") 13 | } 14 | return result[0], []byte(result[1]), nil 15 | } 16 | 17 | func Unmarshal(data []byte, out interface{}) error { 18 | return json.Unmarshal(data, out) 19 | } 20 | 21 | func Marshal(out interface{}) ([]byte, error) { 22 | return json.Marshal(out) 23 | } 24 | 25 | func Pack(name string, data []byte) ([]byte, error) { 26 | result := []byte(name + " ") 27 | result = append(result, data...) 28 | return result, nil 29 | } 30 | -------------------------------------------------------------------------------- /database.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "io/ioutil" 6 | "strings" 7 | "sync" 8 | "time" 9 | 10 | "github.com/jmoiron/sqlx" 11 | _ "github.com/mattn/go-sqlite3" 12 | ) 13 | 14 | type database struct { 15 | db *sqlx.DB 16 | insertban chan *dbInsertBan 17 | deleteban chan *dbDeleteBan 18 | sync.Mutex 19 | } 20 | 21 | type dbInsertBan struct { 22 | uid Userid 23 | targetuid Userid 24 | ipaddress *sql.NullString 25 | reason string 26 | starttime int64 27 | endtime int64 28 | retries uint8 29 | } 30 | 31 | type dbDeleteBan struct { 32 | uid Userid 33 | } 34 | 35 | var db = &database{ 36 | insertban: make(chan *dbInsertBan, 10), 37 | deleteban: make(chan *dbDeleteBan, 10), 38 | } 39 | 40 | func initDatabase(dbfile string, init bool) { 41 | db.db = sqlx.MustConnect("sqlite3", dbfile) 42 | if init { 43 | sql, err := ioutil.ReadFile("db-init.sql") 44 | if err != nil { 45 | panic(err) 46 | } 47 | 48 | stmt, err := db.db.Prepare(string(sql)) 49 | if err != nil { 50 | panic(err) 51 | } 52 | 53 | stmt.Exec() 54 | stmt.Close() 55 | } 56 | 57 | bans.loadActive() 58 | go db.runInsertBan() // TODO ??? 59 | go db.runDeleteBan() 60 | } 61 | 62 | func (db *database) getStatement(name string, sql string) *sql.Stmt { 63 | db.Lock() 64 | stmt, err := db.db.Prepare(sql) 65 | db.Unlock() 66 | if err != nil { 67 | D("Unable to create", name, "statement:", err) 68 | time.Sleep(100 * time.Millisecond) 69 | return db.getStatement(name, sql) 70 | } 71 | return stmt 72 | } 73 | 74 | func (db *database) getInsertBanStatement() *sql.Stmt { 75 | return db.getStatement("insertBan", ` 76 | INSERT INTO bans 77 | VALUES ( 78 | ?, 79 | ?, 80 | ?, 81 | ?, 82 | ?, 83 | ? 84 | )`) 85 | } 86 | 87 | func (db *database) getDeleteBanStatement() *sql.Stmt { 88 | return db.getStatement("deleteBan", ` 89 | UPDATE bans 90 | SET endtimestamp = strftime('%s', 'now') 91 | WHERE 92 | targetuserid = ? AND 93 | ( 94 | endtimestamp IS NULL OR 95 | endtimestamp > strftime('%s', 'now') 96 | ) 97 | `) 98 | } 99 | 100 | func (db *database) runInsertBan() { 101 | t := time.NewTimer(time.Minute) 102 | stmt := db.getInsertBanStatement() 103 | for { 104 | select { 105 | case <-t.C: 106 | stmt.Close() 107 | stmt = nil 108 | case data := <-db.insertban: 109 | t.Reset(time.Minute) 110 | if stmt == nil { 111 | stmt = db.getInsertBanStatement() 112 | } 113 | if data.retries > 2 { 114 | continue 115 | } 116 | db.Lock() 117 | _, err := stmt.Exec(data.uid, data.targetuid, data.ipaddress, data.reason, data.starttime, data.endtime) 118 | db.Unlock() 119 | if err != nil { 120 | data.retries++ 121 | D("Unable to insert event", err) 122 | go (func() { 123 | db.insertban <- data 124 | })() 125 | } 126 | } 127 | } 128 | } 129 | 130 | func (db *database) runDeleteBan() { 131 | t := time.NewTimer(time.Minute) 132 | stmt := db.getDeleteBanStatement() 133 | for { 134 | select { 135 | case <-t.C: 136 | stmt.Close() 137 | stmt = nil 138 | case data := <-db.deleteban: 139 | t.Reset(time.Minute) 140 | if stmt == nil { 141 | stmt = db.getDeleteBanStatement() 142 | } 143 | db.Lock() 144 | _, err := stmt.Exec(data.uid) 145 | db.Unlock() 146 | if err != nil { 147 | D("Unable to insert event", err) 148 | go (func() { 149 | db.deleteban <- data 150 | })() 151 | } 152 | } 153 | } 154 | } 155 | 156 | func (db *database) insertBan(uid Userid, targetuid Userid, ban *BanIn, ip string) { 157 | ipaddress := &sql.NullString{} 158 | if ban.BanIP && len(ip) != 0 { 159 | ipaddress.String = ip 160 | ipaddress.Valid = true 161 | } 162 | 163 | starttime := time.Now().UTC() 164 | var endtimestamp int64 165 | 166 | if ban.Ispermanent { 167 | endtimestamp = getFuturetimeUTC().Unix() 168 | } else { 169 | endtimestamp = starttime.Add(time.Duration(ban.Duration)).Unix() 170 | } 171 | 172 | starttimestamp := starttime.Unix() 173 | 174 | db.insertban <- &dbInsertBan{uid, targetuid, ipaddress, ban.Reason, starttimestamp, endtimestamp, 0} 175 | } 176 | 177 | func (db *database) deleteBan(targetuid Userid) { 178 | db.deleteban <- &dbDeleteBan{targetuid} 179 | } 180 | 181 | func (db *database) getBans(f func(Userid, sql.NullString, time.Time)) { 182 | db.Lock() 183 | defer db.Unlock() 184 | 185 | rows, err := db.db.Query(` 186 | SELECT 187 | targetuserid, 188 | ipaddress, 189 | endtimestamp 190 | FROM bans 191 | WHERE 192 | endtimestamp IS NULL OR 193 | endtimestamp > strftime('%s', 'now') 194 | GROUP BY targetuserid, ipaddress 195 | `) 196 | if err != nil { 197 | D("Unable to get active bans: ", err) 198 | return 199 | } 200 | 201 | defer rows.Close() 202 | for rows.Next() { 203 | var uid Userid 204 | var ipaddress sql.NullString 205 | var endtimestamp time.Time 206 | var t int64 207 | err = rows.Scan(&uid, &ipaddress, &t) 208 | if err != nil { 209 | D("Unable to scan bans row: ", err) 210 | continue 211 | } 212 | 213 | endtimestamp = time.Unix(t, 0).UTC() 214 | 215 | f(uid, ipaddress, endtimestamp) 216 | } 217 | } 218 | 219 | func (db *database) getUser(nick string) (Userid, bool) { 220 | stmt := db.getStatement("getUser", ` 221 | SELECT 222 | u.userid, 223 | instr(u.features, 'admin') OR 224 | instr(u.features, 'protected') 225 | FROM users AS u 226 | WHERE u.nick = ? 227 | `) 228 | db.Lock() 229 | defer stmt.Close() 230 | defer db.Unlock() 231 | 232 | var uid int32 233 | var protected bool 234 | err := stmt.QueryRow(nick).Scan(&uid, &protected) 235 | if err != nil { 236 | D("error looking up", nick, err) 237 | return 0, false 238 | } 239 | return Userid(uid), protected 240 | } 241 | 242 | // TODO ... for uuid-id conversion 243 | func (db *database) getUserInfo(uuid string) ([]string, int, error) { 244 | stmt := db.getStatement("getUserInfo", ` 245 | SELECT 246 | userid, features 247 | FROM users 248 | WHERE uuid = ? 249 | `) 250 | db.Lock() 251 | defer stmt.Close() 252 | defer db.Unlock() 253 | 254 | var f string 255 | var uid int 256 | err := stmt.QueryRow(uuid).Scan(&uid, &f) 257 | if err != nil { 258 | D("features err", err) 259 | return []string{}, -1, err // TODO -1 implications... 260 | } 261 | features := strings.Split(f, ",") // TODO features are placed into db like this... 262 | return features, uid, nil 263 | } 264 | 265 | func (db *database) newUser(uuid string, name string, ip string) error { 266 | // TODO 267 | // chat-internal uid is autoincrement primary key... 268 | // UNIQUE check on uuid makes sure of no double intserts. 269 | stmt := db.getStatement("newUser", ` 270 | INSERT INTO users ( 271 | uuid, nick, features, firstlogin, lastlogin, lastip 272 | ) 273 | VALUES ( 274 | ?, ?, "", strftime('%s', 'now'), strftime('%s', 'now'), ? 275 | ) 276 | `) 277 | 278 | db.Lock() 279 | defer stmt.Close() 280 | defer db.Unlock() 281 | 282 | _, err := stmt.Exec(uuid, name, ip) 283 | if err != nil { 284 | D("newuser err", err) // TODO this is actually expected and normal for existing users... 285 | return err 286 | } 287 | 288 | return nil 289 | } 290 | 291 | func (db *database) updateUser(id Userid, name string, ip string) error { 292 | stmt := db.getStatement("updateUser", ` 293 | UPDATE users SET 294 | nick = ?, 295 | lastlogin = strftime('%s', 'now'), 296 | lastip = ? 297 | WHERE userid = ? 298 | `) 299 | db.Lock() 300 | defer stmt.Close() 301 | defer db.Unlock() 302 | 303 | _, err := stmt.Exec(name, ip, id) 304 | if err != nil { 305 | D("updateUser err", err) 306 | return err 307 | } 308 | 309 | return nil 310 | } 311 | -------------------------------------------------------------------------------- /db-init.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS users ( 2 | userid INTEGER PRIMARY KEY AUTOINCREMENT, /* legacy chat id */ 3 | uuid TEXT NOT NULL UNIQUE, /* strims id */ 4 | nick TEXT NOT NULL, 5 | features TEXT NOT NULL, /* array like "f1,f2" */ 6 | firstlogin INTEGER, /* unix epoch */ 7 | lastlogin INTEGER, /* unix epoch */ 8 | lastip TEXT NOT NULL 9 | ); 10 | 11 | CREATE TABLE IF NOT EXISTS bans ( 12 | userid INTEGER NOT NULL, /*TODO? userid cant be uniq because deleteBan does not delete row, just update expiretime to NOW. on reban we get another ban for same id */ 13 | targetuserid INTEGER NOT NULL, 14 | ipaddress TEXT, 15 | reason TEXT, 16 | starttimestamp INTEGER, /* unix epoch */ 17 | endtimestamp INTEGER /* unix epoch */ 18 | ); 19 | -------------------------------------------------------------------------------- /debug.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | //"github.com/emicklei/hopwatch" 7 | "bytes" 8 | "log" 9 | "runtime" 10 | "time" 11 | ) 12 | 13 | // source https://groups.google.com/forum/?fromgroups#!topic/golang-nuts/C24fRw8HDmI 14 | // from David Wright 15 | type ErrorTrace struct { 16 | err error 17 | trace string 18 | } 19 | 20 | func NewErrorTrace(v ...interface{}) error { 21 | msg := fmt.Sprint(v...) 22 | buf := bytes.Buffer{} 23 | skip := 2 24 | addtrace: 25 | pc, file, line, ok := runtime.Caller(skip) 26 | if ok && skip < 6 { // print a max of 6 lines of trace 27 | fun := runtime.FuncForPC(pc) 28 | buf.WriteString(fmt.Sprint(fun.Name(), " -- ", file, ":", line, "\n")) 29 | skip++ 30 | goto addtrace 31 | } 32 | 33 | if buf.Len() > 0 { 34 | trace := buf.String() 35 | return ErrorTrace{err: errors.New(msg), trace: trace} 36 | } 37 | return errors.New("error generating error") 38 | } 39 | 40 | func (et ErrorTrace) Error() string { 41 | return et.err.Error() + "\n " + et.trace 42 | } 43 | 44 | func B(v ...interface{}) { 45 | ts := time.Now().Format("2006-02-01 15:04:05: ") 46 | println(ts, NewErrorTrace(v...).Error()) 47 | } 48 | 49 | // Unused ... 50 | func F(v ...interface{}) { 51 | ts := time.Now().Format("2006-02-01 15:04:05: ") 52 | println(ts, NewErrorTrace(v...).Error()) 53 | panic("-----") 54 | } 55 | 56 | func D(v ...interface{}) { 57 | if debuggingenabled { 58 | formatstring := "" 59 | for range v { 60 | formatstring += " %+v" 61 | } 62 | log.Printf(formatstring, v...) 63 | } 64 | } 65 | 66 | func DP(v ...interface{}) { 67 | if debuggingenabled { 68 | formatstring := "" 69 | for range v { 70 | formatstring += " %+v" 71 | } 72 | log.Printf(formatstring, v...) 73 | } 74 | } 75 | 76 | func P(v ...interface{}) { 77 | formatstring := "" 78 | for range v { 79 | formatstring += " %+v" 80 | } 81 | log.Printf(formatstring, v...) 82 | } 83 | -------------------------------------------------------------------------------- /entities.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "regexp" 9 | "time" 10 | 11 | parser "github.com/MemeLabs/chat-parser" 12 | "mvdan.cc/xurls/v2" 13 | ) 14 | 15 | var entities *EntityExtractor 16 | 17 | func initEntities() { 18 | var err error 19 | entities, err = NewEntityExtractor() 20 | if err != nil { 21 | log.Fatal(err) 22 | } 23 | 24 | go entities.scheduleEmoteSync() 25 | } 26 | 27 | func loadEmoteManifest() (emotes, modifiers, tags []string, err error) { 28 | resp, err := http.Get(EMOTEMANIFEST) 29 | if err != nil { 30 | return nil, nil, nil, fmt.Errorf("failed to get emotes: %w", err) 31 | } 32 | defer resp.Body.Close() 33 | manifest := struct { 34 | Emotes []struct { 35 | Name string `json:"name"` 36 | } `json:"emotes"` 37 | Modifiers []string `json:"modifiers"` 38 | Tags []string `json:"tags"` 39 | }{} 40 | if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil { 41 | return nil, nil, nil, fmt.Errorf("failed to parse emotes manifest: %w", err) 42 | } 43 | 44 | emotes = make([]string, len(manifest.Emotes)) 45 | for i, e := range manifest.Emotes { 46 | emotes[i] = e.Name 47 | } 48 | return emotes, manifest.Modifiers, manifest.Tags, nil 49 | } 50 | 51 | func NewEntityExtractor() (*EntityExtractor, error) { 52 | emotes, modifiers, tags, err := loadEmoteManifest() 53 | if err != nil { 54 | log.Printf("failed to update emotes: %v", err) 55 | } 56 | 57 | return &EntityExtractor{ 58 | parserCtx: parser.NewParserContext(parser.ParserContextValues{ 59 | Emotes: emotes, 60 | Nicks: []string{}, 61 | Tags: tags, 62 | EmoteModifiers: modifiers, 63 | }), 64 | urls: xurls.Relaxed(), 65 | }, nil 66 | } 67 | 68 | type EntityExtractor struct { 69 | parserCtx *parser.ParserContext 70 | urls *regexp.Regexp 71 | } 72 | 73 | func (x *EntityExtractor) scheduleEmoteSync() { 74 | for range time.NewTicker(time.Minute).C { 75 | emotes, modifiers, tags, err := loadEmoteManifest() 76 | if err != nil { 77 | log.Printf("failed to update emotes: %v", err) 78 | continue 79 | } 80 | x.parserCtx.Emotes.Replace(parser.RunesFromStrings(emotes)) 81 | x.parserCtx.EmoteModifiers.Replace(parser.RunesFromStrings(modifiers)) 82 | x.parserCtx.Tags.Replace(parser.RunesFromStrings(tags)) 83 | } 84 | } 85 | 86 | func (x *EntityExtractor) AddNick(nick string) { 87 | x.parserCtx.Nicks.Insert([]rune(nick)) 88 | } 89 | 90 | func (x *EntityExtractor) RemoveNick(nick string) { 91 | x.parserCtx.Nicks.Remove([]rune(nick)) 92 | } 93 | 94 | func (x *EntityExtractor) Extract(msg string) *Entities { 95 | e := &Entities{} 96 | 97 | for _, b := range x.urls.FindAllStringIndex(msg, -1) { 98 | e.Links = append(e.Links, &Link{ 99 | URL: msg[b[0]:b[1]], 100 | Bounds: [2]int{b[0], b[1]}, 101 | }) 102 | } 103 | 104 | addEntitiesFromSpan(e, parser.NewParser(x.parserCtx, parser.NewLexer(msg)).ParseMessage()) 105 | 106 | return e 107 | } 108 | 109 | func addEntitiesFromSpan(e *Entities, span *parser.Span) { 110 | switch span.Type { 111 | case parser.SpanCode: 112 | e.Codes = append(e.Codes, &Code{ 113 | Bounds: [2]int{span.Pos(), span.End()}, 114 | }) 115 | case parser.SpanSpoiler: 116 | e.Spoilers = append(e.Spoilers, &Spoiler{ 117 | Bounds: [2]int{span.Pos(), span.End()}, 118 | }) 119 | case parser.SpanGreentext: 120 | e.Greentext = &Generic{ 121 | Bounds: [2]int{span.Pos(), span.End()}, 122 | } 123 | case parser.SpanMe: 124 | e.Me = &Generic{ 125 | Bounds: [2]int{span.Pos(), span.End()}, 126 | } 127 | } 128 | 129 | EachNode: 130 | for _, ni := range span.Nodes { 131 | for _, l := range e.Links { 132 | if l.Bounds[0] <= ni.Pos() && l.Bounds[1] >= ni.End() { 133 | continue EachNode 134 | } 135 | } 136 | 137 | switch n := ni.(type) { 138 | case *parser.Emote: 139 | e.Emotes = append(e.Emotes, &Emote{ 140 | Name: n.Name, 141 | Modifiers: n.Modifiers, 142 | Bounds: [2]int{n.Pos(), n.End()}, 143 | }) 144 | case *parser.Nick: 145 | e.Nicks = append(e.Nicks, &Nick{ 146 | Nick: n.Nick, 147 | Bounds: [2]int{n.Pos(), n.End()}, 148 | }) 149 | case *parser.Tag: 150 | e.Tags = append(e.Tags, &Tag{ 151 | Name: n.Name, 152 | Bounds: [2]int{n.Pos(), n.End()}, 153 | }) 154 | case *parser.Span: 155 | addEntitiesFromSpan(e, n) 156 | } 157 | } 158 | } 159 | 160 | type Link struct { 161 | URL string `json:"url,omitempty"` 162 | Bounds [2]int `json:"bounds,omitempty"` 163 | } 164 | 165 | type Emote struct { 166 | Name string `json:"name,omitempty"` 167 | Modifiers []string `json:"modifiers,omitempty"` 168 | Bounds [2]int `json:"bounds,omitempty"` 169 | Combo int `json:"combo,omitempty"` 170 | } 171 | 172 | type Nick struct { 173 | Nick string `json:"nick,omitempty"` 174 | Bounds [2]int `json:"bounds,omitempty"` 175 | } 176 | 177 | type Tag struct { 178 | Name string `json:"name,omitempty"` 179 | Bounds [2]int `json:"bounds,omitempty"` 180 | } 181 | 182 | type Code struct { 183 | Bounds [2]int `json:"bounds,omitempty"` 184 | } 185 | 186 | type Spoiler struct { 187 | Bounds [2]int `json:"bounds,omitempty"` 188 | } 189 | 190 | type Generic struct { 191 | Bounds [2]int `json:"bounds,omitempty"` 192 | } 193 | 194 | type Entities struct { 195 | Links []*Link `json:"links,omitempty"` 196 | Emotes []*Emote `json:"emotes,omitempty"` 197 | Nicks []*Nick `json:"nicks,omitempty"` 198 | Tags []*Tag `json:"tags,omitempty"` 199 | Codes []*Code `json:"codes,omitempty"` 200 | Spoilers []*Spoiler `json:"spoilers,omitempty"` 201 | Greentext *Generic `json:"greentext,omitempty"` 202 | Me *Generic `json:"me,omitempty"` 203 | } 204 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/MemeLabs/chat-backend 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/MemeLabs/chat-parser v1.0.1 7 | github.com/dgrijalva/jwt-go v3.2.0+incompatible 8 | github.com/gorilla/websocket v1.4.2 9 | github.com/jmoiron/sqlx v1.2.0 10 | github.com/mattn/go-sqlite3 v2.0.3+incompatible 11 | github.com/msbranco/goconfig v0.0.0-20160629072055-3189001257ce 12 | google.golang.org/appengine v1.6.5 // indirect 13 | mvdan.cc/xurls/v2 v2.2.0 14 | ) 15 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/MemeLabs/chat-parser v1.0.1 h1:NXbrnXIHxpQ/AhSx8YpTuIngBBmyaAnoujhLDKIDknY= 2 | github.com/MemeLabs/chat-parser v1.0.1/go.mod h1:sJ6/lyctAYBGJrxNS649FT/ujrUTTvgznHnfAZOqDvA= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= 6 | github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= 7 | github.com/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw= 8 | github.com/go-sql-driver/mysql v1.4.0 h1:7LxgVwFb2hIQtMm87NdgAVfXjnt4OePseqT1tKx+opk= 9 | github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= 10 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 11 | github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= 12 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 13 | github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA= 14 | github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= 15 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 16 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 17 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 18 | github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A= 19 | github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 20 | github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= 21 | github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U= 22 | github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= 23 | github.com/msbranco/goconfig v0.0.0-20160629072055-3189001257ce h1:QtMvEL/+svm0fxbpyRZS0aquv34BCfMwksEE+2aTLZU= 24 | github.com/msbranco/goconfig v0.0.0-20160629072055-3189001257ce/go.mod h1:PKNAOitD7HlXaDyVXgbpVnof2Th8T7Glv5Wz0lJLCEg= 25 | github.com/rogpeppe/go-internal v1.5.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 26 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 27 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 28 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 29 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 30 | golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= 31 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 32 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 33 | google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM= 34 | google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 35 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 36 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 37 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 38 | mvdan.cc/xurls/v2 v2.1.0 h1:KaMb5GLhlcSX+e+qhbRJODnUUBvlw01jt4yrjFIHAuA= 39 | mvdan.cc/xurls/v2 v2.1.0/go.mod h1:5GrSd9rOnKOpZaji1OZLYL/yeAAtGDlo/cFe+8K5n8E= 40 | mvdan.cc/xurls/v2 v2.2.0 h1:NSZPykBXJFCetGZykLAxaL6SIpvbVy/UFEniIfHAa8A= 41 | mvdan.cc/xurls/v2 v2.2.0/go.mod h1:EV1RMtya9D6G5DMYPGD8zTQzaHet6Jh8gFlRgGRJeO8= 42 | -------------------------------------------------------------------------------- /hub.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type Hub struct { 8 | connections map[*Connection]bool 9 | broadcast chan *message 10 | privmsg chan *PrivmsgOut 11 | register chan *Connection 12 | unregister chan *Connection 13 | bans chan Userid 14 | ipbans chan string 15 | getips chan useridips 16 | users map[Userid]*User 17 | refreshuser chan Userid 18 | } 19 | 20 | type useridips struct { 21 | userid Userid 22 | c chan []string 23 | } 24 | 25 | var hub = Hub{ 26 | connections: make(map[*Connection]bool), 27 | broadcast: make(chan *message, BROADCASTCHANNELSIZE), 28 | privmsg: make(chan *PrivmsgOut, BROADCASTCHANNELSIZE), 29 | register: make(chan *Connection, 256), 30 | unregister: make(chan *Connection), 31 | bans: make(chan Userid, 4), 32 | ipbans: make(chan string, 4), 33 | getips: make(chan useridips), 34 | users: make(map[Userid]*User), 35 | refreshuser: make(chan Userid, 4), 36 | } 37 | 38 | func (hub *Hub) run() { 39 | pinger := time.NewTicker(PINGINTERVAL) 40 | 41 | for { 42 | select { 43 | case c := <-hub.register: 44 | hub.connections[c] = true 45 | case c := <-hub.unregister: 46 | delete(hub.connections, c) 47 | case userid := <-hub.refreshuser: 48 | for c, _ := range hub.connections { 49 | if c.user != nil && c.user.id == userid { 50 | go c.Refresh() 51 | } 52 | } 53 | case userid := <-hub.bans: 54 | for c, _ := range hub.connections { 55 | if c.user != nil && c.user.id == userid { 56 | go c.Banned() 57 | } 58 | } 59 | case stringip := <-hub.ipbans: 60 | for c := range hub.connections { 61 | if c.ip == stringip { 62 | DP("Found connection to ban with ip", stringip, "user", c.user) 63 | go c.Banned() 64 | } 65 | } 66 | case d := <-hub.getips: 67 | ips := make([]string, 0, 3) 68 | for c, _ := range hub.connections { 69 | if c.user != nil && c.user.id == d.userid { 70 | ips = append(ips, c.ip) 71 | } 72 | } 73 | d.c <- ips 74 | case message := <-hub.broadcast: 75 | // TODO should be channel, could lock up... 76 | // TODO save into state in case of restart?? 77 | if message.event != "JOIN" && message.event != "QUIT" && message.event != "VIEWERSTATE" { 78 | cacheChatEvent(message) 79 | } 80 | 81 | for c := range hub.connections { 82 | if len(c.sendmarshalled) < SENDCHANNELSIZE { 83 | c.sendmarshalled <- message 84 | } 85 | } 86 | case p := <-hub.privmsg: 87 | for c, _ := range hub.connections { 88 | if c.user != nil && c.user.id == p.targetuid { 89 | if len(c.sendmarshalled) < SENDCHANNELSIZE { 90 | c.sendmarshalled <- &p.message 91 | } 92 | } 93 | } 94 | // timeout handling 95 | case t := <-pinger.C: 96 | for c := range hub.connections { 97 | if c.ping != nil && len(c.ping) < 2 { 98 | c.ping <- t 99 | } else if c.ping != nil { 100 | close(c.ping) 101 | c.ping = nil 102 | } 103 | } 104 | } 105 | } 106 | } 107 | 108 | // TODO redis replacement 109 | func cacheChatEvent(msg *message) { 110 | if len(MSGCACHE) <= 0 { 111 | return 112 | } 113 | 114 | MSGLOCK.Lock() 115 | defer MSGLOCK.Unlock() 116 | 117 | if len(MSGCACHE) >= MSGCACHESIZE { 118 | MSGCACHE = MSGCACHE[1:] 119 | } 120 | 121 | data, err := Pack(msg.event, msg.data.([]byte)) 122 | if err != nil { 123 | D("cacheChatEvent pack error", err) 124 | return 125 | } 126 | 127 | MSGCACHE = append(MSGCACHE, string(data[:])) 128 | } 129 | 130 | // TODO 131 | func getCache() []string { 132 | MSGLOCK.RLock() 133 | defer MSGLOCK.RUnlock() 134 | 135 | out := []string{} 136 | for _, v := range MSGCACHE { 137 | if v != "" { 138 | out = append(out, v) 139 | } 140 | } 141 | 142 | return out 143 | } 144 | 145 | func (hub *Hub) getIPsForUserid(userid Userid) []string { 146 | c := make(chan []string, 1) 147 | hub.getips <- useridips{userid, c} 148 | return <-c 149 | } 150 | 151 | func (hub *Hub) canUserSpeak(c *Connection) bool { 152 | state.RLock() 153 | defer state.RUnlock() 154 | 155 | if !state.submode || c.user.isSubscriber() { 156 | return true 157 | } 158 | 159 | return false 160 | } 161 | 162 | func (hub *Hub) toggleSubmode(enabled bool) { 163 | state.Lock() 164 | defer state.Unlock() 165 | 166 | state.submode = enabled 167 | state.save() 168 | } 169 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Based on https://github.com/trevex/golem 3 | Licensed under the Apache License, Version 2.0 4 | http://www.apache.org/licenses/LICENSE-2.0.html 5 | */ 6 | package main 7 | 8 | import ( 9 | "bytes" 10 | "encoding/gob" 11 | "encoding/json" 12 | _ "expvar" 13 | "fmt" 14 | "io/ioutil" 15 | "log" 16 | "net" 17 | "net/http" 18 | "runtime" 19 | "strconv" 20 | "sync" 21 | "time" 22 | 23 | jwt "github.com/dgrijalva/jwt-go" 24 | "github.com/gorilla/websocket" 25 | conf "github.com/msbranco/goconfig" 26 | ) 27 | 28 | type State struct { 29 | mutes map[Userid]time.Time 30 | submode bool 31 | sync.RWMutex 32 | } 33 | 34 | var state = &State{mutes: make(map[Userid]time.Time)} 35 | 36 | const ( 37 | WRITETIMEOUT = 10 * time.Second 38 | READTIMEOUT = time.Minute 39 | PINGINTERVAL = 10 * time.Second 40 | PINGTIMEOUT = 30 * time.Second 41 | MAXMESSAGESIZE = 6144 // 512 max chars in a message, 8bytes per chars possible, plus factor in some protocol overhead 42 | SENDCHANNELSIZE = 16 43 | BROADCASTCHANNELSIZE = 256 44 | DEFAULTBANDURATION = time.Hour 45 | DEFAULTMUTEDURATION = 10 * time.Minute 46 | ) 47 | 48 | var ( 49 | debuggingenabled = false 50 | DELAY = 300 * time.Millisecond 51 | MAXTHROTTLETIME = 5 * time.Minute 52 | JWTSECRET = "" 53 | JWTCOOKIENAME = "jwt" 54 | APIUSERID = "" 55 | USERNAMEAPI = "http://localhost:8076/api/username/" 56 | VIEWERSTATEAPI = "http://localhost:8076/api/admin/viewer-state" 57 | MSGCACHE = []string{} // TODO redis replacement... 58 | MSGCACHESIZE = 150 59 | MSGLOCK sync.RWMutex 60 | RARECHANCE = 0.00001 61 | EMOTEMANIFEST = "http://localhost:18078/emote-manifest.json" 62 | ) 63 | 64 | func main() { 65 | c, err := conf.ReadConfigFile("settings.cfg") 66 | if err != nil { 67 | nc := conf.NewConfigFile() 68 | nc.AddOption("default", "debug", "false") 69 | nc.AddOption("default", "listenaddress", ":9998") 70 | nc.AddOption("default", "maxprocesses", "0") 71 | nc.AddOption("default", "chatdelay", fmt.Sprintf("%d", 300*time.Millisecond)) 72 | nc.AddOption("default", "maxthrottletime", fmt.Sprintf("%d", 5*time.Minute)) 73 | nc.AddOption("default", "dbfile", "chatbackend.sqlite") 74 | nc.AddOption("default", "jwtcookiename", JWTCOOKIENAME) 75 | nc.AddOption("default", "jwtsecret", "") 76 | nc.AddOption("default", "apiuserid", "") 77 | nc.AddOption("default", "usernameapi", USERNAMEAPI) 78 | nc.AddOption("default", "viewerstateapi", VIEWERSTATEAPI) 79 | nc.AddOption("default", "messagecachesize", "150") 80 | nc.AddOption("default", "rarechance", strconv.FormatFloat(RARECHANCE, 'f', -1, 64)) 81 | nc.AddOption("default", "emotemanifest", EMOTEMANIFEST) 82 | nc.AddOption("default", "initdb", "false") 83 | 84 | if err = nc.WriteConfigFile("settings.cfg", 0644, "ChatBackend"); err != nil { 85 | log.Fatal("Unable to create settings.cfg: ", err) 86 | } 87 | if c, err = conf.ReadConfigFile("settings.cfg"); err != nil { 88 | log.Fatal("Unable to read settings.cfg: ", err) 89 | } 90 | } 91 | 92 | debuggingenabled, _ = c.GetBool("default", "debug") 93 | addr, _ := c.GetString("default", "listenaddress") 94 | processes, _ := c.GetInt64("default", "maxprocesses") 95 | delay, _ := c.GetInt64("default", "chatdelay") 96 | maxthrottletime, _ := c.GetInt64("default", "maxthrottletime") 97 | dbfile, _ := c.GetString("default", "dbfile") 98 | initdb, _ := c.GetBool("default", "initdb") 99 | DELAY = time.Duration(delay) 100 | MAXTHROTTLETIME = time.Duration(maxthrottletime) 101 | JWTSECRET, _ = c.GetString("default", "jwtsecret") 102 | JWTCOOKIENAME, _ = c.GetString("default", "jwtcookiename") 103 | APIUSERID, _ = c.GetString("default", "apiuserid") 104 | USERNAMEAPI, _ = c.GetString("default", "usernameapi") 105 | VIEWERSTATEAPI, _ = c.GetString("default", "viewerstateapi") 106 | msgcachesize, _ := c.GetInt64("default", "messagecachesize") 107 | RARECHANCE, _ = c.GetFloat("default", "rarechance") 108 | EMOTEMANIFEST, _ = c.GetString("default", "emotemanifest") 109 | 110 | if JWTSECRET == "" { 111 | JWTSECRET = "PepoThink" 112 | fmt.Println("Insecurely using default JWT secret") 113 | } 114 | if msgcachesize >= 0 { 115 | MSGCACHESIZE = int(msgcachesize) 116 | } 117 | MSGCACHE = make([]string, MSGCACHESIZE) 118 | 119 | if processes <= 0 { 120 | processes = int64(runtime.NumCPU()) 121 | } 122 | runtime.GOMAXPROCS(int(processes)) 123 | 124 | state.load() 125 | initDatabase(dbfile, initdb) 126 | initEntities() 127 | 128 | go hub.run() 129 | go bans.run() 130 | go viewerStates.run() 131 | 132 | var checkOrigin func(r *http.Request) bool 133 | if debuggingenabled { 134 | checkOrigin = func(r *http.Request) bool { return true } 135 | } 136 | 137 | upgrader := websocket.Upgrader{ 138 | ReadBufferSize: 1024, 139 | WriteBufferSize: 1024, 140 | CheckOrigin: checkOrigin, 141 | } 142 | 143 | // TODO hacked in api for compat 144 | http.HandleFunc("/api/chat/me", func(w http.ResponseWriter, r *http.Request) { 145 | if r.Method != "GET" { 146 | http.Error(w, "Method not allowed", 405) 147 | return 148 | } 149 | 150 | jwtcookie, err := r.Cookie(JWTCOOKIENAME) 151 | if err != nil { 152 | http.Error(w, "Not logged in", 401) 153 | return 154 | } 155 | claims, err := parseJwt(jwtcookie.Value) 156 | if err != nil { 157 | http.Error(w, "Not logged in", 401) 158 | return 159 | } 160 | username, err := userFromAPI(claims.UserId) 161 | if err != nil { 162 | http.Error(w, "Really makes you think", 401) 163 | return 164 | } 165 | 166 | w.Header().Set("Content-Type", "application/json") 167 | w.Write([]byte(fmt.Sprintf(`{"username":"%s", "nick":"%s"}`, username, username))) 168 | }) 169 | 170 | // TODO cache foo 171 | http.HandleFunc("/api/chat/history", func(w http.ResponseWriter, r *http.Request) { 172 | if r.Method != "GET" { 173 | http.Error(w, "Method not allowed", 405) 174 | return 175 | } 176 | 177 | w.Header().Set("Content-Type", "application/json") 178 | history, err := json.Marshal(getCache()) 179 | if err != nil { 180 | http.Error(w, "", 500) 181 | return 182 | } 183 | w.Write(history) 184 | }) 185 | 186 | // TODO cache foo 187 | http.HandleFunc("/api/chat/viewer-states", func(w http.ResponseWriter, r *http.Request) { 188 | if r.Method != "GET" { 189 | http.Error(w, "Method not allowed", 405) 190 | return 191 | } 192 | 193 | w.Header().Set("Content-Type", "application/json") 194 | if err := json.NewEncoder(w).Encode(viewerStates.DumpChanges()); err != nil { 195 | http.Error(w, "[]", 500) 196 | } 197 | }) 198 | 199 | http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { 200 | if r.Method != "GET" { 201 | http.Error(w, "Method not allowed", 405) 202 | return 203 | } 204 | 205 | ws, err := upgrader.Upgrade(w, r, nil) 206 | if err != nil { 207 | return 208 | } 209 | 210 | user, banned, ip := getUserFromWebRequest(r) 211 | 212 | if banned { 213 | ws.SetWriteDeadline(time.Now().Add(WRITETIMEOUT)) 214 | ws.WriteMessage(websocket.TextMessage, []byte(`ERR "banned"`)) 215 | return 216 | } 217 | 218 | newConnection(ws, user, ip) 219 | }) 220 | 221 | fmt.Printf("Using %v threads, and listening on: %v\n", processes, addr) 222 | if err := http.ListenAndServe(addr, nil); err != nil { 223 | log.Fatal("ListenAndServe: ", err) 224 | } 225 | } 226 | 227 | func getMaskedIP(s string) string { 228 | ipv6mask := net.CIDRMask(64, 128) 229 | ip := net.ParseIP(s) 230 | if ip.To4() == nil { 231 | return ip.Mask(ipv6mask).String() 232 | } 233 | return s 234 | } 235 | 236 | func unixMilliTime() int64 { 237 | return time.Now().UTC().Truncate(time.Millisecond).UnixNano() / int64(time.Millisecond) 238 | } 239 | 240 | // expecting the argument to be in UTC 241 | func isExpiredUTC(t time.Time) bool { 242 | return t.Before(time.Now().UTC()) 243 | } 244 | 245 | func addDurationUTC(d time.Duration) time.Time { 246 | return time.Now().UTC().Add(d) 247 | } 248 | 249 | func getFuturetimeUTC() time.Time { 250 | return time.Date(2030, time.January, 1, 0, 0, 0, 0, time.UTC) 251 | } 252 | 253 | func (s *State) load() { 254 | s.Lock() 255 | defer s.Unlock() 256 | 257 | b, err := ioutil.ReadFile(".state.dc") 258 | if err != nil { 259 | D("Error while reading from states file", err) 260 | return 261 | } 262 | mb := bytes.NewBuffer(b) 263 | dec := gob.NewDecoder(mb) 264 | err = dec.Decode(&s.mutes) 265 | if err != nil { 266 | D("Error decoding mutes from states file", err) 267 | } 268 | err = dec.Decode(&s.submode) 269 | if err != nil { 270 | D("Error decoding submode from states file", err) 271 | } 272 | } 273 | 274 | // expects to be called with locks held 275 | func (s *State) save() { 276 | mb := new(bytes.Buffer) 277 | enc := gob.NewEncoder(mb) 278 | err := enc.Encode(&s.mutes) 279 | if err != nil { 280 | D("Error encoding mutes:", err) 281 | } 282 | err = enc.Encode(&s.submode) 283 | if err != nil { 284 | D("Error encoding submode:", err) 285 | } 286 | 287 | err = ioutil.WriteFile(".state.dc", mb.Bytes(), 0600) 288 | if err != nil { 289 | D("Error with writing out state file:", err) 290 | } 291 | } 292 | 293 | func createAPIJWT(userID string) (string, error) { 294 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ 295 | "id": userID, 296 | "exp": time.Now().Add(time.Hour).Unix(), 297 | }) 298 | return token.SignedString([]byte(JWTSECRET)) 299 | } 300 | -------------------------------------------------------------------------------- /mutes.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type Mutes int 8 | 9 | var mutes Mutes 10 | 11 | func (m *Mutes) clean() { 12 | state.Lock() 13 | defer state.Unlock() 14 | 15 | for uid, unmutetime := range state.mutes { 16 | if isExpiredUTC(unmutetime) { 17 | delete(state.mutes, uid) 18 | } 19 | } 20 | state.save() 21 | } 22 | 23 | func (m *Mutes) muteUserid(uid Userid, duration int64) { 24 | state.Lock() 25 | defer state.Unlock() 26 | 27 | state.mutes[uid] = time.Now().UTC().Add(time.Duration(duration)) 28 | state.save() 29 | } 30 | 31 | func (m *Mutes) unmuteUserid(uid Userid) { 32 | state.Lock() 33 | defer state.Unlock() 34 | 35 | delete(state.mutes, uid) 36 | state.save() 37 | } 38 | 39 | func (m *Mutes) isUserMuted(c *Connection) bool { 40 | if c.user == nil { 41 | return true 42 | } 43 | 44 | state.Lock() 45 | defer state.Unlock() 46 | 47 | t, ok := state.mutes[c.user.id] 48 | if !ok { 49 | return false 50 | } 51 | return !isExpiredUTC(t) 52 | } 53 | -------------------------------------------------------------------------------- /mutes_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestMuteTimes(t *testing.T) { 9 | timeinfuture := time.Date(time.Now().Year()+1, time.September, 10, 23, 0, 0, 0, time.UTC) 10 | timeinpast := time.Date(time.Now().Year()-1, time.September, 10, 23, 0, 0, 0, time.UTC) 11 | uid := Userid(1) 12 | c := new(Connection) 13 | c.user = &User{} 14 | c.user.id = uid 15 | 16 | state.mutes[uid] = timeinfuture 17 | if !mutes.isUserMuted(c) { 18 | t.Error("user should be banned because the expiretime is in the future") 19 | } 20 | state.mutes[uid] = timeinpast 21 | if mutes.isUserMuted(c) { 22 | t.Error("user should NOT be banned because the expiretime is in the past") 23 | } 24 | 25 | mutes.clean() 26 | if len(state.mutes) > 0 { 27 | t.Error("mutes.clean did not clean the users") 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /namescache.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "sync" 6 | "sync/atomic" 7 | ) 8 | 9 | type namesCache struct { 10 | users map[Userid]*User 11 | marshallednames []byte 12 | connectioncount uint32 13 | ircnames [][]string 14 | sync.RWMutex 15 | } 16 | 17 | type namesOut struct { 18 | Users []*SimplifiedUser `json:"users"` 19 | Connections uint32 `json:"connectioncount"` 20 | } 21 | 22 | var namescache = namesCache{ 23 | users: make(map[Userid]*User), 24 | RWMutex: sync.RWMutex{}, 25 | } 26 | 27 | func (nc *namesCache) updateNames() { 28 | users := make([]*SimplifiedUser, 0, len(nc.users)) 29 | 30 | for _, u := range nc.users { 31 | u.RLock() 32 | n := atomic.LoadInt32(&u.connections) 33 | u.RUnlock() 34 | if n <= 0 { 35 | // should not happen anymore since we remove users with 0 connections now. 36 | continue 37 | } 38 | users = append(users, u.simplified) 39 | } 40 | 41 | n := namesOut{ 42 | Users: users, 43 | Connections: nc.connectioncount, 44 | } 45 | 46 | var err error 47 | nc.marshallednames, err = json.Marshal(n) 48 | if err != nil { 49 | B(err) 50 | } 51 | } 52 | 53 | func (nc *namesCache) getNames() []byte { 54 | nc.RLock() 55 | defer nc.RUnlock() 56 | return nc.marshallednames 57 | } 58 | 59 | func (nc *namesCache) get(id Userid) *User { 60 | nc.RLock() 61 | defer nc.RUnlock() 62 | u := nc.users[id] 63 | return u 64 | } 65 | 66 | func (nc *namesCache) add(user *User) *User { 67 | nc.Lock() 68 | defer nc.Unlock() 69 | 70 | nc.connectioncount++ 71 | if u, ok := nc.users[user.id]; ok { 72 | atomic.AddInt32(&u.connections, 1) 73 | } else { 74 | atomic.AddInt32(&user.connections, 1) 75 | su := &SimplifiedUser{ 76 | Nick: user.nick, 77 | Features: user.simplified.Features, 78 | } 79 | user.simplified = su 80 | nc.users[user.id] = user 81 | entities.AddNick(user.nick) 82 | } 83 | 84 | nc.updateNames() 85 | return nc.users[user.id] 86 | } 87 | 88 | func (nc *namesCache) disconnect(user *User) { 89 | nc.Lock() 90 | defer nc.Unlock() 91 | 92 | if user != nil { 93 | nc.connectioncount-- 94 | if u, ok := nc.users[user.id]; ok { 95 | conncount := atomic.AddInt32(&u.connections, -1) 96 | if conncount <= 0 { 97 | delete(nc.users, user.id) 98 | entities.RemoveNick(u.nick) 99 | } 100 | } 101 | 102 | } else { 103 | nc.connectioncount-- 104 | } 105 | nc.updateNames() 106 | } 107 | 108 | func (nc *namesCache) refresh(user *User) { 109 | nc.RLock() 110 | defer nc.RUnlock() 111 | 112 | if u, ok := nc.users[user.id]; ok { 113 | u.Lock() 114 | u.simplified.Nick = user.nick 115 | u.simplified.Features = user.simplified.Features 116 | u.nick = user.nick 117 | u.features = user.features 118 | u.Unlock() 119 | nc.updateNames() 120 | } 121 | } 122 | 123 | func (nc *namesCache) addConnection() { 124 | nc.Lock() 125 | defer nc.Unlock() 126 | nc.connectioncount++ 127 | nc.updateNames() 128 | } 129 | -------------------------------------------------------------------------------- /namescache_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "sync" 5 | "testing" 6 | ) 7 | 8 | func TestNamescacheRefresh(t *testing.T) { 9 | initEntities() 10 | 11 | uid := Userid(1) 12 | 13 | u := &User{} 14 | u.id = uid 15 | u.nick = "testnick" 16 | u.setFeatures([]string{"admin", "moderator", "protected", "subscriber", "vip", "bot"}) 17 | u.assembleSimplifiedUser() 18 | 19 | nc := &namesCache{ 20 | users: make(map[Userid]*User), 21 | RWMutex: sync.RWMutex{}, 22 | } 23 | 24 | nc.add(u) 25 | 26 | if nu, ok := nc.users[uid]; ok { 27 | if nu.connections != 1 { 28 | t.Errorf("Usercount was not 1 but %v, %+v", u.connections, u) 29 | } 30 | if len(*nu.simplified.Features) != 6 { 31 | t.Errorf("Simplified user features length was not 6 %+v", nu.simplified.Features) 32 | } 33 | } else { 34 | t.Errorf("Namescache did not have user %+v", nu) 35 | } 36 | 37 | u = &User{} 38 | u.id = uid 39 | u.nick = "NEWNICK" 40 | u.setFeatures([]string{"protected"}) 41 | u.assembleSimplifiedUser() 42 | 43 | nc.refresh(u) 44 | 45 | if nu, ok := nc.users[uid]; ok { 46 | if nu.nick != "NEWNICK" { 47 | t.Errorf("Users refresh did not succeed, nick was %+v", nu.nick) 48 | } 49 | if len(*nu.simplified.Features) != 1 { 50 | t.Errorf("Simplified user features length was not 1 %+v", nu.simplified.Features) 51 | } 52 | } else { 53 | t.Errorf("Namescache did not have user %+v", nu) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /rares.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "math/rand" 5 | ) 6 | 7 | func TransformRares(msg *EventDataOut) { 8 | if rand.Float64() > RARECHANCE { 9 | return 10 | } 11 | 12 | for _, e := range msg.Entities.Emotes { 13 | e.Modifiers = append(e.Modifiers, "rare") 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /user.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io/ioutil" 8 | "net" 9 | "net/http" 10 | "strconv" 11 | "strings" 12 | "sync" 13 | "time" 14 | 15 | jwt "github.com/dgrijalva/jwt-go" 16 | ) 17 | 18 | type userTools struct { 19 | nicklookup map[string]*uidprot 20 | nicklock sync.RWMutex 21 | featurelock sync.RWMutex 22 | features map[uint32][]string 23 | } 24 | 25 | var usertools = userTools{nicklookup: make(map[string]*uidprot), nicklock: sync.RWMutex{}, featurelock: sync.RWMutex{}, features: make(map[uint32][]string)} 26 | 27 | const ( 28 | ISADMIN = 1 << iota 29 | ISMODERATOR = 1 << iota 30 | ISVIP = 1 << iota 31 | ISPROTECTED = 1 << iota 32 | ISSUBSCRIBER = 1 << iota 33 | ISBOT = 1 << iota 34 | ) 35 | 36 | type uidprot struct { 37 | id Userid 38 | protected bool 39 | } 40 | 41 | func (ut *userTools) getUseridForNick(nick string) (Userid, bool) { 42 | ut.nicklock.RLock() 43 | d, ok := uidprot{}, false // ut.nicklookup[strings.ToLower(nick)] //TODO reimplement... 44 | if !ok { 45 | uid, protected := db.getUser(nick) 46 | if uid != 0 { 47 | ut.nicklock.RUnlock() 48 | ut.nicklock.Lock() 49 | ut.nicklookup[strings.ToLower(nick)] = &uidprot{uid, protected} 50 | ut.nicklock.Unlock() 51 | return uid, protected 52 | } 53 | ut.nicklock.RUnlock() 54 | return 0, false 55 | } 56 | ut.nicklock.RUnlock() 57 | return d.id, d.protected 58 | } 59 | 60 | func (ut *userTools) addUser(u *User, force bool) { 61 | lowernick := strings.ToLower(u.nick) 62 | if !force { 63 | ut.nicklock.RLock() 64 | _, ok := ut.nicklookup[lowernick] 65 | ut.nicklock.RUnlock() 66 | if ok { 67 | return 68 | } 69 | } 70 | ut.nicklock.Lock() 71 | defer ut.nicklock.Unlock() 72 | ut.nicklookup[lowernick] = &uidprot{u.id, u.isProtected()} 73 | } 74 | 75 | type Userid int32 76 | 77 | type User struct { 78 | id Userid 79 | nick string 80 | features uint32 81 | lastmessage []byte // TODO remove? 82 | lastmessagetime time.Time 83 | delayscale uint8 84 | simplified *SimplifiedUser 85 | connections int32 86 | sync.RWMutex 87 | } 88 | 89 | type UserClaims struct { 90 | UserId string `json:"id"` // TODO from rustla2 backend impl 91 | jwt.StandardClaims 92 | } 93 | 94 | // TODO 95 | func parseJwt(cookie string) (*UserClaims, error) { 96 | // verify jwt cookie - https://godoc.org/github.com/dgrijalva/jwt-go#example-Parse--Hmac 97 | token, err := jwt.ParseWithClaims(cookie, &UserClaims{}, func(token *jwt.Token) (interface{}, error) { 98 | return []byte(JWTSECRET), nil 99 | }) 100 | if err != nil { 101 | return nil, errors.New("Token invalid") 102 | } 103 | 104 | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { 105 | return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) 106 | } 107 | 108 | claims, ok := token.Claims.(*UserClaims) // TODO 109 | 110 | if !ok || !token.Valid { 111 | return nil, errors.New("Token invalid") 112 | } 113 | 114 | return claims, nil 115 | } 116 | 117 | // TODO 118 | func userFromAPI(uuid string) (username string, err error) { 119 | // TODO here we trusted signed id in claims json is well-formed uuid... 120 | 121 | // TODO check exp-time as the backend does! (or not?) -- {"id":"uuid","exp":futurts} 122 | 123 | if err != nil { 124 | fmt.Println("err1", uuid) 125 | return "", err 126 | } 127 | 128 | // TODO - get username from api 129 | type un struct { 130 | Username string `json:"username"` 131 | } 132 | 133 | resp, err := http.Get(fmt.Sprintf("%s%s", USERNAMEAPI, uuid)) 134 | if err != nil { 135 | return "", err 136 | } 137 | 138 | body, err := ioutil.ReadAll(resp.Body) 139 | if err != nil { 140 | fmt.Println("err2", err) 141 | return "", err 142 | } 143 | 144 | response := un{} 145 | err = json.Unmarshal(body, &response) 146 | if err != nil { 147 | fmt.Println("err3", err) 148 | return "", err 149 | } 150 | 151 | D("username parsed:", response) 152 | if response.Username == "" { 153 | return "", errors.New("User needs to set a username") 154 | } 155 | 156 | return response.Username, nil 157 | } 158 | 159 | func userfromCookie(cookie string, ip string) (u *User, err error) { 160 | // TODO remoteaddr in go contains port - now we use the header that doesnt... TODO standardize... 161 | // ip = strings.Split(ip, ":")[0] 162 | 163 | claims, err := parseJwt(cookie) 164 | if err != nil { 165 | return nil, err 166 | } 167 | 168 | username, err := userFromAPI(claims.UserId) 169 | if err != nil { 170 | return nil, err 171 | } 172 | 173 | // if user not found, insert new user into db 174 | 175 | // ignoring the error for now 176 | db.newUser(claims.UserId, username, ip) 177 | // TODO err is expected for non-new users... 178 | 179 | // now get features from db, update stuff - TODO 180 | 181 | features, uid, err := db.getUserInfo(claims.UserId) 182 | if err != nil { 183 | fmt.Println("err4", err) 184 | return nil, err 185 | } 186 | 187 | // finally update records... 188 | db.updateUser(Userid(uid), username, ip) 189 | 190 | u = &User{ 191 | id: Userid(uid), 192 | nick: username, 193 | features: 0, 194 | lastmessage: nil, 195 | lastmessagetime: time.Time{}, 196 | delayscale: 1, 197 | simplified: nil, 198 | connections: 0, 199 | RWMutex: sync.RWMutex{}, 200 | } 201 | 202 | // init features finally - CASE SENSITIVE. TODO. 203 | u.setFeatures(features) 204 | 205 | forceupdate := false 206 | if cu := namescache.get(u.id); cu != nil && cu.features == u.features { 207 | forceupdate = true 208 | } 209 | 210 | u.assembleSimplifiedUser() 211 | usertools.addUser(u, forceupdate) 212 | return u, nil 213 | } 214 | 215 | func (u *User) featureGet(bitnum uint32) bool { 216 | return ((u.features & bitnum) != 0) 217 | } 218 | 219 | func (u *User) featureSet(bitnum uint32) { 220 | u.features |= bitnum 221 | } 222 | 223 | func (u *User) featureCount() (c uint8) { 224 | // Counting bits set, Brian Kernighan's way 225 | v := u.features 226 | for c = 0; v != 0; c++ { 227 | v &= v - 1 // clear the least significant bit set 228 | } 229 | return 230 | } 231 | 232 | // isModerator checks if the user can use mod commands 233 | func (u *User) isModerator() bool { 234 | return u.featureGet(ISMODERATOR | ISADMIN) 235 | } 236 | 237 | // isSubscriber checks if the user can speak when the chat is in submode 238 | func (u *User) isSubscriber() bool { 239 | return u.featureGet(ISSUBSCRIBER | ISADMIN | ISMODERATOR | ISVIP | ISBOT) 240 | } 241 | 242 | // isBot checks if the user is exempt from ratelimiting 243 | func (u *User) isBot() bool { 244 | return u.featureGet(ISBOT) 245 | } 246 | 247 | // isProtected checks if the user can be moderated or not 248 | func (u *User) isProtected() bool { 249 | return u.featureGet(ISADMIN | ISPROTECTED) 250 | } 251 | 252 | func (u *User) setFeatures(features []string) { 253 | for _, feature := range features { 254 | switch feature { 255 | case "admin": 256 | u.featureSet(ISADMIN) 257 | case "moderator": 258 | u.featureSet(ISMODERATOR) 259 | case "protected": 260 | u.featureSet(ISPROTECTED) 261 | case "subscriber": 262 | u.featureSet(ISSUBSCRIBER) 263 | case "vip": 264 | u.featureSet(ISVIP) 265 | case "bot": 266 | u.featureSet(ISBOT) 267 | case "": 268 | continue 269 | default: // flairNN for future flairs 270 | if feature[:5] == "flair" { 271 | flair, err := strconv.Atoi(feature[5:]) 272 | if err != nil { 273 | D("Could not parse unknown feature:", feature, err) 274 | continue 275 | } 276 | // six proper features, all others are just useless flairs 277 | u.featureSet(1 << (6 + uint8(flair))) 278 | } 279 | } 280 | } 281 | } 282 | 283 | func (u *User) assembleSimplifiedUser() { 284 | usertools.featurelock.RLock() 285 | f, ok := usertools.features[u.features] 286 | usertools.featurelock.RUnlock() 287 | 288 | if !ok { 289 | usertools.featurelock.Lock() 290 | defer usertools.featurelock.Unlock() 291 | 292 | numfeatures := u.featureCount() 293 | f = make([]string, 0, numfeatures) 294 | if u.featureGet(ISPROTECTED) { 295 | f = append(f, "protected") 296 | } 297 | if u.featureGet(ISSUBSCRIBER) { 298 | f = append(f, "subscriber") 299 | } 300 | if u.featureGet(ISVIP) { 301 | f = append(f, "vip") 302 | } 303 | if u.featureGet(ISMODERATOR) { 304 | f = append(f, "moderator") 305 | } 306 | if u.featureGet(ISADMIN) { 307 | f = append(f, "admin") 308 | } 309 | if u.featureGet(ISBOT) { 310 | f = append(f, "bot") 311 | } 312 | 313 | for i := uint8(6); i <= 26; i++ { 314 | if u.featureGet(1 << i) { 315 | flair := fmt.Sprintf("flair%d", i-6) 316 | f = append(f, flair) 317 | } 318 | } 319 | 320 | usertools.features[u.features] = f 321 | } 322 | 323 | u.simplified = &SimplifiedUser{ 324 | u.nick, 325 | &f, 326 | } 327 | } 328 | 329 | func getUserFromWebRequest(r *http.Request) (user *User, banned bool, ip string) { 330 | // TODO make this an option? - need this if run behind e.g. nginx 331 | // TODO test 332 | ip = r.Header.Get("X-Forwarded-For") 333 | if ip == "" { 334 | ip, _, _ = net.SplitHostPort(r.RemoteAddr) 335 | } 336 | 337 | ip = getMaskedIP(ip) 338 | banned = bans.isIPBanned(ip) 339 | if banned { 340 | return 341 | } 342 | 343 | jwtcookie, err := r.Cookie(JWTCOOKIENAME) 344 | if err != nil { 345 | return 346 | } 347 | 348 | user, err = userfromCookie(jwtcookie.Value, ip) 349 | if err != nil || user == nil { 350 | B(err) 351 | return 352 | } 353 | 354 | banned = bans.isUseridBanned(user.id) 355 | if banned { 356 | return 357 | } 358 | 359 | // there is only ever one single "user" struct, the namescache makes sure of that 360 | user = namescache.add(user) 361 | return 362 | } 363 | -------------------------------------------------------------------------------- /users_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func init() { 8 | initDatabase(":memory:", true) 9 | } 10 | 11 | /* 12 | // this needs to be fixed along with `nicklookup` 13 | 14 | func TestUserLookup(t *testing.T) { 15 | uid := Userid(1) 16 | nick := "testnick" 17 | 18 | u := &User{} 19 | u.id = uid 20 | u.nick = nick 21 | 22 | usertools.addUser(u, true) 23 | if r, _ := usertools.getUseridForNick(nick); r != uid { 24 | t.Error("usertools.adduser failed, returned uid was: ", r, "(expected:", uid, ") for nick: ", nick) 25 | } 26 | 27 | nick = "TESTNICK" 28 | if r, _ := usertools.getUseridForNick(nick); r != uid { 29 | t.Error("usertools.adduser failed, returned uid was: ", r, "(expected:", uid, ") for nick: ", nick) 30 | } 31 | } 32 | */ 33 | 34 | func TestFeatures(t *testing.T) { 35 | uid := Userid(1) 36 | nick := "testnick" 37 | 38 | u := &User{} 39 | u.id = uid 40 | u.nick = nick 41 | 42 | if u.featureGet(ISPROTECTED) { 43 | t.Error("feature should not be set") 44 | } 45 | if u.featureGet(ISSUBSCRIBER) { 46 | t.Error("feature should not be set") 47 | } 48 | if u.featureGet(ISVIP) { 49 | t.Error("feature should not be set") 50 | } 51 | if u.featureGet(ISMODERATOR) { 52 | t.Error("feature should not be set") 53 | } 54 | if u.featureGet(ISADMIN) { 55 | t.Error("feature should not be set") 56 | } 57 | if u.featureGet(ISBOT) { 58 | t.Error("feature should not be set") 59 | } 60 | for i := uint8(6); i <= 28; i++ { 61 | if u.featureGet(1 << i) { 62 | t.Error("feature should not be set") 63 | } 64 | } 65 | if u.isProtected() { 66 | t.Error("should not be protected") 67 | } 68 | if u.isBot() { 69 | t.Error("should not be bot") 70 | } 71 | if u.isSubscriber() { 72 | t.Error("should not be subscriber") 73 | } 74 | if u.isModerator() { 75 | t.Error("should not be moderator") 76 | } 77 | 78 | //-------- 79 | features := []string{"admin", "moderator", "protected", "subscriber", "vip", "bot"} 80 | u.setFeatures(features) 81 | 82 | if !u.featureGet(ISPROTECTED) { 83 | t.Error("feature should be set") 84 | } 85 | if !u.featureGet(ISSUBSCRIBER) { 86 | t.Error("feature should be set") 87 | } 88 | if !u.featureGet(ISVIP) { 89 | t.Error("feature should be set") 90 | } 91 | if !u.featureGet(ISMODERATOR) { 92 | t.Error("feature should be set") 93 | } 94 | if !u.featureGet(ISADMIN) { 95 | t.Error("feature should be set") 96 | } 97 | if !u.featureGet(ISBOT) { 98 | t.Error("feature should be set") 99 | } 100 | for i := uint8(6); i <= 28; i++ { 101 | if u.featureGet(1 << i) { 102 | t.Error("feature should not be set") 103 | } 104 | } 105 | if !u.isProtected() { 106 | t.Error("should be protected") 107 | } 108 | if !u.isBot() { 109 | t.Error("should be bot") 110 | } 111 | if !u.isSubscriber() { 112 | t.Error("should be subscriber") 113 | } 114 | if !u.isModerator() { 115 | t.Error("should be moderator") 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /viewerstate.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "sync" 10 | "time" 11 | ) 12 | 13 | // NewViewerStateStore create new ViewerStateStore 14 | func NewViewerStateStore() *ViewerStateStore { 15 | return &ViewerStateStore{ 16 | viewerStatesLock: &sync.RWMutex{}, 17 | viewerStates: make(map[string]*ViewerState), 18 | notifyChansLock: &sync.Mutex{}, 19 | notifyChans: []chan *ViewerStateChange{}, 20 | } 21 | } 22 | 23 | var viewerStates = NewViewerStateStore() 24 | 25 | // ViewerStateStore viewer states synced with rustla api 26 | type ViewerStateStore struct { 27 | viewerStatesLock *sync.RWMutex 28 | viewerStates map[string]*ViewerState 29 | notifyChansLock *sync.Mutex 30 | notifyChans []chan *ViewerStateChange 31 | } 32 | 33 | // ViewerStateChange state change event emitted when viewer changes channels, 34 | // comes online or goes offline 35 | type ViewerStateChange struct { 36 | Nick string `json:"nick"` 37 | Online bool `json:"online"` 38 | Channel *StreamChannel `json:"channel,omitempty"` 39 | } 40 | 41 | // ViewerState rustla api viewer state record 42 | type ViewerState struct { 43 | UserID string `json:"user_id"` 44 | Name string `json:"name"` 45 | Online bool `json:"online"` 46 | EnablePublicState bool `json:"enable_public_state"` 47 | StreamID uint64 `json:"stream_id"` 48 | Channel *StreamChannel `json:"channel"` 49 | } 50 | 51 | // Equals check if two ViewerStates are identical 52 | func (s *ViewerState) Equals(o *ViewerState) bool { 53 | if s == nil || o == nil { 54 | return s == o 55 | } 56 | return s.Online == o.Online && s.EnablePublicState == o.EnablePublicState && s.Channel.Equals(o.Channel) 57 | } 58 | 59 | // StreamChannel rustla channel definition 60 | type StreamChannel struct { 61 | Channel string `json:"channel"` 62 | Service string `json:"service"` 63 | Path string `json:"path"` 64 | } 65 | 66 | // Equals check if two channels are identical 67 | func (s *StreamChannel) Equals(o *StreamChannel) bool { 68 | if s == nil || o == nil { 69 | return s == o 70 | } 71 | return s.Channel == o.Channel && s.Service == o.Service && s.Path == o.Path 72 | } 73 | 74 | func (v *ViewerStateStore) run() { 75 | // run rustla api reader with retries on failure 76 | go func() { 77 | for { 78 | if err := v.sync(); err != nil { 79 | log.Printf("error syncing viewer state: %s", err) 80 | } 81 | time.Sleep(time.Second * 30) 82 | } 83 | }() 84 | 85 | // broadcast state change events to chat 86 | go func() { 87 | changes := make(chan *ViewerStateChange, 4) 88 | v.NotifyChange(changes) 89 | 90 | for c := range changes { 91 | data, err := json.Marshal(c) 92 | if err != nil { 93 | continue 94 | } 95 | 96 | hub.broadcast <- &message{ 97 | event: "VIEWERSTATE", 98 | data: data, 99 | } 100 | } 101 | }() 102 | } 103 | 104 | func (v *ViewerStateStore) sync() error { 105 | req, err := http.NewRequest("GET", VIEWERSTATEAPI, nil) 106 | if err != nil { 107 | return fmt.Errorf("creating http request: %w", err) 108 | } 109 | 110 | jwt, err := createAPIJWT(APIUSERID) 111 | if err != nil { 112 | return fmt.Errorf("creating api jwt: %w", err) 113 | } 114 | req.AddCookie(&http.Cookie{ 115 | Name: JWTCOOKIENAME, 116 | Value: jwt, 117 | }) 118 | 119 | client := &http.Client{ 120 | Transport: http.DefaultTransport, 121 | Timeout: 0, 122 | } 123 | res, err := client.Do(req) 124 | if err != nil { 125 | return fmt.Errorf("executing http request: %w", err) 126 | } 127 | 128 | r := bufio.NewReader(res.Body) 129 | for { 130 | line, err := r.ReadBytes('\n') 131 | if err != nil { 132 | return err 133 | } 134 | state := &ViewerState{} 135 | if err := json.Unmarshal(line, state); err != nil { 136 | return fmt.Errorf("parsing viewer state: %w", err) 137 | } 138 | v.updatePublicState(state) 139 | } 140 | } 141 | 142 | func (v *ViewerStateStore) updatePublicState(state *ViewerState) { 143 | v.viewerStatesLock.Lock() 144 | defer v.viewerStatesLock.Unlock() 145 | 146 | prev, ok := v.viewerStates[state.UserID] 147 | if !state.EnablePublicState || !state.Online { 148 | if ok { 149 | delete(v.viewerStates, state.UserID) 150 | v.emitChange(&ViewerStateChange{ 151 | Nick: state.Name, 152 | Online: false, 153 | }) 154 | } 155 | return 156 | } 157 | 158 | if prev.Equals(state) { 159 | return 160 | } 161 | 162 | v.viewerStates[state.UserID] = state 163 | v.emitChange(&ViewerStateChange{ 164 | Nick: state.Name, 165 | Online: true, 166 | Channel: state.Channel, 167 | }) 168 | } 169 | 170 | func (v *ViewerStateStore) emitChange(c *ViewerStateChange) { 171 | v.notifyChansLock.Lock() 172 | defer v.notifyChansLock.Unlock() 173 | for _, ch := range v.notifyChans { 174 | ch <- c 175 | } 176 | } 177 | 178 | // NotifyChange register channel to be notified when viewer state changes 179 | func (v *ViewerStateStore) NotifyChange(ch chan *ViewerStateChange) { 180 | v.notifyChansLock.Lock() 181 | defer v.notifyChansLock.Unlock() 182 | v.notifyChans = append(v.notifyChans, ch) 183 | } 184 | 185 | // DumpChanges dump store to a slice for client sync via http api 186 | func (v *ViewerStateStore) DumpChanges() []ViewerStateChange { 187 | v.viewerStatesLock.RLock() 188 | defer v.viewerStatesLock.RUnlock() 189 | 190 | changes := make([]ViewerStateChange, 0, len(v.viewerStates)) 191 | for _, state := range v.viewerStates { 192 | changes = append(changes, ViewerStateChange{ 193 | Nick: state.Name, 194 | Online: true, 195 | Channel: state.Channel, 196 | }) 197 | } 198 | 199 | return changes 200 | } 201 | --------------------------------------------------------------------------------