mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-10-03 22:16:49 +02:00
added scripts feature
Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
37 files changed
+4319
-82
No files matched your search
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -19,5 +19,8 @@
|
||||
},
|
||||
"remote_browser": {
|
||||
"enabled": true
|
||||
},
|
||||
"script": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
@@ -24,5 +24,8 @@
|
||||
"remote_browser": {
|
||||
"enabled": false,
|
||||
"exec_path": ""
|
||||
},
|
||||
"script": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -199,6 +199,7 @@ func (c *Campaign) GetByID(g *gin.Context) {
|
||||
WithDenyPage: true,
|
||||
WithEvasionPage: true,
|
||||
WithWebhooks: true,
|
||||
WithScripts: true,
|
||||
},
|
||||
)
|
||||
// handle responses
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -54,6 +54,8 @@ func initialInstallAndSeed(
|
||||
&database.CampaignAllowDeny{},
|
||||
&database.Webhook{},
|
||||
&database.CampaignWebhook{},
|
||||
&database.Script{},
|
||||
&database.CampaignScript{},
|
||||
&database.Identifier{},
|
||||
&database.CampaignStats{},
|
||||
&database.OAuthProvider{},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
@@ -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}, {/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">
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user