Files
phishingclub/backend/database/campaignRecipient.go
T
2026-07-27 17:57:53 +02:00

116 lines
4.4 KiB
Go

package database
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
const (
CAMPAIGN_RECIPIENT_TABLE_NAME = "campaign_recipients"
)
// CampaigReciever is gorm data model
// this model/table is primarily used to keep track of who and when should recieve a campaign
type CampaignRecipient struct {
ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
CreatedAt *time.Time `gorm:"not null;index;"`
UpdatedAt *time.Time `gorm:"not null;index;"`
Campaign *Campaign
CampaignID *uuid.UUID `gorm:"not null;type:uuid;uniqueIndex:idx_campaign_recipients_campaign_id_recipient_id;"`
// CancelledAt *time.Time `gorm:"index;"`
CancelledAt *time.Time `gorm:"index;"`
// when it should be send
SendAt *time.Time `gorm:"index;"`
// when it was last attempted send
LastAttemptAt *time.Time `gorm:"index;"`
// when it was sent
SentAt *time.Time `gorm:"index;"`
// self-managed
SelfManaged bool `gorm:"not null;default:false;"`
// AnonymizedID is set when the recipient has been anonymized
AnonymizedID *uuid.UUID `gorm:"type:uuid;"`
Recipient *Recipient
// A null recipientID means that the data has been anonymized
RecipientID *uuid.UUID `gorm:"type:uuid;index;uniqueIndex:idx_campaign_recipients_campaign_id_recipient_id;"`
// NotableEventID is the most notable event for this recipient
NotableEvent *Event `gorm:"foreignKey:NotableEventID;references:ID"`
NotableEventID *uuid.UUID `gorm:"type:uuid;index"`
// LureCode is the short identifier that appears in the lure URL instead of
// the campaign recipient UUID, for example https://example.com/4H7K9QM2XR3T.
// It is stored exactly as written and matched exactly, which is what lets an
// operator pick a code such as Special-42 and get that link verbatim.
//
// Releasing a code sets this back to null. Nulling rather than flagging is
// what keeps a reclaimed code from still showing against its former owner,
// and it lets the unique index below need nothing but IS NOT NULL.
LureCode *string `gorm:"type:varchar(64)"`
// LureCodeCustom marks a code the operator set by hand. A custom code cannot
// be told apart from a generated one by shape, so the distinction is stored.
LureCodeCustom bool `gorm:"not null;default:false"`
}
func (CampaignRecipient) TableName() string {
return CAMPAIGN_RECIPIENT_TABLE_NAME
}
// Migrate creates the partial unique index backing lure code allocation.
//
// The predicate has to exclude rows carrying no code, otherwise every recipient
// in a query mode campaign enters the index under the same value and the second
// insert fails. Because releasing a code nulls it, that single predicate also
// covers reuse: a released code leaves the index and is free to claim again.
//
// This is raw SQL rather than a gorm index tag because only the sqlite driver
// emits the WHERE clause. The generic migrator drops it, which would silently
// produce a full unique index and break reuse.
func (CampaignRecipient) Migrate(db *gorm.DB) error {
// lure_code is the only code column this table carries. AutoMigrate adds
// columns but never drops them, so any other lure column and its indexes are
// removed here. errors are ignored because on a fresh install there is
// nothing to drop.
db.Exec(`DROP INDEX IF EXISTS idx_campaign_recipients_lure_code_lookup`)
db.Exec(`DROP INDEX IF EXISTS idx_campaign_recipients_lure_code_released_at`)
db.Exec(`DROP INDEX IF EXISTS idx_campaign_recipients_lure_code_folded`)
db.Exec(`ALTER TABLE campaign_recipients DROP COLUMN lure_code_lookup`)
db.Exec(`ALTER TABLE campaign_recipients DROP COLUMN lure_code_released_at`)
db.Exec(`ALTER TABLE campaign_recipients DROP COLUMN lure_code_folded`)
createIndex := func() error {
return db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_campaign_recipients_lure_code
ON campaign_recipients (lure_code)
WHERE lure_code IS NOT NULL`).Error
}
err := createIndex()
if err == nil {
return nil
}
// the index fails when two rows already hold the same code. keep the first
// row for each code and release the rest, then build the index.
//
// this runs only after a failure, because the scan and grouping it needs
// are too costly to pay on every startup.
if dedupErr := db.Exec(`UPDATE campaign_recipients SET lure_code = NULL
WHERE lure_code IS NOT NULL
AND rowid NOT IN (
SELECT MIN(rowid) FROM campaign_recipients
WHERE lure_code IS NOT NULL
GROUP BY lure_code
)`).Error; dedupErr != nil {
return dedupErr
}
return createIndex()
}