mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-07-30 15:38:49 +02:00
116 lines
4.4 KiB
Go
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()
|
|
}
|