Files
phishingclub/backend/model/campaign.go
Ronni Skansing 8bf457c592 Added webhook data level and events filtering
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
2025-12-16 22:15:57 +01:00

551 lines
19 KiB
Go

package model
import (
"fmt"
"time"
"github.com/go-errors/errors"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/utils"
"github.com/phishingclub/phishingclub/validate"
"github.com/phishingclub/phishingclub/vo"
)
// Campaign is a phishing Campaign entity
type Campaign struct {
ID nullable.Nullable[uuid.UUID] `json:"id"`
CreatedAt *time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
CloseAt nullable.Nullable[time.Time] `json:"closeAt"`
ClosedAt nullable.Nullable[time.Time] `json:"closedAt"`
AnonymizeAt nullable.Nullable[time.Time] `json:"anonymizeAt"`
AnonymizedAt nullable.Nullable[time.Time] `json:"anonymizedAt"`
SortField nullable.Nullable[vo.CampaignSortField] `json:"sortField"`
SortOrder nullable.Nullable[vo.CampaignSendingOrder] `json:"sortOrder"`
SendStartAt nullable.Nullable[time.Time] `json:"sendStartAt"`
SendEndAt nullable.Nullable[time.Time] `json:"sendEndAt"`
ConstraintWeekDays nullable.Nullable[vo.CampaignWeekDays] `json:"constraintWeekDays"`
ConstraintStartTime nullable.Nullable[vo.CampaignTimeConstraint] `json:"constraintStartTime"`
ConstraintEndTime nullable.Nullable[vo.CampaignTimeConstraint] `json:"constraintEndTime"`
// jitter is used only during scheduling, not persisted to database
JitterMin nullable.Nullable[int] `json:"jitterMin,omitempty"`
JitterMax nullable.Nullable[int] `json:"jitterMax,omitempty"`
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"`
// must not be set by a user
NotableEventID nullable.Nullable[uuid.UUID] `json:"notableEventID"`
NotableEventName string `json:"notableEventName"`
}
// Validate checks if the campaign has a valid state
const (
WebhookDataLevelNone = "none"
WebhookDataLevelBasic = "basic"
WebhookDataLevelFull = "full"
)
// WebhookEventsToBinary converts array of event names to bitwise int
func WebhookEventsToBinary(events []string) int {
if len(events) == 0 {
return 0 // 0 means all events
}
binary := 0
for _, event := range events {
if bit, ok := data.WebhookEventToBit[event]; ok {
binary |= bit
}
}
return binary
}
// WebhookEventsFromBinary converts bitwise int to array of event names
func WebhookEventsFromBinary(binary int) []string {
if binary == 0 {
return []string{} // empty array means all events
}
events := []string{}
for eventName, bit := range data.WebhookEventToBit {
if binary&bit != 0 {
events = append(events, eventName)
}
}
return events
}
// IsWebhookEventEnabled checks if an event should trigger webhook
func IsWebhookEventEnabled(webhookEvents int, eventName string) bool {
// 0 means all events enabled (backward compatible)
if webhookEvents == 0 {
return true
}
// check if the event bit is set
if bit, ok := data.WebhookEventToBit[eventName]; ok {
return webhookEvents&bit != 0
}
return false
}
func (c *Campaign) Validate() error {
if err := validate.NullableFieldRequired("name", c.Name); err != nil {
return err
}
if err := validate.NullableFieldRequired("sortField", c.SortField); err != nil {
return err
}
if err := validate.NullableFieldRequired("sortOrder", c.SortOrder); err != nil {
return err
}
// if a start or end is set, then end must be equal or after the start
if c.SendStartAt.IsSpecified() && !c.SendStartAt.IsNull() || (c.SendEndAt.IsSpecified() && !c.SendEndAt.IsNull()) {
if err := validate.NullableFieldRequired("sendStartAt", c.SendStartAt); err != nil {
return err
}
if err := validate.NullableFieldRequired("sendEndAt", c.SendEndAt); err != nil {
return err
}
if c.SendEndAt.MustGet().Before(c.SendStartAt.MustGet()) {
return validate.WrapErrorWithField(errors.New("send end time must be after start time"), "sendEndAt")
}
}
if err := validate.NullableFieldRequired("templateID", c.TemplateID); err != nil {
return err
}
if err := validate.NullableFieldRequired("RecipientGroupIDs", c.RecipientGroupIDs); err != nil {
return err
}
if len(c.RecipientGroupIDs.MustGet()) == 0 {
return validate.WrapErrorWithField(errors.New("must have at least one recipient group"), "RecipientGroupIDs")
}
if err := c.ValidateEvasionAndDenyPages(); err != nil {
return err
}
// if ConstraintWeekDays or ConstraintStartTime or ConstraintEndTime is set, then this is a 'scheduled type'
// this requires that all fields are set and that end time is equal or after start time
if (c.ConstraintWeekDays.IsSpecified() && !c.ConstraintWeekDays.IsNull()) ||
(c.ConstraintStartTime.IsSpecified() && !c.ConstraintStartTime.IsNull()) ||
(c.ConstraintEndTime.IsSpecified() && !c.ConstraintEndTime.IsNull()) {
// check required fields are set
if err := c.ValidateSendTimesSet(); err != nil {
return err
}
if err := validate.NullableFieldRequired("ConstraintWeekDays", c.ConstraintWeekDays); err != nil {
return err
}
if c.ConstraintWeekDays.MustGet().Count() == 0 {
return validate.WrapErrorWithField(errors.New("must have at least one day selected"), "ConstraintWeekDays")
}
if err := validate.NullableFieldRequired("ConstraintStartTime", c.ConstraintStartTime); err != nil {
return err
}
if err := validate.NullableFieldRequired("ConstraintEndTime", c.ConstraintEndTime); err != nil {
return err
}
// check that times and days are valid
constraintStartTime := c.ConstraintStartTime.MustGet()
constraintEndTime := c.ConstraintEndTime.MustGet()
if constraintStartTime.IsAfter(constraintEndTime) {
return validate.WrapErrorWithField(errors.New("constraint end time must be after start time"), "ConstraintEndTime")
}
if constraintStartTime.IsEqual(constraintEndTime) {
return validate.WrapErrorWithField(errors.New("constraint end time must be after start time"), "ConstraintEndTime")
}
startAt := c.SendStartAt.MustGet()
endAt := c.SendEndAt.MustGet()
// check that selected weekdays are within the start and end date
isWithin := c.ConstraintWeekDays.MustGet().IsWithin(&startAt, &endAt)
if !isWithin {
return validate.WrapErrorWithField(
fmt.Errorf(
"constraint week days must be within the start (%s) and end date (%s)",
startAt.Format("2006-01-02"),
endAt.Format("2006-01-02"),
),
"ConstraintWeekDays",
)
}
}
// ensure closeAt and anonymize is correctly set after other dates if set
if c.CloseAt.IsSpecified() && !c.CloseAt.IsNull() {
closeAt := c.CloseAt.MustGet()
if v, err := c.SendEndAt.Get(); err == nil {
if closeAt.Before(v) {
return validate.WrapErrorWithField(errors.New("close at must be after end date"), "CloseAt")
}
}
}
if c.AnonymizeAt.IsSpecified() && !c.AnonymizeAt.IsNull() {
anonymizeAt := c.AnonymizeAt.MustGet()
if v, err := c.CloseAt.Get(); err == nil {
if anonymizeAt.Before(v) {
return validate.WrapErrorWithField(errors.New("anonymize at must be after close date"), "AnonymizeAt")
}
}
if v, err := c.SendEndAt.Get(); err != nil {
if anonymizeAt.Before(v) {
return validate.WrapErrorWithField(errors.New("anonymize at must be after end date"), "AnonymizeAt")
}
}
if v, err := c.SendStartAt.Get(); err == nil {
if anonymizeAt.Before(v) {
return validate.WrapErrorWithField(errors.New("anonymize at must be after start date"), "AnonymizeAt")
}
}
}
// validate jitter values if specified
// negative values are allowed for asymmetric jitter (e.g., -10 to 20 means send 10 min early to 20 min late)
jitterMinSpecified := c.JitterMin.IsSpecified() && !c.JitterMin.IsNull()
jitterMaxSpecified := c.JitterMax.IsSpecified() && !c.JitterMax.IsNull()
// both must be specified together or both unspecified
if jitterMinSpecified != jitterMaxSpecified {
return validate.WrapErrorWithField(errors.New("jitter min and max must both be specified or both be unspecified"), "jitter")
}
if jitterMinSpecified && jitterMaxSpecified {
jitterMin := c.JitterMin.MustGet()
jitterMax := c.JitterMax.MustGet()
if jitterMax < jitterMin {
return validate.WrapErrorWithField(errors.New("jitter max must be greater than or equal to jitter min"), "jitterMax")
}
// validate jitter doesn't exceed campaign duration
if c.SendStartAt.IsSpecified() && !c.SendStartAt.IsNull() && c.SendEndAt.IsSpecified() && !c.SendEndAt.IsNull() {
startAt := c.SendStartAt.MustGet()
endAt := c.SendEndAt.MustGet()
campaignDuration := endAt.Sub(startAt)
// check if max jitter (positive) could push beyond end time
maxJitterDuration := time.Duration(jitterMax) * time.Minute
if maxJitterDuration > campaignDuration {
return validate.WrapErrorWithField(
errors.New("jitter max cannot exceed campaign duration"),
"jitterMax",
)
}
// check if min jitter (negative) could push before start time
minJitterDuration := time.Duration(jitterMin) * time.Minute
if minJitterDuration < -campaignDuration {
return validate.WrapErrorWithField(
errors.New("jitter min cannot exceed campaign duration"),
"jitterMin",
)
}
}
}
// must not be set from api consumers
if c.NotableEventID.IsSpecified() && !c.NotableEventID.IsNull() {
c.NotableEventID.SetNull()
}
// validate webhookIncludeData enum
if c.WebhookIncludeData.IsSpecified() {
if level, err := c.WebhookIncludeData.Get(); err == nil {
if level != WebhookDataLevelNone &&
level != WebhookDataLevelBasic &&
level != WebhookDataLevelFull {
return validate.WrapErrorWithField(
errors.New("must be 'none', 'basic', or 'full'"),
"webhookIncludeData",
)
}
}
}
// validate webhookEvents is non-negative
if c.WebhookEvents.IsSpecified() && !c.WebhookEvents.IsNull() {
if events, err := c.WebhookEvents.Get(); err == nil {
if events < 0 {
return validate.WrapErrorWithField(
errors.New("must be non-negative"),
"webhookEvents",
)
}
}
}
return nil
}
// ValidateSendTimesSet checks that the send start and end times are set
func (c *Campaign) ValidateSendTimesSet() error {
if err := validate.NullableFieldRequired("sendStartAt", c.SendStartAt); err != nil {
return err
}
if err := validate.NullableFieldRequired("sendEndAt", c.SendEndAt); err != nil {
return err
}
return nil
}
// ValidateScheduledType checks times related to a scheduled type campaign
func (c *Campaign) ValidateScheduledTimes() error {
if err := c.ValidateSendTimesSet(); err != nil {
return err
}
if err := validate.NullableFieldRequired("ConstraintWeekDays", c.ConstraintWeekDays); err != nil {
return err
}
if err := validate.NullableFieldRequired("ConstraintStartTime", c.ConstraintStartTime); err != nil {
return err
}
if err := validate.NullableFieldRequired("ConstraintEndTime", c.ConstraintEndTime); err != nil {
return err
}
return nil
}
// ValidateNoSendTimesSet checks that the send start and end times are not set
func (c *Campaign) ValidateNoSendTimesSet() error {
if c.SendStartAt.IsSpecified() && !c.SendStartAt.IsNull() {
return validate.WrapErrorWithField(errors.New("send start time must not be set"), "sendStartAt")
}
if c.SendEndAt.IsSpecified() && !c.SendEndAt.IsNull() {
return validate.WrapErrorWithField(errors.New("send end time must not be set"), "sendEndAt")
}
return nil
}
// ValidateEvasionAndDenyPages checks evasion page and deny page requirements
func (c *Campaign) ValidateEvasionAndDenyPages() error {
// if evasion page is set, deny page must also be set
if c.EvasionPageID.IsSpecified() && !c.EvasionPageID.IsNull() {
if !c.DenyPageID.IsSpecified() || c.DenyPageID.IsNull() {
return validate.WrapErrorWithField(errors.New("evasion page requires a deny page to be set"), "evasionPage")
}
}
// if allow/deny IDs are set (IP filtering), deny page must be set
if c.AllowDenyIDs.IsSpecified() && !c.AllowDenyIDs.IsNull() && len(c.AllowDenyIDs.MustGet()) > 0 {
if !c.DenyPageID.IsSpecified() || c.DenyPageID.IsNull() {
return validate.WrapErrorWithField(errors.New("IP filtering requires a deny page to be set"), "denyPage")
}
}
return nil
}
// ToDBMap converts the fields that can be stored or updated to a map
// if the value is nullable and not set, it is not included
// if the value is nullable and set, it is included, if it is null, it is set to nil
func (c *Campaign) ToDBMap() map[string]any {
m := map[string]any{}
if c.Name.IsSpecified() {
m["name"] = nil
if v, err := c.Name.Get(); err == nil {
m["name"] = v.String()
}
}
if c.SortField.IsSpecified() {
m["sort_field"] = nil
if v, err := c.SortField.Get(); err == nil {
m["sort_field"] = v.String()
}
}
if c.SortOrder.IsSpecified() {
m["sort_order"] = nil
if v, err := c.SortOrder.Get(); err == nil {
m["sort_order"] = v.String()
}
}
if c.SendStartAt.IsSpecified() {
m["send_start_at"] = nil
if v, err := c.SendStartAt.Get(); err == nil {
m["send_start_at"] = utils.RFC3339UTC(v)
}
}
if c.ConstraintWeekDays.IsSpecified() {
m["constraint_week_days"] = nil
if v, err := c.ConstraintWeekDays.Get(); err == nil {
m["constraint_week_days"] = v.Int()
}
}
if c.ConstraintStartTime.IsSpecified() {
m["constraint_start_time"] = nil
if v, err := c.ConstraintStartTime.Get(); err == nil {
m["constraint_start_time"] = v.String()
}
}
if c.ConstraintEndTime.IsSpecified() {
m["constraint_end_time"] = nil
if v, err := c.ConstraintEndTime.Get(); err == nil {
m["constraint_end_time"] = v.String()
}
}
if c.SendEndAt.IsSpecified() {
m["send_end_at"] = nil
if v, err := c.SendEndAt.Get(); err == nil {
m["send_end_at"] = utils.RFC3339UTC(v)
}
}
if c.CloseAt.IsSpecified() {
m["close_at"] = nil
if v, err := c.CloseAt.Get(); err == nil {
m["close_at"] = utils.RFC3339UTC(v)
}
}
if c.ClosedAt.IsSpecified() {
m["closed_at"] = nil
if v, err := c.ClosedAt.Get(); err == nil {
m["closed_at"] = utils.RFC3339UTC(v)
}
}
if c.AnonymizeAt.IsSpecified() {
m["anonymize_at"] = nil
if v, err := c.AnonymizeAt.Get(); err == nil {
m["anonymize_at"] = utils.RFC3339UTC(v)
}
}
if c.SaveSubmittedData.IsSpecified() {
m["save_submitted_data"] = false
if v, err := c.SaveSubmittedData.Get(); err == nil {
m["save_submitted_data"] = v
}
}
if c.SaveBrowserMetadata.IsSpecified() {
m["save_browser_metadata"] = false
if v, err := c.SaveBrowserMetadata.Get(); err == nil {
m["save_browser_metadata"] = v
}
}
if c.IsTest.IsSpecified() {
m["is_test"] = false
if v, err := c.IsTest.Get(); err == nil {
m["is_test"] = v
}
}
if c.IsAnonymous.IsSpecified() {
m["is_anonymous"] = false
if v, err := c.IsAnonymous.Get(); err == nil {
m["is_anonymous"] = v
}
}
if c.Obfuscate.IsSpecified() {
m["obfuscate"] = false
if v, err := c.Obfuscate.Get(); err == nil {
m["obfuscate"] = v
}
}
if c.WebhookIncludeData.IsSpecified() {
m["webhook_include_data"] = WebhookDataLevelFull
if v, err := c.WebhookIncludeData.Get(); err == nil {
m["webhook_include_data"] = v
}
}
if c.WebhookEvents.IsSpecified() {
m["webhook_events"] = 0
if v, err := c.WebhookEvents.Get(); err == nil {
m["webhook_events"] = v
}
}
if c.TemplateID.IsSpecified() {
m["campaign_template_id"] = nil
if v, err := c.TemplateID.Get(); err == nil {
m["campaign_template_id"] = v.String()
}
}
if c.CompanyID.IsSpecified() {
if c.CompanyID.IsNull() {
m["company_id"] = nil
} else {
m["company_id"] = c.CompanyID.MustGet()
}
}
if c.DenyPageID.IsSpecified() {
m["deny_page_id"] = nil
if v, err := c.DenyPageID.Get(); err == nil {
m["deny_page_id"] = v.String()
}
}
if c.EvasionPageID.IsSpecified() {
m["evasion_page_id"] = nil
if v, err := c.EvasionPageID.Get(); err == nil {
m["evasion_page_id"] = v.String()
}
}
if c.WebhookID.IsSpecified() {
m["webhook_id"] = nil
if v, err := c.WebhookID.Get(); err == nil {
m["webhook_id"] = v.String()
}
}
if v, err := c.NotableEventID.Get(); err == nil {
m["notable_event_id"] = v.String()
}
return m
}
// Close sets the close at timestamp to now
// dont confuse with method Closed
func (c *Campaign) Close() error {
if c.ClosedAt.IsSpecified() && !c.ClosedAt.IsNull() {
return errs.ErrCampaignAlreadyClosed
}
if c.CloseAt.IsSpecified() && !c.CloseAt.IsNull() {
return errs.ErrCampaignAlreadySetToClose
}
c.CloseAt.Set(time.Now().UTC())
return nil
}
// Closed sets the closed at timestamp to now
// dont confuse with method Close
func (c *Campaign) Closed() error {
if c.ClosedAt.IsSpecified() && !c.ClosedAt.IsNull() {
return errs.ErrCampaignAlreadyClosed
}
c.ClosedAt.Set(time.Now().UTC())
return nil
}
// Anonymize sets the anonymized at timestamp
func (c *Campaign) Anonymize() error {
if c.AnonymizedAt.IsSpecified() && !c.AnonymizedAt.IsNull() {
return errs.ErrCampaignAlreadyAnonymized
}
c.AnonymizedAt.Set(time.Now().UTC())
return nil
}
// IsActive returns true if the campaign is active
func (c *Campaign) IsActive() bool {
now := time.Now()
if c.ClosedAt.IsSpecified() && !c.ClosedAt.IsNull() && c.ClosedAt.MustGet().Before(now) {
return false
}
return true
}
// IsSelfManaged returns true if the campaign is self managed
func (c *Campaign) IsSelfManaged() bool {
return c.SendStartAt.IsSpecified() && c.SendStartAt.IsNull() && c.SendEndAt.IsSpecified() && c.SendEndAt.IsNull()
}