Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,7 @@ dev:
@sleep 3
@echo "API running at http://localhost:8080"
@echo ""
@echo "Installing frontend dependencies..."
@cd frontend && npm install --silent
@if [ ! -d frontend/node_modules ]; then echo "Installing frontend dependencies..." && cd frontend && npm install --silent; fi
@echo "Starting Vite dev server at http://localhost:3000"
@echo ""
@cd frontend && npm run dev
Expand Down
20 changes: 14 additions & 6 deletions backend/internal/api/handlers/slack_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ type slackConfigResponse struct {
WorkspaceName string `json:"workspace_name,omitempty"`
BotUserID string `json:"bot_user_id,omitempty"`
HasBotToken bool `json:"has_bot_token"`
HasAppToken bool `json:"has_app_token"`
HasOAuthConfig bool `json:"has_oauth_config"`
ConnectedAt *time.Time `json:"connected_at,omitempty"`
}
Expand All @@ -33,7 +32,6 @@ func toSlackConfigResponse(cfg *models.SlackConfig) slackConfigResponse {
WorkspaceName: cfg.WorkspaceName,
BotUserID: cfg.BotUserID,
HasBotToken: cfg.BotToken != "",
HasAppToken: cfg.AppToken != "",
HasOAuthConfig: cfg.OAuthClientID != "" && cfg.OAuthClientSecret != "",
ConnectedAt: &cfg.ConnectedAt,
}
Expand All @@ -51,13 +49,18 @@ func GetSlackConfig(repo repository.SlackConfigRepository) gin.HandlerFunc {
}
}

// HandlerInvalidator is implemented by types that cache a Slack event handler
// and need to be notified when config changes.
type HandlerInvalidator interface {
Invalidate()
}

// SaveSlackConfig stores Slack tokens.
func SaveSlackConfig(repo repository.SlackConfigRepository) gin.HandlerFunc {
func SaveSlackConfig(repo repository.SlackConfigRepository, invalidator ...HandlerInvalidator) gin.HandlerFunc {
return func(c *gin.Context) {
var req struct {
BotToken string `json:"bot_token"`
SigningSecret string `json:"signing_secret"`
AppToken string `json:"app_token"`
WorkspaceID string `json:"workspace_id"`
WorkspaceName string `json:"workspace_name"`
BotUserID string `json:"bot_user_id"`
Expand All @@ -83,7 +86,6 @@ func SaveSlackConfig(repo repository.SlackConfigRepository) gin.HandlerFunc {
cfg := &models.SlackConfig{
BotToken: req.BotToken,
SigningSecret: req.SigningSecret,
AppToken: req.AppToken,
WorkspaceID: req.WorkspaceID,
WorkspaceName: req.WorkspaceName,
BotUserID: req.BotUserID,
Expand All @@ -97,6 +99,9 @@ func SaveSlackConfig(repo repository.SlackConfigRepository) gin.HandlerFunc {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save slack config"})
return
}
for _, inv := range invalidator {
inv.Invalidate()
}
c.JSON(http.StatusOK, toSlackConfigResponse(cfg))
}
}
Expand Down Expand Up @@ -129,12 +134,15 @@ func TestSlackConfig() gin.HandlerFunc {
}

// DeleteSlackConfig removes the Slack integration.
func DeleteSlackConfig(repo repository.SlackConfigRepository) gin.HandlerFunc {
func DeleteSlackConfig(repo repository.SlackConfigRepository, invalidator ...HandlerInvalidator) gin.HandlerFunc {
return func(c *gin.Context) {
if err := repo.Delete(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete slack config"})
return
}
for _, inv := range invalidator {
inv.Invalidate()
}
c.JSON(http.StatusOK, gin.H{"message": "slack integration removed"})
}
}
Expand Down
137 changes: 137 additions & 0 deletions backend/internal/api/handlers/slack_http.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package handlers

import (
"encoding/json"
"log/slog"
"net/http"
"net/url"

"github.com/FluidifyAI/Regen/backend/internal/api/middleware"
"github.com/FluidifyAI/Regen/backend/internal/services"
"github.com/gin-gonic/gin"
"github.com/slack-go/slack"
"github.com/slack-go/slack/slackevents"
)

// SlackEvents handles POST /api/v1/slack/events — Slack Events API payloads.
// Signature verification is done by SlackSignatureVerification middleware upstream.
func SlackEvents(resolver *services.SlackHandlerResolver) gin.HandlerFunc {
return func(c *gin.Context) {
body, err := middleware.SlackBodyFromContext(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read body"})
return
}

// url_verification is Slack's one-time challenge when saving the Events URL.
// Respond even when the handler isn't ready yet.
var challenge struct {
Type string `json:"type"`
Challenge string `json:"challenge"`
}
if err := json.Unmarshal(body, &challenge); err == nil && challenge.Type == "url_verification" {
c.JSON(http.StatusOK, gin.H{"challenge": challenge.Challenge})
return
}

handler := resolver.Get()
if handler == nil {
c.Status(http.StatusOK) // ACK Slack; processing unavailable until config is saved
return
}

eventsAPIEvent, err := slackevents.ParseEvent(json.RawMessage(body), slackevents.OptionNoVerifyToken())
if err != nil {
slog.Warn("slack events: failed to parse payload", "error", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid event payload"})
return
}

// ACK immediately; dispatch is async inside HandleEventsAPI.
c.Status(http.StatusOK)
go handler.HandleEventsAPI(eventsAPIEvent)
}
}

// SlackInteractions handles POST /api/v1/slack/interactions — button clicks and modal submissions.
func SlackInteractions(resolver *services.SlackHandlerResolver) gin.HandlerFunc {
return func(c *gin.Context) {
handler := resolver.Get()
if handler == nil {
c.Status(http.StatusOK)
return
}

body, err := middleware.SlackBodyFromContext(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read body"})
return
}

// Interactions arrive as application/x-www-form-urlencoded with a "payload" key.
values, err := url.ParseQuery(string(body))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid form body"})
return
}
payloadJSON := values.Get("payload")
if payloadJSON == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing payload field"})
return
}

var callback slack.InteractionCallback
if err := json.Unmarshal([]byte(payloadJSON), &callback); err != nil {
slog.Warn("slack interactions: failed to parse payload", "error", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid interaction payload"})
return
}

// ACK with 200 immediately — Slack requires a response within 3 seconds.
c.Status(http.StatusOK)
go handler.HandleInteraction(callback)
}
}

// SlackCommands handles POST /api/v1/slack/commands — slash command payloads.
func SlackCommands(resolver *services.SlackHandlerResolver) gin.HandlerFunc {
return func(c *gin.Context) {
handler := resolver.Get()
if handler == nil {
c.Status(http.StatusOK)
return
}

body, err := middleware.SlackBodyFromContext(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read body"})
return
}

values, err := url.ParseQuery(string(body))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid form body"})
return
}

cmd := slack.SlashCommand{
Token: values.Get("token"),
TeamID: values.Get("team_id"),
TeamDomain: values.Get("team_domain"),
EnterpriseID: values.Get("enterprise_id"),
EnterpriseName: values.Get("enterprise_name"),
ChannelID: values.Get("channel_id"),
ChannelName: values.Get("channel_name"),
UserID: values.Get("user_id"),
UserName: values.Get("user_name"),
Command: values.Get("command"),
Text: values.Get("text"),
ResponseURL: values.Get("response_url"),
TriggerID: values.Get("trigger_id"),
}

// ACK with 200 immediately — Slack requires a response within 3 seconds.
c.Status(http.StatusOK)
go handler.HandleCommand(cmd)
}
}
46 changes: 23 additions & 23 deletions backend/internal/api/middleware/slack_signature.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
"log/slog"
"math"
"net/http"
"os"
"strconv"
"time"

Expand All @@ -26,33 +25,20 @@ const (
SlackSignatureVersion = "v0"
)

// SlackSignatureVerification returns a middleware that verifies Slack request signatures
// SlackSignatureVerification returns a middleware that verifies Slack request signatures.
// getSecret is called on every request so the signing secret is always current — no restart
// needed after Slack config is saved via the UI.
//
// This implements Slack's signature verification algorithm:
// https://api.slack.com/authentication/verifying-requests-from-slack
//
// The middleware:
// 1. Checks that the request timestamp is within 5 minutes (prevents replay attacks)
// 2. Computes HMAC-SHA256 signature of "v0:timestamp:body"
// 3. Compares computed signature with X-Slack-Signature header
// 4. Rejects requests with invalid or missing signatures
//
// Usage:
//
// slackRoutes.Use(middleware.SlackSignatureVerification())
func SlackSignatureVerification() gin.HandlerFunc {
signingSecret := os.Getenv("SLACK_SIGNING_SECRET")

// If no signing secret is configured, allow requests through with a warning
// This is for development/testing only
if signingSecret == "" {
slog.Warn("SLACK_SIGNING_SECRET not set - Slack signature verification disabled")
return func(c *gin.Context) {
func SlackSignatureVerification(getSecret func() string) gin.HandlerFunc {
return func(c *gin.Context) {
signingSecret := getSecret()
if signingSecret == "" {
slog.Warn("Slack signing secret not configured - signature verification disabled")
c.Next()
return
}
}

return func(c *gin.Context) {
// Get the timestamp and signature headers
timestamp := c.GetHeader("X-Slack-Request-Timestamp")
signature := c.GetHeader("X-Slack-Signature")
Expand Down Expand Up @@ -141,6 +127,9 @@ func SlackSignatureVerification() gin.HandlerFunc {
return
}

// Store the buffered body so handlers can read it without a second io.ReadAll.
c.Set("slack_raw_body", bodyBytes)

// Signature is valid, proceed
c.Next()
}
Expand Down Expand Up @@ -173,3 +162,14 @@ func computeSlackSignature(signingSecret, timestamp string, body []byte) string
// Format as v0=<hex>
return fmt.Sprintf("%s=%s", SlackSignatureVersion, hex.EncodeToString(hash))
}

// SlackBodyFromContext retrieves the raw request body buffered by SlackSignatureVerification.
// Falls back to reading from c.Request.Body when the key is absent (e.g. in unit tests).
func SlackBodyFromContext(c *gin.Context) ([]byte, error) {
if raw, ok := c.Get("slack_raw_body"); ok {
if b, ok := raw.([]byte); ok {
return b, nil
}
}
return io.ReadAll(c.Request.Body)
}
17 changes: 7 additions & 10 deletions backend/internal/api/middleware/slack_signature_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"strconv"
"testing"
"time"
Expand Down Expand Up @@ -115,23 +114,21 @@ func TestSlackSignatureVerification(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup environment
if tt.setupEnv {
os.Setenv("SLACK_SIGNING_SECRET", tt.signingSecret)
} else {
os.Unsetenv("SLACK_SIGNING_SECRET")
}
defer os.Unsetenv("SLACK_SIGNING_SECRET")

// Compute signature if requested
signature := tt.signature
if tt.computeSignature && tt.timestamp != "" && tt.setupEnv {
signature = computeTestSignature(tt.signingSecret, tt.timestamp, []byte(tt.body))
}

// Pass the signing secret directly (DB-stored, not via env var)
secret := ""
if tt.setupEnv {
secret = tt.signingSecret
}

// Create test router
router := gin.New()
router.Use(SlackSignatureVerification())
router.Use(SlackSignatureVerification(func() string { return secret }))
router.POST("/slack/events", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
Expand Down
40 changes: 19 additions & 21 deletions backend/internal/api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,25 +108,10 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, teamsSvc *
}
}

// Start Slack Socket Mode event handler (bidirectional sync) if app token is configured.
if slackCfgForSocket, err := slackConfigRepo.Get(); err == nil &&
slackCfgForSocket != nil && slackCfgForSocket.AppToken != "" && chatService != nil {
eventHandler, err := services.NewSlackEventHandler(
slackCfgForSocket.AppToken,
slackCfgForSocket.BotToken,
incidentSvc,
chatService,
userRepo,
pmRepo,
)
if err != nil {
slog.Error("failed to initialize slack socket mode", "error", err)
slog.Warn("bidirectional Slack sync disabled - Slack will be one-way only")
} else {
eventHandler.SetAIService(aiSvc)
eventHandler.Start()
}
}
// Slack event handler resolver — lazily initializes from DB config on first use.
// Routes are always registered so events work immediately after config is saved via UI,
// without requiring a server restart.
slackResolver := services.NewSlackHandlerResolver(slackConfigRepo, incidentSvc, chatService, userRepo, pmRepo, aiSvc)

// Middleware
router.Use(middleware.RequestID()) // Must be first for request tracing
Expand Down Expand Up @@ -243,6 +228,19 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, teamsSvc *
// Public: is Slack OAuth login enabled? (LoginPage uses this to show/hide the button)
v1.GET("/auth/slack/config", handlers.GetSlackOAuthConfig(slackConfigRepo))

// Slack Events API, interactive components, and slash commands.
// Routes are always registered; the handler and signing secret are resolved from DB
// on each request so they activate immediately after config is saved — no restart needed.
slackGroup := v1.Group("/slack", middleware.SlackSignatureVerification(func() string {
if cfg, err := slackConfigRepo.Get(); err == nil && cfg != nil {
return cfg.SigningSecret
}
return ""
}))
slackGroup.POST("/events", handlers.SlackEvents(slackResolver))
slackGroup.POST("/interactions", handlers.SlackInteractions(slackResolver))
slackGroup.POST("/commands", handlers.SlackCommands(slackResolver))

// Local login/logout endpoints (always open — these ARE the auth actions)
if localAuth != nil {
v1.POST("/auth/login", middleware.RateLimitAuth(), handlers.Login(localAuth))
Expand Down Expand Up @@ -412,9 +410,9 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, teamsSvc *

// Slack integration config (admin only)
settingsGroup.GET("/slack", handlers.GetSlackConfig(slackConfigRepo))
settingsGroup.POST("/slack", handlers.SaveSlackConfig(slackConfigRepo))
settingsGroup.POST("/slack", handlers.SaveSlackConfig(slackConfigRepo, slackResolver))
settingsGroup.POST("/slack/test", handlers.TestSlackConfig())
settingsGroup.DELETE("/slack", handlers.DeleteSlackConfig(slackConfigRepo))
settingsGroup.DELETE("/slack", handlers.DeleteSlackConfig(slackConfigRepo, slackResolver))
settingsGroup.GET("/slack/members", handlers.ListSlackMembers(slackConfigRepo, userRepo))

// Teams integration config (admin only)
Expand Down
Loading
Loading