Some checks failed
cache-seeder / prune (push) Has been skipped
cache-seeder / frontend (push) Failing after 3s
cache-seeder / python (push) Failing after 3s
cache-seeder / gobuild (push) Failing after 4s
cache-seeder / lint (bindata, lint-backend) (push) Failing after 3s
giteabot backport / giteabot (push) Has been skipped
giteabot / giteabot (push) Has been skipped
cache-prune / prune (push) Has been skipped
release-nightly-snapcraft / build-and-publish (ubuntu-24.04) (push) Has been cancelled
release-nightly-snapcraft / build-and-publish (ubuntu-24.04-arm) (push) Has been cancelled
release-nightly / nightly-binary (push) Has been cancelled
release-nightly / nightly-container (push) Has been cancelled
cron-renovate / cron-renovate (push) Has been skipped
118 lines
3.5 KiB
Go
118 lines
3.5 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
package websocket
|
||
|
||
import (
|
||
gocontext "context"
|
||
"net/http"
|
||
"time"
|
||
|
||
"gitea.dev/modules/graceful"
|
||
"gitea.dev/modules/json"
|
||
"gitea.dev/modules/log"
|
||
"gitea.dev/services/context"
|
||
"gitea.dev/services/pubsub"
|
||
websocket_service "gitea.dev/services/websocket"
|
||
|
||
gitea_ws "github.com/coder/websocket"
|
||
)
|
||
|
||
const (
|
||
pingInterval = 30 * time.Second
|
||
pingTimeout = 10 * time.Second
|
||
writeTimeout = 10 * time.Second
|
||
|
||
// First code in the IANA library/framework reserved range (3000–3999).
|
||
// Sentinel for an unauthenticated session so the SharedWorker can tell
|
||
// "your cookie is gone" apart from a transient network failure and stop
|
||
// reconnecting in a tight loop.
|
||
closeCodeUnauthenticated gitea_ws.StatusCode = 3000
|
||
)
|
||
|
||
// filterLogout forwards a session-free logout only to the targeted connection
|
||
// (its own session, or every session when SessionID is empty) and drops it for
|
||
// the rest. Non-logout messages pass through untouched.
|
||
func filterLogout(eventType string, eventDataBytes []byte, connSessionID string) []byte {
|
||
if eventType != websocket_service.EventLogout {
|
||
return eventDataBytes
|
||
}
|
||
var lm websocket_service.UserEventMessage[websocket_service.LogoutEventData]
|
||
if err := json.Unmarshal(eventDataBytes, &lm); err != nil {
|
||
return eventDataBytes
|
||
}
|
||
if lm.EventData.SessionID == "" || lm.EventData.SessionID == connSessionID {
|
||
return []byte(`{"eventType":"logout"}`)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func Serve(ctx *context.Context) {
|
||
// Answer plain GETs (health checks, crawlers) here; letting Accept reject them
|
||
// would log an error per request. Same reply it would have sent.
|
||
if ctx.Req.Header.Get("Upgrade") == "" {
|
||
ctx.Resp.Header().Set("Connection", "Upgrade")
|
||
ctx.Resp.Header().Set("Upgrade", "websocket")
|
||
ctx.Resp.WriteHeader(http.StatusUpgradeRequired)
|
||
return
|
||
}
|
||
|
||
conn, err := gitea_ws.Accept(ctx.Resp, ctx.Req, nil)
|
||
if err != nil {
|
||
log.Error("websocket: accept failed: %v", err)
|
||
return
|
||
}
|
||
defer conn.CloseNow() //nolint:errcheck // best-effort close
|
||
|
||
if !ctx.IsSigned {
|
||
_ = conn.Close(closeCodeUnauthenticated, "unauthenticated")
|
||
return
|
||
}
|
||
|
||
sessionID := ctx.Session.ID()
|
||
ch, cancel := pubsub.DefaultBroker.Subscribe(pubsub.UserTopic(ctx.Doer.ID))
|
||
defer cancel()
|
||
|
||
// Ping requires a concurrent reader to observe the pong frame; CloseRead
|
||
// spawns one and cancels its context when the peer goes away.
|
||
wsCtx := conn.CloseRead(ctx.Req.Context())
|
||
shutdownCtx := graceful.GetManager().ShutdownContext()
|
||
pingTicker := time.NewTicker(pingInterval)
|
||
defer pingTicker.Stop()
|
||
|
||
for {
|
||
select {
|
||
case <-wsCtx.Done():
|
||
return
|
||
case <-shutdownCtx.Done():
|
||
return
|
||
case <-pingTicker.C:
|
||
pingCtx, cancelPing := gocontext.WithTimeout(wsCtx, pingTimeout)
|
||
err := conn.Ping(pingCtx)
|
||
cancelPing()
|
||
if err != nil {
|
||
log.Trace("websocket: ping failed: %v", err)
|
||
return
|
||
}
|
||
case brokerPayload, ok := <-ch:
|
||
if !ok {
|
||
return
|
||
}
|
||
eventType, eventDataBytes := websocket_service.ExtractUserEventMessage(brokerPayload)
|
||
eventDataBytes = filterLogout(eventType, eventDataBytes, sessionID)
|
||
if eventDataBytes == nil {
|
||
continue
|
||
}
|
||
// Bound the write so a stalled/slow peer can't block this goroutine
|
||
// indefinitely and starve the ping ticker.
|
||
writeCtx, cancelWrite := gocontext.WithTimeout(wsCtx, writeTimeout)
|
||
err := conn.Write(writeCtx, gitea_ws.MessageText, eventDataBytes)
|
||
cancelWrite()
|
||
if err != nil {
|
||
log.Trace("websocket: write failed: %v", err)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|