267 lines
7.0 KiB
Go
267 lines
7.0 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/gorilla/websocket"
|
|
"net/http"
|
|
"net/url"
|
|
"perler-beads-server/internal/domain"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
wsAuthTimeout = 5 * time.Second
|
|
wsHeartbeat = 20 * time.Second
|
|
wsReadTimeout = 45 * time.Second
|
|
wsWriteTimeout = 5 * time.Second
|
|
wsRedisTimeout = 3 * time.Second
|
|
wsMessageLimit = 4096
|
|
)
|
|
|
|
type wsClose struct {
|
|
code int
|
|
reason string
|
|
}
|
|
type taskSocket struct {
|
|
conn *websocket.Conn
|
|
user domain.User
|
|
token string
|
|
changed chan struct{}
|
|
pong chan string
|
|
stop chan wsClose
|
|
}
|
|
type taskHub struct {
|
|
mu sync.Mutex
|
|
stores map[string]map[*taskSocket]struct{}
|
|
closed bool
|
|
}
|
|
|
|
func newTaskHub() *taskHub { return &taskHub{stores: map[string]map[*taskSocket]struct{}{}} }
|
|
func (h *taskHub) add(client *taskSocket) bool {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
if h.closed {
|
|
return false
|
|
}
|
|
if h.stores[client.user.StoreID] == nil {
|
|
h.stores[client.user.StoreID] = map[*taskSocket]struct{}{}
|
|
}
|
|
h.stores[client.user.StoreID][client] = struct{}{}
|
|
return true
|
|
}
|
|
func (h *taskHub) remove(client *taskSocket) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
store := h.stores[client.user.StoreID]
|
|
delete(store, client)
|
|
if len(store) == 0 {
|
|
delete(h.stores, client.user.StoreID)
|
|
}
|
|
}
|
|
|
|
// Capacity one merges invalidations; slow browsers never block a committed business request.
|
|
func (h *taskHub) notify(storeID string) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
for client := range h.stores[storeID] {
|
|
select {
|
|
case client.changed <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
func (h *taskHub) shutdown() {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
h.closed = true
|
|
for _, store := range h.stores {
|
|
for client := range store {
|
|
client.close(1001, "server shutting down")
|
|
}
|
|
}
|
|
}
|
|
func (c *taskSocket) close(code int, reason string) {
|
|
select {
|
|
case c.stop <- wsClose{code, reason}:
|
|
default:
|
|
}
|
|
}
|
|
func (s *Server) notifyTasks(storeID string) {
|
|
if s.wsHub != nil {
|
|
s.wsHub.notify(storeID)
|
|
}
|
|
}
|
|
func (s *Server) CloseWebSockets() {
|
|
if s.wsHub != nil {
|
|
s.wsHub.shutdown()
|
|
}
|
|
}
|
|
func (s *Server) wsOrigin(r *http.Request) bool {
|
|
origin := r.Header.Get("Origin")
|
|
if origin == "" || len(r.Header.Values("Origin")) != 1 {
|
|
return false
|
|
}
|
|
u, e := url.Parse(origin)
|
|
if e != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" {
|
|
return false
|
|
}
|
|
if strings.EqualFold(u.Host, r.Host) {
|
|
return true
|
|
}
|
|
for _, allowed := range s.Origins {
|
|
if allowed != "" && origin == allowed {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
func (s *Server) wsUser(token string) (domain.User, error) {
|
|
var u domain.User
|
|
if token == "" || len(token) > 256 || s.Redis == nil {
|
|
return u, errors.New("invalid session")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), wsRedisTimeout)
|
|
defer cancel()
|
|
h := sha256.Sum256([]byte(token))
|
|
b, e := s.Redis.Get(ctx, "session:"+hex.EncodeToString(h[:])).Bytes()
|
|
if e != nil {
|
|
return u, e
|
|
}
|
|
if e = json.Unmarshal(b, &u); e != nil {
|
|
return u, e
|
|
}
|
|
if u.ID == "" || u.StoreID == "" || (u.Role != "admin" && u.Role != "staff") {
|
|
return u, errors.New("staff session required")
|
|
}
|
|
return u, nil
|
|
}
|
|
func (s *Server) websocket(c *gin.Context) {
|
|
if !s.wsOrigin(c.Request) {
|
|
c.Status(http.StatusForbidden)
|
|
return
|
|
}
|
|
if c.Request.URL.RawQuery != "" {
|
|
c.Status(http.StatusBadRequest)
|
|
return
|
|
}
|
|
upgrader := websocket.Upgrader{HandshakeTimeout: wsAuthTimeout, ReadBufferSize: 1024, WriteBufferSize: 1024, CheckOrigin: s.wsOrigin}
|
|
conn, e := upgrader.Upgrade(c.Writer, c.Request, nil)
|
|
if e != nil {
|
|
return
|
|
}
|
|
conn.SetReadLimit(wsMessageLimit)
|
|
conn.SetReadDeadline(time.Now().Add(wsAuthTimeout))
|
|
conn.SetCloseHandler(func(code int, text string) error { return &websocket.CloseError{Code: code, Text: text} })
|
|
var auth struct {
|
|
Type string `json:"type"`
|
|
Token string `json:"token"`
|
|
}
|
|
kind, payload, e := conn.ReadMessage()
|
|
if e != nil {
|
|
conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(4403, "authentication required"), time.Now().Add(wsWriteTimeout))
|
|
conn.Close()
|
|
return
|
|
}
|
|
if kind != websocket.TextMessage || json.Unmarshal(payload, &auth) != nil || auth.Type != "auth" {
|
|
conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(4403, "authentication required"), time.Now().Add(wsWriteTimeout))
|
|
conn.Close()
|
|
return
|
|
}
|
|
u, e := s.wsUser(auth.Token)
|
|
if e != nil {
|
|
conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(4403, "invalid staff session"), time.Now().Add(wsWriteTimeout))
|
|
conn.Close()
|
|
return
|
|
}
|
|
client := &taskSocket{conn: conn, user: u, token: auth.Token, changed: make(chan struct{}, 1), pong: make(chan string, 4), stop: make(chan wsClose, 1)}
|
|
if !s.wsHub.add(client) {
|
|
conn.Close()
|
|
return
|
|
}
|
|
defer s.wsHub.remove(client)
|
|
readerDone := make(chan struct{})
|
|
writerDone := make(chan struct{})
|
|
conn.SetReadDeadline(time.Now().Add(wsReadTimeout))
|
|
conn.SetPongHandler(func(string) error { return conn.SetReadDeadline(time.Now().Add(wsReadTimeout)) })
|
|
conn.SetPingHandler(func(data string) error {
|
|
select {
|
|
case client.pong <- data:
|
|
return nil
|
|
default:
|
|
return errors.New("too many pings")
|
|
}
|
|
})
|
|
go func() { defer close(writerDone); s.writeSocket(client, readerDone) }()
|
|
defer func() { close(readerDone); <-writerDone }()
|
|
for {
|
|
kind, payload, e = conn.ReadMessage()
|
|
if e != nil {
|
|
return
|
|
}
|
|
var message struct {
|
|
Type string `json:"type"`
|
|
}
|
|
if kind != websocket.TextMessage || json.Unmarshal(payload, &message) != nil || message.Type != "pong" {
|
|
client.close(4400, "unexpected message")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
func (s *Server) writeSocket(client *taskSocket, readerDone <-chan struct{}) {
|
|
defer client.conn.Close()
|
|
write := func(kind string) error {
|
|
client.conn.SetWriteDeadline(time.Now().Add(wsWriteTimeout))
|
|
return client.conn.WriteJSON(map[string]string{"type": kind})
|
|
}
|
|
closeFrame := func(reason wsClose) {
|
|
client.conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(reason.code, reason.reason), time.Now().Add(wsWriteTimeout))
|
|
}
|
|
if e := write("ready"); e != nil {
|
|
return
|
|
}
|
|
ticker := time.NewTicker(wsHeartbeat)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case reason := <-client.stop:
|
|
closeFrame(reason)
|
|
return
|
|
case <-readerDone:
|
|
select {
|
|
case reason := <-client.stop:
|
|
closeFrame(reason)
|
|
default:
|
|
closeFrame(wsClose{1000, "connection closed"})
|
|
}
|
|
return
|
|
case data := <-client.pong:
|
|
if e := client.conn.WriteControl(websocket.PongMessage, []byte(data), time.Now().Add(wsWriteTimeout)); e != nil {
|
|
return
|
|
}
|
|
case <-client.changed:
|
|
if e := write("tasks.changed"); e != nil {
|
|
return
|
|
}
|
|
case <-ticker.C:
|
|
u, e := s.wsUser(client.token)
|
|
if e != nil || u.ID != client.user.ID || u.Role != client.user.Role || u.StoreID != client.user.StoreID {
|
|
closeFrame(wsClose{4403, "session expired"})
|
|
return
|
|
}
|
|
if e := client.conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(wsWriteTimeout)); e != nil {
|
|
return
|
|
}
|
|
if e := write("heartbeat"); e != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|