Files

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
}
}
}
}