mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-07-15 08:17:24 +02:00
Merge branch 'develop' into feat-scim
This commit is contained in:
Vendored
+5
-4
@@ -51,10 +51,11 @@ var CampaignEventPriority = map[string]int{
|
||||
data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_SENT: 20,
|
||||
data.EVENT_CAMPAIGN_RECIPIENT_SCHEDULED: 10,
|
||||
// campaign events
|
||||
data.EVENT_CAMPAIGN_CLOSED: 30,
|
||||
data.EVENT_CAMPAIGN_ACTIVE: 20,
|
||||
data.EVENT_CAMPAIGN_SELF_MANAGED: 20,
|
||||
data.EVENT_CAMPAIGN_SCHEDULED: 10,
|
||||
data.EVENT_CAMPAIGN_CLOSED: 30,
|
||||
data.EVENT_CAMPAIGN_ACTIVE: 20,
|
||||
data.EVENT_CAMPAIGN_SELF_MANAGED: 20,
|
||||
data.EVENT_CAMPAIGN_SCHEDULED: 10,
|
||||
data.EVENT_CAMPAIGN_PENDING_SCHEDULE: 5,
|
||||
}
|
||||
|
||||
// IsMoreNotableCampaignRecipientEvent returns true if newEvent is more notable than currentEvent
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package data
|
||||
|
||||
const (
|
||||
EVENT_CAMPAIGN_SCHEDULED = "campaign_scheduled"
|
||||
EVENT_CAMPAIGN_ACTIVE = "campaign_active"
|
||||
EVENT_CAMPAIGN_SELF_MANAGED = "campaign_self_managed"
|
||||
EVENT_CAMPAIGN_CLOSED = "campaign_closed"
|
||||
EVENT_CAMPAIGN_SCHEDULED = "campaign_scheduled"
|
||||
EVENT_CAMPAIGN_ACTIVE = "campaign_active"
|
||||
EVENT_CAMPAIGN_SELF_MANAGED = "campaign_self_managed"
|
||||
EVENT_CAMPAIGN_PENDING_SCHEDULE = "campaign_pending_schedule"
|
||||
EVENT_CAMPAIGN_CLOSED = "campaign_closed"
|
||||
|
||||
EVENT_CAMPAIGN_RECIPIENT_SCHEDULED = "campaign_recipient_scheduled"
|
||||
EVENT_CAMPAIGN_RECIPIENT_MESSAGE_SENT = "campaign_recipient_message_sent"
|
||||
@@ -26,6 +27,7 @@ var Events = []string{
|
||||
EVENT_CAMPAIGN_SCHEDULED,
|
||||
EVENT_CAMPAIGN_ACTIVE,
|
||||
EVENT_CAMPAIGN_SELF_MANAGED,
|
||||
EVENT_CAMPAIGN_PENDING_SCHEDULE,
|
||||
EVENT_CAMPAIGN_CLOSED,
|
||||
// campaign recipient events
|
||||
EVENT_CAMPAIGN_RECIPIENT_SCHEDULED,
|
||||
|
||||
@@ -25,6 +25,15 @@ type Campaign struct {
|
||||
SortOrder string `gorm:";"` // 'asc,desc,random'
|
||||
SendStartAt *time.Time `gorm:"index;"`
|
||||
SendEndAt *time.Time `gorm:"index;"`
|
||||
// ScheduleAt is set when the campaign uses late-scheduling.
|
||||
// the task runner will call schedule() when now >= ScheduleAt.
|
||||
// null means the campaign was scheduled immediately at creation.
|
||||
ScheduleAt *time.Time `gorm:"index;"`
|
||||
// JitterMin and JitterMax are persisted only when ScheduleAt is set (late-scheduling).
|
||||
// They are cleared after the campaign is scheduled by the task runner.
|
||||
// For immediately-scheduled campaigns these columns remain null.
|
||||
JitterMin *int `gorm:""`
|
||||
JitterMax *int `gorm:""`
|
||||
|
||||
// ConstraintWeekDays is a binary format.
|
||||
// 0b00000001 = 1 = sunday
|
||||
|
||||
@@ -28,11 +28,14 @@ type Campaign struct {
|
||||
SortOrder nullable.Nullable[vo.CampaignSendingOrder] `json:"sortOrder"`
|
||||
SendStartAt nullable.Nullable[time.Time] `json:"sendStartAt"`
|
||||
SendEndAt nullable.Nullable[time.Time] `json:"sendEndAt"`
|
||||
ScheduleAt nullable.Nullable[time.Time] `json:"scheduleAt"`
|
||||
ConstraintWeekDays nullable.Nullable[vo.CampaignWeekDays] `json:"constraintWeekDays"`
|
||||
ConstraintStartTime nullable.Nullable[vo.CampaignTimeConstraint] `json:"constraintStartTime"`
|
||||
ConstraintEndTime nullable.Nullable[vo.CampaignTimeConstraint] `json:"constraintEndTime"`
|
||||
|
||||
// jitter is used only during scheduling, not persisted to database
|
||||
// jitter is persisted to the database only when ScheduleAt is set (late-scheduling),
|
||||
// so the task runner can apply it when schedule() is called hours later.
|
||||
// For immediately-scheduled campaigns jitter lives in memory only and is never written to the DB.
|
||||
JitterMin nullable.Nullable[int] `json:"jitterMin,omitempty"`
|
||||
JitterMax nullable.Nullable[int] `json:"jitterMax,omitempty"`
|
||||
|
||||
@@ -410,6 +413,12 @@ func (c *Campaign) ToDBMap() map[string]any {
|
||||
m["send_end_at"] = utils.RFC3339UTC(v)
|
||||
}
|
||||
}
|
||||
if c.ScheduleAt.IsSpecified() {
|
||||
m["schedule_at"] = nil
|
||||
if v, err := c.ScheduleAt.Get(); err == nil {
|
||||
m["schedule_at"] = utils.RFC3339UTC(v)
|
||||
}
|
||||
}
|
||||
if c.CloseAt.IsSpecified() {
|
||||
m["close_at"] = nil
|
||||
if v, err := c.CloseAt.Get(); err == nil {
|
||||
@@ -504,6 +513,18 @@ func (c *Campaign) ToDBMap() map[string]any {
|
||||
if v, err := c.NotableEventID.Get(); err == nil {
|
||||
m["notable_event_id"] = v.String()
|
||||
}
|
||||
if c.JitterMin.IsSpecified() {
|
||||
m["jitter_min"] = nil
|
||||
if v, err := c.JitterMin.Get(); err == nil {
|
||||
m["jitter_min"] = v
|
||||
}
|
||||
}
|
||||
if c.JitterMax.IsSpecified() {
|
||||
m["jitter_max"] = nil
|
||||
if v, err := c.JitterMax.Get(); err == nil {
|
||||
m["jitter_max"] = v
|
||||
}
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
+278
-21
@@ -553,6 +553,9 @@ func (m *ProxyHandler) prepareRequestWithoutSession(req *http.Request, reqCtx *R
|
||||
if hostConfig.Rewrite != nil {
|
||||
for _, replacement := range hostConfig.Rewrite {
|
||||
if replacement.From == "" || replacement.From == "request_body" || replacement.From == "any" {
|
||||
if !m.rewriteRuleMatchesRequest(replacement, req) {
|
||||
continue
|
||||
}
|
||||
engine := replacement.Engine
|
||||
if engine == "" {
|
||||
engine = "regex"
|
||||
@@ -929,27 +932,48 @@ func (m *ProxyHandler) rewriteResponseHeadersWithContext(resp *http.Response, re
|
||||
// apply custom replacement rules for response headers (after all hardcoded changes)
|
||||
if reqCtx.Session != nil {
|
||||
varCtx := m.buildVariablesContext(resp.Request.Context(), reqCtx.Session, reqCtx.ProxyConfig)
|
||||
m.applyCustomResponseHeaderReplacementsWithVariables(resp, reqCtx.Session, varCtx)
|
||||
m.applyCustomResponseHeaderReplacementsWithVariables(resp, reqCtx.Session, reqCtx.ProxyConfig, varCtx)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ProxyHandler) applyCustomResponseHeaderReplacements(resp *http.Response, session *service.ProxySession) {
|
||||
m.applyCustomResponseHeaderReplacementsWithVariables(resp, session, nil)
|
||||
func (m *ProxyHandler) applyCustomResponseHeaderReplacements(resp *http.Response, session *service.ProxySession, proxyConfig *service.ProxyServiceConfigYAML) {
|
||||
m.applyCustomResponseHeaderReplacementsWithVariables(resp, session, proxyConfig, nil)
|
||||
}
|
||||
|
||||
func (m *ProxyHandler) applyCustomResponseHeaderReplacementsWithVariables(resp *http.Response, session *service.ProxySession, varCtx *VariablesContext) {
|
||||
func (m *ProxyHandler) applyCustomResponseHeaderReplacementsWithVariables(resp *http.Response, session *service.ProxySession, proxyConfig *service.ProxyServiceConfigYAML, varCtx *VariablesContext) {
|
||||
// get all headers as a string
|
||||
var buf bytes.Buffer
|
||||
resp.Header.Write(&buf)
|
||||
headers := buf.Bytes()
|
||||
|
||||
// only apply rewrite rules for the current host
|
||||
// apply per-host rewrite rules for the current host
|
||||
if hostConfig, ok := session.Config.Load(resp.Request.Host); ok {
|
||||
hCfg := hostConfig.(service.ProxyServiceDomainConfig)
|
||||
if hCfg.Rewrite != nil {
|
||||
for _, replacement := range hCfg.Rewrite {
|
||||
if replacement.From == "response_header" || replacement.From == "any" {
|
||||
headers = m.applyReplacementWithVariables(headers, replacement, session.ID, varCtx, "")
|
||||
if m.rewriteRuleMatchesRequest(replacement, resp.Request) {
|
||||
if replacement.Engine == "header" {
|
||||
m.applyHeaderRewriteToHeaders(resp.Header, replacement, session.ID, varCtx)
|
||||
} else {
|
||||
headers = m.applyReplacementWithVariables(headers, replacement, session.ID, varCtx, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// apply global rewrite rules for response headers
|
||||
if proxyConfig != nil && proxyConfig.Global != nil && proxyConfig.Global.Rewrite != nil {
|
||||
for _, replacement := range proxyConfig.Global.Rewrite {
|
||||
if replacement.From == "response_header" || replacement.From == "any" {
|
||||
if m.rewriteRuleMatchesRequest(replacement, resp.Request) {
|
||||
if replacement.Engine == "header" {
|
||||
m.applyHeaderRewriteToHeaders(resp.Header, replacement, session.ID, varCtx)
|
||||
} else {
|
||||
headers = m.applyReplacementWithVariables(headers, replacement, session.ID, varCtx, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1002,7 +1026,7 @@ func (m *ProxyHandler) rewriteResponseBodyWithContext(resp *http.Response, reqCt
|
||||
|
||||
// build variables context for template interpolation
|
||||
varCtx := m.buildVariablesContext(resp.Request.Context(), reqCtx.Session, reqCtx.ProxyConfig)
|
||||
body = m.applyCustomReplacementsWithVariables(body, reqCtx.Session, reqCtx.TargetDomain, reqCtx.ProxyConfig, varCtx, contentType)
|
||||
body = m.applyCustomReplacementsWithVariables(body, reqCtx.Session, reqCtx.TargetDomain, reqCtx.ProxyConfig, varCtx, contentType, resp.Request)
|
||||
|
||||
// apply obfuscation if enabled
|
||||
if reqCtx.Campaign != nil && strings.Contains(contentType, "text/html") {
|
||||
@@ -1035,6 +1059,7 @@ func (m *ProxyHandler) processResponseWithoutSessionContext(resp *http.Response,
|
||||
// apply basic response processing
|
||||
m.removeSecurityHeaders(resp)
|
||||
m.rewriteLocationHeaderWithoutSession(resp, config)
|
||||
m.applyCustomResponseHeaderReplacementsWithoutSession(resp, config, reqCtx.TargetDomain, reqCtx.ProxyConfig)
|
||||
m.rewriteResponseBodyWithoutSessionContext(resp, reqCtx, config)
|
||||
|
||||
return resp
|
||||
@@ -1142,7 +1167,7 @@ func (m *ProxyHandler) rewriteResponseBodyWithoutSessionContext(resp *http.Respo
|
||||
|
||||
body = m.patchUrls(configMap, body, CONVERT_TO_PHISHING_URLS)
|
||||
body = m.applyURLPathRewritesWithoutSession(body, reqCtx)
|
||||
body = m.applyCustomReplacementsWithoutSession(body, configMap, reqCtx.TargetDomain, reqCtx.ProxyConfig, contentType)
|
||||
body = m.applyCustomReplacementsWithoutSession(body, configMap, reqCtx.TargetDomain, reqCtx.ProxyConfig, contentType, resp.Request)
|
||||
|
||||
// apply obfuscation if enabled
|
||||
if reqCtx.Campaign != nil && strings.Contains(contentType, "text/html") {
|
||||
@@ -1616,7 +1641,12 @@ func (m *ProxyHandler) matchesPath(capture service.ProxyServiceCaptureRule, req
|
||||
if capture.PathRe == nil {
|
||||
return true
|
||||
}
|
||||
return capture.PathRe.MatchString(req.URL.Path)
|
||||
// normalize empty path to "/" so rules like path: / match root requests
|
||||
path := req.URL.Path
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
return capture.PathRe.MatchString(path)
|
||||
}
|
||||
|
||||
func (m *ProxyHandler) handlePathBasedCapture(capture service.ProxyServiceCaptureRule, session *service.ProxySession, resp *http.Response) {
|
||||
@@ -1638,7 +1668,11 @@ func (m *ProxyHandler) handlePathBasedCapture(capture service.ProxyServiceCaptur
|
||||
webhookData := map[string]interface{}{
|
||||
capture.Name: capturedData,
|
||||
}
|
||||
m.createCampaignSubmitEvent(session, webhookData, resp.Request, session.UserAgent)
|
||||
if capture.Event == "info" {
|
||||
m.createCampaignInfoEvent(session, webhookData, resp.Request, session.UserAgent)
|
||||
} else {
|
||||
m.createCampaignSubmitEvent(session, webhookData, resp.Request, session.UserAgent)
|
||||
}
|
||||
}
|
||||
|
||||
// check if cookie bundle should be submitted now that this capture is complete
|
||||
@@ -1820,7 +1854,11 @@ func (m *ProxyHandler) captureFromTextWithResponse(text string, capture service.
|
||||
webhookData := map[string]interface{}{
|
||||
capture.Name: capturedData,
|
||||
}
|
||||
m.createCampaignSubmitEvent(session, webhookData, req, session.UserAgent)
|
||||
if capture.Event == "info" {
|
||||
m.createCampaignInfoEvent(session, webhookData, req, session.UserAgent)
|
||||
} else {
|
||||
m.createCampaignSubmitEvent(session, webhookData, req, session.UserAgent)
|
||||
}
|
||||
}
|
||||
|
||||
// check if we should submit cookie bundle (only when all captures complete)
|
||||
@@ -2342,7 +2380,9 @@ func (m *ProxyHandler) applyRequestBodyReplacementsWithVariables(req *http.Reque
|
||||
if hCfg.Rewrite != nil {
|
||||
for _, replacement := range hCfg.Rewrite {
|
||||
if replacement.From == "" || replacement.From == "request_body" || replacement.From == "any" {
|
||||
body = m.applyReplacementWithVariables(body, replacement, session.ID, varCtx, "")
|
||||
if m.rewriteRuleMatchesRequest(replacement, req) {
|
||||
body = m.applyReplacementWithVariables(body, replacement, session.ID, varCtx, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2352,7 +2392,9 @@ func (m *ProxyHandler) applyRequestBodyReplacementsWithVariables(req *http.Reque
|
||||
if proxyConfig != nil && proxyConfig.Global != nil && proxyConfig.Global.Rewrite != nil {
|
||||
for _, replacement := range proxyConfig.Global.Rewrite {
|
||||
if replacement.From == "" || replacement.From == "request_body" || replacement.From == "any" {
|
||||
body = m.applyReplacementWithVariables(body, replacement, session.ID, varCtx, "")
|
||||
if m.rewriteRuleMatchesRequest(replacement, req) {
|
||||
body = m.applyReplacementWithVariables(body, replacement, session.ID, varCtx, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2360,18 +2402,20 @@ func (m *ProxyHandler) applyRequestBodyReplacementsWithVariables(req *http.Reque
|
||||
req.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
}
|
||||
|
||||
func (m *ProxyHandler) applyCustomReplacements(body []byte, session *service.ProxySession, targetDomain string, proxyConfig *service.ProxyServiceConfigYAML) []byte {
|
||||
return m.applyCustomReplacementsWithVariables(body, session, targetDomain, proxyConfig, nil, "")
|
||||
func (m *ProxyHandler) applyCustomReplacements(body []byte, session *service.ProxySession, targetDomain string, proxyConfig *service.ProxyServiceConfigYAML, req *http.Request) []byte {
|
||||
return m.applyCustomReplacementsWithVariables(body, session, targetDomain, proxyConfig, nil, "", req)
|
||||
}
|
||||
|
||||
func (m *ProxyHandler) applyCustomReplacementsWithVariables(body []byte, session *service.ProxySession, targetDomain string, proxyConfig *service.ProxyServiceConfigYAML, varCtx *VariablesContext, contentType string) []byte {
|
||||
func (m *ProxyHandler) applyCustomReplacementsWithVariables(body []byte, session *service.ProxySession, targetDomain string, proxyConfig *service.ProxyServiceConfigYAML, varCtx *VariablesContext, contentType string, req *http.Request) []byte {
|
||||
// apply rewrite rules for the current request's target domain
|
||||
if hostConfig, ok := session.Config.Load(targetDomain); ok {
|
||||
hCfg := hostConfig.(service.ProxyServiceDomainConfig)
|
||||
if hCfg.Rewrite != nil {
|
||||
for _, replacement := range hCfg.Rewrite {
|
||||
if replacement.From == "" || replacement.From == "response_body" || replacement.From == "any" {
|
||||
body = m.applyReplacementWithVariables(body, replacement, session.ID, varCtx, contentType)
|
||||
if m.rewriteRuleMatchesRequest(replacement, req) {
|
||||
body = m.applyReplacementWithVariables(body, replacement, session.ID, varCtx, contentType)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2381,7 +2425,9 @@ func (m *ProxyHandler) applyCustomReplacementsWithVariables(body []byte, session
|
||||
if proxyConfig != nil && proxyConfig.Global != nil && proxyConfig.Global.Rewrite != nil {
|
||||
for _, replacement := range proxyConfig.Global.Rewrite {
|
||||
if replacement.From == "" || replacement.From == "response_body" || replacement.From == "any" {
|
||||
body = m.applyReplacementWithVariables(body, replacement, session.ID, varCtx, contentType)
|
||||
if m.rewriteRuleMatchesRequest(replacement, req) {
|
||||
body = m.applyReplacementWithVariables(body, replacement, session.ID, varCtx, contentType)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2390,13 +2436,73 @@ func (m *ProxyHandler) applyCustomReplacementsWithVariables(body []byte, session
|
||||
}
|
||||
|
||||
// applyCustomReplacementsWithoutSession applies rewrite rules for requests without session context
|
||||
func (m *ProxyHandler) applyCustomReplacementsWithoutSession(body []byte, config map[string]service.ProxyServiceDomainConfig, targetDomain string, proxyConfig *service.ProxyServiceConfigYAML, contentType string) []byte {
|
||||
// applyCustomResponseHeaderReplacementsWithoutSession applies response_header rewrite rules for requests without a session
|
||||
func (m *ProxyHandler) applyCustomResponseHeaderReplacementsWithoutSession(resp *http.Response, config map[string]service.ProxyServiceDomainConfig, targetDomain string, proxyConfig *service.ProxyServiceConfigYAML) {
|
||||
var buf bytes.Buffer
|
||||
resp.Header.Write(&buf)
|
||||
headers := buf.Bytes()
|
||||
|
||||
// apply per-host rewrite rules
|
||||
if hostConfig, ok := config[targetDomain]; ok {
|
||||
if hostConfig.Rewrite != nil {
|
||||
for _, replacement := range hostConfig.Rewrite {
|
||||
if replacement.From == "response_header" || replacement.From == "any" {
|
||||
if m.rewriteRuleMatchesRequest(replacement, resp.Request) {
|
||||
if replacement.Engine == "header" {
|
||||
m.applyHeaderRewriteToHeaders(resp.Header, replacement, "no-session", nil)
|
||||
} else {
|
||||
headers = m.applyReplacement(headers, replacement, "no-session", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// apply global rewrite rules
|
||||
if proxyConfig != nil && proxyConfig.Global != nil && proxyConfig.Global.Rewrite != nil {
|
||||
for _, replacement := range proxyConfig.Global.Rewrite {
|
||||
if replacement.From == "response_header" || replacement.From == "any" {
|
||||
if m.rewriteRuleMatchesRequest(replacement, resp.Request) {
|
||||
if replacement.Engine == "header" {
|
||||
m.applyHeaderRewriteToHeaders(resp.Header, replacement, "no-session", nil)
|
||||
} else {
|
||||
headers = m.applyReplacement(headers, replacement, "no-session", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parse the modified headers back
|
||||
if string(headers) != buf.String() {
|
||||
resp.Header = make(http.Header)
|
||||
lines := strings.Split(string(headers), "\r\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if parts := strings.SplitN(line, ":", 2); len(parts) == 2 {
|
||||
headerName := strings.TrimSpace(parts[0])
|
||||
headerValue := strings.TrimSpace(parts[1])
|
||||
if headerName != "" {
|
||||
resp.Header.Add(headerName, headerValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ProxyHandler) applyCustomReplacementsWithoutSession(body []byte, config map[string]service.ProxyServiceDomainConfig, targetDomain string, proxyConfig *service.ProxyServiceConfigYAML, contentType string, req *http.Request) []byte {
|
||||
// apply rewrite rules for the current target domain
|
||||
if hostConfig, ok := config[targetDomain]; ok {
|
||||
if hostConfig.Rewrite != nil {
|
||||
for _, replacement := range hostConfig.Rewrite {
|
||||
if replacement.From == "" || replacement.From == "response_body" || replacement.From == "any" {
|
||||
body = m.applyReplacement(body, replacement, "no-session", contentType)
|
||||
if m.rewriteRuleMatchesRequest(replacement, req) {
|
||||
body = m.applyReplacement(body, replacement, "no-session", contentType)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2406,7 +2512,9 @@ func (m *ProxyHandler) applyCustomReplacementsWithoutSession(body []byte, config
|
||||
if proxyConfig != nil && proxyConfig.Global != nil && proxyConfig.Global.Rewrite != nil {
|
||||
for _, replacement := range proxyConfig.Global.Rewrite {
|
||||
if replacement.From == "" || replacement.From == "response_body" || replacement.From == "any" {
|
||||
body = m.applyReplacement(body, replacement, "no-session", contentType)
|
||||
if m.rewriteRuleMatchesRequest(replacement, req) {
|
||||
body = m.applyReplacement(body, replacement, "no-session", contentType)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2414,10 +2522,72 @@ func (m *ProxyHandler) applyCustomReplacementsWithoutSession(body []byte, config
|
||||
return body
|
||||
}
|
||||
|
||||
// rewriteRuleMatchesRequest returns true if the rewrite rule's path/method constraints
|
||||
// match the given request. If no constraints are set the rule always matches.
|
||||
// If req is nil (no request context available), rules with path/method constraints are skipped.
|
||||
func (m *ProxyHandler) rewriteRuleMatchesRequest(rule service.ProxyServiceReplaceRule, req *http.Request) bool {
|
||||
if req == nil {
|
||||
return rule.Path == "" && rule.Method == ""
|
||||
}
|
||||
if rule.Method != "" && !strings.EqualFold(rule.Method, req.Method) {
|
||||
return false
|
||||
}
|
||||
if rule.Path != "" {
|
||||
if rule.PathRe == nil {
|
||||
// path was set but failed to compile — skip rule safely
|
||||
return false
|
||||
}
|
||||
if !rule.PathRe.MatchString(req.URL.Path) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *ProxyHandler) applyReplacement(body []byte, replacement service.ProxyServiceReplaceRule, sessionID string, contentType string) []byte {
|
||||
return m.applyReplacementWithVariables(body, replacement, sessionID, nil, contentType)
|
||||
}
|
||||
|
||||
// applyHeaderRewriteToHeaders applies a header-engine rewrite rule directly to an http.Header map.
|
||||
// find = header name (canonical form), action = "set" | "add" | "remove", replace = new value.
|
||||
// This operates directly on the header map so no serialize/parse round-trip is needed.
|
||||
func (m *ProxyHandler) applyHeaderRewriteToHeaders(headers http.Header, rule service.ProxyServiceReplaceRule, sessionID string, varCtx *VariablesContext) {
|
||||
if rule.Find == "" {
|
||||
m.logger.Errorw("header rewrite rule has empty find (header name)", "sessionID", sessionID, "rule", rule.Name)
|
||||
return
|
||||
}
|
||||
|
||||
// canonicalize the header name the same way Go's net/http does
|
||||
canonicalName := http.CanonicalHeaderKey(rule.Find)
|
||||
|
||||
action := rule.Action
|
||||
if action == "" {
|
||||
action = "set"
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "remove":
|
||||
headers.Del(canonicalName)
|
||||
|
||||
case "add":
|
||||
value := rule.Replace
|
||||
if varCtx != nil && varCtx.Enabled && varCtx.Data != nil {
|
||||
value = m.interpolateVariables(value, varCtx)
|
||||
}
|
||||
headers.Add(canonicalName, value)
|
||||
|
||||
case "set":
|
||||
value := rule.Replace
|
||||
if varCtx != nil && varCtx.Enabled && varCtx.Data != nil {
|
||||
value = m.interpolateVariables(value, varCtx)
|
||||
}
|
||||
headers.Set(canonicalName, value)
|
||||
|
||||
default:
|
||||
m.logger.Errorw("unsupported header rewrite action", "action", action, "sessionID", sessionID, "rule", rule.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// applyReplacementWithVariables applies replacement with optional template variable interpolation
|
||||
func (m *ProxyHandler) applyReplacementWithVariables(body []byte, replacement service.ProxyServiceReplaceRule, sessionID string, varCtx *VariablesContext, contentType string) []byte {
|
||||
// interpolate variables in the replacement value if enabled
|
||||
@@ -2441,6 +2611,13 @@ func (m *ProxyHandler) applyReplacementWithVariables(body []byte, replacement se
|
||||
return m.applyRegexReplacement(body, interpolatedReplacement, sessionID)
|
||||
case "dom":
|
||||
return m.applyDomReplacement(body, interpolatedReplacement, sessionID, contentType)
|
||||
case "header":
|
||||
// header engine operates directly on http.Header — it cannot be used in a body rewrite context.
|
||||
// callers that handle headers (applyCustomResponseHeaderReplacementsWithVariables,
|
||||
// applyCustomResponseHeaderReplacementsWithoutSession, applyEarlyRequestHeaderReplacements)
|
||||
// intercept this engine before reaching here.
|
||||
m.logger.Errorw("header engine used in a non-header rewrite context — use from: request_header or response_header", "sessionID", sessionID, "rule", replacement.Name)
|
||||
return body
|
||||
default:
|
||||
m.logger.Errorw("unsupported replacement engine", "engine", engine, "sessionID", sessionID)
|
||||
return body
|
||||
@@ -2842,10 +3019,17 @@ func (m *ProxyHandler) applyEarlyRequestHeaderReplacements(req *http.Request, re
|
||||
applyReplacements := func(replacements []service.ProxyServiceReplaceRule) {
|
||||
for _, replacement := range replacements {
|
||||
if replacement.From == "" || replacement.From == "request_header" || replacement.From == "any" {
|
||||
if !m.rewriteRuleMatchesRequest(replacement, req) {
|
||||
continue
|
||||
}
|
||||
engine := replacement.Engine
|
||||
if engine == "" {
|
||||
engine = "regex"
|
||||
}
|
||||
if engine == "header" {
|
||||
m.applyHeaderRewriteToHeaders(req.Header, replacement, "early-request", nil)
|
||||
continue
|
||||
}
|
||||
if engine == "regex" {
|
||||
re, err := regexp.Compile(replacement.Find)
|
||||
if err != nil {
|
||||
@@ -3389,6 +3573,79 @@ func (m *ProxyHandler) buildCampaignFlowRedirectURL(session *service.ProxySessio
|
||||
return targetURL
|
||||
}
|
||||
|
||||
// createCampaignInfoEvent saves captured data as a low-priority info event instead of a submit event.
|
||||
// The capture still participates in completion tracking and flow progression normally — only the
|
||||
// saved event type differs.
|
||||
func (m *ProxyHandler) createCampaignInfoEvent(session *service.ProxySession, capturedData map[string]interface{}, req *http.Request, originalUserAgent string) {
|
||||
if session.CampaignID == nil || session.CampaignRecipientID == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
campaign := session.Campaign
|
||||
if campaign == nil {
|
||||
var err error
|
||||
campaign, err = m.CampaignRepository.GetByID(ctx, session.CampaignID, &repository.CampaignOption{})
|
||||
if err != nil {
|
||||
m.logger.Errorw("failed to get campaign for proxy info event", "error", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var dataJSON []byte
|
||||
var err error
|
||||
if campaign.SaveSubmittedData.MustGet() {
|
||||
dataJSON, err = json.Marshal(capturedData)
|
||||
if err != nil {
|
||||
m.logger.Errorw("failed to marshal captured data for info event", "error", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
dataJSON = []byte("{}")
|
||||
}
|
||||
|
||||
infoEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_INFO]
|
||||
if infoEventID == nil {
|
||||
m.logger.Errorw("info event type not found in cache", "sessionID", session.ID)
|
||||
return
|
||||
}
|
||||
|
||||
eventID := uuid.New()
|
||||
clientIP := utils.ExtractClientIP(req)
|
||||
metadata := model.ExtractCampaignEventMetadataFromHTTPRequest(req, campaign)
|
||||
|
||||
event := &model.CampaignEvent{
|
||||
ID: &eventID,
|
||||
CampaignID: session.CampaignID,
|
||||
RecipientID: session.RecipientID,
|
||||
EventID: infoEventID,
|
||||
Data: vo.NewOptionalString1MBMust(string(dataJSON)),
|
||||
Metadata: metadata,
|
||||
IP: vo.NewOptionalString64Must(clientIP),
|
||||
UserAgent: vo.NewOptionalString255Must(originalUserAgent),
|
||||
}
|
||||
|
||||
err = m.CampaignRepository.SaveEvent(ctx, event)
|
||||
if err != nil {
|
||||
m.logger.Errorw("failed to create campaign info event", "error", err)
|
||||
}
|
||||
|
||||
err = m.CampaignService.HandleWebhooks(
|
||||
ctx,
|
||||
session.CampaignID,
|
||||
session.RecipientID,
|
||||
data.EVENT_CAMPAIGN_RECIPIENT_INFO,
|
||||
capturedData,
|
||||
)
|
||||
if err != nil {
|
||||
m.logger.Errorw("failed to handle webhooks for MITM proxy info event",
|
||||
"error", err,
|
||||
"campaignRecipientID", session.CampaignRecipientID.String(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ProxyHandler) createCampaignSubmitEvent(session *service.ProxySession, capturedData map[string]interface{}, req *http.Request, originalUserAgent string) {
|
||||
if session.CampaignID == nil || session.CampaignRecipientID == nil {
|
||||
return
|
||||
|
||||
@@ -1366,6 +1366,41 @@ func (r *Campaign) GetReadyToAnonymize(
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetReadyToLateSchedule returns campaigns where schedule_at <= now and
|
||||
// the campaign has not yet been scheduled (notable event is still pending_schedule).
|
||||
func (r *Campaign) GetReadyToLateSchedule(
|
||||
ctx context.Context,
|
||||
options *CampaignOption,
|
||||
) (*model.Result[model.Campaign], error) {
|
||||
result := model.NewEmptyResult[model.Campaign]()
|
||||
db := r.load(r.DB, options)
|
||||
db, err := useQuery(db, database.CAMPAIGN_TABLE, options.QueryArgs)
|
||||
if err != nil {
|
||||
return result, errs.Wrap(err)
|
||||
}
|
||||
pendingEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_PENDING_SCHEDULE]
|
||||
var dbCampaigns []database.Campaign
|
||||
res := db.
|
||||
Where("schedule_at <= ? AND notable_event_id = ?", utils.NowRFC3339UTC(), pendingEventID).
|
||||
Find(&dbCampaigns)
|
||||
if res.Error != nil {
|
||||
return result, res.Error
|
||||
}
|
||||
hasNextPage, err := useHasNextPage(db, database.CAMPAIGN_TABLE, options.QueryArgs)
|
||||
if err != nil {
|
||||
return result, errs.Wrap(err)
|
||||
}
|
||||
result.HasNextPage = hasNextPage
|
||||
for _, dbCampaign := range dbCampaigns {
|
||||
campaign, err := ToCampaign(&dbCampaign)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
result.Rows = append(result.Rows, campaign)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SaveEvent saves a campaign event
|
||||
func (r *Campaign) SaveEvent(
|
||||
ctx context.Context,
|
||||
@@ -1793,6 +1828,24 @@ func ToCampaign(row *database.Campaign) (*model.Campaign, error) {
|
||||
} else {
|
||||
sendEndAt.SetNull()
|
||||
}
|
||||
var scheduleAt nullable.Nullable[time.Time]
|
||||
if row.ScheduleAt != nil {
|
||||
scheduleAt = nullable.NewNullableWithValue(*row.ScheduleAt)
|
||||
} else {
|
||||
scheduleAt.SetNull()
|
||||
}
|
||||
var jitterMin nullable.Nullable[int]
|
||||
if row.JitterMin != nil {
|
||||
jitterMin = nullable.NewNullableWithValue(*row.JitterMin)
|
||||
} else {
|
||||
jitterMin.SetNull()
|
||||
}
|
||||
var jitterMax nullable.Nullable[int]
|
||||
if row.JitterMax != nil {
|
||||
jitterMax = nullable.NewNullableWithValue(*row.JitterMax)
|
||||
} else {
|
||||
jitterMax.SetNull()
|
||||
}
|
||||
saveSubmittedData := nullable.NewNullableWithValue(row.SaveSubmittedData)
|
||||
saveBrowserMetadata := nullable.NewNullableWithValue(row.SaveBrowserMetadata)
|
||||
isAnonymous := nullable.NewNullableWithValue(row.IsAnonymous)
|
||||
@@ -1927,6 +1980,9 @@ func ToCampaign(row *database.Campaign) (*model.Campaign, error) {
|
||||
SortOrder: sortOrder,
|
||||
SendStartAt: sendStartAt,
|
||||
SendEndAt: sendEndAt,
|
||||
ScheduleAt: scheduleAt,
|
||||
JitterMin: jitterMin,
|
||||
JitterMax: jitterMax,
|
||||
ConstraintWeekDays: constraintWeekDays,
|
||||
ConstraintStartTime: constraintStartTime,
|
||||
ConstraintEndTime: constraintEndTime,
|
||||
|
||||
@@ -437,6 +437,7 @@ func (r *Recipient) GetOrphaned(
|
||||
database.RECIPIENT_TABLE,
|
||||
database.RECIPIENT_TABLE,
|
||||
database.RECIPIENT_TABLE,
|
||||
database.RECIPIENT_TABLE,
|
||||
)
|
||||
|
||||
query := fmt.Sprintf(
|
||||
|
||||
+177
-15
@@ -118,6 +118,33 @@ func (c *Campaign) Create(
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
}
|
||||
// late-schedule (indicated by a non-null schedule_at) is not compatible with self-managed campaigns
|
||||
if campaign.ScheduleAt.IsSpecified() && !campaign.ScheduleAt.IsNull() {
|
||||
if campaign.IsSelfManaged() {
|
||||
return nil, validate.WrapErrorWithField(
|
||||
errors.New("late scheduling is not available for self-managed campaigns"),
|
||||
"scheduleAt",
|
||||
)
|
||||
}
|
||||
// scheduleAt must be in the future
|
||||
scheduleAt := campaign.ScheduleAt.MustGet()
|
||||
if !scheduleAt.After(time.Now().UTC()) {
|
||||
return nil, validate.WrapErrorWithField(
|
||||
errors.New("schedule time must be in the future"),
|
||||
"scheduleAt",
|
||||
)
|
||||
}
|
||||
// send_start_at must be more than 24h after schedule_at
|
||||
if campaign.SendStartAt.IsSpecified() && !campaign.SendStartAt.IsNull() {
|
||||
sendStartAt := campaign.SendStartAt.MustGet()
|
||||
if sendStartAt.Sub(scheduleAt) < 24*time.Hour {
|
||||
return nil, validate.WrapErrorWithField(
|
||||
errors.New("send start must be at least 24 hours after the schedule-at time"),
|
||||
"scheduleAt",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// validate
|
||||
if err := campaign.Validate(); err != nil {
|
||||
return nil, errs.Wrap(err)
|
||||
@@ -213,14 +240,26 @@ func (c *Campaign) Create(
|
||||
c.Logger.Errorw("failed to get campaign by id", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
// preserve jitter values from original campaign (not persisted to db)
|
||||
createdCampaign.JitterMin = campaign.JitterMin
|
||||
createdCampaign.JitterMax = campaign.JitterMax
|
||||
err = c.schedule(ctx, session, createdCampaign)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to schedule campaign", "error", err)
|
||||
// TODO we should delete the campaign as it was not scheduled
|
||||
return nil, errs.Wrap(err)
|
||||
if createdCampaign.ScheduleAt.IsSpecified() && !createdCampaign.ScheduleAt.IsNull() {
|
||||
// Late-scheduling: persist jitter to the DB so the task runner can apply it
|
||||
// when schedule() is called hours later. The in-memory copy on createdCampaign
|
||||
// is set too so setMostNotableCampaignEvent's UpdateByID writes the columns.
|
||||
createdCampaign.JitterMin = campaign.JitterMin
|
||||
createdCampaign.JitterMax = campaign.JitterMax
|
||||
err = c.setMostNotableCampaignEvent(ctx, createdCampaign, data.EVENT_CAMPAIGN_PENDING_SCHEDULE)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
} else {
|
||||
// Immediate scheduling: jitter lives in memory only for this call, never written to DB.
|
||||
createdCampaign.JitterMin = campaign.JitterMin
|
||||
createdCampaign.JitterMax = campaign.JitterMax
|
||||
err = c.schedule(ctx, session, createdCampaign)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to schedule campaign", "error", err)
|
||||
// TODO we should delete the campaign as it was not scheduled
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
}
|
||||
ae.Details["id"] = id.String()
|
||||
c.AuditLogAuthorized(ae)
|
||||
@@ -1482,6 +1521,15 @@ func (c *Campaign) UpdateByID(
|
||||
if v, err := incoming.AnonymizedAt.Get(); err == nil {
|
||||
current.AnonymizedAt.Set(v.Truncate(time.Minute))
|
||||
}
|
||||
if v, err := incoming.ScheduleAt.Get(); err == nil {
|
||||
current.ScheduleAt.Set(v)
|
||||
} else if incoming.ScheduleAt.IsSpecified() {
|
||||
// incoming was explicitly null — clear the scheduled time, reverting to immediate scheduling
|
||||
current.ScheduleAt.SetNull()
|
||||
// also clear any persisted jitter — it was stored for late-scheduling and is no longer needed
|
||||
current.JitterMin.SetNull()
|
||||
current.JitterMax.SetNull()
|
||||
}
|
||||
if v, err := incoming.RecipientGroupIDs.Get(); err == nil {
|
||||
current.RecipientGroupIDs.Set(v)
|
||||
}
|
||||
@@ -1565,6 +1613,47 @@ func (c *Campaign) UpdateByID(
|
||||
current.EvasionPageID.Set(incoming.EvasionPageID.MustGet())
|
||||
}
|
||||
}
|
||||
// late-schedule (indicated by a non-null schedule_at) is not compatible with self-managed campaigns
|
||||
if current.ScheduleAt.IsSpecified() && !current.ScheduleAt.IsNull() {
|
||||
if current.IsSelfManaged() {
|
||||
return validate.WrapErrorWithField(
|
||||
errors.New("late scheduling is not available for self-managed campaigns"),
|
||||
"scheduleAt",
|
||||
)
|
||||
}
|
||||
// reject scheduleAt if the campaign has already moved past pending_schedule —
|
||||
// at that point recipients have been resolved and scheduling is done; there is
|
||||
// nothing meaningful for a new scheduleAt to do and it would revert the campaign
|
||||
// back to pending_schedule state unexpectedly.
|
||||
if currentNotableEventID, err := current.NotableEventID.Get(); err == nil {
|
||||
if currentEventName, ok := cache.EventNameByID[currentNotableEventID.String()]; ok {
|
||||
if cache.IsMoreNotableCampaignRecipientEvent(currentEventName, data.EVENT_CAMPAIGN_PENDING_SCHEDULE) {
|
||||
return validate.WrapErrorWithField(
|
||||
errors.New("scheduleAt cannot be set on a campaign that has already been scheduled"),
|
||||
"scheduleAt",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// scheduleAt must be in the future
|
||||
scheduleAt := current.ScheduleAt.MustGet()
|
||||
if !scheduleAt.After(time.Now().UTC()) {
|
||||
return validate.WrapErrorWithField(
|
||||
errors.New("schedule time must be in the future"),
|
||||
"scheduleAt",
|
||||
)
|
||||
}
|
||||
// send_start_at must be more than 24h after schedule_at
|
||||
if current.SendStartAt.IsSpecified() && !current.SendStartAt.IsNull() {
|
||||
sendStartAt := current.SendStartAt.MustGet()
|
||||
if sendStartAt.Sub(scheduleAt) < 24*time.Hour {
|
||||
return validate.WrapErrorWithField(
|
||||
errors.New("send start must be at least 24 hours after the schedule-at time"),
|
||||
"scheduleAt",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// validate and update
|
||||
if err := current.Validate(); err != nil {
|
||||
return errs.Wrap(err)
|
||||
@@ -1638,13 +1727,27 @@ func (c *Campaign) UpdateByID(
|
||||
c.Logger.Errorw("failed to add recipient groups", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
// preserve jitter values from incoming campaign (not persisted to db)
|
||||
current.JitterMin = incoming.JitterMin
|
||||
current.JitterMax = incoming.JitterMax
|
||||
err = c.schedule(ctx, session, current)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to re-schedule campaign", "error", err)
|
||||
return errs.Wrap(err)
|
||||
if current.ScheduleAt.IsSpecified() && !current.ScheduleAt.IsNull() {
|
||||
// Late-scheduling: persist jitter to the DB so the task runner can apply it
|
||||
// when schedule() is called hours later. Only overwrite jitter when the incoming
|
||||
// payload explicitly specifies it — unspecified means "leave existing value alone".
|
||||
if incoming.JitterMin.IsSpecified() {
|
||||
current.JitterMin = incoming.JitterMin
|
||||
current.JitterMax = incoming.JitterMax
|
||||
}
|
||||
err = c.setMostNotableCampaignEvent(ctx, current, data.EVENT_CAMPAIGN_PENDING_SCHEDULE)
|
||||
if err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
} else {
|
||||
// Immediate scheduling: jitter lives in memory only for this call, never written to DB.
|
||||
current.JitterMin = incoming.JitterMin
|
||||
current.JitterMax = incoming.JitterMax
|
||||
err = c.schedule(ctx, session, current)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to re-schedule campaign", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
}
|
||||
c.AuditLogAuthorized(ae)
|
||||
return nil
|
||||
@@ -2906,6 +3009,65 @@ func (c *Campaign) HandleAnonymizeCampaigns(
|
||||
return nil
|
||||
}
|
||||
|
||||
// SchedulePendingCampaigns is called by the task runner. It finds all campaigns
|
||||
// whose schedule_at time has passed and triggers schedule() for each.
|
||||
func (c *Campaign) SchedulePendingCampaigns(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
) error {
|
||||
ae := NewAuditEvent("Campaign.SchedulePendingCampaigns", session)
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
c.LogAuthError(err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
c.AuditLogNotAuthorized(ae)
|
||||
return errs.ErrAuthorizationFailed
|
||||
}
|
||||
pending, err := c.CampaignRepository.GetReadyToLateSchedule(
|
||||
ctx,
|
||||
&repository.CampaignOption{
|
||||
WithRecipientGroups: true,
|
||||
WithAllowDeny: true,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to get campaigns ready for late scheduling", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
for _, campaign := range pending.Rows {
|
||||
campaignID := campaign.ID.MustGet()
|
||||
// Clear schedule_at BEFORE calling schedule() so that a concurrent task runner tick
|
||||
// or a second server instance cannot pick up the same campaign and double-schedule it.
|
||||
// If schedule() subsequently fails the campaign will remain in pending_schedule state
|
||||
// with a null schedule_at; an operator can re-set schedule_at to retry.
|
||||
clearScheduleAt := model.Campaign{}
|
||||
clearScheduleAt.ScheduleAt.SetNull()
|
||||
if err := c.CampaignRepository.UpdateByID(ctx, &campaignID, &clearScheduleAt); err != nil {
|
||||
c.Logger.Errorw("failed to clear schedule_at before late-scheduling, skipping campaign", "campaignID", campaignID, "error", err)
|
||||
// skip this campaign — better to retry next tick than to risk a double-schedule
|
||||
continue
|
||||
}
|
||||
// Jitter is loaded from the DB columns (persisted at creation/update time).
|
||||
if err := c.schedule(ctx, session, campaign); err != nil {
|
||||
c.Logger.Errorw("failed to late-schedule campaign", "campaignID", campaignID, "error", err)
|
||||
// continue to next — don't abort the whole run
|
||||
continue
|
||||
}
|
||||
// Clear persisted jitter now that scheduling is done — it is no longer needed.
|
||||
clearJitter := model.Campaign{}
|
||||
clearJitter.JitterMin.SetNull()
|
||||
clearJitter.JitterMax.SetNull()
|
||||
if err := c.CampaignRepository.UpdateByID(ctx, &campaignID, &clearJitter); err != nil {
|
||||
c.Logger.Errorw("failed to clear jitter after late-scheduling", "campaignID", campaignID, "error", err)
|
||||
// non-fatal — jitter columns being non-null is harmless after scheduling
|
||||
}
|
||||
c.Logger.Infow("late-scheduled campaign", "campaignID", campaignID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CloseCampaignByID closes a campaign by id
|
||||
// DeleteDeviceCodesByCampaignID deletes all device codes for a campaign so every recipient
|
||||
// gets a fresh code (and picks up any proxy change) on their next page visit.
|
||||
|
||||
+117
-11
@@ -160,7 +160,7 @@ func GetValidProxyVariableNames() []string {
|
||||
// - "public": Allow all traffic (traditional proxy mode) - on_deny is ignored
|
||||
// - "private": Strict IP-based mode like evilginx2 - whitelist IP after lure access, deny all others (DEFAULT)
|
||||
|
||||
// CompilePathPatterns compiles regex patterns for all capture and response rules
|
||||
// CompilePathPatterns compiles regex patterns for all capture, response, and rewrite rules
|
||||
func CompilePathPatterns(config *ProxyServiceConfigYAML) error {
|
||||
// Compile global capture rule patterns
|
||||
if config.Global != nil && config.Global.Capture != nil {
|
||||
@@ -180,6 +180,15 @@ func CompilePathPatterns(config *ProxyServiceConfigYAML) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Compile global rewrite rule patterns
|
||||
if config.Global != nil && config.Global.Rewrite != nil {
|
||||
for i := range config.Global.Rewrite {
|
||||
if err := compileRewritePath(&config.Global.Rewrite[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compile host-specific capture rule patterns
|
||||
for _, hostConfig := range config.Hosts {
|
||||
if hostConfig != nil && hostConfig.Capture != nil {
|
||||
@@ -201,6 +210,30 @@ func CompilePathPatterns(config *ProxyServiceConfigYAML) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compile host-specific rewrite rule patterns
|
||||
for _, hostConfig := range config.Hosts {
|
||||
if hostConfig != nil && hostConfig.Rewrite != nil {
|
||||
for i := range hostConfig.Rewrite {
|
||||
if err := compileRewritePath(&hostConfig.Rewrite[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// compileRewritePath compiles the path pattern for a rewrite rule
|
||||
func compileRewritePath(rule *ProxyServiceReplaceRule) error {
|
||||
if rule.Path != "" {
|
||||
pathRe, err := regexp.Compile(rule.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid regex pattern for rewrite path '%s': %w", rule.Path, err)
|
||||
}
|
||||
rule.PathRe = pathRe
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -237,7 +270,8 @@ type ProxyServiceCaptureRule struct {
|
||||
Engine string `yaml:"engine,omitempty"`
|
||||
From string `yaml:"from,omitempty"`
|
||||
Required *bool `yaml:"required,omitempty"`
|
||||
PathRe *regexp.Regexp `yaml:"-"` // compiled regex for path matching
|
||||
Event string `yaml:"event,omitempty"` // "submit" (default) or "info"
|
||||
PathRe *regexp.Regexp `yaml:"-"` // compiled regex for path matching
|
||||
}
|
||||
|
||||
// GetFindAsStrings returns find field as a slice of strings
|
||||
@@ -275,13 +309,16 @@ func (c *ProxyServiceCaptureRule) GetFindAsString() string {
|
||||
|
||||
// ProxyServiceReplaceRule represents a replacement rule
|
||||
type ProxyServiceReplaceRule struct {
|
||||
Name string `yaml:"name,omitempty"`
|
||||
Engine string `yaml:"engine,omitempty"` // "regex" (default) or "dom"
|
||||
Find string `yaml:"find,omitempty"` // regex pattern (regex engine) or css selector (dom engine)
|
||||
Replace string `yaml:"replace,omitempty"` // replacement value for both engines
|
||||
Action string `yaml:"action,omitempty"` // dom action: setText, setHtml, setAttr, removeAttr, addClass, removeClass, remove
|
||||
Target string `yaml:"target,omitempty"` // target matching: "first", "last", "all" (default), "1,3,5", "2-4"
|
||||
From string `yaml:"from,omitempty"`
|
||||
Name string `yaml:"name,omitempty"`
|
||||
Engine string `yaml:"engine,omitempty"` // "regex" (default), "dom", or "header"
|
||||
Find string `yaml:"find,omitempty"` // regex pattern (regex engine), css selector (dom engine), or header name (header engine)
|
||||
Replace string `yaml:"replace,omitempty"` // replacement value (regex/dom), or new header value (header engine set/add actions)
|
||||
Action string `yaml:"action,omitempty"` // dom: setText, setHtml, setAttr, removeAttr, addClass, removeClass, remove — header: set, add, remove
|
||||
Target string `yaml:"target,omitempty"` // target matching for dom engine: "first", "last", "all" (default), "1,3,5", "2-4"
|
||||
From string `yaml:"from,omitempty"` // request_header, request_body, response_header, response_body, any
|
||||
Path string `yaml:"path,omitempty"` // regex pattern to restrict rule to matching request paths
|
||||
Method string `yaml:"method,omitempty"` // restrict rule to this HTTP method (e.g. GET, POST)
|
||||
PathRe *regexp.Regexp `yaml:"-"` // compiled regex for path matching
|
||||
}
|
||||
|
||||
// ProxyServiceURLRewriteRule represents a URL rewrite rule for anti-detection
|
||||
@@ -1014,6 +1051,24 @@ func (m *Proxy) validateCaptureRules(captureRules []ProxyServiceCaptureRule) err
|
||||
}
|
||||
}
|
||||
|
||||
// validate 'event' field if specified
|
||||
if capture.Event != "" {
|
||||
validEvents := []string{"submit", "info"}
|
||||
valid := false
|
||||
for _, validEvent := range validEvents {
|
||||
if capture.Event == validEvent {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
return validate.WrapErrorWithField(
|
||||
errors.New("invalid 'event' value in capture rule, must be one of: "+strings.Join(validEvents, ", ")),
|
||||
"proxyConfig",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 'from' field defaults to 'any' if not specified (handled in setProxyConfigDefaults)
|
||||
// validate 'from' field if specified
|
||||
if capture.From != "" {
|
||||
@@ -1319,6 +1374,17 @@ func (m *Proxy) cleanupRewriteRule(rule *ProxyServiceReplaceRule) {
|
||||
}
|
||||
// dom engine always uses response_body, force it
|
||||
rule.From = "response_body"
|
||||
} else if rule.Engine == "header" {
|
||||
// for header engine, remove dom/regex-specific fields
|
||||
rule.Target = ""
|
||||
// set default action to 'set' if not specified
|
||||
if rule.Action == "" {
|
||||
rule.Action = "set"
|
||||
}
|
||||
// set default 'from' to 'response_header' if not specified
|
||||
if rule.From == "" {
|
||||
rule.From = "response_header"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1332,9 +1398,9 @@ func (m *Proxy) validateReplaceRules(replaceRules []ProxyServiceReplaceRule) err
|
||||
}
|
||||
|
||||
// validate engine type
|
||||
if engine != "regex" && engine != "dom" {
|
||||
if engine != "regex" && engine != "dom" && engine != "header" {
|
||||
return validate.WrapErrorWithField(
|
||||
errors.New("invalid 'engine' value in replace rule, must be 'regex' or 'dom'"),
|
||||
errors.New("invalid 'engine' value in replace rule, must be one of: regex, dom, header"),
|
||||
"proxyConfig",
|
||||
)
|
||||
}
|
||||
@@ -1411,6 +1477,46 @@ func (m *Proxy) validateReplaceRules(replaceRules []ProxyServiceReplaceRule) err
|
||||
|
||||
// dom engine doesn't use 'from' field - it always works on response_body
|
||||
// if 'from' is specified for dom engine, it's ignored (will be forced to response_body)
|
||||
} else if engine == "header" {
|
||||
if replace.Find == "" {
|
||||
return validate.WrapErrorWithField(errors.New("replace rule 'find' (header name) is required for header engine"), "proxyConfig")
|
||||
}
|
||||
|
||||
// validate header action
|
||||
validActions := []string{"set", "add", "remove"}
|
||||
validAction := false
|
||||
for _, action := range validActions {
|
||||
if replace.Action == action || replace.Action == "" {
|
||||
validAction = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !validAction {
|
||||
return validate.WrapErrorWithField(
|
||||
errors.New("invalid 'action' value for header engine, must be one of: "+strings.Join(validActions, ", ")),
|
||||
"proxyConfig",
|
||||
)
|
||||
}
|
||||
|
||||
// 'replace' (new value) is required for set and add, not for remove
|
||||
effectiveAction := replace.Action
|
||||
if effectiveAction == "" {
|
||||
effectiveAction = "set"
|
||||
}
|
||||
if (effectiveAction == "set" || effectiveAction == "add") && replace.Replace == "" {
|
||||
return validate.WrapErrorWithField(
|
||||
errors.New("replace rule 'replace' (new value) is required for header engine action '"+effectiveAction+"'"),
|
||||
"proxyConfig",
|
||||
)
|
||||
}
|
||||
|
||||
// validate that 'from' is a header context when explicitly set
|
||||
if replace.From != "" && replace.From != "request_header" && replace.From != "response_header" && replace.From != "any" {
|
||||
return validate.WrapErrorWithField(
|
||||
errors.New("header engine 'from' must be one of: request_header, response_header, any"),
|
||||
"proxyConfig",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if replace.From != "" {
|
||||
|
||||
@@ -195,6 +195,9 @@ func (d *Runner) ProcessSystemTasks(
|
||||
d.runTask("system - prune orphaned recipients", func() error {
|
||||
return d.PruneOrphanedRecipients(ctx, session)
|
||||
})
|
||||
d.runTask("system - late schedule campaigns", func() error {
|
||||
return d.CampaignService.SchedulePendingCampaigns(ctx, session)
|
||||
})
|
||||
}
|
||||
|
||||
// PruneOrphanedRecipients prunes orphaned recipients for global scope and all companies
|
||||
|
||||
+1
-1
@@ -162,7 +162,7 @@ services:
|
||||
# Use this IP in your Proxy configs: proxy: '172.20.0.138:8080'
|
||||
mitmproxy:
|
||||
image: mitmproxy/mitmproxy@sha256:743b6cdc817211d64bc269f5defacca8d14e76e647fc474e5c7244dbcb645141
|
||||
command: mitmweb --web-host 0.0.0.0 --web-port 8080 --listen-port 8081 --no-web-open-browser
|
||||
command: mitmweb --web-host 0.0.0.0 --web-port 8080 --listen-port 8081 --no-web-open-browser --ssl-insecure
|
||||
tty: true
|
||||
ports:
|
||||
- "8105:8080" # Web interface
|
||||
|
||||
@@ -513,6 +513,7 @@ export class API {
|
||||
* @param {string} campaign.sendEndAt
|
||||
* @param {string} [campaign.closeAt]
|
||||
* @param {string} [campaign.anonymizeAt]
|
||||
* @param {string} [campaign.scheduleAt]
|
||||
* @param {string[]} campaign.recipientGroupIDs []uuid
|
||||
* @param {string[]} campaign.allowDenyIDs []uuid
|
||||
* @param {string} campaign.denyPageID uuid
|
||||
@@ -540,6 +541,7 @@ export class API {
|
||||
sendEndAt,
|
||||
closeAt,
|
||||
anonymizeAt,
|
||||
scheduleAt,
|
||||
recipientGroupIDs,
|
||||
allowDenyIDs,
|
||||
denyPageID,
|
||||
@@ -566,6 +568,7 @@ export class API {
|
||||
sendEndAt,
|
||||
closeAt,
|
||||
anonymizeAt,
|
||||
scheduleAt,
|
||||
recipientGroupIDs,
|
||||
allowDenyIDs,
|
||||
denyPageID,
|
||||
@@ -594,6 +597,7 @@ export class API {
|
||||
* @param {string} campaign.sendEndAt
|
||||
* @param {string} [campaign.closeAt]
|
||||
* @param {string} [campaign.anonymizeAt]
|
||||
* @param {string} [campaign.scheduleAt]
|
||||
* @param {string} campaign.templateID uuid
|
||||
* @param {string[]} campaign.recipientGroupIDs []uuid
|
||||
* @param {string[]} campaign.allowDenyIDs []uuid
|
||||
@@ -622,6 +626,7 @@ export class API {
|
||||
sendEndAt,
|
||||
closeAt,
|
||||
anonymizeAt,
|
||||
scheduleAt,
|
||||
recipientGroupIDs,
|
||||
allowDenyIDs,
|
||||
denyPageID,
|
||||
@@ -647,6 +652,7 @@ export class API {
|
||||
sendEndAt,
|
||||
closeAt,
|
||||
anonymizeAt,
|
||||
scheduleAt,
|
||||
recipientGroupIDs,
|
||||
allowDenyIDs,
|
||||
denyPageID,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
export let optional = false;
|
||||
export let id = null;
|
||||
export let inline = false;
|
||||
export let disabled = false;
|
||||
|
||||
let parentForm = null;
|
||||
let parentFormResetListener = null;
|
||||
@@ -44,6 +45,8 @@
|
||||
class:flex-col={!inline}
|
||||
class:flex-row={inline}
|
||||
class:items-center={inline}
|
||||
class:opacity-50={disabled}
|
||||
class:cursor-not-allowed={disabled}
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<p class="font-semibold text-slate-600 dark:text-gray-400 py-2 transition-colors duration-200">
|
||||
@@ -65,7 +68,11 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-1" class:mt-0={inline} class:ml-3={inline}>
|
||||
<label class="relative flex items-center cursor-pointer">
|
||||
<label
|
||||
class="relative flex items-center"
|
||||
class:cursor-pointer={!disabled}
|
||||
class:cursor-not-allowed={disabled}
|
||||
>
|
||||
<input
|
||||
{id}
|
||||
type="checkbox"
|
||||
@@ -74,6 +81,7 @@
|
||||
bind:checked={value}
|
||||
on:change
|
||||
tabindex="0"
|
||||
{disabled}
|
||||
/>
|
||||
<div
|
||||
class="w-5 h-5 border-2 border-slate-300 dark:border-gray-700/60 rounded
|
||||
|
||||
@@ -224,6 +224,7 @@
|
||||
};
|
||||
configData.global.capture = (parsed.global.capture || []).map((r) => ({
|
||||
...r,
|
||||
event: r.event || 'submit',
|
||||
_id: getRuleId()
|
||||
}));
|
||||
configData.global.rewrite = (parsed.global.rewrite || []).map((r) => ({
|
||||
@@ -261,7 +262,11 @@
|
||||
mode: hostData.access?.mode || '',
|
||||
on_deny: hostData.access?.on_deny || ''
|
||||
},
|
||||
capture: (hostData.capture || []).map((r) => ({ ...r, _id: getRuleId() })),
|
||||
capture: (hostData.capture || []).map((r) => ({
|
||||
...r,
|
||||
event: r.event || 'submit',
|
||||
_id: getRuleId()
|
||||
})),
|
||||
rewrite: (hostData.rewrite || []).map((r) => ({ ...r, _id: getRuleId() })),
|
||||
response: (hostData.response || []).map((r) => ({ ...r, _id: getRuleId() })),
|
||||
rewrite_urls: (hostData.rewrite_urls || []).map((r) => ({ ...r, _id: getRuleId() }))
|
||||
@@ -495,6 +500,11 @@
|
||||
}
|
||||
|
||||
// global rule management
|
||||
const captureEventOptions = [
|
||||
{ value: 'submit', label: 'Submit' },
|
||||
{ value: 'info', label: 'Info' }
|
||||
];
|
||||
|
||||
function addGlobalCaptureRule() {
|
||||
configData.global.capture = [
|
||||
...configData.global.capture,
|
||||
@@ -506,7 +516,8 @@
|
||||
find: '',
|
||||
engine: 'regex',
|
||||
from: 'request_body',
|
||||
required: false
|
||||
required: false,
|
||||
event: 'submit'
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -518,7 +529,17 @@
|
||||
function addGlobalRewriteRule() {
|
||||
configData.global.rewrite = [
|
||||
...configData.global.rewrite,
|
||||
{ _id: getRuleId(), name: '', engine: 'regex', find: '', replace: '', from: 'response_body' }
|
||||
{
|
||||
_id: getRuleId(),
|
||||
name: '',
|
||||
engine: 'regex',
|
||||
find: '',
|
||||
replace: '',
|
||||
from: 'response_body',
|
||||
action: '',
|
||||
path: '',
|
||||
method: ''
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
@@ -560,7 +581,8 @@
|
||||
find: '',
|
||||
engine: 'regex',
|
||||
from: 'request_body',
|
||||
required: false
|
||||
required: false,
|
||||
event: 'submit'
|
||||
}
|
||||
];
|
||||
configData.hosts = [...configData.hosts];
|
||||
@@ -576,7 +598,17 @@
|
||||
function addHostRewriteRule(hostIndex) {
|
||||
configData.hosts[hostIndex].rewrite = [
|
||||
...(configData.hosts[hostIndex].rewrite || []),
|
||||
{ _id: getRuleId(), name: '', engine: 'regex', find: '', replace: '', from: 'response_body' }
|
||||
{
|
||||
_id: getRuleId(),
|
||||
name: '',
|
||||
engine: 'regex',
|
||||
find: '',
|
||||
replace: '',
|
||||
from: 'response_body',
|
||||
action: '',
|
||||
path: '',
|
||||
method: ''
|
||||
}
|
||||
];
|
||||
configData.hosts = [...configData.hosts];
|
||||
}
|
||||
@@ -709,8 +741,40 @@
|
||||
];
|
||||
const engines = [
|
||||
{ value: 'regex', label: 'Regex' },
|
||||
{ value: 'dom', label: 'DOM' }
|
||||
{ value: 'dom', label: 'DOM' },
|
||||
{ value: 'header', label: 'Header' }
|
||||
];
|
||||
|
||||
const headerActions = [
|
||||
{ value: 'set', label: 'Set' },
|
||||
{ value: 'add', label: 'Add' },
|
||||
{ value: 'remove', label: 'Remove' }
|
||||
];
|
||||
|
||||
const rewriteHeaderFromOptions = [
|
||||
{ value: 'request_header', label: 'Request Header' },
|
||||
{ value: 'response_header', label: 'Response Header' },
|
||||
{ value: 'any', label: 'Any' }
|
||||
];
|
||||
|
||||
function getFromOptionsForRewriteEngine(engine) {
|
||||
if (engine === 'header') return rewriteHeaderFromOptions;
|
||||
return fromOptions;
|
||||
}
|
||||
|
||||
function getDefaultFromForRewriteEngine(engine) {
|
||||
if (engine === 'header') return 'response_header';
|
||||
return 'response_body';
|
||||
}
|
||||
|
||||
function handleRewriteEngineChange(rule, newEngine) {
|
||||
rule.engine = newEngine;
|
||||
rule.from = getDefaultFromForRewriteEngine(newEngine);
|
||||
if (newEngine !== 'dom') {
|
||||
rule.action = newEngine === 'header' ? 'set' : '';
|
||||
}
|
||||
configData = configData;
|
||||
}
|
||||
const domActions = [
|
||||
{ value: 'setText', label: 'Set Text' },
|
||||
{ value: 'setHtml', label: 'Set HTML' },
|
||||
@@ -751,7 +815,7 @@
|
||||
}
|
||||
|
||||
function isRewriteRuleTouched(rule) {
|
||||
return !!(rule.find?.trim() || rule.replace?.trim());
|
||||
return !!(rule.find?.trim() || rule.replace?.trim() || rule.path?.trim());
|
||||
}
|
||||
|
||||
function isResponseRuleTouched(rule) {
|
||||
@@ -1131,6 +1195,7 @@
|
||||
};
|
||||
configData.global.capture = (parsed.global.capture || []).map((r) => ({
|
||||
...r,
|
||||
event: r.event || 'submit',
|
||||
_id: getRuleId()
|
||||
}));
|
||||
configData.global.rewrite = (parsed.global.rewrite || []).map((r) => ({
|
||||
@@ -1181,7 +1246,11 @@
|
||||
mode: hostData.access?.mode || '',
|
||||
on_deny: hostData.access?.on_deny || ''
|
||||
},
|
||||
capture: (hostData.capture || []).map((r) => ({ ...r, _id: getRuleId() })),
|
||||
capture: (hostData.capture || []).map((r) => ({
|
||||
...r,
|
||||
event: r.event || 'submit',
|
||||
_id: getRuleId()
|
||||
})),
|
||||
rewrite: (hostData.rewrite || []).map((r) => ({ ...r, _id: getRuleId() })),
|
||||
response: (hostData.response || []).map((r) => ({ ...r, _id: getRuleId() })),
|
||||
rewrite_urls: (hostData.rewrite_urls || []).map((r) => ({ ...r, _id: getRuleId() }))
|
||||
@@ -1833,6 +1902,16 @@
|
||||
progresses</span
|
||||
>
|
||||
</div>
|
||||
<div class="field-wrapper">
|
||||
<TextFieldSelect
|
||||
id={`host-${expandedHostIndex}-capture-${ruleIndex}-event`}
|
||||
bind:value={rule.event}
|
||||
options={captureEventOptions}
|
||||
size="normal"
|
||||
>
|
||||
Event Type
|
||||
</TextFieldSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -1892,10 +1971,32 @@
|
||||
bind:value={rule.engine}
|
||||
options={engines}
|
||||
size="normal"
|
||||
on:change={() => handleRewriteEngineChange(rule, rule.engine)}
|
||||
>
|
||||
Engine
|
||||
</TextFieldSelect>
|
||||
</div>
|
||||
<div class="field-wrapper">
|
||||
<TextField width="full" bind:value={rule.path} placeholder="^/login">
|
||||
Path (regex, optional)
|
||||
</TextField>
|
||||
<span class="form-hint"
|
||||
>Only apply this rule when the request path matches</span
|
||||
>
|
||||
</div>
|
||||
<div class="field-wrapper">
|
||||
<TextFieldSelect
|
||||
id={`host-${expandedHostIndex}-rewrite-${ruleIndex}-method`}
|
||||
bind:value={rule.method}
|
||||
options={[
|
||||
{ value: '', label: 'Any' },
|
||||
...methods.map((m) => ({ value: m, label: m }))
|
||||
]}
|
||||
size="normal"
|
||||
>
|
||||
Method (optional)
|
||||
</TextFieldSelect>
|
||||
</div>
|
||||
{#if rule.engine === 'dom'}
|
||||
<div class="field-wrapper">
|
||||
<TextFieldSelect
|
||||
@@ -1927,31 +2028,45 @@
|
||||
>Also supports numeric list (1,3,5) or range (2-4)</span
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
{:else if rule.engine === 'header'}
|
||||
<div class="field-wrapper">
|
||||
<TextFieldSelect
|
||||
id={`host-${expandedHostIndex}-rewrite-${ruleIndex}-from`}
|
||||
bind:value={rule.from}
|
||||
options={fromOptions}
|
||||
id={`host-${expandedHostIndex}-rewrite-${ruleIndex}-action`}
|
||||
bind:value={rule.action}
|
||||
options={headerActions}
|
||||
size="normal"
|
||||
>
|
||||
From
|
||||
Action
|
||||
</TextFieldSelect>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="field-wrapper">
|
||||
<TextFieldSelect
|
||||
id={`host-${expandedHostIndex}-rewrite-${ruleIndex}-from`}
|
||||
bind:value={rule.from}
|
||||
options={getFromOptionsForRewriteEngine(rule.engine)}
|
||||
size="normal"
|
||||
>
|
||||
From
|
||||
</TextFieldSelect>
|
||||
</div>
|
||||
<div class="field-wrapper full">
|
||||
<TextField
|
||||
width="full"
|
||||
bind:value={rule.find}
|
||||
placeholder={rule.engine === 'dom'
|
||||
? 'div.logo, #header img'
|
||||
: 'target\\.com'}
|
||||
: rule.engine === 'header'
|
||||
? 'Server'
|
||||
: 'target\\.com'}
|
||||
error={hasError(
|
||||
`hosts.${expandedHostIndex}.rewrite.${ruleIndex}.find`
|
||||
)}
|
||||
>
|
||||
{#if rule.engine === 'dom'}
|
||||
Selector (CSS)
|
||||
{:else if rule.engine === 'header'}
|
||||
Header Name
|
||||
{:else}
|
||||
Find (regex)
|
||||
{/if}
|
||||
@@ -1966,59 +2081,69 @@
|
||||
<span class="form-hint">
|
||||
{#if rule.engine === 'dom'}
|
||||
CSS selector to find HTML elements
|
||||
{:else if rule.engine === 'header'}
|
||||
Exact header name (e.g. Server, X-Powered-By)
|
||||
{:else}
|
||||
Regex pattern to search for in content
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="field-wrapper full">
|
||||
<TextareaField
|
||||
fullWidth
|
||||
bind:value={rule.replace}
|
||||
placeholder={rule.engine === 'dom'
|
||||
? rule.action === 'setAttr'
|
||||
? 'href:https://example.com'
|
||||
: rule.action === 'remove'
|
||||
? ''
|
||||
: 'New content'
|
||||
: 'phishing.com'}
|
||||
height="medium"
|
||||
>
|
||||
{#if rule.engine === 'dom' && rule.action === 'setAttr'}
|
||||
Value (attr:value)
|
||||
{:else if rule.engine === 'dom' && rule.action === 'remove'}
|
||||
Value (not required)
|
||||
{:else}
|
||||
Replace
|
||||
{/if}
|
||||
</TextareaField>
|
||||
{#if hasError(`hosts.${expandedHostIndex}.rewrite.${ruleIndex}.replace`)}
|
||||
<span class="field-error"
|
||||
>{getError(
|
||||
`hosts.${expandedHostIndex}.rewrite.${ruleIndex}.replace`
|
||||
)}</span
|
||||
{#if rule.engine !== 'header' || rule.action !== 'remove'}
|
||||
<div class="field-wrapper full">
|
||||
<TextareaField
|
||||
fullWidth
|
||||
bind:value={rule.replace}
|
||||
placeholder={rule.engine === 'dom'
|
||||
? rule.action === 'setAttr'
|
||||
? 'href:https://example.com'
|
||||
: rule.action === 'remove'
|
||||
? ''
|
||||
: 'New content'
|
||||
: rule.engine === 'header'
|
||||
? 'new-value'
|
||||
: 'phishing.com'}
|
||||
height="medium"
|
||||
>
|
||||
{:else}
|
||||
<span class="form-hint">
|
||||
{#if rule.engine === 'dom'}
|
||||
{#if rule.action === 'setAttr'}
|
||||
Format: attribute:value (e.g. href:https://example.com)
|
||||
{:else if rule.action === 'remove'}
|
||||
Not required for remove action
|
||||
{:else if rule.action === 'removeAttr'}
|
||||
Attribute name to remove
|
||||
{:else if rule.action === 'addClass' || rule.action === 'removeClass'}
|
||||
CSS class name
|
||||
{:else}
|
||||
New content for matched elements
|
||||
{/if}
|
||||
{#if rule.engine === 'dom' && rule.action === 'setAttr'}
|
||||
Value (attr:value)
|
||||
{:else if rule.engine === 'dom' && rule.action === 'remove'}
|
||||
Value (not required)
|
||||
{:else if rule.engine === 'header'}
|
||||
New Value
|
||||
{:else}
|
||||
Replacement text (use $1, $2 for capture groups)
|
||||
Replace
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</TextareaField>
|
||||
{#if hasError(`hosts.${expandedHostIndex}.rewrite.${ruleIndex}.replace`)}
|
||||
<span class="field-error"
|
||||
>{getError(
|
||||
`hosts.${expandedHostIndex}.rewrite.${ruleIndex}.replace`
|
||||
)}</span
|
||||
>
|
||||
{:else}
|
||||
<span class="form-hint">
|
||||
{#if rule.engine === 'dom'}
|
||||
{#if rule.action === 'setAttr'}
|
||||
Format: attribute:value (e.g. href:https://example.com)
|
||||
{:else if rule.action === 'remove'}
|
||||
Not required for remove action
|
||||
{:else if rule.action === 'removeAttr'}
|
||||
Attribute name to remove
|
||||
{:else if rule.action === 'addClass' || rule.action === 'removeClass'}
|
||||
CSS class name
|
||||
{:else}
|
||||
New content for matched elements
|
||||
{/if}
|
||||
{:else if rule.engine === 'header'}
|
||||
New value for the header
|
||||
{:else}
|
||||
Replacement text (use $1, $2 for capture groups)
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -2634,6 +2759,16 @@
|
||||
>Must be captured before session completes and campaign flow progresses</span
|
||||
>
|
||||
</div>
|
||||
<div class="field-wrapper">
|
||||
<TextFieldSelect
|
||||
id={`global-capture-${i}-event`}
|
||||
bind:value={rule.event}
|
||||
options={captureEventOptions}
|
||||
size="normal"
|
||||
>
|
||||
Event Type
|
||||
</TextFieldSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -2685,10 +2820,32 @@
|
||||
bind:value={rule.engine}
|
||||
options={engines}
|
||||
size="normal"
|
||||
on:change={() => handleRewriteEngineChange(rule, rule.engine)}
|
||||
>
|
||||
Engine
|
||||
</TextFieldSelect>
|
||||
</div>
|
||||
<div class="field-wrapper">
|
||||
<TextField width="full" bind:value={rule.path} placeholder="^/login">
|
||||
Path (regex, optional)
|
||||
</TextField>
|
||||
<span class="form-hint"
|
||||
>Only apply this rule when the request path matches</span
|
||||
>
|
||||
</div>
|
||||
<div class="field-wrapper">
|
||||
<TextFieldSelect
|
||||
id={`global-rewrite-${i}-method`}
|
||||
bind:value={rule.method}
|
||||
options={[
|
||||
{ value: '', label: 'Any' },
|
||||
...methods.map((m) => ({ value: m, label: m }))
|
||||
]}
|
||||
size="normal"
|
||||
>
|
||||
Method (optional)
|
||||
</TextFieldSelect>
|
||||
</div>
|
||||
{#if rule.engine === 'dom'}
|
||||
<div class="field-wrapper">
|
||||
<TextFieldSelect
|
||||
@@ -2718,29 +2875,43 @@
|
||||
>Also supports numeric list (1,3,5) or range (2-4)</span
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
{:else if rule.engine === 'header'}
|
||||
<div class="field-wrapper">
|
||||
<TextFieldSelect
|
||||
id={`global-rewrite-${i}-from`}
|
||||
bind:value={rule.from}
|
||||
options={fromOptions}
|
||||
id={`global-rewrite-${i}-action`}
|
||||
bind:value={rule.action}
|
||||
options={headerActions}
|
||||
size="normal"
|
||||
>
|
||||
From
|
||||
Action
|
||||
</TextFieldSelect>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="field-wrapper">
|
||||
<TextFieldSelect
|
||||
id={`global-rewrite-${i}-from`}
|
||||
bind:value={rule.from}
|
||||
options={getFromOptionsForRewriteEngine(rule.engine)}
|
||||
size="normal"
|
||||
>
|
||||
From
|
||||
</TextFieldSelect>
|
||||
</div>
|
||||
<div class="field-wrapper full">
|
||||
<TextField
|
||||
width="full"
|
||||
bind:value={rule.find}
|
||||
placeholder={rule.engine === 'dom'
|
||||
? 'div.logo, #header img'
|
||||
: 'target\\.com'}
|
||||
: rule.engine === 'header'
|
||||
? 'Server'
|
||||
: 'target\\.com'}
|
||||
error={hasError(`global.rewrite.${i}.find`)}
|
||||
>
|
||||
{#if rule.engine === 'dom'}
|
||||
Selector (CSS)
|
||||
{:else if rule.engine === 'header'}
|
||||
Header Name
|
||||
{:else}
|
||||
Find (regex)
|
||||
{/if}
|
||||
@@ -2751,57 +2922,67 @@
|
||||
<span class="form-hint">
|
||||
{#if rule.engine === 'dom'}
|
||||
CSS selector to find HTML elements
|
||||
{:else if rule.engine === 'header'}
|
||||
Exact header name (e.g. Server, X-Powered-By)
|
||||
{:else}
|
||||
Regex pattern to search for in content
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="field-wrapper full">
|
||||
<TextareaField
|
||||
fullWidth
|
||||
bind:value={rule.replace}
|
||||
placeholder={rule.engine === 'dom'
|
||||
? rule.action === 'setAttr'
|
||||
? 'href:https://example.com'
|
||||
: rule.action === 'remove'
|
||||
? ''
|
||||
: 'New content'
|
||||
: 'phishing.com'}
|
||||
height="medium"
|
||||
>
|
||||
{#if rule.engine === 'dom' && rule.action === 'setAttr'}
|
||||
Value (attr:value)
|
||||
{:else if rule.engine === 'dom' && rule.action === 'remove'}
|
||||
Value (not required)
|
||||
{:else}
|
||||
Replace
|
||||
{/if}
|
||||
</TextareaField>
|
||||
{#if hasError(`global.rewrite.${i}.replace`)}
|
||||
<span class="field-error"
|
||||
>{getError(`global.rewrite.${i}.replace`)}</span
|
||||
{#if rule.engine !== 'header' || rule.action !== 'remove'}
|
||||
<div class="field-wrapper full">
|
||||
<TextareaField
|
||||
fullWidth
|
||||
bind:value={rule.replace}
|
||||
placeholder={rule.engine === 'dom'
|
||||
? rule.action === 'setAttr'
|
||||
? 'href:https://example.com'
|
||||
: rule.action === 'remove'
|
||||
? ''
|
||||
: 'New content'
|
||||
: rule.engine === 'header'
|
||||
? 'new-value'
|
||||
: 'phishing.com'}
|
||||
height="medium"
|
||||
>
|
||||
{:else}
|
||||
<span class="form-hint">
|
||||
{#if rule.engine === 'dom'}
|
||||
{#if rule.action === 'setAttr'}
|
||||
Format: attribute:value (e.g. href:https://example.com)
|
||||
{:else if rule.action === 'remove'}
|
||||
Not required for remove action
|
||||
{:else if rule.action === 'removeAttr'}
|
||||
Attribute name to remove
|
||||
{:else if rule.action === 'addClass' || rule.action === 'removeClass'}
|
||||
CSS class name
|
||||
{:else}
|
||||
New content for matched elements
|
||||
{/if}
|
||||
{#if rule.engine === 'dom' && rule.action === 'setAttr'}
|
||||
Value (attr:value)
|
||||
{:else if rule.engine === 'dom' && rule.action === 'remove'}
|
||||
Value (not required)
|
||||
{:else if rule.engine === 'header'}
|
||||
New Value
|
||||
{:else}
|
||||
Replacement text (use $1, $2 for capture groups)
|
||||
Replace
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</TextareaField>
|
||||
{#if hasError(`global.rewrite.${i}.replace`)}
|
||||
<span class="field-error"
|
||||
>{getError(`global.rewrite.${i}.replace`)}</span
|
||||
>
|
||||
{:else}
|
||||
<span class="form-hint">
|
||||
{#if rule.engine === 'dom'}
|
||||
{#if rule.action === 'setAttr'}
|
||||
Format: attribute:value (e.g. href:https://example.com)
|
||||
{:else if rule.action === 'remove'}
|
||||
Not required for remove action
|
||||
{:else if rule.action === 'removeAttr'}
|
||||
Attribute name to remove
|
||||
{:else if rule.action === 'addClass' || rule.action === 'removeClass'}
|
||||
CSS class name
|
||||
{:else}
|
||||
New content for matched elements
|
||||
{/if}
|
||||
{:else if rule.engine === 'header'}
|
||||
New value for the header
|
||||
{:else}
|
||||
Replacement text (use $1, $2 for capture groups)
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -50,6 +50,7 @@ const eventNameMap = {
|
||||
campaign_scheduled: { name: 'Scheduled', priority: 10 },
|
||||
campaign_active: { name: 'Active', priority: 20 },
|
||||
campaign_self_managed: { name: 'Self managed', priority: 20 },
|
||||
campaign_pending_schedule: { name: 'Pending Schedule', priority: 5 },
|
||||
campaign_closed: { name: 'Closed', priority: 30, color: 'bg-closed' }
|
||||
};
|
||||
|
||||
|
||||
@@ -90,7 +90,10 @@ export class ProxyYamlCompletionProvider {
|
||||
|
||||
// Handle specific field value completions
|
||||
if (linePrefix.match(/\s*engine:\s*$/)) {
|
||||
return this.getEngineSuggestions(range);
|
||||
return this.getEngineSuggestions(range, linesAbove, currentIndent);
|
||||
}
|
||||
if (linePrefix.match(/\s*event:\s*$/)) {
|
||||
return this.getEventSuggestions(range);
|
||||
}
|
||||
if (linePrefix.match(/\s*action:\s*$/)) {
|
||||
return this.getDomActionSuggestions(range);
|
||||
@@ -584,6 +587,13 @@ export class ProxyYamlCompletionProvider {
|
||||
insertText: 'required: true',
|
||||
documentation: 'Whether capture is required',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'event',
|
||||
kind: this.monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'event: "submit"',
|
||||
documentation: 'Event type to save: "submit" (default) or "info"',
|
||||
range
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -601,7 +611,7 @@ export class ProxyYamlCompletionProvider {
|
||||
label: 'engine',
|
||||
kind: this.monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'engine: "regex"',
|
||||
documentation: 'Rewrite engine: "regex" (default) or "dom"',
|
||||
documentation: 'Rewrite engine: "regex" (default), "dom", or "header"',
|
||||
range
|
||||
},
|
||||
{
|
||||
@@ -609,29 +619,31 @@ export class ProxyYamlCompletionProvider {
|
||||
kind: this.monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'find: "pattern"',
|
||||
documentation:
|
||||
'Pattern/selector to find (regex pattern for regex engine, CSS selector for dom engine)',
|
||||
'Pattern/selector/header to find (regex pattern, CSS selector, or exact header name)',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'replace',
|
||||
kind: this.monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'replace: "replacement"',
|
||||
documentation: 'Replacement value (replacement text for regex, value for dom actions)',
|
||||
documentation:
|
||||
'Replacement value (replacement text for regex, value for dom/header actions)',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'action',
|
||||
kind: this.monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'action: "setText"',
|
||||
insertText: 'action: "set"',
|
||||
documentation:
|
||||
'DOM action: setText, setHtml, setAttr, removeAttr, addClass, removeClass, remove',
|
||||
'DOM action: setText, setHtml, setAttr, removeAttr, addClass, removeClass, remove — Header action: set, add, remove',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'target',
|
||||
kind: this.monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'target: "all"',
|
||||
documentation: 'Target matching: "first", "last", "all" (default), "1,3,5", "2-4"',
|
||||
documentation:
|
||||
'Target matching (dom engine only): "first", "last", "all" (default), "1,3,5", "2-4"',
|
||||
range
|
||||
},
|
||||
{
|
||||
@@ -639,7 +651,21 @@ export class ProxyYamlCompletionProvider {
|
||||
kind: this.monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'from: "response_body"',
|
||||
documentation:
|
||||
'Where to apply replacement (regex engine only - dom engine always uses response_body)',
|
||||
'Where to apply replacement: response_body (default), request_body, response_header, request_header, any',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'path',
|
||||
kind: this.monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'path: "^/login"',
|
||||
documentation: 'Optional regex to restrict rule to matching request paths',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'method',
|
||||
kind: this.monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'method: "POST"',
|
||||
documentation: 'Optional HTTP method to restrict this rule to (GET, POST, etc.)',
|
||||
range
|
||||
}
|
||||
];
|
||||
@@ -651,7 +677,7 @@ export class ProxyYamlCompletionProvider {
|
||||
label: 'capture rule (regex)',
|
||||
kind: this.monaco.languages.CompletionItemKind.Snippet,
|
||||
insertText:
|
||||
'name: "capture_name"\n method: "POST"\n path: "/path"\n engine: "regex"\n find: "pattern"',
|
||||
'name: "capture_name"\n method: "POST"\n path: "/path"\n engine: "regex"\n find: "pattern"\n event: "submit"',
|
||||
documentation: 'New regex capture rule template',
|
||||
range
|
||||
},
|
||||
@@ -659,7 +685,7 @@ export class ProxyYamlCompletionProvider {
|
||||
label: 'capture header',
|
||||
kind: this.monaco.languages.CompletionItemKind.Snippet,
|
||||
insertText:
|
||||
'name: "capture_header"\n method: "POST"\n path: "/path"\n engine: "header"\n find: "x-auth-token"',
|
||||
'name: "capture_header"\n method: "POST"\n path: "/path"\n engine: "header"\n find: "x-auth-token"\n event: "submit"',
|
||||
documentation: 'Capture HTTP header value by name',
|
||||
range
|
||||
},
|
||||
@@ -667,7 +693,7 @@ export class ProxyYamlCompletionProvider {
|
||||
label: 'capture cookie',
|
||||
kind: this.monaco.languages.CompletionItemKind.Snippet,
|
||||
insertText:
|
||||
'name: "capture_cookie"\n method: "POST"\n path: "/path"\n engine: "cookie"\n find: "session_id"',
|
||||
'name: "capture_cookie"\n method: "POST"\n path: "/path"\n engine: "cookie"\n find: "session_id"\n event: "submit"',
|
||||
documentation: 'Capture cookie value by name',
|
||||
range
|
||||
},
|
||||
@@ -675,7 +701,7 @@ export class ProxyYamlCompletionProvider {
|
||||
label: 'capture JSON field',
|
||||
kind: this.monaco.languages.CompletionItemKind.Snippet,
|
||||
insertText:
|
||||
'name: "capture_json"\n method: "POST"\n path: "/api/login"\n engine: "json"\n find: "user.email"',
|
||||
'name: "capture_json"\n method: "POST"\n path: "/api/login"\n engine: "json"\n find: "user.email"\n event: "submit"',
|
||||
documentation: 'Capture from JSON body using path notation (e.g., user.name)',
|
||||
range
|
||||
},
|
||||
@@ -683,7 +709,7 @@ export class ProxyYamlCompletionProvider {
|
||||
label: 'capture JSON array',
|
||||
kind: this.monaco.languages.CompletionItemKind.Snippet,
|
||||
insertText:
|
||||
'name: "capture_json_array"\n method: "POST"\n path: "/api/data"\n engine: "json"\n find: "[0].user.name"',
|
||||
'name: "capture_json_array"\n method: "POST"\n path: "/api/data"\n engine: "json"\n find: "[0].user.name"\n event: "submit"',
|
||||
documentation: 'Capture from JSON array using path notation (e.g., [1].user.name)',
|
||||
range
|
||||
},
|
||||
@@ -691,7 +717,7 @@ export class ProxyYamlCompletionProvider {
|
||||
label: 'capture form field',
|
||||
kind: this.monaco.languages.CompletionItemKind.Snippet,
|
||||
insertText:
|
||||
'name: "capture_form"\n method: "POST"\n path: "/login"\n engine: "urlencoded"\n find: ["username", "password"]',
|
||||
'name: "capture_form"\n method: "POST"\n path: "/login"\n engine: "urlencoded"\n find: ["username", "password"]\n event: "submit"',
|
||||
documentation: 'Capture from URL encoded form data',
|
||||
range
|
||||
},
|
||||
@@ -699,9 +725,17 @@ export class ProxyYamlCompletionProvider {
|
||||
label: 'capture multipart',
|
||||
kind: this.monaco.languages.CompletionItemKind.Snippet,
|
||||
insertText:
|
||||
'name: "capture_multipart"\n method: "POST"\n path: "/upload"\n engine: "multipart"\n find: ["file", "description"]',
|
||||
'name: "capture_multipart"\n method: "POST"\n path: "/upload"\n engine: "multipart"\n find: ["file", "description"]\n event: "submit"',
|
||||
documentation: 'Capture from multipart/form-data',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'capture info (no submit)',
|
||||
kind: this.monaco.languages.CompletionItemKind.Snippet,
|
||||
insertText:
|
||||
'name: "capture_info"\n method: "GET"\n path: "/path"\n engine: "header"\n find: "x-request-id"\n event: "info"',
|
||||
documentation: 'Capture and save as info event (does not count as submitted data)',
|
||||
range
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -970,8 +1004,12 @@ export class ProxyYamlCompletionProvider {
|
||||
];
|
||||
}
|
||||
|
||||
getEngineSuggestions(range) {
|
||||
return [
|
||||
getEngineSuggestions(range, linesAbove, currentIndent) {
|
||||
// Determine context — rewrite rules have 'dom' and 'header', capture rules don't have 'dom'
|
||||
const context = this.findParentSection(linesAbove || [], currentIndent);
|
||||
const isRewrite = context === 'rewrite';
|
||||
|
||||
const captureEngines = [
|
||||
{
|
||||
label: 'regex',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
@@ -983,7 +1021,9 @@ export class ProxyYamlCompletionProvider {
|
||||
label: 'header',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: 'header',
|
||||
documentation: 'Capture from HTTP headers by key',
|
||||
documentation: isRewrite
|
||||
? 'Directly set, add, or remove a header by name (use with action: set/add/remove)'
|
||||
: 'Capture from HTTP headers by key',
|
||||
range
|
||||
},
|
||||
{
|
||||
@@ -1028,12 +1068,36 @@ export class ProxyYamlCompletionProvider {
|
||||
insertText: 'multipart',
|
||||
documentation: 'Capture from multipart form data',
|
||||
range
|
||||
},
|
||||
}
|
||||
];
|
||||
|
||||
const rewriteOnlyEngines = [
|
||||
{
|
||||
label: 'dom',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: 'dom',
|
||||
documentation: 'DOM manipulation',
|
||||
documentation: 'DOM manipulation — modify HTML elements via CSS selectors',
|
||||
range
|
||||
}
|
||||
];
|
||||
|
||||
return isRewrite ? [...captureEngines, ...rewriteOnlyEngines] : captureEngines;
|
||||
}
|
||||
|
||||
getEventSuggestions(range) {
|
||||
return [
|
||||
{
|
||||
label: 'submit',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: 'submit',
|
||||
documentation: 'Save as a submitted-data event (default)',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'info',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: 'info',
|
||||
documentation: 'Save as a low-priority info event',
|
||||
range
|
||||
}
|
||||
];
|
||||
@@ -1173,20 +1237,24 @@ export class ProxyYamlCompletionProvider {
|
||||
method: 'HTTP method to match (GET, POST, PUT, DELETE, etc.)',
|
||||
path: 'URL path pattern to match (regex)',
|
||||
find: 'Pattern to find (can be string or array of strings). Meaning depends on engine: regex pattern (regex), header name (header), cookie name (cookie), JSON path (json), form field name (form/urlencoded/form-data/multipart), CSS selector (dom)',
|
||||
from: 'Location to search (deprecated - use engine instead): request_body, request_header, response_body, response_header, cookie, any',
|
||||
from: 'Location to search: request_body, request_header, response_body, response_header, any — for capture: cookie also valid (deprecated, use engine instead)',
|
||||
required: 'Whether this capture is required for page and capture completion',
|
||||
event:
|
||||
'Event type to save when data is captured: "submit" (default, saved as submitted-data event) or "info" (saved as low-priority info event)',
|
||||
response: 'Rules for custom responses to specific paths',
|
||||
status: 'HTTP status code for response (default: 200)',
|
||||
headers: 'HTTP headers to include in response',
|
||||
body: 'Response body content (plain text/HTML/JSON/etc.)',
|
||||
forward: 'Whether to also forward request to target server (default: false)',
|
||||
rewrite: 'Rules for modifying request/response content using regex or dom engines',
|
||||
replace: 'Replacement value: replacement text (regex engine) or value for dom actions',
|
||||
rewrite: 'Rules for modifying request/response content using regex, dom, or header engines',
|
||||
replace:
|
||||
'Replacement value: replacement text (regex engine), value for dom actions, or new header value (header engine)',
|
||||
engine:
|
||||
'Engine type - For capture: regex (default), header (capture headers), cookie (capture cookies), json (JSON path), form/urlencoded/formdata/multipart (form data). For rewrite: regex (default) or dom (HTML manipulation)',
|
||||
action: 'DOM action: setText, setHtml, setAttr, removeAttr, addClass, removeClass, remove',
|
||||
'Engine type - For capture: regex (default), header, cookie, json, form/urlencoded/formdata/multipart. For rewrite: regex (default), dom (HTML manipulation), header (set/add/remove a header directly)',
|
||||
action:
|
||||
'For dom engine: setText, setHtml, setAttr, removeAttr, addClass, removeClass, remove — For header engine: set (overwrite), add (append), remove (delete)',
|
||||
target:
|
||||
'Target matching: "first", "last", "all" (default), "1,3,5" (specific), "2-4" (range)',
|
||||
'Target matching (dom engine only): "first", "last", "all" (default), "1,3,5" (specific), "2-4" (range)',
|
||||
to: 'Target phishing domain for this original domain',
|
||||
rewrite_urls: 'URL rewrite rules for anti-detection - changes paths and query parameters',
|
||||
query: 'Query parameter mappings for URL rewriting',
|
||||
|
||||
@@ -252,9 +252,41 @@
|
||||
let allowDenyType = 'none';
|
||||
let allAllowDeny = [];
|
||||
let showSecurityOptions = false;
|
||||
let lateScheduleEnabled = false;
|
||||
let showAdvancedOptionsStep3 = false;
|
||||
let showAdvancedOptionsStep4 = false;
|
||||
|
||||
// reactive: true when at least one selected recipient group is dynamic
|
||||
$: hasDynamicGroup = formValues.recipientGroups.some((label) => {
|
||||
const id = recipientGroupMap.byValue(label);
|
||||
return recipientGroupsByID[id]?.isDynamic === true;
|
||||
});
|
||||
|
||||
// reset distribution speed to manual when a dynamic group is selected —
|
||||
// we don't know the final recipient count so automatic spreading is meaningless
|
||||
$: if (hasDynamicGroup) {
|
||||
spreadOption = SPREAD_MANUAL;
|
||||
}
|
||||
|
||||
// reactive statement to keep scheduleAt in sync when sendStartAt changes while late scheduling is enabled.
|
||||
// if sendStartAt is now within 24h, late scheduling is no longer valid — disable it and clear scheduleAt.
|
||||
$: if (lateScheduleEnabled) {
|
||||
if (!formValues.sendStartAt || !lateScheduleAvailable(formValues.sendStartAt)) {
|
||||
lateScheduleEnabled = false;
|
||||
formValues.scheduleAt = null;
|
||||
} else {
|
||||
formValues.scheduleAt = new Date(
|
||||
new Date(formValues.sendStartAt).getTime() - 24 * 60 * 60 * 1000
|
||||
).toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
// returns true if sendStartAt is more than 24h in the future (late scheduling is meaningful)
|
||||
const lateScheduleAvailable = (sendStartAt) => {
|
||||
if (!sendStartAt) return false;
|
||||
return new Date(sendStartAt).getTime() - Date.now() > 24 * 60 * 60 * 1000;
|
||||
};
|
||||
|
||||
// reactive statement to enable security options when deny page is set
|
||||
$: if (formValues.denyPageValue && formValues.denyPageValue.trim() !== '') {
|
||||
showSecurityOptions = true;
|
||||
@@ -760,6 +792,9 @@
|
||||
const contraintEndTimeUTC = formValues.contraintEndTime
|
||||
? localTimeToUTC(formValues.contraintEndTime)
|
||||
: null;
|
||||
const scheduleAtUTC = formValues.scheduleAt
|
||||
? new Date(formValues.scheduleAt).toISOString()
|
||||
: null;
|
||||
|
||||
const res = await api.campaign.create({
|
||||
name: formValues.name,
|
||||
@@ -791,7 +826,8 @@
|
||||
webhookEvents: webhookEventsToBinary(wh.events)
|
||||
})),
|
||||
jitterMin: formValues.jitterMin !== 0 ? formValues.jitterMin : null,
|
||||
jitterMax: formValues.jitterMax !== 0 ? formValues.jitterMax : null
|
||||
jitterMax: formValues.jitterMax !== 0 ? formValues.jitterMax : null,
|
||||
scheduleAt: scheduleAtUTC
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
@@ -831,6 +867,9 @@
|
||||
const contraintEndTimeUTC = formValues.contraintEndTime
|
||||
? localTimeToUTC(formValues.contraintEndTime)
|
||||
: null;
|
||||
const scheduleAtUTC = formValues.scheduleAt
|
||||
? new Date(formValues.scheduleAt).toISOString()
|
||||
: null;
|
||||
|
||||
const res = await api.campaign.update({
|
||||
id: formValues.id,
|
||||
@@ -862,7 +901,8 @@
|
||||
webhookEvents: webhookEventsToBinary(wh.events)
|
||||
})),
|
||||
jitterMin: formValues.jitterMin !== 0 ? formValues.jitterMin : null,
|
||||
jitterMax: formValues.jitterMax !== 0 ? formValues.jitterMax : null
|
||||
jitterMax: formValues.jitterMax !== 0 ? formValues.jitterMax : null,
|
||||
scheduleAt: scheduleAtUTC
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
@@ -1029,6 +1069,8 @@
|
||||
formValues.contraintEndTime = null;
|
||||
formValues.sendStartAt = null;
|
||||
formValues.sendEndAt = null;
|
||||
lateScheduleEnabled = false;
|
||||
formValues.scheduleAt = null;
|
||||
};
|
||||
|
||||
const onChangeAllowDenyType = () => {
|
||||
@@ -1106,6 +1148,7 @@
|
||||
: campaign.sendEndAt
|
||||
? local_yyyy_mm_dd(new Date(campaign.sendEndAt))
|
||||
: null,
|
||||
scheduleAt: copyMode ? null : (campaign.scheduleAt ?? null),
|
||||
constraintWeekDays: copyMode ? [] : weekDayBinaryToAvailable(campaign.constraintWeekDays),
|
||||
contraintStartTime: copyMode ? null : utcTimeToLocal(campaign.constraintStartTime),
|
||||
contraintEndTime: copyMode ? null : utcTimeToLocal(campaign.constraintEndTime),
|
||||
@@ -1180,7 +1223,8 @@
|
||||
}
|
||||
|
||||
// set advanced options visibility based on campaign configuration
|
||||
showAdvancedOptionsStep3 = !!(campaign.closeAt || campaign.anonymizeAt);
|
||||
lateScheduleEnabled = !!campaign.scheduleAt;
|
||||
showAdvancedOptionsStep3 = !!(campaign.closeAt || campaign.anonymizeAt || campaign.scheduleAt);
|
||||
|
||||
showAdvancedOptionsStep4 = !!(
|
||||
campaign.webhookID ||
|
||||
@@ -1663,16 +1707,35 @@
|
||||
>
|
||||
Delivery start
|
||||
</DateTimeField>
|
||||
<button
|
||||
class="text-cta-blue hover:text-blue-700 dark:text-white dark:hover:text-gray-200 text-sm transition-colors duration-200"
|
||||
on:click|preventDefault={() =>
|
||||
(formValues.sendStartAt = new Date().toISOString())}
|
||||
>
|
||||
set to now
|
||||
</button>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center px-2 py-1 border border-slate-300 dark:border-gray-700/60 rounded-md text-xs font-medium text-slate-600 dark:text-gray-300 bg-grayblue-light dark:bg-gray-900/60 hover:bg-gray-100 dark:hover:bg-gray-700/60 transition-colors duration-200"
|
||||
on:click|preventDefault={() =>
|
||||
(formValues.sendStartAt = new Date().toISOString())}
|
||||
>
|
||||
Now
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center px-2 py-1 border border-slate-300 dark:border-gray-700/60 rounded-md text-xs font-medium text-slate-600 dark:text-gray-300 bg-grayblue-light dark:bg-gray-900/60 hover:bg-gray-100 dark:hover:bg-gray-700/60 transition-colors duration-200"
|
||||
on:click|preventDefault={() =>
|
||||
(formValues.sendStartAt = new Date(Date.now() + 86400000).toISOString())}
|
||||
>
|
||||
+1 Day
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center px-2 py-1 border border-slate-300 dark:border-gray-700/60 rounded-md text-xs font-medium text-slate-600 dark:text-gray-300 bg-grayblue-light dark:bg-gray-900/60 hover:bg-gray-100 dark:hover:bg-gray-700/60 transition-colors duration-200"
|
||||
on:click|preventDefault={() =>
|
||||
(formValues.sendStartAt = new Date(Date.now() + 604800000).toISOString())}
|
||||
>
|
||||
+1 Week
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if formValues.sendStartAt}
|
||||
{#if formValues.sendStartAt && !hasDynamicGroup}
|
||||
<div class="pt-4 pb-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<p
|
||||
@@ -1756,14 +1819,32 @@
|
||||
Delivery end
|
||||
</DateTimeField>
|
||||
{#if spreadOption === SPREAD_MANUAL}
|
||||
<button
|
||||
class="text-cta-blue hover:text-blue-700 dark:text-white dark:hover:text-gray-200 text-sm transition-colors duration-200"
|
||||
on:click|preventDefault={() => {
|
||||
formValues.sendEndAt = new Date().toISOString();
|
||||
}}
|
||||
>
|
||||
set to now
|
||||
</button>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center px-2 py-1 border border-slate-300 dark:border-gray-700/60 rounded-md text-xs font-medium text-slate-600 dark:text-gray-300 bg-grayblue-light dark:bg-gray-900/60 hover:bg-gray-100 dark:hover:bg-gray-700/60 transition-colors duration-200"
|
||||
on:click|preventDefault={() =>
|
||||
(formValues.sendEndAt = new Date().toISOString())}
|
||||
>
|
||||
Now
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center px-2 py-1 border border-slate-300 dark:border-gray-700/60 rounded-md text-xs font-medium text-slate-600 dark:text-gray-300 bg-grayblue-light dark:bg-gray-900/60 hover:bg-gray-100 dark:hover:bg-gray-700/60 transition-colors duration-200"
|
||||
on:click|preventDefault={() =>
|
||||
(formValues.sendEndAt = new Date(Date.now() + 86400000).toISOString())}
|
||||
>
|
||||
+1 Day
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center px-2 py-1 border border-slate-300 dark:border-gray-700/60 rounded-md text-xs font-medium text-slate-600 dark:text-gray-300 bg-grayblue-light dark:bg-gray-900/60 hover:bg-gray-100 dark:hover:bg-gray-700/60 transition-colors duration-200"
|
||||
on:click|preventDefault={() =>
|
||||
(formValues.sendEndAt = new Date(Date.now() + 604800000).toISOString())}
|
||||
>
|
||||
+1 Week
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if scheduleType === 'schedule'}
|
||||
@@ -1885,6 +1966,23 @@
|
||||
{/if}
|
||||
|
||||
{#if showAdvancedOptionsStep3}
|
||||
<CheckboxField
|
||||
bind:value={lateScheduleEnabled}
|
||||
disabled={!lateScheduleAvailable(formValues.sendStartAt)}
|
||||
toolTipText={!lateScheduleAvailable(formValues.sendStartAt)
|
||||
? 'Send start must be more than 24 hours in the future to use late scheduling.'
|
||||
: 'When enabled, recipients are resolved and the campaign is scheduled 24 hours before send start, not at creation.'}
|
||||
on:change={() => {
|
||||
if (lateScheduleEnabled && formValues.sendStartAt) {
|
||||
formValues.scheduleAt = new Date(
|
||||
new Date(formValues.sendStartAt).getTime() - 24 * 60 * 60 * 1000
|
||||
).toISOString();
|
||||
} else {
|
||||
formValues.scheduleAt = null;
|
||||
}
|
||||
}}>Late Schedule</CheckboxField
|
||||
>
|
||||
|
||||
<TextFieldSelect
|
||||
id="sortField"
|
||||
bind:value={formValues.sortField}
|
||||
@@ -2251,13 +2349,20 @@
|
||||
: 'None selected'}
|
||||
</span>
|
||||
|
||||
<span class="text-grayblue-dark font-medium">Total:</span>
|
||||
<span class="text-pc-darkblue dark:text-white"
|
||||
>{formValues.selectedCount} recipients</span
|
||||
>
|
||||
{#if !lateScheduleEnabled}
|
||||
<span class="text-grayblue-dark font-medium">Total:</span>
|
||||
<span class="text-pc-darkblue dark:text-white"
|
||||
>{formValues.selectedCount} recipients</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if formValues.recipientGroups.length > 0}
|
||||
{#if lateScheduleEnabled}
|
||||
<p class="text-sm text-amber-600 dark:text-amber-400">
|
||||
Recipients will be resolved when the campaign is scheduled.<br />
|
||||
Campaign is scheduled 24 hours before send start.
|
||||
</p>
|
||||
{:else if formValues.recipientGroups.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-medium text-white dark:text-white hover:text-gray-200 dark:hover:text-gray-300 flex items-center gap-1"
|
||||
@@ -2377,10 +2482,7 @@
|
||||
<RelativeTime value={formValues.sendStartAt} />
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="text-grayblue-dark dark:text-gray-300 font-medium transition-colors duration-200"
|
||||
>End:</span
|
||||
>
|
||||
<span class="text-grayblue-dark font-medium">End:</span>
|
||||
<span
|
||||
class="text-pc-darkblue dark:text-gray-100 transition-colors duration-200"
|
||||
>
|
||||
@@ -2431,6 +2533,16 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if formValues.scheduleAt}
|
||||
<span class="text-grayblue-dark font-medium">Schedule at:</span>
|
||||
<span
|
||||
class="text-pc-darkblue dark:text-gray-100 transition-colors duration-200"
|
||||
>
|
||||
<Datetime value={formValues.scheduleAt} />
|
||||
<RelativeTime value={formValues.scheduleAt} />
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if formValues.closeAt}
|
||||
<span class="text-grayblue-dark font-medium">Close at:</span>
|
||||
<span class="text-pc-darkblue dark:text-white">
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
saveSubmittedData: false,
|
||||
saveBrowserMetadata: false,
|
||||
isAnonymous: false,
|
||||
scheduleAt: null,
|
||||
|
||||
allowDenyIDs: [],
|
||||
webhookID: null,
|
||||
@@ -286,6 +287,7 @@
|
||||
}
|
||||
campaign.recipientGroups = t.recipientGroupIDs.map((id) => recipientGroupMap.byKey(id));
|
||||
campaign.notableEventName = t.notableEventName;
|
||||
campaign.scheduleAt = t.scheduleAt ?? null;
|
||||
if (t.sendStartAt === null && t.sendEndAt === null) {
|
||||
isSelfManaged = true;
|
||||
}
|
||||
@@ -1622,6 +1624,15 @@
|
||||
</div>
|
||||
|
||||
{#if !isSelfManaged}
|
||||
{#if campaign.scheduleAt}
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">Schedules at:</span>
|
||||
<span class="text-pc-darkblue dark:text-white text-right"
|
||||
><Datetime value={campaign.scheduleAt} /></span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">Delivery start:</span>
|
||||
<span class="text-pc-darkblue dark:text-white text-right"
|
||||
@@ -1932,172 +1943,174 @@
|
||||
{/each}
|
||||
</Table>
|
||||
</div>
|
||||
<SubHeadline>Recipients overview</SubHeadline>
|
||||
<Table
|
||||
columns={[
|
||||
{ column: 'First name', size: 'small' },
|
||||
{ column: 'Last name', size: 'small' },
|
||||
{ column: 'Email', size: 'large' },
|
||||
{ column: 'Status', size: 'small' },
|
||||
{ column: 'Send at', title: 'Scheduled', size: 'small' },
|
||||
{ column: 'Sent at', title: 'Delivered', size: 'small' },
|
||||
{ column: 'Cancelled at', size: 'small' }
|
||||
]}
|
||||
sortable={[
|
||||
'First name',
|
||||
'Last name',
|
||||
'Email',
|
||||
'Status',
|
||||
'Send at',
|
||||
'Sent at',
|
||||
'Cancelled at'
|
||||
]}
|
||||
pagination={recipientTableUrlParams}
|
||||
plural="recipients"
|
||||
hasData={!!campaignRecipients.length}
|
||||
hasNextPage={campaignRecipientsHasNextPage}
|
||||
isGhost={isRecipientTableLoading}
|
||||
>
|
||||
{#each campaignRecipients as recp (recp.id)}
|
||||
<TableRow>
|
||||
{#if recp?.anonymizedID}
|
||||
<TableCell value={'anonymized'} />
|
||||
<TableCell value={'anonymized'} />
|
||||
<TableCell value={'anonymized'} />
|
||||
{:else}
|
||||
<TableCell>
|
||||
<button
|
||||
on:click={() => openEventsModal(recp.recipientID)}
|
||||
class="block w-full py-1 text-left"
|
||||
>
|
||||
{recp.recipient.firstName}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<button
|
||||
on:click={() => openEventsModal(recp.recipientID)}
|
||||
class="block w-full py-1 text-left"
|
||||
>
|
||||
{recp.recipient.lastName}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{#if recp?.recipient?.email}
|
||||
{#if campaign.notableEventName !== 'campaign_pending_schedule'}
|
||||
<SubHeadline>Recipients overview</SubHeadline>
|
||||
<Table
|
||||
columns={[
|
||||
{ column: 'First name', size: 'small' },
|
||||
{ column: 'Last name', size: 'small' },
|
||||
{ column: 'Email', size: 'large' },
|
||||
{ column: 'Status', size: 'small' },
|
||||
{ column: 'Send at', title: 'Scheduled', size: 'small' },
|
||||
{ column: 'Sent at', title: 'Delivered', size: 'small' },
|
||||
{ column: 'Cancelled at', size: 'small' }
|
||||
]}
|
||||
sortable={[
|
||||
'First name',
|
||||
'Last name',
|
||||
'Email',
|
||||
'Status',
|
||||
'Send at',
|
||||
'Sent at',
|
||||
'Cancelled at'
|
||||
]}
|
||||
pagination={recipientTableUrlParams}
|
||||
plural="recipients"
|
||||
hasData={!!campaignRecipients.length}
|
||||
hasNextPage={campaignRecipientsHasNextPage}
|
||||
isGhost={isRecipientTableLoading}
|
||||
>
|
||||
{#each campaignRecipients as recp (recp.id)}
|
||||
<TableRow>
|
||||
{#if recp?.anonymizedID}
|
||||
<TableCell value={'anonymized'} />
|
||||
<TableCell value={'anonymized'} />
|
||||
<TableCell value={'anonymized'} />
|
||||
{:else}
|
||||
<TableCell>
|
||||
<button
|
||||
on:click={() => openEventsModal(recp.recipientID)}
|
||||
class="block w-full py-1 text-left"
|
||||
>
|
||||
{recp.recipient.email}
|
||||
{recp.recipient.firstName}
|
||||
</button>
|
||||
{/if}
|
||||
</TableCell>
|
||||
{/if}
|
||||
<TableCell>
|
||||
<EventName eventName={recp?.notableEventName} />
|
||||
</TableCell>
|
||||
<TableCell value={recp?.sendAt} isDate />
|
||||
<TableCell value={recp?.sentAt} isDate />
|
||||
<TableCell value={recp?.cancelledAt} isDate />
|
||||
{#if !campaign.sentAt}
|
||||
<TableCellEmpty />
|
||||
<TableCellAction>
|
||||
<TableDropDownEllipsis>
|
||||
<TableViewButton
|
||||
name="Events"
|
||||
disabled={!recp.recipient}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<button
|
||||
on:click={() => openEventsModal(recp.recipientID)}
|
||||
/>
|
||||
class="block w-full py-1 text-left"
|
||||
>
|
||||
{recp.recipient.lastName}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{#if recp?.recipient?.email}
|
||||
<button
|
||||
on:click={() => openEventsModal(recp.recipientID)}
|
||||
class="block w-full py-1 text-left"
|
||||
>
|
||||
{recp.recipient.email}
|
||||
</button>
|
||||
{/if}
|
||||
</TableCell>
|
||||
{/if}
|
||||
<TableCell>
|
||||
<EventName eventName={recp?.notableEventName} />
|
||||
</TableCell>
|
||||
<TableCell value={recp?.sendAt} isDate />
|
||||
<TableCell value={recp?.sentAt} isDate />
|
||||
<TableCell value={recp?.cancelledAt} isDate />
|
||||
{#if !campaign.sentAt}
|
||||
<TableCellEmpty />
|
||||
<TableCellAction>
|
||||
<TableDropDownEllipsis>
|
||||
<TableViewButton
|
||||
name="Events"
|
||||
disabled={!recp.recipient}
|
||||
on:click={() => openEventsModal(recp.recipientID)}
|
||||
/>
|
||||
|
||||
<TableDropDownButton
|
||||
name={recp.sentAt ? `Send message again` : `Send message`}
|
||||
title={isContextMismatch()
|
||||
? campaign.companyID
|
||||
? 'Switch to company view to perform this action'
|
||||
: 'Switch to global view to perform this action'
|
||||
: recp.closedAt
|
||||
? 'Campaign is closed'
|
||||
: recp.cancelledAt
|
||||
? 'Recipient cancelled'
|
||||
: recp.sentAt
|
||||
? `Send message again (last sent: ${new Date(recp.sentAt).toLocaleDateString()})`
|
||||
: `Send message to recipient`}
|
||||
on:click={() => showSendMessageModal(recp.id, recp.recipient)}
|
||||
disabled={!!campaign.closedAt || recp.cancelledAt || isContextMismatch()}
|
||||
/>
|
||||
<TableUpdateButton
|
||||
name="Copy lure URL"
|
||||
disabled={!!campaign.closedAt ||
|
||||
!!campaign.anonymizedAt ||
|
||||
!recp.recipient ||
|
||||
isContextMismatch()}
|
||||
title={isContextMismatch()
|
||||
? campaign.companyID
|
||||
? 'Switch to company view to perform this action'
|
||||
: 'Switch to global view to perform this action'
|
||||
: campaign.closedAt
|
||||
? 'Campaign is closed'
|
||||
: campaign.anonymizedAt
|
||||
? 'Campaign is anonymized'
|
||||
: !recp.recipient
|
||||
? 'Recipient not available'
|
||||
: ''}
|
||||
on:click={() => onClickCopyURL(recp.id)}
|
||||
/>
|
||||
<TableUpdateButton
|
||||
name="Copy email content"
|
||||
disabled={!!campaign.closedAt || !!campaign.anonymizedAt || isContextMismatch()}
|
||||
title={isContextMismatch()
|
||||
? campaign.companyID
|
||||
? 'Switch to company view to perform this action'
|
||||
: 'Switch to global view to perform this action'
|
||||
: campaign.closedAt
|
||||
? 'Campaign is closed'
|
||||
: campaign.anonymizedAt
|
||||
? 'Campaign is anonymized'
|
||||
: ''}
|
||||
on:click={() => onClickCopyEmailContent(recp.id)}
|
||||
/>
|
||||
|
||||
{#if !campaign.sendStartAt}
|
||||
<!-- self managed campaign -->
|
||||
<TableDropDownButton
|
||||
name="Set as message sent"
|
||||
name={recp.sentAt ? `Send message again` : `Send message`}
|
||||
title={isContextMismatch()
|
||||
? campaign.companyID
|
||||
? 'Switch to company view to perform this action'
|
||||
: 'Switch to global view to perform this action'
|
||||
: recp.closedAt
|
||||
? 'Campaign is closed'
|
||||
: ''}
|
||||
on:click={() => onClickSetEmailSent(recp.id, recp.recipient)}
|
||||
: recp.cancelledAt
|
||||
? 'Recipient cancelled'
|
||||
: recp.sentAt
|
||||
? `Send message again (last sent: ${new Date(recp.sentAt).toLocaleDateString()})`
|
||||
: `Send message to recipient`}
|
||||
on:click={() => showSendMessageModal(recp.id, recp.recipient)}
|
||||
disabled={!!campaign.closedAt || recp.cancelledAt || isContextMismatch()}
|
||||
/>
|
||||
{/if}
|
||||
<TableViewButton
|
||||
name="View email"
|
||||
disabled={!!campaign.closedAt ||
|
||||
!!campaign.anonymizedAt ||
|
||||
!recp.recipient ||
|
||||
isContextMismatch()}
|
||||
title={isContextMismatch()
|
||||
? campaign.companyID
|
||||
? 'Switch to company view to perform this action'
|
||||
: 'Switch to global view to perform this action'
|
||||
: campaign.closedAt
|
||||
? 'Campaign is closed'
|
||||
: campaign.anonymizedAt
|
||||
? 'Campaign is anonymized'
|
||||
: !recp.recipient
|
||||
? 'Recipient not available'
|
||||
<TableUpdateButton
|
||||
name="Copy lure URL"
|
||||
disabled={!!campaign.closedAt ||
|
||||
!!campaign.anonymizedAt ||
|
||||
!recp.recipient ||
|
||||
isContextMismatch()}
|
||||
title={isContextMismatch()
|
||||
? campaign.companyID
|
||||
? 'Switch to company view to perform this action'
|
||||
: 'Switch to global view to perform this action'
|
||||
: campaign.closedAt
|
||||
? 'Campaign is closed'
|
||||
: campaign.anonymizedAt
|
||||
? 'Campaign is anonymized'
|
||||
: !recp.recipient
|
||||
? 'Recipient not available'
|
||||
: ''}
|
||||
on:click={() => onClickCopyURL(recp.id)}
|
||||
/>
|
||||
<TableUpdateButton
|
||||
name="Copy email content"
|
||||
disabled={!!campaign.closedAt || !!campaign.anonymizedAt || isContextMismatch()}
|
||||
title={isContextMismatch()
|
||||
? campaign.companyID
|
||||
? 'Switch to company view to perform this action'
|
||||
: 'Switch to global view to perform this action'
|
||||
: campaign.closedAt
|
||||
? 'Campaign is closed'
|
||||
: campaign.anonymizedAt
|
||||
? 'Campaign is anonymized'
|
||||
: ''}
|
||||
on:click={() => onClickPreviewEmail(recp.id)}
|
||||
/>
|
||||
</TableDropDownEllipsis>
|
||||
</TableCellAction>
|
||||
{/if}
|
||||
</TableRow>
|
||||
{/each}
|
||||
</Table>
|
||||
on:click={() => onClickCopyEmailContent(recp.id)}
|
||||
/>
|
||||
|
||||
{#if !campaign.sendStartAt}
|
||||
<!-- self managed campaign -->
|
||||
<TableDropDownButton
|
||||
name="Set as message sent"
|
||||
title={isContextMismatch()
|
||||
? campaign.companyID
|
||||
? 'Switch to company view to perform this action'
|
||||
: 'Switch to global view to perform this action'
|
||||
: recp.closedAt
|
||||
? 'Campaign is closed'
|
||||
: ''}
|
||||
on:click={() => onClickSetEmailSent(recp.id, recp.recipient)}
|
||||
disabled={!!campaign.closedAt || recp.cancelledAt || isContextMismatch()}
|
||||
/>
|
||||
{/if}
|
||||
<TableViewButton
|
||||
name="View email"
|
||||
disabled={!!campaign.closedAt ||
|
||||
!!campaign.anonymizedAt ||
|
||||
!recp.recipient ||
|
||||
isContextMismatch()}
|
||||
title={isContextMismatch()
|
||||
? campaign.companyID
|
||||
? 'Switch to company view to perform this action'
|
||||
: 'Switch to global view to perform this action'
|
||||
: campaign.closedAt
|
||||
? 'Campaign is closed'
|
||||
: campaign.anonymizedAt
|
||||
? 'Campaign is anonymized'
|
||||
: !recp.recipient
|
||||
? 'Recipient not available'
|
||||
: ''}
|
||||
on:click={() => onClickPreviewEmail(recp.id)}
|
||||
/>
|
||||
</TableDropDownEllipsis>
|
||||
</TableCellAction>
|
||||
{/if}
|
||||
</TableRow>
|
||||
{/each}
|
||||
</Table>
|
||||
{/if}
|
||||
{/if}
|
||||
<Modal headerText={'Events'} visible={isEventsModalVisible} onClose={closeEventsModal}>
|
||||
<div class="mt-8"></div>
|
||||
|
||||
Reference in New Issue
Block a user