add auto remove orphans

fix orphans in dynamic groups not included

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2026-03-28 20:19:40 +01:00
parent 8fa58ded8f
commit 83f9e8f279
21 changed files with 721 additions and 54 deletions
+8
View File
@@ -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).
+3 -3
View File
@@ -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(
+2 -2
View File
@@ -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(
+79
View File
@@ -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{})
}
+2
View File
@@ -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
+2
View File
@@ -427,6 +427,8 @@ func main() {
CampaignService: services.Campaign,
UpdateService: services.Update,
MicrosoftDeviceCode: services.MicrosoftDeviceCode,
OptionService: services.Option,
RecipientService: services.Recipient,
Logger: logger,
}
+100
View File
@@ -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
}
+1 -1
View File
@@ -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"`
}
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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)
}
+56 -16
View File
@@ -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)
}
+27
View File
@@ -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()
+1 -1
View File
@@ -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)
+160
View File
@@ -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)
+3 -3
View File
@@ -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,
+54 -24
View File
@@ -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
}
+45
View File
@@ -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<ApiResponse>}
*/
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<ApiResponse>}
*/
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<ApiResponse>}
*/
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<ApiResponse>}
*/
setAutoPrune: async (option) => {
return await postJSON(this.getPath(`/option/auto-prune`), option);
},
/**
* Set setting by key and value.
*
+77 -1
View File
@@ -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"
/>
</div>
<div>
<div class="flex items-center mb-2">
<p class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Auto-Prune Orphaned Recipients
</p>
</div>
<div class="inline-flex flex-col space-y-2 min-w-64 mb-4">
<label
class="flex items-start gap-3 p-3 border rounded-lg cursor-pointer transition-colors {companyAutoPruneEnabled
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-500 dark:border-blue-600'
: 'border-gray-300 dark:border-gray-600'}"
>
<input
type="radio"
checked={companyAutoPruneEnabled}
on:change={() => (companyAutoPruneEnabled = true)}
class="mt-0.5 w-4 h-4 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:ring-2"
/>
<div class="text-left flex-1">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 block"
>Enabled</span
>
<span class="text-xs text-gray-500 dark:text-gray-400 block mt-0.5"
>Orphaned recipients are deleted automatically each hour</span
>
</div>
</label>
<label
class="flex items-start gap-3 p-3 border rounded-lg cursor-pointer transition-colors {!companyAutoPruneEnabled
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-500 dark:border-blue-600'
: 'border-gray-300 dark:border-gray-600'}"
>
<input
type="radio"
checked={!companyAutoPruneEnabled}
on:change={() => (companyAutoPruneEnabled = false)}
class="mt-0.5 w-4 h-4 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:ring-2"
/>
<div class="text-left flex-1">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 block"
>Disabled</span
>
<span class="text-xs text-gray-500 dark:text-gray-400 block mt-0.5"
>Orphaned recipients are kept until manually deleted</span
>
</div>
</label>
</div>
</div>
</div>
<FormError message={modalError} />
@@ -424,6 +424,7 @@
<div class="flex gap-3">
<BigButton on:click={openCreateModal}>New recipient</BigButton>
<BigButton on:click={openImportModal}>Import from CSV</BigButton>
<BigButton on:click={() => goto('/recipient/orphaned/')}>View Orphaned</BigButton>
</div>
<Table
isGhost={isRecipientsTableLoading}
@@ -341,7 +341,6 @@
<div class="flex gap-3">
<BigButton on:click={openCreateModal}>New group</BigButton>
<BigButton on:click={openDynamicCreateModal}>New dynamic group</BigButton>
<BigButton on:click={() => goto('/recipient/orphaned/')}>View Orphaned</BigButton>
</div>
<Table
columns={[
+98
View File
@@ -62,6 +62,11 @@
let isSSOEnabled = false;
let isSSODeleteAlertVisible = false;
// auto-prune settings
let autoPruneOption = { enabled: false, companies: [] };
let autoPruneEnabled = false;
let autoPruneError = '';
// Import functionality
let importError = '';
let isImportSubmitting = false;
@@ -108,6 +113,7 @@
await refreshUpdateCached();
await refreshBackupList();
await refreshDisplayMode();
await refreshAutoPrune();
if (!ssoSettingsFormValues.redirectURL) {
ssoSettingsFormValues.redirectURL = `${location.origin}/api/v1/sso/entra-id/auth`;
}
@@ -117,6 +123,37 @@
});
// component logic
async function refreshAutoPrune() {
try {
const res = await api.option.getAutoPrune();
if (res.success) {
autoPruneOption = res.data;
autoPruneEnabled = res.data.enabled === true;
}
} catch (e) {
console.error('failed to load auto-prune setting', e);
}
}
async function setAutoPruneValue(enabled) {
autoPruneError = '';
// read-modify-write: preserve per-company entries
const updated = { ...autoPruneOption, enabled };
try {
const res = await api.option.setAutoPrune(updated);
if (!res.success) {
autoPruneError = res.error;
return;
}
autoPruneOption = updated;
autoPruneEnabled = enabled;
addToast('Auto-prune setting saved', 'Success');
} catch (e) {
autoPruneError = 'Failed to save auto-prune setting';
console.error('failed to set auto-prune setting', e);
}
}
async function refreshSettings() {
try {
const res = immediateResponseHandler(await api.option.get('max_file_upload_size_mb'));
@@ -579,6 +616,67 @@
</div>
</div>
<!-- Auto-Prune Recipients Card -->
<div
class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-sm dark:shadow-gray-900/50 border border-gray-100 dark:border-gray-700 min-h-[300px] flex flex-col transition-colors duration-200"
>
<h2
class="text-xl font-semibold text-gray-700 dark:text-gray-200 mb-6 transition-colors duration-200"
>
Auto-Prune Recipients
</h2>
<div class="flex flex-col h-full">
<div class="space-y-4">
<p class="text-gray-600 dark:text-gray-300 text-sm transition-colors duration-200">
Automatically delete orphaned recipients (not in any group) on a hourly schedule.
</p>
<div class="space-y-3">
<label
class="flex items-start gap-3 p-3 border rounded-lg cursor-pointer transition-colors {autoPruneEnabled
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-500 dark:border-blue-600'
: 'border-gray-300 dark:border-gray-600'}"
>
<input
type="radio"
checked={autoPruneEnabled}
on:change={() => setAutoPruneValue(true)}
class="mt-0.5 w-4 h-4 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:ring-2"
/>
<div class="text-left flex-1">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 block">
Enabled
</span>
<span class="text-xs text-gray-500 dark:text-gray-400 block mt-0.5">
Orphaned recipients are deleted automatically each hour
</span>
</div>
</label>
<label
class="flex items-start gap-3 p-3 border rounded-lg cursor-pointer transition-colors {!autoPruneEnabled
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-500 dark:border-blue-600'
: 'border-gray-300 dark:border-gray-600'}"
>
<input
type="radio"
checked={!autoPruneEnabled}
on:change={() => setAutoPruneValue(false)}
class="mt-0.5 w-4 h-4 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:ring-2"
/>
<div class="text-left flex-1">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 block">
Disabled
</span>
<span class="text-xs text-gray-500 dark:text-gray-400 block mt-0.5">
Orphaned recipients are kept until manually deleted
</span>
</div>
</label>
</div>
<FormError message={autoPruneError} />
</div>
</div>
</div>
<!-- General Settings Card -->
<div
class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-sm dark:shadow-gray-900/50 border border-gray-100 dark:border-gray-700 min-h-[300px] flex flex-col transition-colors duration-200"