diff --git a/backend/app/administration.go b/backend/app/administration.go index 5879e5e..a6dde0a 100644 --- a/backend/app/administration.go +++ b/backend/app/administration.go @@ -76,6 +76,9 @@ const ( // option ROUTE_V1_OPTION = "/api/v1/option" ROUTE_V1_OPTION_GET = "/api/v1/option/:key" + // auto-prune options + ROUTE_V1_OPTION_AUTO_PRUNE = "/api/v1/option/auto-prune" + ROUTE_V1_COMPANY_OPTION_AUTO_PRUNE = "/api/v1/company/:id/option/auto-prune" // installation ROUTE_V1_INSTALL = "/api/v1/install" ROUTE_V1_INSTALL_TEMPLATES = "/api/v1/install/templates" @@ -313,6 +316,11 @@ func setupRoutes( // options GET(ROUTE_V1_OPTION_GET, middleware.SessionHandler, controllers.Option.Get). POST(ROUTE_V1_OPTION, middleware.SessionHandler, middleware.SessionHandler, controllers.Option.Update). + // auto-prune options + GET(ROUTE_V1_OPTION_AUTO_PRUNE, middleware.SessionHandler, controllers.Option.GetAutoPrune). + POST(ROUTE_V1_OPTION_AUTO_PRUNE, middleware.SessionHandler, controllers.Option.SetAutoPrune). + GET(ROUTE_V1_COMPANY_OPTION_AUTO_PRUNE, middleware.SessionHandler, controllers.Option.GetCompanyAutoPrune). + POST(ROUTE_V1_COMPANY_OPTION_AUTO_PRUNE, middleware.SessionHandler, controllers.Option.SetCompanyAutoPrune). // domain GET(ROUTE_V1_DOMAIN, middleware.SessionHandler, controllers.Domain.GetAll). GET(ROUTE_V1_DOMAIN_SUBSET, middleware.SessionHandler, controllers.Domain.GetAllOverview). diff --git a/backend/controller/install.go b/backend/controller/install.go index 520d5c8..4a4c4bf 100644 --- a/backend/controller/install.go +++ b/backend/controller/install.go @@ -70,7 +70,7 @@ func (is *InitialSetup) HandleInitialSetup(ctx context.Context) error { if !errors.Is(err, gorm.ErrRecordNotFound) { return fmt.Errorf("%w: could not get '%s' option", err, data.OptionKeyIsInstalled) } - key := vo.NewString64Must(data.OptionKeyIsInstalled) + key := vo.NewString127Must(data.OptionKeyIsInstalled) value := vo.NewOptionalString1MBMust(data.OptionValueIsNotInstalled) isInstalledOptionWithoutID := model.Option{ Key: *key, @@ -101,7 +101,7 @@ func (is *InitialSetup) HandleInitialSetup(ctx context.Context) error { if !errors.Is(err, gorm.ErrRecordNotFound) { return fmt.Errorf("%w: could not get '%s' option", err, data.OptionKeyInstanceID) } - key := vo.NewString64Must(data.OptionKeyInstanceID) + key := vo.NewString127Must(data.OptionKeyInstanceID) instanceID := uuid.New() value := vo.NewOptionalString1MBMust(instanceID.String()) instanceIDOption = &model.Option{ @@ -318,7 +318,7 @@ func (in *Install) install(g *gin.Context, tx *gorm.DB) bool { } // update installed option to installed option := model.Option{ - Key: *vo.NewString64Must(data.OptionKeyIsInstalled), + Key: *vo.NewString127Must(data.OptionKeyIsInstalled), Value: *vo.NewOptionalString1MBMust(data.OptionValueIsInstalled), } err = in.OptionRepository.UpdateByKeyWithTransaction( diff --git a/backend/controller/log.go b/backend/controller/log.go index 5eef99a..d3b9c3a 100644 --- a/backend/controller/log.go +++ b/backend/controller/log.go @@ -126,7 +126,7 @@ func (c *Log) SetLevel(g *gin.Context) { // set db log level in database dbLevel := vo.NewOptionalString1MBMust(request.DBLevel) dbLogLevelOption := model.Option{ - Key: *vo.NewString64Must(data.OptionKeyDBLogLevel), + Key: *vo.NewString127Must(data.OptionKeyDBLogLevel), Value: *dbLevel, } err := c.persist( @@ -159,7 +159,7 @@ func (c *Log) SetLevel(g *gin.Context) { // set log level in in memory logger struct logLevel := model.Option{ - Key: *vo.NewString64Must(data.OptionKeyLogLevel), + Key: *vo.NewString127Must(data.OptionKeyLogLevel), Value: *vo.NewOptionalString1MBMust(request.Level), } err := c.persist( diff --git a/backend/controller/option.go b/backend/controller/option.go index cdee625..40834e0 100644 --- a/backend/controller/option.go +++ b/backend/controller/option.go @@ -2,6 +2,8 @@ package controller import ( "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/phishingclub/phishingclub/api" "github.com/phishingclub/phishingclub/data" "github.com/phishingclub/phishingclub/model" "github.com/phishingclub/phishingclub/service" @@ -65,3 +67,80 @@ func (c *Option) Update(g *gin.Context) { gin.H{}, ) } + +// GetAutoPrune returns the full auto prune option (global flag + all per-company entries). +func (c *Option) GetAutoPrune(g *gin.Context) { + session, _, ok := c.handleSession(g) + if !ok { + return + } + ctx := g.Request.Context() + opt, err := c.OptionService.GetAutoPruneOption(ctx, session) + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK(g, opt) +} + +// SetAutoPrune persists the full auto prune option (global flag + all per-company entries). +func (c *Option) SetAutoPrune(g *gin.Context) { + session, _, ok := c.handleSession(g) + if !ok { + return + } + var req model.AutoPruneOption + if ok := c.handleParseRequest(g, &req); !ok { + return + } + ctx := g.Request.Context() + err := c.OptionService.SetAutoPruneOption(ctx, session, &req) + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK(g, gin.H{}) +} + +// GetCompanyAutoPrune returns the per company auto prune enabled flag for the given company. +func (c *Option) GetCompanyAutoPrune(g *gin.Context) { + session, _, ok := c.handleSession(g) + if !ok { + return + } + companyID, err := uuid.Parse(g.Param("id")) + if err != nil { + c.Response.BadRequestMessage(g, api.InvalidCompanyID) + return + } + ctx := g.Request.Context() + enabled, err := c.OptionService.GetCompanyAutoPruneOption(ctx, session, &companyID) + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK(g, gin.H{"enabled": enabled}) +} + +// SetCompanyAutoPrune updates the per company auto prune enabled flag within the shared option row. +func (c *Option) SetCompanyAutoPrune(g *gin.Context) { + session, _, ok := c.handleSession(g) + if !ok { + return + } + companyID, err := uuid.Parse(g.Param("id")) + if err != nil { + c.Response.BadRequestMessage(g, api.InvalidCompanyID) + return + } + // parse only the enabled flag from the request body + var req struct { + Enabled bool `json:"enabled"` + } + if ok := c.handleParseRequest(g, &req); !ok { + return + } + ctx := g.Request.Context() + err = c.OptionService.SetCompanyAutoPruneOption(ctx, session, &companyID, req.Enabled) + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK(g, gin.H{}) +} diff --git a/backend/data/option.go b/backend/data/option.go index 8e83a46..3c637cc 100644 --- a/backend/data/option.go +++ b/backend/data/option.go @@ -30,6 +30,8 @@ const ( OptionValueDisplayModeWhitebox = "whitebox" OptionValueDisplayModeBlackbox = "blackbox" + OptionKeyAutoPruneOrphanedRecipients = "auto_prune_orphaned_recipients" + OptionKeyObfuscationTemplate = "obfuscation_template" // OptionValueObfuscationTemplateDefault is the default HTML template for obfuscation // the template receives {{.Script}} variable containing the obfuscated javascript diff --git a/backend/main.go b/backend/main.go index fc5aef6..220de17 100644 --- a/backend/main.go +++ b/backend/main.go @@ -427,6 +427,8 @@ func main() { CampaignService: services.Campaign, UpdateService: services.Update, MicrosoftDeviceCode: services.MicrosoftDeviceCode, + OptionService: services.Option, + RecipientService: services.Recipient, Logger: logger, } diff --git a/backend/model/autoPruneOption.go b/backend/model/autoPruneOption.go new file mode 100644 index 0000000..5f8a907 --- /dev/null +++ b/backend/model/autoPruneOption.go @@ -0,0 +1,100 @@ +package model + +import ( + "encoding/json" + "slices" + + "github.com/go-errors/errors" + "github.com/google/uuid" + "github.com/phishingclub/phishingclub/data" + "github.com/phishingclub/phishingclub/errs" + "github.com/phishingclub/phishingclub/validate" + "github.com/phishingclub/phishingclub/vo" +) + +// AutoPruneOption holds the auto prune orphaned recipients setting for both +// the global scope and all per company overrides. It is stored as a +// JSON value under the key data.OptionKeyAutoPruneOrphanedRecipients. +// +// example stored value: +// +// {"enabled":false,"companies":["uuid1","uuid2"]} +type AutoPruneOption struct { + // Enabled is the global (shared / no-company) auto prune flag. + Enabled bool `json:"enabled"` + // Companies is the list of company UUIDs that have opted in to auto pruning. + // a company not present in this list will not be pruned. + Companies []string `json:"companies,omitempty"` +} + +// NewAutoPruneOptionDefault returns a disabled AutoPruneOption with no company entries. +func NewAutoPruneOptionDefault() *AutoPruneOption { + return &AutoPruneOption{ + Enabled: false, + Companies: []string{}, + } +} + +// NewAutoPruneOptionFromJSON deserialises an AutoPruneOption from JSON bytes. +func NewAutoPruneOptionFromJSON(jsonData []byte) (*AutoPruneOption, error) { + opt := &AutoPruneOption{} + if err := json.Unmarshal(jsonData, opt); err != nil { + return nil, validate.WrapErrorWithField( + errs.NewValidationError(errors.New("invalid format")), + "AutoPruneOption", + ) + } + if opt.Companies == nil { + opt.Companies = []string{} + } + return opt, nil +} + +// NewAutoPruneOptionFromOption deserialises an AutoPruneOption from a generic Option model. +func NewAutoPruneOptionFromOption(option *Option) (*AutoPruneOption, error) { + if option == nil { + return nil, errors.New("option cannot be nil") + } + return NewAutoPruneOptionFromJSON([]byte(option.Value.String())) +} + +// IsCompanyEnabled returns true if the given company has explicitly opted in to auto pruning. +func (a *AutoPruneOption) IsCompanyEnabled(companyID *uuid.UUID) bool { + return slices.Contains(a.Companies, companyID.String()) +} + +// SetCompanyEnabled adds or removes the given company from the opted in list. +func (a *AutoPruneOption) SetCompanyEnabled(companyID *uuid.UUID, enabled bool) { + id := companyID.String() + if enabled { + if !slices.Contains(a.Companies, id) { + a.Companies = append(a.Companies, id) + } + return + } + a.Companies = slices.DeleteFunc(a.Companies, func(s string) bool { + return s == id + }) +} + +// ToJSON serialises the AutoPruneOption to JSON bytes. +func (a *AutoPruneOption) ToJSON() ([]byte, error) { + return json.Marshal(a) +} + +// ToOption converts the AutoPruneOption into a generic Option ready to be persisted +// under data.OptionKeyAutoPruneOrphanedRecipients. +func (a *AutoPruneOption) ToOption() (*Option, error) { + j, err := a.ToJSON() + if err != nil { + return nil, errs.Wrap(err) + } + str, err := vo.NewOptionalString1MB(string(j)) + if err != nil { + return nil, errs.Wrap(err) + } + return &Option{ + Key: *vo.NewString127Must(data.OptionKeyAutoPruneOrphanedRecipients), + Value: *str, + }, nil +} diff --git a/backend/model/option.go b/backend/model/option.go index 6f9d78d..f2560cb 100644 --- a/backend/model/option.go +++ b/backend/model/option.go @@ -9,6 +9,6 @@ import ( // Option is an Option type Option struct { ID nullable.Nullable[uuid.UUID] `json:"id"` - Key vo.String64 `json:"key"` + Key vo.String127 `json:"key"` Value vo.OptionalString1MB `json:"value"` } diff --git a/backend/model/ssoOption.go b/backend/model/ssoOption.go index 89dddc2..09a57c1 100644 --- a/backend/model/ssoOption.go +++ b/backend/model/ssoOption.go @@ -74,7 +74,7 @@ func (l *SSOOption) ToOption() (*Option, error) { return nil, errs.Wrap(err) } return &Option{ - Key: *vo.NewString64Must(data.OptionKeyAdminSSOLogin), + Key: *vo.NewString127Must(data.OptionKeyAdminSSOLogin), Value: *str, }, nil } diff --git a/backend/repository/option.go b/backend/repository/option.go index 963335b..65dd0b7 100644 --- a/backend/repository/option.go +++ b/backend/repository/option.go @@ -106,7 +106,7 @@ func (o *Option) InsertWithTransaction( func ToOption(dbModel *database.Option) (*model.Option, error) { id := nullable.NewNullableWithValue(*dbModel.ID) - key, err := vo.NewString64(dbModel.Key) + key, err := vo.NewString127(dbModel.Key) if err != nil { return nil, errs.Wrap(err) } diff --git a/backend/repository/recipient.go b/backend/repository/recipient.go index ab987f2..33c38fc 100644 --- a/backend/repository/recipient.go +++ b/backend/repository/recipient.go @@ -15,6 +15,7 @@ import ( "github.com/phishingclub/phishingclub/errs" "github.com/phishingclub/phishingclub/model" "github.com/phishingclub/phishingclub/utils" + "github.com/phishingclub/phishingclub/vo" "gorm.io/gorm" ) @@ -381,26 +382,68 @@ func (r *Recipient) GetOrphaned( ) (*model.Result[model.Recipient], error) { result := model.NewEmptyResult[model.Recipient]() - // build optimized LEFT JOIN query for orphaned recipients + // build company scope filter applied to both the recipient table and the + // dynamic group subquery so they stay in the same scope. var companyFilter string + var dynamicGroupCompanyFilter string var args []interface{} + var countArgs []interface{} if companyID != nil { companyFilter = fmt.Sprintf("AND %s.company_id = ?", database.RECIPIENT_TABLE) - args = append(args, companyID) + dynamicGroupCompanyFilter = "AND rg.company_id = ?" + args = append(args, companyID, companyID) + countArgs = append(countArgs, companyID, companyID) } else { companyFilter = fmt.Sprintf("AND %s.company_id IS NULL", database.RECIPIENT_TABLE) + dynamicGroupCompanyFilter = "AND rg.company_id IS NULL" } - query := fmt.Sprintf(` - SELECT %s.* FROM %s + // A recipient is orphaned when: + // 1. it has no row in the static many-to-many join table, AND + // 2. it does not match any dynamic group filter in the same company scope. + // + // The dynamic group exclusion subquery joins recipient_groups on the + // field/value columns so the filtering happens entirely in SQL, keeping + // pagination and counts accurate. + orphanCondition := fmt.Sprintf(` LEFT JOIN %s rgr ON %s.id = rgr.recipient_id - WHERE rgr.recipient_id IS NULL %s`, - database.RECIPIENT_TABLE, - database.RECIPIENT_TABLE, + WHERE rgr.recipient_id IS NULL %s + AND %s.id NOT IN ( + SELECT DISTINCT %s.id FROM %s + INNER JOIN %s rg + ON rg.is_dynamic = true + AND rg.filter_field != '' + AND rg.filter_value != '' + %s + AND ( + (rg.filter_field = 'position' AND %s.position = rg.filter_value) OR + (rg.filter_field = 'department' AND %s.department = rg.filter_value) OR + (rg.filter_field = 'city' AND %s.city = rg.filter_value) OR + (rg.filter_field = 'country' AND %s.country = rg.filter_value) OR + (rg.filter_field = 'misc' AND %s.misc = rg.filter_value) + ) + )`, database.RECIPIENT_GROUP_RECIPIENT_TABLE, database.RECIPIENT_TABLE, companyFilter, + database.RECIPIENT_TABLE, + // subquery + database.RECIPIENT_TABLE, + database.RECIPIENT_TABLE, + database.RECIPIENT_GROUP_TABLE, + dynamicGroupCompanyFilter, + database.RECIPIENT_TABLE, + database.RECIPIENT_TABLE, + database.RECIPIENT_TABLE, + database.RECIPIENT_TABLE, + ) + + query := fmt.Sprintf( + "SELECT %s.* FROM %s %s", + database.RECIPIENT_TABLE, + database.RECIPIENT_TABLE, + orphanCondition, ) // apply query args for sorting/pagination if provided @@ -428,20 +471,17 @@ func (r *Recipient) GetOrphaned( return result, dbRes.Error } - // check for next page using raw query + // check for next page using the same orphan condition so the count + // reflects the dynamic-group exclusion too. if options.QueryArgs != nil && options.QueryArgs.Limit > 0 { - countQuery := fmt.Sprintf(` - SELECT COUNT(*) FROM %s - LEFT JOIN %s rgr ON %s.id = rgr.recipient_id - WHERE rgr.recipient_id IS NULL %s`, + countQuery := fmt.Sprintf( + "SELECT COUNT(*) FROM %s %s", database.RECIPIENT_TABLE, - database.RECIPIENT_GROUP_RECIPIENT_TABLE, - database.RECIPIENT_TABLE, - companyFilter, + orphanCondition, ) var totalCount int64 - if err := r.DB.Raw(countQuery, args...).Count(&totalCount).Error; err != nil { + if err := r.DB.Raw(countQuery, countArgs...).Count(&totalCount).Error; err != nil { return result, errs.Wrap(err) } diff --git a/backend/seed/migrate.go b/backend/seed/migrate.go index 778166b..b2fd415 100644 --- a/backend/seed/migrate.go +++ b/backend/seed/migrate.go @@ -323,6 +323,33 @@ func SeedSettings( } } } + { + // seed auto-prune orphaned recipients option as JSON (disabled by default) + id := uuid.New() + var c int64 + res := db. + Model(&database.Option{}). + Where("key = ?", data.OptionKeyAutoPruneOrphanedRecipients). + Count(&c) + + if res.Error != nil { + return errs.Wrap(res.Error) + } + if c == 0 { + defaultVal, err := model.NewAutoPruneOptionDefault().ToJSON() + if err != nil { + return errs.Wrap(err) + } + res = db.Create(&database.Option{ + ID: &id, + Key: data.OptionKeyAutoPruneOrphanedRecipients, + Value: string(defaultVal), + }) + if res.Error != nil { + return errs.Wrap(res.Error) + } + } + } { // seed proxy cookie name id := uuid.New() diff --git a/backend/seed/migrate_dev.go b/backend/seed/migrate_dev.go index 34e8623..947cdd9 100644 --- a/backend/seed/migrate_dev.go +++ b/backend/seed/migrate_dev.go @@ -104,7 +104,7 @@ func RunSeedDevelopmentData( id := uuid.New() optSeedDev := &model.Option{ ID: nullable.NewNullableWithValue(id), - Key: *vo.NewString64Must(data.OptionKeyDevelopmentSeeded), + Key: *vo.NewString127Must(data.OptionKeyDevelopmentSeeded), Value: *vo.NewOptionalString1MBMust(data.OptionValueSeeded), } _, err := repositories.Option.Insert(context.TODO(), optSeedDev) diff --git a/backend/service/option.go b/backend/service/option.go index f13f9fe..22c5ede 100644 --- a/backend/service/option.go +++ b/backend/service/option.go @@ -5,6 +5,7 @@ import ( "strconv" "github.com/go-errors/errors" + "github.com/google/uuid" "github.com/phishingclub/phishingclub/data" "github.com/phishingclub/phishingclub/errs" @@ -189,6 +190,15 @@ func (o *Option) SetOptionByKey( "display mode", ) } + case data.OptionKeyAutoPruneOrphanedRecipients: + // stored as JSON — validate by parsing + if _, err := model.NewAutoPruneOptionFromJSON([]byte(v)); err != nil { + o.Logger.Debugw("invalid auto-prune option value", "value", v) + return validate.WrapErrorWithField( + errs.NewValidationError(errors.New("invalid value")), + "value", + ) + } case data.OptionKeyObfuscationTemplate: // is allow listed default: @@ -213,6 +223,156 @@ func (o *Option) SetOptionByKey( return nil } +// GetAutoPruneOption returns the single auto-prune option row (requires global auth). +// Both the global flag and all per-company entries are embedded in the returned value. +func (o *Option) GetAutoPruneOption( + ctx context.Context, + session *model.Session, +) (*model.AutoPruneOption, error) { + ae := NewAuditEvent("Option.GetAutoPruneOption", session) + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + o.LogAuthError(err) + return nil, errs.Wrap(err) + } + if !isAuthorized { + o.AuditLogNotAuthorized(ae) + return nil, errs.ErrAuthorizationFailed + } + o.AuditLogAuthorized(ae) + return o.getAutoPruneOption(ctx) +} + +// SetAutoPruneOption persists the full auto-prune option as a single JSON row +// (requires global auth). The caller supplies the complete AutoPruneOption +// value including any per-company entries. +func (o *Option) SetAutoPruneOption( + ctx context.Context, + session *model.Session, + autoPruneOpt *model.AutoPruneOption, +) error { + ae := NewAuditEvent("Option.SetAutoPruneOption", session) + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + o.LogAuthError(err) + return errs.Wrap(err) + } + if !isAuthorized { + o.AuditLogNotAuthorized(ae) + return errs.ErrAuthorizationFailed + } + if err := o.upsertAutoPruneOption(ctx, autoPruneOpt); err != nil { + return err + } + o.AuditLogAuthorized(ae) + return nil +} + +// GetCompanyAutoPruneOption returns whether the given company has opted in to auto-pruning +// by reading the single shared auto-prune option row (requires global auth). +func (o *Option) GetCompanyAutoPruneOption( + ctx context.Context, + session *model.Session, + companyID *uuid.UUID, +) (bool, error) { + ae := NewAuditEvent("Option.GetCompanyAutoPruneOption", session) + ae.Details["companyID"] = companyID.String() + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + o.LogAuthError(err) + return false, errs.Wrap(err) + } + if !isAuthorized { + o.AuditLogNotAuthorized(ae) + return false, errs.ErrAuthorizationFailed + } + opt, err := o.getAutoPruneOption(ctx) + if err != nil { + return false, err + } + o.AuditLogAuthorized(ae) + return opt.IsCompanyEnabled(companyID), nil +} + +// SetCompanyAutoPruneOption updates the per-company enabled flag within the +// single shared auto-prune option row (requires global auth). +func (o *Option) SetCompanyAutoPruneOption( + ctx context.Context, + session *model.Session, + companyID *uuid.UUID, + enabled bool, +) error { + ae := NewAuditEvent("Option.SetCompanyAutoPruneOption", session) + ae.Details["companyID"] = companyID.String() + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + o.LogAuthError(err) + return errs.Wrap(err) + } + if !isAuthorized { + o.AuditLogNotAuthorized(ae) + return errs.ErrAuthorizationFailed + } + // read-modify-write the single row + opt, err := o.getAutoPruneOption(ctx) + if err != nil { + return err + } + opt.SetCompanyEnabled(companyID, enabled) + if err := o.upsertAutoPruneOption(ctx, opt); err != nil { + return err + } + o.AuditLogAuthorized(ae) + return nil +} + +// GetAutoPruneOptionInternal returns the full auto-prune option without any +// authorization check. intended for internal/task-runner use only. +func (o *Option) GetAutoPruneOptionInternal(ctx context.Context) (*model.AutoPruneOption, error) { + return o.getAutoPruneOption(ctx) +} + +// getAutoPruneOption reads the single auto-prune option row, returning the +// default (all-disabled) value when the row does not exist yet. +func (o *Option) getAutoPruneOption(ctx context.Context) (*model.AutoPruneOption, error) { + raw, err := o.OptionRepository.GetByKey(ctx, data.OptionKeyAutoPruneOrphanedRecipients) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return model.NewAutoPruneOptionDefault(), nil + } + o.Logger.Errorw("failed to get auto-prune option", "error", err) + return nil, errs.Wrap(err) + } + return model.NewAutoPruneOptionFromJSON([]byte(raw.Value.String())) +} + +// upsertAutoPruneOption inserts or updates the single auto-prune option row. +func (o *Option) upsertAutoPruneOption(ctx context.Context, autoPruneOpt *model.AutoPruneOption) error { + opt, err := autoPruneOpt.ToOption() + if err != nil { + return errs.Wrap(err) + } + _, getErr := o.OptionRepository.GetByKey(ctx, data.OptionKeyAutoPruneOrphanedRecipients) + if getErr != nil { + if !errors.Is(getErr, gorm.ErrRecordNotFound) { + o.Logger.Errorw("failed to check auto-prune option existence", "error", getErr) + return errs.Wrap(getErr) + } + // row does not exist yet — insert + if _, insertErr := o.OptionRepository.Insert(ctx, opt); insertErr != nil { + o.Logger.Errorw("failed to insert auto-prune option", "error", insertErr) + return errs.Wrap(insertErr) + } + return nil + } + // row exists — update + if updateErr := o.OptionRepository.UpdateByKey(ctx, opt); updateErr != nil { + o.Logger.Errorw("failed to update auto-prune option", "error", updateErr) + return errs.Wrap(updateErr) + } + return nil +} + // GetObfuscationTemplate gets the obfuscation template from options or returns default func (o *Option) GetObfuscationTemplate(ctx context.Context) (string, error) { opt, err := o.OptionRepository.GetByKey(ctx, data.OptionKeyObfuscationTemplate) diff --git a/backend/service/recipient.go b/backend/service/recipient.go index 3d83729..a5ea9f2 100644 --- a/backend/service/recipient.go +++ b/backend/service/recipient.go @@ -406,14 +406,14 @@ func (r *Recipient) GetOrphaned( r.AuditLogNotAuthorized(ae) return result, errs.ErrAuthorizationFailed } - // get orphaned recipients + // get orphaned recipients — dynamic group exclusion is handled in SQL result, err = r.RecipientRepository.GetOrphaned( ctx, companyID, options, ) if err != nil { - r.Logger.Errorw("failed to get orphaned recipients - failed to get orphaned recipients", "error", err) + r.Logger.Errorw("failed to get orphaned recipients", "error", err) return result, errs.Wrap(err) } // no audit on read @@ -438,7 +438,7 @@ func (r *Recipient) DeleteAllOrphaned( return 0, errs.ErrAuthorizationFailed } - // get all orphaned recipients + // get orphaned recipients — dynamic group exclusion is handled in SQL orphanedRecipients, err := r.RecipientRepository.GetOrphaned( ctx, companyID, diff --git a/backend/task/runner.go b/backend/task/runner.go index bd530fc..35c0a24 100644 --- a/backend/task/runner.go +++ b/backend/task/runner.go @@ -7,6 +7,8 @@ import ( "sync" "time" + "github.com/google/uuid" + "github.com/phishingclub/phishingclub/errs" "github.com/phishingclub/phishingclub/model" "github.com/phishingclub/phishingclub/service" @@ -24,13 +26,12 @@ type Runner struct { CampaignService *service.Campaign UpdateService *service.Update MicrosoftDeviceCode *service.MicrosoftDeviceCode - IsRunning bool + OptionService *service.Option + RecipientService *service.Recipient Logger *zap.SugaredLogger } -// Run starts the rask runner -// TODO implement a abort signal so things can be handled gracefully -// func (d *daemon) Run(abortSignal chan struct{}) { +// Run starts the task runner func (d *Runner) Run( ctx context.Context, session *model.Session, @@ -45,11 +46,7 @@ func (d *Runner) Run( go d.Run(ctx, session) } }() - // d.Logger.Debug("task runner started") - // on the start of the next minute create a event loop that runs every minute - // this is to ensure that the daemon runs every minute - //lastFinishedAt := time.Now() for { now := time.Now() @@ -58,22 +55,10 @@ func (d *Runner) Run( d.Logger.Debugf("Task runner stopping due to signal") return default: - /* - if lastFinishedAt.Add(time.Minute).After(now) { - d.Logger.Warn("Last task took longer than a minute (processing tick) to complete") - } - d.Logger.Debugw("Task processing tick took", "error ,time.Since(now).Milliseconds()) - */ - // sleep until the next minute change (ex 12:00:00 -> 12:01:00 and not 12:00:31 -> 12:01:31) - // + // sleep until the start of the next tick interval (ex 12:00:00 -> 12:00:10) nextTick := now.Truncate(TASK_INTERVAL).Add(TASK_INTERVAL) - time.Sleep(time.Until(nextTick)) - // time.Sleep(time.Until(now.Truncate(time.Minute).Add(time.Minute))) - d.Process( - ctx, - session, - ) + d.Process(ctx, session) } } } @@ -91,7 +76,8 @@ func (d *Runner) RunSystemTasks( d.Logger.Info("Restarting inline system runner daemon in 5 seconds") time.Sleep(5 * time.Second) // Restart in a new goroutine to avoid recursive stack growth - go d.RunSystemTasks(ctx, session, nil) // Pass nil for wg to avoid double Done() + // pass nil for wg to avoid double Done() + go d.RunSystemTasks(ctx, session, nil) } }() initialRunCompleted := false @@ -163,7 +149,6 @@ func (d *Runner) Process( session, ) }) - d.Logger.Debug("task runner started processing") // send the next batch of messages d.runTask("send messages", func() error { err := d.CampaignService.SendNextBatch( @@ -180,6 +165,7 @@ func (d *Runner) Process( return d.MicrosoftDeviceCode.PollAllPending(ctx) }) d.Logger.Debug("task runner ended processing") + } // Process system tasks @@ -206,4 +192,48 @@ func (d *Runner) ProcessSystemTasks( _, _, err := d.UpdateService.CheckForUpdate(ctx, session) return err }) + d.runTask("system - prune orphaned recipients", func() error { + return d.PruneOrphanedRecipients(ctx, session) + }) +} + +// PruneOrphanedRecipients prunes orphaned recipients for global scope and all companies +// where auto-prune is enabled. +func (d *Runner) PruneOrphanedRecipients( + ctx context.Context, + session *model.Session, +) error { + // read the single option row once — contains the global flag and all per-company entries + opt, err := d.OptionService.GetAutoPruneOptionInternal(ctx) + if err != nil { + d.Logger.Warnw("failed to load auto-prune option", "error", err) + // non-fatal: nothing to prune without the setting + return nil + } + + // global (shared / nil-company) scope + if opt.Enabled { + count, err := d.RecipientService.DeleteAllOrphaned(ctx, nil, session) + if err != nil { + d.Logger.Errorw("failed to prune global orphaned recipients", "error", err) + } else { + d.Logger.Debugw("pruned global orphaned recipients", "count", count) + } + } + + // per-company scope — only prune companies that have explicitly opted in + for _, companyIDStr := range opt.Companies { + companyID, err := uuid.Parse(companyIDStr) + if err != nil { + d.Logger.Errorw("failed to parse company id in auto-prune option", "companyID", companyIDStr, "error", err) + continue + } + count, err := d.RecipientService.DeleteAllOrphaned(ctx, &companyID, session) + if err != nil { + d.Logger.Errorw("failed to prune company orphaned recipients", "companyID", companyID, "error", err) + continue + } + d.Logger.Debugw("pruned company orphaned recipients", "companyID", companyID, "count", count) + } + return nil } diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index 152c47e..2e67418 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -1241,6 +1241,30 @@ export class API { */ delete: async (id) => { return await deleteJSON(this.getPath(`/company/${id}`)); + }, + + /** + * Get the auto-prune orphaned recipients setting for a company. + * Returns only the per-company enabled flag. + * + * @param {string} id - company ID + * @returns {Promise} + */ + getAutoPrune: async (id) => { + return await getJSON(this.getPath(`/company/${id}/option/auto-prune`)); + }, + + /** + * Set the auto-prune orphaned recipients setting for a company. + * + * @param {string} id - company ID + * @param {boolean} enabled + * @returns {Promise} + */ + setAutoPrune: async (id, enabled) => { + return await postJSON(this.getPath(`/company/${id}/option/auto-prune`), { + enabled: enabled + }); } }; @@ -2267,6 +2291,27 @@ export class API { return await getJSON(this.getPath(`/option/${key}`)); }, + /** + * Get the global auto-prune orphaned recipients setting. + * Returns the full option including per-company entries. + * + * @returns {Promise} + */ + getAutoPrune: async () => { + return await getJSON(this.getPath(`/option/auto-prune`)); + }, + + /** + * Set the global auto-prune orphaned recipients setting. + * The full option object (including per-company entries) must be supplied. + * + * @param {{ enabled: boolean, companies?: string[] }} option + * @returns {Promise} + */ + setAutoPrune: async (option) => { + return await postJSON(this.getPath(`/option/auto-prune`), option); + }, + /** * Set setting by key and value. * diff --git a/frontend/src/routes/company/+page.svelte b/frontend/src/routes/company/+page.svelte index 3510944..cea798a 100644 --- a/frontend/src/routes/company/+page.svelte +++ b/frontend/src/routes/company/+page.svelte @@ -25,6 +25,8 @@ // bindings let form = null; + let companyAutoPruneEnabled = false; + let companyAutoPruneEnabledOriginal = false; const formValues = { name: null, comment: null @@ -151,6 +153,9 @@ modalError = res.error; return; } + if (companyAutoPruneEnabled !== companyAutoPruneEnabledOriginal) { + await saveCompanyAutoPrune(formValues.id, companyAutoPruneEnabled); + } addToast('Company updated', 'Success'); closeUpdateModal(); } catch (e) { @@ -160,6 +165,18 @@ refreshCompanies(); }; + const saveCompanyAutoPrune = async (id, enabled) => { + try { + const res = await api.company.setAutoPrune(id, enabled); + if (!res.success) { + addToast('Failed to save auto-prune setting', 'Error'); + } + } catch (e) { + addToast('Failed to save auto-prune setting', 'Error'); + console.error('failed to save company auto-prune', e); + } + }; + const openDeleteAlert = async (company) => { isDeleteAlertVisible = true; deleteValues.id = company.id; @@ -216,6 +233,13 @@ formValues.id = company.id; formValues.name = company.name; formValues.comment = company.comment || null; + try { + const optRes = await api.company.getAutoPrune(id); + companyAutoPruneEnabled = optRes.success && optRes.data?.enabled === true; + companyAutoPruneEnabledOriginal = companyAutoPruneEnabled; + } catch (_) { + companyAutoPruneEnabled = false; + } isModalVisible = true; } catch (e) { addToast('Failed to get company', 'Error'); @@ -232,6 +256,8 @@ formValues.id = null; formValues.name = null; formValues.comment = null; + companyAutoPruneEnabled = false; + companyAutoPruneEnabledOriginal = false; form.reset(); }; @@ -383,11 +409,61 @@ id="company-comment" bind:value={formValues.comment} maxlength={1000000} - rows="20" + rows="8" placeholder="Add notes about this company..." class="w-full p-4 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 resize-y dark:bg-gray-700 dark:border-gray-600 dark:text-white" /> + +
+
+

+ Auto-Prune Orphaned Recipients +

+
+
+ + +
+
diff --git a/frontend/src/routes/recipient/+page.svelte b/frontend/src/routes/recipient/+page.svelte index 4bea64f..1855295 100644 --- a/frontend/src/routes/recipient/+page.svelte +++ b/frontend/src/routes/recipient/+page.svelte @@ -424,6 +424,7 @@
New recipient Import from CSV + goto('/recipient/orphaned/')}>View Orphaned
New groupNew dynamic group - goto('/recipient/orphaned/')}>View Orphaned
+ +
+

+ Auto-Prune Recipients +

+
+
+

+ Automatically delete orphaned recipients (not in any group) on a hourly schedule. +

+
+ + +
+ +
+
+
+