Add support for multiple webhooks per campaign

Add webhook links on campaign page

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2026-02-18 20:56:05 +01:00
parent d6ad85250f
commit 9efdb5c28d
16 changed files with 905 additions and 441 deletions
+29 -78
View File
@@ -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",
+1
View File
@@ -180,6 +180,7 @@ func (c *Campaign) GetByID(g *gin.Context) {
WithAllowDeny: true,
WithDenyPage: true,
WithEvasionPage: true,
WithWebhooks: true,
},
)
// handle responses
+7 -15
View File
@@ -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"`
}
+39
View File
@@ -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"
}
+25 -20
View File
@@ -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"`
+71
View File
@@ -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
}
+35 -76
View File
@@ -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
+154 -2
View File
@@ -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
+30
View File
@@ -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,
+22
View File
@@ -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
}
+245 -135
View File
@@ -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
+7
View File
@@ -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 {
+6 -18
View File
@@ -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,
+193 -94
View File
@@ -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}
<div class="mb-6">
<TextFieldSelect
id="webhook"
bind:value={formValues.webhookValue}
optional
options={Array.from(webhookMap.values())}>Webhook</TextFieldSelect
>
</div>
{#if formValues.webhookValue}
<div class="mb-6">
<SelectSquare
bind:value={formValues.webhookIncludeData}
options={webhookDataLevelOptions}
label="Webhook Data Level"
toolTipText="Control what campaign and recipient information is sent to the webhook endpoint"
/>
</div>
<div class="mb-6 max-w-xl">
<label class="flex flex-col">
<div class="flex items-center py-2">
<p class="font-semibold text-slate-600 dark:text-gray-400">Webhook Events</p>
<div class="ml-2 text-sm text-gray-600 dark:text-gray-400">
<span class="text-xs"
>({formValues.webhookEvents.length === webhookEventOptions.length
? 'All'
: formValues.webhookEvents.length} selected)</span
>
<div class="mb-6 pt-4">
<div class="flex flex-col">
<div class="flex items-center py-2">
<p class="font-semibold text-slate-600 dark:text-gray-400">Webhooks</p>
<ToolTip>
Configure multiple webhooks to receive campaign event notifications. Each
webhook can have its own data level and event filters.
</ToolTip>
<div
class="bg-gray-100 dark:bg-gray-800/60 ml-2 px-2 rounded-md transition-colors duration-200 h-6 flex items-center"
>
<p
class="text-slate-600 dark:text-gray-400 text-xs transition-colors duration-200"
>
optional
</p>
</div>
</div>
<div class="space-y-3 max-w-lg">
{#each formValues.webhooks as webhook, index}
<div
class="flex flex-col gap-3 p-4 bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800/50 dark:to-gray-800/30 rounded-lg border border-gray-300 dark:border-gray-600/50 shadow-sm hover:shadow-md transition-all duration-200"
>
<div class="flex gap-2 items-start">
<div class="flex-1">
<TextFieldSelect
id="webhook-{index}"
bind:value={webhook.id}
optional
options={webhookMap
.keys()
.map((k) => ({ value: k, label: webhookMap.byKey(k) }))}
>
Endpoint
</TextFieldSelect>
</div>
<div class="flex items-end pb-4">
<button
type="button"
class="p-1 hover:bg-gray-200 dark:hover:bg-gray-700/80 rounded-md transition-colors duration-200"
on:click={() => removeWebhook(index)}
title="Remove this webhook"
aria-label="Remove webhook"
>
<img class="w-4 flex-shrink-0" src="/delete2.svg" alt="" />
</button>
</div>
</div>
<div>
<SelectSquare
bind:value={webhook.includeData}
options={webhookDataLevelOptions}
label="Data Level"
/>
</div>
<div class="pt-1">
<div class="flex items-center gap-2 mb-2">
<p class="text-xs font-semibold text-gray-700 dark:text-gray-300">
Events
</p>
<span
class="px-2 py-0.5 bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 rounded-full text-xs font-medium"
>
{webhook.events.length === webhookEventOptions.length
? 'All'
: webhook.events.length} / {webhookEventOptions.length}
</span>
</div>
<div
class="flex flex-row flex-wrap gap-1.5 max-h-28 overflow-y-auto p-2 bg-white/50 dark:bg-gray-900/30 rounded border border-gray-200 dark:border-gray-700/50"
>
{#each webhookEventDisplayOptions as eventOption}
{@const isSelected = webhook.events.includes(eventOption.value)}
<button
type="button"
on:click={() => toggleWebhookEvent(index, eventOption.value)}
class="px-2.5 py-1 rounded-md text-xs font-medium transition-colors duration-200 {isSelected
? 'bg-green-50 dark:bg-green-900/30 text-green-600 dark:text-green-400 border border-green-400 dark:border-green-500 hover:bg-green-100 dark:hover:bg-green-900/40'
: 'bg-white dark:bg-gray-900/60 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-700/60 hover:border-blue-300 dark:hover:border-highlight-blue/80 hover:bg-blue-50 dark:hover:bg-highlight-blue/20'}"
>
{eventOption.label}
</button>
{/each}
</div>
</div>
</div>
</div>
<div class="flex flex-row flex-wrap gap-2 max-h-48 overflow-y-auto">
{#each webhookEventDisplayOptions as eventOption}
{@const isSelected = formValues.webhookEvents.includes(eventOption.value)}
<button
type="button"
on:click={() => {
if (isSelected) {
// prevent unselecting the last item
if (formValues.webhookEvents.length > 1) {
formValues.webhookEvents = formValues.webhookEvents.filter(
(e) => e !== eventOption.value
);
}
} else {
formValues.webhookEvents = [
...formValues.webhookEvents,
eventOption.value
];
}
}}
class="flex flex-row items-center px-3 py-2 rounded-md text-xs transition-colors duration-200 flex-shrink-0 {isSelected
? 'bg-green-50 dark:bg-green-900/30 text-green-600 dark:text-green-400 border-2 border-green-400 dark:border-green-500 hover:bg-green-100 dark:hover:bg-green-900/40'
: 'bg-white dark:bg-gray-900/60 text-gray-700 dark:text-gray-300 border-2 border-gray-200 dark:border-gray-700/60 hover:border-blue-300 dark:hover:border-highlight-blue/80 hover:bg-blue-50 dark:hover:bg-highlight-blue/20'}"
style="max-width: 280px;"
>
<span class="truncate block" style="max-width: 250px;">
{eventOption.label}
</span>
</button>
{/each}
</div>
</label>
{/each}
<button
type="button"
class="px-4 py-2 bg-gradient-to-b from-blue-500 to-indigo-400 dark:from-blue-600 dark:to-indigo-500 hover:from-blue-400 hover:to-indigo-400 dark:hover:from-blue-500 dark:hover:to-indigo-400 text-white font-semibold rounded-md transition-all duration-200"
on:click={addWebhook}
>
+ Add Webhook
</button>
</div>
</div>
{/if}
</div>
<ConditionalDisplay show="blackbox">
<div class="mb-6">
@@ -2349,20 +2443,25 @@
>
-->
{#if formValues.webhookValue}
<span class="text-grayblue-dark font-medium">Webhook:</span>
<span class="text-pc-darkblue dark:text-white">{formValues.webhookValue}</span
>
<span class="text-grayblue-dark font-medium">Data Level:</span>
<span class="text-pc-darkblue dark:text-white capitalize"
>{formValues.webhookIncludeData}</span
>
<span class="text-grayblue-dark font-medium">Webhook Events:</span>
<span class="text-pc-darkblue dark:text-white">
{formValues.webhookEvents.length === webhookEventOptions.length
? 'All Events'
: formValues.webhookEvents.length + ' selected'}
</span>
{#if formValues.webhooks.length > 0}
<span class="text-grayblue-dark font-medium">Webhooks:</span>
<div class="text-pc-darkblue dark:text-white space-y-2">
{#each formValues.webhooks as webhook, index}
<div class="border-l-2 border-blue-400 pl-3 py-1">
<div class="font-medium">
{webhookMap.byKey(webhook.id) || 'Not selected'}
</div>
<div class="text-sm text-gray-600 dark:text-gray-400">
Data Level: <span class="capitalize">{webhook.includeData}</span>
</div>
<div class="text-sm text-gray-600 dark:text-gray-400">
Events: {webhook.events.length === webhookEventOptions.length
? 'All Events'
: webhook.events.length + ' selected'}
</div>
</div>
{/each}
</div>
{/if}
{#if formValues.denyPageValue}
+33 -3
View File
@@ -73,6 +73,7 @@
allowDenyIDs: [],
webhookID: null,
webhooks: [],
// groups by name, must be mapped to IDs before sending to the server
recipientGroups: [],
events: [],
@@ -81,6 +82,8 @@
companyID: null,
company: null
};
// map of webhook id -> webhook name for display
let webhookMap = new BiMap({});
let allowedFilter = null;
let campaignRecipients = [];
let campaignRecipientsHasNextPage = false;
@@ -247,9 +250,20 @@
campaign.denyPage = t.denyPage;
campaign.evasionPage = t.evasionPage;
campaign.webhookID = t.webhookID;
campaign.webhooks = t.webhooks ?? [];
campaign.companyID = t.companyID;
campaign.company = t.company;
// load webhook names for display
try {
const allWebhooks = await fetchAllRows((options) => {
return api.webhook.getAll(options, campaign.companyID ?? null);
});
webhookMap = BiMap.FromArrayOfObjects(allWebhooks);
} catch (e) {
console.error('failed to load webhooks for display', e);
}
// if company exists but name is missing, fetch it
if (campaign.companyID && !campaign.company?.name) {
try {
@@ -1443,9 +1457,25 @@
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Webhook:</span>
<span class="text-pc-darkblue dark:text-white">
{campaign.webhookID ? 'Yes' : 'None'}
</span>
<div class="text-right">
{#if campaign.webhooks?.length}
{#each campaign.webhooks as wh, i}
<a
class="text-cta-blue dark:text-blue-400 hover:underline"
href="/webhook?edit={wh.webhookID}"
target="_blank">{webhookMap.byKey(wh.webhookID) || wh.webhookID}</a
>{#if i < campaign.webhooks.length - 1},&nbsp;{/if}
{/each}
{:else if campaign.webhookID}
<a
class="text-cta-blue dark:text-blue-400 hover:underline"
href="/webhook?edit={campaign.webhookID}"
target="_blank">{webhookMap.byKey(campaign.webhookID) || 'Yes'}</a
>
{:else}
<span class="text-pc-darkblue dark:text-white">None</span>
{/if}
</div>
</div>
<div class="flex justify-between">
+8
View File
@@ -1,4 +1,5 @@
<script>
import { page } from '$app/stores';
import { api } from '$lib/api/apiProxy.js';
import { onMount } from 'svelte';
import { newTableURLParams } from '$lib/service/tableURLParams.js';
@@ -79,6 +80,13 @@
refreshWebhooks();
tableURLParams.onChange(refreshWebhooks);
(async () => {
const editID = $page.url.searchParams.get('edit');
if (editID) {
await openEditModal(editID);
}
})();
return () => {
tableURLParams.unsubscribe();
};