diff --git a/pkg/initialize/init.go b/pkg/initialize/init.go index 7145b06d8..4296cfd12 100644 --- a/pkg/initialize/init.go +++ b/pkg/initialize/init.go @@ -37,6 +37,7 @@ import ( _ "code.vikunja.io/api/pkg/plugins/yaegi" // register yaegi plugin loader "code.vikunja.io/api/pkg/red" "code.vikunja.io/api/pkg/user" + ws "code.vikunja.io/api/pkg/websocket" ) // LightInit will only init config, redis, logger but no db connection. @@ -133,11 +134,15 @@ func FullInit() { openid.RegisterEmptyOpenIDTeamCleanupCron() models.RegisterAPITokenExpiryCheckCron() + // Initialize WebSocket hub + ws.InitHub() + // Start processing events go func() { models.RegisterListeners() user.RegisterListeners() migrationHandler.RegisterListeners() + ws.RegisterListeners() err := events.InitEvents() if err != nil { log.Fatal(err.Error()) diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index e6c956209..4d0d532e9 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -79,6 +79,7 @@ import ( "code.vikunja.io/api/pkg/routes/caldav" "code.vikunja.io/api/pkg/version" "code.vikunja.io/api/pkg/web/handler" + ws "code.vikunja.io/api/pkg/websocket" "github.com/getsentry/sentry-go" "github.com/labstack/echo/v5" @@ -352,6 +353,9 @@ func registerAPIRoutes(a *echo.Group) { n.GET("/docs", apiv1.RedocUI) n.GET("/docs/redoc.standalone.js", apiv1.RedocJS) + // WebSocket (auth happens after upgrade via first message) + n.GET("/ws", ws.UpgradeHandler) + // Prometheus endpoint setupMetrics(n) diff --git a/pkg/websocket/handler.go b/pkg/websocket/handler.go new file mode 100644 index 000000000..8a2cdc49f --- /dev/null +++ b/pkg/websocket/handler.go @@ -0,0 +1,66 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package websocket + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/log" + + "github.com/coder/websocket" + "github.com/labstack/echo/v5" +) + +var globalHub *Hub + +// InitHub creates the global hub. Must be called once at startup. +func InitHub() { + globalHub = NewHub() +} + +// GetHub returns the global hub. +func GetHub() *Hub { + return globalHub +} + +// UpgradeHandler is the Echo handler for WebSocket upgrades at /api/v1/ws. +// The upgrade happens without authentication - auth is done via the first message. +func UpgradeHandler(c *echo.Context) error { + if globalHub == nil { + log.Errorf("WebSocket: hub not initialized") + return echo.NewHTTPError(http.StatusServiceUnavailable, "WebSocket hub not initialized") + } + + ws, err := websocket.Accept(c.Response(), c.Request(), &websocket.AcceptOptions{ + OriginPatterns: config.CorsOrigins.GetStringSlice(), + }) + if err != nil { + log.Errorf("WebSocket: upgrade failed: %v", err) + return nil // Accept already wrote the error response + } + + conn := NewConnection(ws, globalHub) + + ctx, cancel := context.WithCancel(context.Background()) + + go conn.WriteLoop(ctx, cancel) + go conn.ReadLoop(ctx, cancel) + + return nil +}