added scripts feature

Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
RonniSkansing committed 2026-09-19 11:31:42 +02:00
1 parent a32d2ef4fb
commit f7f73dd531
37 files changed
+4319 -82

No files matched your search

+11
View File
@@ -230,6 +230,10 @@ const (
ROUTE_V1_WEBHOOK = "/api/v1/webhook"
ROUTE_V1_WEBHOOK_ID = "/api/v1/webhook/:id"
ROUTE_V1_WEBHOOK_ID_TEST = "/api/v1/webhook/:id/test"
ROUTE_V1_SCRIPT = "/api/v1/script"
ROUTE_V1_SCRIPT_ID = "/api/v1/script/:id"
ROUTE_V1_SCRIPT_TEST = "/api/v1/script/test"
// identifiers
ROUTE_V1_IDENTIFIER = "/api/v1/identifier"
// oauth providers
@@ -593,6 +597,13 @@ func setupRoutes(
PATCH(ROUTE_V1_WEBHOOK_ID, middleware.SessionHandler, controllers.Webhook.UpdateByID).
DELETE(ROUTE_V1_WEBHOOK_ID, middleware.SessionHandler, controllers.Webhook.DeleteByID).
POST(ROUTE_V1_WEBHOOK_ID_TEST, middleware.SessionHandler, controllers.Webhook.SendTest).
// scripts
GET(ROUTE_V1_SCRIPT, middleware.SessionHandler, controllers.Script.GetAll).
GET(ROUTE_V1_SCRIPT_ID, middleware.SessionHandler, controllers.Script.GetByID).
POST(ROUTE_V1_SCRIPT, middleware.SessionHandler, controllers.Script.Create).
POST(ROUTE_V1_SCRIPT_TEST, middleware.SessionHandler, controllers.Script.Test).
PATCH(ROUTE_V1_SCRIPT_ID, middleware.SessionHandler, controllers.Script.UpdateByID).
DELETE(ROUTE_V1_SCRIPT_ID, middleware.SessionHandler, controllers.Script.DeleteByID).
// identifiers
GET(ROUTE_V1_IDENTIFIER, middleware.SessionHandler, controllers.Identifier.GetAll).
// version
+69 -62
View File
@@ -9,43 +9,44 @@ import (
// Controllers is a collection of controllers
type Controllers struct {
Asset *controller.Asset
Attachment *controller.Attachment
Company *controller.Company
Health *controller.Health
Installer *controller.Install
InitialSetup *controller.InitialSetup
Page *controller.Page
Proxy *controller.Proxy
Log *controller.Log
Option *controller.Option
User *controller.User
Domain *controller.Domain
Recipient *controller.Recipient
RecipientGroup *controller.RecipientGroup
SMTPConfiguration *controller.SMTPConfiguration
Email *controller.Email
CampaignTemplate *controller.CampaignTemplate
Campaign *controller.Campaign
QR *controller.QRGenerator
APISender *controller.APISender
AllowDeny *controller.AllowDeny
GeoIP *controller.GeoIP
Webhook *controller.Webhook
Identifier *controller.Identifier
Version *controller.Version
SSO *controller.SSO
Update *controller.Update
Import *controller.Import
Backup *controller.Backup
IPAllowList *controller.IPAllowList
OAuthProvider *controller.OAuthProvider
Asset *controller.Asset
Attachment *controller.Attachment
Company *controller.Company
Health *controller.Health
Installer *controller.Install
InitialSetup *controller.InitialSetup
Page *controller.Page
Proxy *controller.Proxy
Log *controller.Log
Option *controller.Option
User *controller.User
Domain *controller.Domain
Recipient *controller.Recipient
RecipientGroup *controller.RecipientGroup
SMTPConfiguration *controller.SMTPConfiguration
Email *controller.Email
CampaignTemplate *controller.CampaignTemplate
Campaign *controller.Campaign
QR *controller.QRGenerator
APISender *controller.APISender
AllowDeny *controller.AllowDeny
GeoIP *controller.GeoIP
Webhook *controller.Webhook
Identifier *controller.Identifier
Version *controller.Version
SSO *controller.SSO
Update *controller.Update
Import *controller.Import
Backup *controller.Backup
IPAllowList *controller.IPAllowList
OAuthProvider *controller.OAuthProvider
CompanyScimConfig *controller.CompanyScimConfig
CompanyReportConfig *controller.CompanyReportConfig
Scim *controller.Scim
RemoteBrowser *controller.RemoteBrowserController
ReportTemplate *controller.ReportTemplate
Branding *controller.Branding
Script *controller.Script
}
// NewControllers creates a collection of controllers
@@ -173,6 +174,11 @@ func NewControllers(
Common: common,
WebhookService: services.Webhook,
}
script := &controller.Script{
Common: common,
ScriptService: services.Script,
Enabled: conf.Script.Enabled,
}
identifier := &controller.Identifier{
Common: common,
IdentifierService: services.Identifier,
@@ -242,42 +248,43 @@ func NewControllers(
}
return &Controllers{
Asset: asset,
Attachment: attachment,
Company: company,
Installer: installer,
InitialSetup: initialSetup,
Health: health,
Page: page,
Proxy: proxy,
Log: log,
Option: option,
User: user,
Domain: domain,
Recipient: recipient,
RecipientGroup: recipientGroup,
SMTPConfiguration: smtpConfiguration,
Email: email,
CampaignTemplate: campaignTemplate,
Campaign: campaign,
QR: qr,
APISender: apiSender,
AllowDeny: allowDeny,
GeoIP: geoIP,
Webhook: webhook,
Identifier: identifier,
Version: version,
SSO: sso,
Update: update,
Import: importController,
Backup: backup,
IPAllowList: ipAllowList,
OAuthProvider: oauthProvider,
Asset: asset,
Attachment: attachment,
Company: company,
Installer: installer,
InitialSetup: initialSetup,
Health: health,
Page: page,
Proxy: proxy,
Log: log,
Option: option,
User: user,
Domain: domain,
Recipient: recipient,
RecipientGroup: recipientGroup,
SMTPConfiguration: smtpConfiguration,
Email: email,
CampaignTemplate: campaignTemplate,
Campaign: campaign,
QR: qr,
APISender: apiSender,
AllowDeny: allowDeny,
GeoIP: geoIP,
Webhook: webhook,
Identifier: identifier,
Version: version,
SSO: sso,
Update: update,
Import: importController,
Backup: backup,
IPAllowList: ipAllowList,
OAuthProvider: oauthProvider,
CompanyScimConfig: companyScimConfig,
CompanyReportConfig: companyReportConfig,
Scim: scim,
RemoteBrowser: remoteBrowser,
ReportTemplate: reportTemplate,
Branding: branding,
Script: script,
}
}
+2
View File
@@ -36,6 +36,7 @@ type Repositories struct {
ReportSendLog *repository.ReportSendLog
RemoteBrowser *repository.RemoteBrowser
ReportTemplate *repository.ReportTemplate
Script *repository.Script
}
// NewRepositories creates a collection of repositories
@@ -73,5 +74,6 @@ func NewRepositories(
ReportSendLog: &repository.ReportSendLog{DB: db},
RemoteBrowser: &repository.RemoteBrowser{DB: db},
ReportTemplate: &repository.ReportTemplate{DB: db},
Script: &repository.Script{DB: db},
}
}
+38
View File
@@ -5,6 +5,7 @@ import (
"time"
"github.com/caddyserver/certmagic"
"github.com/phishingclub/phishingclub/script"
"github.com/phishingclub/phishingclub/service"
"go.uber.org/zap"
"gorm.io/gorm"
@@ -49,6 +50,10 @@ type Services struct {
RemoteBrowser *service.RemoteBrowser
ReportTemplate *service.ReportTemplate
Branding *service.Branding
Script *service.Script
// ScriptDispatcher is the worker pool for event triggered scripts, exposed so
// graceful shutdown can drain in flight jobs. Nil when the feature is disabled.
ScriptDispatcher *script.Dispatcher
}
// NewServices creates a collection of services
@@ -67,10 +72,23 @@ func NewServices(
trustedProxies []string,
remoteBrowserExecPath string,
brandingPath string,
scriptEnabled bool,
) *Services {
common := service.Common{
Logger: logger,
}
// the script dispatcher only exists when the feature is enabled at the
// server level. When nil the campaign service skips script dispatch.
var scriptDispatcher *script.Dispatcher
if scriptEnabled {
scriptDispatcher = script.NewDispatcher(
logger,
script.DefaultWorkers,
script.DefaultQueueSize,
script.DefaultTimeout,
)
scriptDispatcher.Start()
}
microsoftDeviceCodeService := &service.MicrosoftDeviceCode{
Common: common,
MicrosoftDeviceCodeRepository: repositories.MicrosoftDeviceCode,
@@ -149,6 +167,21 @@ func NewServices(
CampaignRepository: repositories.Campaign,
WebhookRepository: repositories.Webhook,
}
// a standalone runner powers the editor test panel (capture mode)
var scriptTestRunner *script.Runner
if scriptEnabled {
scriptTestRunner = &script.Runner{
Logger: logger,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
Timeout: 30 * time.Second,
}
}
scriptSvc := &service.Script{
Common: common,
CampaignRepository: repositories.Campaign,
ScriptRepository: repositories.Script,
TestRunner: scriptTestRunner,
}
campaignTemplate := &service.CampaignTemplate{
Common: common,
@@ -227,6 +260,8 @@ func NewServices(
RecipientGroupRepository: repositories.RecipientGroup,
AllowDenyRepository: repositories.AllowDeny,
WebhookRepository: repositories.Webhook,
ScriptRepository: repositories.Script,
ScriptDispatcher: scriptDispatcher,
CampaignTemplateService: campaignTemplate,
DomainService: domain,
RecipientService: recipient,
@@ -269,6 +304,7 @@ func NewServices(
CampaignTemplate: campaignTemplate,
AllowDenyService: allowDeny,
WebhookService: webhook,
ScriptService: scriptSvc,
AssetService: asset,
CompanyRepository: repositories.Company,
}
@@ -379,5 +415,7 @@ func NewServices(
RemoteBrowser: remoteBrowser,
ReportTemplate: reportTemplate,
Branding: brandingService,
Script: scriptSvc,
ScriptDispatcher: scriptDispatcher,
}
}
+3
View File
@@ -19,5 +19,8 @@
},
"remote_browser": {
"enabled": true
},
"script": {
"enabled": true
}
}
+3
View File
@@ -24,5 +24,8 @@
"remote_browser": {
"enabled": false,
"exec_path": ""
},
"script": {
"enabled": false
}
}
+14
View File
@@ -80,6 +80,7 @@ type (
IPSecurity IPSecurityConfig
RemoteBrowser RemoteBrowserServerConfig
Script ScriptServerConfig
Authentication AuthenticationConfig
}
@@ -92,6 +93,7 @@ type (
Log Log `json:"log"`
IPSecurity IPSecurityConfig `json:"ip_security"`
RemoteBrowser RemoteBrowserServerConfig `json:"remote_browser"`
Script ScriptServerConfig `json:"script"`
Authentication AuthenticationConfig `json:"authentication"`
}
@@ -147,6 +149,16 @@ type (
// its own auto-downloaded Chromium. Set at the server level only.
ExecPath string `json:"exec_path"`
}
// ScriptServerConfig holds server side script settings.
ScriptServerConfig struct {
// Enabled controls whether the script feature is available.
// Defaults to false. When false all script endpoints return 404 and
// no scripts run. When true admin authored scripts run in a sandboxed
// engine with outbound network access, so only enable on instances where
// every operator is trusted as a server admin.
Enabled bool `json:"enabled"`
}
)
type IPSecurityConfig struct {
@@ -506,6 +518,7 @@ func FromDTO(dto *ConfigDTO) (*Config, error) {
return nil, err
}
cfg.RemoteBrowser = dto.RemoteBrowser
cfg.Script = dto.Script
cfg.Authentication = dto.Authentication
return cfg, nil
}
@@ -537,6 +550,7 @@ func (c *Config) ToDTO() *ConfigDTO {
},
IPSecurity: c.IPSecurity,
RemoteBrowser: c.RemoteBrowser,
Script: c.Script,
Authentication: c.Authentication,
}
}
+14 -1
View File
@@ -420,17 +420,30 @@ func TestNewDefaultConfig(t *testing.T) {
{
name: "happypath",
want: &Config{
tlsHost: "phish.test",
tlsCertPath: adminPublicCertPath,
tlsKeyPath: adminPrivateCertPath,
adminNetAddress: net.TCPAddr{
IP: net.IPv4(127, 0, 0, 1),
IP: net.IPv4(0, 0, 0, 0),
Port: DefaultDevAdministrationPort,
},
phishingHTTPNetAddress: net.TCPAddr{
IP: net.IPv4(0, 0, 0, 0),
Port: DefaultDevHTTPPhishingPort,
},
phishingHTTPSNetAddress: net.TCPAddr{
IP: net.IPv4(0, 0, 0, 0),
Port: DefaultDevHTTPSPhishingPort,
},
database: Database{
Engine: DefaultAdministrationUseSqlite,
DSN: DefaultAdministrationDSN,
},
fileWriter: &file.FileWriter{},
IPSecurity: IPSecurityConfig{
AdminAllowed: []string{},
TrustedProxies: []string{},
},
},
},
}
+1
View File
@@ -199,6 +199,7 @@ func (c *Campaign) GetByID(g *gin.Context) {
WithDenyPage: true,
WithEvasionPage: true,
WithWebhooks: true,
WithScripts: true,
},
)
// handle responses
+232
View File
@@ -0,0 +1,232 @@
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/script"
"github.com/phishingclub/phishingclub/service"
)
// ScriptColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
var ScriptColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.SCRIPT_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.SCRIPT_TABLE, "updated_at"),
"name": repository.TableColumn(database.SCRIPT_TABLE, "name"),
}
// Script is a controller
type Script struct {
Common
ScriptService *service.Script
// Enabled mirrors config.ScriptServerConfig.Enabled. When false every
// endpoint returns 404, matching the remote browser gate. The script
// engine runs admin authored scripts with outbound network access, so it is
// only enabled on instances where every operator is trusted as a server admin.
Enabled bool
}
// isEnabled returns true when the feature is enabled, otherwise it aborts the
// request with 404 so the feature is invisible when turned off.
func (a *Script) isEnabled(g *gin.Context) bool {
if !a.Enabled {
g.AbortWithStatus(http.StatusNotFound)
return false
}
return true
}
// Create creates a new script
func (a *Script) Create(g *gin.Context) {
if !a.isEnabled(g) {
return
}
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
var req model.Script
if ok := a.handleParseRequest(g, &req); !ok {
return
}
// save script
id, err := a.ScriptService.Create(g.Request.Context(), session, &req)
// handle response
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(
g,
gin.H{
"id": id.String(),
},
)
}
// GetAll gets the scripts
func (a *Script) GetAll(g *gin.Context) {
if !a.isEnabled(g) {
return
}
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := a.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
queryArgs.RemapOrderBy(ScriptColumnsMap)
companyID := companyIDFromRequestQuery(g)
// get
scripts, err := a.ScriptService.GetAll(
g.Request.Context(),
session,
companyID,
&repository.ScriptOption{
QueryArgs: queryArgs,
},
)
// handle response
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(
g,
scripts,
)
}
// GetByID gets a script by id
func (a *Script) GetByID(g *gin.Context) {
if !a.isEnabled(g) {
return
}
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
// get
script, err := a.ScriptService.GetByID(
g.Request.Context(),
session,
id,
)
// handle response
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, script)
}
// UpdateByID updates a script
func (a *Script) UpdateByID(g *gin.Context) {
if !a.isEnabled(g) {
return
}
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
var req model.Script
if ok := a.handleParseRequest(g, &req); !ok {
return
}
// save
err := a.ScriptService.Update(g.Request.Context(), session, id, &req)
// handle response
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, nil)
}
// scriptTestRequest is the body for a test run: a script plus a simulated
// campaign event to feed it.
type scriptTestRequest struct {
Script string `json:"script"`
Event struct {
Name string `json:"name"`
CampaignName string `json:"campaignName"`
Email string `json:"email"`
CampaignID string `json:"campaignId"`
RecipientID string `json:"recipientId"`
Data map[string]interface{} `json:"data"`
} `json:"event"`
}
// Test runs a script against a simulated event and returns what it did, without
// touching any campaign (log/info/emitEvent are captured, not applied).
func (a *Script) Test(g *gin.Context) {
if !a.isEnabled(g) {
return
}
session, _, ok := a.handleSession(g)
if !ok {
return
}
var req scriptTestRequest
if ok := a.handleParseRequest(g, &req); !ok {
return
}
result, err := a.ScriptService.Test(
g.Request.Context(),
session,
req.Script,
script.EventContext{
CampaignID: req.Event.CampaignID,
RecipientID: req.Event.RecipientID,
Event: req.Event.Name,
CampaignName: req.Event.CampaignName,
Email: req.Event.Email,
Data: req.Event.Data,
},
)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, result)
}
// DeleteByID deletes a script by id
func (a *Script) DeleteByID(g *gin.Context) {
if !a.isEnabled(g) {
return
}
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
// delete
err := a.ScriptService.DeleteByID(g, session, id)
// handle response
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, nil)
}
+35
View File
@@ -0,0 +1,35 @@
package database
import (
"github.com/google/uuid"
)
const (
CAMPAIGN_SCRIPT_TABLE = "campaign_scripts"
)
// CampaignScript is a gorm data model.
// It is a junction table for the campaign to script many to many relationship
// and stores the per script configuration (events and data level).
// It mirrors CampaignWebhook so scripts are configured on a campaign the
// same way webhooks are.
type CampaignScript struct {
CampaignID *uuid.UUID `gorm:"not null;index;type:uuid;uniqueIndex:idx_campaign_script;primaryKey;"`
Campaign *Campaign
ScriptID *uuid.UUID `gorm:"not null;index;type:uuid;uniqueIndex:idx_campaign_script;primaryKey;"`
Script *Script
// scriptincludedata is the data level handed to the script.
// values: "none", "basic", "full"
ScriptIncludeData string `gorm:"not null;default:'full'"`
// scriptevents is a binary format storing selected events as bits.
// 0 = all events (default). Uses the same bit map as webhooks
// (data.WebhookEventToBit) so the two features subscribe identically.
ScriptEvents int `gorm:"not null;default:0"`
}
func (CampaignScript) TableName() string {
return CAMPAIGN_SCRIPT_TABLE
}
+34
View File
@@ -0,0 +1,34 @@
package database
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
const (
SCRIPT_TABLE = "scripts"
)
// Script is a gorm data model for scripts.
// An script holds a JavaScript program that runs when a subscribed
// campaign event fires. It is the scripting counterpart to a webhook.
type Script struct {
ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
CreatedAt *time.Time `gorm:"not null;index;"`
UpdatedAt *time.Time `gorm:"not null;index;"`
CompanyID *uuid.UUID `gorm:"uniqueIndex:idx_scripts_unique_name_and_company_id;type:uuid"`
Name string `gorm:"not null;uniqueIndex:idx_scripts_unique_name_and_company_id;"`
Script string `gorm:"not null;"`
}
func (e *Script) Migrate(db *gorm.DB) error {
// SQLITE
// ensure name + company id is unique
return UniqueIndexNameAndNullCompanyID(db, SCRIPT_TABLE)
}
func (Script) TableName() string {
return SCRIPT_TABLE
}
+19
View File
@@ -251,6 +251,7 @@ func main() {
conf.IPSecurity.TrustedProxies,
conf.RemoteBrowser.ExecPath,
brandingPath,
conf.Script.Enabled,
)
// get entra-id options and setup msal client
ssoOpt, err := services.SSO.GetSSOOptionWithoutAuth(context.Background())
@@ -538,6 +539,24 @@ func main() {
logger.Errorw("HTTPS server shutdown error", "error", err)
}
// Drain in flight scripts before closing the database, since their write
// back funnel needs the DB. Nil when the feature is disabled. Bounded so a
// full queue cannot overrun the graceful shutdown window: in flight jobs
// finish, jobs still queued past the window are abandoned (best effort).
if services.ScriptDispatcher != nil {
logger.Debugf("Stopping script dispatcher")
drained := make(chan struct{})
go func() {
services.ScriptDispatcher.Stop()
close(drained)
}()
select {
case <-drained:
case <-time.After(20 * time.Second):
logger.Warnw("script dispatcher drain exceeded its window, abandoning queued jobs")
}
}
// Close database connections
sqlDB, err := db.DB()
if err != nil {
+4
View File
@@ -76,6 +76,10 @@ type Campaign struct {
// webhooks configuration with per-webhook settings
Webhooks nullable.Nullable[[]*CampaignWebhook] `json:"webhooks,omitempty"`
// scripts configuration with per-script settings.
// attached to a campaign the same way webhooks are.
Scripts nullable.Nullable[[]*CampaignScript] `json:"scripts,omitempty"`
// snapshotted from the campaign template while the campaign holds no
// recipients. read only, see ToDBMap.
LureURLMode nullable.Nullable[string] `json:"lureURLMode"`
+72
View File
@@ -0,0 +1,72 @@
package model
import (
"github.com/go-errors/errors"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/validate"
)
// CampaignScript represents a script configuration for a campaign.
// It allows per script event and data level settings and mirrors
// CampaignWebhook so scripts attach to a campaign exactly like webhooks.
type CampaignScript struct {
ScriptID nullable.Nullable[uuid.UUID] `json:"scriptID"`
ScriptIncludeData nullable.Nullable[string] `json:"scriptIncludeData"`
ScriptEvents nullable.Nullable[int] `json:"scriptEvents"`
}
// Validate checks if the campaign script has valid configuration
func (ca *CampaignScript) Validate() error {
if err := validate.NullableFieldRequired("scriptID", ca.ScriptID); err != nil {
return err
}
// validate scriptincludedata is one of the allowed values
if ca.ScriptIncludeData.IsSpecified() && !ca.ScriptIncludeData.IsNull() {
dataLevel := ca.ScriptIncludeData.MustGet()
if dataLevel != WebhookDataLevelNone &&
dataLevel != WebhookDataLevelBasic &&
dataLevel != WebhookDataLevelFull {
return validate.WrapErrorWithField(
errors.New("must be 'none', 'basic', or 'full'"),
"scriptIncludeData",
)
}
}
// validate scriptevents is a valid binary value
if ca.ScriptEvents.IsSpecified() && !ca.ScriptEvents.IsNull() {
events := ca.ScriptEvents.MustGet()
// check if any invalid bits are set, the valid ones are the mapped events
maxValidBits := 0
for _, bit := range data.WebhookEventToBit {
maxValidBits |= bit
}
if events < 0 || (events > 0 && events&^maxValidBits != 0) {
return validate.WrapErrorWithField(
errors.New("invalid script events binary value"),
"scriptEvents",
)
}
}
return nil
}
// GetScriptIncludeDataOrDefault returns the data level or default to "full"
func (ca *CampaignScript) GetScriptIncludeDataOrDefault() string {
if ca.ScriptIncludeData.IsSpecified() && !ca.ScriptIncludeData.IsNull() {
return ca.ScriptIncludeData.MustGet()
}
return WebhookDataLevelFull
}
// GetScriptEventsOrDefault returns the script events binary or default to 0 (all events)
func (ca *CampaignScript) GetScriptEventsOrDefault() int {
if ca.ScriptEvents.IsSpecified() && !ca.ScriptEvents.IsNull() {
return ca.ScriptEvents.MustGet()
}
return 0 // 0 means all events
}
+56
View File
@@ -0,0 +1,56 @@
package model
import (
"time"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/validate"
"github.com/phishingclub/phishingclub/vo"
)
// Script is a gorm data model for scripts.
// The Script is a JavaScript program run in a sandboxed engine when a
// subscribed campaign event fires.
type Script struct {
ID nullable.Nullable[uuid.UUID] `json:"id"`
CreatedAt *time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"`
Name nullable.Nullable[vo.String127] `json:"name"`
Script nullable.Nullable[vo.String1MB] `json:"script"`
}
// Validate runs the validations for this struct
func (a *Script) Validate() error {
if err := validate.NullableFieldRequired("name", a.Name); err != nil {
return err
}
if err := validate.NullableFieldRequired("script", a.Script); err != nil {
return err
}
return nil
}
// ToDBMap converts the fields that can be stored or updated to a map
// if the value is nullable and not set, it is not included
// if the value is nullable and set, it is included, if it is null, it is set to nil
func (a *Script) ToDBMap() map[string]any {
m := map[string]any{}
if a.Name.IsSpecified() {
m["name"] = nil
if name, err := a.Name.Get(); err == nil {
m["name"] = name.String()
}
}
if a.Script.IsSpecified() {
m["script"] = nil
if script, err := a.Script.Get(); err == nil {
m["script"] = script.String()
}
}
if v, err := a.CompanyID.Get(); err == nil {
m["company_id"] = v.String()
}
return m
}
+103
View File
@@ -63,6 +63,7 @@ type CampaignOption struct {
WithDenyPage bool
WithEvasionPage bool
WithWebhooks bool
WithScripts bool
IncludeTestCampaigns bool
}
@@ -482,6 +483,101 @@ func (r *Campaign) RemoveWebhookFromJunctionByWebhookID(
return nil
}
// AddScripts adds scripts to a campaign with per script configuration
func (r *Campaign) AddScripts(
ctx context.Context,
campaignID *uuid.UUID,
scripts []*model.CampaignScript,
) error {
batch := []database.CampaignScript{}
// deduplicate by script id to prevent unique constraint violations
seen := map[string]struct{}{}
for _, ca := range scripts {
scriptID := ca.ScriptID.MustGet()
key := scriptID.String()
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
batch = append(batch, database.CampaignScript{
CampaignID: campaignID,
ScriptID: &scriptID,
ScriptIncludeData: ca.GetScriptIncludeDataOrDefault(),
ScriptEvents: ca.GetScriptEventsOrDefault(),
})
}
if len(batch) == 0 {
return nil
}
res := r.DB.Create(&batch)
if res.Error != nil {
return res.Error
}
return nil
}
// RemoveScriptsByCampaignID removes all scripts from a campaign
func (r *Campaign) RemoveScriptsByCampaignID(
ctx context.Context,
campaignID *uuid.UUID,
) error {
res := r.DB.
Where("campaign_id = ?", campaignID).
Delete(&database.CampaignScript{})
if res.Error != nil {
return res.Error
}
return nil
}
// GetCampaignScripts fetches script configurations for a campaign from the junction table
func (r *Campaign) GetCampaignScripts(
ctx context.Context,
campaignID *uuid.UUID,
) ([]*model.CampaignScript, error) {
var rows []database.CampaignScript
res := r.DB.
Where("campaign_id = ?", campaignID.String()).
Find(&rows)
if res.Error != nil {
return nil, res.Error
}
scripts := []*model.CampaignScript{}
for _, row := range rows {
if row.ScriptID == nil {
continue
}
ca := &model.CampaignScript{
ScriptID: nullable.NewNullableWithValue(*row.ScriptID),
ScriptIncludeData: nullable.NewNullableWithValue(row.ScriptIncludeData),
ScriptEvents: nullable.NewNullableWithValue(row.ScriptEvents),
}
scripts = append(scripts, ca)
}
return scripts, nil
}
// RemoveScriptFromJunctionByScriptID removes all campaign_scripts rows for a given script id
// must be called before deleting a script to avoid orphaned junction rows
func (r *Campaign) RemoveScriptFromJunctionByScriptID(
ctx context.Context,
scriptID *uuid.UUID,
) error {
res := r.DB.
Where("script_id = ?", scriptID.String()).
Delete(&database.CampaignScript{})
if res.Error != nil {
return res.Error
}
return nil
}
// RemoveWebhookByCampaignIDs removes the webhook from campaigns by ids
func (r *Campaign) RemoveWebhookByCampaignIDs(
ctx context.Context,
@@ -1297,6 +1393,13 @@ func (r *Campaign) GetByID(
}
campaign.Webhooks = nullable.NewNullableWithValue(webhooks)
}
if options.WithScripts {
scripts, err := r.GetCampaignScripts(ctx, id)
if err != nil {
return nil, err
}
campaign.Scripts = nullable.NewNullableWithValue(scripts)
}
return campaign, nil
}
+208
View File
@@ -0,0 +1,208 @@
package repository
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/vo"
"gorm.io/gorm"
)
var scriptAllowedColumns = assignTableToColumns(database.SCRIPT_TABLE, []string{
"created_at",
"updated_at",
"name",
})
type ScriptOption struct {
*vo.QueryArgs
}
type Script struct {
DB *gorm.DB
}
// Insert inserts a new script
func (r *Script) Insert(
ctx context.Context,
script *model.Script,
) (*uuid.UUID, error) {
id := uuid.New()
row := script.ToDBMap()
row["id"] = id
AddTimestamps(row)
res := r.DB.
Model(&database.Script{}).
Create(row)
if res.Error != nil {
return nil, res.Error
}
return &id, nil
}
// GetAll gets all scripts
func (r *Script) GetAll(
ctx context.Context,
companyID *uuid.UUID,
options *ScriptOption,
) (*model.Result[model.Script], error) {
result := model.NewEmptyResult[model.Script]()
db := withCompanyIncludingNullContext(r.DB, companyID, database.SCRIPT_TABLE)
db, err := useQuery(db, database.SCRIPT_TABLE, options.QueryArgs, scriptAllowedColumns...)
if err != nil {
return result, errs.Wrap(err)
}
var rows []*database.Script
res := db.
Find(&rows)
if res.Error != nil {
return result, res.Error
}
hasNextPage, err := useHasNextPage(db, database.SCRIPT_TABLE, options.QueryArgs, scriptAllowedColumns...)
if err != nil {
return result, errs.Wrap(err)
}
result.HasNextPage = hasNextPage
for _, row := range rows {
result.Rows = append(result.Rows, ToScript(row))
}
return result, nil
}
// GetAllByCompanyID gets all scripts for a company
func (r *Script) GetAllByCompanyID(
ctx context.Context,
companyID *uuid.UUID,
options *ScriptOption,
) ([]*model.Script, error) {
out := []*model.Script{}
db := whereCompany(r.DB, database.SCRIPT_TABLE, companyID)
db, err := useQuery(db, database.SCRIPT_TABLE, options.QueryArgs, scriptAllowedColumns...)
if err != nil {
return out, errs.Wrap(err)
}
var rows []*database.Script
res := db.
Find(&rows)
if res.Error != nil {
return out, res.Error
}
for _, row := range rows {
out = append(out, ToScript(row))
}
return out, nil
}
// GetByID gets a script by id
func (r *Script) GetByID(
ctx context.Context,
id *uuid.UUID,
) (*model.Script, error) {
var row database.Script
res := r.DB.
Where(
fmt.Sprintf(
"%s = ?",
TableColumnID(database.SCRIPT_TABLE),
),
id.String(),
).
First(&row)
if res.Error != nil {
return nil, res.Error
}
return ToScript(&row), nil
}
// GetByIDs fetches multiple scripts by their IDs in a single query
func (r *Script) GetByIDs(
ctx context.Context,
ids []*uuid.UUID,
) ([]*model.Script, error) {
out := []*model.Script{}
if len(ids) == 0 {
return out, nil
}
idStrings := make([]string, 0, len(ids))
for _, id := range ids {
idStrings = append(idStrings, id.String())
}
var rows []*database.Script
res := r.DB.
Where(
fmt.Sprintf("%s IN ?", TableColumnID(database.SCRIPT_TABLE)),
idStrings,
).
Find(&rows)
if res.Error != nil {
return nil, res.Error
}
for _, row := range rows {
out = append(out, ToScript(row))
}
return out, nil
}
// UpdateByID updates a script by id
func (r *Script) UpdateByID(
ctx context.Context,
id *uuid.UUID,
script *model.Script,
) error {
row := script.ToDBMap()
AddUpdatedAt(row)
res := r.DB.
Model(&database.Script{}).
Where("id = ?", id).
Updates(row)
return res.Error
}
// DeleteByID deletes a script by id
func (r *Script) DeleteByID(
ctx context.Context,
id *uuid.UUID,
) error {
res := r.DB.
Where("id = ?", id).
Delete(&database.Script{})
return res.Error
}
func ToScript(
row *database.Script,
) *model.Script {
id := nullable.NewNullableWithValue(*row.ID)
companyID := nullable.NewNullNullable[uuid.UUID]()
if row.CompanyID != nil {
companyID.Set(*row.CompanyID)
}
name := nullable.NewNullableWithValue(*vo.NewString127Must(row.Name))
script := nullable.NewNullableWithValue(*vo.NewString1MBMust(row.Script))
return &model.Script{
ID: id,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
CompanyID: companyID,
Name: name,
Script: script,
}
}
+299
View File
@@ -0,0 +1,299 @@
package script
import (
"bytes"
"compress/flate"
"compress/gzip"
"crypto/hmac"
"crypto/md5"
"crypto/rand"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"encoding/base32"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"hash"
"html"
"io"
"net/url"
"strings"
"github.com/dop251/goja"
"github.com/google/uuid"
)
// registerCodec installs the encode/decode/hash/hmac/jwt/random helpers on the
// VM. These are the data-transform toolkit for scripts: base and binary
// encodings, hashing and keyed HMAC signing, JWT inspection, and secure random
// (nonces, PKCE, OAuth state). Output encoding for hash/hmac/random is selectable
// as "hex" (default), "base64", "base64url" or "base32".
func registerCodec(vm *goja.Runtime) {
vm.Set("encode", map[string]interface{}{
// text and url
"base64": func(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) },
"base64url": func(s string) string { return base64.RawURLEncoding.EncodeToString([]byte(s)) },
"base32": func(s string) string { return base32.StdEncoding.EncodeToString([]byte(s)) },
"hex": func(s string) string { return hex.EncodeToString([]byte(s)) },
"url": func(s string) string { return url.QueryEscape(s) },
"urlPath": func(s string) string { return url.PathEscape(s) },
"html": func(s string) string { return html.EscapeString(s) },
"json": func(call goja.FunctionCall) goja.Value {
b, err := json.Marshal(call.Argument(0).Export())
if err != nil {
panic(vm.NewGoError(err))
}
return vm.ToValue(string(b))
},
// object -> application/x-www-form-urlencoded
"form": func(call goja.FunctionCall) goja.Value {
return vm.ToValue(formEncode(call.Argument(0)))
},
// compression, output as base64 so it survives as a JS string
"gzip": func(s string) (string, error) { return deflateEncode(s, true) },
"deflate": func(s string) (string, error) {
return deflateEncode(s, false)
},
})
vm.Set("decode", map[string]interface{}{
"base64": func(s string) (string, error) {
b, err := base64.StdEncoding.DecodeString(s)
return string(b), err
},
"base64url": func(s string) (string, error) {
b, err := decodeBase64URL(s)
return string(b), err
},
"base32": func(s string) (string, error) {
b, err := base32.StdEncoding.DecodeString(s)
return string(b), err
},
"hex": func(s string) (string, error) {
b, err := hex.DecodeString(s)
return string(b), err
},
"url": func(s string) (string, error) { return url.QueryUnescape(s) },
"urlPath": func(s string) (string, error) { return url.PathUnescape(s) },
"html": func(s string) string { return html.UnescapeString(s) },
"json": func(s string) (interface{}, error) {
var v interface{}
err := json.Unmarshal([]byte(s), &v)
return v, err
},
// application/x-www-form-urlencoded -> object
"form": func(s string) (map[string]interface{}, error) { return formDecode(s) },
"gzip": func(s string) (string, error) { return inflateDecode(s, true) },
"deflate": func(s string) (string, error) {
return inflateDecode(s, false)
},
})
// hashing: hash.sha256(input, enc?) -> string
vm.Set("hash", map[string]interface{}{
"md5": hashFn(vm, func() hash.Hash { return md5.New() }),
"sha1": hashFn(vm, func() hash.Hash { return sha1.New() }),
"sha256": hashFn(vm, func() hash.Hash { return sha256.New() }),
"sha384": hashFn(vm, func() hash.Hash { return sha512.New384() }),
"sha512": hashFn(vm, func() hash.Hash { return sha512.New() }),
})
// keyed HMAC: hmac.sha256(key, message, enc?) -> string
vm.Set("hmac", map[string]interface{}{
"sha1": hmacFn(vm, func() hash.Hash { return sha1.New() }),
"sha256": hmacFn(vm, func() hash.Hash { return sha256.New() }),
"sha384": hmacFn(vm, func() hash.Hash { return sha512.New384() }),
"sha512": hmacFn(vm, func() hash.Hash { return sha512.New() }),
})
// jwt.decode(token) -> { header, payload, signature } (NOT verified)
vm.Set("jwt", map[string]interface{}{
"decode": func(token string) (map[string]interface{}, error) { return jwtDecode(token) },
})
// random: nonces, PKCE verifiers, OAuth state
vm.Set("random", map[string]interface{}{
"bytes": func(call goja.FunctionCall) goja.Value {
n := int(call.Argument(0).ToInteger())
if n <= 0 || n > 4096 {
panic(vm.NewTypeError("random.bytes: length must be 1..4096"))
}
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic(vm.NewGoError(err))
}
return vm.ToValue(codecOutput(b, argString(call, 1, "hex")))
},
"uuid": func() string { return uuid.NewString() },
})
}
// codecOutput renders bytes in the requested encoding, defaulting to hex.
func codecOutput(b []byte, enc string) string {
switch strings.ToLower(enc) {
case "base64":
return base64.StdEncoding.EncodeToString(b)
case "base64url":
return base64.RawURLEncoding.EncodeToString(b)
case "base32":
return base32.StdEncoding.EncodeToString(b)
default:
return hex.EncodeToString(b)
}
}
// argString reads an optional string argument, falling back to def.
func argString(call goja.FunctionCall, i int, def string) string {
if len(call.Arguments) > i {
v := call.Argument(i)
if !goja.IsUndefined(v) && !goja.IsNull(v) {
return v.String()
}
}
return def
}
func hashFn(vm *goja.Runtime, newH func() hash.Hash) func(goja.FunctionCall) goja.Value {
return func(call goja.FunctionCall) goja.Value {
h := newH()
h.Write([]byte(call.Argument(0).String()))
return vm.ToValue(codecOutput(h.Sum(nil), argString(call, 1, "hex")))
}
}
func hmacFn(vm *goja.Runtime, newH func() hash.Hash) func(goja.FunctionCall) goja.Value {
return func(call goja.FunctionCall) goja.Value {
m := hmac.New(newH, []byte(call.Argument(0).String()))
m.Write([]byte(call.Argument(1).String()))
return vm.ToValue(codecOutput(m.Sum(nil), argString(call, 2, "hex")))
}
}
// decodeBase64URL decodes url-safe base64 with or without padding.
func decodeBase64URL(s string) ([]byte, error) {
s = strings.TrimRight(s, "=")
return base64.RawURLEncoding.DecodeString(s)
}
func deflateEncode(s string, gz bool) (string, error) {
var buf bytes.Buffer
var w io.WriteCloser
var err error
if gz {
w = gzip.NewWriter(&buf)
} else {
w, err = flate.NewWriter(&buf, flate.DefaultCompression)
if err != nil {
return "", err
}
}
if _, err := w.Write([]byte(s)); err != nil {
return "", err
}
if err := w.Close(); err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(buf.Bytes()), nil
}
func inflateDecode(b64 string, gz bool) (string, error) {
data, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return "", err
}
var r io.ReadCloser
if gz {
r, err = gzip.NewReader(bytes.NewReader(data))
if err != nil {
return "", err
}
} else {
r = flate.NewReader(bytes.NewReader(data))
}
defer r.Close()
out, err := io.ReadAll(r)
if err != nil {
return "", err
}
return string(out), nil
}
// formEncode turns an object into an application/x-www-form-urlencoded string.
func formEncode(val goja.Value) string {
values := url.Values{}
if m, ok := val.Export().(map[string]interface{}); ok {
for k, v := range m {
switch vv := v.(type) {
case []interface{}:
for _, item := range vv {
values.Add(k, fmt.Sprint(item))
}
default:
values.Set(k, fmt.Sprint(v))
}
}
}
return values.Encode()
}
// formDecode parses a form-encoded string into an object. A key with one value
// becomes a string, a repeated key becomes an array.
func formDecode(s string) (map[string]interface{}, error) {
vals, err := url.ParseQuery(s)
if err != nil {
return nil, err
}
out := map[string]interface{}{}
for k, v := range vals {
if len(v) == 1 {
out[k] = v[0]
continue
}
arr := make([]interface{}, len(v))
for i, x := range v {
arr[i] = x
}
out[k] = arr
}
return out, nil
}
// jwtDecode splits a JWT and returns its header and payload as objects plus the
// raw signature. It does NOT verify the signature: it is for inspecting tokens.
func jwtDecode(token string) (map[string]interface{}, error) {
parts := strings.Split(token, ".")
if len(parts) < 2 {
return nil, fmt.Errorf("invalid jwt: expected header.payload.signature")
}
header, err := decodeB64URLJSON(parts[0])
if err != nil {
return nil, fmt.Errorf("jwt header: %w", err)
}
payload, err := decodeB64URLJSON(parts[1])
if err != nil {
return nil, fmt.Errorf("jwt payload: %w", err)
}
sig := ""
if len(parts) >= 3 {
sig = parts[2]
}
return map[string]interface{}{
"header": header,
"payload": payload,
"signature": sig,
}, nil
}
func decodeB64URLJSON(s string) (interface{}, error) {
b, err := decodeBase64URL(s)
if err != nil {
return nil, err
}
var v interface{}
if err := json.Unmarshal(b, &v); err != nil {
return nil, err
}
return v, nil
}
+579
View File
@@ -0,0 +1,579 @@
// Package script runs admin authored JavaScript when a subscribed campaign
// event fires. It is the scripting counterpart to webhooks: a script receives
// the event payload, can call out over HTTP, transform data, and create a new
// campaign event in the same context.
//
// Trust model: scripts run only when the feature is enabled at the server level
// (config.Script.Enabled), which is the operator acknowledgement that every
// admin is trusted as a server admin. The engine is a goja VM with a small,
// explicit binding set (no require, no filesystem, no process access). Scripts
// are bounded by a wall clock timeout, run one per fresh VM, and execute on a
// bounded worker pool so a burst of events cannot exhaust memory or goroutines.
//
// All bindings are synchronous: http.fetch blocks and returns the response.
// Each run owns its VM on its own worker goroutine, so a blocking call stalls
// only that run.
package script
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/dop251/goja"
"github.com/phishingclub/phishingclub/data"
"go.uber.org/zap"
"golang.org/x/net/proxy"
)
const (
// DefaultTimeout is the wall clock budget for a single script run.
DefaultTimeout = 10 * time.Second
// DefaultWorkers is the number of scripts that can run at once.
DefaultWorkers = 4
// DefaultQueueSize is how many pending jobs are buffered before new ones
// are dropped.
DefaultQueueSize = 256
// maxResponseBytes caps a fetch response body so a large download cannot
// exhaust memory.
maxResponseBytes = 5 << 20 // 5 MB
// defaultFetchTimeout and maxFetchTimeout bound a single outbound request.
defaultFetchTimeout = 10 * time.Second
maxFetchTimeout = 30 * time.Second
// maxCallStackSize bounds JS recursion depth so a runaway recursive script
// throws a StackOverflowError instead of exhausting the Go stack.
maxCallStackSize = 2000
)
// scriptStopError is thrown by the stop() binding to end a run cleanly. It is
// detected by identity via errors.As (which traverses goja.Exception.Unwrap to
// the wrapped Go error), so a script that catches stop() and then throws a real
// error later is never mistaken for a clean stop.
type scriptStopError struct{}
func (scriptStopError) Error() string { return "script stopped" }
// emittableEvents are the only events a script may create via emitEvent: data the
// script itself authored. Server-detected outcome events (message delivery,
// opens, clicks, page visits, reports, training) are deliberately excluded so a
// script cannot fabricate a campaign's statistics. info() emits the info event
// through its own binding, not emitEvent.
var emittableEvents = map[string]bool{
data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA: true,
data.EVENT_CAMPAIGN_RECIPIENT_INFO: true,
}
// EventContext is the payload handed to a script. The caller fills it after
// applying the anonymization guard and the none/basic/full data level, so a
// script never sees more than its configuration allows.
type EventContext struct {
CampaignID string
RecipientID string
Event string
CampaignName string
Email string
Data map[string]interface{}
}
// EmitFunc lets a script create a new campaign event in the same context.
// The caller implements it so the write goes through the native event
// chokepoint (SaveSubmittedData plus anonymization) and does not re-trigger
// scripts.
type EmitFunc func(eventName string, data map[string]interface{}) error
// Job is a single script run.
type Job struct {
ScriptID string
Script string
Event EventContext
Emit EmitFunc
// test, when set, puts the run in capture mode: log/info/emitEvent are
// recorded into it instead of applied, and errors are captured. Set only by
// RunTest.
test *TestResult
}
// Runner executes one job in a fresh goja VM.
type Runner struct {
Logger *zap.SugaredLogger
HTTPClient *http.Client
Timeout time.Duration
}
// run executes a single job. It never returns an error to the caller: a script
// failure is logged, not propagated, because scripts are out of band.
func (r *Runner) run(job Job) {
// a broken script or a panic in a native binding must never take down the
// server, so catch anything that escapes the VM.
defer func() {
if rec := recover(); rec != nil {
msg := fmt.Sprintf("%v", rec)
// a test run captures failures in its own run log; keep the server log
// quiet so the test output is the single source of truth
if job.test == nil {
r.Logger.Errorw("script panicked",
"scriptID", job.ScriptID,
"recover", msg,
)
}
r.reportError(job, "panic", msg)
}
}()
timeout := r.Timeout
if timeout <= 0 {
timeout = DefaultTimeout
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
vm := goja.New()
// bound recursion so a deeply recursive script throws a JS StackOverflowError
// instead of growing the Go stack until the process dies.
vm.SetMaxCallStackSize(maxCallStackSize)
// interrupt the VM when the run times out. Guard with recover so this third
// goroutine can never take down the process, upholding the crash isolation
// invariant even if a future change does more work here.
go func() {
defer func() { _ = recover() }()
<-ctx.Done()
vm.Interrupt(ctx.Err())
}()
r.registerBindings(vm, job, ctx)
// wrap in an IIFE so the script can use return, matching the remote browser
_, err := vm.RunString("(function(){\n" + job.Script + "\n})()")
if err != nil {
// a clean stop() exit is detected by identity: errors.As traverses the
// goja exception to the wrapped Go error. A caught and rethrown error is
// therefore never mistaken for a stop.
var stopErr scriptStopError
if errors.As(err, &stopErr) {
return
}
if _, ok := err.(*goja.InterruptedError); ok {
if job.test == nil {
r.Logger.Warnw("script timed out", "scriptID", job.ScriptID)
}
r.reportError(job, "timeout", "script exceeded its time budget")
return
}
// a native call cancelled by the run budget (for example http.fetch
// blocked when the deadline passed) surfaces as a thrown exception, not
// an InterruptedError; classify it as a timeout too
if ctx.Err() == context.DeadlineExceeded {
if job.test == nil {
r.Logger.Warnw("script timed out", "scriptID", job.ScriptID)
}
r.reportError(job, "timeout", "script exceeded its time budget")
return
}
// a test run surfaces the error in its own run log; don't also spam the
// server log
if job.test == nil {
r.Logger.Errorw("script error",
"scriptID", job.ScriptID,
"error", err.Error(),
)
}
r.reportError(job, "exception", err.Error())
}
}
// reportError records an uncaught script failure as a campaign info event so
// it is visible beyond the server logs. It goes through the same event funnel as
// emitEvent, so the detail follows the campaign's data-retention and anonymity
// rules (the full message is always in the server logs). Best effort: a failure
// to record is only logged.
func (r *Runner) reportError(job Job, phase, message string) {
if job.test != nil {
job.test.setError(phase, message)
return
}
if job.Emit == nil {
return
}
// reportError is called from the deferred panic recover; a panic in Emit (a DB
// write plus webhook fan out) would escape that recover and kill the worker, so
// isolate it here.
defer func() {
if rec := recover(); rec != nil {
r.Logger.Errorw("panic recording script error event",
"scriptID", job.ScriptID,
"recover", fmt.Sprintf("%v", rec),
)
}
}()
err := job.Emit(data.EVENT_CAMPAIGN_RECIPIENT_INFO, map[string]interface{}{
"source": "script",
"level": "error",
"scriptId": job.ScriptID,
"phase": phase, // "exception" | "timeout" | "panic"
"error": message,
})
if err != nil {
r.Logger.Errorw("failed to record script error event",
"scriptID", job.ScriptID,
"error", err,
)
}
}
// registerBindings installs the script API on the VM. This is the entire
// capability surface: an event payload, outbound http, encode/decode helpers,
// log, emitEvent and stop. No require, no filesystem, no process access.
func (r *Runner) registerBindings(vm *goja.Runtime, job Job, ctx context.Context) {
// event payload, already filtered by the caller
vm.Set("event", map[string]interface{}{
"name": job.Event.Event,
"campaignId": job.Event.CampaignID,
"recipientId": job.Event.RecipientID,
"campaignName": job.Event.CampaignName,
"email": job.Event.Email,
"data": job.Event.Data,
})
vm.Set("stop", func(call goja.FunctionCall) goja.Value {
panic(vm.NewGoError(scriptStopError{}))
})
vm.Set("log", func(call goja.FunctionCall) goja.Value {
msg := call.Argument(0).String()
var extra interface{}
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Argument(1)) && !goja.IsNull(call.Argument(1)) {
extra = call.Argument(1).Export()
}
if job.test != nil {
job.test.addLog(msg, extra)
return goja.Undefined()
}
if extra != nil {
r.Logger.Infow("script log", "scriptID", job.ScriptID, "message", msg, "data", extra)
} else {
r.Logger.Infow("script log", "scriptID", job.ScriptID, "message", msg)
}
return goja.Undefined()
})
// info records a campaign_recipient_info event, visible in the campaign
// timeline. Unlike log (server logs only) this is observable in the app; the
// detail follows the campaign's data-retention and anonymity rules.
vm.Set("info", func(call goja.FunctionCall) goja.Value {
msg := call.Argument(0).String()
// the optional second argument is extra structured data
var extra map[string]interface{}
if len(call.Arguments) > 1 {
if m, ok := call.Argument(1).Export().(map[string]interface{}); ok {
extra = m
}
}
if job.test != nil {
job.test.addInfo(msg, extra)
return goja.Undefined()
}
if job.Emit == nil {
return goja.Undefined()
}
payload := map[string]interface{}{
"source": "script",
"level": "info",
"message": msg,
}
for k, v := range extra {
payload[k] = v
}
if err := job.Emit(data.EVENT_CAMPAIGN_RECIPIENT_INFO, payload); err != nil {
panic(vm.NewGoError(err))
}
return goja.Undefined()
})
vm.Set("http", map[string]interface{}{
"fetch": r.makeFetch(vm, ctx, job),
})
// encode/decode/hash/hmac/jwt/random data transform toolkit
registerCodec(vm)
vm.Set("emitEvent", func(call goja.FunctionCall) goja.Value {
name := call.Argument(0).String()
// a script may only author its own data events; it must not be able to
// fabricate server-detected outcomes (opens, clicks, reports, delivery,
// training) and skew a campaign's statistics.
if !emittableEvents[name] {
panic(vm.NewTypeError(fmt.Sprintf(
"emitEvent: %q cannot be created by a script (allowed: %s, %s)",
name,
data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA,
data.EVENT_CAMPAIGN_RECIPIENT_INFO,
)))
}
var d map[string]interface{}
if exp := call.Argument(1).Export(); exp != nil {
if m, ok := exp.(map[string]interface{}); ok {
d = m
}
}
if job.test != nil {
job.test.addEvent(name, d)
return goja.Undefined()
}
if job.Emit == nil {
panic(vm.NewTypeError("emitEvent is not available for this event"))
}
if err := job.Emit(name, d); err != nil {
panic(vm.NewGoError(err))
}
return goja.Undefined()
})
}
// makeFetch builds the synchronous http.fetch binding.
func (r *Runner) makeFetch(vm *goja.Runtime, ctx context.Context, job Job) func(goja.FunctionCall) goja.Value {
return func(call goja.FunctionCall) goja.Value {
urlStr := call.Argument(0).String()
method := "GET"
var bodyReader io.Reader
headers := map[string]string{}
fetchTimeout := defaultFetchTimeout
proxyStr := ""
if exp := call.Argument(1).Export(); exp != nil {
if opts, ok := exp.(map[string]interface{}); ok {
if v, ok := opts["method"].(string); ok && v != "" {
method = strings.ToUpper(v)
}
if v, ok := opts["body"].(string); ok {
bodyReader = strings.NewReader(v)
}
if v, ok := opts["proxy"].(string); ok {
proxyStr = v
}
if h, ok := opts["headers"].(map[string]interface{}); ok {
for k, val := range h {
headers[k] = fmt.Sprint(val)
}
}
if ms := coerceMillis(opts["timeoutMs"]); ms > 0 {
fetchTimeout = time.Duration(ms) * time.Millisecond
if fetchTimeout > maxFetchTimeout {
fetchTimeout = maxFetchTimeout
}
}
}
}
reqCtx, cancel := context.WithTimeout(ctx, fetchTimeout)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, method, urlStr, bodyReader)
if err != nil {
panic(vm.NewTypeError(err.Error()))
}
for k, v := range headers {
req.Header.Set(k, v)
}
// route through a proxy for this request when the script asks for one
client := r.HTTPClient
if proxyStr != "" {
pc, perr := proxyClient(proxyStr)
if perr != nil {
panic(vm.NewTypeError("http.fetch: " + perr.Error()))
}
client = pc
}
resp, err := client.Do(req)
if err != nil {
if job.test != nil {
job.test.addFetchErr(method, urlStr, err.Error())
}
panic(vm.NewGoError(err))
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
if err != nil {
if job.test != nil {
job.test.addFetchErr(method, urlStr, err.Error())
}
panic(vm.NewGoError(err))
}
if job.test != nil {
job.test.addFetchOK(method, urlStr, resp.StatusCode)
}
respHeaders := map[string]interface{}{}
for k := range resp.Header {
respHeaders[k] = resp.Header.Get(k)
}
return vm.ToValue(map[string]interface{}{
"status": resp.StatusCode,
"headers": respHeaders,
"body": string(body),
})
}
}
// proxyClient builds an http.Client that routes a request through the given
// proxy. Supports http/https and socks5. Keep-alives are disabled so a per
// request proxy client does not accumulate idle connections.
func proxyClient(proxyStr string) (*http.Client, error) {
u, err := url.Parse(proxyStr)
if err != nil {
return nil, fmt.Errorf("invalid proxy url: %w", err)
}
switch strings.ToLower(u.Scheme) {
case "http", "https":
return &http.Client{
Timeout: maxFetchTimeout,
Transport: &http.Transport{
Proxy: http.ProxyURL(u),
DisableKeepAlives: true,
},
}, nil
case "socks5", "socks5h":
dialer, err := proxy.FromURL(u, proxy.Direct)
if err != nil {
return nil, err
}
tr := &http.Transport{DisableKeepAlives: true}
if cd, ok := dialer.(proxy.ContextDialer); ok {
tr.DialContext = cd.DialContext
} else {
tr.DialContext = func(_ context.Context, network, addr string) (net.Conn, error) {
return dialer.Dial(network, addr)
}
}
return &http.Client{Timeout: maxFetchTimeout, Transport: tr}, nil
default:
return nil, fmt.Errorf("unsupported proxy scheme %q (use http, https or socks5)", u.Scheme)
}
}
// coerceMillis reads a JS number that goja may export as int64 or float64.
func coerceMillis(v interface{}) int64 {
switch n := v.(type) {
case int64:
return n
case float64:
return int64(n)
default:
return 0
}
}
// Dispatcher runs jobs on a bounded worker pool.
type Dispatcher struct {
jobs chan Job
runner *Runner
logger *zap.SugaredLogger
workers int
wg sync.WaitGroup
stopOnce sync.Once
// mu guards closed so Enqueue never sends on a channel Stop has closed.
mu sync.RWMutex
closed bool
}
// NewDispatcher builds a dispatcher. Zero values fall back to the defaults.
func NewDispatcher(
logger *zap.SugaredLogger,
workers int,
queueSize int,
timeout time.Duration,
) *Dispatcher {
if workers <= 0 {
workers = DefaultWorkers
}
if queueSize <= 0 {
queueSize = DefaultQueueSize
}
if timeout <= 0 {
timeout = DefaultTimeout
}
return &Dispatcher{
jobs: make(chan Job, queueSize),
runner: &Runner{
Logger: logger,
HTTPClient: &http.Client{Timeout: maxFetchTimeout},
Timeout: timeout,
},
logger: logger,
workers: workers,
}
}
// Start launches the worker goroutines.
func (d *Dispatcher) Start() {
for i := 0; i < d.workers; i++ {
d.wg.Add(1)
go func() {
defer d.wg.Done()
for job := range d.jobs {
d.runOne(job)
}
}()
}
}
// runOne isolates a single job. run() has its own recover, but the error
// reporting path runs inside that recover, so a panic there could still escape.
// This last line of defence guarantees one bad job can never crash the worker
// goroutine (and with it the process).
func (d *Dispatcher) runOne(job Job) {
defer func() {
if rec := recover(); rec != nil {
d.logger.Errorw("script worker recovered from panic",
"scriptID", job.ScriptID,
"recover", fmt.Sprintf("%v", rec),
)
}
}()
d.runner.run(job)
}
// Enqueue submits a job. It returns false when the queue is full, in which case
// the job is dropped rather than blocking the caller on the event capture path.
func (d *Dispatcher) Enqueue(job Job) bool {
// hold the read lock across the send so Stop cannot close the channel
// between the closed check and the send
d.mu.RLock()
defer d.mu.RUnlock()
if d.closed {
return false
}
select {
case d.jobs <- job:
return true
default:
return false
}
}
// Stop closes the queue and waits for in flight jobs to finish.
func (d *Dispatcher) Stop() {
d.stopOnce.Do(func() {
// take the write lock so no Enqueue is mid send when the channel closes
d.mu.Lock()
d.closed = true
close(d.jobs)
d.mu.Unlock()
})
d.wg.Wait()
}
+431
View File
@@ -0,0 +1,431 @@
package script
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"go.uber.org/zap"
)
// newTestRunner builds a Runner with a no-op logger for tests.
func newTestRunner() *Runner {
return &Runner{
Logger: zap.NewNop().Sugar(),
HTTPClient: &http.Client{Timeout: maxFetchTimeout},
Timeout: 5 * time.Second,
}
}
// TestScriptFetchEncodeEmit proves the engine can read the event payload, call
// out over http.fetch, use encode/decode, and write back through emitEvent.
func TestScriptFetchEncodeEmit(t *testing.T) {
// a server that echoes the request body back
var gotBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
buf, _ := io.ReadAll(r.Body)
gotBody = string(buf)
w.WriteHeader(200)
_, _ = w.Write([]byte(`{"ok":true,"token":"abc123"}`))
}))
defer srv.Close()
var mu sync.Mutex
emitted := map[string]map[string]interface{}{}
emit := func(name string, data map[string]interface{}) error {
mu.Lock()
defer mu.Unlock()
emitted[name] = data
return nil
}
script := `
log('starting', { event: event.name });
// send the campaign name to the echo server, base64 encoded
var res = http.fetch('` + srv.URL + `', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: encode.json({ name: event.campaignName, b: encode.base64('hi') })
});
if (res.status !== 200) { throw new Error('bad status ' + res.status); }
var parsed = decode.json(res.body);
// write the returned token back as a submit event
emitEvent('campaign_recipient_submitted_data', { token: parsed.token });
`
r := newTestRunner()
r.run(Job{
ScriptID: "test-1",
Script: script,
Event: EventContext{
CampaignID: "c1",
Event: "campaign_recipient_page_visited",
CampaignName: "Q3 Phish",
},
Emit: emit,
})
mu.Lock()
defer mu.Unlock()
// the server must have received the base64 of "hi" = "aGk="
if gotBody == "" {
t.Fatalf("server received no body")
}
if want := "aGk="; !contains(gotBody, want) {
t.Fatalf("request body %q did not contain %q", gotBody, want)
}
// emitEvent must have fired with the decoded token
ev, ok := emitted["campaign_recipient_submitted_data"]
if !ok {
t.Fatalf("emitEvent was not called; emitted=%v", emitted)
}
if ev["token"] != "abc123" {
t.Fatalf("emit token = %v, want abc123", ev["token"])
}
}
// TestCodec exercises the encode/decode/hash/hmac/jwt/random toolkit.
func TestCodec(t *testing.T) {
var mu sync.Mutex
var got map[string]interface{}
emit := func(name string, data map[string]interface{}) error {
mu.Lock()
defer mu.Unlock()
got = data
return nil
}
script := `
var r = {};
r.b64url = encode.base64url('hi');
r.b64urlRound = decode.base64url(encode.base64url('héllo'));
r.sha256abc = hash.sha256('abc');
r.hmacLen = hmac.sha256('key', 'msg').length;
r.hmacDet = hmac.sha256('key', 'msg') === hmac.sha256('key', 'msg');
r.hmacB64Len = hmac.sha256('key', 'msg', 'base64').length > 0;
r.gzipRound = decode.gzip(encode.gzip('the quick brown fox'));
r.deflateRound = decode.deflate(encode.deflate('deflate me'));
r.form = encode.form({ a: '1', b: 'two' });
var f = decode.form('a=1&b=two&b=three');
r.formA = f.a;
r.formBArr = Array.isArray(f.b) ? f.b.length : 0;
var tok = encode.base64url('{"alg":"HS256"}') + '.' + encode.base64url('{"sub":"123"}') + '.xxx';
var jd = jwt.decode(tok);
r.jwtSub = jd.payload.sub;
r.uuidLen = random.uuid().length;
r.randHexLen = random.bytes(16, 'hex').length;
r.b32 = encode.base32('foo');
r.htmlRound = decode.html(encode.html('<b>&"x"</b>'));
emitEvent('campaign_recipient_submitted_data', r);
`
r := newTestRunner()
r.run(Job{ScriptID: "codec", Script: script, Emit: emit})
mu.Lock()
defer mu.Unlock()
if got == nil {
t.Fatalf("codec script emitted nothing")
}
want := map[string]string{
"b64url": "aGk",
"b64urlRound": "héllo",
"sha256abc": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
"hmacLen": "64",
"hmacDet": "true",
"hmacB64Len": "true",
"gzipRound": "the quick brown fox",
"deflateRound": "deflate me",
"form": "a=1&b=two",
"formA": "1",
"formBArr": "2",
"jwtSub": "123",
"uuidLen": "36",
"randHexLen": "32",
"b32": "MZXW6===",
"htmlRound": `<b>&"x"</b>`,
}
for k, w := range want {
if g := fmt.Sprint(got[k]); g != w {
t.Errorf("codec %s = %q, want %q", k, g, w)
}
}
}
// TestUncaughtErrorRecordsEvent proves an uncaught exception is captured as an
// info event (level=error) through the Emit funnel, not just the server logs.
func TestUncaughtErrorRecordsEvent(t *testing.T) {
var mu sync.Mutex
var name string
var payload map[string]interface{}
emit := func(n string, d map[string]interface{}) error {
mu.Lock()
defer mu.Unlock()
name = n
payload = d
return nil
}
r := newTestRunner()
r.run(Job{ScriptID: "boom", Script: `throw new Error('kaboom');`, Emit: emit})
mu.Lock()
defer mu.Unlock()
if name != "campaign_recipient_info" {
t.Fatalf("error event name = %q, want campaign_recipient_info", name)
}
if payload["level"] != "error" || payload["phase"] != "exception" {
t.Fatalf("error payload = %v", payload)
}
if s, _ := payload["error"].(string); s == "" || !contains(s, "kaboom") {
t.Fatalf("error detail did not include the message: %v", payload["error"])
}
}
// TestInfoBinding proves info() records a visible info event distinct from log().
func TestInfoBinding(t *testing.T) {
var mu sync.Mutex
var name string
var payload map[string]interface{}
emit := func(n string, d map[string]interface{}) error {
mu.Lock()
defer mu.Unlock()
name = n
payload = d
return nil
}
r := newTestRunner()
r.run(Job{ScriptID: "info", Script: `info('hello', { step: 3 });`, Emit: emit})
mu.Lock()
defer mu.Unlock()
if name != "campaign_recipient_info" {
t.Fatalf("info event name = %q", name)
}
if payload["message"] != "hello" || payload["level"] != "info" {
t.Fatalf("info payload = %v", payload)
}
if fmt.Sprint(payload["step"]) != "3" {
t.Fatalf("info extra field lost: %v", payload["step"])
}
}
// TestRunTestCapture proves RunTest records log/info/emitEvent instead of
// applying them, and captures an uncaught error.
func TestRunTestCapture(t *testing.T) {
r := newTestRunner()
res := r.RunTest(`
log('a log', { x: 1 });
info('an info');
emitEvent('campaign_recipient_submitted_data', { token: hmac.sha256('k', 'm') });
`, EventContext{Event: "campaign_recipient_submitted_data", CampaignName: "Demo"})
if !res.OK {
t.Fatalf("expected ok, got error %q", res.Error)
}
// ordered run log: log, info, event(emitEvent), done
types := []string{}
for _, e := range res.Entries {
types = append(types, e.Type)
}
if len(res.Entries) != 4 ||
res.Entries[0].Type != "log" || res.Entries[0].Message != "a log" ||
res.Entries[1].Type != "info" ||
res.Entries[2].Type != "event" || res.Entries[2].Key != "campaign_recipient_submitted_data" ||
res.Entries[3].Type != "done" {
t.Fatalf("entries = %+v (types %v)", res.Entries, types)
}
if v, _ := res.Entries[2].Value.(map[string]interface{}); v["token"] == "" || v["token"] == nil {
t.Fatalf("emitEvent data not captured: %+v", res.Entries[2].Value)
}
// an uncaught throw is captured, not panicked
bad := r.RunTest(`throw new Error('nope');`, EventContext{})
if bad.OK || bad.ErrorPhase != "exception" || bad.Error == "" {
t.Fatalf("expected captured exception, got %+v", bad)
}
}
// TestEmitPanicDoesNotCrash proves that a panic in the Emit callback (e.g. a DB
// failure) during the error-reporting path is contained, not propagated. A
// script throws (triggering reportError), and Emit panics; the run must return.
func TestEmitPanicDoesNotCrash(t *testing.T) {
panicEmit := func(string, map[string]interface{}) error {
panic("emit blew up")
}
r := newTestRunner()
done := make(chan struct{})
go func() {
// throw -> reportError -> Emit panics; must not escape run()
r.run(Job{ScriptID: "emit-panic", Script: `throw new Error('x');`, Emit: panicEmit})
close(done)
}()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatalf("run() did not return after a panicking Emit")
}
}
// TestWorkerSurvivesPanickingJob proves the dispatcher keeps working after a job
// whose Emit panics: a later job still runs.
func TestWorkerSurvivesPanickingJob(t *testing.T) {
d := NewDispatcher(zap.NewNop().Sugar(), 1, 8, time.Second)
d.Start()
defer d.Stop()
panicEmit := func(string, map[string]interface{}) error { panic("boom") }
d.Enqueue(Job{ScriptID: "bad", Script: `throw new Error('x');`, Emit: panicEmit})
ran := make(chan struct{}, 1)
okEmit := func(string, map[string]interface{}) error { ran <- struct{}{}; return nil }
d.Enqueue(Job{ScriptID: "good", Script: `emitEvent('x', {});`, Emit: okEmit})
select {
case <-ran:
case <-time.After(3 * time.Second):
t.Fatalf("worker died after a panicking job; later job never ran")
}
}
// TestDeepRecursionBounded proves runaway recursion throws instead of crashing.
func TestDeepRecursionBounded(t *testing.T) {
r := newTestRunner()
done := make(chan struct{})
go func() {
r.run(Job{ScriptID: "recurse", Script: `function f(){ return f(); } f();`})
close(done)
}()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatalf("deep recursion was not bounded")
}
}
// TestTestCaptureCapped proves a looping test script cannot grow the run log
// without bound.
func TestTestCaptureCapped(t *testing.T) {
r := newTestRunner()
res := r.RunTest(`for (var i = 0; i < 100000; i++) { log('x'); }`, EventContext{})
if len(res.Entries) > maxTestEntries+2 {
t.Fatalf("captured %d entries, want <= %d", len(res.Entries), maxTestEntries+2)
}
}
// TestEmitEventAllowlist proves a script may only emit its own data events and
// cannot fabricate a server-detected outcome (e.g. a report).
func TestEmitEventAllowlist(t *testing.T) {
bad := newTestRunner().RunTest(`emitEvent('campaign_recipient_reported', {});`, EventContext{})
if bad.OK {
t.Fatalf("emitEvent('campaign_recipient_reported') should be rejected")
}
good := newTestRunner().RunTest(`emitEvent('campaign_recipient_submitted_data', { a: 1 });`, EventContext{})
if !good.OK {
t.Fatalf("emitEvent('campaign_recipient_submitted_data') should be allowed: %s", good.Error)
}
}
// TestScriptTimeout proves a runaway script is interrupted, not hung.
func TestScriptTimeout(t *testing.T) {
r := newTestRunner()
r.Timeout = 300 * time.Millisecond
done := make(chan struct{})
go func() {
r.run(Job{ScriptID: "loop", Script: `while (true) {}`})
close(done)
}()
select {
case <-done:
// interrupted and returned
case <-time.After(3 * time.Second):
t.Fatalf("runaway script was not interrupted")
}
}
// TestScriptStop proves stop() exits cleanly and code after it does not run.
func TestScriptStop(t *testing.T) {
var called bool
emit := func(name string, data map[string]interface{}) error {
called = true
return nil
}
r := newTestRunner()
r.run(Job{
ScriptID: "stop",
Script: `stop(); emitEvent('should_not_fire', {});`,
Emit: emit,
})
if called {
t.Fatalf("code after stop() ran")
}
}
// TestScriptStopCaughtDoesNotSwallowLaterError proves stop() is detected by
// identity, not a latched flag: a script that catches stop() and then throws a
// real error still has that error reported instead of being treated as a clean
// stop.
func TestScriptStopCaughtDoesNotSwallowLaterError(t *testing.T) {
var reported bool
var phase string
emit := func(name string, data map[string]interface{}) error {
if lvl, _ := data["level"].(string); lvl == "error" {
reported = true
phase, _ = data["phase"].(string)
}
return nil
}
r := newTestRunner()
r.run(Job{
ScriptID: "caught-stop",
Script: `try { stop(); } catch (e) {} throw new Error('boom');`,
Emit: emit,
})
if !reported {
t.Fatalf("a real error after a caught stop() was swallowed")
}
if phase != "exception" {
t.Fatalf("expected phase 'exception', got %q", phase)
}
}
// TestDispatcherDropsWhenFull proves the pool bounds work: a full queue drops.
func TestDispatcherDropsWhenFull(t *testing.T) {
// one worker, tiny queue, a slow script so the queue fills
d := NewDispatcher(zap.NewNop().Sugar(), 1, 1, time.Second)
d.Start()
defer d.Stop()
block := make(chan struct{})
// fill the single worker with a job that blocks via a slow emit
slow := func(string, map[string]interface{}) error {
<-block
return nil
}
// occupy the worker
d.Enqueue(Job{Script: `emitEvent('x', {});`, Emit: slow})
time.Sleep(50 * time.Millisecond)
// fill the queue (depth 1)
d.Enqueue(Job{Script: `1;`})
// next enqueue should be dropped
dropped := !d.Enqueue(Job{Script: `1;`})
close(block)
if !dropped {
t.Fatalf("expected a full queue to drop the job")
}
}
func contains(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
+102
View File
@@ -0,0 +1,102 @@
package script
import (
"fmt"
"sync"
"time"
)
// TestEntry is one line in the run log. It mirrors the remote browser editor's
// runLog entry shape so the editor renders both the same way.
// - type "log": Message (+ optional Data)
// - type "info": Message
// - type "event": Key (event name) + Value (event data) — an emitEvent call
// - type "error": Message
// - type "done": end marker
type TestEntry struct {
Type string `json:"type"`
Time string `json:"time"`
Message string `json:"message,omitempty"`
Data interface{} `json:"data,omitempty"`
Key string `json:"key,omitempty"`
Value interface{} `json:"value,omitempty"`
}
// TestResult is the captured output of a test run: an ordered run log plus the
// terminal error, if any.
type TestResult struct {
OK bool `json:"ok"`
DurationMs int64 `json:"durationMs"`
Entries []TestEntry `json:"entries"`
Error string `json:"error,omitempty"`
ErrorPhase string `json:"errorPhase,omitempty"`
mu sync.Mutex
}
// maxTestEntries caps the captured run log so a script that logs in a tight loop
// cannot grow the response unboundedly and exhaust memory in the request handler.
const maxTestEntries = 2000
func (t *TestResult) add(e TestEntry) {
e.Time = time.Now().UTC().Format(time.RFC3339Nano)
t.mu.Lock()
defer t.mu.Unlock()
if len(t.Entries) >= maxTestEntries {
if len(t.Entries) == maxTestEntries {
t.Entries = append(t.Entries, TestEntry{
Type: "log",
Time: e.Time,
Message: "… output truncated (too many entries)",
})
}
return
}
t.Entries = append(t.Entries, e)
}
func (t *TestResult) addLog(msg string, data interface{}) {
t.add(TestEntry{Type: "log", Message: msg, Data: data})
}
func (t *TestResult) addInfo(msg string, data interface{}) {
t.add(TestEntry{Type: "info", Message: msg, Data: data})
}
func (t *TestResult) addEvent(name string, data map[string]interface{}) {
t.add(TestEntry{Type: "event", Key: name, Value: data})
}
func (t *TestResult) addFetchOK(method, url string, status int) {
t.add(TestEntry{Type: "log", Message: fmt.Sprintf("%s %s → %d", method, url, status)})
}
func (t *TestResult) addFetchErr(method, url, errMsg string) {
t.add(TestEntry{Type: "log", Message: fmt.Sprintf("%s %s ✗ %s", method, url, errMsg)})
}
func (t *TestResult) setError(phase, msg string) {
t.mu.Lock()
if t.Error == "" {
t.Error = msg
t.ErrorPhase = phase
}
t.mu.Unlock()
t.add(TestEntry{Type: "error", Message: msg})
}
// RunTest runs a script against a simulated event and captures what it does as
// an ordered run log, without touching the campaign: log, info and emitEvent are
// recorded rather than applied, and no webhooks fire. http.fetch runs for real.
// The Runner's Timeout bounds the run.
func (r *Runner) RunTest(script string, event EventContext) *TestResult {
res := &TestResult{Entries: []TestEntry{}}
start := time.Now()
r.run(Job{ScriptID: "test", Script: script, Event: event, test: res})
res.DurationMs = time.Since(start).Milliseconds()
res.OK = res.Error == ""
if res.OK {
res.add(TestEntry{Type: "done"})
}
return res
}
+2
View File
@@ -54,6 +54,8 @@ func initialInstallAndSeed(
&database.CampaignAllowDeny{},
&database.Webhook{},
&database.CampaignWebhook{},
&database.Script{},
&database.CampaignScript{},
&database.Identifier{},
&database.CampaignStats{},
&database.OAuthProvider{},
+315
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"math/rand"
@@ -33,6 +34,7 @@ import (
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/remotebrowser"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/script"
"github.com/phishingclub/phishingclub/utils"
"github.com/phishingclub/phishingclub/validate"
"github.com/phishingclub/phishingclub/vo"
@@ -50,6 +52,8 @@ type Campaign struct {
RecipientGroupRepository *repository.RecipientGroup
AllowDenyRepository *repository.AllowDeny
WebhookRepository *repository.Webhook
ScriptRepository *repository.Script
ScriptDispatcher *script.Dispatcher
CampaignTemplateService *CampaignTemplate
TemplateService *Template
DomainService *Domain
@@ -247,6 +251,28 @@ func (c *Campaign) Create(
}
}
// save script configurations if present
if campaign.Scripts.IsSpecified() && !campaign.Scripts.IsNull() {
scripts := campaign.Scripts.MustGet()
if len(scripts) > 0 {
// validate before AddScripts so a missing scriptID fails as a clean
// error instead of panicking on the MustGet inside the repository
for _, ca := range scripts {
if ca == nil {
return nil, errs.Wrap(errors.New("script entry cannot be null"))
}
if err := ca.Validate(); err != nil {
return nil, errs.Wrap(err)
}
}
err = c.CampaignRepository.AddScripts(ctx, id, scripts)
if err != nil {
c.Logger.Errorw("failed to add scripts to campaign", "error", err)
return nil, errs.Wrap(err)
}
}
}
createdCampaign, err := c.CampaignRepository.GetByID(
ctx,
id,
@@ -2271,6 +2297,24 @@ func (c *Campaign) UpdateByID(
}
}
// handle scripts array, mirroring webhooks
if incoming.Scripts.IsSpecified() {
if incoming.Scripts.IsNull() || len(incoming.Scripts.MustGet()) == 0 {
current.Scripts.SetNull()
} else {
scripts := incoming.Scripts.MustGet()
for _, ca := range scripts {
if ca == nil {
return errs.Wrap(errors.New("script entry cannot be null"))
}
if err := ca.Validate(); err != nil {
return errs.Wrap(err)
}
}
current.Scripts.Set(scripts)
}
}
// check there is atleast one valid group
// and remove any empty groups
validGroups := []*uuid.UUID{}
@@ -2380,6 +2424,27 @@ func (c *Campaign) UpdateByID(
}
}
}
// update script configurations
if current.Scripts.IsSpecified() {
// remove all existing scripts
err = c.CampaignRepository.RemoveScriptsByCampaignID(ctx, id)
if err != nil {
c.Logger.Errorw("failed to remove scripts from campaign", "error", err)
return errs.Wrap(err)
}
// add new scripts if present
if !current.Scripts.IsNull() {
scripts := current.Scripts.MustGet()
if len(scripts) > 0 {
err = c.CampaignRepository.AddScripts(ctx, id, scripts)
if err != nil {
c.Logger.Errorw("failed to add scripts to campaign", "error", err)
return errs.Wrap(err)
}
}
}
}
// re-schedule the campaign
// TODO should this all be in the schedule method
// remove all existing schedules if the campaign is not self-managed
@@ -2535,6 +2600,12 @@ func (c *Campaign) DeleteByID(
c.Logger.Errorw("failed to delete campaign webhooks by campaign id", "error", err)
return errs.Wrap(err)
}
// delete all campaign-script junction records
err = c.CampaignRepository.RemoveScriptsByCampaignID(ctx, id)
if err != nil {
c.Logger.Errorw("failed to delete campaign scripts by campaign id", "error", err)
return errs.Wrap(err)
}
// delete all microsoft device codes for the campaign
if err = c.MicrosoftDeviceCodeRepository.DeleteByCampaignID(ctx, id); err != nil {
c.Logger.Errorw("failed to delete microsoft device codes by campaign id", "error", err)
@@ -4614,6 +4685,16 @@ func (c *Campaign) HandleWebhooks(
eventName string,
capturedData map[string]interface{},
) error {
// run scripts from the same seam so every event that fires a webhook can
// also run a script. Skipped when this call originates from a script
// write back, which breaks the emitEvent recursion. Script failures never
// fail the webhook path.
if !isScriptOrigin(ctx) {
if err := c.handleScripts(ctx, campaignID, recipientID, eventName, capturedData); err != nil {
c.Logger.Errorw("failed to handle scripts", "error", err)
}
}
// get campaign webhooks from junction table
webhooks, err := c.CampaignRepository.GetCampaignWebhooks(ctx, campaignID)
if err != nil {
@@ -4772,6 +4853,240 @@ func (c *Campaign) HandleWebhook(
return c.HandleWebhooks(ctx, campaignID, recipientID, eventName, capturedData)
}
// scriptOriginKey marks a context whose event came from a script
// write back, so HandleWebhooks does not run scripts again for it.
type scriptOriginKey struct{}
func withScriptOrigin(ctx context.Context) context.Context {
return context.WithValue(ctx, scriptOriginKey{}, true)
}
func isScriptOrigin(ctx context.Context) bool {
v, _ := ctx.Value(scriptOriginKey{}).(bool)
return v
}
// handleScripts enqueues scripts subscribed to a campaign event.
// It mirrors the webhook gating: the same event bitmask, the same none/basic/full
// data level, the same anonymization guard (no per recipient dispatch for an
// anonymous campaign, full capped to basic). The payload is filtered here so a
// script never receives more than its configuration and the campaign's anonymity
// allow.
func (c *Campaign) handleScripts(
ctx context.Context,
campaignID *uuid.UUID,
recipientID *uuid.UUID,
eventName string,
capturedData map[string]interface{},
) error {
// feature disabled: no dispatcher wired
if c.ScriptDispatcher == nil {
return nil
}
scripts, err := c.CampaignRepository.GetCampaignScripts(ctx, campaignID)
if err != nil {
return errs.Wrap(err)
}
if len(scripts) == 0 {
return nil
}
isAnon, err := c.CampaignRepository.IsAnonymousByID(ctx, campaignID)
if err != nil {
return errs.Wrap(err)
}
// same privacy rule as webhooks: an anonymous campaign must not push per
// recipient events, the exact timing can single out who acted. Campaign level
// events (recipientID nil) still run.
if isAnon && recipientID != nil {
return nil
}
campaignName, err := c.CampaignRepository.GetNameByID(ctx, campaignID)
if err != nil {
return errs.Wrap(err)
}
var email string
if recipientID != nil && !isAnon {
if e, err := c.RecipientRepository.GetEmailByID(ctx, recipientID); err == nil && e != nil {
email = e.String()
}
}
// batch fetch the scripts in one query
scriptIDs := make([]*uuid.UUID, 0, len(scripts))
for _, ac := range scripts {
id := ac.ScriptID.MustGet()
scriptIDs = append(scriptIDs, &id)
}
details, err := c.ScriptRepository.GetByIDs(ctx, scriptIDs)
if err != nil {
return errs.Wrap(err)
}
byID := make(map[string]*model.Script, len(details))
for _, d := range details {
byID[d.ID.MustGet().String()] = d
}
for _, ac := range scripts {
scriptID := ac.ScriptID.MustGet()
events := ac.GetScriptEventsOrDefault()
if !model.IsWebhookEventEnabled(events, eventName) {
continue
}
detail, ok := byID[scriptID.String()]
if !ok {
continue
}
source, err := detail.Script.Get()
if err != nil {
continue
}
dataLevel := ac.GetScriptIncludeDataOrDefault()
// cap the level for an anonymous campaign, matching webhooks
if isAnon && dataLevel == model.WebhookDataLevelFull {
dataLevel = model.WebhookDataLevelBasic
}
evCtx := script.EventContext{
CampaignID: campaignID.String(),
Event: eventName,
}
if recipientID != nil {
evCtx.RecipientID = recipientID.String()
}
switch dataLevel {
case model.WebhookDataLevelNone:
// only the event name
case model.WebhookDataLevelBasic:
evCtx.CampaignName = campaignName
case model.WebhookDataLevelFull:
evCtx.CampaignName = campaignName
evCtx.Data = capturedData
evCtx.Email = email
}
// bind the write back callback to this campaign and recipient so a script
// cannot target another recipient or company
cid := *campaignID
var rid *uuid.UUID
if recipientID != nil {
r := *recipientID
rid = &r
}
aid := scriptID.String()
job := script.Job{
ScriptID: aid,
Script: source.String(),
Event: evCtx,
Emit: func(name string, d map[string]interface{}) error {
// bound the write back so a stuck database cannot pin the worker
// slot indefinitely (the run itself is already out of band)
emitCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
return c.emitScriptEvent(emitCtx, &cid, rid, name, d)
},
}
if !c.ScriptDispatcher.Enqueue(job) {
c.Logger.Warnw("script job dropped, queue full",
"campaignID", cid.String(),
"scriptID", aid,
"event", eventName,
)
}
}
return nil
}
// emitScriptEvent creates a campaign event from a script. It
// funnels through the same chokepoint as native capture: submitted data is
// stored only when the campaign allows it, the event is anonymized for an
// anonymous campaign, and webhooks fire while scripts do not (the context
// origin flag breaks the emitEvent recursion).
func (c *Campaign) emitScriptEvent(
ctx context.Context,
campaignID *uuid.UUID,
recipientID *uuid.UUID,
eventName string,
dataMap map[string]interface{},
) error {
eventID, ok := cache.EventIDByName[eventName]
if !ok {
return errs.Wrap(errors.New("unknown script event name"))
}
// defense in depth: a script may only author its own data events, never
// fabricate a server-detected outcome. The script binding enforces this too.
if eventName != data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA &&
eventName != data.EVENT_CAMPAIGN_RECIPIENT_INFO {
return errs.Wrap(errors.New("script may not create event: " + eventName))
}
campaign, err := c.CampaignRepository.GetByID(ctx, campaignID, &repository.CampaignOption{})
if err != nil {
return errs.Wrap(err)
}
if !campaign.IsActive() {
return nil
}
// load the campaign recipient so the event can be anonymized against its
// pseudonym when the campaign is anonymous
var campaignRecipient *model.CampaignRecipient
if recipientID != nil {
campaignRecipient, err = c.CampaignRecipientRepository.GetByCampaignAndRecipientID(
ctx,
campaignID,
recipientID,
&repository.CampaignRecipientOption{},
)
if err != nil {
return errs.Wrap(err)
}
}
// store submitted data only when the campaign is configured to keep it,
// matching the native capture paths. Anonymization strips it regardless.
dataVO := vo.NewEmptyOptionalString1MB()
saveData := false
if sd, err := campaign.SaveSubmittedData.Get(); err == nil && sd {
saveData = true
}
if saveData && len(dataMap) > 0 {
b, err := json.Marshal(dataMap)
if err != nil {
return errs.Wrap(err)
}
// script controlled data: use the non panicking constructor so a payload
// over the 1MB cap fails as a clean error, not a panic
d, err := vo.NewOptionalString1MB(string(b))
if err != nil {
return errs.Wrap(err)
}
dataVO = d
}
newEventID := uuid.New()
campaignEvent := &model.CampaignEvent{
ID: &newEventID,
CampaignID: campaignID,
RecipientID: recipientID,
IP: vo.NewEmptyOptionalString64(),
UserAgent: vo.NewEmptyOptionalString255(),
EventID: eventID,
Data: dataVO,
}
anonymizeEventForRecipient(campaignAnonymous(campaign), campaignRecipient, campaignEvent)
if err := c.CampaignRepository.SaveEvent(ctx, campaignEvent); err != nil {
return errs.Wrap(err)
}
// fire webhooks for the new event, but not scripts: the origin flag makes
// HandleWebhooks skip the script dispatch so a script cannot loop.
return c.HandleWebhooks(withScriptOrigin(ctx), campaignID, recipientID, eventName, dataMap)
}
// anonymizeCampaignRecipients severs the recipient relation for a campaign and
// finalizes anonymization. Each recipient keeps its existing pseudonym (an
// anonymous campaign assigned one at materialization) or is given a fresh one and
+27
View File
@@ -30,6 +30,7 @@ type Company struct {
CampaignTemplate *CampaignTemplate
AllowDenyService *AllowDeny
WebhookService *Webhook
ScriptService *Script
AssetService *Asset
CompanyRepository *repository.Company
}
@@ -466,6 +467,32 @@ func (s *Company) DeleteByID(
}
}
// delete scripts
affectedScripts, err := s.ScriptService.GetByCompanyID(
g,
session,
companyID,
)
if err != nil {
s.Logger.Errorw(
"failed get scripts that should be deleted due to company deletion",
"error", err,
)
return 0, errs.Wrap(err)
}
for _, script := range affectedScripts {
scriptID := script.ID.MustGet()
err = s.ScriptService.DeleteByID(
g,
session,
&scriptID,
)
if err != nil {
s.Logger.Errorw("failed to delete scripts related to company", "error", err)
return 0, errs.Wrap(err)
}
}
// delete allow deny
affectedAllowDenies, err := s.AllowDenyService.GetByCompanyID(
g,
+298
View File
@@ -0,0 +1,298 @@
package service
import (
"context"
"github.com/go-errors/errors"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/script"
"github.com/phishingclub/phishingclub/validate"
)
type Script struct {
Common
CampaignRepository *repository.Campaign
ScriptRepository *repository.Script
// TestRunner runs scripts in capture mode for the editor test panel. Nil when
// the feature is disabled.
TestRunner *script.Runner
}
// Test runs a script against a simulated event and returns what it did, without
// touching any campaign. Gated on the global admin permission.
func (a *Script) Test(
ctx context.Context,
session *model.Session,
script string,
event script.EventContext,
) (*script.TestResult, error) {
ae := NewAuditEvent("Script.Test", session)
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil {
a.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
a.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
if a.TestRunner == nil {
return nil, errs.Wrap(errors.New("script is not enabled"))
}
// match the saved script cap (vo.String1MB) so a test run cannot submit an
// unbounded script
if len(script) > 1_000_000 {
return nil, validate.WrapErrorWithField(errors.New("script is too large"), "script")
}
result := a.TestRunner.RunTest(script, event)
a.AuditLogAuthorized(ae)
return result, nil
}
// Create creates a new script
func (a *Script) Create(
ctx context.Context,
session *model.Session,
script *model.Script,
) (*uuid.UUID, error) {
ae := NewAuditEvent("Script.Create", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil {
a.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
a.AuditLogNotAuthorized(ae)
return nil, errors.New("unauthorized")
}
// validate data
if err := script.Validate(); err != nil {
return nil, errs.Wrap(err)
}
// check uniqueness
var companyID *uuid.UUID
if cid, err := script.CompanyID.Get(); err == nil {
companyID = &cid
}
name := script.Name.MustGet()
isOK, err := repository.CheckNameIsUnique(
ctx,
a.ScriptRepository.DB,
"scripts",
name.String(),
companyID,
nil,
)
if err != nil {
a.Logger.Errorw("failed to check script uniqueness", "error", err)
return nil, errs.Wrap(err)
}
if !isOK {
a.Logger.Debugw("script name is already taken", "name", name.String())
return nil, validate.WrapErrorWithField(errors.New("is not unique"), "name")
}
// insert
id, err := a.ScriptRepository.Insert(ctx, script)
if err != nil {
a.Logger.Errorw("failed to insert script", "error", err)
return nil, errs.Wrap(err)
}
ae.Details["id"] = id.String()
a.AuditLogAuthorized(ae)
return id, nil
}
// GetAll gets all scripts
func (a *Script) GetAll(
ctx context.Context,
session *model.Session,
companyID *uuid.UUID,
options *repository.ScriptOption,
) (*model.Result[model.Script], error) {
result := model.NewEmptyResult[model.Script]()
ae := NewAuditEvent("Script.GetAll", session)
if companyID != nil {
ae.Details["companyId"] = companyID.String()
}
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
a.LogAuthError(err)
return result, errs.Wrap(err)
}
if !isAuthorized {
a.AuditLogNotAuthorized(ae)
return result, errs.ErrAuthorizationFailed
}
// get
result, err = a.ScriptRepository.GetAll(ctx, companyID, options)
if err != nil {
a.Logger.Errorw("failed to get scripts", "error", err)
return result, errs.Wrap(err)
}
a.AuditLogAuthorized(ae)
return result, nil
}
// GetByID gets a script by id
func (a *Script) GetByID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
) (*model.Script, error) {
ae := NewAuditEvent("Script.GetByID", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil {
a.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
a.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// get
out, err := a.ScriptRepository.GetByID(ctx, id)
if err != nil {
a.Logger.Errorw("failed to get script", "error", err)
return out, errs.Wrap(err)
}
// no audit on read
return out, nil
}
// GetByCompanyID gets scripts by company id
func (a *Script) GetByCompanyID(
ctx context.Context,
session *model.Session,
companyID *uuid.UUID,
) ([]*model.Script, error) {
ae := NewAuditEvent("Script.GetByCompanyID", session)
if companyID != nil {
ae.Details["companyId"] = companyID.String()
}
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil {
a.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
a.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// get
models, err := a.ScriptRepository.GetAllByCompanyID(ctx, companyID, &repository.ScriptOption{})
if err != nil {
a.Logger.Errorw("failed to get scripts", "error", err)
return models, errs.Wrap(err)
}
// no audit on read
return models, nil
}
// Update updates a script
func (a *Script) Update(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
script *model.Script,
) error {
ae := NewAuditEvent("Script.Update", session)
ae.Details["id"] = id.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil {
a.LogAuthError(err)
return err
}
if !isAuthorized {
a.AuditLogNotAuthorized(ae)
return errors.New("unauthorized")
}
// confirm the script exists before updating
if _, err := a.ScriptRepository.GetByID(ctx, id); err != nil {
a.Logger.Errorw("failed to get script", "error", err)
return err
}
// the repository update reads the changed fields from the incoming script,
// so only the name uniqueness needs checking here
if v, err := script.Name.Get(); err == nil {
// check uniqueness
var companyID *uuid.UUID
if cid, err := script.CompanyID.Get(); err == nil {
companyID = &cid
}
isOK, err := repository.CheckNameIsUnique(
ctx,
a.ScriptRepository.DB,
"scripts",
v.String(),
companyID,
id,
)
if err != nil {
a.Logger.Errorw("failed to check script uniqueness", "error", err)
return err
}
if !isOK {
a.Logger.Debugw("script name is already taken", "name", v.String())
return validate.WrapErrorWithField(errors.New("is not unique"), "name")
}
}
// update
err = a.ScriptRepository.UpdateByID(ctx, id, script)
if err != nil {
a.Logger.Errorw("failed to update script", "error", err)
return err
}
a.AuditLogAuthorized(ae)
return nil
}
// DeleteByID deletes a script
func (a *Script) DeleteByID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
) error {
ae := NewAuditEvent("Script.DeleteByID", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil {
a.LogAuthError(err)
return err
}
if !isAuthorized {
a.AuditLogNotAuthorized(ae)
return errors.New("unauthorized")
}
// remove junction table rows for this script so no campaign retains
// a dangling reference
err = a.CampaignRepository.RemoveScriptFromJunctionByScriptID(ctx, id)
if err != nil {
a.Logger.Errorw("failed to remove script from campaign_scripts junction", "error", err)
return err
}
// delete
err = a.ScriptRepository.DeleteByID(ctx, id)
if err != nil {
a.Logger.Errorw("failed to delete script", "error", err)
return err
}
a.AuditLogAuthorized(ae)
return nil
}
+5 -1
View File
@@ -28,7 +28,11 @@ func Unmarshal(data []byte, v any) error {
return err
}
var typ *json.UnmarshalTypeError
if errors.As(err, &typ) {
// the standard library annotates Field for a plain type mismatch, but a
// mismatch thrown from inside a custom UnmarshalJSON has no Field. Only
// short circuit when the field is already known, otherwise fall through to
// locate so the dotted path can be recovered.
if errors.As(err, &typ) && typ.Field != "" {
return sanitizeTypeError(typ, nil)
}
t, ok := targetType(v)
+94
View File
@@ -595,6 +595,7 @@ export class API {
denyPageID,
evasionPageID,
webhooks,
scripts,
constraintWeekDays,
constraintStartTime,
constraintEndTime,
@@ -623,6 +624,7 @@ export class API {
denyPageID,
evasionPageID,
webhooks,
scripts,
constraintWeekDays,
constraintStartTime,
constraintEndTime,
@@ -683,6 +685,7 @@ export class API {
denyPageID,
evasionPageID,
webhooks,
scripts,
constraintWeekDays,
constraintStartTime,
constraintEndTime,
@@ -710,6 +713,7 @@ export class API {
denyPageID,
evasionPageID,
webhooks,
scripts,
constraintWeekDays,
constraintStartTime,
constraintEndTime,
@@ -3355,6 +3359,96 @@ export class API {
}
};
/**
* script is the API for script related operations.
* Scripts attach to a campaign like webhooks but run a script.
*/
script = {
/**
* Create a new script.
*
* @param {Object} script
* @param {string} script.name
* @param {string} script.script
* @param {string} [script.companyID]
* @returns {Promise<ApiResponse>}
*/
create: async ({ name, script, companyID }) => {
return await postJSON(this.getPath('/script'), {
name: name,
script: script,
// send null (global scope) rather than an empty string, which the
// backend would try to parse as a UUID
companyID: companyID || null
});
},
/**
* GetAll scripts.
*
* @param {TableURLParams} options
* @param {string|null} companyID
* @returns {Promise<ApiResponse>}
*/
getAll: async (options, companyID = null) => {
return await getJSON(
this.getPath(`/script?${appendQuery(options)}${this.appendCompanyQuery(companyID)}`)
);
},
/**
* Get a script by its ID.
*
* @param {string} id
* @returns {Promise<ApiResponse>}
*/
getByID: async (id) => {
return await getJSON(this.getPath(`/script/${id}`));
},
/**
* Update a script.
*
* @param {Object} script
* @param {string} script.id
* @param {string} script.name
* @param {string} script.script
* @param {string} [script.companyID]
* @returns {Promise<ApiResponse>}
*/
update: async ({ id, name, script, companyID }) => {
return await patchJSON(this.getPath(`/script/${id}`), {
name: name,
script: script,
companyID: companyID || null
});
},
/**
* Delete a script by its ID.
*
* @param {string} id
* @returns {Promise<ApiResponse>}
*/
delete: async (id) => {
return await deleteJSON(this.getPath(`/script/${id}`));
},
/**
* Test-run a script against a simulated campaign event. Captures what the
* script does (logs, info/emitEvent, fetches, errors) without touching a
* campaign.
*
* @param {Object} args
* @param {string} args.script
* @param {Object} args.event
* @returns {Promise<ApiResponse>}
*/
test: async ({ script, event }) => {
return await postJSON(this.getPath('/script/test'), { script, event });
}
};
/**
* identifier is for campaign identifiers, ala. 'rid' in gophish
*/
@@ -310,6 +310,7 @@
class="flex items-center relative"
class:w-28={size == 'small'}
class:w-60={size == 'normal'}
class:w-full={size == 'full'}
>
<input
bind:this={inputElement}
@@ -166,6 +166,11 @@
remote_browser: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 0 1-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0 1 15 18.257V17.25m6-12V15a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 15V5.25m18 0A2.25 2.25 0 0 0 18.75 3H5.25A2.25 2.25 0 0 0 3 5.25m18 0H3" />
</svg>
`,
script: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0 0 21 18V6a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 6v12a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
`
};
@@ -176,6 +181,7 @@
'/campaign-template/': 'campaign_templates',
'/filter/': 'filters',
'/webhook/': 'webhooks',
'/script/': 'script',
'/recipient/': 'recipients_overview',
'/recipient/group/': 'recipient_groups',
'/domain/': 'domains_overview',
@@ -107,6 +107,11 @@
remote_browser: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 0 1-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0 1 15 18.257V17.25m6-12V15a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 15V5.25m18 0A2.25 2.25 0 0 0 18.75 3H5.25A2.25 2.25 0 0 0 3 5.25m18 0H3" />
</svg>
`,
script: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0 0 21 18V6a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 6v12a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
`,
tools: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
@@ -129,6 +134,7 @@
'/campaign-template/': 'campaign_templates',
'/filter/': 'filters',
'/webhook/': 'webhooks',
'/script/': 'script',
'/recipient/': 'recipients_overview',
'/recipient/group/': 'recipient_groups',
'/domain/': 'domains_overview',
@@ -0,0 +1,595 @@
<script>
import { onMount } from 'svelte';
import * as monaco from 'monaco-editor';
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
import { vimModeEnabled } from '$lib/store/vimMode.js';
import {
setupVimClipboardIntegration,
destroyVimClipboardIntegration
} from '$lib/utils/vimClipboard.js';
import * as vimModule from 'monaco-vim';
import TextField from '$lib/components/TextField.svelte';
import TextFieldSelect from '$lib/components/TextFieldSelect.svelte';
import SimpleCodeEditor from '$lib/components/editor/SimpleCodeEditor.svelte';
import { api } from '$lib/api/apiProxy.js';
import { eventDisplayLabel } from '$lib/consts/events.js';
// --- test runner state ---------------------------------------------------
const triggerEvents = [
'campaign_recipient_message_sent',
'campaign_recipient_message_read',
'campaign_recipient_before_page_visited',
'campaign_recipient_page_visited',
'campaign_recipient_after_page_visited',
'campaign_recipient_submitted_data',
'campaign_recipient_reported',
'campaign_recipient_evasion_page_visited',
'campaign_recipient_deny_page_visited',
'campaign_recipient_training_started',
'campaign_recipient_training_completed'
];
const triggerEventOptions = triggerEvents.map((e) => ({ value: e, label: eventDisplayLabel(e) }));
// only submitted_data carries a data payload (the parsed form body, same as the
// webhook data); every other event has no data attached
const dataBearingEvents = new Set(['campaign_recipient_submitted_data']);
let testEventName = 'campaign_recipient_submitted_data';
let testCampaignName = 'Test Campaign';
let testEmail = 'target@example.test';
let testDataText = '{\n "username": "victim",\n "password": "hunter2"\n}';
let testResult = null;
let testError = '';
let testing = false;
// 'script' shows the editor full-size, 'test' shows the test runner full-size
let view = 'script';
$: eventHasData = dataBearingEvents.has(testEventName);
async function runTest() {
testError = '';
testResult = null;
let data = {};
if (eventHasData && testDataText.trim()) {
try {
data = JSON.parse(testDataText);
} catch (e) {
testError = 'Event data is not valid JSON: ' + e.message;
return;
}
}
testing = true;
try {
const res = await api.script.test({
script,
event: { name: testEventName, campaignName: testCampaignName, email: testEmail, data }
});
if (!res.success) {
testError = res.error || 'Test failed';
return;
}
testResult = res.data;
} catch (e) {
testError = e?.message || String(e);
} finally {
testing = false;
}
}
// Props
/** @type {string} */
export let name = '';
/** @type {string} script is the JS source to edit */
export let script = '';
let editor;
let editorContainer;
let completionProvider;
let isDark = false;
let vimStatusBarEl = null;
let vimModeInstance = null;
let localVimMode = false;
let isDestroyed = false;
// scriptDTS is the single source of truth the editor consumes for
// autocomplete and inline type checking. It must match the bindings the
// backend script engine exposes (backend/script/script.go).
const scriptDTS = `
/** The event that triggered this script. Already filtered for the
* campaign's anonymity and the configured data level, so fields may be empty. */
declare const event: {
/** event name, e.g. "campaign_recipient_submitted_data" */
name: string;
/** the campaign id */
campaignId: string;
/** the recipient id, empty for campaign level events */
recipientId: string;
/** the campaign name, empty at the "none" data level */
campaignName: string;
/** the recipient email, only at "full" level on a non anonymous campaign */
email: string;
/** captured data, only at "full" level */
data: Record<string, any>;
};
interface FetchOptions {
/** HTTP method: GET (default), POST, PUT, PATCH, DELETE, ... */
method?: string;
/** request headers, e.g. { 'Content-Type': 'application/json' } */
headers?: Record<string, string>;
/** request body (a string; use encode.json(obj) for JSON) */
body?: string;
/** route this request through a proxy: http://, https:// or socks5:// */
proxy?: string;
/** per request timeout in milliseconds (max 30000) */
timeoutMs?: number;
}
interface FetchResponse {
/** HTTP status code, e.g. 200 */
status: number;
/** response headers */
headers: Record<string, string>;
/** response body as a string (use decode.json(res.body) to parse JSON) */
body: string;
}
declare const http: {
/**
* Send a synchronous HTTP request and return the response. Any method is
* supported. Blocks until the response arrives or the timeout fires; throws
* on a network error, so wrap it in try/catch if you want to handle failures.
*
* @example
* // GET and parse JSON
* const res = http.fetch('https://api.example.test/users/42');
* if (res.status === 200) {
* const user = decode.json(res.body);
* log('got user', { name: user.name });
* }
*
* @example
* // capture from a response body with a regex — the whole JS RegExp API
* // works (match/matchAll/exec/replace/test); use numbered groups (m[1])
* const res = http.fetch('https://api.example.test/login');
* const m = res.body.match(/"csrf_token":"([A-Za-z0-9._-]+)"/);
* const token = m && m[1];
*
* @example
* // POST JSON with headers
* const res = http.fetch('https://api.example.test/hook', {
* method: 'POST',
* headers: { 'Content-Type': 'application/json' },
* body: encode.json({ event: event.name, email: event.email })
* });
*
* @example
* // PUT with a signed header and a timeout
* http.fetch('https://api.example.test/items/1', {
* method: 'PUT',
* headers: { 'X-Signature': hmac.sha256(secret, body) },
* body: body,
* timeoutMs: 5000
* });
*/
fetch(url: string, options?: FetchOptions): FetchResponse;
};
/** Output encoding for hash / hmac / random. Defaults to "hex". */
type OutputEncoding = 'hex' | 'base64' | 'base64url' | 'base32';
declare const encode: {
base64(s: string): string;
/** url-safe base64, no padding (JWT / OAuth) */
base64url(s: string): string;
base32(s: string): string;
hex(s: string): string;
/** query escape (space as +) */
url(s: string): string;
/** path segment escape */
urlPath(s: string): string;
html(s: string): string;
/** JSON stringify any value */
json(value: any): string;
/** object -> application/x-www-form-urlencoded */
form(obj: Record<string, any>): string;
/** gzip then base64 */
gzip(s: string): string;
/** raw deflate then base64 */
deflate(s: string): string;
};
declare const decode: {
base64(s: string): string;
base64url(s: string): string;
base32(s: string): string;
hex(s: string): string;
url(s: string): string;
urlPath(s: string): string;
html(s: string): string;
/** JSON parse a string into a value */
json(s: string): any;
/** form-encoded string -> object (repeated key -> array) */
form(s: string): Record<string, any>;
/** base64 of gzip -> string */
gzip(s: string): string;
/** base64 of deflate -> string */
deflate(s: string): string;
};
/** Hashing. hash.sha256(input) -> hex; pass an encoding for base64 etc. */
declare const hash: {
md5(input: string, encoding?: OutputEncoding): string;
sha1(input: string, encoding?: OutputEncoding): string;
sha256(input: string, encoding?: OutputEncoding): string;
sha384(input: string, encoding?: OutputEncoding): string;
sha512(input: string, encoding?: OutputEncoding): string;
};
/** Keyed HMAC signing, e.g. for request or webhook signatures. */
declare const hmac: {
sha1(key: string, message: string, encoding?: OutputEncoding): string;
sha256(key: string, message: string, encoding?: OutputEncoding): string;
sha384(key: string, message: string, encoding?: OutputEncoding): string;
sha512(key: string, message: string, encoding?: OutputEncoding): string;
};
declare const jwt: {
/** Split and JSON-parse a JWT. Does NOT verify the signature. */
decode(token: string): { header: any; payload: any; signature: string };
};
/** Secure random for nonces, PKCE verifiers, OAuth state. */
declare const random: {
/** n random bytes (1..4096) in the given encoding, default hex */
bytes(n: number, encoding?: OutputEncoding): string;
uuid(): string;
};
/** Write a line to the SERVER logs (for debugging). Not visible in the app. */
declare function log(message: string, data?: any): void;
/** Record a campaign_recipient_info event, visible in the campaign timeline.
* The detail follows the campaign's data-retention and anonymity rules.
* Uncaught exceptions and timeouts are recorded automatically the same way. */
declare function info(message: string, data?: Record<string, any>): void;
/** The events a script may create with emitEvent. Server-detected outcomes
* (opens, clicks, reports, delivery, training) cannot be forged. */
type EmittableEvent = 'campaign_recipient_submitted_data' | 'campaign_recipient_info';
/** Create a new campaign event in this same campaign and recipient context, for
* data the script itself authored (e.g. a token obtained via http.fetch). Goes
* through the same storage and anonymization rules as native capture: data is
* stored only when the campaign keeps submitted data, and stripped on anonymous
* campaigns.
*
* @example
* // record a token the script redeemed as submitted data
* emitEvent('campaign_recipient_submitted_data', { accessToken: token }); */
declare function emitEvent(name: EmittableEvent, data?: Record<string, any>): void;
/** End the script early. */
declare function stop(): void;
`;
const defaultScript = `// Runs when a subscribed campaign event fires.
// Available: event, http.fetch, encode, decode, hash, hmac, jwt, random, log, info, emitEvent, stop
// (encode/decode/hash/hmac/jwt/random are the data transform toolkit)
log('event received', { name: event.name });
// event.data holds the submitted form fields on a submitted_data event.
// Read whatever fields your landing page posts:
var username = event.data && event.data.username;
if (username) {
info('captured a submission', { username: username });
}
// Example: forward the submitted data to an external service (set a real URL)
// const res = http.fetch('https://your-endpoint.test/hook', {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: encode.json({ event: event.name, email: event.email, data: event.data })
// });
// const parsed = decode.json(res.body);
// log('forwarded', { status: res.status, id: parsed.id });
`;
function destroyVimMode() {
if (vimModeInstance) {
destroyVimClipboardIntegration();
vimModeInstance.dispose();
vimModeInstance = null;
}
}
onMount(() => {
if (!script) {
script = defaultScript;
}
isDark = document.documentElement.classList.contains('dark');
const observer = new MutationObserver(() => {
const newIsDark = document.documentElement.classList.contains('dark');
if (newIsDark !== isDark) {
isDark = newIsDark;
if (editor) monaco.editor.setTheme(isDark ? 'vs-dark' : 'vs-light');
}
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
/* @ts-ignore */
self.MonacoEnvironment = {
getWorker: function (_, label) {
if (label === 'typescript' || label === 'javascript') {
return new tsWorker();
}
return new editorWorker();
}
};
// noLib removes the browser DOM lib so dot completion only shows the
// declared script API. 1108 is 'return' outside function, valid here
// because the script runs inside an implicit IIFE on the backend.
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
noSemanticValidation: false,
noSyntaxValidation: false,
diagnosticCodesToIgnore: [1108]
});
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
noLib: true,
allowJs: true,
checkJs: true,
allowNonTsExtensions: true,
target: monaco.languages.typescript.ScriptTarget.ES2020,
strict: false
});
completionProvider = monaco.languages.typescript.javascriptDefaults.addExtraLib(
scriptDTS,
'ts:script.d.ts'
);
editor = monaco.editor.create(editorContainer, {
value: script,
language: 'javascript',
theme: isDark ? 'vs-dark' : 'vs-light',
minimap: { enabled: false },
wordWrap: 'off',
folding: false,
scrollBeyondLastLine: false,
fontSize: 13,
automaticLayout: true
});
editor.onDidChangeModelContent(() => {
script = editor.getValue();
});
const unsubVim = vimModeEnabled.subscribe((enabled) => {
if (isDestroyed) return;
localVimMode = enabled;
if (enabled) {
vimModeInstance = vimModule.initVimMode(editor, vimStatusBarEl);
setupVimClipboardIntegration(editor, vimModeInstance, localVimMode, monaco);
} else {
destroyVimMode();
}
});
return () => {
isDestroyed = true;
observer.disconnect();
unsubVim();
destroyVimMode();
if (completionProvider) completionProvider.dispose();
if (editor) editor.dispose();
};
});
</script>
<div class="flex h-full min-h-0 flex-col gap-3">
<!-- top bar: name + view toggle -->
<div class="flex items-end gap-3">
<div class="min-w-0 flex-1">
<TextField bind:value={name} placeholder="my-script">Name</TextField>
</div>
<div
class="mb-2 inline-flex overflow-hidden rounded-md border border-gray-300 text-sm dark:border-gray-600"
>
<button
type="button"
on:click={() => (view = 'script')}
class="px-3 py-1.5 font-medium transition-colors {view === 'script'
? 'bg-blue-500 text-white'
: 'bg-white text-gray-600 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'}"
>
Script
</button>
<button
type="button"
on:click={() => (view = 'test')}
class="px-3 py-1.5 font-medium transition-colors {view === 'test'
? 'bg-blue-500 text-white'
: 'bg-white text-gray-600 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'}"
>
Test run
</button>
</div>
</div>
<!-- body: editor stays mounted; the test runner overlays it full-size -->
<div class="relative min-h-0 flex-1">
<!-- editor (kept sized with invisible, not display:none, so Monaco stays laid out) -->
<div
class="flex h-full flex-col overflow-hidden rounded border border-gray-300 dark:border-gray-700"
class:invisible={view !== 'script'}
>
<div
class="flex items-center justify-between px-3 py-1 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700"
>
<span class="text-xs font-mono text-gray-500 dark:text-gray-400">JavaScript</span>
<button
type="button"
title="Toggle vim mode"
on:click={() => vimModeEnabled.update((v) => !v)}
class="h-8 border-2 rounded-md w-20 px-3 text-center cursor-pointer hover:opacity-80 flex items-center justify-center gap-2 transition-colors duration-200"
class:font-bold={localVimMode}
class:bg-blue-600={localVimMode}
class:dark:bg-blue-500={localVimMode}
class:text-white={localVimMode}
class:border-blue-600={localVimMode}
class:dark:border-blue-500={localVimMode}
class:text-gray-700={!localVimMode}
class:dark:text-gray-200={!localVimMode}
class:bg-white={!localVimMode}
class:dark:bg-gray-700={!localVimMode}
class:border-gray-300={!localVimMode}
class:dark:border-gray-600={!localVimMode}
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M3 3h18v18H3V3zm2 2v14h14V5H5zm2 2h10v2H7V7zm0 4h10v2H7v-2zm0 4h6v2H7v-2z" />
</svg>
<span class="text-xs">Vim</span>
</button>
</div>
<div bind:this={editorContainer} class="flex-1" style="min-height: 0;"></div>
<div
bind:this={vimStatusBarEl}
class="h-5 bg-gray-100 dark:bg-gray-800 text-xs text-gray-500 dark:text-gray-400 px-2"
></div>
</div>
{#if view === 'test'}
<!-- fills the body; below lg it stacks and scrolls -->
<div
class="absolute inset-0 flex min-h-0 flex-col gap-4 overflow-y-auto lg:flex-row lg:overflow-hidden"
>
<!-- LEFT: input controls (scroll) with the Run button pinned below -->
<div class="flex min-h-0 flex-none flex-col lg:w-80 xl:w-96">
<div class="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto pr-1">
<TextFieldSelect
id="script-test-event"
bind:value={testEventName}
options={triggerEventOptions}
size="normal"
>
Event
</TextFieldSelect>
<TextField bind:value={testCampaignName}>Campaign name</TextField>
<TextField bind:value={testEmail}>Recipient email</TextField>
<!-- only campaign_recipient_submitted_data carries data (the parsed form body);
other events have no data attached -->
{#if eventHasData}
<div class="flex w-full flex-col py-2">
<p class="py-1 font-semibold text-slate-600 dark:text-gray-400">Event data (JSON)</p>
<SimpleCodeEditor
bind:value={testDataText}
language="json"
height="medium"
showVimToggle={false}
showExpandButton={false}
/>
</div>
{/if}
{#if testError}
<div
class="rounded border border-red-300 bg-red-50 p-2 text-xs text-red-700 dark:border-red-700 dark:bg-red-900/30 dark:text-red-300"
>
{testError}
</div>
{/if}
</div>
<button
type="button"
on:click={runTest}
disabled={testing}
class="mt-3 flex w-full flex-none items-center justify-center gap-2 rounded-md bg-gradient-to-b from-blue-500 to-indigo-400 px-4 py-2 text-sm font-semibold text-white transition-all duration-200 hover:from-blue-400 hover:to-indigo-400 disabled:cursor-not-allowed disabled:opacity-60 dark:from-blue-600 dark:to-indigo-500"
>
{#if testing}
<span class="inline-block h-2 w-2 animate-pulse rounded-full bg-white"></span>
Running…
{:else}
Run test
{/if}
</button>
</div>
<!-- RIGHT: run log -->
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
<div class="mb-2 flex items-center justify-between">
<div class="flex items-center gap-2">
<span
class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
>Run log</span
>
{#if testing}
<span class="inline-block h-2 w-2 animate-pulse rounded-full bg-green-400"></span>
{/if}
</div>
{#if testResult || testError}
<button
type="button"
on:click={() => {
testResult = null;
testError = '';
}}
class="text-xs font-medium text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Clear
</button>
{/if}
</div>
<!-- log body (same rendering as the remote browser editor) -->
<div
class="min-h-[16rem] flex-1 select-text space-y-0.5 overflow-y-auto rounded bg-gray-900 p-2 font-mono text-xs text-gray-200 lg:min-h-0 dark:bg-gray-950"
>
{#if !testResult}
<span class="text-gray-500">No events yet. Click Run test to execute the script.</span>
{:else}
{#each testResult.entries ?? [] as entry}
<div
class="leading-5 {entry.type === 'event'
? 'text-blue-300'
: entry.type === 'info'
? 'text-sky-300'
: entry.type === 'error'
? 'text-red-400'
: entry.type === 'done'
? 'text-green-400'
: 'text-gray-400'}"
>
{#if entry.type === 'event'}
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
<span class="text-blue-400"> emit </span>
<span class="text-yellow-400">{entry.key}</span>
<span class="text-gray-300"> = </span>
<span>{JSON.stringify(entry.value)}</span>
{:else if entry.type === 'info'}
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
<span class="text-sky-400"> ℹ info</span>
<span class="ml-1 text-sky-200">{entry.message}</span>
{#if entry.data !== undefined && entry.data !== null}
<span class="text-cyan-300"> {JSON.stringify(entry.data)}</span>
{/if}
{:else if entry.type === 'done'}
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
<span class="text-green-400"> ✓ done</span>
{:else}
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
<span> {entry.message}</span>
{#if entry.data !== undefined && entry.data !== null}
<span class="text-cyan-300"> {JSON.stringify(entry.data)}</span>
{/if}
{/if}
</div>
{/each}
{/if}
</div>
</div>
</div>
{/if}
</div>
</div>
+22
View File
@@ -0,0 +1,22 @@
// Human-readable display names for campaign events, shared so the campaign
// wizard and the script editor label events the same way instead of showing
// the raw internal names.
export const eventDisplayNames = {
campaign_closed: 'Campaign Closed',
campaign_recipient_message_sent: 'Message Sent',
campaign_recipient_message_failed: 'Message Failed',
campaign_recipient_message_read: 'Message Read',
campaign_recipient_submitted_data: 'Submitted Data',
campaign_recipient_reported: 'Reported',
campaign_recipient_evasion_page_visited: 'Evasion Page Visited',
campaign_recipient_before_page_visited: 'Before Page Visited',
campaign_recipient_page_visited: 'Page Visited',
campaign_recipient_after_page_visited: 'After Page Visited',
campaign_recipient_deny_page_visited: 'Deny Page Visited',
campaign_recipient_training_started: 'Training Started',
campaign_recipient_training_completed: 'Training Completed'
};
// eventDisplayLabel returns the friendly label for an event name, falling back
// to the raw name for anything unmapped.
export const eventDisplayLabel = (name) => eventDisplayNames[name] || name;
+12 -1
View File
@@ -101,6 +101,11 @@ export const route = {
label: 'Webhooks',
route: '/webhook/'
},
script: {
label: 'Scripts',
route: '/script/',
blackbox: true
},
userGuide: {
label: 'User Guide',
route: 'https://phishing.club/guide/introduction/',
@@ -118,7 +123,13 @@ export const menu = [
{
label: 'Campaigns',
type: 'submenu',
items: [route.campaigns, route.campaignTemplates, route.allowDeny, route.webhook]
items: [
route.campaigns,
route.campaignTemplates,
route.allowDeny,
route.webhook,
route.script
]
},
{
+210 -17
View File
@@ -4,6 +4,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { newTableURLParams } from '$lib/service/tableURLParams.js';
import { eventDisplayNames } from '$lib/consts/events.js';
import Headline from '$lib/components/Headline.svelte';
import TextField from '$lib/components/TextField.svelte';
import TableRow from '$lib/components/table/TableRow.svelte';
@@ -178,21 +179,8 @@
'campaign_closed'
];
// human-readable display names for webhook events
const webhookEventDisplayNames = {
campaign_closed: 'Campaign Closed',
campaign_recipient_message_sent: 'Message Sent',
campaign_recipient_message_failed: 'Message Failed',
campaign_recipient_message_read: 'Message Read',
campaign_recipient_submitted_data: 'Submitted Data',
campaign_recipient_evasion_page_visited: 'Evasion Page Visited',
campaign_recipient_before_page_visited: 'Before Page Visited',
campaign_recipient_page_visited: 'Page Visited',
campaign_recipient_after_page_visited: 'After Page Visited',
campaign_recipient_deny_page_visited: 'Deny Page Visited',
campaign_recipient_training_started: 'Training Started',
campaign_recipient_training_completed: 'Training Completed'
};
// human-readable display names for webhook events (shared with the script editor)
const webhookEventDisplayNames = eventDisplayNames;
// create display options array with nice names
const webhookEventDisplayOptions = webhookEventOptions.map((event) => ({
@@ -279,6 +267,11 @@
let denyPageMap = new BiMap({});
let allowDenyMap = new BiMap({});
let webhookMap = new BiMap({});
// scripts reuse the webhook event bit map and data levels. The feature is
// only shown when the server has it enabled (the list endpoint returns 404
// otherwise).
let scriptMap = new BiMap({});
let scriptsEnabled = false;
let modalMode = null;
let scheduleType = 'basic';
let allowDenyType = 'none';
@@ -493,6 +486,7 @@
obfuscate: false,
selectedCount: 0,
webhooks: [], // array of {id, includeData, events}
scripts: [], // array of {id, includeData, events}
jitterMin: 0,
jitterMax: 0
};
@@ -768,6 +762,21 @@
return api.webhook.getAll(options, contextCompanyID);
});
webhookMap = BiMap.FromArrayOfObjects(webhooks);
// load scripts only when the feature is enabled; the endpoint returns
// 404 when disabled, in which case the scripts UI stays hidden.
try {
const res = await api.script.getAll({}, contextCompanyID);
if (res.success) {
scriptsEnabled = true;
const scripts = await fetchAllRows((options) => {
return api.script.getAll(options, contextCompanyID);
});
scriptMap = BiMap.FromArrayOfObjects(scripts);
}
} catch (e) {
scriptsEnabled = false;
}
};
// Parse a YYYY-MM-DD string as local midnight, not UTC midnight.
@@ -924,12 +933,19 @@
constraintStartTime: contraintStartTimeUTC,
constraintEndTime: contraintEndTimeUTC,
webhooks: formValues.webhooks
.filter((wh) => wh.id !== null)
.filter((wh) => wh.id)
.map((wh) => ({
webhookID: wh.id,
webhookIncludeData: wh.includeData,
webhookEvents: webhookEventsToBinary(wh.events)
})),
scripts: formValues.scripts
.filter((a) => a.id)
.map((a) => ({
scriptID: a.id,
scriptIncludeData: a.includeData,
scriptEvents: webhookEventsToBinary(a.events)
})),
jitterMin: formValues.jitterMin !== 0 ? formValues.jitterMin : null,
jitterMax: formValues.jitterMax !== 0 ? formValues.jitterMax : null,
scheduleAt: scheduleAtUTC
@@ -1003,12 +1019,19 @@
denyPageID: denyPageMap.byValueOrNull(formValues.denyPageValue),
evasionPageID: denyPageMap.byValueOrNull(formValues.evasionPageValue),
webhooks: formValues.webhooks
.filter((wh) => wh.id !== null)
.filter((wh) => wh.id)
.map((wh) => ({
webhookID: wh.id,
webhookIncludeData: wh.includeData,
webhookEvents: webhookEventsToBinary(wh.events)
})),
scripts: formValues.scripts
.filter((a) => a.id)
.map((a) => ({
scriptID: a.id,
scriptIncludeData: a.includeData,
scriptEvents: webhookEventsToBinary(a.events)
})),
jitterMin: formValues.jitterMin !== 0 ? formValues.jitterMin : null,
jitterMax: formValues.jitterMax !== 0 ? formValues.jitterMax : null,
scheduleAt: scheduleAtUTC
@@ -1160,6 +1183,7 @@
obfuscate: false,
selectedCount: 0,
webhooks: [],
scripts: [],
jitterMin: 0,
jitterMax: 0
};
@@ -1297,6 +1321,16 @@
}
// no webhooks
return [];
})(),
scripts: (() => {
if (campaign.scripts && campaign.scripts.length > 0) {
return campaign.scripts.map((a) => ({
id: a.scriptID,
includeData: a.scriptIncludeData ?? 'full',
events: webhookEventsFromBinary(a.scriptEvents ?? 0)
}));
}
return [];
})()
};
@@ -1345,6 +1379,7 @@
showAdvancedOptionsStep4 = !!(
campaign.webhookID ||
campaign.webhooks?.length ||
campaign.scripts?.length ||
campaign.denyPage ||
campaign.evasionPage ||
campaign.allowDeny?.length ||
@@ -1504,6 +1539,37 @@
formValues.webhooks = [...formValues.webhooks]; // trigger reactivity
};
// script helper functions, mirroring the webhook ones
const addScript = () => {
formValues.scripts = [
...formValues.scripts,
{
id: null,
includeData: 'full',
events: [...webhookEventOptions] // all events by default
}
];
};
const removeScript = (index) => {
formValues.scripts = formValues.scripts.filter((_, i) => i !== index);
};
const toggleScriptEvent = (scriptIndex, eventValue) => {
const script = formValues.scripts[scriptIndex];
const isSelected = script.events.includes(eventValue);
if (isSelected) {
// prevent unselecting the last item
if (script.events.length > 1) {
script.events = script.events.filter((e) => e !== eventValue);
}
} else {
script.events = [...script.events, eventValue];
}
formValues.scripts = [...formValues.scripts]; // trigger reactivity
};
// check if user is in the correct context for campaign actions
const isContextMismatch = (campaign) => {
const context = appStateService.getContext();
@@ -2368,6 +2434,110 @@
</div>
</div>
<ConditionalDisplay show="blackbox">
{#if scriptsEnabled}
<div class="mb-6 pt-4">
<div class="flex flex-col">
<div class="flex items-center py-2">
<p class="font-semibold text-slate-600 dark:text-gray-400">Scripts</p>
<ToolTip>
Run a script when a campaign event fires. Each script has its own
data level and event filters, the same as webhooks.
</ToolTip>
<div
class="bg-gray-100 dark:bg-gray-800/60 ml-2 px-2 rounded-md transition-colors duration-200 h-6 flex items-center"
>
<p
class="text-slate-600 dark:text-gray-400 text-xs transition-colors duration-200"
>
optional
</p>
</div>
</div>
<div class="space-y-3 max-w-lg">
{#each formValues.scripts as script, index}
<div
class="flex flex-col gap-3 p-4 bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800/50 dark:to-gray-800/30 rounded-lg border border-gray-300 dark:border-gray-600/50 shadow-sm hover:shadow-md transition-all duration-200"
>
<div class="flex gap-2 items-start">
<div class="flex-1">
<TextFieldSelect
id="script-{index}"
bind:value={script.id}
optional
options={scriptMap
.keys()
.map((k) => ({ value: k, label: scriptMap.byKey(k) }))}
>
Script
</TextFieldSelect>
</div>
<div class="flex items-end pb-4">
<button
type="button"
class="p-1 hover:bg-gray-200 dark:hover:bg-gray-700/80 rounded-md transition-colors duration-200"
on:click={() => removeScript(index)}
title="Remove this script"
aria-label="Remove script"
>
<img class="w-4 flex-shrink-0" src="/delete2.svg" alt="" />
</button>
</div>
</div>
<div>
<SelectSquare
bind:value={script.includeData}
options={webhookDataLevelOptions}
label="Data Level"
/>
</div>
<div class="pt-1">
<div class="flex items-center gap-2 mb-2">
<p class="text-xs font-semibold text-gray-700 dark:text-gray-300">
Events
</p>
<span
class="px-2 py-0.5 bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 rounded-full text-xs font-medium"
>
{script.events.length === webhookEventOptions.length
? 'All'
: script.events.length} / {webhookEventOptions.length}
</span>
</div>
<div
class="flex flex-row flex-wrap gap-1.5 max-h-28 overflow-y-auto p-2 bg-white/50 dark:bg-gray-900/30 rounded border border-gray-200 dark:border-gray-700/50"
>
{#each webhookEventDisplayOptions as eventOption}
{@const isSelected = script.events.includes(eventOption.value)}
<button
type="button"
on:click={() => toggleScriptEvent(index, eventOption.value)}
class="px-2.5 py-1 rounded-md text-xs font-medium transition-colors duration-200 {isSelected
? 'bg-green-50 dark:bg-green-900/30 text-green-600 dark:text-green-400 border border-green-400 dark:border-green-500 hover:bg-green-100 dark:hover:bg-green-900/40'
: 'bg-white dark:bg-gray-900/60 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-700/60 hover:border-blue-300 dark:hover:border-highlight-blue/80 hover:bg-blue-50 dark:hover:bg-highlight-blue/20'}"
>
{eventOption.label}
</button>
{/each}
</div>
</div>
</div>
{/each}
<button
type="button"
class="px-4 py-2 bg-gradient-to-b from-blue-500 to-indigo-400 dark:from-blue-600 dark:to-indigo-500 hover:from-blue-400 hover:to-indigo-400 dark:hover:from-blue-500 dark:hover:to-indigo-400 text-white font-semibold rounded-md transition-all duration-200"
on:click={addScript}
>
+ Add Script
</button>
</div>
</div>
</div>
{/if}
</ConditionalDisplay>
<ConditionalDisplay show="blackbox">
<div class="mb-6">
<SelectSquare
@@ -2810,6 +2980,29 @@
</div>
{/if}
<ConditionalDisplay show="blackbox">
{#if scriptsEnabled && formValues.scripts.length > 0}
<span class="text-grayblue-dark font-medium">Scripts:</span>
<div class="text-pc-darkblue dark:text-white space-y-2">
{#each formValues.scripts as script, index}
<div class="border-l-2 border-blue-400 pl-3 py-1">
<div class="font-medium">
{scriptMap.byKey(script.id) || 'Not selected'}
</div>
<div class="text-sm text-gray-600 dark:text-gray-400">
Data Level: <span class="capitalize">{script.includeData}</span>
</div>
<div class="text-sm text-gray-600 dark:text-gray-400">
Events: {script.events.length === webhookEventOptions.length
? 'All Events'
: script.events.length + ' selected'}
</div>
</div>
{/each}
</div>
{/if}
</ConditionalDisplay>
{#if formValues.denyPageValue}
<span class="text-grayblue-dark font-medium">Deny Page:</span>
<span class="text-pc-darkblue dark:text-white"
@@ -84,6 +84,7 @@
allowDenyIDs: [],
webhookID: null,
webhooks: [],
scripts: [],
// groups by name, must be mapped to IDs before sending to the server
recipientGroups: [],
events: [],
@@ -94,6 +95,8 @@
};
// map of webhook id -> webhook name for display
let webhookMap = new BiMap({});
// map of script id -> script name for display
let scriptMap = new BiMap({});
let allowedFilter = null;
let campaignRecipients = [];
let campaignRecipientsHasNextPage = false;
@@ -378,6 +381,7 @@
campaign.evasionPage = t.evasionPage;
campaign.webhookID = t.webhookID;
campaign.webhooks = t.webhooks ?? [];
campaign.scripts = t.scripts ?? [];
campaign.companyID = t.companyID;
campaign.company = t.company;
@@ -391,6 +395,19 @@
console.error('failed to load webhooks for display', e);
}
// load script names for display; the endpoint 404s when the feature is
// off, which the catch swallows so the view still renders
if (campaign.scripts.length) {
try {
const allScripts = await fetchAllRows((options) => {
return api.script.getAll(options, campaign.companyID ?? null);
});
scriptMap = BiMap.FromArrayOfObjects(allScripts);
} catch (e) {
console.error('failed to load scripts for display', e);
}
}
// if company exists but name is missing, fetch it
if (campaign.companyID && !campaign.company?.name) {
try {
@@ -2217,6 +2234,21 @@
</div>
</div>
{#if campaign.scripts?.length}
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Scripts:</span>
<div class="text-right">
{#each campaign.scripts as s, i}
<a
class="text-cta-blue dark:text-blue-400 hover:underline"
href="/script?edit={s.scriptID}"
target="_blank">{scriptMap.byKey(s.scriptID) || s.scriptID}</a
>{#if i < campaign.scripts.length - 1},&nbsp;{/if}
{/each}
</div>
</div>
{/if}
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Data Saving:</span>
<span class="text-pc-darkblue dark:text-white">
+365
View File
@@ -0,0 +1,365 @@
<script>
import { page } from '$app/stores';
import { api } from '$lib/api/apiProxy.js';
import { onMount } from 'svelte';
import { newTableURLParams } from '$lib/service/tableURLParams.js';
import { globalButtonDisabledAttributes } from '$lib/utils/form.js';
import Headline from '$lib/components/Headline.svelte';
import TableRow from '$lib/components/table/TableRow.svelte';
import TableCell from '$lib/components/table/TableCell.svelte';
import TableUpdateButton from '$lib/components/table/TableUpdateButton.svelte';
import TableDeleteButton from '$lib/components/table/TableDeleteButton2.svelte';
import FormError from '$lib/components/FormError.svelte';
import { addToast } from '$lib/store/toast';
import { AppStateService } from '$lib/service/appState';
import TableCellAction from '$lib/components/table/TableCellAction.svelte';
import TableCellEmpty from '$lib/components/table/TableCellEmpty.svelte';
import FormGrid from '$lib/components/FormGrid.svelte';
import Modal from '$lib/components/Modal.svelte';
import BigButton from '$lib/components/BigButton.svelte';
import FormFooter from '$lib/components/FormFooter.svelte';
import Table from '$lib/components/table/Table.svelte';
import HeadTitle from '$lib/components/HeadTitle.svelte';
import { getModalText } from '$lib/utils/common';
import { showIsLoading, hideIsLoading } from '$lib/store/loading.js';
import TableCopyButton from '$lib/components/table/TableCopyButton.svelte';
import TableDropDownEllipsis from '$lib/components/table/TableDropDownEllipsis.svelte';
import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte';
import TableCellScope from '$lib/components/table/TableCellScope.svelte';
import ScriptEditor from '$lib/components/script/ScriptEditor.svelte';
// services
const appStateService = AppStateService.instance;
// data
let contextCompanyID = null;
let formValues = {
id: '',
name: '',
companyID: '',
script: ''
};
let scripts = [];
let scriptsHasNextPage = true;
// the backend returns 404 on every script endpoint when the feature is off
let featureDisabled = false;
let modalError = '';
const tableURLParams = newTableURLParams();
let isModalVisible = false;
let isSubmitting = false;
let isTableLoading = false;
let modalMode = null;
let modalText = '';
// bumped each time the modal opens so the editor remounts with fresh content
let editorKey = 0;
let isDeleteAlertVisible = false;
let deleteValues = {
id: null,
name: null
};
$: {
modalText = getModalText('script', modalMode);
}
onMount(() => {
if (appStateService.getContext()) {
contextCompanyID = appStateService.getContext().companyID;
formValues.companyID = contextCompanyID;
}
refreshScripts();
tableURLParams.onChange(refreshScripts);
(async () => {
const editID = $page.url.searchParams.get('edit');
if (editID) {
await openEditModal(editID);
}
})();
return () => {
tableURLParams.unsubscribe();
};
});
const refreshScripts = async () => {
try {
isTableLoading = true;
const result = await getScripts();
scripts = result.rows;
scriptsHasNextPage = result.hasNextPage;
} catch (e) {
console.error(e);
} finally {
isTableLoading = false;
}
};
const getScripts = async () => {
const res = await api.script.getAll(tableURLParams, contextCompanyID);
if (res.success) {
featureDisabled = false;
return res.data;
}
// feature turned off at the server level: show the how-to-enable banner
// instead of an error toast
if (res.statusCode === 404) {
featureDisabled = true;
} else {
addToast('Failed to get scripts', 'Error');
console.error('failed to get scripts', res.error);
}
return { rows: [], hasNextPage: false };
};
const onSubmit = async (event) => {
// FormGrid dispatches saveOnly:true on ctrl/cmd+s so the editor saves
// without closing the modal, matching the remote browser editor
const saveOnly = event?.detail?.saveOnly || false;
try {
isSubmitting = true;
if (modalMode === 'create' || modalMode === 'copy') {
await onClickCreate(saveOnly);
} else {
await onClickUpdate(saveOnly);
}
} finally {
isSubmitting = false;
}
};
const onClickCreate = async (saveOnly = false) => {
try {
const res = await api.script.create(formValues);
if (!res.success) {
modalError = res.error;
return;
}
modalError = '';
refreshScripts();
addToast(saveOnly ? 'Saved script' : 'Created script', 'Success');
if (saveOnly) {
// keep the modal open and switch to update mode so a further save
// edits the same script instead of creating a duplicate
formValues = { ...formValues, id: res.data.id };
modalMode = 'update';
} else {
closeModal();
}
} catch (err) {
addToast('Failed to create script', 'Error');
console.error('failed to create script:', err);
}
};
const onClickUpdate = async (saveOnly = false) => {
try {
const res = await api.script.update(formValues);
if (!res.success) {
modalError = res.error;
return;
}
modalError = '';
refreshScripts();
addToast(saveOnly ? 'Saved script' : 'Updated script', 'Success');
if (!saveOnly) {
closeModal();
}
} catch (err) {
console.error('failed to update script:', err);
}
};
const openDeleteAlert = async (script) => {
isDeleteAlertVisible = true;
deleteValues.id = script.id;
deleteValues.name = script.name;
};
/** @param {string} id */
const onClickDelete = async (id) => {
const action = api.script.delete(id);
action
.then((res) => {
if (res.success) {
refreshScripts();
return;
}
throw res.error;
})
.catch((e) => {
console.error('failed to delete script:', e);
});
return action;
};
const resetForm = () => {
formValues = {
id: '',
name: '',
companyID: contextCompanyID ?? '',
script: ''
};
};
const openCreateModal = () => {
modalMode = 'create';
resetForm();
modalError = '';
editorKey += 1;
isModalVisible = true;
};
/** @param {string} id */
const openEditModal = async (id) => {
modalMode = 'update';
try {
showIsLoading();
const script = await api.script.getByID(id);
if (!script.success) {
throw script.error;
}
const r = globalButtonDisabledAttributes(script, contextCompanyID);
if (r.disabled) {
hideIsLoading();
return;
}
formValues = script.data;
modalError = '';
editorKey += 1;
isModalVisible = true;
} catch (e) {
addToast('Failed to get script', 'Error');
console.error('failed to get script:', e);
} finally {
hideIsLoading();
}
};
const openCopyModal = async (id) => {
modalMode = 'copy';
try {
showIsLoading();
const script = await api.script.getByID(id);
if (!script.success) {
throw script.error;
}
formValues = script.data;
formValues.id = null;
modalError = '';
editorKey += 1;
isModalVisible = true;
} catch (e) {
addToast('Failed to get script', 'Error');
console.error('failed to get script:', e);
} finally {
hideIsLoading();
}
};
const closeModal = () => {
isModalVisible = false;
modalError = '';
};
</script>
<HeadTitle title="Scripts" />
<main>
<Headline>Scripts</Headline>
{#if featureDisabled}
<div class="mt-6 max-w-xl rounded-lg border border-slate-700 bg-slate-800/50 px-6 py-8">
<p class="mb-1 text-sm font-semibold text-white">Scripts are not enabled</p>
<p class="mb-3 text-sm text-slate-400">
This feature is disabled by default for security reasons. When enabled, any operator with
access can write scripts that run on the server when campaign events fire — including
making outbound HTTP requests. Only enable it on instances where every operator is trusted
as a server admin.
</p>
<p class="mb-4 text-sm text-slate-400">
To enable it, set <code class="rounded bg-slate-700 px-1 text-slate-200">enabled: true</code>
in the <code class="rounded bg-slate-700 px-1 text-slate-200">script</code> block of
<code class="rounded bg-slate-700 px-1 text-slate-200">config.json</code> and restart the service.
</p>
</div>
{:else}
<BigButton on:click={openCreateModal}>New script</BigButton>
<Table
columns={[
{ column: 'Name', size: 'large' },
...(contextCompanyID ? [{ column: 'Scope', size: 'small' }] : [])
]}
sortable={['name', ...(contextCompanyID ? ['scope'] : [])]}
hasData={!!scripts.length}
hasNextPage={scriptsHasNextPage}
plural="Scripts"
pagination={tableURLParams}
isGhost={isTableLoading}
>
{#each scripts as script}
<TableRow>
<TableCell>
<button
on:click={() => {
openEditModal(script.id);
}}
{...globalButtonDisabledAttributes(script, contextCompanyID)}
title={script.name}
>
{script.name}
</button>
</TableCell>
{#if contextCompanyID}
<TableCellScope companyID={script.companyID} />
{/if}
<TableCellEmpty />
<TableCellAction>
<TableDropDownEllipsis>
<TableUpdateButton
on:click={() => openEditModal(script.id)}
{...globalButtonDisabledAttributes(script, contextCompanyID)}
/>
<TableCopyButton
title={'Copy'}
on:click={() => openCopyModal(script.id)}
{...globalButtonDisabledAttributes(script, contextCompanyID)}
/>
<TableDeleteButton
on:click={() => openDeleteAlert(script)}
{...globalButtonDisabledAttributes(script, contextCompanyID)}
></TableDeleteButton>
</TableDropDownEllipsis>
</TableCellAction>
</TableRow>
{/each}
</Table>
{/if}
<Modal
headerText={modalText}
visible={isModalVisible}
fullscreen={true}
onClose={closeModal}
{isSubmitting}
>
<FormGrid on:submit={onSubmit} {isSubmitting} {modalMode}>
<div class="col-span-3 flex flex-col min-h-0 overflow-hidden px-4 py-4">
{#key editorKey}
<ScriptEditor bind:name={formValues.name} bind:script={formValues.script} />
{/key}
</div>
<FormError message={modalError} />
<FormFooter {closeModal} {isSubmitting} okText={modalMode === 'create' ? 'Create' : 'Update'} />
</FormGrid>
</Modal>
<DeleteAlert
name={deleteValues.name}
onClick={() => onClickDelete(deleteValues.id)}
bind:isVisible={isDeleteAlertVisible}
></DeleteAlert>
</main>