Add initial websocket implementation

This commit is contained in:
Morgan Bazalgette
2017-02-19 18:19:59 +01:00
parent 9b296fc8ed
commit 6f9cae0bcd
25 changed files with 3036 additions and 16 deletions

View File

@@ -28,6 +28,9 @@ func (r router) Peppy(path string, a func(c *fasthttp.RequestCtx, db *sqlx.DB))
func (r router) GET(path string, handle fasthttp.RequestHandler) {
r.r.GET(path, wrap(handle))
}
func (r router) PlainGET(path string, handle fasthttp.RequestHandler) {
r.r.GET(path, handle)
}
const (
// \x1b is escape code for ESC

View File

@@ -7,11 +7,11 @@ import (
fhr "github.com/buaazp/fasthttprouter"
"github.com/getsentry/raven-go"
"github.com/jmoiron/sqlx"
"github.com/serenize/snaker"
"gopkg.in/redis.v5"
"zxq.co/ripple/rippleapi/app/internals"
"zxq.co/ripple/rippleapi/app/peppy"
"zxq.co/ripple/rippleapi/app/v1"
"zxq.co/ripple/rippleapi/app/websockets"
"zxq.co/ripple/rippleapi/common"
)
@@ -22,23 +22,11 @@ var (
red *redis.Client
)
var commonClusterfucks = map[string]string{
"RegisteredOn": "register_datetime",
"UsernameAKA": "username_aka",
}
// Start begins taking HTTP connections.
func Start(conf common.Conf, dbO *sqlx.DB) *fhr.Router {
db = dbO
cf = conf
db.MapperFunc(func(s string) string {
if x, ok := commonClusterfucks[s]; ok {
return x
}
return snaker.CamelToSnake(s)
})
rawRouter := fhr.New()
r := router{rawRouter}
// TODO: add back gzip
@@ -78,6 +66,9 @@ func Start(conf common.Conf, dbO *sqlx.DB) *fhr.Router {
// token updater
go tokenUpdater(db)
// start websocket
websockets.Start(red, db)
// peppyapi
{
r.Peppy("/api/get_user", peppy.GetUser)
@@ -152,6 +143,11 @@ func Start(conf common.Conf, dbO *sqlx.DB) *fhr.Router {
common.PrivilegeManageUser, common.PrivilegeAPIMeta)
}
// Websocket API
{
r.PlainGET("/api/v1/ws", websockets.WebsocketV1Entry)
}
// in the new osu-web, the old endpoints are also in /v1 it seems. So /shrug
{
r.Peppy("/api/v1/get_user", peppy.GetUser)

View File

@@ -8,7 +8,8 @@ import (
"zxq.co/ripple/rippleapi/common"
)
type score struct {
// Score is a score done on Ripple.
type Score struct {
ID int `json:"id"`
BeatmapMD5 string `json:"beatmap_md5"`
Score int64 `json:"score"`
@@ -31,7 +32,7 @@ type score struct {
// beatmapScore is to differentiate from userScore, as beatmapScore contains
// also an user, while userScore contains the beatmap.
type beatmapScore struct {
score
Score
User userData `json:"user"`
}

View File

@@ -7,7 +7,7 @@ import (
)
type userScore struct {
score
Score
Beatmap beatmap `json:"beatmap"`
}

18
app/websockets/entry.go Normal file
View File

@@ -0,0 +1,18 @@
package websockets
import (
"github.com/leavengood/websocket"
"github.com/valyala/fasthttp"
)
var upgrader = websocket.FastHTTPUpgrader{
Handler: handler,
CheckOrigin: func(ctx *fasthttp.RequestCtx) bool {
return true
},
}
// WebsocketV1Entry upgrades a connection to a websocket.
func WebsocketV1Entry(ctx *fasthttp.RequestCtx) {
upgrader.UpgradeHandler(ctx)
}

View File

@@ -0,0 +1,99 @@
package websockets
import (
"encoding/json"
"sync"
"sync/atomic"
"time"
"github.com/leavengood/websocket"
)
var stepNumber uint64
func handler(rawConn *websocket.Conn) {
defer rawConn.Close()
step := atomic.AddUint64(&stepNumber, 1)
// 5 is a security margin in case
if step == (1<<10 - 5) {
atomic.StoreUint64(&stepNumber, 0)
}
c := &conn{
rawConn,
sync.Mutex{},
step | uint64(time.Now().UnixNano()<<10),
}
c.WriteJSON(TypeConnected, nil)
defer cleanup(c.ID)
for {
var i incomingMessage
err := c.Conn.ReadJSON(&i)
if _, ok := err.(*websocket.CloseError); ok {
return
}
if err != nil {
c.WriteJSON(TypeInvalidMessage, err.Error())
continue
}
f, ok := messageHandler[i.Type]
if !ok {
c.WriteJSON(TypeInvalidMessage, "invalid message type")
continue
}
f(c, i)
}
}
type conn struct {
Conn *websocket.Conn
Mtx sync.Mutex
ID uint64
}
func (c *conn) WriteJSON(t string, data interface{}) error {
c.Mtx.Lock()
err := c.Conn.WriteJSON(newMessage(t, data))
c.Mtx.Unlock()
return err
}
var messageHandler = map[string]func(c *conn, message incomingMessage){
TypeSubscribeScores: SubscribeScores,
}
// Server Message Types
const (
TypeConnected = "connected"
TypeInvalidMessage = "invalid_message_type"
TypeSubscribed = "subscribed"
TypeNewScore = "new_score"
)
// Client Message Types
const (
TypeSubscribeScores = "subscribe_scores"
)
// Message is the wrapped information for a message sent to the client.
type Message struct {
Type string `json:"type"`
Data interface{} `json:"data,omitempty"`
}
func newMessage(t string, data interface{}) Message {
return Message{
Type: t,
Data: data,
}
}
type incomingMessage struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}

121
app/websockets/scores.go Normal file
View File

@@ -0,0 +1,121 @@
package websockets
import (
"encoding/json"
"fmt"
"sync"
"zxq.co/ripple/rippleapi/app/v1"
)
type subscribeScoresUser struct {
User int `json:"user"`
Modes []int `json:"modes"`
}
// SubscribeScores subscribes a connection to score updates.
func SubscribeScores(c *conn, message incomingMessage) {
var ssu []subscribeScoresUser
err := json.Unmarshal(message.Data, &ssu)
if err != nil {
c.WriteJSON(TypeInvalidMessage, err.Error())
return
}
scoreSubscriptionsMtx.Lock()
var found bool
for idx, el := range scoreSubscriptions {
// already exists, change the users
if el.Conn.ID == c.ID {
found = true
scoreSubscriptions[idx].Users = ssu
}
}
// if it was not found, we need to add it
if !found {
scoreSubscriptions = append(scoreSubscriptions, scoreSubscription{c, ssu})
}
scoreSubscriptionsMtx.Unlock()
c.WriteJSON(TypeSubscribed, message)
}
type scoreSubscription struct {
Conn *conn
Users []subscribeScoresUser
}
var scoreSubscriptions []scoreSubscription
var scoreSubscriptionsMtx = new(sync.RWMutex)
func scoreRetriever() {
ps, err := red.Subscribe("api:score_submission")
if err != nil {
fmt.Println(err)
}
for {
msg, err := ps.ReceiveMessage()
if err != nil {
fmt.Println(err.Error())
return
}
go handleNewScore(msg.Payload)
}
}
type score struct {
v1.Score
UserID int `json:"user_id"`
}
func handleNewScore(id string) {
var s score
err := db.Get(&s, `
SELECT
id, beatmap_md5, score, max_combo, full_combo, mods,
300_count, 100_count, 50_count, gekis_count, katus_count, misses_count,
time, play_mode, accuracy, pp, completed, userid AS user_id
FROM scores WHERE id = ?`, id)
if err != nil {
fmt.Println(err)
return
}
scoreSubscriptionsMtx.RLock()
cp := make([]scoreSubscription, len(scoreSubscriptions))
copy(cp, scoreSubscriptions)
scoreSubscriptionsMtx.RUnlock()
for _, el := range cp {
if len(el.Users) > 0 && !scoreUserValid(el.Users, s) {
continue
}
el.Conn.WriteJSON(TypeNewScore, s)
}
}
func scoreUserValid(users []subscribeScoresUser, s score) bool {
for _, u := range users {
if u.User == s.UserID {
if len(u.Modes) > 0 {
if !inModes(u.Modes, s.PlayMode) {
return false
}
}
return true
}
}
return false
}
func inModes(modes []int, i int) bool {
for _, m := range modes {
if m == i {
return true
}
}
return false
}

View File

@@ -0,0 +1,32 @@
// Package websockets implements functionality related to the API websockets.
package websockets
import (
"github.com/jmoiron/sqlx"
"gopkg.in/redis.v5"
)
var (
red *redis.Client
db *sqlx.DB
)
// Start begins websocket functionality
func Start(r *redis.Client, _db *sqlx.DB) error {
red = r
db = _db
go scoreRetriever()
return nil
}
func cleanup(connID uint64) {
scoreSubscriptionsMtx.Lock()
for idx, el := range scoreSubscriptions {
if el.Conn.ID == connID {
scoreSubscriptions[idx] = scoreSubscriptions[len(scoreSubscriptions)-1]
scoreSubscriptions = scoreSubscriptions[:len(scoreSubscriptions)-1]
break
}
}
scoreSubscriptionsMtx.Unlock()
}