mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-05-20 15:14:57 +02:00
add auto remove orphans
fix orphans in dynamic groups not included Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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{})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -427,6 +427,8 @@ func main() {
|
||||
CampaignService: services.Campaign,
|
||||
UpdateService: services.Update,
|
||||
MicrosoftDeviceCode: services.MicrosoftDeviceCode,
|
||||
OptionService: services.Option,
|
||||
RecipientService: services.Recipient,
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user