mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-11 17:28:05 +02:00
Security: - WebSocket events are routed to their owner only (no cross-user leak); hub close is idempotent (fixes double-close panic), adds ping/pong and write deadlines. - Session tokens stored as SHA-256 (migration 008 keeps sessions valid); single-query auth middleware puts the user in the request context. - Client IP only trusts X-Forwarded-For from TRUSTED_PROXIES; rate limiter map is bounded; per-user limit on AI summaries. - Argon2id at OWASP minimum with a concurrency cap; constant-time login for unknown emails; atomic first-admin bootstrap; REGISTRATION_ENABLED. - CSP/HSTS/COOP headers, same-origin guard on mutations, body size limits, wider SSRF denylist, bounded feed/page/AI response reads, generic errors. - Upgrade chi, pgx, x/net, x/text, x/crypto (known CVEs); commit go.sum. Performance: - List endpoints return a plain-text excerpt and reading time instead of full HTML; content is sanitized once at ingest (legacy rows backfilled). - Keyset pagination on (sort_at, id) with matching partial indexes; redundant indexes dropped (migration 007). - Fetcher: bounded worker pool, conditional GET (ETag/Last-Modified), exponential backoff, dedupe before insert, column-safe truncation, retention-aware ingest, per-user refresh coalescing. - Read/favorite/read-all are single ownership-scoped statements. - gzip compression, immutable caching for hashed assets, path-safe SPA handler, server timeouts; expired sessions purged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
238 lines
5.6 KiB
Go
238 lines
5.6 KiB
Go
package ws
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
const (
|
|
// Time allowed to write a message to the peer.
|
|
writeWait = 10 * time.Second
|
|
// Time allowed to read the next pong message from the peer.
|
|
pongWait = 60 * time.Second
|
|
// Send pings to peer with this period. Must be less than pongWait.
|
|
pingPeriod = (pongWait * 9) / 10
|
|
// Maximum message size allowed from peer (clients don't send data).
|
|
maxMessageSize = 512
|
|
)
|
|
|
|
// allowedWSOrigins holds optional extra origins (comma-separated) from the
|
|
// WS_ALLOWED_ORIGINS env var, for deployments where the WS host differs.
|
|
var allowedWSOrigins = parseAllowedOrigins(os.Getenv("WS_ALLOWED_ORIGINS"))
|
|
|
|
func parseAllowedOrigins(raw string) map[string]bool {
|
|
out := make(map[string]bool)
|
|
for _, o := range strings.Split(raw, ",") {
|
|
if o = strings.TrimSpace(strings.ToLower(o)); o != "" {
|
|
out[o] = true
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// checkOrigin enforces a same-origin policy to prevent Cross-Site WebSocket
|
|
// Hijacking (CSWSH). Requests without an Origin header (non-browser clients)
|
|
// are allowed; browser requests must match the Host or an allow-listed origin.
|
|
func checkOrigin(r *http.Request) bool {
|
|
origin := r.Header.Get("Origin")
|
|
if origin == "" {
|
|
return true // non-browser client (e.g. native app, curl)
|
|
}
|
|
u, err := url.Parse(origin)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if strings.EqualFold(u.Host, r.Host) {
|
|
return true
|
|
}
|
|
return allowedWSOrigins[strings.ToLower(u.Host)]
|
|
}
|
|
|
|
var upgrader = websocket.Upgrader{
|
|
CheckOrigin: checkOrigin,
|
|
}
|
|
|
|
// Event represents a websocket event.
|
|
type Event struct {
|
|
Type string `json:"type"`
|
|
Payload json.RawMessage `json:"payload"`
|
|
}
|
|
|
|
// userEvent is an event addressed to every connection of a single user.
|
|
type userEvent struct {
|
|
userID uuid.UUID
|
|
data []byte
|
|
}
|
|
|
|
// Client represents a connected user via websocket.
|
|
type Client struct {
|
|
ID uuid.UUID
|
|
Conn *websocket.Conn
|
|
Send chan []byte
|
|
Hub *Hub
|
|
}
|
|
|
|
// Hub maintains the set of active clients and routes messages to them.
|
|
// The clients map is only ever touched by the Run goroutine.
|
|
type Hub struct {
|
|
clients map[uuid.UUID]map[*Client]struct{}
|
|
broadcast chan userEvent
|
|
register chan *Client
|
|
unregister chan *Client
|
|
}
|
|
|
|
// NewHub creates a new hub.
|
|
func NewHub() *Hub {
|
|
return &Hub{
|
|
broadcast: make(chan userEvent, 256),
|
|
register: make(chan *Client),
|
|
unregister: make(chan *Client),
|
|
clients: make(map[uuid.UUID]map[*Client]struct{}),
|
|
}
|
|
}
|
|
|
|
// Run starts the hub loop.
|
|
func (h *Hub) Run() {
|
|
for {
|
|
select {
|
|
case client := <-h.register:
|
|
set := h.clients[client.ID]
|
|
if set == nil {
|
|
set = make(map[*Client]struct{})
|
|
h.clients[client.ID] = set
|
|
}
|
|
set[client] = struct{}{}
|
|
|
|
case client := <-h.unregister:
|
|
h.remove(client)
|
|
|
|
case ev := <-h.broadcast:
|
|
for client := range h.clients[ev.userID] {
|
|
select {
|
|
case client.Send <- ev.data:
|
|
default:
|
|
// Slow consumer: drop it. remove() is idempotent so a
|
|
// later unregister from readPump is harmless.
|
|
h.remove(client)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// remove unregisters a client and closes its send channel exactly once.
|
|
func (h *Hub) remove(client *Client) {
|
|
set, ok := h.clients[client.ID]
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, ok := set[client]; !ok {
|
|
return
|
|
}
|
|
delete(set, client)
|
|
close(client.Send)
|
|
if len(set) == 0 {
|
|
delete(h.clients, client.ID)
|
|
}
|
|
}
|
|
|
|
// SendToUser sends an event to every connection of the given user only.
|
|
func (h *Hub) SendToUser(userID uuid.UUID, eventType string, payload interface{}) {
|
|
raw, err := json.Marshal(payload)
|
|
if err != nil {
|
|
log.Printf("Error marshaling WS payload: %v", err)
|
|
return
|
|
}
|
|
data, err := json.Marshal(Event{Type: eventType, Payload: raw})
|
|
if err != nil {
|
|
log.Printf("Error marshaling WS event: %v", err)
|
|
return
|
|
}
|
|
select {
|
|
case h.broadcast <- userEvent{userID: userID, data: data}:
|
|
default:
|
|
// Never block request handlers on a saturated hub; clients resync
|
|
// on their next query anyway.
|
|
log.Printf("WS hub saturated, dropping %s event", eventType)
|
|
}
|
|
}
|
|
|
|
// ServeWS handles websocket requests.
|
|
func (h *Hub) ServeWS(userID uuid.UUID, w http.ResponseWriter, r *http.Request) {
|
|
conn, err := upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
log.Printf("WS upgrade error: %v", err)
|
|
return
|
|
}
|
|
|
|
client := &Client{
|
|
ID: userID,
|
|
Conn: conn,
|
|
Send: make(chan []byte, 64),
|
|
Hub: h,
|
|
}
|
|
h.register <- client
|
|
|
|
go client.writePump()
|
|
go client.readPump()
|
|
}
|
|
|
|
func (c *Client) readPump() {
|
|
defer func() {
|
|
c.Hub.unregister <- c
|
|
c.Conn.Close()
|
|
}()
|
|
|
|
c.Conn.SetReadLimit(maxMessageSize)
|
|
c.Conn.SetReadDeadline(time.Now().Add(pongWait))
|
|
c.Conn.SetPongHandler(func(string) error {
|
|
return c.Conn.SetReadDeadline(time.Now().Add(pongWait))
|
|
})
|
|
|
|
for {
|
|
if _, _, err := c.Conn.ReadMessage(); err != nil {
|
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
|
log.Printf("WS read error: %v", err)
|
|
}
|
|
return
|
|
}
|
|
// Clients don't send messages; anything received is ignored.
|
|
}
|
|
}
|
|
|
|
func (c *Client) writePump() {
|
|
ticker := time.NewTicker(pingPeriod)
|
|
defer func() {
|
|
ticker.Stop()
|
|
c.Conn.Close()
|
|
}()
|
|
|
|
for {
|
|
select {
|
|
case message, ok := <-c.Send:
|
|
c.Conn.SetWriteDeadline(time.Now().Add(writeWait))
|
|
if !ok {
|
|
c.Conn.WriteMessage(websocket.CloseMessage, []byte{})
|
|
return
|
|
}
|
|
// One frame per event so the client can JSON.parse each one.
|
|
if err := c.Conn.WriteMessage(websocket.TextMessage, message); err != nil {
|
|
return
|
|
}
|
|
case <-ticker.C:
|
|
c.Conn.SetWriteDeadline(time.Now().Add(writeWait))
|
|
if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|