From 9efdb5c28d587405128e607951e2455ae6071bb1 Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Wed, 18 Feb 2026 20:56:05 +0100 Subject: [PATCH] Add support for multiple webhooks per campaign Add webhook links on campaign page Signed-off-by: Ronni Skansing --- backend/app/server.go | 107 ++--- backend/controller/campaign.go | 1 + backend/database/campaign.go | 22 +- backend/database/campaignWebhook.go | 39 ++ backend/model/campaign.go | 45 ++- backend/model/campaignWebhook.go | 71 ++++ backend/proxy/proxy.go | 111 ++--- backend/repository/campaign.go | 156 ++++++- backend/repository/webhook.go | 30 ++ backend/seed/migrate.go | 22 + backend/service/campaign.go | 380 +++++++++++------- backend/service/webhook.go | 7 + frontend/src/lib/api/api.js | 24 +- frontend/src/routes/campaign/+page.svelte | 287 ++++++++----- .../src/routes/campaign/[id]/+page.svelte | 36 +- frontend/src/routes/webhook/+page.svelte | 8 + 16 files changed, 905 insertions(+), 441 deletions(-) create mode 100644 backend/database/campaignWebhook.go create mode 100644 backend/model/campaignWebhook.go diff --git a/backend/app/server.go b/backend/app/server.go index d4b9a2c..9814ffb 100644 --- a/backend/app/server.go +++ b/backend/app/server.go @@ -1190,31 +1190,17 @@ func (s *Server) checkAndServePhishingPage( return true, errs.Wrap(err) } } - // handle webhook - webhookID, err := s.repositories.Campaign.GetWebhookIDByCampaignID( - c, + // handle webhooks + err = s.services.Campaign.HandleWebhooks( + // TODO this should be tied to a application wide context not the request + context.TODO(), &campaignID, + &recipientID, + data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA, + webhookData, ) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - s.logger.Errorw("failed to get webhook id by campaign id", - "campaignID", campaignID.String(), - "error", err, - ) - return true, errs.Wrap(err) - } - if webhookID != nil { - err = s.services.Campaign.HandleWebhook( - // TODO this should be tied to a application wide context not the request - context.TODO(), - webhookID, - &campaignID, - &recipientID, - data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA, - webhookData, - ) - if err != nil { - return true, fmt.Errorf("failed to handle webhook: %s", err) - } + if err != nil { + return true, fmt.Errorf("failed to handle webhooks: %s", err) } } // if redirect && data submission && final page @@ -1451,32 +1437,23 @@ func (s *Server) checkAndServePhishingPage( } } - // handle webhook for Proxy page visit - webhookID, err := s.repositories.Campaign.GetWebhookIDByCampaignID( - c, - &campaignID, - ) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - s.logger.Errorw("failed to get webhook id by campaign id for proxy", - "campaignID", campaignID.String(), - "error", err, - ) - } - if webhookID != nil && currentPageType != data.PAGE_TYPE_DONE { - err = s.services.Campaign.HandleWebhook( + // handle webhooks for Proxy page visit + if currentPageType != data.PAGE_TYPE_DONE { + err = s.services.Campaign.HandleWebhooks( // TODO this should be tied to a application wide context not the request context.TODO(), - webhookID, &campaignID, &recipientID, eventName, nil, ) if err != nil { - s.logger.Errorw("failed to handle webhook for Proxy page", + s.logger.Errorw("failed to handle webhooks for Proxy page visit", + "campaignID", campaignID.String(), + "recipientID", recipientID.String(), "error", err, - "proxyID", proxyID.String(), ) + return true, errs.Wrap(err) } } @@ -1759,34 +1736,19 @@ func (s *Server) checkAndServePhishingPage( return true, errs.Wrap(err) } } - // handle webhook - webhookID, err := s.repositories.Campaign.GetWebhookIDByCampaignID( - c, - &campaignID, - ) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - s.logger.Errorw("failed to get webhook id by campaign id %s", - "campaignID", campaignID.String(), - "error", err, - ) - return true, errs.Wrap(err) - } - if webhookID == nil { - return true, nil - } + // handle webhooks // do not notify on visiting the page done as it is a repeat of the flow if currentPageType != data.PAGE_TYPE_DONE { - err = s.services.Campaign.HandleWebhook( + err = s.services.Campaign.HandleWebhooks( // TODO this should be tied to a application wide context not the request context.TODO(), - webhookID, &campaignID, &recipientID, eventName, nil, ) if err != nil { - return true, fmt.Errorf("failed to handle webhook: %s", err) + return true, fmt.Errorf("failed to handle webhooks: %s", err) } } @@ -1982,32 +1944,21 @@ func (s *Server) renderDenyPage( recipientEmailStr = recipientEmailVal.String() } - // handle webhook for deny page visit - webhookID, err := s.repositories.Campaign.GetWebhookIDByCampaignID( - c, + // handle webhooks for deny page visit + err = s.services.Campaign.HandleWebhooks( + context.TODO(), &campaignID, + &recipientID, + data.EVENT_CAMPAIGN_RECIPIENT_DENY_PAGE_VISITED, + nil, ) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - s.logger.Errorw("failed to get webhook id by campaign id for deny page", + if err != nil { + s.logger.Errorw("failed to handle webhooks for deny page visit", "campaignID", campaignID.String(), + "recipientID", recipientID.String(), "error", err, ) - } - if webhookID != nil { - err = s.services.Campaign.HandleWebhook( - context.TODO(), - webhookID, - &campaignID, - &recipientID, - data.EVENT_CAMPAIGN_RECIPIENT_DENY_PAGE_VISITED, - nil, - ) - if err != nil { - s.logger.Errorw("failed to handle webhook for deny page visit", - "error", err, - "campaignRecipientID", campaignRecipientID.String(), - ) - } + // not returning error as we don't want to block page rendering } s.logger.Debugw("rendered deny page", diff --git a/backend/controller/campaign.go b/backend/controller/campaign.go index 1003dc7..93fcf3d 100644 --- a/backend/controller/campaign.go +++ b/backend/controller/campaign.go @@ -180,6 +180,7 @@ func (c *Campaign) GetByID(g *gin.Context) { WithAllowDeny: true, WithDenyPage: true, WithEvasionPage: true, + WithWebhooks: true, }, ) // handle responses diff --git a/backend/database/campaign.go b/backend/database/campaign.go index 2e2c814..7b48f36 100644 --- a/backend/database/campaign.go +++ b/backend/database/campaign.go @@ -44,21 +44,11 @@ type Campaign struct { IsAnonymous bool `gorm:"not null;default:false"` IsTest bool `gorm:"not null;default:false"` Obfuscate bool `gorm:"not null;default:false"` - WebhookIncludeData string `gorm:"not null;default:'full'"` - // WebhookEvents is a binary format storing selected events as bits (10 events) - // 0 = all events (default, backward compatible) - // non-zero = only selected events trigger webhooks - // bit 0 (1): campaign_closed - // bit 1 (2): campaign_recipient_message_sent - // bit 2 (4): campaign_recipient_message_failed - // bit 3 (8): campaign_recipient_message_read - // bit 4 (16): campaign_recipient_submitted_data - // bit 5 (32): campaign_recipient_evasion_page_visited - // bit 6 (64): campaign_recipient_before_page_visited - // bit 7 (128): campaign_recipient_page_visited - // bit 8 (256): campaign_recipient_after_page_visited - // bit 9 (512): campaign_recipient_deny_page_visited - WebhookEvents int `gorm:"not null;default:0"` + + // deprecated: webhook settings moved to campaign_webhooks junction table + // kept for backward compatibility during migration + WebhookIncludeData string `gorm:"not null;default:'full'"` + WebhookEvents int `gorm:"not null;default:0"` // has one CampaignTemplateID *uuid.UUID `gorm:"index;type:uuid;"` @@ -75,11 +65,13 @@ type Campaign struct { NotableEvent *Event `gorm:"foreignKey:NotableEventID;references:ID"` NotableEventID *uuid.UUID `gorm:"type:uuid;index"` + // deprecated: kept for backward compatibility during migration WebhookID *uuid.UUID `gorm:"type:uuid;index;"` // has many-to-many RecipientGroups []*RecipientGroup `gorm:"many2many:campaign_recipient_groups"` AllowDeny []*AllowDeny `gorm:"many2many:campaign_allow_denies"` + Webhooks []*Webhook `gorm:"many2many:campaign_webhooks"` Name string `gorm:"not null;uniqueIndex:idx_campaigns_unique_name_and_company_id"` } diff --git a/backend/database/campaignWebhook.go b/backend/database/campaignWebhook.go new file mode 100644 index 0000000..0819b61 --- /dev/null +++ b/backend/database/campaignWebhook.go @@ -0,0 +1,39 @@ +package database + +import ( + "github.com/google/uuid" +) + +// CampaignWebhook is gorm data model +// is a junction table for campaign-webhook many-to-many relationship +// stores per-webhook configuration (events and data level) +type CampaignWebhook struct { + CampaignID *uuid.UUID `gorm:"not null;index;type:uuid;uniqueIndex:idx_campaign_webhook;primaryKey;"` + Campaign *Campaign + + WebhookID *uuid.UUID `gorm:"not null;index;type:uuid;uniqueIndex:idx_campaign_webhook;primaryKey;"` + Webhook *Webhook + + // webhookincludedata is the data level to include in webhook payload + // values: "none", "basic", "full" + WebhookIncludeData string `gorm:"not null;default:'full'"` + + // webhookevents is a binary format storing selected events as bits (10 events) + // 0 = all events (default, backward compatible) + // non-zero = only selected events trigger webhooks + // bit 0 (1): campaign_closed + // bit 1 (2): campaign_recipient_message_sent + // bit 2 (4): campaign_recipient_message_failed + // bit 3 (8): campaign_recipient_message_read + // bit 4 (16): campaign_recipient_submitted_data + // bit 5 (32): campaign_recipient_evasion_page_visited + // bit 6 (64): campaign_recipient_before_page_visited + // bit 7 (128): campaign_recipient_page_visited + // bit 8 (256): campaign_recipient_after_page_visited + // bit 9 (512): campaign_recipient_deny_page_visited + WebhookEvents int `gorm:"not null;default:0"` +} + +func (CampaignWebhook) TableName() string { + return "campaign_webhooks" +} diff --git a/backend/model/campaign.go b/backend/model/campaign.go index 39c60d9..43dd35d 100644 --- a/backend/model/campaign.go +++ b/backend/model/campaign.go @@ -38,26 +38,31 @@ type Campaign struct { Name nullable.Nullable[vo.String64] `json:"name"` - SaveSubmittedData nullable.Nullable[bool] `json:"saveSubmittedData"` - SaveBrowserMetadata nullable.Nullable[bool] `json:"saveBrowserMetadata"` - IsAnonymous nullable.Nullable[bool] `json:"isAnonymous"` - IsTest nullable.Nullable[bool] `json:"isTest"` - Obfuscate nullable.Nullable[bool] `json:"obfuscate"` - WebhookIncludeData nullable.Nullable[string] `json:"webhookIncludeData"` - WebhookEvents nullable.Nullable[int] `json:"webhookEvents"` - TemplateID nullable.Nullable[uuid.UUID] `json:"templateID"` - Template *CampaignTemplate `json:"template"` - CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"` - Company *Company `json:"company"` - RecipientGroups []*RecipientGroup `json:"recipientGroups"` - RecipientGroupIDs nullable.Nullable[[]*uuid.UUID] `json:"recipientGroupIDs,omitempty"` - AllowDeny []*AllowDeny `json:"allowDeny"` - AllowDenyIDs nullable.Nullable[[]*uuid.UUID] `json:"allowDenyIDs,omitempty"` - DenyPageID nullable.Nullable[uuid.UUID] `json:"denyPageID,omitempty"` - DenyPage *Page `json:"denyPage"` - EvasionPageID nullable.Nullable[uuid.UUID] `json:"evasionPageID,omitempty"` - EvasionPage *Page `json:"evasionPage"` - WebhookID nullable.Nullable[uuid.UUID] `json:"webhookID"` + SaveSubmittedData nullable.Nullable[bool] `json:"saveSubmittedData"` + SaveBrowserMetadata nullable.Nullable[bool] `json:"saveBrowserMetadata"` + IsAnonymous nullable.Nullable[bool] `json:"isAnonymous"` + IsTest nullable.Nullable[bool] `json:"isTest"` + Obfuscate nullable.Nullable[bool] `json:"obfuscate"` + // deprecated: use Webhooks array instead for multiple webhooks with per-webhook settings + WebhookIncludeData nullable.Nullable[string] `json:"webhookIncludeData,omitempty"` + WebhookEvents nullable.Nullable[int] `json:"webhookEvents,omitempty"` + WebhookID nullable.Nullable[uuid.UUID] `json:"webhookID,omitempty"` + + TemplateID nullable.Nullable[uuid.UUID] `json:"templateID"` + Template *CampaignTemplate `json:"template"` + CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"` + Company *Company `json:"company"` + RecipientGroups []*RecipientGroup `json:"recipientGroups"` + RecipientGroupIDs nullable.Nullable[[]*uuid.UUID] `json:"recipientGroupIDs,omitempty"` + AllowDeny []*AllowDeny `json:"allowDeny"` + AllowDenyIDs nullable.Nullable[[]*uuid.UUID] `json:"allowDenyIDs,omitempty"` + DenyPageID nullable.Nullable[uuid.UUID] `json:"denyPageID,omitempty"` + DenyPage *Page `json:"denyPage"` + EvasionPageID nullable.Nullable[uuid.UUID] `json:"evasionPageID,omitempty"` + EvasionPage *Page `json:"evasionPage"` + + // webhooks configuration with per-webhook settings + Webhooks nullable.Nullable[[]*CampaignWebhook] `json:"webhooks,omitempty"` // must not be set by a user NotableEventID nullable.Nullable[uuid.UUID] `json:"notableEventID"` diff --git a/backend/model/campaignWebhook.go b/backend/model/campaignWebhook.go new file mode 100644 index 0000000..3e4d2b6 --- /dev/null +++ b/backend/model/campaignWebhook.go @@ -0,0 +1,71 @@ +package model + +import ( + "github.com/go-errors/errors" + "github.com/google/uuid" + "github.com/oapi-codegen/nullable" + "github.com/phishingclub/phishingclub/data" + "github.com/phishingclub/phishingclub/validate" +) + +// CampaignWebhook represents a webhook configuration for a campaign +// allows per-webhook event and data level settings +type CampaignWebhook struct { + WebhookID nullable.Nullable[uuid.UUID] `json:"webhookID"` + WebhookIncludeData nullable.Nullable[string] `json:"webhookIncludeData"` + WebhookEvents nullable.Nullable[int] `json:"webhookEvents"` +} + +// Validate checks if the campaign webhook has valid configuration +func (cw *CampaignWebhook) Validate() error { + if err := validate.NullableFieldRequired("webhookID", cw.WebhookID); err != nil { + return err + } + + // validate webhookincludedata is one of the allowed values + if cw.WebhookIncludeData.IsSpecified() && !cw.WebhookIncludeData.IsNull() { + dataLevel := cw.WebhookIncludeData.MustGet() + if dataLevel != WebhookDataLevelNone && + dataLevel != WebhookDataLevelBasic && + dataLevel != WebhookDataLevelFull { + return validate.WrapErrorWithField( + errors.New("must be 'none', 'basic', or 'full'"), + "webhookIncludeData", + ) + } + } + + // validate webhookevents is a valid binary value + if cw.WebhookEvents.IsSpecified() && !cw.WebhookEvents.IsNull() { + events := cw.WebhookEvents.MustGet() + // check if any invalid bits are set (only bits 0-9 are valid) + maxValidBits := 0 + for _, bit := range data.WebhookEventToBit { + maxValidBits |= bit + } + if events < 0 || (events > 0 && events&^maxValidBits != 0) { + return validate.WrapErrorWithField( + errors.New("invalid webhook events binary value"), + "webhookEvents", + ) + } + } + + return nil +} + +// GetWebhookIncludeDataOrDefault returns the data level or default to "full" +func (cw *CampaignWebhook) GetWebhookIncludeDataOrDefault() string { + if cw.WebhookIncludeData.IsSpecified() && !cw.WebhookIncludeData.IsNull() { + return cw.WebhookIncludeData.MustGet() + } + return WebhookDataLevelFull +} + +// GetWebhookEventsOrDefault returns the webhook events binary or default to 0 (all events) +func (cw *CampaignWebhook) GetWebhookEventsOrDefault() int { + if cw.WebhookEvents.IsSpecified() && !cw.WebhookEvents.IsNull() { + return cw.WebhookEvents.MustGet() + } + return 0 // 0 means all events +} diff --git a/backend/proxy/proxy.go b/backend/proxy/proxy.go index dd737c5..b55c79c 100644 --- a/backend/proxy/proxy.go +++ b/backend/proxy/proxy.go @@ -3428,30 +3428,20 @@ func (m *ProxyHandler) createCampaignSubmitEvent(session *service.ProxySession, m.logger.Errorw("failed to create campaign submit event", "error", err) } - // handle webhook for submitted data event - webhookID, err := m.CampaignRepository.GetWebhookIDByCampaignID(ctx, session.CampaignID) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - m.logger.Errorw("failed to get webhook id by campaign id for MITM proxy submit", - "campaignID", session.CampaignID.String(), + // handle webhooks for submitted data event + err = m.CampaignService.HandleWebhooks( + ctx, + session.CampaignID, + session.RecipientID, + data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA, + capturedData, + ) + if err != nil { + m.logger.Errorw("failed to handle webhooks for MITM proxy submit", "error", err, + "campaignRecipientID", session.CampaignRecipientID.String(), ) } - if webhookID != nil { - err = m.CampaignService.HandleWebhook( - ctx, - webhookID, - session.CampaignID, - session.RecipientID, - data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA, - capturedData, - ) - if err != nil { - m.logger.Errorw("failed to handle webhook for MITM proxy submit", - "error", err, - "campaignRecipientID", session.CampaignRecipientID.String(), - ) - } - } } func (m *ProxyHandler) parseProxyConfig(configStr string) (*service.ProxyServiceConfigYAML, error) { @@ -4181,27 +4171,18 @@ func (m *ProxyHandler) registerPageVisitEvent(req *http.Request, session *servic } } - // handle webhook for MITM page visit - webhookID, err := m.CampaignRepository.GetWebhookIDByCampaignID(ctx, session.CampaignID) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - m.logger.Errorw("failed to get webhook id by campaign id for MITM proxy", - "campaignID", session.CampaignID.String(), - "error", err, - ) - } - if webhookID != nil && currentPageType != data.PAGE_TYPE_DONE { - err = m.CampaignService.HandleWebhook( + // handle webhooks for MITM page visit + if currentPageType != data.PAGE_TYPE_DONE { + err = m.CampaignService.HandleWebhooks( ctx, - webhookID, session.CampaignID, session.RecipientID, eventName, nil, ) if err != nil { - m.logger.Errorw("failed to handle webhook for MITM page visit", + m.logger.Errorw("failed to handle webhooks for MITM page visit", "error", err, - "campaignRecipientID", session.CampaignRecipientID.String(), ) } } @@ -4742,30 +4723,19 @@ func (m *ProxyHandler) registerDenyPageVisitEventDirect(req *http.Request, reqCt } } - // handle webhook for deny page visit - webhookID, err := m.CampaignRepository.GetWebhookIDByCampaignID(req.Context(), campaignID) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - m.logger.Errorw("failed to get webhook id by campaign id for deny page", - "campaignID", campaignID.String(), + // handle webhooks for deny page visit + err = m.CampaignService.HandleWebhooks( + req.Context(), + campaignID, + recipientID, + data.EVENT_CAMPAIGN_RECIPIENT_DENY_PAGE_VISITED, + nil, + ) + if err != nil { + m.logger.Errorw("failed to handle webhooks for deny page visit", "error", err, ) } - if webhookID != nil { - err = m.CampaignService.HandleWebhook( - req.Context(), - webhookID, - campaignID, - recipientID, - data.EVENT_CAMPAIGN_RECIPIENT_DENY_PAGE_VISITED, - nil, - ) - if err != nil { - m.logger.Errorw("failed to handle webhook for deny page visit", - "error", err, - "campaignRecipientID", reqCtx.CampaignRecipientID.String(), - ) - } - } } func (m *ProxyHandler) registerEvasionPageVisitEventDirect(req *http.Request, reqCtx *RequestContext) { @@ -4815,30 +4785,19 @@ func (m *ProxyHandler) registerEvasionPageVisitEventDirect(req *http.Request, re m.logger.Errorw("failed to save evasion page visit event", "error", err) } - // handle webhook for evasion page visit - webhookID, err := m.CampaignRepository.GetWebhookIDByCampaignID(req.Context(), campaignID) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - m.logger.Errorw("failed to get webhook id by campaign id for evasion page", - "campaignID", campaignID.String(), + // handle webhooks for evasion page visit + err = m.CampaignService.HandleWebhooks( + req.Context(), + campaignID, + recipientID, + data.EVENT_CAMPAIGN_RECIPIENT_EVASION_PAGE_VISITED, + nil, + ) + if err != nil { + m.logger.Errorw("failed to handle webhooks for evasion page visit", "error", err, ) } - if webhookID != nil { - err = m.CampaignService.HandleWebhook( - req.Context(), - webhookID, - campaignID, - recipientID, - data.EVENT_CAMPAIGN_RECIPIENT_EVASION_PAGE_VISITED, - nil, - ) - if err != nil { - m.logger.Errorw("failed to handle webhook for evasion page visit", - "error", err, - "campaignRecipientID", reqCtx.CampaignRecipientID.String(), - ) - } - } } // checkFilter checks if the client IP, JA4 fingerprint and geo ip are allowed for proxy requests diff --git a/backend/repository/campaign.go b/backend/repository/campaign.go index 3cad2b8..c08b9e6 100644 --- a/backend/repository/campaign.go +++ b/backend/repository/campaign.go @@ -60,6 +60,7 @@ type CampaignOption struct { WithAllowDeny bool WithDenyPage bool WithEvasionPage bool + WithWebhooks bool IncludeTestCampaigns bool } @@ -117,6 +118,9 @@ func (r *Campaign) load(db *gorm.DB, options *CampaignOption) *gorm.DB { if options.WithEvasionPage { db = db.Preload("EvasionPage") } + // WithWebhooks is handled post-query via GetCampaignWebhooks + // because the junction table stores extra columns (include_data, events) + // that GORM many2many preload cannot populate into model.CampaignWebhook return db } @@ -253,6 +257,85 @@ func (r *Campaign) AddAllowDenyLists( return nil } +// AddWebhooks adds webhooks to campaign with per-webhook configuration +func (r *Campaign) AddWebhooks( + ctx context.Context, + campaignID *uuid.UUID, + webhooks []*model.CampaignWebhook, +) error { + batch := []database.CampaignWebhook{} + // deduplicate by webhook id to prevent unique constraint violations + seen := map[string]struct{}{} + for _, wh := range webhooks { + webhookID := wh.WebhookID.MustGet() + key := webhookID.String() + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + batch = append(batch, database.CampaignWebhook{ + CampaignID: campaignID, + WebhookID: &webhookID, + WebhookIncludeData: wh.GetWebhookIncludeDataOrDefault(), + WebhookEvents: wh.GetWebhookEventsOrDefault(), + }) + } + if len(batch) == 0 { + return nil + } + res := r.DB.Create(&batch) + + if res.Error != nil { + return res.Error + } + return nil +} + +// RemoveWebhooksByCampaignID removes all webhooks from a campaign +func (r *Campaign) RemoveWebhooksByCampaignID( + ctx context.Context, + campaignID *uuid.UUID, +) error { + res := r.DB. + Where("campaign_id = ?", campaignID). + Delete(&database.CampaignWebhook{}) + + if res.Error != nil { + return res.Error + } + return nil +} + +// GetCampaignWebhooks fetches webhook configurations for a campaign from junction table +func (r *Campaign) GetCampaignWebhooks( + ctx context.Context, + campaignID *uuid.UUID, +) ([]*model.CampaignWebhook, error) { + var rows []database.CampaignWebhook + res := r.DB. + Where("campaign_id = ?", campaignID.String()). + Find(&rows) + + if res.Error != nil { + return nil, res.Error + } + + webhooks := []*model.CampaignWebhook{} + for _, row := range rows { + if row.WebhookID == nil { + continue + } + wh := &model.CampaignWebhook{ + WebhookID: nullable.NewNullableWithValue(*row.WebhookID), + WebhookIncludeData: nullable.NewNullableWithValue(row.WebhookIncludeData), + WebhookEvents: nullable.NewNullableWithValue(row.WebhookEvents), + } + webhooks = append(webhooks, wh) + } + + return webhooks, nil +} + // GetByWebhookID gets campaigns by webhook ID // not paginated func (r *Campaign) GetByWebhookID( @@ -261,8 +344,41 @@ func (r *Campaign) GetByWebhookID( ) ([]*model.Campaign, error) { rows := []*database.Campaign{} models := []*model.Campaign{} + + // use a map to deduplicate campaign IDs collected from both the junction + // table and the legacy webhook_id column - after migration a campaign will + // appear in both sources, so deduplication is required before the final query + seen := map[string]struct{}{} + + // get from junction table + var junctionRows []database.CampaignWebhook + r.DB.Where("webhook_id = ?", webhookID.String()).Find(&junctionRows) + for _, row := range junctionRows { + if row.CampaignID != nil { + seen[row.CampaignID.String()] = struct{}{} + } + } + + // get from old webhook_id field (backward compatibility) + var oldRows []*database.Campaign + r.DB.Where("webhook_id = ?", webhookID.String()).Find(&oldRows) + for _, row := range oldRows { + if row.ID != nil { + seen[row.ID.String()] = struct{}{} + } + } + + if len(seen) == 0 { + return models, nil + } + + campaignIDs := make([]string, 0, len(seen)) + for id := range seen { + campaignIDs = append(campaignIDs, id) + } + res := r.DB. - Where("webhook_id = ?", webhookID.String()). + Where("id IN ?", campaignIDs). Find(&rows) if res.Error != nil { @@ -348,11 +464,30 @@ func (r *Campaign) GetByTemplateIDs( return models, nil } +// RemoveWebhookFromJunctionByWebhookID removes all campaign_webhooks rows for a given webhook id +// must be called before deleting a webhook to avoid orphaned junction rows +func (r *Campaign) RemoveWebhookFromJunctionByWebhookID( + ctx context.Context, + webhookID *uuid.UUID, +) error { + res := r.DB. + Where("webhook_id = ?", webhookID.String()). + Delete(&database.CampaignWebhook{}) + + if res.Error != nil { + return res.Error + } + return nil +} + // RemoveWebhookByCampaignIDs removes the webhook from campaigns by ids func (r *Campaign) RemoveWebhookByCampaignIDs( ctx context.Context, campaignIDs []*uuid.UUID, ) error { + if len(campaignIDs) == 0 { + return nil + } row := map[string]interface{}{} ids := UUIDsToStrings(campaignIDs) AddUpdatedAt(row) @@ -1084,7 +1219,18 @@ func (r *Campaign) GetByID( if res.Error != nil { return nil, res.Error } - return ToCampaign(&dbCampaign) + campaign, err := ToCampaign(&dbCampaign) + if err != nil { + return nil, err + } + if options.WithWebhooks { + webhooks, err := r.GetCampaignWebhooks(ctx, id) + if err != nil { + return nil, err + } + campaign.Webhooks = nullable.NewNullableWithValue(webhooks) + } + return campaign, nil } // GetNameByID gets a campaign name by id @@ -1652,6 +1798,7 @@ func ToCampaign(row *database.Campaign) (*model.Campaign, error) { isAnonymous := nullable.NewNullableWithValue(row.IsAnonymous) isTest := nullable.NewNullableWithValue(row.IsTest) obfuscate := nullable.NewNullableWithValue(row.Obfuscate) + // deprecated fields - kept for backward compatibility webhookIncludeData := nullable.NewNullableWithValue(row.WebhookIncludeData) webhookEvents := nullable.NewNullableWithValue(row.WebhookEvents) var templateID nullable.Nullable[uuid.UUID] @@ -1740,10 +1887,14 @@ func ToCampaign(row *database.Campaign) (*model.Campaign, error) { } constraintEndTime.Set(*t) } + // handle old single webhook (backward compatibility) webhookID := nullable.NewNullNullable[uuid.UUID]() if row.WebhookID != nil { webhookID.Set(*row.WebhookID) } + + // handle new multiple webhooks - will be populated separately if needed + webhooks := nullable.NewNullNullable[[]*model.CampaignWebhook]() anonymizeAt := nullable.NewNullNullable[time.Time]() if row.AnonymizeAt != nil { anonymizeAt.Set(*row.AnonymizeAt) @@ -1797,6 +1948,7 @@ func ToCampaign(row *database.Campaign) (*model.Campaign, error) { EvasionPage: evasionPage, EvasionPageID: evasionPageID, WebhookID: webhookID, + Webhooks: webhooks, NotableEventID: notableEventID, NotableEventName: notableEventName, }, nil diff --git a/backend/repository/webhook.go b/backend/repository/webhook.go index a95d95b..8840e0d 100644 --- a/backend/repository/webhook.go +++ b/backend/repository/webhook.go @@ -127,6 +127,36 @@ func (r *Webhook) GetByID( return ToWebhook(&row), nil } +// GetByIDs fetches multiple webhooks by their IDs in a single query +func (r *Webhook) GetByIDs( + ctx context.Context, + ids []*uuid.UUID, +) ([]*model.Webhook, error) { + out := []*model.Webhook{} + if len(ids) == 0 { + return out, nil + } + idStrings := make([]string, 0, len(ids)) + for _, id := range ids { + idStrings = append(idStrings, id.String()) + } + var rows []*database.Webhook + res := r.DB. + Where( + fmt.Sprintf("%s IN ?", TableColumnID(database.WEBHOOK_TABLE)), + idStrings, + ). + Find(&rows) + + if res.Error != nil { + return nil, res.Error + } + for _, row := range rows { + out = append(out, ToWebhook(row)) + } + return out, nil +} + // GetByNames gets webhooks by names func (r *Webhook) GetByName( ctx context.Context, diff --git a/backend/seed/migrate.go b/backend/seed/migrate.go index e42be0f..ce2169b 100644 --- a/backend/seed/migrate.go +++ b/backend/seed/migrate.go @@ -52,6 +52,7 @@ func initialInstallAndSeed( &database.AllowDeny{}, &database.CampaignAllowDeny{}, &database.Webhook{}, + &database.CampaignWebhook{}, &database.Identifier{}, &database.CampaignStats{}, &database.OAuthProvider{}, @@ -126,6 +127,13 @@ func initialInstallAndSeed( errors.Errorf("failed to seed identifiers: %w", err), ) } + // run data migrations (idempotent - safe to run on every startup) + err = migrate(db) + if err != nil { + return errs.Wrap( + errors.Errorf("failed to run data migrations: %w", err), + ) + } return nil } @@ -398,5 +406,19 @@ func migrate(db *gorm.DB) error { return errs.Wrap(err) } + // migration for converting single webhook to multiple webhooks + // migrate existing campaigns with webhook_id to campaign_webhooks junction table + // ON CONFLICT DO NOTHING makes this idempotent on re-runs without relying on + // db-driver-specific error message strings + if err := db.Exec(` + INSERT INTO campaign_webhooks (campaign_id, webhook_id, webhook_include_data, webhook_events) + SELECT id, webhook_id, webhook_include_data, webhook_events + FROM campaigns + WHERE webhook_id IS NOT NULL + ON CONFLICT DO NOTHING + `).Error; err != nil { + return errs.Wrap(err) + } + return nil } diff --git a/backend/service/campaign.go b/backend/service/campaign.go index 8673fc2..c24cd57 100644 --- a/backend/service/campaign.go +++ b/backend/service/campaign.go @@ -77,13 +77,36 @@ func (c *Campaign) Create( if len(campaign.RecipientGroupIDs) == 0 { return nil, validate.WrapErrorWithField(errors.New("no groups provided"), "Recipient Groups") } - // set default webhookIncludeData for backward compatibility - if !campaign.WebhookIncludeData.IsSpecified() { - campaign.WebhookIncludeData.Set(model.WebhookDataLevelFull) + + // handle backward compatibility: convert old single webhook to new array format + if campaign.WebhookID.IsSpecified() && !campaign.WebhookID.IsNull() { + webhookID := campaign.WebhookID.MustGet() + // set default values for old fields if not specified + if !campaign.WebhookIncludeData.IsSpecified() { + campaign.WebhookIncludeData.Set(model.WebhookDataLevelFull) + } + if !campaign.WebhookEvents.IsSpecified() { + campaign.WebhookEvents.Set(0) + } + // convert to new array format + webhookConfig := &model.CampaignWebhook{ + WebhookID: nullable.NewNullableWithValue(webhookID), + WebhookIncludeData: campaign.WebhookIncludeData, + WebhookEvents: campaign.WebhookEvents, + } + campaign.Webhooks.Set([]*model.CampaignWebhook{webhookConfig}) + // clear old fields + campaign.WebhookID.SetNull() } - // set default webhookEvents (0 means all events) for backward compatibility - if !campaign.WebhookEvents.IsSpecified() { - campaign.WebhookEvents.Set(0) + + // validate new webhooks array + if campaign.Webhooks.IsSpecified() && !campaign.Webhooks.IsNull() { + webhooks := campaign.Webhooks.MustGet() + for _, wh := range webhooks { + if err := wh.Validate(); err != nil { + return nil, errs.Wrap(err) + } + } } // if the schedule type is scheduled, set the start time to start of day and end to the end of the last day if campaign.ConstraintWeekDays.IsSpecified() && !campaign.ConstraintWeekDays.IsNull() { @@ -165,6 +188,18 @@ func (c *Campaign) Create( return nil, errs.Wrap(err) } + // save webhook configurations if present + if campaign.Webhooks.IsSpecified() && !campaign.Webhooks.IsNull() { + webhooks := campaign.Webhooks.MustGet() + if len(webhooks) > 0 { + err = c.CampaignRepository.AddWebhooks(ctx, id, webhooks) + if err != nil { + c.Logger.Errorw("failed to add webhooks to campaign", "error", err) + return nil, errs.Wrap(err) + } + } + } + createdCampaign, err := c.CampaignRepository.GetByID( ctx, id, @@ -1251,18 +1286,9 @@ func (c *Campaign) SaveTrackingPixelLoaded( // logging was done in the previous call return errs.Wrap(err) } - // handle webhook - webhookID, err := c.CampaignRepository.GetWebhookIDByCampaignID(ctx, &campaignID) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - c.Logger.Errorw("failed to get webhook id by campaign id", "error", err) - return errs.Wrap(err) - } - if errors.Is(err, gorm.ErrRecordNotFound) || webhookID == nil { - return nil - } - err = c.HandleWebhook( + // handle webhooks + err = c.HandleWebhooks( ctx, - webhookID, &campaignID, &recipientID, data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_READ, @@ -1383,12 +1409,9 @@ func (c *Campaign) UpdateByID( if v, err := incoming.Obfuscate.Get(); err == nil { current.Obfuscate.Set(v) } - if v, err := incoming.WebhookIncludeData.Get(); err == nil { - current.WebhookIncludeData.Set(v) - } - if v, err := incoming.WebhookEvents.Get(); err == nil { - current.WebhookEvents.Set(v) - } + // deprecated: webhookIncludeData and webhookEvents on the campaign root are not + // written back - they are only read for backward-compat in HandleWebhooks. + // per-webhook settings are managed via the webhooks array and campaign_webhooks table. if v, err := incoming.TemplateID.Get(); err == nil { current.TemplateID.Set(v) } @@ -1425,18 +1448,48 @@ func (c *Campaign) UpdateByID( if v, err := incoming.AnonymizedAt.Get(); err == nil { current.AnonymizedAt.Set(v.Truncate(time.Minute)) } - if v, err := incoming.TemplateID.Get(); err == nil { - current.TemplateID.Set(v) - } if v, err := incoming.RecipientGroupIDs.Get(); err == nil { current.RecipientGroupIDs.Set(v) } - // handle webhook ID - if incoming.WebhookID.IsSpecified() { - if incoming.WebhookID.IsNull() { - current.WebhookID.SetNull() + // handle backward compatibility: convert old single webhook to new array format + if incoming.WebhookID.IsSpecified() && !incoming.WebhookID.IsNull() { + webhookID := incoming.WebhookID.MustGet() + // set default values for old fields if not specified + webhookIncludeData := model.WebhookDataLevelFull + if incoming.WebhookIncludeData.IsSpecified() && !incoming.WebhookIncludeData.IsNull() { + webhookIncludeData = incoming.WebhookIncludeData.MustGet() + } + webhookEvents := 0 + if incoming.WebhookEvents.IsSpecified() && !incoming.WebhookEvents.IsNull() { + webhookEvents = incoming.WebhookEvents.MustGet() + } + // convert to new array format + webhookConfig := &model.CampaignWebhook{ + WebhookID: nullable.NewNullableWithValue(webhookID), + WebhookIncludeData: nullable.NewNullableWithValue(webhookIncludeData), + WebhookEvents: nullable.NewNullableWithValue(webhookEvents), + } + incoming.Webhooks.Set([]*model.CampaignWebhook{webhookConfig}) + // clear old fields + incoming.WebhookID.SetNull() + } + + // handle new webhooks array + if incoming.Webhooks.IsSpecified() { + // always clear the deprecated webhook_id column when the new format is used + // so the legacy fallback in HandleWebhooks never reads a stale value + current.WebhookID.SetNull() + if incoming.Webhooks.IsNull() || len(incoming.Webhooks.MustGet()) == 0 { + current.Webhooks.SetNull() } else { - current.WebhookID.Set(incoming.WebhookID.MustGet()) + webhooks := incoming.Webhooks.MustGet() + // validate each webhook + for _, wh := range webhooks { + if err := wh.Validate(); err != nil { + return errs.Wrap(err) + } + } + current.Webhooks.Set(webhooks) } } @@ -1487,6 +1540,27 @@ func (c *Campaign) UpdateByID( c.Logger.Errorw("failed to update campaign by id", "error", err) return errs.Wrap(err) } + + // update webhook configurations + if current.Webhooks.IsSpecified() { + // remove all existing webhooks + err = c.CampaignRepository.RemoveWebhooksByCampaignID(ctx, id) + if err != nil { + c.Logger.Errorw("failed to remove webhooks from campaign", "error", err) + return errs.Wrap(err) + } + // add new webhooks if present + if !current.Webhooks.IsNull() { + webhooks := current.Webhooks.MustGet() + if len(webhooks) > 0 { + err = c.CampaignRepository.AddWebhooks(ctx, id, webhooks) + if err != nil { + c.Logger.Errorw("failed to add webhooks to campaign", "error", err) + return errs.Wrap(err) + } + } + } + } // re-schedule the campaign // TODO should this all be in the schedule method // remove all existing schedules if the campaign is not self-managed @@ -2580,18 +2654,9 @@ func (c *Campaign) saveSendingResult( // logging was done in the previous call return errs.Wrap(err) } - // handle webhook - webhookID, err := c.CampaignRepository.GetWebhookIDByCampaignID(ctx, &campaignID) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - c.Logger.Errorw("failed to get webhook id by campaign id", "error", err) - return errs.Wrap(err) - } - if webhookID == nil { - return nil - } - err = c.HandleWebhook( + // handle webhooks + err = c.HandleWebhooks( ctx, - webhookID, &campaignID, &recipientID, eventName, @@ -2629,18 +2694,9 @@ func (c *Campaign) saveEventCampaignClose( if err != nil { return fmt.Errorf("failed to save event: %s", err) } - // handle webhook - webhookID, err := c.CampaignRepository.GetWebhookIDByCampaignID(ctx, campaignID) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - c.Logger.Errorw("failed to get webhook id by campaign id", "error", err) - return errs.Wrap(err) - } - if webhookID == nil { - return nil - } - err = c.HandleWebhook( + // handle webhooks + err = c.HandleWebhooks( ctx, - webhookID, campaignID, nil, data.EVENT_CAMPAIGN_CLOSED, @@ -3338,18 +3394,9 @@ func (c *Campaign) SetSentAtByCampaignRecipientID( return errs.Wrap(err) } c.AuditLogAuthorized(ae) - // handle webhook - webhookID, err := c.CampaignRepository.GetWebhookIDByCampaignID(ctx, &campaignID) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - c.Logger.Errorw("failed to get webhook id by campaign id", "error", err) - return errs.Wrap(err) - } - if errors.Is(err, gorm.ErrRecordNotFound) || webhookID == nil { - return nil - } - err = c.HandleWebhook( + // handle webhooks + err = c.HandleWebhooks( ctx, - webhookID, &campaignID, &recipientID, data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_SENT, @@ -3361,8 +3408,138 @@ func (c *Campaign) SetSentAtByCampaignRecipientID( return nil } -// HandleWebhook handles a webhook -// it must only be called from secure contexts as it is not checked for permissions +// HandleWebhooks handles multiple webhooks for a campaign event +func (c *Campaign) HandleWebhooks( + ctx context.Context, + campaignID *uuid.UUID, + recipientID *uuid.UUID, + eventName string, + capturedData map[string]interface{}, +) error { + // get campaign webhooks from junction table + webhooks, err := c.CampaignRepository.GetCampaignWebhooks(ctx, campaignID) + if err != nil { + c.Logger.Errorw("failed to get campaign webhooks", "error", err) + return errs.Wrap(err) + } + + // if no webhooks in new format, check for old single webhook (backward compatibility) + if len(webhooks) == 0 { + campaign, err := c.CampaignRepository.GetByID(ctx, campaignID, &repository.CampaignOption{}) + if err != nil { + return errs.Wrap(err) + } + // check if old webhook_id is set + if campaign.WebhookID.IsSpecified() && !campaign.WebhookID.IsNull() { + webhookID := campaign.WebhookID.MustGet() + // use old webhook fields for backward compatibility + webhookIncludeData := model.WebhookDataLevelFull + if level, err := campaign.WebhookIncludeData.Get(); err == nil { + webhookIncludeData = level + } + webhookEvents := 0 + if events, err := campaign.WebhookEvents.Get(); err == nil { + webhookEvents = events + } + webhooks = []*model.CampaignWebhook{{ + WebhookID: nullable.NewNullableWithValue(webhookID), + WebhookIncludeData: nullable.NewNullableWithValue(webhookIncludeData), + WebhookEvents: nullable.NewNullableWithValue(webhookEvents), + }} + } + } + + if len(webhooks) == 0 { + return nil + } + + // get campaign name once for all webhooks + campaignName, err := c.CampaignRepository.GetNameByID(ctx, campaignID) + if err != nil { + return errs.Wrap(err) + } + + // get email once for all webhooks + var email *vo.Email + if recipientID != nil { + email, err = c.RecipientRepository.GetEmailByID(ctx, recipientID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return errs.Wrap(err) + } + } + + // batch-fetch all webhook details in one query to avoid N+1 db calls + webhookIDs := make([]*uuid.UUID, 0, len(webhooks)) + for _, wc := range webhooks { + id := wc.WebhookID.MustGet() + webhookIDs = append(webhookIDs, &id) + } + webhookDetails, err := c.WebhookRepository.GetByIDs(ctx, webhookIDs) + if err != nil { + c.Logger.Errorw("failed to batch-fetch webhook details", "error", err) + return errs.Wrap(err) + } + // index fetched webhooks by id for O(1) lookup inside the loop + webhookByID := make(map[string]*model.Webhook, len(webhookDetails)) + for _, wd := range webhookDetails { + webhookByID[wd.ID.MustGet().String()] = wd + } + + // send to each configured webhook + for _, webhookConfig := range webhooks { + webhookID := webhookConfig.WebhookID.MustGet() + webhookEvents := webhookConfig.GetWebhookEventsOrDefault() + dataLevel := webhookConfig.GetWebhookIncludeDataOrDefault() + + // check if this event should trigger this webhook + if !model.IsWebhookEventEnabled(webhookEvents, eventName) { + continue + } + + webhook, ok := webhookByID[webhookID.String()] + if !ok { + c.Logger.Errorw("webhook not found in batch result", "webhookID", webhookID.String()) + continue + } + + now := time.Now() + webhookReq := WebhookRequest{ + Time: &now, + Event: eventName, + } + + // apply data level filters + switch dataLevel { + case model.WebhookDataLevelNone: + // only time and event - no campaign name, no email, no data + case model.WebhookDataLevelBasic: + // include campaign name but no email or captured data + webhookReq.CampaignName = campaignName + case model.WebhookDataLevelFull: + // include everything + webhookReq.CampaignName = campaignName + webhookReq.Data = capturedData + if email != nil { + webhookReq.Email = email.String() + } + } + + // use context.Background() so a cancelled request context does not + // abort the send - the goroutine outlives the originating request + go func(wh *model.Webhook, req *WebhookRequest) { + c.Logger.Debugw("sending webhook", "url", wh.URL.MustGet().String()) + _, err := c.WebhookService.Send(context.Background(), wh, req) + if err != nil { + c.Logger.Errorw("failed to send webhook", "error", err) + } + c.Logger.Debugw("sending webhook completed", "url", wh.URL.MustGet().String()) + }(webhook, &webhookReq) + } + + return nil +} + +// HandleWebhook handles a single webhook (deprecated - kept for backward compatibility) func (c *Campaign) HandleWebhook( ctx context.Context, webhookID *uuid.UUID, @@ -3371,75 +3548,8 @@ func (c *Campaign) HandleWebhook( eventName string, capturedData map[string]interface{}, ) error { - campaignName, err := c.CampaignRepository.GetNameByID(ctx, campaignID) - if err != nil { - return errs.Wrap(err) - } - email, err := c.RecipientRepository.GetEmailByID(ctx, recipientID) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return errs.Wrap(err) - } - webhook, err := c.WebhookRepository.GetByID(ctx, webhookID) - if err != nil { - return errs.Wrap(err) - } - - // check campaign webhook data level - campaign, err := c.CampaignRepository.GetByID(ctx, campaignID, &repository.CampaignOption{}) - if err != nil { - return errs.Wrap(err) - } - - // check if this event should trigger webhook notification - webhookEvents := 0 - if events, err := campaign.WebhookEvents.Get(); err == nil { - webhookEvents = events - } - // 0 means all events (backward compatibility) - // otherwise check if the event bit is set - if !model.IsWebhookEventEnabled(webhookEvents, eventName) { - // event not in selected events, skip webhook - return nil - } - - // determine what data to send based on webhookIncludeData level - dataLevel := model.WebhookDataLevelFull - if level, err := campaign.WebhookIncludeData.Get(); err == nil { - dataLevel = level - } - - now := time.Now() - webhookReq := WebhookRequest{ - Time: &now, - Event: eventName, - } - - // apply data level filters - switch dataLevel { - case model.WebhookDataLevelNone: - // only time and event - no campaign name, no email, no data - case model.WebhookDataLevelBasic: - // include campaign name but no email or captured data - webhookReq.CampaignName = campaignName - case model.WebhookDataLevelFull: - // include everything - webhookReq.CampaignName = campaignName - webhookReq.Data = capturedData - if email != nil { - webhookReq.Email = email.String() - } - } - // the webhook is handles as a different go routine - // so we don't block the campaign handling thread - go func() { - c.Logger.Debugw("sending webhook", "url", webhook.URL.MustGet().String()) - _, err := c.WebhookService.Send(ctx, webhook, &webhookReq) - if err != nil { - c.Logger.Errorw("failed to send webhook", "error", err) - } - c.Logger.Debugw("sending webhook completed", "url", webhook.URL.MustGet().String()) - }() - return nil + // for backward compatibility, delegate to HandleWebhooks + return c.HandleWebhooks(ctx, campaignID, recipientID, eventName, capturedData) } // AnonymizeByID anonymizes a campaign including the events diff --git a/backend/service/webhook.go b/backend/service/webhook.go index 086de29..1f62e29 100644 --- a/backend/service/webhook.go +++ b/backend/service/webhook.go @@ -280,6 +280,13 @@ func (w *Webhook) DeleteByID( w.Logger.Errorw("failed to remove web hook from campaigns", "error", err) return err } + // remove junction table rows for this webhook so no campaign retains + // a dangling reference in the new many-to-many format + err = w.CampaignRepository.RemoveWebhookFromJunctionByWebhookID(ctx, id) + if err != nil { + w.Logger.Errorw("failed to remove webhook from campaign_webhooks junction", "error", err) + return err + } // delete err = w.WebhookRepository.DeleteByID(ctx, id) if err != nil { diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index 99ff2d4..2d8b605 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -517,9 +517,7 @@ export class API { * @param {string[]} campaign.allowDenyIDs []uuid * @param {string} campaign.denyPageID uuid * @param {string} campaign.evasionPageID uuid - * @param {string} campaign.webhookID uuid - * @param {string} [campaign.webhookIncludeData] - * @param {number} [campaign.webhookEvents] + * @param {Array} [campaign.webhooks] array of webhook configs * @param {Array} [campaign.constraintWeekDays] * @param {string} [campaign.constraintStartTime] * @param {string} [campaign.constraintEndTime] @@ -546,9 +544,7 @@ export class API { allowDenyIDs, denyPageID, evasionPageID, - webhookID, - webhookIncludeData, - webhookEvents, + webhooks, constraintWeekDays, constraintStartTime, constraintEndTime, @@ -574,9 +570,7 @@ export class API { allowDenyIDs, denyPageID, evasionPageID, - webhookID, - webhookIncludeData, - webhookEvents, + webhooks, constraintWeekDays, constraintStartTime, constraintEndTime, @@ -605,9 +599,7 @@ export class API { * @param {string[]} campaign.allowDenyIDs []uuid * @param {string} campaign.denyPageID uuid * @param {string} campaign.evasionPageID uuid - * @param {string} campaign.webhookID uuid - * @param {string} [campaign.webhookIncludeData] - * @param {number} [campaign.webhookEvents] + * @param {Array} [campaign.webhooks] array of webhook configs * @param {Array} [campaign.constraintWeekDays] * @param {string} [campaign.constraintStartTime] * @param {string} [campaign.constraintEndTime] @@ -634,9 +626,7 @@ export class API { allowDenyIDs, denyPageID, evasionPageID, - webhookID, - webhookIncludeData, - webhookEvents, + webhooks, constraintWeekDays, constraintStartTime, constraintEndTime, @@ -661,9 +651,7 @@ export class API { allowDenyIDs, denyPageID, evasionPageID, - webhookID, - webhookIncludeData, - webhookEvents, + webhooks, constraintWeekDays, constraintStartTime, constraintEndTime, diff --git a/frontend/src/routes/campaign/+page.svelte b/frontend/src/routes/campaign/+page.svelte index 7749647..15fb495 100644 --- a/frontend/src/routes/campaign/+page.svelte +++ b/frontend/src/routes/campaign/+page.svelte @@ -53,6 +53,7 @@ import AutoRefresh from '$lib/components/AutoRefresh.svelte'; import CheckboxField from '$lib/components/CheckboxField.svelte'; import ConditionalDisplay from '$lib/components/ConditionalDisplay.svelte'; + import ToolTip from '$lib/components/ToolTip.svelte'; let currentStep = 1; @@ -200,9 +201,11 @@ const WEBHOOK_EVENT_ALL_BITS = 1023; // 2^10 - 1, all 10 events selected // convert array of event names to bitwise int + // if all events are selected, return 0 to preserve the "all events" semantic const webhookEventsToBinary = (events) => { if (!events?.length) return 0; // 0 means all events - return events.reduce((binary, event) => binary | (webhookEventToBit[event] || 0), 0); + const binary = events.reduce((acc, event) => acc | (webhookEventToBit[event] || 0), 0); + return binary === WEBHOOK_EVENT_ALL_BITS ? 0 : binary; }; // convert bitwise int to array of event names (internal values, not display names) @@ -384,9 +387,7 @@ isTest: false, obfuscate: false, selectedCount: 0, - webhookValue: null, - webhookIncludeData: 'full', - webhookEvents: [...webhookEventOptions], // all events selected by default + webhooks: [], // array of {id, includeData, events} jitterMin: 0, jitterMax: 0 }; @@ -770,9 +771,13 @@ constraintWeekDays: weekDaysAvailableToBinary(formValues.constraintWeekDays), constraintStartTime: contraintStartTimeUTC, constraintEndTime: contraintEndTimeUTC, - webhookID: webhookMap.byValueOrNull(formValues.webhookValue), - webhookIncludeData: formValues.webhookIncludeData, - webhookEvents: webhookEventsToBinary(formValues.webhookEvents), + webhooks: formValues.webhooks + .filter((wh) => wh.id !== null) + .map((wh) => ({ + webhookID: wh.id, + webhookIncludeData: wh.includeData, + webhookEvents: webhookEventsToBinary(wh.events) + })), jitterMin: formValues.jitterMin !== 0 ? formValues.jitterMin : null, jitterMax: formValues.jitterMax !== 0 ? formValues.jitterMax : null }); @@ -837,9 +842,13 @@ allowDenyIDs: allowDenyIDs, denyPageID: denyPageMap.byValueOrNull(formValues.denyPageValue), evasionPageID: denyPageMap.byValueOrNull(formValues.evasionPageValue), - webhookID: webhookMap.byValueOrNull(formValues.webhookValue), - webhookIncludeData: formValues.webhookIncludeData, - webhookEvents: webhookEventsToBinary(formValues.webhookEvents), + webhooks: formValues.webhooks + .filter((wh) => wh.id !== null) + .map((wh) => ({ + webhookID: wh.id, + webhookIncludeData: wh.includeData, + webhookEvents: webhookEventsToBinary(wh.events) + })), jitterMin: formValues.jitterMin !== 0 ? formValues.jitterMin : null, jitterMax: formValues.jitterMax !== 0 ? formValues.jitterMax : null }); @@ -943,7 +952,7 @@ const resetFormValues = () => { formValues = { - name: '', + name: null, sendStartAt: null, sendEndAt: null, scheduledStartAt: null, @@ -951,8 +960,8 @@ closeAt: null, anonymizeAt: null, template: null, - sortField: defaultSendField, - sortOrder: defaultSendOrder, + sortField: null, + sortOrder: null, recipientGroups: [], allowDeny: [], denyPageValue: null, @@ -966,9 +975,7 @@ isTest: false, obfuscate: false, selectedCount: 0, - webhookValue: null, - webhookIncludeData: 'full', - webhookEvents: [...webhookEventOptions], // all events selected by default + webhooks: [], jitterMin: 0, jitterMax: 0 }; @@ -1076,9 +1083,28 @@ isTest: campaign.isTest, obfuscate: campaign.obfuscate || false, template: templateMap.byKey(campaign.templateID), - webhookValue: webhookMap.byKey(campaign.webhookID), - webhookIncludeData: campaign.webhookIncludeData || 'full', - webhookEvents: webhookEventsFromBinary(campaign.webhookEvents || 0) + webhooks: (() => { + // handle new webhooks array format + if (campaign.webhooks && campaign.webhooks.length > 0) { + return campaign.webhooks.map((wh) => ({ + id: wh.webhookID, + includeData: wh.webhookIncludeData ?? 'full', + events: webhookEventsFromBinary(wh.webhookEvents ?? 0) + })); + } + // handle old single webhook format (backward compatibility) + if (campaign.webhookID) { + return [ + { + id: campaign.webhookID, + includeData: campaign.webhookIncludeData ?? 'full', + events: webhookEventsFromBinary(campaign.webhookEvents ?? 0) + } + ]; + } + // no webhooks + return []; + })() }; if (copyMode) { @@ -1122,6 +1148,7 @@ showAdvancedOptionsStep4 = !!( campaign.webhookID || + campaign.webhooks?.length || campaign.denyPage || campaign.evasionPage || campaign.allowDeny?.length || @@ -1250,6 +1277,37 @@ element.reportValidity(); }; + // webhook helper functions + const addWebhook = () => { + formValues.webhooks = [ + ...formValues.webhooks, + { + id: null, + includeData: 'full', + events: [...webhookEventOptions] // all events by default + } + ]; + }; + + const removeWebhook = (index) => { + formValues.webhooks = formValues.webhooks.filter((_, i) => i !== index); + }; + + const toggleWebhookEvent = (webhookIndex, eventValue) => { + const webhook = formValues.webhooks[webhookIndex]; + const isSelected = webhook.events.includes(eventValue); + + if (isSelected) { + // prevent unselecting the last item + if (webhook.events.length > 1) { + webhook.events = webhook.events.filter((e) => e !== eventValue); + } + } else { + webhook.events = [...webhook.events, eventValue]; + } + formValues.webhooks = [...formValues.webhooks]; // trigger reactivity + }; + // check if user is in the correct context for campaign actions const isContextMismatch = (campaign) => { const context = appStateService.getContext(); @@ -1884,69 +1942,105 @@ {/if} {#if showAdvancedOptionsStep4} -
- Webhook -
- {#if formValues.webhookValue} -
- -
-
-