mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-08-17 16:07:18 +02:00
162 lines
5.7 KiB
Go
162 lines
5.7 KiB
Go
package database
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"strings"
|
|
"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 replaces the campaign recipient UUID in a lure URL, for example
|
|
// https://example.com/4H7K9QM2XR3T. Stored and matched byte for byte, so an
|
|
// operator picking Special-42 gets that link verbatim.
|
|
//
|
|
// Releasing sets this to null rather than flagging it, which keeps a
|
|
// reclaimed code from showing against its former owner and 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, which cannot be told
|
|
// apart from a generated one by shape.
|
|
LureCodeCustom bool `gorm:"not null;default:false"`
|
|
}
|
|
|
|
func (CampaignRecipient) TableName() string {
|
|
return CAMPAIGN_RECIPIENT_TABLE_NAME
|
|
}
|
|
|
|
// lureCodeIndexName is the partial unique index backing lure code allocation.
|
|
const lureCodeIndexName = "idx_campaign_recipients_lure_code"
|
|
|
|
// lureCodeCustomIndexName is the index answering whether a campaign carries any
|
|
// operator set code.
|
|
const lureCodeCustomIndexName = "idx_campaign_recipients_lure_code_custom"
|
|
|
|
// Migrate creates the indexes the lure code feature reads.
|
|
//
|
|
// The unique predicate must exclude rows carrying no code, or every recipient in
|
|
// a query mode campaign enters the index under the same value and the second
|
|
// insert fails. It covers reuse too, since releasing a code nulls it and the row
|
|
// leaves the index.
|
|
//
|
|
// Raw SQL rather than gorm index tags, because the generic migrator drops the
|
|
// WHERE clause and would silently build a full unique index.
|
|
func (CampaignRecipient) Migrate(db *gorm.DB) error {
|
|
createUnique := func() error {
|
|
return ensureIndex(db, lureCodeIndexName, `CREATE UNIQUE INDEX `+lureCodeIndexName+`
|
|
ON campaign_recipients (lure_code)
|
|
WHERE lure_code IS NOT NULL`)
|
|
}
|
|
if err := createUnique(); err != nil {
|
|
// creation fails when two rows already hold the same code. keep the first
|
|
// row for each and release the rest. only after a failure, because the
|
|
// scan and grouping 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
|
|
}
|
|
if err := createUnique(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// only rows with an operator set code enter the index, rather than every
|
|
// recipient ever created. sqlite uses a partial index only where the query
|
|
// repeats the predicate, so HasCustomLureCodesByCampaignID writes the same
|
|
// lure_code_custom = 1 literal. a bare column predicate would not match.
|
|
return ensureIndex(db, lureCodeCustomIndexName, `CREATE INDEX `+lureCodeCustomIndexName+`
|
|
ON campaign_recipients (campaign_id)
|
|
WHERE lure_code_custom = 1`)
|
|
}
|
|
|
|
// ensureIndex creates an index, replacing whatever carries the name when its
|
|
// definition differs.
|
|
//
|
|
// The whole definition is compared, not just existence: an index left by an
|
|
// earlier release keeps working and goes unnoticed, while a predicate that no
|
|
// longer matches the query stops the planner using it at all. The ddl must omit
|
|
// IF NOT EXISTS, because sqlite drops those words before storing the statement
|
|
// and the two would never compare equal.
|
|
func ensureIndex(db *gorm.DB, name string, ddl string) error {
|
|
// through the underlying pool rather than gorm, whose Row returns nil when it
|
|
// could not build the statement and would panic here during startup
|
|
sqlDB, err := db.DB()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var definition sql.NullString
|
|
err = sqlDB.QueryRow(
|
|
`SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?`,
|
|
name,
|
|
).Scan(&definition)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return err
|
|
}
|
|
if err == nil {
|
|
// an index sqlite created for itself carries no definition and is not ours
|
|
if !definition.Valid {
|
|
return nil
|
|
}
|
|
if normalizeSQL(definition.String) == normalizeSQL(ddl) {
|
|
return nil
|
|
}
|
|
if err := db.Exec(`DROP INDEX IF EXISTS ` + name).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return db.Exec(ddl).Error
|
|
}
|
|
|
|
// normalizeSQL collapses whitespace so a statement read back from sqlite_master
|
|
// compares equal to the indented literal it was created from.
|
|
func normalizeSQL(s string) string {
|
|
return strings.Join(strings.Fields(s), " ")
|
|
}
|