├── .env ├── pkg ├── config │ ├── testdata │ │ ├── invalid.yaml │ │ ├── limits.yaml │ │ └── testdata.yaml │ ├── config.go │ └── config_test.go ├── nats │ └── nats.go └── redis │ └── redis.go ├── .gitignore ├── up ├── cmd └── goch │ ├── Dockerfile │ ├── conf.local.yaml │ └── main.go ├── .travis.yml ├── limit.go ├── user.go ├── test.sh ├── message_test.go ├── message.go ├── go.mod ├── docker-compose.yml ├── LICENSE ├── internal ├── ingest │ ├── ingest.go │ └── ingest_test.go ├── agent │ ├── api.go │ └── agent.go ├── broker │ ├── broker.go │ └── broker_test.go └── chat │ ├── chat.go │ └── chat_test.go ├── chat.go ├── README.md ├── chat_test.go └── go.sum /.env: -------------------------------------------------------------------------------- 1 | ADMIN_USERNAME=admin 2 | ADMIN_PASSWORD=pass -------------------------------------------------------------------------------- /pkg/config/testdata/invalid.yaml: -------------------------------------------------------------------------------- 1 | invalid config format -------------------------------------------------------------------------------- /pkg/config/testdata/limits.yaml: -------------------------------------------------------------------------------- 1 | limits: 2 | 1: [3,128] -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !Dockerfile 3 | !up 4 | !*.* 5 | !*/ 6 | *.sw? 7 | .DS_Store 8 | *-data/ 9 | *.test 10 | *.out -------------------------------------------------------------------------------- /up: -------------------------------------------------------------------------------- 1 | cd cmd/goch 2 | GOOS=linux CGO_ENABLED=0 go build -o goch . 3 | echo "Starting goch..." 4 | docker-compose up --build -------------------------------------------------------------------------------- /cmd/goch/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM yikaus/alpine-bash 2 | RUN apk update && apk add ca-certificates && rm -rf /var/cache/apk/* 3 | ADD goch / 4 | ENTRYPOINT /goch -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | sudo: required 3 | go: 4 | - "1.12" 5 | before_install: 6 | - go get -t -v ./... 7 | 8 | script: 9 | - ./test.sh 10 | 11 | after_success: 12 | - bash <(curl -s https://codecov.io/bash) -------------------------------------------------------------------------------- /limit.go: -------------------------------------------------------------------------------- 1 | package goch 2 | 3 | // Limit represents limit type 4 | type Limit int 5 | 6 | // Limit constants 7 | const ( 8 | DisplayNameLimit Limit = iota + 1 9 | UIDLimit 10 | SecretLimit 11 | ChanLimit 12 | ChanSecretLimit 13 | ) 14 | -------------------------------------------------------------------------------- /user.go: -------------------------------------------------------------------------------- 1 | package goch 2 | 3 | // User represents user entity 4 | type User struct { 5 | UID string `json:"uid"` 6 | DisplayName string `json:"display_name"` 7 | Email string `json:"email"` 8 | Secret string `json:"secret"` 9 | } 10 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | echo "" > coverage.txt 5 | 6 | for d in $(go list ./...); do 7 | go test -race -coverprofile=profile.out -covermode=atomic "$d" 8 | if [ -f profile.out ]; then 9 | cat profile.out >> coverage.txt 10 | rm profile.out 11 | fi 12 | done -------------------------------------------------------------------------------- /cmd/goch/conf.local.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 8080 3 | 4 | redis: 5 | address: redis 6 | port: 6379 7 | 8 | nats: 9 | cluster_id: test-cluster 10 | client_id: test-client 11 | url: nats://nats_stream:4222 12 | 13 | limits: 14 | 1: [3,128] 15 | 2: [20,20] 16 | 3: [20,50] 17 | 4: [10,20] 18 | 5: [20,20] -------------------------------------------------------------------------------- /pkg/config/testdata/testdata.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 8080 3 | 4 | redis: 5 | address: test.com 6 | port: 6379 7 | 8 | nats: 9 | cluster_id: test-cluster 10 | client_id: test-client 11 | url: test-url 12 | 13 | limits: 14 | 1: [3,128] 15 | 2: [20,20] 16 | 3: [20,50] 17 | 4: [10,20] 18 | 5: [20,20] -------------------------------------------------------------------------------- /message_test.go: -------------------------------------------------------------------------------- 1 | package goch_test 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/ribice/goch" 8 | ) 9 | 10 | func TestEncodeMSG(t *testing.T) { 11 | m := &goch.Message{Time: 123, Seq: 1, Text: "Hello World", FromUID: "ABC", FromName: "User1"} 12 | bts, err := m.Encode() 13 | if err != nil { 14 | t.Errorf("did not expect error but received: %v", err) 15 | } 16 | msg, err := goch.DecodeMsg(bts) 17 | if err != nil { 18 | t.Errorf("did not expect error but received: %v", err) 19 | } 20 | if !reflect.DeepEqual(m, msg) { 21 | t.Errorf("expected msg %v but got %v", m, msg) 22 | } 23 | 24 | _, err = goch.DecodeMsg([]byte("msg")) 25 | if err == nil { 26 | t.Error("expected error but received nil") 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /message.go: -------------------------------------------------------------------------------- 1 | package goch 2 | 3 | import ( 4 | "github.com/vmihailenco/msgpack" 5 | ) 6 | 7 | // Message represents chat message 8 | type Message struct { 9 | Meta map[string]string `json:"meta"` 10 | Time int64 `json:"time"` 11 | Seq uint64 `json:"seq"` 12 | Text string `json:"text"` 13 | FromUID string `json:"from_uid"` 14 | FromName string `json:"from_name"` 15 | } 16 | 17 | // DecodeMsg tries to decode binary formatted message in b to Message 18 | func DecodeMsg(b []byte) (*Message, error) { 19 | var msg Message 20 | err := msgpack.Unmarshal(b, &msg) 21 | return &msg, err 22 | } 23 | 24 | // Encode encodes provided chat Message in binary format 25 | func (m *Message) Encode() ([]byte, error) { 26 | return msgpack.Marshal(m) 27 | } 28 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ribice/goch 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/go-redis/redis v6.15.2+incompatible 7 | github.com/gorilla/mux v1.7.1 8 | github.com/gorilla/websocket v1.4.0 9 | github.com/nats-io/gnatsd v1.4.1 // indirect 10 | github.com/nats-io/go-nats v1.7.2 // indirect 11 | github.com/nats-io/go-nats-streaming v0.4.2 12 | github.com/nats-io/nats-server v1.4.1 // indirect 13 | github.com/nats-io/nats-streaming-server v0.15.1 // indirect 14 | github.com/onsi/ginkgo v1.8.0 // indirect 15 | github.com/onsi/gomega v1.5.0 // indirect 16 | github.com/ribice/msv v0.0.0-20190710162041-f63af07e33fc 17 | github.com/rs/xid v1.2.1 18 | github.com/stretchr/testify v1.3.0 19 | github.com/vmihailenco/msgpack v4.0.4+incompatible 20 | golang.org/x/net v0.0.0-20190420063019-afa5a82059c6 // indirect 21 | gopkg.in/yaml.v2 v2.2.2 22 | ) 23 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | # TODO 4 | # - push to docker hub via wercker and use here 5 | 6 | services: 7 | goch: 8 | build: cmd/goch 9 | ports: 10 | - "80:8080" 11 | depends_on: 12 | - redis 13 | - nats_stream 14 | links: 15 | - nats_stream 16 | - redis 17 | # restart: always 18 | volumes: 19 | - ./cmd/goch:/opt/conf 20 | entrypoint: 21 | - /goch 22 | - -config 23 | - /opt/conf/conf.local.yaml 24 | env_file: 25 | - .env 26 | nats_stream: 27 | image: nats-streaming 28 | # restart: always 29 | ports: 30 | - "8222:8222" 31 | volumes: 32 | - ./nats-data:/data 33 | entrypoint: 34 | - /nats-streaming-server 35 | - --http_port 36 | - '8222' 37 | - -store 38 | - file 39 | - -dir 40 | - data 41 | - --max_channels 42 | - '0' 43 | redis: 44 | image: redis:alpine 45 | # restart: always 46 | command: ["redis-server", "--appendonly", "yes"] 47 | working_dir: /db 48 | volumes: 49 | - ./redis-data:/db -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Emir Ribić 4 | Copyright (c) 2018 Anes Hasičić 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /cmd/goch/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | 7 | "github.com/ribice/msv/middleware/bauth" 8 | 9 | "github.com/ribice/goch/internal/chat" 10 | 11 | "github.com/ribice/goch/internal/agent" 12 | 13 | "github.com/ribice/goch/internal/broker" 14 | "github.com/ribice/goch/internal/ingest" 15 | 16 | "github.com/ribice/goch/pkg/config" 17 | 18 | "github.com/ribice/goch/pkg/nats" 19 | "github.com/ribice/goch/pkg/redis" 20 | "github.com/ribice/msv" 21 | ) 22 | 23 | func main() { 24 | cfgPath := flag.String("config", "./conf.yaml", "Path to config file") 25 | flag.Parse() 26 | cfg, err := config.Load(*cfgPath) 27 | checkErr(err) 28 | mq, err := nats.New(cfg.NATS.ClusterID, cfg.NATS.ClientID, cfg.NATS.URL) 29 | checkErr(err) 30 | store, err := redis.New(cfg.Redis.Address, cfg.Redis.Password, cfg.Redis.Port) 31 | checkErr(err) 32 | 33 | srv, mux := msv.New("goch") 34 | aMW := bauth.New(cfg.Admin.Username, cfg.Admin.Password, "GOCH") 35 | 36 | agent.NewAPI(mux, broker.New(mq, store, ingest.New(mq, store)), store, cfg) 37 | chat.New(mux, store, cfg, aMW.MWFunc) 38 | 39 | srv.Start() 40 | } 41 | 42 | func checkErr(err error) { 43 | if err != nil { 44 | log.Fatal(err) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /internal/ingest/ingest.go: -------------------------------------------------------------------------------- 1 | // Package ingest provides functionality for 2 | // updating per chat read models (recent history) 3 | package ingest 4 | 5 | import ( 6 | "fmt" 7 | "io" 8 | "time" 9 | 10 | "github.com/ribice/goch" 11 | ) 12 | 13 | // New creates new ingest instance 14 | func New(mq MQ, s ChatStore) *Ingest { 15 | return &Ingest{ 16 | mq: mq, 17 | store: s, 18 | } 19 | } 20 | 21 | // Ingest represents chat ingester 22 | type Ingest struct { 23 | mq MQ 24 | store ChatStore 25 | } 26 | 27 | // MQ represents ingest message queue interface 28 | type MQ interface { 29 | SubscribeQueue(string, func(uint64, []byte)) (io.Closer, error) 30 | } 31 | 32 | // ChatStore represents chat store interface 33 | type ChatStore interface { 34 | AppendMessage(string, *goch.Message) error 35 | } 36 | 37 | // Run subscribes to ingest queue group and updates chat read model 38 | func (i *Ingest) Run(id string) (func(), error) { 39 | closer, err := i.mq.SubscribeQueue( 40 | "chat."+id, 41 | func(seq uint64, data []byte) { 42 | msg, err := goch.DecodeMsg(data) 43 | if err != nil { 44 | msg = &goch.Message{ 45 | FromUID: "ingest", 46 | Text: "ingest: message unavailable: decoding error", 47 | Time: time.Now().UnixNano(), 48 | } 49 | } 50 | 51 | msg.Seq = seq 52 | // TODO: Handle error via ACK 53 | i.store.AppendMessage(id, msg) 54 | }, 55 | ) 56 | 57 | if err != nil { 58 | return nil, fmt.Errorf("ingest: couldn't subscribe: %v", err) 59 | } 60 | 61 | return func() { closer.Close() }, nil 62 | } 63 | -------------------------------------------------------------------------------- /pkg/nats/nats.go: -------------------------------------------------------------------------------- 1 | package nats 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "time" 7 | 8 | stan "github.com/nats-io/go-nats-streaming" 9 | ) 10 | 11 | // Client represents NATS client 12 | type Client struct { 13 | cn stan.Conn 14 | } 15 | 16 | // New initializes a connection to NATS server 17 | func New(clusterID, clientID, url string) (*Client, error) { 18 | conn, err := stan.Connect(clusterID, clientID, stan.NatsURL(url)) 19 | if err != nil { 20 | return nil, fmt.Errorf("error connecting to NATS: %v", err) 21 | } 22 | return &Client{cn: conn}, nil 23 | } 24 | 25 | // SubscribeQueue subscribers to a message queue 26 | func (c *Client) SubscribeQueue(subj string, f func(uint64, []byte)) (io.Closer, error) { 27 | return c.cn.QueueSubscribe( 28 | subj, 29 | "ingest", 30 | func(m *stan.Msg) { 31 | f(m.Sequence, m.Data) 32 | }, 33 | stan.SetManualAckMode(), 34 | ) 35 | } 36 | 37 | // SubscribeSeq subscribers to a message queue from received sequence 38 | func (c *Client) SubscribeSeq(id string, nick string, start uint64, f func(uint64, []byte)) (io.Closer, error) { 39 | return c.cn.Subscribe( 40 | id, 41 | func(m *stan.Msg) { 42 | f(m.Sequence, m.Data) 43 | }, 44 | stan.StartAtSequence(start), 45 | stan.SetManualAckMode(), 46 | ) 47 | } 48 | 49 | // SubscribeTimestamp subscribers to a message queue from received time.Time 50 | func (c *Client) SubscribeTimestamp(id string, nick string, t time.Time, f func(uint64, []byte)) (io.Closer, error) { 51 | return c.cn.Subscribe( 52 | id, 53 | func(m *stan.Msg) { 54 | f(m.Sequence, m.Data) 55 | }, 56 | stan.StartAtTime(t), 57 | stan.SetManualAckMode(), 58 | ) 59 | } 60 | 61 | // Send publishes new message 62 | func (c *Client) Send(id string, msg []byte) error { 63 | return c.cn.Publish(id, msg) 64 | } 65 | -------------------------------------------------------------------------------- /chat.go: -------------------------------------------------------------------------------- 1 | package goch 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/rs/xid" 8 | "github.com/vmihailenco/msgpack" 9 | ) 10 | 11 | // NewChannel creates new channel chat 12 | func NewChannel(name string, private bool) *Chat { 13 | ch := Chat{ 14 | Name: name, 15 | Members: make(map[string]*User), 16 | } 17 | 18 | if private { 19 | ch.Secret = newSecret() 20 | } 21 | 22 | return &ch 23 | } 24 | 25 | // Chat represents private or channel chat 26 | type Chat struct { 27 | Name string `json:"name"` 28 | Secret string `json:"secret"` 29 | Members map[string]*User `json:"members"` 30 | } 31 | 32 | // Chat errors 33 | var ( 34 | errAlreadyRegistered = errors.New("chat: uid already registered in this chat") 35 | errNotRegistered = errors.New("chat: not a member of this channel") 36 | errInvalidSecret = errors.New("chat: invalid secret") 37 | ) 38 | 39 | // Register registers user with a chat and returns secret which should 40 | // be stored on the client side, and used for subsequent join requests 41 | func (c *Chat) Register(u *User) (string, error) { 42 | if _, ok := c.Members[u.UID]; ok { 43 | return "", errAlreadyRegistered 44 | } 45 | if u.Secret == "" { 46 | u.Secret = newSecret() 47 | } 48 | c.Members[u.UID] = u 49 | return u.Secret, nil 50 | } 51 | 52 | // Join attempts to join user to chat 53 | func (c *Chat) Join(uid, secret string) (*User, error) { 54 | u, ok := c.Members[uid] 55 | if !ok { 56 | return nil, errNotRegistered 57 | } 58 | if u.Secret != secret { 59 | return nil, errInvalidSecret 60 | } 61 | u.Secret = "" 62 | return u, nil 63 | } 64 | 65 | // Leave removes user from channel 66 | func (c *Chat) Leave(uid string) { 67 | delete(c.Members, uid) 68 | } 69 | 70 | func newSecret() string { 71 | return xid.New().String() 72 | } 73 | 74 | // ListMembers returns list of members associated to a chat 75 | func (c *Chat) ListMembers() []*User { 76 | if len(c.Members) < 1 { 77 | return nil 78 | } 79 | var members []*User 80 | for _, u := range c.Members { 81 | u.Secret = "" 82 | members = append(members, u) 83 | } 84 | return members 85 | } 86 | 87 | // DecodeChat tries to decode binary formatted message in b to Message 88 | func DecodeChat(b string) (*Chat, error) { 89 | var c Chat 90 | if err := msgpack.Unmarshal([]byte(b), &c); err != nil { 91 | return nil, fmt.Errorf("client: unable to unmarshal chat: %v", err) 92 | } 93 | return &c, nil 94 | } 95 | 96 | // Encode encodes provided chat in binary format 97 | func (c *Chat) Encode() ([]byte, error) { 98 | return msgpack.Marshal(c) 99 | } 100 | 101 | // TODO: Private chats (can only init private chat with people in the same channel) 102 | -------------------------------------------------------------------------------- /internal/agent/api.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "regexp" 9 | 10 | "github.com/gorilla/mux" 11 | 12 | "github.com/ribice/goch" 13 | 14 | "github.com/gorilla/websocket" 15 | "github.com/ribice/goch/internal/broker" 16 | ) 17 | 18 | var ( 19 | alfaRgx *regexp.Regexp 20 | ) 21 | 22 | // NewAPI creates new websocket api 23 | func NewAPI(m *mux.Router, br *broker.Broker, store ChatStore, lim Limiter) *API { 24 | api := API{ 25 | broker: br, 26 | store: store, 27 | upgrader: websocket.Upgrader{ 28 | ReadBufferSize: 1024, 29 | WriteBufferSize: 1024, 30 | CheckOrigin: func(r *http.Request) bool { return true }, 31 | }, 32 | } 33 | alfaRgx = regexp.MustCompile("^[a-zA-Z0-9_]*$") 34 | 35 | m.HandleFunc("/connect", api.connect).Methods("GET") 36 | 37 | return &api 38 | } 39 | 40 | // API represents websocket api service 41 | type API struct { 42 | broker *broker.Broker 43 | store ChatStore 44 | upgrader websocket.Upgrader 45 | rlim Limiter 46 | } 47 | 48 | // Limiter represents chat service limit checker 49 | type Limiter interface { 50 | ExceedsAny(map[string]goch.Limit) error 51 | } 52 | 53 | func (api *API) connect(w http.ResponseWriter, r *http.Request) { 54 | conn, err := api.upgrader.Upgrade(w, r, nil) 55 | if err != nil { 56 | http.Error(w, fmt.Sprintf("error while upgrading to ws connection: %v", err), 500) 57 | return 58 | } 59 | 60 | req, err := api.waitConnInit(conn) 61 | if err != nil { 62 | if err == errConnClosed { 63 | return 64 | } 65 | writeErr(conn, err.Error()) 66 | return 67 | } 68 | 69 | agent := New(api.broker, api.store) 70 | agent.HandleConn(conn, req) 71 | } 72 | 73 | type initConReq struct { 74 | Channel string `json:"channel"` 75 | UID string `json:"uid"` 76 | Secret string `json:"secret"` // User secret 77 | LastSeq *uint64 `json:"last_seq"` 78 | } 79 | 80 | func (api *API) bindReq(r *initConReq) error { 81 | if !alfaRgx.MatchString(r.Secret) { 82 | return errors.New("secret must contain only alphanumeric and underscores") 83 | } 84 | if !alfaRgx.MatchString(r.Channel) { 85 | return errors.New("channel must contain only alphanumeric and underscores") 86 | } 87 | 88 | return api.rlim.ExceedsAny(map[string]goch.Limit{ 89 | r.UID: goch.UIDLimit, 90 | r.Secret: goch.SecretLimit, 91 | r.Channel: goch.ChanLimit, 92 | }) 93 | } 94 | 95 | var errConnClosed = errors.New("connection closed") 96 | 97 | func (api *API) waitConnInit(conn *websocket.Conn) (*initConReq, error) { 98 | t, wsr, err := conn.NextReader() 99 | if err != nil || t == websocket.CloseMessage { 100 | return nil, errConnClosed 101 | } 102 | 103 | var req *initConReq 104 | 105 | err = json.NewDecoder(wsr).Decode(req) 106 | if err != nil { 107 | return nil, err 108 | } 109 | 110 | if err = api.bindReq(req); err != nil { 111 | return nil, err 112 | } 113 | 114 | return req, nil 115 | } 116 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # goch 2 | [![Build Status](https://travis-ci.org/ribice/goch.svg?branch=master)](https://travis-ci.org/ribice/goch) 3 | [![codecov](https://codecov.io/gh/ribice/goch/branch/master/graph/badge.svg)](https://codecov.io/gh/ribice/goch) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/ribice/goch)](https://goreportcard.com/report/github.com/ribice/goch) 5 | [![Maintainability](https://api.codeclimate.com/v1/badges/c3cb09dbc0bc43186464/maintainability)](https://codeclimate.com/github/ribice/goch/maintainability) 6 | 7 | goch is a self-hosted live-chat server written in Go. 8 | 9 | It allows you to run a live-chat software on your own infrastructure. 10 | 11 | You can create multiple private and public chatrooms where two or more users can be at the same time. 12 | 13 | For communication, it uses RESTful endpoints, Websockets, NATS Streaming, and Redis. 14 | 15 | Goch is a fork of [Gossip](https://github.com/aneshas/gossip), with many added features and fixes. 16 | 17 | ## Getting started 18 | 19 | To run goch locally, you need `docker`, `docker-compose` and `go` installed and set on your path. After downloading/cloning the project, run `./up` which compiles the binary and runs docker-compose with goch, NATS Streaming, and Redis. If there were no errors, goch should be running on localhost (port 8080). 20 | 21 | ## How it works 22 | 23 | In order for the server to run, `ADMIN_USERNAME` and `ADMIN_PASSWORD` env variables have to be set. In the repository, they are set to `admin` and `pass` respectively, but you should obviously change those for security reasons. 24 | 25 | Once the server is running, the following routes are available: 26 | 27 | * `POST /admin/channels`: Creates a new channel. You have to provide a unique name for a channel (usually an ID), and the response includes channel's secret which will be used for connecting to channel later on. This endpoint should be invoked server-side with provided admin credentials. The response should be saved in order to connect to the channel later on. 28 | 29 | * `POST /register`: Register a user in a channel. In order to register for the channel, a UID, DisplayName, ChannelSecret, and ChannelName needs to be provided. Optionally user secret needs to be provided, but if not the server will generate and return one. 30 | 31 | * `GET /connect`: Connects to a chat and returns a WebSocket connection, along with chat history. Channel, UID, and Secret need to be provided. Optionally LastSeq is provided which will return chat history only after LastSeq (UNIX timestamp). 32 | 33 | The remaining routes are only used as 'helpers': 34 | 35 | * `GET /channels/{name}?secret=$SECRET`: Returns list of members in a channel. Channel name has to be provided as URL param and channel secret as a query param. 36 | 37 | * `GET /admin/channels`: Returns list of all available channels. 38 | 39 | * `GET /admin/channels/{name}/user/{uid}`: Returns list of unread messages on a chat for a user. 40 | 41 | ## License 42 | 43 | goch is licensed under the MIT license. Check the [LICENSE](LICENSE) file for details. 44 | 45 | ## Author 46 | 47 | [Emir Ribic](https://dev.ribic.ba) 48 | -------------------------------------------------------------------------------- /internal/broker/broker.go: -------------------------------------------------------------------------------- 1 | // Package broker provides chat broker functionality 2 | package broker 3 | 4 | import ( 5 | "fmt" 6 | "io" 7 | "time" 8 | 9 | "github.com/ribice/goch" 10 | ) 11 | 12 | // New creates new chat broker instance 13 | func New(mq MQ, store ChatStore, ig Ingester) *Broker { 14 | return &Broker{mq: mq, store: store, ig: ig} 15 | } 16 | 17 | // Broker represents chat broker 18 | type Broker struct { 19 | mq MQ 20 | ig Ingester 21 | store ChatStore 22 | } 23 | 24 | // MQ represents message broker interface 25 | type MQ interface { 26 | Send(string, []byte) error 27 | SubscribeSeq(string, string, uint64, func(uint64, []byte)) (io.Closer, error) 28 | SubscribeTimestamp(string, string, time.Time, func(uint64, []byte)) (io.Closer, error) 29 | } 30 | 31 | // Ingester represents chat history read model ingester 32 | type Ingester interface { 33 | Run(string) (func(), error) 34 | } 35 | 36 | // ChatStore represents chat store interface 37 | type ChatStore interface { 38 | UpdateLastClientSeq(string, string, uint64) 39 | } 40 | 41 | // Subscribe subscribes to provided chat id at start sequence 42 | // Returns close subscription func, or an error. 43 | func (b *Broker) Subscribe(chatID, uid string, start uint64, c chan *goch.Message) (func(), error) { 44 | closer, err := b.mq.SubscribeSeq("chat."+chatID, uid, start, func(seq uint64, data []byte) { 45 | msg, err := goch.DecodeMsg(data) 46 | if err != nil { 47 | msg = &goch.Message{ 48 | FromUID: "broker", 49 | Text: "broker: message unavailable: decoding error", 50 | Time: time.Now().UnixNano(), 51 | } 52 | } 53 | 54 | msg.Seq = seq 55 | 56 | if msg.FromUID != uid { 57 | c <- msg 58 | } else { 59 | b.store.UpdateLastClientSeq(msg.FromUID, chatID, seq) 60 | } 61 | }) 62 | 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | cleanup, err := b.ig.Run(chatID) 68 | if err != nil { 69 | closer.Close() 70 | return nil, fmt.Errorf("broker: unable to run ingest for chat: %v", err) 71 | } 72 | 73 | return func() { closer.Close(); cleanup() }, nil 74 | } 75 | 76 | // SubscribeNew subscribes to provided chat id subject starting from time.Now() 77 | // Returns close subscription func, or an error. 78 | func (b *Broker) SubscribeNew(chatID, uid string, c chan *goch.Message) (func(), error) { 79 | closer, err := b.mq.SubscribeTimestamp("chat."+chatID, uid, time.Now(), func(seq uint64, data []byte) { 80 | msg, err := goch.DecodeMsg(data) 81 | if err != nil { 82 | msg = &goch.Message{ 83 | FromUID: "broker", 84 | Text: "broker: message unavailable: decoding error", 85 | Time: time.Now().UnixNano(), 86 | } 87 | } 88 | 89 | msg.Seq = seq 90 | 91 | if msg.FromUID != uid { 92 | c <- msg 93 | } 94 | }) 95 | 96 | if err != nil { 97 | return nil, err 98 | } 99 | 100 | cleanup, err := b.ig.Run(chatID) 101 | if err != nil { 102 | closer.Close() 103 | return nil, fmt.Errorf("broker: unable to run ingest for chat: %v", err) 104 | } 105 | 106 | return func() { closer.Close(); cleanup() }, nil 107 | } 108 | 109 | // Send sends new message to a given chat 110 | func (b *Broker) Send(chatID string, msg *goch.Message) error { 111 | data, err := msg.Encode() 112 | if err != nil { 113 | return err 114 | } 115 | 116 | return b.mq.Send("chat."+chatID, data) 117 | } 118 | -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | 8 | "github.com/ribice/goch" 9 | "gopkg.in/yaml.v2" 10 | ) 11 | 12 | // Config represents application configuration 13 | type Config struct { 14 | Server *Server `yaml:"server,omitempty"` 15 | Redis *Redis `yaml:"redis,omitempty"` 16 | NATS *NATS `yaml:"nats,omitempty"` 17 | Admin *AdminAccount `yaml:"-"` 18 | Limits map[goch.Limit][2]int `yaml:"limits,omitempty"` 19 | LimitErrs map[goch.Limit]error `yaml:"-"` 20 | } 21 | 22 | // Server holds data necessery for server configuration 23 | type Server struct { 24 | Port int `yaml:"port"` 25 | } 26 | 27 | // Redis holds credentials for Redis 28 | type Redis struct { 29 | Address string `yaml:"address"` 30 | Port int `yaml:"port"` 31 | Password string `yaml:"-"` 32 | } 33 | 34 | // NATS holds credentials for NATS-Streaming server 35 | type NATS struct { 36 | ClusterID string `yaml:"cluster_id"` 37 | ClientID string `yaml:"client_id"` 38 | URL string `yaml:"url"` 39 | } 40 | 41 | // AdminAccount represents an account needed for creating new channels 42 | type AdminAccount struct { 43 | Username string 44 | Password string 45 | } 46 | 47 | // Load loads config from file and env variables 48 | func Load(path string) (*Config, error) { 49 | bytes, err := ioutil.ReadFile(path) 50 | if err != nil { 51 | return nil, fmt.Errorf("error reading config file, %s", err) 52 | } 53 | var cfg = new(Config) 54 | if err := yaml.Unmarshal(bytes, cfg); err != nil { 55 | return nil, fmt.Errorf("unable to decode config into struct, %v", err) 56 | } 57 | 58 | if len(cfg.Limits) != int(goch.ChanSecretLimit) { 59 | return nil, fmt.Errorf("not all limits were loaded. needed %v, actual %v", goch.ChanSecretLimit, len(cfg.Limits)) 60 | } 61 | 62 | if cfg.Redis != nil { 63 | cfg.Redis.Password = os.Getenv("REDIS_PASSWORD") 64 | } 65 | 66 | user, err := getEnv("ADMIN_USERNAME") 67 | if err != nil { 68 | return nil, err 69 | } 70 | 71 | pass, err := getEnv("ADMIN_PASSWORD") 72 | if err != nil { 73 | return nil, err 74 | } 75 | 76 | cfg.Admin = &AdminAccount{Username: user, Password: pass} 77 | cfg.LimitErrs = map[goch.Limit]error{ 78 | goch.DisplayNameLimit: fmt.Errorf("displayName must be between %v and %v characters long", cfg.Limits[1][0], cfg.Limits[1][1]), 79 | goch.UIDLimit: fmt.Errorf("uid must be between %v and %v characters long", cfg.Limits[2][0], cfg.Limits[2][1]), 80 | goch.SecretLimit: fmt.Errorf("secret must be between %v and %v characters long", cfg.Limits[3][0], cfg.Limits[3][1]), 81 | goch.ChanLimit: fmt.Errorf("channel must be between %v and %v characters long", cfg.Limits[4][0], cfg.Limits[4][1]), 82 | goch.ChanSecretLimit: fmt.Errorf("channelSecret must be between %v and %v characters long", cfg.Limits[5][0], cfg.Limits[5][1]), 83 | } 84 | return cfg, nil 85 | } 86 | 87 | // ExceedsAny checks whether any limit is exceeded 88 | func (c *Config) ExceedsAny(m map[string]goch.Limit) error { 89 | for k, v := range m { 90 | if exceedsLim(k, c.Limits[v]) { 91 | return c.LimitErrs[v] 92 | } 93 | } 94 | return nil 95 | 96 | } 97 | 98 | // Exceeds checks whether a string exceeds chat limitation 99 | func (c *Config) Exceeds(str string, lim goch.Limit) error { 100 | if exceedsLim(str, c.Limits[lim]) { 101 | return c.LimitErrs[lim] 102 | } 103 | return nil 104 | } 105 | 106 | func exceedsLim(s string, lims [2]int) bool { 107 | return len(s) < lims[0] || len(s) > lims[1] 108 | } 109 | 110 | func getEnv(key string) (string, error) { 111 | v, ok := os.LookupEnv(key) 112 | if !ok || v == "" { 113 | return "", fmt.Errorf("env variable %s required but not found", key) 114 | } 115 | return v, nil 116 | } 117 | -------------------------------------------------------------------------------- /internal/ingest/ingest_test.go: -------------------------------------------------------------------------------- 1 | package ingest_test 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "testing" 9 | "time" 10 | 11 | "github.com/ribice/goch" 12 | 13 | "github.com/ribice/goch/internal/ingest" 14 | ) 15 | 16 | var errTest = errors.New("error") 17 | 18 | func TestChatIngest(t *testing.T) { 19 | cases := []struct { 20 | name string 21 | chat string 22 | n int 23 | wantErr bool 24 | }{ 25 | { 26 | name: "empty queue", 27 | chat: "general", 28 | n: 0, 29 | wantErr: false, 30 | }, 31 | { 32 | name: "100 messages", 33 | chat: "general", 34 | n: 100, 35 | wantErr: false, 36 | }, 37 | { 38 | name: "1000 messages", 39 | chat: "general", 40 | n: 1000, 41 | wantErr: false, 42 | }, 43 | { 44 | name: "test err", 45 | chat: "general", 46 | n: 1000, 47 | wantErr: true, 48 | }, 49 | } 50 | 51 | for _, tc := range cases { 52 | t.Run(tc.name, func(t *testing.T) { 53 | q := queue{err: tc.wantErr} 54 | s := store{} 55 | 56 | for i := 0; i < tc.n; i++ { 57 | msg := &goch.Message{ 58 | Text: fmt.Sprintf("msg number %d", i), 59 | } 60 | bts, err := msg.Encode() 61 | if err != nil { 62 | t.Fatal(err) 63 | } 64 | q.data = append( 65 | q.data, 66 | struct { 67 | seq uint64 68 | msg []byte 69 | }{ 70 | seq: uint64(i), 71 | msg: bts, 72 | }, 73 | ) 74 | } 75 | 76 | ig := ingest.New( 77 | &q, 78 | &s, 79 | ) 80 | 81 | close, err := ig.Run(tc.chat) 82 | if (err != nil) != tc.wantErr { 83 | t.Errorf("error = %v, wantErr %v", err, tc.wantErr) 84 | return 85 | } 86 | 87 | if err != nil { 88 | return 89 | } 90 | 91 | defer close() 92 | 93 | <-q.purged 94 | 95 | time.Sleep(100 * time.Millisecond) 96 | 97 | if len(s.data[tc.chat]) != tc.n { 98 | t.Fatalf("messages not received by ingester, want: %d, got: %d", tc.n, len(s.data[tc.chat])) 99 | } 100 | 101 | for i, m := range s.data[tc.chat] { 102 | if m.Text != fmt.Sprintf("msg number %d", i) && m.Seq != uint64(i) { 103 | t.Fatalf("message not received by ingester: %d", i) 104 | } 105 | } 106 | }) 107 | } 108 | } 109 | 110 | func TestChatIngestDecodingErrs(t *testing.T) { 111 | cases := []struct { 112 | name string 113 | chat string 114 | n int 115 | }{ 116 | { 117 | name: "test 100", 118 | chat: "general", 119 | n: 100, 120 | }, 121 | 122 | // TODO - test AppendMessage error handling 123 | } 124 | 125 | for _, tc := range cases { 126 | t.Run(tc.name, func(t *testing.T) { 127 | q := queue{} 128 | s := store{} 129 | 130 | for i := 0; i < tc.n; i++ { 131 | d, err := json.Marshal(goch.Message{Text: "foo bar"}) 132 | if err != nil { 133 | t.Fatal(err) 134 | } 135 | q.data = append( 136 | q.data, 137 | struct { 138 | seq uint64 139 | msg []byte 140 | }{ 141 | seq: uint64(i), 142 | msg: d, 143 | }, 144 | ) 145 | } 146 | 147 | ig := ingest.New( 148 | &q, 149 | &s, 150 | ) 151 | 152 | close, err := ig.Run(tc.chat) 153 | if err != nil { 154 | t.Fatal(err) 155 | } 156 | 157 | defer close() 158 | 159 | <-q.purged 160 | 161 | time.Sleep(100 * time.Millisecond) 162 | 163 | if len(s.data[tc.chat]) != tc.n { 164 | t.Fatalf("messages not received by ingester, want: %d, got: %d", tc.n, len(s.data[tc.chat])) 165 | } 166 | 167 | for i, m := range s.data[tc.chat] { 168 | if m.Text != "ingest: message unavailable: decoding error" && m.Seq != uint64(i) { 169 | t.Fatalf("message not received by ingester: %d", i) 170 | } 171 | } 172 | }) 173 | } 174 | } 175 | 176 | type store struct { 177 | data map[string][]*goch.Message 178 | err bool 179 | } 180 | 181 | func (s *store) AppendMessage(id string, msg *goch.Message) error { 182 | if s.data == nil { 183 | s.data = make(map[string][]*goch.Message) 184 | } 185 | s.data[id] = append(s.data[id], msg) 186 | if s.err { 187 | return errTest 188 | } 189 | return nil 190 | } 191 | 192 | type queue struct { 193 | data []struct { 194 | seq uint64 195 | msg []byte 196 | } 197 | purged chan struct{} 198 | err bool 199 | } 200 | 201 | func (q *queue) SubscribeQueue(id string, f func(uint64, []byte)) (io.Closer, error) { 202 | if q.err { 203 | return nil, errTest 204 | } 205 | q.purged = make(chan struct{}) 206 | go func() { 207 | for _, m := range q.data { 208 | d := m.msg 209 | f(m.seq, d) 210 | } 211 | q.purged <- struct{}{} 212 | }() 213 | return &cl{}, nil 214 | } 215 | 216 | type cl struct{} 217 | 218 | func (c *cl) Close() error { return nil } 219 | -------------------------------------------------------------------------------- /chat_test.go: -------------------------------------------------------------------------------- 1 | package goch_test 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/ribice/goch" 8 | ) 9 | 10 | func TestNewChannel(t *testing.T) { 11 | channel := "channelName" 12 | ch := goch.NewChannel(channel, true) 13 | if ch.Secret == "" { 14 | t.Error("expected channel to have secret but does not") 15 | } 16 | if ch.Name != channel { 17 | t.Error("invalid channel name") 18 | } 19 | } 20 | 21 | func TestRegister(t *testing.T) { 22 | cases := []struct { 23 | name string 24 | c *goch.Chat 25 | req *goch.User 26 | wantErr string 27 | }{ 28 | { 29 | name: "User already registered", 30 | c: &goch.Chat{ 31 | Members: map[string]*goch.User{ 32 | "ABC": &goch.User{}, 33 | }, 34 | }, 35 | req: &goch.User{UID: "ABC"}, 36 | wantErr: "chat: uid already registered in this chat", 37 | }, 38 | { 39 | name: "User with secret", 40 | c: &goch.Chat{ 41 | Members: map[string]*goch.User{ 42 | "ABC": &goch.User{Secret: "secret2"}, 43 | }, 44 | }, 45 | req: &goch.User{UID: "DAF", Secret: "Secret"}, 46 | }, 47 | { 48 | name: "User without secret", 49 | c: &goch.Chat{ 50 | Members: map[string]*goch.User{ 51 | "ABC": &goch.User{}, 52 | }, 53 | }, 54 | req: &goch.User{UID: "DAF"}, 55 | }, 56 | } 57 | for _, tc := range cases { 58 | t.Run(tc.name, func(t *testing.T) { 59 | if tc.c == nil { 60 | t.Error("Chat has to be instantiated") 61 | } 62 | secret, err := tc.c.Register(tc.req) 63 | if err != nil && tc.wantErr != err.Error() { 64 | t.Errorf("expected err %s but got %s", tc.wantErr, err.Error()) 65 | } 66 | 67 | if tc.wantErr == "" { 68 | if tc.req.Secret != "" { 69 | if secret != tc.req.Secret { 70 | t.Errorf("expected secret %s but got %s", tc.req.Secret, secret) 71 | } 72 | } else if len(secret) != 20 { 73 | t.Errorf("expected len to be 20 but got %v", len(secret)) 74 | } 75 | } 76 | 77 | }) 78 | } 79 | } 80 | 81 | func TestJoin(t *testing.T) { 82 | cases := []struct { 83 | name string 84 | c *goch.Chat 85 | uid string 86 | secret string 87 | want *goch.User 88 | wantErr string 89 | }{ 90 | { 91 | name: "User not registered", 92 | c: &goch.Chat{ 93 | Members: map[string]*goch.User{ 94 | "ABC": &goch.User{}, 95 | }, 96 | }, 97 | uid: "DFA", 98 | wantErr: "chat: not a member of this channel", 99 | }, 100 | { 101 | name: "Invalid secret", 102 | c: &goch.Chat{ 103 | Members: map[string]*goch.User{ 104 | "ABC": &goch.User{Secret: "secret2"}, 105 | }, 106 | }, 107 | uid: "ABC", 108 | secret: "secret1", 109 | wantErr: "chat: invalid secret", 110 | }, 111 | { 112 | name: "Success", 113 | c: &goch.Chat{ 114 | Members: map[string]*goch.User{ 115 | "ABC": &goch.User{Secret: "secret1", DisplayName: "John", UID: "ABC", Email: "john@doe.com"}, 116 | }, 117 | }, 118 | uid: "ABC", 119 | secret: "secret1", 120 | want: &goch.User{DisplayName: "John", UID: "ABC", Email: "john@doe.com"}, 121 | }, 122 | } 123 | for _, tc := range cases { 124 | t.Run(tc.name, func(t *testing.T) { 125 | if tc.c == nil { 126 | t.Error("Chat has to be instantiated") 127 | } 128 | user, err := tc.c.Join(tc.uid, tc.secret) 129 | if err != nil && tc.wantErr != err.Error() { 130 | t.Errorf("expected err %s but got %s", tc.wantErr, err.Error()) 131 | } 132 | 133 | if tc.want != nil && !reflect.DeepEqual(tc.want, user) { 134 | t.Errorf("expected user %v but got %v", tc.want, user) 135 | } 136 | }) 137 | } 138 | } 139 | 140 | func TestLeave(t *testing.T) { 141 | c := &goch.Chat{ 142 | Members: map[string]*goch.User{ 143 | "user1": &goch.User{}, 144 | }, 145 | } 146 | c.Leave("user1") 147 | if len(c.Members) > 0 { 148 | t.Errorf("expected 0 mumbers, but found: %v", len(c.Members)) 149 | } 150 | } 151 | 152 | func TestMembers(t *testing.T) { 153 | c := &goch.Chat{} 154 | mems := c.ListMembers() 155 | if len(mems) > 0 { 156 | t.Errorf("expected 0 members, but got %v", len(mems)) 157 | } 158 | c.Members = map[string]*goch.User{ 159 | "User1": &goch.User{Secret: "secret1"}, 160 | "User2": &goch.User{Secret: "secret2"}, 161 | } 162 | newMems := c.ListMembers() 163 | if len(newMems) != len(c.Members) { 164 | t.Errorf("expected %v members, but got %v", len(c.Members), len(newMems)) 165 | } 166 | for _, v := range newMems { 167 | if v.Secret != "" { 168 | t.Error("expected secret to be empty but was not") 169 | } 170 | } 171 | } 172 | 173 | func TestChatEncode(t *testing.T) { 174 | c := &goch.Chat{Name: "msgPack", Secret: "packMsg", 175 | Members: map[string]*goch.User{ 176 | "User1": &goch.User{UID: "User1"}, 177 | }} 178 | bts, err := c.Encode() 179 | if err != nil { 180 | t.Errorf("did not expect error but received: %v", err) 181 | } 182 | ch, err := goch.DecodeChat(string(bts)) 183 | if err != nil { 184 | t.Errorf("did not expect error but received: %v", err) 185 | } 186 | if !reflect.DeepEqual(c, ch) { 187 | t.Errorf("expected chat %v but got %v", c, ch) 188 | } 189 | 190 | _, err = goch.DecodeChat("test") 191 | if err == nil { 192 | t.Error("expected error but received nil") 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /pkg/redis/redis.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/vmihailenco/msgpack" 8 | 9 | "github.com/go-redis/redis" 10 | "github.com/ribice/goch" 11 | ) 12 | 13 | const ( 14 | chanListKey = "channel.list" 15 | historyPrefix = "history" 16 | chatPrefix = "chat" 17 | chatLastSeqPrefix = "last_seq" 18 | chatClientLastSeqPrefix = "client.last_seq" 19 | 20 | maxHistorySize int64 = 1000 21 | ) 22 | 23 | // Client represents Redis client 24 | type Client struct { 25 | cl *redis.Client 26 | } 27 | 28 | // New instantiates new Redis client 29 | func New(addr, pass string, port int) (*Client, error) { 30 | opts := redis.Options{ 31 | Addr: addr + ":" + strconv.Itoa(port), 32 | } 33 | if pass != "" { 34 | opts.Password = pass 35 | } 36 | 37 | client := redis.NewClient(&opts) 38 | 39 | _, err := client.Ping().Result() 40 | if err != nil { 41 | return nil, fmt.Errorf("Cannot connect to Redis Addr %v, Port %v Reason %v", addr, port, err) 42 | } 43 | return &Client{cl: client}, nil 44 | } 45 | 46 | // Get retrieves chat from Client 47 | func (s *Client) Get(id string) (*goch.Chat, error) { 48 | val, err := s.cl.Get(chatID(id)).Result() 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | return goch.DecodeChat(val) 54 | } 55 | 56 | // GetRecent returns list of recent messages, and sequence until last message 57 | func (s *Client) GetRecent(id string, n int64) ([]goch.Message, uint64, error) { 58 | cmd := s.cl.LRange(chatHistoryID(id), -n, -1) 59 | if cmd.Err() != nil { 60 | return nil, 0, cmd.Err() 61 | } 62 | 63 | data, err := cmd.Result() 64 | if err != nil { 65 | return nil, 0, err 66 | } 67 | 68 | if data == nil || len(data) == 0 { 69 | return nil, 0, nil 70 | } 71 | 72 | var seq uint64 73 | msgs := make([]goch.Message, len(data)) 74 | 75 | for i, m := range data { 76 | msg, err := goch.DecodeMsg([]byte(m)) 77 | if err != nil { 78 | msg.Text = "message unavailable!" 79 | } else { 80 | seq = msgs[i].Seq 81 | } 82 | } 83 | 84 | return msgs, (seq + 1), nil 85 | } 86 | 87 | // AppendMessage adds new message 88 | func (s *Client) AppendMessage(id string, m *goch.Message) error { 89 | data, err := m.Encode() 90 | if err != nil { 91 | data, _ = msgpack.Marshal([]byte(`{"text":"message unavailable, unable to encode","from":"goch/client"}`)) 92 | } 93 | 94 | key := chatHistoryID(id) 95 | 96 | if err := s.cl.RPush(key, data).Err(); err != nil { 97 | return err 98 | } 99 | 100 | s.updateChannelSeq(id, m.Seq) 101 | 102 | return s.cl.LTrim(key, -maxHistorySize, -1).Err() 103 | } 104 | 105 | func (s *Client) updateChannelSeq(id string, seq uint64) { 106 | var currSeq int64 107 | 108 | val, err := s.cl.Get(chatLastSeqID(id)).Result() 109 | if err != nil { 110 | if err != redis.Nil { 111 | return 112 | } 113 | val = "0" 114 | } 115 | 116 | currSeq, _ = strconv.ParseInt(val, 10, 64) 117 | 118 | if uint64(currSeq) >= seq { 119 | return 120 | } 121 | 122 | s.cl.Set(chatLastSeqID(id), seq, 0) 123 | } 124 | 125 | // UpdateLastClientSeq updates client's last seen message 126 | func (s *Client) UpdateLastClientSeq(uid string, id string, seq uint64) { 127 | var currSeq int64 128 | 129 | val, err := s.cl.Get(chatClientLastSeqID(uid, id)).Result() 130 | if err != nil { 131 | if err != redis.Nil { 132 | return 133 | } 134 | val = "0" 135 | } 136 | 137 | currSeq, _ = strconv.ParseInt(val, 10, 64) 138 | 139 | if uint64(currSeq) >= seq { 140 | return 141 | } 142 | 143 | s.cl.Set(chatClientLastSeqID(uid, id), seq, 0) 144 | } 145 | 146 | // GetUnreadCount returns number of unread messages 147 | func (s *Client) GetUnreadCount(uid string, id string) uint64 { 148 | val, err := s.cl.Get(chatClientLastSeqID(uid, id)).Result() 149 | if err != nil { 150 | if err != redis.Nil { 151 | return 0 152 | } 153 | val = "0" 154 | } 155 | 156 | useq, err := strconv.ParseInt(val, 10, 64) 157 | if err != nil { 158 | return 0 159 | } 160 | 161 | val, err = s.cl.Get(chatLastSeqID(id)).Result() 162 | if err != nil { 163 | return 0 164 | } 165 | 166 | cseq, err := strconv.ParseInt(val, 10, 64) 167 | if err != nil { 168 | return 0 169 | } 170 | 171 | delta := cseq - useq 172 | 173 | if delta <= 0 { 174 | return 0 175 | } 176 | 177 | return uint64(delta) 178 | } 179 | 180 | // Save saves new chat 181 | func (s *Client) Save(ct *goch.Chat) error { 182 | data, err := ct.Encode() 183 | if err != nil { 184 | return err 185 | } 186 | 187 | pipe := s.cl.TxPipeline() 188 | pipe.Set(chatID(ct.Name), data, 0) 189 | 190 | // Save only public channels 191 | if ct.Secret == "" { 192 | pipe.SAdd(chanListKey, ct.Name) 193 | } 194 | 195 | _, err = pipe.Exec() 196 | return err 197 | } 198 | 199 | // ListChannels returns list of all channels 200 | func (s *Client) ListChannels() ([]string, error) { 201 | return s.cl.SMembers(chanListKey).Result() 202 | } 203 | 204 | func chatID(id string) string { 205 | return fmt.Sprintf("%s.%s", chatPrefix, id) 206 | } 207 | 208 | func chatHistoryID(id string) string { 209 | return fmt.Sprintf("%s.%s.%s", historyPrefix, chatPrefix, id) 210 | } 211 | 212 | func chatLastSeqID(id string) string { 213 | return fmt.Sprintf("%s.%s.%s", chatLastSeqPrefix, chatPrefix, id) 214 | } 215 | 216 | func chatClientLastSeqID(uid, id string) string { 217 | return fmt.Sprintf("%s.%s.%s", chatClientLastSeqPrefix, uid, id) 218 | } 219 | -------------------------------------------------------------------------------- /pkg/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config_test 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "testing" 8 | 9 | "github.com/ribice/goch" 10 | 11 | "github.com/ribice/goch/pkg/config" 12 | 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | var ( 17 | lims = map[goch.Limit][2]int{ 18 | goch.DisplayNameLimit: [2]int{3, 128}, 19 | goch.UIDLimit: [2]int{20, 20}, 20 | goch.SecretLimit: [2]int{20, 50}, 21 | goch.ChanLimit: [2]int{10, 20}, 22 | goch.ChanSecretLimit: [2]int{20, 20}, 23 | } 24 | limErrs = map[goch.Limit]error{ 25 | goch.DisplayNameLimit: errors.New("displayName must be between 3 and 128 characters long"), 26 | goch.UIDLimit: errors.New("uid must be between 20 and 20 characters long"), 27 | goch.SecretLimit: errors.New("secret must be between 20 and 50 characters long"), 28 | goch.ChanLimit: errors.New("channel must be between 10 and 20 characters long"), 29 | goch.ChanSecretLimit: errors.New("channelSecret must be between 20 and 20 characters long"), 30 | } 31 | ) 32 | 33 | func TestLoad(t *testing.T) { 34 | type data struct { 35 | user string 36 | pass string 37 | redisPass string 38 | } 39 | cases := []struct { 40 | name string 41 | path string 42 | wantData *config.Config 43 | wantErr bool 44 | envData *data 45 | }{ 46 | { 47 | name: "Fail on non-existing file", 48 | path: "notExists", 49 | wantErr: true, 50 | }, 51 | { 52 | name: "Fail on wrong file format", 53 | path: "testdata/invalid.yaml", 54 | wantErr: true, 55 | }, 56 | { 57 | name: "Fail on incorrect number of limits", 58 | path: "testdata/limits.yaml", 59 | wantErr: true, 60 | }, 61 | { 62 | name: "Missing env vars", 63 | path: "testdata/testdata.yaml", 64 | wantErr: true, 65 | }, 66 | { 67 | name: "Missing pass env var", 68 | path: "testdata/testdata.yaml", 69 | envData: &data{ 70 | user: "username", 71 | }, 72 | wantErr: true, 73 | }, 74 | { 75 | name: "Success", 76 | path: "testdata/testdata.yaml", 77 | wantData: &config.Config{ 78 | Server: &config.Server{ 79 | Port: 8080, 80 | }, 81 | Redis: &config.Redis{ 82 | Address: "test.com", 83 | Port: 6379, 84 | Password: "repassword", 85 | }, 86 | NATS: &config.NATS{ 87 | ClusterID: "test-cluster", 88 | ClientID: "test-client", 89 | URL: "test-url", 90 | }, 91 | Admin: &config.AdminAccount{ 92 | Username: "admin", 93 | Password: "password", 94 | }, 95 | Limits: lims, 96 | LimitErrs: limErrs, 97 | }, 98 | envData: &data{ 99 | user: "admin", 100 | pass: "password", 101 | redisPass: "repassword", 102 | }, 103 | }, 104 | } 105 | for _, tt := range cases { 106 | t.Run(tt.name, func(t *testing.T) { 107 | if tt.envData != nil { 108 | os.Setenv("REDIS_PASSWORD", tt.envData.redisPass) 109 | os.Setenv("ADMIN_USERNAME", tt.envData.user) 110 | os.Setenv("ADMIN_PASSWORD", tt.envData.pass) 111 | } 112 | cfg, err := config.Load(tt.path) 113 | fmt.Println(err) 114 | assert.Equal(t, tt.wantData, cfg) 115 | assert.Equal(t, tt.wantErr, err != nil) 116 | }) 117 | } 118 | } 119 | 120 | func TestExceedsAny(t *testing.T) { 121 | cases := []struct { 122 | name string 123 | req map[string]goch.Limit 124 | wantErr error 125 | }{ 126 | { 127 | name: "Exceeds DisplayName", 128 | req: map[string]goch.Limit{ 129 | "TT": goch.DisplayNameLimit, 130 | }, 131 | wantErr: errors.New("displayName must be between 3 and 128 characters long"), 132 | }, 133 | { 134 | name: "Exceeds UID", 135 | req: map[string]goch.Limit{ 136 | "TTT": goch.DisplayNameLimit, 137 | "UID": goch.UIDLimit, 138 | }, 139 | wantErr: errors.New("uid must be between 20 and 20 characters long"), 140 | }, 141 | { 142 | name: "Success", 143 | req: map[string]goch.Limit{ 144 | "TTT": goch.DisplayNameLimit, 145 | "1234567890": goch.ChanLimit, 146 | }, 147 | }, 148 | } 149 | for _, tt := range cases { 150 | t.Run(tt.name, func(t *testing.T) { 151 | cfg := &config.Config{ 152 | Limits: map[goch.Limit][2]int{ 153 | goch.DisplayNameLimit: [2]int{3, 128}, 154 | goch.UIDLimit: [2]int{20, 20}, 155 | goch.ChanLimit: [2]int{10, 20}, 156 | }, 157 | LimitErrs: map[goch.Limit]error{ 158 | goch.DisplayNameLimit: errors.New("displayName must be between 3 and 128 characters long"), 159 | goch.UIDLimit: errors.New("uid must be between 20 and 20 characters long"), 160 | }, 161 | } 162 | assert.Equal(t, tt.wantErr, cfg.ExceedsAny(tt.req)) 163 | }) 164 | } 165 | } 166 | 167 | func TestExceeds(t *testing.T) { 168 | cases := []struct { 169 | name string 170 | req string 171 | limit goch.Limit 172 | wantErr error 173 | }{ 174 | { 175 | name: "Fail", 176 | req: "TT", 177 | limit: goch.DisplayNameLimit, 178 | wantErr: errors.New("displayName must be between 3 and 128 characters long"), 179 | }, 180 | { 181 | name: "Success", 182 | req: "TTT", 183 | limit: goch.DisplayNameLimit, 184 | }, 185 | } 186 | for _, tt := range cases { 187 | t.Run(tt.name, func(t *testing.T) { 188 | cfg := &config.Config{ 189 | Limits: map[goch.Limit][2]int{ 190 | goch.DisplayNameLimit: [2]int{3, 128}, 191 | }, 192 | LimitErrs: map[goch.Limit]error{ 193 | goch.DisplayNameLimit: errors.New("displayName must be between 3 and 128 characters long"), 194 | }, 195 | } 196 | assert.Equal(t, tt.wantErr, cfg.Exceeds(tt.req, tt.limit)) 197 | }) 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /internal/chat/chat.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "regexp" 8 | 9 | "github.com/gorilla/mux" 10 | "github.com/ribice/goch" 11 | "github.com/ribice/msv/render" 12 | ) 13 | 14 | var ( 15 | exceedsAny func(map[string]goch.Limit) error 16 | exceeds func(string, goch.Limit) error 17 | alfaRgx *regexp.Regexp 18 | mailRgx *regexp.Regexp 19 | ) 20 | 21 | // Limiter represents chat service limit checker 22 | type Limiter interface { 23 | Exceeds(string, goch.Limit) error 24 | ExceedsAny(map[string]goch.Limit) error 25 | } 26 | 27 | // New creates new websocket api 28 | func New(m *mux.Router, store Store, l Limiter, authMW mux.MiddlewareFunc) *API { 29 | api := API{ 30 | store: store, 31 | } 32 | 33 | exceeds = l.Exceeds 34 | exceedsAny = l.ExceedsAny 35 | alfaRgx = regexp.MustCompile("^[a-zA-Z0-9_]*$") 36 | mailRgx = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") 37 | 38 | sr := m.PathPrefix("/channels").Subrouter() 39 | sr.HandleFunc("/register", api.register).Methods("POST") 40 | sr.HandleFunc("/{name}", api.listMembers).Methods("GET").Queries("secret", "{[a-zA-Z0-9_]*$}") 41 | 42 | ar := m.PathPrefix("/admin/channels").Subrouter() 43 | ar.Use(authMW) 44 | ar.HandleFunc("", api.listChannels).Methods("GET") 45 | ar.HandleFunc("", api.createChannel).Methods("POST") 46 | ar.HandleFunc("/{chanName}/user/{uid}", api.unreadCount).Methods("GET") 47 | return &api 48 | } 49 | 50 | // API represents websocket api service 51 | type API struct { 52 | store Store 53 | } 54 | 55 | // Store represents chat store interface 56 | type Store interface { 57 | Save(*goch.Chat) error 58 | Get(string) (*goch.Chat, error) 59 | ListChannels() ([]string, error) 60 | GetUnreadCount(string, string) uint64 61 | } 62 | 63 | type createReq struct { 64 | Name string `json:"name"` 65 | IsPrivate bool `json:"is_private"` 66 | } 67 | 68 | func (cr *createReq) Bind() error { 69 | if !alfaRgx.MatchString(cr.Name) { 70 | return errors.New("name must contain only alphanumeric and underscores") 71 | } 72 | return exceeds(cr.Name, goch.ChanLimit) 73 | } 74 | 75 | func (api *API) createChannel(w http.ResponseWriter, r *http.Request) { 76 | var req createReq 77 | if err := render.Bind(w, r, &req); err != nil { 78 | return 79 | } 80 | ch := goch.NewChannel(req.Name, req.IsPrivate) 81 | if err := api.store.Save(ch); err != nil { 82 | http.Error(w, fmt.Sprintf("could not create channel: %v", err), 500) 83 | return 84 | } 85 | render.JSON(w, ch.Secret) 86 | } 87 | 88 | type registerReq struct { 89 | UID string `json:"uid"` 90 | DisplayName string `json:"display_name"` 91 | Email string `json:"email"` 92 | Secret string `json:"secret"` 93 | Channel string `json:"channel"` 94 | ChannelSecret string `json:"channel_secret"` 95 | } 96 | 97 | type registerResp struct { 98 | Secret string `json:"secret"` 99 | } 100 | 101 | func (r *registerReq) Bind() error { 102 | if !alfaRgx.MatchString(r.UID) { 103 | return errors.New("uid must contain only alphanumeric and underscores") 104 | } 105 | if !alfaRgx.MatchString(r.Secret) { 106 | return errors.New("secret must contain only alphanumeric and underscores") 107 | } 108 | if !mailRgx.MatchString(r.Email) { 109 | return errors.New("invalid email address") 110 | } 111 | return exceedsAny(map[string]goch.Limit{ 112 | r.UID: goch.UIDLimit, 113 | r.DisplayName: goch.DisplayNameLimit, 114 | r.ChannelSecret: goch.ChanSecretLimit, 115 | r.Secret: goch.SecretLimit, 116 | r.Channel: goch.ChanLimit, 117 | }) 118 | } 119 | 120 | func (api *API) register(w http.ResponseWriter, r *http.Request) { 121 | var req registerReq 122 | if err := render.Bind(w, r, &req); err != nil { 123 | return 124 | } 125 | ch, err := api.store.Get(req.Channel) 126 | if err != nil || ch.Secret != req.ChannelSecret { 127 | http.Error(w, fmt.Sprintf("invalid secret or unexisting channel: %v", err), 500) 128 | return 129 | } 130 | 131 | secret, err := ch.Register(&goch.User{ 132 | UID: req.UID, 133 | DisplayName: req.DisplayName, 134 | Email: req.Email, 135 | Secret: req.Secret, 136 | }) 137 | 138 | if err != nil { 139 | http.Error(w, fmt.Sprintf("error registering to channel: %v", err), 500) 140 | return 141 | } 142 | 143 | if err = api.store.Save(ch); err != nil { 144 | ch.Leave(req.UID) 145 | http.Error(w, fmt.Sprintf("could not update channel membership: %v", err), 500) 146 | return 147 | } 148 | 149 | render.JSON(w, registerResp{secret}) 150 | 151 | } 152 | 153 | type unreadCountResp struct { 154 | Count uint64 `json:"count"` 155 | } 156 | 157 | func (api *API) unreadCount(w http.ResponseWriter, r *http.Request) { 158 | vars := mux.Vars(r) 159 | uid, chanName := vars["uid"], vars["chanName"] 160 | if err := exceedsAny(map[string]goch.Limit{ 161 | chanName: goch.ChanLimit, 162 | uid: goch.UIDLimit, 163 | }); err != nil { 164 | http.Error(w, err.Error(), 400) 165 | return 166 | } 167 | 168 | uc := api.store.GetUnreadCount(uid, chanName) 169 | render.JSON(w, &unreadCountResp{uc}) 170 | 171 | } 172 | 173 | func (api *API) listMembers(w http.ResponseWriter, r *http.Request) { 174 | 175 | chanName := mux.Vars(r)["name"] 176 | secret := r.URL.Query().Get("secret") 177 | 178 | if err := exceedsAny(map[string]goch.Limit{ 179 | chanName: goch.ChanLimit, 180 | secret: goch.ChanSecretLimit, 181 | }); err != nil { 182 | http.Error(w, err.Error(), 400) 183 | return 184 | } 185 | 186 | ch, err := api.store.Get(chanName) 187 | if err != nil { 188 | http.Error(w, fmt.Sprintf("invalid secret or unexisting channel: %v", err), 500) 189 | return 190 | } 191 | 192 | if ch.Secret != secret { 193 | http.Error(w, "invalid secret", 500) 194 | return 195 | } 196 | 197 | render.JSON(w, ch.ListMembers()) 198 | } 199 | 200 | func (api *API) listChannels(w http.ResponseWriter, r *http.Request) { 201 | chans, err := api.store.ListChannels() 202 | if err != nil { 203 | http.Error(w, fmt.Sprintf("unable to fetch channels: %v", err), 500) 204 | return 205 | } 206 | render.JSON(w, chans) 207 | 208 | } 209 | -------------------------------------------------------------------------------- /internal/agent/agent.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "time" 8 | 9 | "github.com/ribice/goch" 10 | 11 | "github.com/gorilla/websocket" 12 | ) 13 | 14 | // New creates new connection agent instance 15 | func New(mb MessageBroker, store ChatStore) *Agent { 16 | return &Agent{ 17 | mb: mb, 18 | store: store, 19 | done: make(chan struct{}, 1), 20 | } 21 | } 22 | 23 | // Agent represents chat connection agent which handles end to end comm client - broker 24 | type Agent struct { 25 | chat *goch.Chat 26 | uid string 27 | displayName string 28 | done chan struct{} 29 | closeSub func() 30 | closed bool 31 | 32 | conn *websocket.Conn 33 | mb MessageBroker 34 | 35 | store ChatStore 36 | } 37 | 38 | // ChatStore represents chat store interface 39 | type ChatStore interface { 40 | Get(string) (*goch.Chat, error) 41 | GetRecent(string, int64) ([]goch.Message, uint64, error) 42 | UpdateLastClientSeq(string, string, uint64) 43 | } 44 | 45 | // MessageBroker represents broker interface 46 | type MessageBroker interface { 47 | Subscribe(string, string, uint64, chan *goch.Message) (func(), error) 48 | SubscribeNew(string, string, chan *goch.Message) (func(), error) 49 | Send(string, *goch.Message) error 50 | } 51 | 52 | type msgT int 53 | 54 | const ( 55 | chatMsg msgT = iota 56 | historyMsg 57 | errorMsg 58 | infoMsg 59 | historyReqMsg 60 | ) 61 | 62 | const ( 63 | maxHistoryCount uint64 = 512 64 | ) 65 | 66 | type msg struct { 67 | Type msgT `json:"type"` 68 | Data interface{} `json:"data,omitempty"` 69 | Error string `json:"error,omitempty"` 70 | } 71 | 72 | // HandleConn handles websocket communication for requested chat/client 73 | func (a *Agent) HandleConn(conn *websocket.Conn, req *initConReq) { 74 | a.conn = conn 75 | 76 | a.conn.SetCloseHandler(func(code int, text string) error { 77 | a.closed = true 78 | a.done <- struct{}{} 79 | return nil 80 | }) 81 | 82 | ct, err := a.store.Get(req.Channel) 83 | if err != nil { 84 | writeFatal(a.conn, fmt.Sprintf("agent: unable to find chat: %v", err)) 85 | return 86 | } 87 | 88 | // if ct == nil { 89 | // writeFatal(a.conn, "agent: this chat does not exist") 90 | // return 91 | // } 92 | 93 | user, err := ct.Join(req.UID, req.Secret) 94 | if err != nil { 95 | writeFatal(a.conn, fmt.Sprintf("agent: unable to join chat: %v", err)) 96 | return 97 | } 98 | 99 | a.chat = ct 100 | a.setUser(user) 101 | 102 | mc := make(chan *goch.Message) 103 | { 104 | var close func() 105 | 106 | if req.LastSeq != nil { 107 | close, err = a.mb.Subscribe(req.Channel, user.UID, *req.LastSeq, mc) 108 | } else if seq, err := a.pushRecent(); err != nil { 109 | writeErr(a.conn, fmt.Sprintf("agent: unable to fetch chat history: %v", err)) 110 | close, err = a.mb.SubscribeNew(req.Channel, user.UID, mc) 111 | } else { 112 | close, err = a.mb.Subscribe(req.Channel, user.UID, seq, mc) 113 | } 114 | 115 | if err != nil { 116 | writeFatal(a.conn, fmt.Sprintf("agent: unable to subscribe to chat updates due to: %v. closing connection", err)) 117 | return 118 | } 119 | 120 | a.closeSub = close 121 | } 122 | 123 | a.loop(mc) 124 | } 125 | 126 | func (a *Agent) pushRecent() (uint64, error) { 127 | msgs, seq, err := a.store.GetRecent(a.chat.Name, 100) 128 | if err != nil { 129 | return 0, err 130 | } 131 | 132 | if msgs == nil { 133 | return 0, nil 134 | } 135 | 136 | a.store.UpdateLastClientSeq(a.uid, a.chat.Name, msgs[len(msgs)-1].Seq) 137 | 138 | return seq, a.conn.WriteJSON(msg{ 139 | Type: historyMsg, 140 | Data: msgs, 141 | }) 142 | 143 | } 144 | 145 | func (a *Agent) loop(mc chan *goch.Message) { 146 | go func() { 147 | for { 148 | if a.closed { 149 | return 150 | } 151 | 152 | _, r, err := a.conn.NextReader() 153 | if err != nil { 154 | writeErr(a.conn, err.Error()) 155 | continue 156 | } 157 | 158 | a.handleClientMsg(r) 159 | } 160 | }() 161 | 162 | go func() { 163 | defer a.closeSub() 164 | defer a.conn.Close() 165 | for { 166 | select { 167 | case m := <-mc: 168 | a.conn.WriteJSON(msg{ 169 | Type: chatMsg, 170 | Data: m, 171 | }) 172 | 173 | a.store.UpdateLastClientSeq(a.uid, a.chat.Name, m.Seq) 174 | case <-a.done: 175 | return 176 | } 177 | } 178 | }() 179 | } 180 | 181 | func (a *Agent) handleClientMsg(r io.Reader) { 182 | var message struct { 183 | Type msgT `json:"type"` 184 | Data json.RawMessage `json:"data,omitempty"` 185 | } 186 | 187 | err := json.NewDecoder(r).Decode(&message) 188 | if err != nil { 189 | writeErr(a.conn, fmt.Sprintf("invalid message format: %v", err)) 190 | return 191 | } 192 | 193 | switch message.Type { 194 | case chatMsg: 195 | a.handleChatMsg(message.Data) 196 | case historyReqMsg: 197 | a.handleHistoryReqMsg(message.Data) 198 | } 199 | } 200 | 201 | type message struct { 202 | Meta map[string]string `json:"meta"` 203 | Seq uint64 `json:"seq"` 204 | Text string `json:"text"` 205 | } 206 | 207 | func (a *Agent) handleChatMsg(raw json.RawMessage) { 208 | var msg message 209 | 210 | err := json.Unmarshal(raw, &msg) 211 | if err != nil { 212 | writeErr(a.conn, fmt.Sprintf("invalid text message format: %v", err)) 213 | return 214 | } 215 | 216 | if msg.Text == "" { 217 | writeErr(a.conn, "sent empty message") 218 | return 219 | } 220 | 221 | if len(msg.Text) > 1024 { 222 | writeErr(a.conn, "exceeded max message length of 1024 characters") 223 | return 224 | } 225 | 226 | err = a.mb.Send(a.chat.Name, &goch.Message{ 227 | Meta: msg.Meta, 228 | Text: msg.Text, 229 | Seq: msg.Seq, 230 | FromName: a.displayName, 231 | FromUID: a.uid, 232 | Time: time.Now().UnixNano(), 233 | }) 234 | if err != nil { 235 | writeErr(a.conn, fmt.Sprintf("could not forward your message. try again: %v", err)) 236 | } 237 | } 238 | 239 | func (a *Agent) handleHistoryReqMsg(raw json.RawMessage) { 240 | var req struct { 241 | To uint64 `json:"to"` 242 | } 243 | 244 | err := json.Unmarshal(raw, &req) 245 | if err != nil { 246 | writeErr(a.conn, fmt.Sprintf("invalid history request message format: %v", err)) 247 | return 248 | } 249 | 250 | if req.To <= 0 { 251 | return 252 | } 253 | 254 | msgs, err := a.buildHistoryBatch(req.To) 255 | if err != nil { 256 | writeErr(a.conn, fmt.Sprintf("could not fetch chat history: %v", err)) 257 | return 258 | } 259 | 260 | if err := a.conn.WriteJSON(msg{ 261 | Type: historyMsg, 262 | Data: msgs, 263 | }); err != nil { 264 | writeErr(a.conn, fmt.Sprintf("could not write message: %v", err)) 265 | } 266 | } 267 | 268 | func (a *Agent) buildHistoryBatch(to uint64) ([]*goch.Message, error) { 269 | var offset uint64 270 | 271 | if to >= maxHistoryCount { 272 | offset = to - maxHistoryCount 273 | } 274 | 275 | mc := make(chan *goch.Message) 276 | 277 | close, err := a.mb.Subscribe(a.chat.Name, "", offset, mc) 278 | if err != nil { 279 | return nil, err 280 | } 281 | 282 | defer close() 283 | 284 | var msgs []*goch.Message 285 | 286 | for { 287 | msg := <-mc 288 | if msg.Seq >= to { 289 | break 290 | } 291 | msgs = append(msgs, msg) 292 | } 293 | 294 | return msgs, nil 295 | } 296 | 297 | func writeErr(conn *websocket.Conn, err string) { 298 | conn.WriteJSON(msg{Error: err, Type: errorMsg}) 299 | } 300 | 301 | func writeFatal(conn *websocket.Conn, err string) { 302 | conn.WriteJSON(msg{Error: err, Type: errorMsg}) 303 | conn.Close() 304 | } 305 | 306 | func (a *Agent) setUser(u *goch.User) { 307 | a.uid = u.UID 308 | a.displayName = u.DisplayName 309 | } 310 | -------------------------------------------------------------------------------- /internal/broker/broker_test.go: -------------------------------------------------------------------------------- 1 | package broker_test 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "testing" 7 | "time" 8 | 9 | "github.com/ribice/goch" 10 | "github.com/ribice/goch/internal/broker" 11 | ) 12 | 13 | var errTest = errors.New("error used for testing purposes") 14 | 15 | func TestSubscribe(t *testing.T) { 16 | cases := []struct { 17 | name string 18 | chat string 19 | nick string 20 | start uint64 21 | n int 22 | queue queue 23 | ingest ingest 24 | want []goch.Message 25 | wantErr bool 26 | }{ 27 | { 28 | name: "subscribe seq error", 29 | chat: "general", 30 | nick: "me", 31 | queue: queue{ 32 | SubscribeSeqFunc: func(c string, n string, s uint64, f func(uint64, []byte)) (io.Closer, error) { 33 | return nil, errTest 34 | }, 35 | }, 36 | start: 0, 37 | wantErr: true, 38 | }, 39 | { 40 | name: "ingest run error", 41 | chat: "general", 42 | nick: "me", 43 | queue: queue{ 44 | SubscribeSeqFunc: func(c string, n string, s uint64, f func(uint64, []byte)) (io.Closer, error) { 45 | return &cl{}, nil 46 | }, 47 | }, 48 | ingest: ingest{ 49 | RunFn: func(string) (func(), error) { 50 | return nil, errTest 51 | }, 52 | }, 53 | start: 0, 54 | wantErr: true, 55 | }, 56 | { 57 | name: "dont send own messages", 58 | chat: "general", 59 | nick: "me", 60 | start: 0, 61 | n: 3, 62 | queue: queue{ 63 | SubscribeSeqFunc: func(c string, n string, s uint64, f func(uint64, []byte)) (io.Closer, error) { 64 | msgs := []goch.Message{ 65 | {FromUID: "john", Text: "foo msg"}, 66 | {FromUID: n, Text: "foo msg"}, 67 | {FromUID: "john", Text: "foo msg"}, 68 | {FromUID: "john", Text: "foo msg"}, 69 | {FromUID: n, Text: "foo msg"}, 70 | } 71 | 72 | go func() { 73 | for i, m := range msgs { 74 | bts, _ := m.Encode() 75 | f(uint64(i), bts) 76 | } 77 | }() 78 | 79 | return &cl{}, nil 80 | }, 81 | }, 82 | ingest: ingest{ 83 | RunFn: func(string) (func(), error) { return func() {}, nil }, 84 | }, 85 | want: []goch.Message{ 86 | {FromUID: "john", Text: "foo msg", Seq: 0}, 87 | {FromUID: "john", Text: "foo msg", Seq: 2}, 88 | {FromUID: "john", Text: "foo msg", Seq: 3}, 89 | }, 90 | wantErr: false, 91 | }, 92 | { 93 | name: "decoding error", 94 | chat: "general", 95 | nick: "me", 96 | n: 3, 97 | queue: queue{ 98 | SubscribeSeqFunc: func(c string, n string, s uint64, f func(uint64, []byte)) (io.Closer, error) { 99 | msgs := []goch.Message{ 100 | {FromUID: "john", Text: "foo msg"}, 101 | {FromUID: n, Text: "foo msg"}, 102 | {FromUID: "john", Text: "foo msg"}, 103 | {FromUID: "john", Text: "foo msg"}, 104 | {FromUID: n, Text: "foo msg"}, 105 | } 106 | 107 | go func() { 108 | for i, m := range msgs { 109 | bts, _ := m.Encode() 110 | if i == 2 { 111 | f(uint64(i), []byte("xxxx")) 112 | continue 113 | } 114 | f(uint64(i), bts) 115 | } 116 | }() 117 | 118 | return &cl{}, nil 119 | }, 120 | }, 121 | ingest: ingest{ 122 | RunFn: func(string) (func(), error) { return func() {}, nil }, 123 | }, 124 | want: []goch.Message{ 125 | {FromUID: "john", Text: "foo msg", Seq: 0}, 126 | {FromUID: "broker", Text: "broker: message unavailable: decoding error", Seq: 2}, 127 | {FromUID: "john", Text: "foo msg", Seq: 3}, 128 | }, 129 | wantErr: false, 130 | }, 131 | } 132 | 133 | for _, tc := range cases { 134 | t.Run(tc.name, func(t *testing.T) { 135 | b := broker.New(&tc.queue, store{}, &tc.ingest) 136 | 137 | c := make(chan *goch.Message) 138 | 139 | close, err := b.Subscribe(tc.chat, tc.nick, tc.start, c) 140 | 141 | if (err != nil) != tc.wantErr { 142 | t.Errorf("error = %v, wantErr %v", err, tc.wantErr) 143 | return 144 | } 145 | 146 | if err != nil { 147 | return 148 | } 149 | 150 | defer close() 151 | 152 | var msgs []goch.Message 153 | 154 | for i := 0; i < tc.n; i++ { 155 | msg := <-c 156 | msgs = append(msgs, *msg) 157 | } 158 | 159 | if len(tc.want) != len(msgs) { 160 | t.Errorf("invalid number of messages. want: %d, got: %d", len(tc.want), len(msgs)) 161 | } 162 | 163 | for _, w := range tc.want { 164 | found := false 165 | for _, g := range msgs { 166 | if w.Text == g.Text && w.Seq == g.Seq { 167 | found = true 168 | } 169 | } 170 | if !found { 171 | t.Errorf("unexpected response. want: %v, got: %v", tc.want, msgs) 172 | } 173 | } 174 | 175 | if !tc.ingest.RunCalled { 176 | t.Errorf("ingest run should have been called but was not") 177 | } 178 | }) 179 | } 180 | } 181 | 182 | func TestSubscribeNew(t *testing.T) { 183 | cases := []struct { 184 | name string 185 | chat string 186 | nick string 187 | n int 188 | queue queue 189 | ingest ingest 190 | want []goch.Message 191 | wantErr bool 192 | }{ 193 | { 194 | name: "subscribe seq error", 195 | chat: "general", 196 | nick: "me", 197 | queue: queue{ 198 | SubscribeTimestampFunc: func(c string, n string, t time.Time, f func(uint64, []byte)) (io.Closer, error) { 199 | return nil, errTest 200 | }, 201 | }, 202 | wantErr: true, 203 | }, 204 | { 205 | name: "ingest run error", 206 | chat: "general", 207 | nick: "me", 208 | queue: queue{ 209 | SubscribeTimestampFunc: func(c string, n string, t time.Time, f func(uint64, []byte)) (io.Closer, error) { 210 | return &cl{}, nil 211 | }, 212 | }, 213 | ingest: ingest{ 214 | RunFn: func(string) (func(), error) { 215 | return nil, errTest 216 | }, 217 | }, 218 | wantErr: true, 219 | }, 220 | { 221 | name: "dont send own messages", 222 | chat: "general", 223 | nick: "me", 224 | n: 3, 225 | queue: queue{ 226 | SubscribeTimestampFunc: func(c string, n string, t time.Time, f func(uint64, []byte)) (io.Closer, error) { 227 | msgs := []goch.Message{ 228 | {FromUID: "john", Text: "foo msg"}, 229 | {FromUID: n, Text: "foo msg"}, 230 | {FromUID: "john", Text: "foo msg"}, 231 | {FromUID: "john", Text: "foo msg"}, 232 | {FromUID: n, Text: "foo msg"}, 233 | } 234 | 235 | go func() { 236 | for i, m := range msgs { 237 | bts, _ := m.Encode() 238 | f(uint64(i), bts) 239 | } 240 | }() 241 | 242 | return &cl{}, nil 243 | }, 244 | }, 245 | ingest: ingest{ 246 | RunFn: func(string) (func(), error) { return func() {}, nil }, 247 | }, 248 | want: []goch.Message{ 249 | {FromUID: "john", Text: "foo msg", Seq: 0}, 250 | {FromUID: "john", Text: "foo msg", Seq: 2}, 251 | {FromUID: "john", Text: "foo msg", Seq: 3}, 252 | }, 253 | wantErr: false, 254 | }, 255 | { 256 | name: "decoding error", 257 | chat: "general", 258 | nick: "me", 259 | n: 3, 260 | queue: queue{ 261 | SubscribeTimestampFunc: func(c string, n string, t time.Time, f func(uint64, []byte)) (io.Closer, error) { 262 | msgs := []goch.Message{ 263 | {FromUID: "john", Text: "foo msg"}, 264 | {FromUID: n, Text: "foo msg"}, 265 | {FromUID: "john", Text: "foo msg"}, 266 | {FromUID: "john", Text: "foo msg"}, 267 | {FromUID: n, Text: "foo msg"}, 268 | } 269 | 270 | go func() { 271 | for i, m := range msgs { 272 | bts, _ := m.Encode() 273 | if i == 2 { 274 | f(uint64(i), []byte("xxxx")) 275 | continue 276 | } 277 | f(uint64(i), bts) 278 | } 279 | }() 280 | 281 | return &cl{}, nil 282 | }, 283 | }, 284 | ingest: ingest{ 285 | RunFn: func(string) (func(), error) { return func() {}, nil }, 286 | }, 287 | want: []goch.Message{ 288 | {FromUID: "john", Text: "foo msg", Seq: 0}, 289 | {FromUID: "broker", Text: "broker: message unavailable: decoding error", Seq: 2}, 290 | {FromUID: "john", Text: "foo msg", Seq: 3}, 291 | }, 292 | wantErr: false, 293 | }, 294 | } 295 | 296 | for _, tc := range cases { 297 | t.Run(tc.name, func(t *testing.T) { 298 | b := broker.New(&tc.queue, store{}, &tc.ingest) 299 | 300 | c := make(chan *goch.Message) 301 | 302 | close, err := b.SubscribeNew(tc.chat, tc.nick, c) 303 | 304 | if (err != nil) != tc.wantErr { 305 | t.Errorf("error = %v, wantErr %v", err, tc.wantErr) 306 | return 307 | } 308 | 309 | if err != nil { 310 | return 311 | } 312 | 313 | defer close() 314 | 315 | var msgs []goch.Message 316 | 317 | for i := 0; i < tc.n; i++ { 318 | msg := <-c 319 | msgs = append(msgs, *msg) 320 | } 321 | 322 | if len(tc.want) != len(msgs) { 323 | t.Errorf("invalid number of messages. want: %d got: %d", len(tc.want), len(msgs)) 324 | } 325 | 326 | for _, w := range tc.want { 327 | found := false 328 | for _, g := range msgs { 329 | if w.Text == g.Text && w.Seq == g.Seq { 330 | found = true 331 | } 332 | } 333 | if !found { 334 | t.Errorf("unexpected response. want: %v, got: %v", tc.want, msgs) 335 | } 336 | } 337 | 338 | if !tc.ingest.RunCalled { 339 | t.Errorf("ingest run should have been called but was not") 340 | } 341 | }) 342 | } 343 | } 344 | 345 | func TestSend(t *testing.T) { 346 | cases := []struct { 347 | name string 348 | msg *goch.Message 349 | q queue 350 | wantErr bool 351 | }{{ 352 | name: "Fail on sending message", 353 | msg: &goch.Message{FromUID: "123"}, 354 | q: queue{ 355 | SendFunc: func(string, []byte) error { 356 | return errors.New("failed sending message") 357 | }, 358 | }, 359 | wantErr: true, 360 | }} 361 | for _, tc := range cases { 362 | t.Run(tc.name, func(t *testing.T) { 363 | b := broker.New(&tc.q, nil, nil) 364 | err := b.Send("chatID", tc.msg) 365 | if tc.wantErr != (err != nil) { 366 | t.Errorf("Expected err (%v), received %v", tc.wantErr, err) 367 | } 368 | 369 | }) 370 | } 371 | } 372 | 373 | type queue struct { 374 | SubscribeSeqFunc func(string, string, uint64, func(uint64, []byte)) (io.Closer, error) 375 | SubscribeTimestampFunc func(string, string, time.Time, func(uint64, []byte)) (io.Closer, error) 376 | SendFunc func(string, []byte) error 377 | } 378 | 379 | func (q *queue) SubscribeSeq(id string, nick string, start uint64, f func(uint64, []byte)) (io.Closer, error) { 380 | return q.SubscribeSeqFunc(id, nick, start, f) 381 | } 382 | 383 | func (q *queue) SubscribeTimestamp(id string, nick string, t time.Time, f func(uint64, []byte)) (io.Closer, error) { 384 | return q.SubscribeTimestampFunc(id, nick, t, f) 385 | } 386 | 387 | func (q *queue) Send(s string, b []byte) error { 388 | return q.SendFunc(s, b) 389 | } 390 | 391 | type cl struct{} 392 | 393 | func (c *cl) Close() error { return nil } 394 | 395 | type ingest struct { 396 | RunFn func(string) (func(), error) 397 | RunCalled bool 398 | } 399 | 400 | func (i *ingest) Run(s string) (func(), error) { 401 | i.RunCalled = true 402 | return i.RunFn(s) 403 | } 404 | 405 | type store struct{} 406 | 407 | func (s store) UpdateLastClientSeq(string, string, uint64) {} 408 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/DataDog/datadog-go v2.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= 2 | github.com/armon/go-metrics v0.0.0-20190430140413-ec5e00d3c878 h1:EFSB7Zo9Eg91v7MJPVsifUysc/wPdN+NOnVe6bWbdBM= 3 | github.com/armon/go-metrics v0.0.0-20190430140413-ec5e00d3c878/go.mod h1:3AMJUQhVx52RsWOnlkpikZr01T/yAVN2gn0861vByNg= 4 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= 5 | github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= 6 | github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= 7 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= 11 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 12 | github.com/go-redis/redis v6.15.2+incompatible h1:9SpNVG76gr6InJGxoZ6IuuxaCOQwDAhzyXg+Bs+0Sb4= 13 | github.com/go-redis/redis v6.15.2+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= 14 | github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA= 15 | github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= 16 | github.com/gogo/protobuf v1.2.1 h1:/s5zKNz0uPFCZ5hddgPdo2TK2TVrUNMn0OOX8/aZMTE= 17 | github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= 18 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 19 | github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= 20 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 21 | github.com/gorilla/mux v1.7.1 h1:Dw4jY2nghMMRsh1ol8dv1axHkDwMQK2DHerMNJsIpJU= 22 | github.com/gorilla/mux v1.7.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= 23 | github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= 24 | github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= 25 | github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= 26 | github.com/hashicorp/go-hclog v0.9.1 h1:9PZfAcVEvez4yhLH2TBU64/h/z4xlFI80cWXRrxuKuM= 27 | github.com/hashicorp/go-hclog v0.9.1/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= 28 | github.com/hashicorp/go-immutable-radix v1.0.0 h1:AKDB1HM5PWEA7i4nhcpwOrO2byshxBjXVn/J/3+z5/0= 29 | github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= 30 | github.com/hashicorp/go-msgpack v0.5.5 h1:i9R9JSrqIz0QVLz3sz+i3YJdT7TTSLcfLLzJi9aZTuI= 31 | github.com/hashicorp/go-msgpack v0.5.5/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= 32 | github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= 33 | github.com/hashicorp/go-uuid v1.0.0 h1:RS8zrF7PhGwyNPOtxSClXXj9HA8feRnJzgnI1RJCSnM= 34 | github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= 35 | github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo= 36 | github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 37 | github.com/hashicorp/raft v1.1.0 h1:qPMePEczgbkiQsqCsRfuHRqvDUO+zmAInDaD5ptXlq0= 38 | github.com/hashicorp/raft v1.1.0/go.mod h1:4Ak7FSPnuvmb0GV6vgIAJ4vYT4bek9bb6Q+7HVbyzqM= 39 | github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= 40 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 41 | github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= 42 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 43 | github.com/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4= 44 | github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 45 | github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 46 | github.com/nats-io/gnatsd v1.4.1 h1:RconcfDeWpKCD6QIIwiVFcvForlXpWeJP7i5/lDLy44= 47 | github.com/nats-io/gnatsd v1.4.1/go.mod h1:nqco77VO78hLCJpIcVfygDP2rPGfsEHkGTUk94uh5DQ= 48 | github.com/nats-io/go-nats v1.7.2 h1:cJujlwCYR8iMz5ofZSD/p2WLW8FabhkQ2lIEVbSvNSA= 49 | github.com/nats-io/go-nats v1.7.2/go.mod h1:+t7RHT5ApZebkrQdnn6AhQJmhJJiKAvJUio1PiiCtj0= 50 | github.com/nats-io/go-nats-streaming v0.4.2 h1:e7Fs4yxvFTs8N5xKFoJyw0sVW2heJwYvrUWfdf9VQlE= 51 | github.com/nats-io/go-nats-streaming v0.4.2/go.mod h1:gfq4R3c9sKAINOpelo0gn/b9QDMBZnmrttcsNF+lqyo= 52 | github.com/nats-io/jwt v0.2.6 h1:eAyoYvGgGLXR2EpnsBUvi/FcFrBqN6YKFVbOoEfPN4k= 53 | github.com/nats-io/jwt v0.2.6/go.mod h1:mQxQ0uHQ9FhEVPIcTSKwx2lqZEpXWWcCgA7R6NrWvvY= 54 | github.com/nats-io/nats-server v1.4.1 h1:Ul1oSOGNV/L8kjr4v6l2f9Yet6WY+LevH1/7cRZ/qyA= 55 | github.com/nats-io/nats-server v1.4.1/go.mod h1:c8f/fHd2B6Hgms3LtCaI7y6pC4WD1f4SUxcCud5vhBc= 56 | github.com/nats-io/nats-server/v2 v2.0.0 h1:rbFV7gfUPErVdKImVMOlW8Qb1V22nlcpqup5cb9rYa8= 57 | github.com/nats-io/nats-server/v2 v2.0.0/go.mod h1:RyVdsHHvY4B6c9pWG+uRLpZ0h0XsqiuKp2XCTurP5LI= 58 | github.com/nats-io/nats-streaming-server v0.15.1 h1:NLQg18mp68e17v+RJpXyPdA7ZH4osFEZQzV3tdxT6/M= 59 | github.com/nats-io/nats-streaming-server v0.15.1/go.mod h1:bJ1+2CS8MqvkGfr/NwnCF+Lw6aLnL3F5kenM8bZmdCw= 60 | github.com/nats-io/nats.go v1.8.1 h1:6lF/f1/NN6kzUDBz6pyvQDEXO39jqXcWRLu/tKjtOUQ= 61 | github.com/nats-io/nats.go v1.8.1/go.mod h1:BrFz9vVn0fU3AcH9Vn4Kd7W0NpJ651tD5omQ3M8LwxM= 62 | github.com/nats-io/nkeys v0.0.2 h1:+qM7QpgXnvDDixitZtQUBDY9w/s9mu1ghS+JIbsrx6M= 63 | github.com/nats-io/nkeys v0.0.2/go.mod h1:dab7URMsZm6Z/jp9Z5UGa87Uutgc2mVpXLC4B7TDb/4= 64 | github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= 65 | github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= 66 | github.com/nats-io/stan.go v0.4.5 h1:lPZ9y1jVGiXcTaUc1SnEIWPYfh0avuEiHBePNJYgpPk= 67 | github.com/nats-io/stan.go v0.4.5/go.mod h1:Ji7mK6gRZJSH1nc3ZJH6vi7zn/QnZhpR9Arm4iuzsUQ= 68 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 69 | github.com/onsi/ginkgo v1.8.0 h1:VkHVNpR4iVnU8XQR6DBm8BqYjN7CRzw+xKUbVVbbW9w= 70 | github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 71 | github.com/onsi/gomega v1.5.0 h1:izbySO9zDPmjJ8rDjLvkA2zJHIo+HkYXHnf7eN7SSyo= 72 | github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= 73 | github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= 74 | github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= 75 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 76 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 77 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 78 | github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM= 79 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 80 | github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= 81 | github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 82 | github.com/prometheus/procfs v0.0.2 h1:6LJUbpNm42llc4HRCuvApCSWB/WfhuNo9K98Q9sNGfs= 83 | github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= 84 | github.com/ribice/msv v0.0.0-20190710162041-f63af07e33fc h1:3UVAv8Jm2pTeF7fPWkDL8OPyn26gcgpMGjrkbTKbC4A= 85 | github.com/ribice/msv v0.0.0-20190710162041-f63af07e33fc/go.mod h1:poXH0b3a0jP7amNiRphqKYvu91DgKyhMAsIKrhMQz98= 86 | github.com/rs/xid v1.2.1 h1:mhH9Nq+C1fY2l1XIpgxIiUOfNpRBYH1kKcr+qfKgjRc= 87 | github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= 88 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 89 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 90 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= 91 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 92 | github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= 93 | github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= 94 | github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= 95 | go.etcd.io/bbolt v1.3.2 h1:Z/90sZLPOeCy2PwprqkFa25PdkusRzaj9P8zm/KNyvk= 96 | go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= 97 | golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 98 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 99 | golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5 h1:8dUaAV7K4uHsF56JQWkprecIQKdPHtR9jCHF5nB8uzc= 100 | golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 101 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 102 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 103 | golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 104 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 105 | golang.org/x/net v0.0.0-20190420063019-afa5a82059c6 h1:HdqqaWmYAUI7/dmByKKEw+yxDksGSo+9GjkUc9Zp34E= 106 | golang.org/x/net v0.0.0-20190420063019-afa5a82059c6/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 107 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 108 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 109 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 110 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 111 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 112 | golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI= 113 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 114 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 115 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 116 | golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 117 | google.golang.org/appengine v1.6.0 h1:Tfd7cKwKbFRsI8RMAD3oqqw7JPFRrvFlOsfbgVkjOOw= 118 | google.golang.org/appengine v1.6.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 119 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 120 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 121 | gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= 122 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 123 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 124 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 125 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 126 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 127 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 128 | -------------------------------------------------------------------------------- /internal/chat/chat_test.go: -------------------------------------------------------------------------------- 1 | package chat_test 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "io/ioutil" 8 | "net/http" 9 | "net/http/httptest" 10 | "reflect" 11 | "strings" 12 | "testing" 13 | 14 | "github.com/gorilla/mux" 15 | "github.com/ribice/goch" 16 | "github.com/ribice/goch/internal/chat" 17 | "github.com/ribice/goch/pkg/config" 18 | ) 19 | 20 | var cfg = &config.Config{ 21 | Limits: map[goch.Limit][2]int{ 22 | goch.DisplayNameLimit: [2]int{3, 128}, 23 | goch.UIDLimit: [2]int{20, 20}, 24 | goch.SecretLimit: [2]int{20, 50}, 25 | goch.ChanLimit: [2]int{10, 20}, 26 | goch.ChanSecretLimit: [2]int{20, 20}, 27 | }, 28 | LimitErrs: map[goch.Limit]error{ 29 | goch.DisplayNameLimit: errors.New("displayName must be between 3 and 128 characters long"), 30 | goch.UIDLimit: errors.New("uid must be exactly 20 characters long"), 31 | goch.SecretLimit: errors.New("secret must be between 20 and 50 characters long"), 32 | goch.ChanLimit: errors.New("channel must be between 10 and 20 characters long"), 33 | goch.ChanSecretLimit: errors.New("channelSecret must be exactly 20 characters long"), 34 | }, 35 | } 36 | 37 | type createChanReq struct { 38 | Name string `json:"name"` 39 | IsPrivate bool `json:"is_private"` 40 | } 41 | 42 | type createChanResp struct { 43 | Secret string `json:"secret"` 44 | } 45 | 46 | func middleware(h http.Handler) http.Handler { 47 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 48 | h.ServeHTTP(w, r) 49 | }) 50 | } 51 | func TestCreateChannel(t *testing.T) { 52 | cases := []struct { 53 | name string 54 | store *store 55 | req *createChanReq 56 | wantMessage string 57 | wantCode int 58 | }{ 59 | { 60 | name: "ChannelName length validation fail", 61 | req: &createChanReq{}, 62 | wantCode: http.StatusBadRequest, 63 | wantMessage: "error binding request: channel must be between 10 and 20 characters long", 64 | }, 65 | { 66 | name: "ChannelName alfanum fail", 67 | req: &createChanReq{Name: "$%a!"}, 68 | wantCode: http.StatusBadRequest, 69 | wantMessage: "error binding request: name must contain only alphanumeric and underscores", 70 | }, 71 | { 72 | name: "Fail on saving channel", 73 | req: &createChanReq{Name: "abcdefghijklmnop"}, 74 | store: &store{ 75 | SaveFunc: func(*goch.Chat) error { 76 | return errors.New("error saving channel") 77 | }, 78 | }, 79 | wantMessage: "could not create channel: error saving channel", 80 | wantCode: http.StatusInternalServerError, 81 | }, 82 | { 83 | name: "Create public channel", 84 | req: &createChanReq{Name: "abcdefghijklmnop"}, 85 | store: &store{ 86 | SaveFunc: func(*goch.Chat) error { 87 | return nil 88 | }, 89 | }, 90 | wantCode: http.StatusOK, 91 | }, 92 | { 93 | name: "Create private channel", 94 | req: &createChanReq{Name: "abcdefghijklmnop", IsPrivate: true}, 95 | store: &store{ 96 | SaveFunc: func(*goch.Chat) error { 97 | return nil 98 | }, 99 | }, 100 | wantCode: http.StatusOK, 101 | }, 102 | } 103 | 104 | for _, tc := range cases { 105 | t.Run(tc.name, func(t *testing.T) { 106 | m := mux.NewRouter() 107 | chat.New(m, tc.store, cfg, middleware) 108 | srv := httptest.NewServer(m) 109 | defer srv.Close() 110 | path := srv.URL + "/admin/channels" 111 | 112 | req, err := json.Marshal(tc.req) 113 | if err != nil { 114 | t.Error(err) 115 | } 116 | 117 | res, err := http.Post(path, "application/json", bytes.NewBuffer(req)) 118 | if err != nil { 119 | t.Error(err) 120 | } 121 | 122 | if tc.wantCode != res.StatusCode { 123 | t.Errorf("unexpected response code. want: %d, got: %d", tc.wantCode, res.StatusCode) 124 | } 125 | 126 | bts, err := ioutil.ReadAll(res.Body) 127 | if err != nil { 128 | t.Error(err) 129 | } 130 | 131 | if tc.wantMessage != "" && tc.wantMessage != strings.TrimSpace(string(bts)) { 132 | t.Errorf("unexpected response. want: %v, got: %v", tc.wantMessage, string(bts)) 133 | } 134 | 135 | }) 136 | } 137 | } 138 | 139 | type registerReq struct { 140 | UID string `json:"uid"` 141 | DisplayName string `json:"display_name"` 142 | Email string `json:"email"` 143 | Secret string `json:"secret"` 144 | Channel string `json:"channel"` 145 | ChannelSecret string `json:"channel_secret"` 146 | } 147 | 148 | type registerResp struct { 149 | Secret string `json:"secret"` 150 | } 151 | 152 | func TestRegister(t *testing.T) { 153 | cases := []struct { 154 | name string 155 | store *store 156 | req registerReq 157 | wantCode int 158 | wantErrMsg string 159 | }{ 160 | { 161 | name: "validation Test: Empty request", 162 | wantCode: http.StatusBadRequest, 163 | wantErrMsg: "error binding request: invalid email address", 164 | }, 165 | { 166 | name: "validation Test: Invalid UID", 167 | wantCode: http.StatusBadRequest, 168 | req: registerReq{ 169 | UID: "joe??", 170 | Channel: "foo", 171 | DisplayName: "qwertyuiopasdfghjklvv", 172 | Email: "ribice@gmail.com", 173 | ChannelSecret: "qwertyuiopasdfghjklvv", 174 | }, 175 | wantErrMsg: "error binding request: uid must contain only alphanumeric and underscores", 176 | }, 177 | { 178 | name: "validation Test: Invalid Secret", 179 | wantCode: http.StatusBadRequest, 180 | req: registerReq{ 181 | UID: "12324Ab", 182 | Channel: "foo", 183 | DisplayName: "qwertyuiopasdfghjklvv", 184 | Email: "ribice@gmail.com", 185 | ChannelSecret: ">??>^^@!#$@$1@$", 186 | Secret: ">??>^^@!#$@$1@$", 187 | }, 188 | wantErrMsg: "error binding request: secret must contain only alphanumeric and underscores", 189 | }, 190 | { 191 | store: &store{ 192 | GetFunc: func(id string) (*goch.Chat, error) { 193 | return nil, errors.New("err fetching chan") 194 | }, 195 | }, 196 | name: "Error fetching channel", 197 | req: registerReq{UID: "EmirABCDEF1234567890", Channel: "foo1234567", Email: "ribice@gmail.com", ChannelSecret: "ABCDEFGHIJDKLOMNSOPR", DisplayName: "Emir", Secret: "12345678901234567890ABC"}, 198 | wantCode: http.StatusInternalServerError, 199 | wantErrMsg: "invalid secret or unexisting channel: err fetching chan", 200 | }, 201 | { 202 | store: &store{ 203 | GetFunc: func(id string) (*goch.Chat, error) { 204 | return &goch.Chat{Secret: "foo"}, nil 205 | }, 206 | }, 207 | name: "test invalid secret", 208 | req: registerReq{UID: "EmirABCDEF1234567890", Channel: "foo1234567", Email: "ribice@gmail.com", ChannelSecret: "ABCDEFGHIJDKLOMNSOPR", DisplayName: "Emir", Secret: "12345678901234567890ABC"}, 209 | wantCode: http.StatusInternalServerError, 210 | }, 211 | { 212 | store: &store{ 213 | GetFunc: func(id string) (*goch.Chat, error) { 214 | return &goch.Chat{Secret: "ABCDEFGHIJDKLOMNSOPR", Members: map[string]*goch.User{ 215 | "EmirABCDEF1234567890": &goch.User{}, 216 | }}, nil 217 | }, 218 | }, 219 | name: "test uid already registered", 220 | req: registerReq{UID: "EmirABCDEF1234567890", Channel: "foo1234567", Email: "ribice@gmail.com", ChannelSecret: "ABCDEFGHIJDKLOMNSOPR", DisplayName: "Emir", Secret: "12345678901234567890ABC"}, 221 | wantCode: http.StatusInternalServerError, 222 | wantErrMsg: "error registering to channel: chat: uid already registered in this chat", 223 | }, 224 | { 225 | store: &store{ 226 | GetFunc: func(id string) (*goch.Chat, error) { 227 | return &goch.Chat{Secret: "ABCDEFGHIJDKLOMNSOPR", Members: map[string]*goch.User{ 228 | "EmirABCDEF1234567890ASD": &goch.User{}, 229 | }}, nil 230 | }, 231 | SaveFunc: func(*goch.Chat) error { 232 | return errors.New("error saving to redis") 233 | }, 234 | }, 235 | name: "test uid already registered", 236 | req: registerReq{UID: "EmirABCDEF1234567890", Channel: "foo1234567", Email: "ribice@gmail.com", ChannelSecret: "ABCDEFGHIJDKLOMNSOPR", DisplayName: "Emir", Secret: "12345678901234567890ABC"}, 237 | wantCode: http.StatusInternalServerError, 238 | wantErrMsg: "could not update channel membership: error saving to redis", 239 | }, 240 | { 241 | store: &store{ 242 | GetFunc: func(id string) (*goch.Chat, error) { 243 | return &goch.Chat{Secret: "ABCDEFGHIJDKLOMNSOPR", Members: map[string]*goch.User{ 244 | "EmirABCDEF1234567890ASD": &goch.User{}, 245 | }}, nil 246 | }, 247 | SaveFunc: func(ch *goch.Chat) error { return nil }, 248 | }, 249 | name: "success", 250 | req: registerReq{UID: "EmirABCDEF1234567890", Channel: "foo1234567", Email: "ribice@gmail.com", ChannelSecret: "ABCDEFGHIJDKLOMNSOPR", DisplayName: "Emir", Secret: "12345678901234567890ABC"}, 251 | wantCode: http.StatusOK, 252 | }, 253 | } 254 | 255 | type registerResp struct { 256 | Secret string `json:"string"` 257 | } 258 | 259 | for _, tc := range cases { 260 | t.Run(tc.name, func(t *testing.T) { 261 | m := mux.NewRouter() 262 | chat.New(m, tc.store, cfg, middleware) 263 | srv := httptest.NewServer(m) 264 | defer srv.Close() 265 | path := srv.URL + "/channels/register" 266 | 267 | req, err := json.Marshal(tc.req) 268 | if err != nil { 269 | t.Error(err) 270 | } 271 | 272 | res, err := http.Post(path, "application/json", bytes.NewBuffer(req)) 273 | if err != nil { 274 | t.Error(err) 275 | } 276 | 277 | if res.StatusCode != tc.wantCode { 278 | t.Errorf("unexpected response code. want: %d, got: %d", tc.wantCode, res.StatusCode) 279 | } 280 | 281 | bts, err := ioutil.ReadAll(res.Body) 282 | if err != nil { 283 | t.Error(err) 284 | } 285 | 286 | msg := strings.TrimSpace(string(bts)) 287 | 288 | if tc.wantErrMsg != "" && tc.wantErrMsg != msg { 289 | t.Errorf("expected message: %v but got: %v", tc.wantErrMsg, msg) 290 | } 291 | 292 | if tc.wantCode == http.StatusOK { 293 | 294 | secret := msg[11 : len(msg)-2] 295 | 296 | if tc.req.Secret != "" && secret != tc.req.Secret { 297 | t.Errorf("invalid secret received, expected %v got %v", tc.req.Secret, secret) 298 | } 299 | 300 | } 301 | 302 | }) 303 | } 304 | } 305 | 306 | type unreadCountResp struct { 307 | Count uint64 `json:"count"` 308 | } 309 | 310 | func TestUnreadCount(t *testing.T) { 311 | cases := []struct { 312 | name string 313 | store *store 314 | chanName string 315 | uid string 316 | wantCode int 317 | wantResp uint64 318 | }{{ 319 | name: "fail on limits", 320 | chanName: "channel", 321 | uid: "uid", 322 | wantCode: 400, 323 | }, 324 | { 325 | name: "Success", 326 | chanName: "12345678901", 327 | uid: "1234567890ABCDEFGHIJ", 328 | store: &store{ 329 | GetUnreadCountFunc: func(string, string) uint64 { return 12 }, 330 | }, 331 | wantCode: 200, 332 | wantResp: 12, 333 | }, 334 | } 335 | for _, tc := range cases { 336 | t.Run(tc.name, func(t *testing.T) { 337 | m := mux.NewRouter() 338 | chat.New(m, tc.store, cfg, middleware) 339 | srv := httptest.NewServer(m) 340 | defer srv.Close() 341 | path := srv.URL + "/admin/channels/" + tc.chanName + "/user/" + tc.uid 342 | res, err := http.Get(path) 343 | if err != nil { 344 | t.Error(err) 345 | } 346 | 347 | if res.StatusCode != tc.wantCode { 348 | t.Errorf("unexpected response code. want: %d, got: %d", tc.wantCode, res.StatusCode) 349 | } 350 | 351 | if res.StatusCode == 200 { 352 | bts, err := ioutil.ReadAll(res.Body) 353 | if err != nil { 354 | t.Error(err) 355 | } 356 | 357 | var uc unreadCountResp 358 | 359 | if err := json.Unmarshal(bts, &uc); err != nil { 360 | t.Error(err) 361 | } 362 | 363 | if uc.Count != tc.wantResp { 364 | t.Errorf("expected count: %v but got: %v", tc.wantResp, uc.Count) 365 | } 366 | } 367 | }) 368 | } 369 | } 370 | 371 | func TestListMembers(t *testing.T) { 372 | cases := []struct { 373 | name string 374 | store *store 375 | chanName string 376 | secret string 377 | wantCode int 378 | want []goch.User 379 | }{ 380 | { 381 | name: "Fail on validation", 382 | chanName: "abc", 383 | secret: "?secret=123", 384 | wantCode: http.StatusBadRequest, 385 | }, 386 | { 387 | store: &store{ 388 | GetFunc: func(id string) (*goch.Chat, error) { 389 | return nil, errors.New("err fetching chan") 390 | }, 391 | }, 392 | name: "error fetching channel", 393 | chanName: "1234567890", 394 | secret: "?secret=12345678901234567890", 395 | wantCode: http.StatusInternalServerError, 396 | }, 397 | { 398 | name: "invalid secret", 399 | store: &store{ 400 | GetFunc: func(id string) (*goch.Chat, error) { 401 | return &goch.Chat{ 402 | Secret: "invalid", 403 | }, nil 404 | }, 405 | }, 406 | chanName: "1234567890", 407 | secret: "?secret=12345678901234567890", 408 | wantCode: http.StatusInternalServerError, 409 | }, 410 | { 411 | store: &store{ 412 | GetFunc: func(id string) (*goch.Chat, error) { 413 | return &goch.Chat{ 414 | Secret: "12345678901234567890", Members: map[string]*goch.User{ 415 | "joe": {UID: "joe"}, 416 | }, 417 | }, nil 418 | }, 419 | }, 420 | name: "test success", 421 | chanName: "1234567890", 422 | secret: "?secret=12345678901234567890", 423 | want: []goch.User{{UID: "joe"}}, 424 | wantCode: http.StatusOK, 425 | }, 426 | } 427 | for _, tc := range cases { 428 | t.Run(tc.name, func(t *testing.T) { 429 | m := mux.NewRouter() 430 | chat.New(m, tc.store, cfg, middleware) 431 | srv := httptest.NewServer(m) 432 | defer srv.Close() 433 | path := srv.URL + "/channels/" + tc.chanName + tc.secret 434 | res, err := http.Get(path) 435 | if err != nil { 436 | t.Error(err) 437 | } 438 | 439 | if res.StatusCode != tc.wantCode { 440 | t.Errorf("unexpected response code. want: %d, got: %d", tc.wantCode, res.StatusCode) 441 | } 442 | 443 | if res.StatusCode == 200 { 444 | bts, err := ioutil.ReadAll(res.Body) 445 | if err != nil { 446 | t.Error(err) 447 | } 448 | 449 | var users []goch.User 450 | 451 | if err := json.Unmarshal(bts, &users); err != nil { 452 | t.Error(err) 453 | } 454 | 455 | if !reflect.DeepEqual(users, tc.want) { 456 | t.Errorf("expected users: %v but got: %v", tc.want, users) 457 | } 458 | } 459 | }) 460 | } 461 | } 462 | 463 | func TestListChannels(t *testing.T) { 464 | cases := []struct { 465 | name string 466 | store *store 467 | wantCode int 468 | want []string 469 | }{ 470 | { 471 | store: &store{ 472 | ListChansFunc: func() ([]string, error) { 473 | return nil, errors.New("err fetching chan") 474 | }, 475 | }, 476 | name: "error fetching channels", 477 | wantCode: http.StatusInternalServerError, 478 | }, 479 | { 480 | store: &store{ 481 | ListChansFunc: func() ([]string, error) { 482 | return []string{"chan1", "chan2"}, nil 483 | }, 484 | }, 485 | name: "success", 486 | wantCode: http.StatusOK, 487 | want: []string{"chan1", "chan2"}, 488 | }, 489 | } 490 | for _, tc := range cases { 491 | t.Run(tc.name, func(t *testing.T) { 492 | m := mux.NewRouter() 493 | chat.New(m, tc.store, cfg, middleware) 494 | srv := httptest.NewServer(m) 495 | defer srv.Close() 496 | path := srv.URL + "/admin/channels" 497 | res, err := http.Get(path) 498 | if err != nil { 499 | t.Error(err) 500 | } 501 | 502 | if res.StatusCode != tc.wantCode { 503 | t.Errorf("unexpected response code. want: %d, got: %d", tc.wantCode, res.StatusCode) 504 | } 505 | 506 | if res.StatusCode == 200 { 507 | bts, err := ioutil.ReadAll(res.Body) 508 | if err != nil { 509 | t.Error(err) 510 | } 511 | 512 | var chans []string 513 | 514 | if err := json.Unmarshal(bts, &chans); err != nil { 515 | t.Error(err) 516 | } 517 | 518 | if !reflect.DeepEqual(tc.want, chans) { 519 | t.Errorf("expected chans: %v but got: %v", tc.want, chans) 520 | } 521 | } 522 | 523 | }) 524 | } 525 | } 526 | 527 | type store struct { 528 | SaveFunc func(*goch.Chat) error 529 | GetFunc func(string) (*goch.Chat, error) 530 | ListChansFunc func() ([]string, error) 531 | GetUnreadCountFunc func(string, string) uint64 532 | } 533 | 534 | func (s *store) Save(c *goch.Chat) error { return s.SaveFunc(c) } 535 | func (s *store) Get(id string) (*goch.Chat, error) { return s.GetFunc(id) } 536 | func (s *store) ListChannels() ([]string, error) { return s.ListChansFunc() } 537 | func (s *store) GetUnreadCount(uid, chanName string) uint64 { 538 | return s.GetUnreadCountFunc(uid, chanName) 539 | } 540 | --------------------------------------------------------------------------------